@remit/web-client 0.0.1
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/harness/build.mjs +84 -0
- package/harness/index.html +37 -0
- package/harness/vite-preset.ts +29 -0
- package/index.html +47 -0
- package/package.json +147 -0
- package/public/config.js +5 -0
- package/public/locales/de/common.json +1 -0
- package/public/locales/de/errors.json +1 -0
- package/public/locales/de/mail.json +13 -0
- package/public/locales/de/settings.json +1 -0
- package/public/locales/en/common.json +26 -0
- package/public/locales/en/errors.json +1 -0
- package/public/locales/en/mail.json +69 -0
- package/public/locales/en/settings.json +1 -0
- package/public/locales/fr/common.json +1 -0
- package/public/locales/fr/errors.json +1 -0
- package/public/locales/fr/mail.json +13 -0
- package/public/locales/fr/settings.json +1 -0
- package/public/locales/nl/common.json +1 -0
- package/public/locales/nl/errors.json +1 -0
- package/public/locales/nl/mail.json +13 -0
- package/public/locales/nl/settings.json +1 -0
- package/src/auth/AccountMenu.tsx +58 -0
- package/src/auth/AccountSession.tsx +19 -0
- package/src/auth/BetterAuthShell.tsx +198 -0
- package/src/auth/account-menu-mode.test.ts +64 -0
- package/src/auth/account-menu-mode.ts +28 -0
- package/src/auth/amplify-config.test.ts +158 -0
- package/src/auth/amplify-config.ts +60 -0
- package/src/auth/auth-error.test.ts +109 -0
- package/src/auth/auth-error.ts +107 -0
- package/src/auth/auth-interceptor.test.ts +86 -0
- package/src/auth/auth-interceptor.ts +23 -0
- package/src/auth/auth.css +334 -0
- package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
- package/src/auth/better-auth-config.ts +85 -0
- package/src/auth/better-auth-provider.tsx +20 -0
- package/src/auth/cognito/CognitoAccount.tsx +48 -0
- package/src/auth/cognito/CognitoShell.tsx +266 -0
- package/src/auth/cognito/cognito-token.test.ts +110 -0
- package/src/auth/cognito/cognito-token.ts +47 -0
- package/src/auth/cognito/cognito.css +21 -0
- package/src/auth/cognito-provider.tsx +22 -0
- package/src/auth/combined-provider.tsx +42 -0
- package/src/auth/composition.test.ts +136 -0
- package/src/auth/provider.tsx +63 -0
- package/src/auth/sign-out-visibility.test.ts +40 -0
- package/src/auth/sign-out-visibility.ts +10 -0
- package/src/auth/sign-up-disabled.test.ts +23 -0
- package/src/components/compose/AddressField.tsx +237 -0
- package/src/components/compose/ComposeBody.tsx +36 -0
- package/src/components/compose/ComposeForm.tsx +676 -0
- package/src/components/compose/ComposeProvider.tsx +133 -0
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
- package/src/components/compose/FromSelector.tsx +67 -0
- package/src/components/compose/FullCompose.tsx +82 -0
- package/src/components/compose/InlineCompose.tsx +29 -0
- package/src/components/compose/MobileComposeSheet.tsx +152 -0
- package/src/components/compose/PlateEditor.tsx +86 -0
- package/src/components/compose/PlateToolbar.tsx +110 -0
- package/src/components/compose/SubjectField.tsx +19 -0
- package/src/components/compose/draft-discard-banner.test.ts +76 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/sanitize-quote-html.ts +23 -0
- package/src/components/layout/AppShellSkeleton.tsx +53 -0
- package/src/components/layout/ComposeFab.tsx +86 -0
- package/src/components/layout/Drawer.tsx +105 -0
- package/src/components/mail/AutoMovedIndicator.tsx +48 -0
- package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
- package/src/components/mail/BlockedImagesNotice.tsx +41 -0
- package/src/components/mail/BriefPane.tsx +289 -0
- package/src/components/mail/ConversationView.tsx +462 -0
- package/src/components/mail/DailyBrief.tsx +451 -0
- package/src/components/mail/DraftsView.tsx +279 -0
- package/src/components/mail/FlaggedList.tsx +170 -0
- package/src/components/mail/FlaggedPane.tsx +217 -0
- package/src/components/mail/IntelligencePane.test.ts +123 -0
- package/src/components/mail/IntelligencePane.tsx +441 -0
- package/src/components/mail/MailListHeader.tsx +187 -0
- package/src/components/mail/MailNav.tsx +70 -0
- package/src/components/mail/MailSidebarAdapter.tsx +351 -0
- package/src/components/mail/MailViewChrome.tsx +95 -0
- package/src/components/mail/MailboxPane.tsx +1219 -0
- package/src/components/mail/MessageActionMenu.tsx +368 -0
- package/src/components/mail/MessageBody.tsx +207 -0
- package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
- package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
- package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
- package/src/components/mail/MessageCard.tsx +401 -0
- package/src/components/mail/MessageDetail.tsx +153 -0
- package/src/components/mail/MessageList.tsx +804 -0
- package/src/components/mail/MessageListItem.tsx +267 -0
- package/src/components/mail/MessageToolbar.render.test.ts +57 -0
- package/src/components/mail/MessageToolbar.tsx +166 -0
- package/src/components/mail/MobileMessageBar.tsx +142 -0
- package/src/components/mail/MoveToTrigger.tsx +250 -0
- package/src/components/mail/NavMenuButton.tsx +25 -0
- package/src/components/mail/OutboxPane.tsx +531 -0
- package/src/components/mail/PullToRefresh.tsx +24 -0
- package/src/components/mail/RawMessageView.tsx +84 -0
- package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
- package/src/components/mail/SelectionToolbar.tsx +139 -0
- package/src/components/mail/SpamRescue.render.test.ts +35 -0
- package/src/components/mail/SpamRescue.tsx +138 -0
- package/src/components/mail/SwipeableMessageRow.tsx +148 -0
- package/src/components/mail/message-body-error-banner-content.ts +87 -0
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
- package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
- package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
- package/src/components/mail/organize/OrganizePanel.tsx +344 -0
- package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
- package/src/components/settings/AccountFormPanel.test.ts +72 -0
- package/src/components/settings/AccountFormPanel.tsx +942 -0
- package/src/components/settings/DangerZone.tsx +30 -0
- package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
- package/src/components/settings/DeleteAccountDialog.tsx +224 -0
- package/src/components/settings/FiltersList.render.test.ts +71 -0
- package/src/components/settings/FiltersList.tsx +93 -0
- package/src/components/settings/account-form-helpers.test.ts +172 -0
- package/src/components/settings/account-form-helpers.ts +105 -0
- package/src/components/ui/AppVersion.tsx +50 -0
- package/src/components/ui/BugReportButton.tsx +45 -0
- package/src/components/ui/ConfirmDialog.tsx +125 -0
- package/src/components/ui/DropdownMenu.tsx +105 -0
- package/src/components/ui/EmptyState.tsx +11 -0
- package/src/components/ui/ErrorBanner.tsx +88 -0
- package/src/components/ui/ErrorBannerProvider.tsx +77 -0
- package/src/components/ui/ErrorBannerStack.tsx +34 -0
- package/src/components/ui/ErrorState.tsx +80 -0
- package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
- package/src/components/ui/FatalErrorOverlay.tsx +151 -0
- package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
- package/src/components/ui/SlidePanel.tsx +72 -0
- package/src/components/ui/error-banners.test.ts +207 -0
- package/src/components/ui/error-banners.ts +111 -0
- package/src/hooks/queries/keys.ts +38 -0
- package/src/hooks/useArchiveMailbox.ts +104 -0
- package/src/hooks/useAutoMovedBadge.ts +77 -0
- package/src/hooks/useCurrentMailboxName.ts +99 -0
- package/src/hooks/useDebouncedValue.ts +12 -0
- package/src/hooks/useDeleteMessages.test.ts +103 -0
- package/src/hooks/useDeleteMessages.ts +226 -0
- package/src/hooks/useFilters.ts +112 -0
- package/src/hooks/useIntelligenceData.test.ts +269 -0
- package/src/hooks/useIntelligenceData.ts +284 -0
- package/src/hooks/useIsDark.ts +27 -0
- package/src/hooks/useKeyboardNavigation.ts +192 -0
- package/src/hooks/useLayoutTier.test.ts +47 -0
- package/src/hooks/useLayoutTier.ts +44 -0
- package/src/hooks/useLongPress.test.ts +291 -0
- package/src/hooks/useLongPress.ts +87 -0
- package/src/hooks/useMailboxAccount.ts +62 -0
- package/src/hooks/useMailboxNameIndex.ts +32 -0
- package/src/hooks/useMarkAsRead.test.ts +165 -0
- package/src/hooks/useMarkAsRead.ts +319 -0
- package/src/hooks/useMediaQuery.ts +39 -0
- package/src/hooks/useMessageBodyContent.test.ts +370 -0
- package/src/hooks/useMessageBodyContent.ts +380 -0
- package/src/hooks/useMoveMessages.test.ts +63 -0
- package/src/hooks/useMoveMessages.ts +221 -0
- package/src/hooks/useOrganizeJob.ts +90 -0
- package/src/hooks/useOrganizePreview.ts +40 -0
- package/src/hooks/useRescueCandidates.test.ts +217 -0
- package/src/hooks/useRescueCandidates.ts +48 -0
- package/src/hooks/useSaveDraft.ts +118 -0
- package/src/hooks/useSelection.test.ts +75 -0
- package/src/hooks/useSelection.ts +211 -0
- package/src/hooks/useSemanticSearch.ts +94 -0
- package/src/hooks/useSignature.ts +69 -0
- package/src/hooks/useStaleAccountSync.test.ts +211 -0
- package/src/hooks/useStaleAccountSync.ts +246 -0
- package/src/hooks/useSwipeNavigation.test.ts +35 -0
- package/src/hooks/useSwipeNavigation.ts +99 -0
- package/src/hooks/useToggleStar.ts +187 -0
- package/src/hooks/useToggleTrusted.test.ts +102 -0
- package/src/hooks/useToggleTrusted.ts +161 -0
- package/src/hooks/useTriageKeyboard.ts +103 -0
- package/src/hooks/useTriggerSync.test.ts +24 -0
- package/src/hooks/useTriggerSync.ts +58 -0
- package/src/hooks/useUpdateAddressFlags.ts +125 -0
- package/src/hooks/useVisualViewport.test.ts +127 -0
- package/src/hooks/useVisualViewport.ts +51 -0
- package/src/index.css +19 -0
- package/src/lib/account-order.test.ts +84 -0
- package/src/lib/account-order.ts +13 -0
- package/src/lib/adjacent-message.test.ts +43 -0
- package/src/lib/adjacent-message.ts +20 -0
- package/src/lib/api.ts +82 -0
- package/src/lib/app-info.ts +15 -0
- package/src/lib/auto-moved.test.ts +140 -0
- package/src/lib/auto-moved.ts +68 -0
- package/src/lib/autodiscovery.test.ts +94 -0
- package/src/lib/autodiscovery.ts +354 -0
- package/src/lib/brief.test.ts +366 -0
- package/src/lib/brief.ts +180 -0
- package/src/lib/bug-report.test.ts +213 -0
- package/src/lib/bug-report.ts +176 -0
- package/src/lib/client.ts +36 -0
- package/src/lib/console-errors.test.ts +64 -0
- package/src/lib/console-errors.ts +92 -0
- package/src/lib/conversation-target.test.ts +68 -0
- package/src/lib/conversation-target.ts +59 -0
- package/src/lib/display-category.ts +20 -0
- package/src/lib/drafts.test.ts +241 -0
- package/src/lib/drafts.ts +121 -0
- package/src/lib/error-classifier.test.ts +133 -0
- package/src/lib/error-classifier.ts +96 -0
- package/src/lib/fatal-error.test.ts +97 -0
- package/src/lib/fatal-error.ts +165 -0
- package/src/lib/folder-roles.test.ts +113 -0
- package/src/lib/folder-roles.ts +112 -0
- package/src/lib/format.test.ts +115 -0
- package/src/lib/format.ts +198 -0
- package/src/lib/i18n.ts +36 -0
- package/src/lib/intelligence-pref.test.ts +57 -0
- package/src/lib/intelligence-pref.ts +25 -0
- package/src/lib/keymap-dispatch.test.ts +178 -0
- package/src/lib/keymap-dispatch.ts +187 -0
- package/src/lib/keymap.test.ts +49 -0
- package/src/lib/keymap.ts +178 -0
- package/src/lib/mail-context.ts +64 -0
- package/src/lib/mail-route.test.ts +80 -0
- package/src/lib/mail-route.ts +44 -0
- package/src/lib/message-body-source.test.ts +98 -0
- package/src/lib/message-body-source.ts +48 -0
- package/src/lib/move-targets.test.ts +195 -0
- package/src/lib/move-targets.ts +74 -0
- package/src/lib/onboarding-completion.test.ts +64 -0
- package/src/lib/onboarding-completion.ts +23 -0
- package/src/lib/organize/filter-status.test.ts +99 -0
- package/src/lib/organize/filter-status.ts +86 -0
- package/src/lib/organize/organize-copy.test.ts +93 -0
- package/src/lib/organize/organize-copy.ts +61 -0
- package/src/lib/organize/organize-model.test.ts +104 -0
- package/src/lib/organize/organize-model.ts +105 -0
- package/src/lib/organize/organize-poll.test.ts +37 -0
- package/src/lib/organize/organize-poll.ts +28 -0
- package/src/lib/outbox-status.test.ts +64 -0
- package/src/lib/outbox-status.ts +75 -0
- package/src/lib/plate-serializer.ts +164 -0
- package/src/lib/provider-presets.test.ts +96 -0
- package/src/lib/provider-presets.ts +81 -0
- package/src/lib/query-error-handler.test.ts +144 -0
- package/src/lib/query-error-handler.ts +34 -0
- package/src/lib/query-escalation.integration.test.ts +138 -0
- package/src/lib/recent-searches.test.ts +53 -0
- package/src/lib/recent-searches.ts +34 -0
- package/src/lib/rescue-candidates.test.ts +94 -0
- package/src/lib/rescue-candidates.ts +35 -0
- package/src/lib/rescue-telemetry.test.ts +75 -0
- package/src/lib/rescue-telemetry.ts +55 -0
- package/src/lib/rum-adapter.test.ts +299 -0
- package/src/lib/rum-adapter.ts +107 -0
- package/src/lib/saved-searches.test.ts +80 -0
- package/src/lib/saved-searches.ts +52 -0
- package/src/lib/search-pending.test.ts +61 -0
- package/src/lib/search-pending.ts +24 -0
- package/src/lib/search-query.test.ts +35 -0
- package/src/lib/search-query.ts +11 -0
- package/src/lib/search-result.test.ts +160 -0
- package/src/lib/search-result.ts +102 -0
- package/src/lib/search-schemas.ts +34 -0
- package/src/lib/search-token-index.test.ts +112 -0
- package/src/lib/search-token-index.ts +56 -0
- package/src/lib/search-tokens.test.ts +218 -0
- package/src/lib/search-tokens.ts +133 -0
- package/src/lib/telemetry-context.tsx +9 -0
- package/src/lib/telemetry.ts +17 -0
- package/src/lib/theme-preference.ts +46 -0
- package/src/lib/theme.ts +33 -0
- package/src/lib/utils.ts +4 -0
- package/src/main.tsx +10 -0
- package/src/routeTree.gen.ts +392 -0
- package/src/router.test.ts +81 -0
- package/src/router.tsx +31 -0
- package/src/routes/__root.tsx +69 -0
- package/src/routes/index.tsx +7 -0
- package/src/routes/mail/$mailboxId.tsx +27 -0
- package/src/routes/mail/flagged.tsx +37 -0
- package/src/routes/mail/index.tsx +41 -0
- package/src/routes/mail/outbox.tsx +13 -0
- package/src/routes/mail.tsx +413 -0
- package/src/routes/onboarding.tsx +35 -0
- package/src/routes/settings/-accounts.test.ts +65 -0
- package/src/routes/settings/accounts.tsx +542 -0
- package/src/routes/settings/advanced.tsx +55 -0
- package/src/routes/settings/appearance.tsx +140 -0
- package/src/routes/settings/filters.tsx +141 -0
- package/src/routes/settings/folders.tsx +216 -0
- package/src/routes/settings/index.tsx +5 -0
- package/src/routes/settings/senders.tsx +466 -0
- package/src/routes/settings/suggested-vips.tsx +10 -0
- package/src/routes/settings.tsx +66 -0
- package/src/runtime-config.ts +75 -0
- package/src/shell/index.tsx +98 -0
- package/src/types/index.ts +73 -0
- package/src/vite-env.d.ts +4 -0
- package/vite.base.ts +43 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export type ErrorBannerSeverity = "error" | "warning" | "info";
|
|
2
|
+
|
|
3
|
+
export interface ErrorBannerEntry {
|
|
4
|
+
id: string;
|
|
5
|
+
severity: ErrorBannerSeverity;
|
|
6
|
+
title: string;
|
|
7
|
+
detail?: string;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PushErrorInput {
|
|
12
|
+
severity?: ErrorBannerSeverity;
|
|
13
|
+
title: string;
|
|
14
|
+
detail?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MAX_BANNERS = 5;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the banner input for a mutation failure: the error's own message when
|
|
21
|
+
* it has one, otherwise a human fallback. Shared by the previously-silent
|
|
22
|
+
* mutation `onError` handlers (signature save, outbox retry/delete, draft
|
|
23
|
+
* discard) so they surface a real detail instead of nothing. A fatal 5xx is
|
|
24
|
+
* additionally escalated globally; this only governs the soft banner copy.
|
|
25
|
+
*/
|
|
26
|
+
export const buildMutationErrorBanner = (
|
|
27
|
+
title: string,
|
|
28
|
+
fallback: string,
|
|
29
|
+
error: unknown,
|
|
30
|
+
): PushErrorInput => ({
|
|
31
|
+
title,
|
|
32
|
+
detail: formatErrorDetail(error) ?? fallback,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const formatErrorDetail = (error: unknown): string | undefined => {
|
|
36
|
+
if (error === undefined || error === null) return undefined;
|
|
37
|
+
if (error instanceof Error) return error.message || undefined;
|
|
38
|
+
if (typeof error === "string") return error || undefined;
|
|
39
|
+
if (
|
|
40
|
+
typeof error === "object" &&
|
|
41
|
+
"message" in error &&
|
|
42
|
+
typeof (error as { message: unknown }).message === "string"
|
|
43
|
+
) {
|
|
44
|
+
const message = (error as { message: string }).message;
|
|
45
|
+
return message || undefined;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Detect a "describeMessage hit a deleted/missing row" error.
|
|
52
|
+
*
|
|
53
|
+
* Soft-deleted ThreadMessage rows used to leak into the inbox list (#212);
|
|
54
|
+
* clicking one fired `describeMessage` against an already-hard-deleted
|
|
55
|
+
* Message and the backend's `NotFoundError` ("Message not found: <id>")
|
|
56
|
+
* surfaced verbatim. We now filter those rows server-side, but the UI still
|
|
57
|
+
* needs a graceful empty state when the cache is mid-refresh or the row
|
|
58
|
+
* was just deleted in another tab.
|
|
59
|
+
*
|
|
60
|
+
* The backend serializes 404s as `{ message: "Message not found: <id>" }`
|
|
61
|
+
* (see `packages/backend/src/error.ts`). The hey-api fetch client
|
|
62
|
+
* throws the parsed JSON body as the error, so we match by message prefix.
|
|
63
|
+
*/
|
|
64
|
+
export const isMessageNotFoundError = (error: unknown): boolean => {
|
|
65
|
+
const detail = formatErrorDetail(error);
|
|
66
|
+
if (!detail) return false;
|
|
67
|
+
return detail.startsWith("Message not found");
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const sameInput = (
|
|
71
|
+
entry: ErrorBannerEntry,
|
|
72
|
+
input: PushErrorInput,
|
|
73
|
+
severity: ErrorBannerSeverity,
|
|
74
|
+
): boolean =>
|
|
75
|
+
entry.severity === severity &&
|
|
76
|
+
entry.title === input.title &&
|
|
77
|
+
entry.detail === input.detail;
|
|
78
|
+
|
|
79
|
+
export const appendBanner = (
|
|
80
|
+
current: ErrorBannerEntry[],
|
|
81
|
+
entry: ErrorBannerEntry,
|
|
82
|
+
): ErrorBannerEntry[] => {
|
|
83
|
+
const filtered = current.filter(
|
|
84
|
+
(existing) =>
|
|
85
|
+
!sameInput(
|
|
86
|
+
existing,
|
|
87
|
+
{ title: entry.title, detail: entry.detail, severity: entry.severity },
|
|
88
|
+
entry.severity,
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
const next = [...filtered, entry];
|
|
92
|
+
if (next.length <= MAX_BANNERS) return next;
|
|
93
|
+
return next.slice(next.length - MAX_BANNERS);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const dismissBanner = (
|
|
97
|
+
current: ErrorBannerEntry[],
|
|
98
|
+
id: string,
|
|
99
|
+
): ErrorBannerEntry[] => current.filter((entry) => entry.id !== id);
|
|
100
|
+
|
|
101
|
+
export const buildEntry = (
|
|
102
|
+
input: PushErrorInput,
|
|
103
|
+
id: string,
|
|
104
|
+
now: number,
|
|
105
|
+
): ErrorBannerEntry => ({
|
|
106
|
+
id,
|
|
107
|
+
severity: input.severity ?? "error",
|
|
108
|
+
title: input.title,
|
|
109
|
+
detail: input.detail,
|
|
110
|
+
createdAt: now,
|
|
111
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const threadKeys = {
|
|
2
|
+
all: ["threads"] as const,
|
|
3
|
+
|
|
4
|
+
lists: () => [...threadKeys.all, "list"] as const,
|
|
5
|
+
|
|
6
|
+
list: (
|
|
7
|
+
mailboxId: string,
|
|
8
|
+
filters?: { filter?: string; page?: number; cursor?: string },
|
|
9
|
+
) => [...threadKeys.lists(), mailboxId, filters] as const,
|
|
10
|
+
|
|
11
|
+
details: () => [...threadKeys.all, "detail"] as const,
|
|
12
|
+
|
|
13
|
+
detail: (threadId: string) => [...threadKeys.details(), threadId] as const,
|
|
14
|
+
|
|
15
|
+
messages: (threadId: string) =>
|
|
16
|
+
[...threadKeys.detail(threadId), "messages"] as const,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const mailboxKeys = {
|
|
20
|
+
all: ["mailboxes"] as const,
|
|
21
|
+
|
|
22
|
+
lists: () => [...mailboxKeys.all, "list"] as const,
|
|
23
|
+
|
|
24
|
+
list: (accountId: string) => [...mailboxKeys.lists(), accountId] as const,
|
|
25
|
+
|
|
26
|
+
detail: (mailboxId: string) =>
|
|
27
|
+
[...mailboxKeys.all, "detail", mailboxId] as const,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const messageKeys = {
|
|
31
|
+
all: ["messages"] as const,
|
|
32
|
+
|
|
33
|
+
detail: (messageId: string) =>
|
|
34
|
+
[...messageKeys.all, "detail", messageId] as const,
|
|
35
|
+
|
|
36
|
+
body: (messageId: string) =>
|
|
37
|
+
[...messageKeys.detail(messageId), "body"] as const,
|
|
38
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import type {
|
|
3
|
+
RemitImapCanonicalMailboxRole,
|
|
4
|
+
RemitImapFolderAppointment,
|
|
5
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
6
|
+
import { useQuery } from "@tanstack/react-query";
|
|
7
|
+
import { useMemo } from "react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* RFC 032 exclusive-folder-appointment (#976): every "which mailbox plays
|
|
11
|
+
* role X" question resolves from the account's `folderAppointments` map, not
|
|
12
|
+
* from independently re-detecting SPECIAL-USE flags or hardcoded name lists.
|
|
13
|
+
* The map is server-resolved (persisted user choice, or a proposal for
|
|
14
|
+
* anything unfilled), so a single shared lookup replaces what used to be four
|
|
15
|
+
* near-identical flag-then-name detectors.
|
|
16
|
+
*/
|
|
17
|
+
const useFolderRoleMailbox = (
|
|
18
|
+
accountId: string | undefined,
|
|
19
|
+
role: RemitImapCanonicalMailboxRole,
|
|
20
|
+
): { mailboxId: string | undefined; isLoading: boolean } => {
|
|
21
|
+
const { data: config, isLoading } = useQuery({
|
|
22
|
+
...configOperationsGetConfigOptions(),
|
|
23
|
+
staleTime: Infinity,
|
|
24
|
+
enabled: !!accountId,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const mailboxId = useMemo(() => {
|
|
28
|
+
if (!accountId) return undefined;
|
|
29
|
+
const account = config?.accounts.find((a) => a.accountId === accountId);
|
|
30
|
+
return account?.folderAppointments.find((fa) => fa.role === role)
|
|
31
|
+
?.mailboxId;
|
|
32
|
+
}, [config, accountId, role]);
|
|
33
|
+
|
|
34
|
+
return { mailboxId, isLoading };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns the account's appointed Archive mailbox id. `undefined` while
|
|
39
|
+
* loading or when no folder is appointed to (or proposed for) the role.
|
|
40
|
+
*/
|
|
41
|
+
export const useArchiveMailbox = (
|
|
42
|
+
accountId: string | undefined,
|
|
43
|
+
): { archiveMailboxId: string | undefined; isLoading: boolean } => {
|
|
44
|
+
const { mailboxId, isLoading } = useFolderRoleMailbox(accountId, "Archive");
|
|
45
|
+
return { archiveMailboxId: mailboxId, isLoading };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns the account's appointed Drafts mailbox id. Used by the mailbox
|
|
50
|
+
* route to decide whether to render the segmented Drafts view (Remit drafts +
|
|
51
|
+
* IMAP Drafts) in place of the flat message list (issue #505).
|
|
52
|
+
*/
|
|
53
|
+
export const useDraftsMailbox = (
|
|
54
|
+
accountId: string | undefined,
|
|
55
|
+
): { draftsMailboxId: string | undefined; isLoading: boolean } => {
|
|
56
|
+
const { mailboxId, isLoading } = useFolderRoleMailbox(accountId, "Drafts");
|
|
57
|
+
return { draftsMailboxId: mailboxId, isLoading };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Returns the account's Inbox mailbox id — the destination for "Not spam"
|
|
62
|
+
* (issue #594), which moves a message out of Junk back to the inbox.
|
|
63
|
+
*/
|
|
64
|
+
export const useInboxMailbox = (
|
|
65
|
+
accountId: string | undefined,
|
|
66
|
+
): { inboxMailboxId: string | undefined; isLoading: boolean } => {
|
|
67
|
+
const { mailboxId, isLoading } = useFolderRoleMailbox(accountId, "Inbox");
|
|
68
|
+
return { inboxMailboxId: mailboxId, isLoading };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returns the account's appointed Junk/Spam mailbox id, used by the triage
|
|
73
|
+
* `!` (mark junk) key (#429). The message-flags API has no `$Junk` keyword
|
|
74
|
+
* field, so "mark junk" is realized as a move to the Junk mailbox.
|
|
75
|
+
*/
|
|
76
|
+
export const useJunkMailbox = (
|
|
77
|
+
accountId: string | undefined,
|
|
78
|
+
): { junkMailboxId: string | undefined; isLoading: boolean } => {
|
|
79
|
+
const { mailboxId, isLoading } = useFolderRoleMailbox(accountId, "Junk");
|
|
80
|
+
return { junkMailboxId: mailboxId, isLoading };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Returns an account's full role→mailbox appointment map, for callers that
|
|
85
|
+
* need more than one role at once (e.g. the Move-to picker excluding both
|
|
86
|
+
* Drafts and Sent, and ordering system folders by role). Empty while loading
|
|
87
|
+
* or when `accountId` is undefined.
|
|
88
|
+
*/
|
|
89
|
+
export const useFolderAppointments = (
|
|
90
|
+
accountId: string | undefined,
|
|
91
|
+
): readonly RemitImapFolderAppointment[] => {
|
|
92
|
+
const { data: config } = useQuery({
|
|
93
|
+
...configOperationsGetConfigOptions(),
|
|
94
|
+
staleTime: Infinity,
|
|
95
|
+
enabled: !!accountId,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return useMemo(
|
|
99
|
+
() =>
|
|
100
|
+
config?.accounts.find((a) => a.accountId === accountId)
|
|
101
|
+
?.folderAppointments ?? [],
|
|
102
|
+
[config, accountId],
|
|
103
|
+
);
|
|
104
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { RemitImapAutoMovedInfo } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { useCallback } from "react";
|
|
3
|
+
import {
|
|
4
|
+
autoMovedLabel,
|
|
5
|
+
isAutoMoveInEffect,
|
|
6
|
+
resolveUndoTargetMailboxId,
|
|
7
|
+
} from "@/lib/auto-moved";
|
|
8
|
+
import { useInboxMailbox, useJunkMailbox } from "./useArchiveMailbox";
|
|
9
|
+
import { useMoveMessages } from "./useMoveMessages";
|
|
10
|
+
|
|
11
|
+
interface UseAutoMovedBadgeOptions {
|
|
12
|
+
accountId: string | undefined;
|
|
13
|
+
messageId: string;
|
|
14
|
+
threadId: string;
|
|
15
|
+
/** The message's current mailbox — the row/card it's rendered in. */
|
|
16
|
+
mailboxId: string;
|
|
17
|
+
autoMoved: RemitImapAutoMovedInfo | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AutoMovedBadgeState {
|
|
21
|
+
/** Whether the move is still in effect and the badge should render. */
|
|
22
|
+
show: boolean;
|
|
23
|
+
label: string;
|
|
24
|
+
/** Present only when the undo target mailbox resolved. */
|
|
25
|
+
onUndo?: () => void;
|
|
26
|
+
isUndoing: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Composes the account's Inbox/Junk mailboxes with the message's `autoMoved`
|
|
31
|
+
* projection into everything the `AutoMovedBadge` kit component needs: the
|
|
32
|
+
* derived "still in effect" gate, the plain-language label, and a one-click
|
|
33
|
+
* undo bound to the existing `moveMessages` mutation (no new endpoint — moves
|
|
34
|
+
* back through the same bulk move operation, the other direction).
|
|
35
|
+
*
|
|
36
|
+
* `show` re-derives on every render from `mailboxId` — no local dismissed
|
|
37
|
+
* flag. Once `moveMessages` settles, its query invalidation refetches the
|
|
38
|
+
* thread row with its updated `mailboxId`, and the badge naturally stops
|
|
39
|
+
* showing (doc/rules/data-flow.md).
|
|
40
|
+
*/
|
|
41
|
+
export const useAutoMovedBadge = ({
|
|
42
|
+
accountId,
|
|
43
|
+
messageId,
|
|
44
|
+
threadId,
|
|
45
|
+
mailboxId,
|
|
46
|
+
autoMoved,
|
|
47
|
+
}: UseAutoMovedBadgeOptions): AutoMovedBadgeState => {
|
|
48
|
+
const { inboxMailboxId } = useInboxMailbox(accountId);
|
|
49
|
+
const { junkMailboxId } = useJunkMailbox(accountId);
|
|
50
|
+
const { moveMessages, isPending } = useMoveMessages({
|
|
51
|
+
mailboxId,
|
|
52
|
+
threadId,
|
|
53
|
+
accountId,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const roleMailboxes = { inboxMailboxId, junkMailboxId };
|
|
57
|
+
const show = isAutoMoveInEffect(autoMoved, mailboxId, roleMailboxes);
|
|
58
|
+
const undoTargetMailboxId = autoMoved
|
|
59
|
+
? resolveUndoTargetMailboxId(autoMoved.fromPlacement, roleMailboxes)
|
|
60
|
+
: undefined;
|
|
61
|
+
|
|
62
|
+
const handleUndo = useCallback(() => {
|
|
63
|
+
if (!undoTargetMailboxId) return;
|
|
64
|
+
moveMessages([messageId], undoTargetMailboxId);
|
|
65
|
+
}, [moveMessages, messageId, undoTargetMailboxId]);
|
|
66
|
+
|
|
67
|
+
if (!show || !autoMoved) {
|
|
68
|
+
return { show: false, label: "", isUndoing: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
show: true,
|
|
73
|
+
label: autoMovedLabel(autoMoved.fromPlacement),
|
|
74
|
+
onUndo: undoTargetMailboxId ? handleUndo : undefined,
|
|
75
|
+
isUndoing: isPending,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
3
|
+
import { useQueries } from "@tanstack/react-query";
|
|
4
|
+
import { useLocation, useParams } from "@tanstack/react-router";
|
|
5
|
+
import { useCallback } from "react";
|
|
6
|
+
import { useTranslation } from "react-i18next";
|
|
7
|
+
import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
|
|
8
|
+
|
|
9
|
+
interface UseCurrentMailboxNameOptions {
|
|
10
|
+
accounts: RemitImapAccountResponse[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolves a display label for the mailbox or pseudo-mailbox the user is
|
|
15
|
+
* currently looking at — used by the mobile top bar so it reads e.g.
|
|
16
|
+
* "Inbox" or "Outbox" instead of the static "Remit" branding.
|
|
17
|
+
*
|
|
18
|
+
* Priority:
|
|
19
|
+
* - `/mail/outbox` → "Outbox"
|
|
20
|
+
* - `/mail/$mailboxId` → display name from the mailbox query
|
|
21
|
+
* - everything else (e.g. `/mail` mid-redirect) → `null`
|
|
22
|
+
*
|
|
23
|
+
* Reuses the same `mailboxOperationsListMailboxesOptions` queries the
|
|
24
|
+
* `MailSidebarAdapter` runs, so on warm cache (the sidebar is always
|
|
25
|
+
* mounted on `/mail/*`) this is a free lookup.
|
|
26
|
+
*/
|
|
27
|
+
export const useCurrentMailboxName = ({
|
|
28
|
+
accounts,
|
|
29
|
+
}: UseCurrentMailboxNameOptions): string | null => {
|
|
30
|
+
const params = useParams({ strict: false });
|
|
31
|
+
const location = useLocation();
|
|
32
|
+
const mailboxId = (params as { mailboxId?: string }).mailboxId;
|
|
33
|
+
const { t } = useTranslation("mail", { useSuspense: false });
|
|
34
|
+
const translator = useCallback(
|
|
35
|
+
(key: string, fallback: string) => t(key, { defaultValue: fallback }),
|
|
36
|
+
[t],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const queries = useQueries({
|
|
40
|
+
queries: accounts.map((account) => ({
|
|
41
|
+
...mailboxOperationsListMailboxesOptions({
|
|
42
|
+
path: { accountId: account.accountId },
|
|
43
|
+
}),
|
|
44
|
+
staleTime: Infinity,
|
|
45
|
+
})),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (location.pathname.startsWith("/mail/outbox")) return "Outbox";
|
|
49
|
+
if (!mailboxId) return null;
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < accounts.length; i++) {
|
|
52
|
+
const items = queries[i]?.data?.items;
|
|
53
|
+
if (!items) continue;
|
|
54
|
+
const match = items.find((mailbox) => mailbox.mailboxId === mailboxId);
|
|
55
|
+
if (!match) continue;
|
|
56
|
+
const roleMap = buildMailboxRoleMap(accounts[i].folderAppointments);
|
|
57
|
+
return labelForMailbox(match, roleMap.get(mailboxId), translator);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolves the authoritative unread (unseen) total for the current mailbox
|
|
65
|
+
* from the mailbox query — the same warm-cache `mailboxOperationsListMailboxes`
|
|
66
|
+
* data the sidebar and `useCurrentMailboxName` already read.
|
|
67
|
+
*
|
|
68
|
+
* Returns `mailbox.unseenCount` (the true server-side total), not a count
|
|
69
|
+
* derived from the loaded infinite-query pages, which would undercount on any
|
|
70
|
+
* mailbox larger than one page and creep upward as the user scrolls. Returns
|
|
71
|
+
* `null` when the mailbox isn't resolvable yet (loading, or no `mailboxId`),
|
|
72
|
+
* letting callers fall back to a loaded-derived count.
|
|
73
|
+
*/
|
|
74
|
+
export const useCurrentMailboxUnseenCount = ({
|
|
75
|
+
accounts,
|
|
76
|
+
}: UseCurrentMailboxNameOptions): number | null => {
|
|
77
|
+
const params = useParams({ strict: false });
|
|
78
|
+
const mailboxId = (params as { mailboxId?: string }).mailboxId;
|
|
79
|
+
|
|
80
|
+
const queries = useQueries({
|
|
81
|
+
queries: accounts.map((account) => ({
|
|
82
|
+
...mailboxOperationsListMailboxesOptions({
|
|
83
|
+
path: { accountId: account.accountId },
|
|
84
|
+
}),
|
|
85
|
+
staleTime: Infinity,
|
|
86
|
+
})),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (!mailboxId) return null;
|
|
90
|
+
|
|
91
|
+
for (const query of queries) {
|
|
92
|
+
const items = query.data?.items;
|
|
93
|
+
if (!items) continue;
|
|
94
|
+
const match = items.find((mailbox) => mailbox.mailboxId === mailboxId);
|
|
95
|
+
if (match) return match.unseenCount;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return null;
|
|
99
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export const useDebouncedValue = <T>(value: T, delay: number): T => {
|
|
4
|
+
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
5
|
+
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
|
8
|
+
return () => clearTimeout(timer);
|
|
9
|
+
}, [value, delay]);
|
|
10
|
+
|
|
11
|
+
return debouncedValue;
|
|
12
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import {
|
|
5
|
+
dropDeletedThreads,
|
|
6
|
+
removeMessagesFromItems,
|
|
7
|
+
} from "./useDeleteMessages.js";
|
|
8
|
+
|
|
9
|
+
const make = (
|
|
10
|
+
overrides: Partial<RemitImapThreadMessageResponse> & {
|
|
11
|
+
messageId: string;
|
|
12
|
+
threadMessageId: string;
|
|
13
|
+
},
|
|
14
|
+
): RemitImapThreadMessageResponse =>
|
|
15
|
+
({
|
|
16
|
+
threadId: "t1",
|
|
17
|
+
mailboxId: "mb1",
|
|
18
|
+
subject: "s",
|
|
19
|
+
fromName: "n",
|
|
20
|
+
fromEmail: "e",
|
|
21
|
+
sentDate: "2025-01-01T00:00:00Z",
|
|
22
|
+
snippet: "",
|
|
23
|
+
hasAttachment: false,
|
|
24
|
+
hasStars: false,
|
|
25
|
+
isRead: true,
|
|
26
|
+
...overrides,
|
|
27
|
+
}) as RemitImapThreadMessageResponse;
|
|
28
|
+
|
|
29
|
+
describe("removeMessagesFromItems", () => {
|
|
30
|
+
test("drops items whose messageId is in the set", () => {
|
|
31
|
+
const items = [
|
|
32
|
+
make({ messageId: "m1", threadMessageId: "tm1" }),
|
|
33
|
+
make({ messageId: "m2", threadMessageId: "tm2" }),
|
|
34
|
+
make({ messageId: "m3", threadMessageId: "tm3" }),
|
|
35
|
+
];
|
|
36
|
+
const got = removeMessagesFromItems(items, new Set(["m2"]));
|
|
37
|
+
assert.deepStrictEqual(
|
|
38
|
+
got.map((i) => i.messageId),
|
|
39
|
+
["m1", "m3"],
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("returns the input unchanged when nothing matches", () => {
|
|
44
|
+
const items = [
|
|
45
|
+
make({ messageId: "m1", threadMessageId: "tm1" }),
|
|
46
|
+
make({ messageId: "m2", threadMessageId: "tm2" }),
|
|
47
|
+
];
|
|
48
|
+
const got = removeMessagesFromItems(items, new Set(["does-not-exist"]));
|
|
49
|
+
assert.equal(got.length, 2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("returns an empty array when all items match", () => {
|
|
53
|
+
const items = [
|
|
54
|
+
make({ messageId: "m1", threadMessageId: "tm1" }),
|
|
55
|
+
make({ messageId: "m2", threadMessageId: "tm2" }),
|
|
56
|
+
];
|
|
57
|
+
const got = removeMessagesFromItems(items, new Set(["m1", "m2"]));
|
|
58
|
+
assert.deepStrictEqual(got, []);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns an empty array for empty input", () => {
|
|
62
|
+
const got = removeMessagesFromItems([], new Set(["m1"]));
|
|
63
|
+
assert.deepStrictEqual(got, []);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("dropDeletedThreads (#212)", () => {
|
|
68
|
+
// Belt-and-braces UI filter for issue #212. If the backend regresses and
|
|
69
|
+
// returns a soft-deleted row, the inbox list must still hide it. The
|
|
70
|
+
// dedicated helper makes the regression cheap to test without rendering
|
|
71
|
+
// React.
|
|
72
|
+
|
|
73
|
+
test("drops items with isDeleted=true and keeps the rest", () => {
|
|
74
|
+
const items = [
|
|
75
|
+
make({ messageId: "m1", threadMessageId: "tm1", isDeleted: false }),
|
|
76
|
+
make({ messageId: "m2", threadMessageId: "tm2", isDeleted: true }),
|
|
77
|
+
make({ messageId: "m3", threadMessageId: "tm3" }),
|
|
78
|
+
];
|
|
79
|
+
const got = dropDeletedThreads(items);
|
|
80
|
+
assert.deepStrictEqual(
|
|
81
|
+
got.map((i) => i.messageId),
|
|
82
|
+
["m1", "m3"],
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("returns the input unchanged when nothing is deleted", () => {
|
|
87
|
+
const items = [
|
|
88
|
+
make({ messageId: "m1", threadMessageId: "tm1" }),
|
|
89
|
+
make({ messageId: "m2", threadMessageId: "tm2", isDeleted: false }),
|
|
90
|
+
];
|
|
91
|
+
const got = dropDeletedThreads(items);
|
|
92
|
+
assert.equal(got.length, 2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("returns an empty array when every row is deleted", () => {
|
|
96
|
+
const items = [
|
|
97
|
+
make({ messageId: "m1", threadMessageId: "tm1", isDeleted: true }),
|
|
98
|
+
make({ messageId: "m2", threadMessageId: "tm2", isDeleted: true }),
|
|
99
|
+
];
|
|
100
|
+
const got = dropDeletedThreads(items);
|
|
101
|
+
assert.deepStrictEqual(got, []);
|
|
102
|
+
});
|
|
103
|
+
});
|