@remit/web-client 0.0.85 → 0.0.87
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/BetterAuthShell.tsx +2 -2
- package/src/components/compose/AddressField.tsx +60 -77
- package/src/components/mail/AutoMovedIndicator.tsx +7 -9
- package/src/components/mail/BriefPane.tsx +4 -2
- package/src/components/mail/DailyBrief.selection.test.ts +49 -0
- package/src/components/mail/DailyBrief.tsx +484 -76
- package/src/components/mail/FlaggedList.tsx +3 -2
- package/src/components/mail/FlaggedPane.tsx +4 -2
- package/src/components/mail/MailListHeader.tsx +155 -34
- package/src/components/mail/MailViewChrome.tsx +18 -1
- package/src/components/mail/MailboxPane.tsx +55 -20
- package/src/components/mail/MessageCard.tsx +0 -1
- package/src/components/mail/MessageList.tsx +70 -15
- package/src/components/mail/MessageListItem.tsx +4 -21
- package/src/components/mail/MessageRow.tsx +13 -31
- package/src/components/mail/SwipeableMessageRow.tsx +13 -6
- package/src/components/mail/ThreadListInteraction.tsx +51 -6
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +57 -2
- package/src/components/mail/organize/SearchFilterDialog.render.test.ts +13 -2
- package/src/components/mail/organize/SearchFilterDialog.tsx +3 -1
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +37 -3
- package/src/components/mail/organize/SearchFilterEditor.tsx +8 -2
- package/src/components/mail/organize/smart-organize.stories.tsx +86 -5
- package/src/components/mail/useModifierSelect.render.test.ts +247 -0
- package/src/components/mail/useModifierSelect.ts +109 -0
- package/src/components/onboarding/OnboardingWizard.tsx +53 -9
- package/src/components/settings/AccountFormPanel.tsx +10 -5
- package/src/components/settings/TlsRootCaDownload.render.test.ts +37 -0
- package/src/components/settings/TlsRootCaDownload.tsx +33 -0
- package/src/hooks/bulk-chunking.render.test.ts +157 -0
- package/src/hooks/useApplyLabel.ts +6 -3
- package/src/hooks/useClauseSuggestions.ts +57 -0
- package/src/hooks/useDeleteMessages.ts +6 -3
- package/src/hooks/useFollowFocusOpen.render.test.ts +155 -0
- package/src/hooks/useFollowFocusOpen.ts +81 -0
- package/src/hooks/useInitialSyncProgress.render.test.ts +280 -0
- package/src/hooks/useInitialSyncProgress.ts +134 -0
- package/src/hooks/useListCursor.ts +36 -5
- package/src/hooks/useMarkAsRead.ts +6 -3
- package/src/hooks/useMoveMessages.ts +6 -3
- package/src/hooks/useRulePreview.ts +11 -1
- package/src/hooks/useSearchFilterSeed.render.test.ts +50 -15
- package/src/hooks/useSearchFilterSeed.ts +21 -3
- package/src/hooks/useSearchSuggestions.ts +126 -0
- package/src/hooks/useSelectedSubjects.ts +0 -0
- package/src/hooks/useSemanticSearch.ts +26 -7
- package/src/hooks/useThreadActions.ts +11 -2
- package/src/lib/brief.test.ts +67 -0
- package/src/lib/brief.ts +11 -1
- package/src/lib/bulk-actions.ts +29 -0
- package/src/lib/drafts.ts +1 -1
- package/src/lib/organize/clause-suggestions.test.ts +113 -0
- package/src/lib/organize/clause-suggestions.ts +90 -0
- package/src/lib/organize/property-prefill.test.ts +173 -0
- package/src/lib/organize/property-prefill.ts +151 -0
- package/src/lib/organize/rule-model.test.ts +57 -0
- package/src/lib/organize/rule-model.ts +110 -31
- package/src/lib/organize/search-to-rule.ts +12 -4
- package/src/lib/organize/sender-fallback.ts +11 -1
- package/src/lib/search-result.ts +2 -0
- package/src/lib/search-suggestions.test.ts +249 -0
- package/src/lib/search-suggestions.ts +296 -0
- package/src/lib/search-token-index.test.ts +99 -0
- package/src/lib/search-token-index.ts +67 -1
- package/src/lib/search-tokens.test.ts +236 -0
- package/src/lib/search-tokens.ts +303 -36
- package/src/lib/thread-cache.ts +1 -1
- package/src/lib/thread-search-tokens.test.ts +193 -0
- package/src/lib/thread-search-tokens.ts +148 -0
- package/src/routes/onboarding.tsx +9 -4
- package/src/routes/settings/advanced.tsx +2 -0
- package/src/runtime-config.test.ts +26 -0
- package/src/runtime-config.ts +15 -0
|
@@ -4,16 +4,31 @@
|
|
|
4
4
|
* Renders one section per message category (Personal / Transactional /
|
|
5
5
|
* Newsletter / Marketing / Social / Automated) from the GET /threads endpoint.
|
|
6
6
|
* Starred mail is not a section — Flagged is a virtual mailbox in the nav. The
|
|
7
|
-
* brief defaults to the cross-account aggregate
|
|
8
|
-
* the
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* brief defaults to the cross-account aggregate, and `MailListHeader` provides
|
|
8
|
+
* the title, unread count, and search.
|
|
9
|
+
*
|
|
10
|
+
* The list's filter control (categories + attribute chips + the account source
|
|
11
|
+
* group) is hidden — the brief is being tried without it. The kit
|
|
12
|
+
* `BriefSections` renders that control above the list body, so the body is
|
|
13
|
+
* rendered here by `BriefListBody` instead; every input the control consumed is
|
|
14
|
+
* still computed below, so bringing it back is swapping `BriefListBody` for
|
|
15
|
+
* `<BriefSections … />`. Account switching also lives in the nav sidebar, and
|
|
16
|
+
* the phone search takeover keeps its own copy of the filter sheet.
|
|
17
|
+
*
|
|
18
|
+
* Multi-select is the mailbox list's, not a copy of it: the same
|
|
19
|
+
* `ThreadListInteraction` cursor and `useSelection` state, the same
|
|
20
|
+
* `SelectionToolbar` replacing the pane header on desktop, and the same
|
|
21
|
+
* peeking `SelectionSheet` on touch. A shift-range spans the rendered rows in
|
|
22
|
+
* document order, so it crosses category sections exactly as the eye reads
|
|
23
|
+
* them and never picks up a row hidden behind "Show N more" or a collapsed
|
|
24
|
+
* header. Folder-scoped verbs (Move, Apply label, Organize) resolve their
|
|
25
|
+
* account and source folder from the selection — see
|
|
26
|
+
* `resolveBriefSelectionScope`.
|
|
13
27
|
*
|
|
14
28
|
* Loading: skeleton rows on first paint, patch-in-place on refetch.
|
|
15
29
|
* Error: per-section; the brief still renders other sections.
|
|
16
|
-
* Empty: "You're caught up"
|
|
30
|
+
* Empty: "You're caught up", but only once the server confirms no sync is
|
|
31
|
+
* running — while one is, the same empty list says it is still syncing.
|
|
17
32
|
*/
|
|
18
33
|
import {
|
|
19
34
|
mailboxOperationsListMailboxesOptions,
|
|
@@ -22,20 +37,36 @@ import {
|
|
|
22
37
|
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
23
38
|
import {
|
|
24
39
|
type BriefCategoryFilter,
|
|
25
|
-
|
|
40
|
+
BriefEmpty,
|
|
41
|
+
BriefSection,
|
|
26
42
|
briefFilterConfig,
|
|
27
43
|
type FilterSheetProps,
|
|
28
44
|
type FilterSheetSource,
|
|
29
45
|
KeyboardHintBar,
|
|
46
|
+
SELECTION_SHEET_TEASER_HEIGHT,
|
|
30
47
|
type SearchResult,
|
|
48
|
+
SelectionSheet,
|
|
31
49
|
type ThreadRowData,
|
|
32
50
|
type ThreadSection,
|
|
33
51
|
} from "@remit/ui";
|
|
34
52
|
import { useQueries, useQuery } from "@tanstack/react-query";
|
|
35
53
|
import { useNavigate } from "@tanstack/react-router";
|
|
36
|
-
import { AlertCircle, RefreshCw
|
|
37
|
-
import {
|
|
54
|
+
import { AlertCircle, RefreshCw } from "lucide-react";
|
|
55
|
+
import {
|
|
56
|
+
type ReactNode,
|
|
57
|
+
type RefObject,
|
|
58
|
+
useCallback,
|
|
59
|
+
useEffect,
|
|
60
|
+
useMemo,
|
|
61
|
+
useState,
|
|
62
|
+
} from "react";
|
|
63
|
+
import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
|
|
64
|
+
import {
|
|
65
|
+
isSyncingPhase,
|
|
66
|
+
useInitialSyncProgress,
|
|
67
|
+
} from "@/hooks/useInitialSyncProgress";
|
|
38
68
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
69
|
+
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
39
70
|
import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
|
|
40
71
|
import { useSemanticSearch } from "@/hooks/useSemanticSearch";
|
|
41
72
|
import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
|
|
@@ -52,12 +83,18 @@ import { isServerError } from "@/lib/error-classifier";
|
|
|
52
83
|
import { useMailContext } from "@/lib/mail-context";
|
|
53
84
|
import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
|
|
54
85
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
55
|
-
import {
|
|
86
|
+
import { shouldShowSelectionSheet } from "@/lib/selection-sheet-mode";
|
|
87
|
+
import { MailListHeader, type MailListHeaderProps } from "./MailListHeader";
|
|
56
88
|
import type { MessageListCommands } from "./MessageList";
|
|
57
89
|
import { MessageRow } from "./MessageRow";
|
|
90
|
+
import { MoveToTrigger } from "./MoveToTrigger";
|
|
91
|
+
import { MobileOrganizeFlow } from "./organize/MobileOrganizeFlow";
|
|
92
|
+
import { OrganizeDialog } from "./organize/OrganizeDialog";
|
|
93
|
+
import { SelectionToolbar } from "./SelectionToolbar";
|
|
58
94
|
import {
|
|
95
|
+
type OpenMessageOptions,
|
|
59
96
|
ThreadListInteraction,
|
|
60
|
-
|
|
97
|
+
useThreadListSelection,
|
|
61
98
|
} from "./ThreadListInteraction";
|
|
62
99
|
|
|
63
100
|
/* The brief's attribute chips as predicates (mirrors the kit `briefFilterChips`
|
|
@@ -127,6 +164,355 @@ const ErrorBanner = ({ accountEmail }: ErrorBannerProps) => {
|
|
|
127
164
|
);
|
|
128
165
|
};
|
|
129
166
|
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// List body (the kit `BriefSections` body without its filter control)
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
interface BriefListBodyProps {
|
|
172
|
+
sections: ThreadSection[];
|
|
173
|
+
/** Category scope. "all" keeps the per-category sections; anything else flattens. */
|
|
174
|
+
briefCategory: BriefCategoryFilter;
|
|
175
|
+
selectedThreadId?: string;
|
|
176
|
+
onSelectThread?: (id: string) => void;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The brief's list body: one capped section per category at the "all" scope, a
|
|
181
|
+
* headerless flat list once narrowed to a single category (the section headers
|
|
182
|
+
* are redundant there).
|
|
183
|
+
*
|
|
184
|
+
* This is the kit `BriefSections` body minus the filter control it renders above
|
|
185
|
+
* it. The category scope still arrives from the brief — the phone search
|
|
186
|
+
* takeover's filter sheet sets it — so the flatten path stays reachable; the
|
|
187
|
+
* attribute chips are part of the hidden control and narrow nothing here.
|
|
188
|
+
*/
|
|
189
|
+
function BriefListBody({
|
|
190
|
+
sections,
|
|
191
|
+
briefCategory,
|
|
192
|
+
selectedThreadId,
|
|
193
|
+
onSelectThread,
|
|
194
|
+
}: BriefListBodyProps) {
|
|
195
|
+
const showSections = briefCategory === "all";
|
|
196
|
+
const flatRows = sections
|
|
197
|
+
.flatMap((section) => section.threads)
|
|
198
|
+
.filter((thread) => thread.category === briefCategory);
|
|
199
|
+
const empty = showSections ? sections.length === 0 : flatRows.length === 0;
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<div className="h-full overflow-y-auto">
|
|
203
|
+
{showSections ? (
|
|
204
|
+
sections.map((section) => (
|
|
205
|
+
<BriefSection
|
|
206
|
+
key={section.id}
|
|
207
|
+
section={section}
|
|
208
|
+
Row={MessageRow}
|
|
209
|
+
selectedThreadId={selectedThreadId}
|
|
210
|
+
onSelectThread={onSelectThread}
|
|
211
|
+
/>
|
|
212
|
+
))
|
|
213
|
+
) : (
|
|
214
|
+
<div className="divide-y divide-line">
|
|
215
|
+
{flatRows.map((thread) => (
|
|
216
|
+
<MessageRow
|
|
217
|
+
key={thread.id}
|
|
218
|
+
thread={thread}
|
|
219
|
+
active={thread.id === selectedThreadId}
|
|
220
|
+
onClick={() => onSelectThread?.(thread.id)}
|
|
221
|
+
/>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
{empty && (
|
|
226
|
+
<div className="px-row-inset py-6 text-center text-2xs text-fg-subtle">
|
|
227
|
+
No threads match these filters.
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Selection surface (the mailbox list's, mounted on the brief)
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
export interface BriefSelectionScope {
|
|
239
|
+
/** Owning account, when every selected row shares one. */
|
|
240
|
+
accountId?: string;
|
|
241
|
+
/** Source folder, when every selected row shares one. */
|
|
242
|
+
mailboxId?: string;
|
|
243
|
+
/** Why folder-scoped verbs are withheld, in the toolbar's own words. */
|
|
244
|
+
moveDisabledHint?: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The account and source folder a brief selection resolves to.
|
|
249
|
+
*
|
|
250
|
+
* The mailbox list gets these from the route and scopes Move, Apply label and
|
|
251
|
+
* Organize to them. The brief spans accounts and folders, so it resolves them
|
|
252
|
+
* from the selection itself and withholds those verbs — with the reason on
|
|
253
|
+
* screen — whenever the selection spans more than one. Delete and mark-read
|
|
254
|
+
* carry no such scope and are always offered.
|
|
255
|
+
*
|
|
256
|
+
* Pure so the guard is unit-testable without a DOM.
|
|
257
|
+
*/
|
|
258
|
+
export const resolveBriefSelectionScope = (
|
|
259
|
+
rows: readonly ThreadRowData[],
|
|
260
|
+
selectedIds: ReadonlySet<string>,
|
|
261
|
+
): BriefSelectionScope => {
|
|
262
|
+
if (selectedIds.size === 0) return {};
|
|
263
|
+
const accountIds = new Set<string>();
|
|
264
|
+
const mailboxIds = new Set<string>();
|
|
265
|
+
for (const row of rows) {
|
|
266
|
+
if (!selectedIds.has(row.id)) continue;
|
|
267
|
+
if (row.accountId) accountIds.add(row.accountId);
|
|
268
|
+
if (row.mailboxId) mailboxIds.add(row.mailboxId);
|
|
269
|
+
}
|
|
270
|
+
if (accountIds.size > 1) {
|
|
271
|
+
return {
|
|
272
|
+
moveDisabledHint:
|
|
273
|
+
"Move only works within one account — clear selection or pick messages from a single account",
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const accountId = accountIds.size === 1 ? [...accountIds][0] : undefined;
|
|
277
|
+
if (mailboxIds.size > 1) {
|
|
278
|
+
return {
|
|
279
|
+
accountId,
|
|
280
|
+
moveDisabledHint: `Move only works within one folder — this selection spans ${mailboxIds.size} folders`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
accountId,
|
|
285
|
+
mailboxId: mailboxIds.size === 1 ? [...mailboxIds][0] : undefined,
|
|
286
|
+
};
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
interface BriefSelectionChromeProps {
|
|
290
|
+
/** Everything the header needs when no selection is active. */
|
|
291
|
+
header: Omit<
|
|
292
|
+
MailListHeaderProps,
|
|
293
|
+
"children" | "selectionBar" | "selectionSheet"
|
|
294
|
+
>;
|
|
295
|
+
/** The rows the list is showing, for resolving the selection's scope. */
|
|
296
|
+
rows: readonly ThreadRowData[];
|
|
297
|
+
isDesktop: boolean;
|
|
298
|
+
onMarkMessagesRead?: (messageIds: string[]) => void;
|
|
299
|
+
children: ReactNode;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The brief's selection chrome: the same two surfaces the mailbox list raises,
|
|
304
|
+
* in the same places. Desktop replaces the pane header with `SelectionToolbar`;
|
|
305
|
+
* touch leaves the header alone and raises the peeking `SelectionSheet` at two
|
|
306
|
+
* or more selected. The selection itself comes from the enclosing
|
|
307
|
+
* `ThreadListInteraction`, so the brief and the mailbox list run one model.
|
|
308
|
+
*/
|
|
309
|
+
function BriefSelectionChrome({
|
|
310
|
+
header,
|
|
311
|
+
rows,
|
|
312
|
+
isDesktop,
|
|
313
|
+
onMarkMessagesRead,
|
|
314
|
+
children,
|
|
315
|
+
}: BriefSelectionChromeProps) {
|
|
316
|
+
const {
|
|
317
|
+
selectedIds,
|
|
318
|
+
selectedCount,
|
|
319
|
+
exitSelection,
|
|
320
|
+
requestDeleteSelection,
|
|
321
|
+
orderedIds,
|
|
322
|
+
allSelected,
|
|
323
|
+
toggleAllLoaded,
|
|
324
|
+
} = useThreadListSelection();
|
|
325
|
+
|
|
326
|
+
const scope = useMemo(
|
|
327
|
+
() => resolveBriefSelectionScope(rows, selectedIds),
|
|
328
|
+
[rows, selectedIds],
|
|
329
|
+
);
|
|
330
|
+
const { junkMailboxId } = useJunkMailbox(scope.accountId);
|
|
331
|
+
const { moveMessages, isPending: isMoving } = useMoveMessages({
|
|
332
|
+
mailboxId: scope.mailboxId ?? "",
|
|
333
|
+
accountId: scope.accountId,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const [organizeOpen, setOrganizeOpen] = useState(false);
|
|
337
|
+
const [mobileOrganizeEntry, setMobileOrganizeEntry] = useState<
|
|
338
|
+
"select-similar" | "something-else" | null
|
|
339
|
+
>(null);
|
|
340
|
+
|
|
341
|
+
// An emptied selection takes the flows it opened with it, so a later
|
|
342
|
+
// re-selection can't reopen one on a stale entry.
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
if (selectedCount > 0) return;
|
|
345
|
+
setOrganizeOpen(false);
|
|
346
|
+
setMobileOrganizeEntry(null);
|
|
347
|
+
}, [selectedCount]);
|
|
348
|
+
|
|
349
|
+
const selectedMessageIds = useMemo(
|
|
350
|
+
() => Array.from(selectedIds),
|
|
351
|
+
[selectedIds],
|
|
352
|
+
);
|
|
353
|
+
const selectedSenders = useMemo(() => {
|
|
354
|
+
const emails: string[] = [];
|
|
355
|
+
for (const row of rows) {
|
|
356
|
+
if (selectedIds.has(row.id) && row.fromEmail) emails.push(row.fromEmail);
|
|
357
|
+
}
|
|
358
|
+
return emails;
|
|
359
|
+
}, [rows, selectedIds]);
|
|
360
|
+
|
|
361
|
+
const handleMarkAsRead = useCallback(() => {
|
|
362
|
+
onMarkMessagesRead?.(selectedMessageIds);
|
|
363
|
+
exitSelection();
|
|
364
|
+
}, [onMarkMessagesRead, selectedMessageIds, exitSelection]);
|
|
365
|
+
|
|
366
|
+
const handleMove = useCallback(
|
|
367
|
+
(destinationMailboxId: string) => {
|
|
368
|
+
if (selectedMessageIds.length === 0) return;
|
|
369
|
+
moveMessages(selectedMessageIds, destinationMailboxId);
|
|
370
|
+
exitSelection();
|
|
371
|
+
},
|
|
372
|
+
[moveMessages, selectedMessageIds, exitSelection],
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const handleJunk = useCallback(() => {
|
|
376
|
+
if (junkMailboxId) handleMove(junkMailboxId);
|
|
377
|
+
}, [junkMailboxId, handleMove]);
|
|
378
|
+
|
|
379
|
+
const scoped = !!scope.accountId && !!scope.mailboxId;
|
|
380
|
+
const canJunk =
|
|
381
|
+
scoped && !!junkMailboxId && junkMailboxId !== scope.mailboxId;
|
|
382
|
+
|
|
383
|
+
// One select-all for both surfaces: the desktop toolbar and the touch sheet
|
|
384
|
+
// offer the same control over the same rendered rows, so the verb a phone
|
|
385
|
+
// can reach is the verb a desktop can reach.
|
|
386
|
+
const selectAll = useMemo(
|
|
387
|
+
() =>
|
|
388
|
+
orderedIds.length > 0
|
|
389
|
+
? {
|
|
390
|
+
checked: allSelected,
|
|
391
|
+
indeterminate: selectedCount > 0 && !allSelected,
|
|
392
|
+
onChange: toggleAllLoaded,
|
|
393
|
+
}
|
|
394
|
+
: undefined,
|
|
395
|
+
[orderedIds.length, allSelected, selectedCount, toggleAllLoaded],
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
const selectionBar =
|
|
399
|
+
isDesktop && selectedCount > 0 ? (
|
|
400
|
+
<SelectionToolbar
|
|
401
|
+
selectedCount={selectedCount}
|
|
402
|
+
onDelete={requestDeleteSelection}
|
|
403
|
+
onClearSelection={exitSelection}
|
|
404
|
+
onMarkAsRead={onMarkMessagesRead ? handleMarkAsRead : undefined}
|
|
405
|
+
onMove={scoped ? handleMove : undefined}
|
|
406
|
+
onOrganize={() => setOrganizeOpen(true)}
|
|
407
|
+
isMoving={isMoving}
|
|
408
|
+
accountId={scope.accountId}
|
|
409
|
+
currentMailboxId={scope.mailboxId}
|
|
410
|
+
selectedMessageIds={selectedMessageIds}
|
|
411
|
+
moveDisabledHint={scope.moveDisabledHint}
|
|
412
|
+
selectAll={selectAll}
|
|
413
|
+
/>
|
|
414
|
+
) : undefined;
|
|
415
|
+
|
|
416
|
+
// The mobile sheet has no inline hint slot, so the scope restriction rides
|
|
417
|
+
// in as a notice — the same fold-in the mailbox list does.
|
|
418
|
+
const notice = scope.moveDisabledHint
|
|
419
|
+
? { tone: "warning" as const, text: scope.moveDisabledHint }
|
|
420
|
+
: undefined;
|
|
421
|
+
const showSheet =
|
|
422
|
+
!isDesktop &&
|
|
423
|
+
selectedCount > 0 &&
|
|
424
|
+
shouldShowSelectionSheet(selectedCount, "idle", notice !== undefined);
|
|
425
|
+
|
|
426
|
+
const organizeFlow =
|
|
427
|
+
mobileOrganizeEntry &&
|
|
428
|
+
scope.accountId &&
|
|
429
|
+
!isDesktop &&
|
|
430
|
+
selectedCount > 0 ? (
|
|
431
|
+
<MobileOrganizeFlow
|
|
432
|
+
entry={mobileOrganizeEntry}
|
|
433
|
+
accountId={scope.accountId}
|
|
434
|
+
selectedMessageIds={selectedMessageIds}
|
|
435
|
+
selectedSenders={selectedSenders}
|
|
436
|
+
junkMailboxId={junkMailboxId}
|
|
437
|
+
onClose={() => {
|
|
438
|
+
setMobileOrganizeEntry(null);
|
|
439
|
+
exitSelection();
|
|
440
|
+
}}
|
|
441
|
+
/>
|
|
442
|
+
) : undefined;
|
|
443
|
+
|
|
444
|
+
const selectionSheet =
|
|
445
|
+
showSheet || organizeFlow ? (
|
|
446
|
+
<>
|
|
447
|
+
{showSheet && (
|
|
448
|
+
<SelectionSheet
|
|
449
|
+
count={selectedCount}
|
|
450
|
+
onCancel={exitSelection}
|
|
451
|
+
onDelete={requestDeleteSelection}
|
|
452
|
+
onJunk={canJunk ? handleJunk : undefined}
|
|
453
|
+
onMarkRead={onMarkMessagesRead ? handleMarkAsRead : undefined}
|
|
454
|
+
onSelectSimilar={
|
|
455
|
+
scoped
|
|
456
|
+
? () => setMobileOrganizeEntry("select-similar")
|
|
457
|
+
: undefined
|
|
458
|
+
}
|
|
459
|
+
onSomethingElse={
|
|
460
|
+
scoped
|
|
461
|
+
? () => setMobileOrganizeEntry("something-else")
|
|
462
|
+
: undefined
|
|
463
|
+
}
|
|
464
|
+
moveSlot={
|
|
465
|
+
scoped && scope.accountId && scope.mailboxId ? (
|
|
466
|
+
<MoveToTrigger
|
|
467
|
+
accountId={scope.accountId}
|
|
468
|
+
currentMailboxId={scope.mailboxId}
|
|
469
|
+
onMove={isMoving ? () => {} : handleMove}
|
|
470
|
+
label="Move selected messages"
|
|
471
|
+
/>
|
|
472
|
+
) : undefined
|
|
473
|
+
}
|
|
474
|
+
isBusy={isMoving}
|
|
475
|
+
selectAll={selectAll}
|
|
476
|
+
notice={notice}
|
|
477
|
+
/>
|
|
478
|
+
)}
|
|
479
|
+
{organizeFlow}
|
|
480
|
+
</>
|
|
481
|
+
) : undefined;
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<>
|
|
485
|
+
<MailListHeader
|
|
486
|
+
{...header}
|
|
487
|
+
selectionBar={selectionBar}
|
|
488
|
+
selectionSheet={selectionSheet}
|
|
489
|
+
>
|
|
490
|
+
{/* Shrink the body by the teaser's height rather than letting the
|
|
491
|
+
last rows hide behind it. */}
|
|
492
|
+
<div
|
|
493
|
+
className="h-full"
|
|
494
|
+
style={
|
|
495
|
+
showSheet
|
|
496
|
+
? { paddingBottom: SELECTION_SHEET_TEASER_HEIGHT }
|
|
497
|
+
: undefined
|
|
498
|
+
}
|
|
499
|
+
>
|
|
500
|
+
{children}
|
|
501
|
+
</div>
|
|
502
|
+
</MailListHeader>
|
|
503
|
+
{organizeOpen && scope.accountId && (
|
|
504
|
+
<OrganizeDialog
|
|
505
|
+
open={organizeOpen}
|
|
506
|
+
accountId={scope.accountId}
|
|
507
|
+
selectedMessageIds={selectedMessageIds}
|
|
508
|
+
selectedSenders={selectedSenders}
|
|
509
|
+
onClose={() => setOrganizeOpen(false)}
|
|
510
|
+
/>
|
|
511
|
+
)}
|
|
512
|
+
</>
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
130
516
|
// ---------------------------------------------------------------------------
|
|
131
517
|
// Main component
|
|
132
518
|
// ---------------------------------------------------------------------------
|
|
@@ -135,7 +521,7 @@ interface DailyBriefProps {
|
|
|
135
521
|
accounts: RemitImapAccountResponse[];
|
|
136
522
|
selectedMessageId?: string;
|
|
137
523
|
/** Opens an in-list brief row (resolved by messageId against the loaded list). */
|
|
138
|
-
onSelectMessage?: (id: string) => void;
|
|
524
|
+
onSelectMessage?: (id: string, options?: OpenMessageOptions) => void;
|
|
139
525
|
/**
|
|
140
526
|
* Opens a search result. A semantic "Related" hit carries its thread + mailbox
|
|
141
527
|
* so it opens even when its message isn't in the loaded brief list.
|
|
@@ -168,19 +554,18 @@ export function DailyBrief({
|
|
|
168
554
|
[accounts],
|
|
169
555
|
);
|
|
170
556
|
|
|
171
|
-
// "all" = the cross-account aggregate (the brief's default)
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
// and the account source.
|
|
557
|
+
// "all" = the cross-account aggregate (the brief's default), and "all"
|
|
558
|
+
// categories = the full set of sections. Both stay at their default while the
|
|
559
|
+
// list's filter control is hidden; the phone search takeover still drives them
|
|
560
|
+
// (the category also drives the flatten-when-filtered path), and account
|
|
561
|
+
// switching lives in the nav sidebar.
|
|
177
562
|
const [selectedAccountId, setSelectedAccountId] = useState("all");
|
|
178
563
|
const [selectedCategory, setSelectedCategory] =
|
|
179
564
|
useState<BriefCategoryFilter>("all");
|
|
180
565
|
|
|
181
|
-
// Attribute chips for the phone search takeover
|
|
182
|
-
//
|
|
183
|
-
//
|
|
566
|
+
// Attribute chips for the phone search takeover — a separate surface from the
|
|
567
|
+
// list, so it carries its own additive set (category + account are shared
|
|
568
|
+
// above) and keeps its filter sheet while the list's control is hidden.
|
|
184
569
|
const [searchAttributes, setSearchAttributes] = useState<ReadonlySet<string>>(
|
|
185
570
|
new Set(),
|
|
186
571
|
);
|
|
@@ -272,9 +657,8 @@ export function DailyBrief({
|
|
|
272
657
|
// Convert API rows to ThreadRowData, narrowing only by the selected account
|
|
273
658
|
// and the free-text search plus any filter tokens (`from:`, `has:attachment`,
|
|
274
659
|
// `is:unread`, `before:`/`after:`, `in:`, `account:`) parsed out of the
|
|
275
|
-
// query. The category axis
|
|
276
|
-
//
|
|
277
|
-
// handed to it; it groups, narrows, and flattens.
|
|
660
|
+
// query. The category axis is applied further down, on the grouped sections,
|
|
661
|
+
// so the list body can flatten them when narrowed to one category.
|
|
278
662
|
const filteredRows = useMemo<ThreadRowData[]>(() => {
|
|
279
663
|
const briefRows = excludeMutedSenders(threadsData?.items ?? []).map(
|
|
280
664
|
toThreadRowData,
|
|
@@ -395,15 +779,38 @@ export function DailyBrief({
|
|
|
395
779
|
|
|
396
780
|
// The brief is genuinely empty (caught up) only when nothing is narrowing the
|
|
397
781
|
// view: no account source and no search. When a source/search yields nothing,
|
|
398
|
-
// the
|
|
782
|
+
// the list says so instead, so the narrowing is still visible as the reason.
|
|
399
783
|
const caughtUp =
|
|
400
784
|
sections.length === 0 && selectedAccountId === "all" && sq.length === 0;
|
|
401
785
|
|
|
402
|
-
|
|
786
|
+
// "Caught up" is a claim about the user's mail, so it may only be made once
|
|
787
|
+
// the server has confirmed no sync is running (#452). The config snapshot
|
|
788
|
+
// that carries these accounts is fetched once (staleTime Infinity), so its
|
|
789
|
+
// syncPhase is a seed rather than a reading: it says a sync WAS running when
|
|
790
|
+
// the accounts loaded, which is enough to start the live poll before the list
|
|
791
|
+
// paints — the post-onboarding case that produced the false claim.
|
|
792
|
+
const briefAccountIds = useMemo(
|
|
793
|
+
() => nonMuted.map((a) => a.accountId),
|
|
794
|
+
[nonMuted],
|
|
795
|
+
);
|
|
796
|
+
const seededSyncing = useMemo(
|
|
797
|
+
() => nonMuted.some((a) => isSyncingPhase(a.syncPhase)),
|
|
798
|
+
[nonMuted],
|
|
799
|
+
);
|
|
800
|
+
const syncProgress = useInitialSyncProgress(
|
|
801
|
+
briefAccountIds,
|
|
802
|
+
caughtUp || seededSyncing,
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
const briefSkeleton = (
|
|
403
806
|
<div className="h-full overflow-y-auto">
|
|
404
807
|
<SectionSkeleton />
|
|
405
808
|
<SectionSkeleton />
|
|
406
809
|
</div>
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
const stateBody = isLoading ? (
|
|
813
|
+
briefSkeleton
|
|
407
814
|
) : isError ? (
|
|
408
815
|
<div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-sm text-fg-muted">
|
|
409
816
|
<AlertCircle className="size-8 text-danger" />
|
|
@@ -418,63 +825,64 @@ export function DailyBrief({
|
|
|
418
825
|
</button>
|
|
419
826
|
</div>
|
|
420
827
|
) : caughtUp ? (
|
|
421
|
-
|
|
422
|
-
<
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
828
|
+
syncProgress.resolved ? (
|
|
829
|
+
<BriefEmpty
|
|
830
|
+
sync={
|
|
831
|
+
syncProgress.syncing
|
|
832
|
+
? { synced: syncProgress.synced, total: syncProgress.total }
|
|
833
|
+
: undefined
|
|
834
|
+
}
|
|
835
|
+
/>
|
|
836
|
+
) : (
|
|
837
|
+
briefSkeleton
|
|
838
|
+
)
|
|
426
839
|
) : (
|
|
840
|
+
<BriefListBody
|
|
841
|
+
sections={sections}
|
|
842
|
+
briefCategory={selectedCategory}
|
|
843
|
+
selectedThreadId={selectedMessageId}
|
|
844
|
+
onSelectThread={onSelectMessage}
|
|
845
|
+
/>
|
|
846
|
+
);
|
|
847
|
+
|
|
848
|
+
// The cursor and selection wrap the whole pane, not just the list body: the
|
|
849
|
+
// selection toolbar takes the header's place while rows are selected, so it
|
|
850
|
+
// has to sit inside the same provider the rows do.
|
|
851
|
+
return (
|
|
427
852
|
<ThreadListInteraction
|
|
428
853
|
selectedMessageId={selectedMessageId}
|
|
429
|
-
onOpen={(id) => onSelectMessage?.(id)}
|
|
854
|
+
onOpen={(id, options) => onSelectMessage?.(id, options)}
|
|
430
855
|
onDeleteMessages={onDeleteMessages}
|
|
431
856
|
commandsRef={commandsRef}
|
|
432
857
|
onTriageContextChange={onTriageContextChange}
|
|
433
858
|
>
|
|
434
|
-
<
|
|
435
|
-
{
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
859
|
+
<BriefSelectionChrome
|
|
860
|
+
header={{
|
|
861
|
+
title: "Daily brief",
|
|
862
|
+
unreadCount: totalUnseen,
|
|
863
|
+
footer: isDesktop ? <KeyboardHintBar /> : undefined,
|
|
864
|
+
searchFilter: searchFilterConfig,
|
|
865
|
+
searchResults,
|
|
866
|
+
searchLoading: isLoading || searchFetching,
|
|
867
|
+
relatedResults,
|
|
868
|
+
relatedLoading,
|
|
869
|
+
onSelectSearchResult,
|
|
870
|
+
}}
|
|
871
|
+
rows={filteredRows}
|
|
872
|
+
isDesktop={isDesktop}
|
|
873
|
+
onMarkMessagesRead={onMarkMessagesRead}
|
|
874
|
+
>
|
|
875
|
+
<div className="flex h-full flex-col">
|
|
876
|
+
{failedAccounts.map((account) => (
|
|
877
|
+
<ErrorBanner
|
|
878
|
+
key={account.accountId}
|
|
879
|
+
accountEmail={account.email}
|
|
880
|
+
accountId={account.accountId}
|
|
881
|
+
/>
|
|
882
|
+
))}
|
|
883
|
+
<div className="min-h-0 flex-1">{stateBody}</div>
|
|
451
884
|
</div>
|
|
452
|
-
</
|
|
885
|
+
</BriefSelectionChrome>
|
|
453
886
|
</ThreadListInteraction>
|
|
454
887
|
);
|
|
455
|
-
|
|
456
|
-
return (
|
|
457
|
-
<MailListHeader
|
|
458
|
-
title="Daily brief"
|
|
459
|
-
unreadCount={totalUnseen}
|
|
460
|
-
footer={isDesktop ? <KeyboardHintBar /> : undefined}
|
|
461
|
-
searchFilter={searchFilterConfig}
|
|
462
|
-
searchResults={searchResults}
|
|
463
|
-
searchLoading={isLoading || searchFetching}
|
|
464
|
-
relatedResults={relatedResults}
|
|
465
|
-
relatedLoading={relatedLoading}
|
|
466
|
-
onSelectSearchResult={onSelectSearchResult}
|
|
467
|
-
>
|
|
468
|
-
<div className="flex h-full flex-col">
|
|
469
|
-
{failedAccounts.map((account) => (
|
|
470
|
-
<ErrorBanner
|
|
471
|
-
key={account.accountId}
|
|
472
|
-
accountEmail={account.email}
|
|
473
|
-
accountId={account.accountId}
|
|
474
|
-
/>
|
|
475
|
-
))}
|
|
476
|
-
<div className="min-h-0 flex-1">{stateBody}</div>
|
|
477
|
-
</div>
|
|
478
|
-
</MailListHeader>
|
|
479
|
-
);
|
|
480
888
|
}
|
|
@@ -38,6 +38,7 @@ import { MailViewChrome } from "./MailViewChrome";
|
|
|
38
38
|
import type { MessageListCommands } from "./MessageList";
|
|
39
39
|
import { MessageRow } from "./MessageRow";
|
|
40
40
|
import {
|
|
41
|
+
type OpenMessageOptions,
|
|
41
42
|
ThreadListInteraction,
|
|
42
43
|
ThreadListSelectionBar,
|
|
43
44
|
} from "./ThreadListInteraction";
|
|
@@ -49,7 +50,7 @@ const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
|
|
|
49
50
|
|
|
50
51
|
interface FlaggedListProps {
|
|
51
52
|
selectedMessageId?: string;
|
|
52
|
-
onSelectMessage?: (id: string) => void;
|
|
53
|
+
onSelectMessage?: (id: string, options?: OpenMessageOptions) => void;
|
|
53
54
|
/** Where the list publishes the commands the keyboard layer drives. */
|
|
54
55
|
commandsRef?: RefObject<MessageListCommands | null>;
|
|
55
56
|
/** Cursor / selection / display order, reported up to the triage layer. */
|
|
@@ -148,7 +149,7 @@ export function FlaggedList({
|
|
|
148
149
|
const listBody = (
|
|
149
150
|
<ThreadListInteraction
|
|
150
151
|
selectedMessageId={selectedMessageId}
|
|
151
|
-
onOpen={(id) => onSelectMessage?.(id)}
|
|
152
|
+
onOpen={(id, options) => onSelectMessage?.(id, options)}
|
|
152
153
|
onDeleteMessages={onDeleteMessages}
|
|
153
154
|
commandsRef={commandsRef}
|
|
154
155
|
onTriageContextChange={onTriageContextChange}
|