@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,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addressDetailOperationsUpdateAddressMutation,
|
|
3
|
+
messageOperationsDescribeMessageQueryKey,
|
|
4
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
+
import type {
|
|
6
|
+
RemitImapAddressFlags,
|
|
7
|
+
RemitImapDescribeMessageResponse,
|
|
8
|
+
RemitImapEnvelopeAddressResponse,
|
|
9
|
+
RemitImapTrustedFlag,
|
|
10
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
11
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
12
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
13
|
+
import { buildMutationErrorBanner } from "@/components/ui/error-banners";
|
|
14
|
+
|
|
15
|
+
interface UseToggleTrustedOptions {
|
|
16
|
+
messageId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SnapshotEntry {
|
|
20
|
+
queryKey: readonly unknown[];
|
|
21
|
+
data: RemitImapDescribeMessageResponse;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ToggleTrustedContext {
|
|
25
|
+
describePrefix: readonly unknown[];
|
|
26
|
+
previous: SnapshotEntry[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const buildTrustedFlag = (
|
|
30
|
+
nextTrusted: boolean,
|
|
31
|
+
now: number = Date.now(),
|
|
32
|
+
): RemitImapTrustedFlag => ({
|
|
33
|
+
value: nextTrusted,
|
|
34
|
+
setAt: now,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const patchEnvelopeAddress = (
|
|
38
|
+
addr: RemitImapEnvelopeAddressResponse,
|
|
39
|
+
addressId: string,
|
|
40
|
+
nextTrusted: boolean,
|
|
41
|
+
now?: number,
|
|
42
|
+
): RemitImapEnvelopeAddressResponse => {
|
|
43
|
+
if (addr.addressId !== addressId) return addr;
|
|
44
|
+
const flags: RemitImapAddressFlags = { ...(addr.flags ?? {}) };
|
|
45
|
+
if (nextTrusted) {
|
|
46
|
+
flags.trusted = buildTrustedFlag(true, now);
|
|
47
|
+
} else {
|
|
48
|
+
delete flags.trusted;
|
|
49
|
+
}
|
|
50
|
+
return { ...addr, flags };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const patchDescribeMessage = (
|
|
54
|
+
data: RemitImapDescribeMessageResponse,
|
|
55
|
+
addressId: string,
|
|
56
|
+
nextTrusted: boolean,
|
|
57
|
+
now?: number,
|
|
58
|
+
): RemitImapDescribeMessageResponse => {
|
|
59
|
+
const map = (list: RemitImapEnvelopeAddressResponse[]) =>
|
|
60
|
+
list.map((addr) => patchEnvelopeAddress(addr, addressId, nextTrusted, now));
|
|
61
|
+
return {
|
|
62
|
+
...data,
|
|
63
|
+
envelope: {
|
|
64
|
+
...data.envelope,
|
|
65
|
+
from: map(data.envelope.from),
|
|
66
|
+
to: map(data.envelope.to),
|
|
67
|
+
cc: map(data.envelope.cc),
|
|
68
|
+
bcc: map(data.envelope.bcc),
|
|
69
|
+
replyTo: map(data.envelope.replyTo),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Toggle the per-sender `trusted` flag for an Address. Mirrors the optimistic-
|
|
76
|
+
* update + rollback pattern from `useToggleStar`: we patch every cached
|
|
77
|
+
* `describeMessage` response that mentions this address so the UI flips
|
|
78
|
+
* instantly, then roll back on error per the project's "never hide failure"
|
|
79
|
+
* rule. The failure is surfaced reliably here in `onError` (banner + rollback),
|
|
80
|
+
* not via a fragile consumer effect; a fatal 5xx additionally escalates through
|
|
81
|
+
* the global MutationCache.onError sink.
|
|
82
|
+
*/
|
|
83
|
+
export const useToggleTrusted = ({ messageId }: UseToggleTrustedOptions) => {
|
|
84
|
+
const queryClient = useQueryClient();
|
|
85
|
+
const { pushError } = useErrorBanners();
|
|
86
|
+
|
|
87
|
+
const { mutate, isPending, error, reset } = useMutation({
|
|
88
|
+
...addressDetailOperationsUpdateAddressMutation(),
|
|
89
|
+
onMutate: async (vars): Promise<ToggleTrustedContext> => {
|
|
90
|
+
const addressId = vars.path.addressId;
|
|
91
|
+
const trustedPatch = vars.body.flags?.trusted;
|
|
92
|
+
const nextTrusted = trustedPatch?.value === true;
|
|
93
|
+
|
|
94
|
+
const describePrefix = messageOperationsDescribeMessageQueryKey({
|
|
95
|
+
path: { messageId },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await queryClient.cancelQueries({ queryKey: describePrefix });
|
|
99
|
+
|
|
100
|
+
const previous = queryClient
|
|
101
|
+
.getQueriesData<RemitImapDescribeMessageResponse>({
|
|
102
|
+
queryKey: describePrefix,
|
|
103
|
+
})
|
|
104
|
+
.filter(
|
|
105
|
+
(
|
|
106
|
+
entry,
|
|
107
|
+
): entry is [readonly unknown[], RemitImapDescribeMessageResponse] =>
|
|
108
|
+
entry[1] !== undefined,
|
|
109
|
+
)
|
|
110
|
+
.map(([queryKey, data]) => ({ queryKey, data }));
|
|
111
|
+
|
|
112
|
+
queryClient.setQueriesData<RemitImapDescribeMessageResponse>(
|
|
113
|
+
{ queryKey: describePrefix },
|
|
114
|
+
(old) =>
|
|
115
|
+
old ? patchDescribeMessage(old, addressId, nextTrusted) : old,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return { describePrefix, previous };
|
|
119
|
+
},
|
|
120
|
+
onError: (err, _vars, context) => {
|
|
121
|
+
if (context) {
|
|
122
|
+
for (const entry of context.previous) {
|
|
123
|
+
queryClient.setQueryData(entry.queryKey, entry.data);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
pushError(
|
|
127
|
+
buildMutationErrorBanner(
|
|
128
|
+
"Couldn't trust sender",
|
|
129
|
+
"The change wasn't saved.",
|
|
130
|
+
err,
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
onSettled: (_data, _err, _vars, context) => {
|
|
135
|
+
if (!context) return;
|
|
136
|
+
queryClient.invalidateQueries({ queryKey: context.describePrefix });
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const toggleTrusted = (addressId: string, currentlyTrusted: boolean) => {
|
|
141
|
+
const next = !currentlyTrusted;
|
|
142
|
+
// Always send a TrustedFlag object (value: true|false). The schema
|
|
143
|
+
// nominally accepts `trusted: null` to remove the flag, but our
|
|
144
|
+
// openapi-backend validator doesn't honour `nullable: true` on a
|
|
145
|
+
// schema wrapped in `allOf: [$ref]` (which is how TypeSpec emits
|
|
146
|
+
// `TrustedFlag | null` for OAS 3.0), so the server rejected untrust
|
|
147
|
+
// requests as invalid. `{ value: false }` reads as "not trusted"
|
|
148
|
+
// everywhere (every check is `flags?.trusted?.value === true`) and
|
|
149
|
+
// also leaves an audit timestamp on the flag.
|
|
150
|
+
mutate({
|
|
151
|
+
path: { addressId },
|
|
152
|
+
body: {
|
|
153
|
+
flags: {
|
|
154
|
+
trusted: buildTrustedFlag(next),
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return { toggleTrusted, isPending, error, reset };
|
|
161
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import type { TriageAction } from "@/lib/keymap";
|
|
3
|
+
import {
|
|
4
|
+
dispatchKey,
|
|
5
|
+
isEditableTarget,
|
|
6
|
+
type SequencePrefix,
|
|
7
|
+
} from "@/lib/keymap-dispatch";
|
|
8
|
+
|
|
9
|
+
/** Map of action → handler. Omitted actions are inert (no-op). */
|
|
10
|
+
export type TriageHandlers = Partial<Record<TriageAction, () => void>>;
|
|
11
|
+
|
|
12
|
+
interface UseTriageKeyboardOptions {
|
|
13
|
+
handlers: TriageHandlers;
|
|
14
|
+
/** Disable the whole layer (e.g. a blocking modal owns the keyboard). */
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Reset window (ms) for a pending `g …` sequence prefix. After this with no
|
|
18
|
+
* second key, the prefix is dropped. ~1s per the spec.
|
|
19
|
+
*/
|
|
20
|
+
sequenceTimeoutMs?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Global keydown handler for the triage layer's VERBS (#429). Routes keystrokes
|
|
25
|
+
* through the pure {@link dispatchKey} core to the supplied handler table,
|
|
26
|
+
* staying fully inert while focus is in an editable surface (input/textarea/CE;
|
|
27
|
+
* even Esc is left to the focused field's own handler) and carrying the `g …`
|
|
28
|
+
* go-to sequence prefix across keystrokes with a timeout.
|
|
29
|
+
*
|
|
30
|
+
* Routing is intentionally NOT fully unified yet: this hook owns the action
|
|
31
|
+
* verbs + go-to keys, while the list-local navigation keys (j/k/x/Enter/d,
|
|
32
|
+
* Shift+arrow, Delete/Backspace) are still routed by MessageList's legacy
|
|
33
|
+
* `useKeyboardNavigation`, `/` by SearchBar, and `?` by the mail layout — three
|
|
34
|
+
* separate window listeners, each with its own (consistent) input-guard. The
|
|
35
|
+
* `@/lib/keymap` module is the single source of truth for the DISPLAYED
|
|
36
|
+
* bindings (the `?` overlay + tooltips), but not yet for routing. Consolidating
|
|
37
|
+
* every binding through `dispatchKey` is a follow-up; see #429.
|
|
38
|
+
*
|
|
39
|
+
* Per-action targeting (focused row vs selection) and the actual mutations live
|
|
40
|
+
* in the handlers the caller passes in — this hook only dispatches.
|
|
41
|
+
*/
|
|
42
|
+
export function useTriageKeyboard({
|
|
43
|
+
handlers,
|
|
44
|
+
enabled = true,
|
|
45
|
+
sequenceTimeoutMs = 1000,
|
|
46
|
+
}: UseTriageKeyboardOptions): void {
|
|
47
|
+
// Latest handlers without re-subscribing the listener every render.
|
|
48
|
+
const handlersRef = useRef(handlers);
|
|
49
|
+
handlersRef.current = handlers;
|
|
50
|
+
|
|
51
|
+
const prefixRef = useRef<SequencePrefix>(null);
|
|
52
|
+
const prefixTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!enabled) return;
|
|
56
|
+
|
|
57
|
+
const clearPrefixTimer = () => {
|
|
58
|
+
if (prefixTimerRef.current !== null) {
|
|
59
|
+
clearTimeout(prefixTimerRef.current);
|
|
60
|
+
prefixTimerRef.current = null;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
65
|
+
const result = dispatchKey(
|
|
66
|
+
{
|
|
67
|
+
key: event.key,
|
|
68
|
+
shiftKey: event.shiftKey,
|
|
69
|
+
metaKey: event.metaKey,
|
|
70
|
+
ctrlKey: event.ctrlKey,
|
|
71
|
+
altKey: event.altKey,
|
|
72
|
+
inEditable: isEditableTarget(event.target),
|
|
73
|
+
},
|
|
74
|
+
prefixRef.current,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Update the pending prefix and (re)arm / clear its reset timer.
|
|
78
|
+
clearPrefixTimer();
|
|
79
|
+
prefixRef.current = result.nextPrefix;
|
|
80
|
+
if (result.nextPrefix !== null) {
|
|
81
|
+
prefixTimerRef.current = setTimeout(() => {
|
|
82
|
+
prefixRef.current = null;
|
|
83
|
+
prefixTimerRef.current = null;
|
|
84
|
+
}, sequenceTimeoutMs);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (result.action === null) return;
|
|
88
|
+
|
|
89
|
+
const handler = handlersRef.current[result.action];
|
|
90
|
+
if (!handler) return;
|
|
91
|
+
|
|
92
|
+
if (result.preventDefault) event.preventDefault();
|
|
93
|
+
handler();
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
window.addEventListener("keydown", onKeyDown);
|
|
97
|
+
return () => {
|
|
98
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
99
|
+
clearPrefixTimer();
|
|
100
|
+
prefixRef.current = null;
|
|
101
|
+
};
|
|
102
|
+
}, [enabled, sequenceTimeoutMs]);
|
|
103
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
4
|
+
import { buildMailboxListKey } from "./useTriggerSync.js";
|
|
5
|
+
|
|
6
|
+
describe("buildMailboxListKey", () => {
|
|
7
|
+
test("matches the generated SDK query key for the same accountId", () => {
|
|
8
|
+
// On success the trigger-sync mutation invalidates this key — if it
|
|
9
|
+
// drifts from the generated SDK key, the freshly-synced mailbox rows
|
|
10
|
+
// will not be re-fetched. Pin the contract.
|
|
11
|
+
const accountId = "acc-1234";
|
|
12
|
+
const got = buildMailboxListKey(accountId);
|
|
13
|
+
const want = mailboxOperationsListMailboxesQueryKey({
|
|
14
|
+
path: { accountId },
|
|
15
|
+
});
|
|
16
|
+
assert.deepEqual(got, want);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("returns a different key for a different accountId", () => {
|
|
20
|
+
const a = buildMailboxListKey("acc-1");
|
|
21
|
+
const b = buildMailboxListKey("acc-2");
|
|
22
|
+
assert.notDeepEqual(a, b);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mailboxOperationsListMailboxesQueryKey,
|
|
3
|
+
syncOperationsTriggerSyncMutation,
|
|
4
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
6
|
+
import { useTelemetry } from "@/lib/telemetry-context";
|
|
7
|
+
|
|
8
|
+
interface UseTriggerSyncResult {
|
|
9
|
+
trigger: () => void;
|
|
10
|
+
triggerAsync: () => Promise<unknown>;
|
|
11
|
+
isPending: boolean;
|
|
12
|
+
error: Error | null;
|
|
13
|
+
reset: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build the TanStack query key for the mailbox-list query owned by the
|
|
18
|
+
* given account. Extracted from the hook so the staleness/invalidation
|
|
19
|
+
* contract can be unit-tested without rendering React.
|
|
20
|
+
*/
|
|
21
|
+
export const buildMailboxListKey = (accountId: string) =>
|
|
22
|
+
mailboxOperationsListMailboxesQueryKey({ path: { accountId } });
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Trigger a server-side mailbox-list sync for an account.
|
|
26
|
+
*
|
|
27
|
+
* Wraps the generated `syncOperationsTriggerSync` mutation. The backend
|
|
28
|
+
* enqueues a SYNC_MAILBOXES SQS event and the worker picks it up; the
|
|
29
|
+
* mutation resolves as soon as the enqueue ack returns, not when the
|
|
30
|
+
* sync itself finishes. We invalidate the account's mailboxes query on
|
|
31
|
+
* success so the freshly-synced rows are re-fetched once the worker
|
|
32
|
+
* writes them — TanStack will pick the new data up via its background
|
|
33
|
+
* refetch on the next render.
|
|
34
|
+
*/
|
|
35
|
+
export const useTriggerSync = (accountId: string): UseTriggerSyncResult => {
|
|
36
|
+
const queryClient = useQueryClient();
|
|
37
|
+
const telemetry = useTelemetry();
|
|
38
|
+
|
|
39
|
+
const mutation = useMutation({
|
|
40
|
+
...syncOperationsTriggerSyncMutation(),
|
|
41
|
+
onSuccess: () => {
|
|
42
|
+
telemetry.recordEvent("sync.triggered");
|
|
43
|
+
queryClient.invalidateQueries({
|
|
44
|
+
queryKey: buildMailboxListKey(accountId),
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
trigger: () => {
|
|
51
|
+
mutation.mutate({ path: { accountId } });
|
|
52
|
+
},
|
|
53
|
+
triggerAsync: () => mutation.mutateAsync({ path: { accountId } }),
|
|
54
|
+
isPending: mutation.isPending,
|
|
55
|
+
error: mutation.error,
|
|
56
|
+
reset: mutation.reset,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addressDetailOperationsUpdateAddressMutation,
|
|
3
|
+
addressOperationsSearchAddressesQueryKey,
|
|
4
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
+
import type {
|
|
6
|
+
AddressOperationsSearchAddressesResponse,
|
|
7
|
+
RemitImapAddressFlags,
|
|
8
|
+
RemitImapUpdateAddressFlagsInput,
|
|
9
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
10
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
11
|
+
import { useCallback } from "react";
|
|
12
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
13
|
+
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
14
|
+
import { reportFatalError } from "@/lib/fatal-error";
|
|
15
|
+
|
|
16
|
+
interface UseUpdateAddressFlagsOptions {
|
|
17
|
+
addressId: string | undefined;
|
|
18
|
+
/** The normalizedEmail of the sender — used to key the address search cache. */
|
|
19
|
+
senderEmail: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface MutationContext {
|
|
23
|
+
previous: AddressOperationsSearchAddressesResponse | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Apply a flag-update patch onto an existing AddressFlags object. Each key in
|
|
28
|
+
* `patch` is a flag update object (set) or `null` (remove). Mirrors the
|
|
29
|
+
* server-side merge semantics so the optimistic cache matches what the API
|
|
30
|
+
* returns. Typed loosely (the per-key flag value types differ — boolean for
|
|
31
|
+
* most, a category enum for `category`) and re-narrowed at the return.
|
|
32
|
+
*/
|
|
33
|
+
function applyFlagPatch(
|
|
34
|
+
current: RemitImapAddressFlags | undefined,
|
|
35
|
+
patch: RemitImapUpdateAddressFlagsInput,
|
|
36
|
+
): RemitImapAddressFlags {
|
|
37
|
+
const next: Record<string, unknown> = { ...(current ?? {}) };
|
|
38
|
+
for (const [key, update] of Object.entries(patch)) {
|
|
39
|
+
if (update == null) {
|
|
40
|
+
delete next[key];
|
|
41
|
+
} else {
|
|
42
|
+
// Each flag is `{ value, setAt? }`. Stamp setAt so the optimistic
|
|
43
|
+
// shape matches what the server writes back.
|
|
44
|
+
next[key] = { ...update, setAt: update.setAt ?? Date.now() };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return next as RemitImapAddressFlags;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Optimistic PATCH for per-sender flags (VIP / Mute / Block / Unsubscribe).
|
|
52
|
+
* Mirrors the `useToggleTrusted` pattern: patch the cached
|
|
53
|
+
* address-search response so the toggle flips instantly, roll back on error,
|
|
54
|
+
* and invalidate on settle to reconcile with the server.
|
|
55
|
+
*/
|
|
56
|
+
export function useUpdateAddressFlags({
|
|
57
|
+
addressId,
|
|
58
|
+
senderEmail,
|
|
59
|
+
}: UseUpdateAddressFlagsOptions) {
|
|
60
|
+
const queryClient = useQueryClient();
|
|
61
|
+
const { pushError } = useErrorBanners();
|
|
62
|
+
|
|
63
|
+
const addressCacheKey = addressOperationsSearchAddressesQueryKey({
|
|
64
|
+
query: { q: senderEmail ?? "", limit: 1 },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const { mutate, isPending } = useMutation({
|
|
68
|
+
...addressDetailOperationsUpdateAddressMutation(),
|
|
69
|
+
onMutate: async (vars): Promise<MutationContext> => {
|
|
70
|
+
const patch = vars.body.flags ?? {};
|
|
71
|
+
await queryClient.cancelQueries({ queryKey: addressCacheKey });
|
|
72
|
+
|
|
73
|
+
const previous =
|
|
74
|
+
queryClient.getQueryData<AddressOperationsSearchAddressesResponse>(
|
|
75
|
+
addressCacheKey,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
queryClient.setQueryData<AddressOperationsSearchAddressesResponse>(
|
|
79
|
+
addressCacheKey,
|
|
80
|
+
(old) => {
|
|
81
|
+
if (!old) return old;
|
|
82
|
+
return {
|
|
83
|
+
...old,
|
|
84
|
+
items: old.items.map((addr) =>
|
|
85
|
+
addr.addressId === vars.path.addressId
|
|
86
|
+
? { ...addr, flags: applyFlagPatch(addr.flags, patch) }
|
|
87
|
+
: addr,
|
|
88
|
+
),
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return { previous };
|
|
94
|
+
},
|
|
95
|
+
onError: (err, _vars, context) => {
|
|
96
|
+
if (context?.previous !== undefined) {
|
|
97
|
+
queryClient.setQueryData(addressCacheKey, context.previous);
|
|
98
|
+
}
|
|
99
|
+
pushError({
|
|
100
|
+
title: "Couldn't update sender preference",
|
|
101
|
+
detail: formatErrorDetail(err),
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
onSettled: () => {
|
|
105
|
+
queryClient.invalidateQueries({ queryKey: addressCacheKey });
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const updateFlags = useCallback(
|
|
110
|
+
(flags: RemitImapUpdateAddressFlagsInput) => {
|
|
111
|
+
if (!addressId) {
|
|
112
|
+
reportFatalError(
|
|
113
|
+
new Error(
|
|
114
|
+
`Quick action fired but the sender's address record did not resolve (sender: ${senderEmail ?? "unknown"}). This should never happen — the address row exists.`,
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
mutate({ path: { addressId }, body: { flags } });
|
|
120
|
+
},
|
|
121
|
+
[addressId, mutate, senderEmail],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return { updateFlags, isPending };
|
|
125
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { beforeEach, describe, test } from "node:test";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Unit test for useVisualViewport hook logic.
|
|
6
|
+
* Tests the core behaviour without React — mirrors the pattern used
|
|
7
|
+
* by useLongPress.test.ts in this project.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const KEYBOARD_THRESHOLD = 150;
|
|
11
|
+
|
|
12
|
+
interface ViewportState {
|
|
13
|
+
viewportHeight: number;
|
|
14
|
+
isKeyboardOpen: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Simulates the viewport-tracking logic from useVisualViewport.
|
|
19
|
+
*/
|
|
20
|
+
class VisualViewportSimulator {
|
|
21
|
+
private innerHeight: number;
|
|
22
|
+
private viewportHeight: number;
|
|
23
|
+
private listeners: Array<() => void> = [];
|
|
24
|
+
|
|
25
|
+
constructor(innerHeight: number) {
|
|
26
|
+
this.innerHeight = innerHeight;
|
|
27
|
+
this.viewportHeight = innerHeight;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getState(): ViewportState {
|
|
31
|
+
return {
|
|
32
|
+
viewportHeight: this.viewportHeight,
|
|
33
|
+
isKeyboardOpen:
|
|
34
|
+
this.innerHeight - this.viewportHeight > KEYBOARD_THRESHOLD,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Simulate a visualViewport resize (e.g. keyboard appearing). */
|
|
39
|
+
resize(newViewportHeight: number): ViewportState {
|
|
40
|
+
this.viewportHeight = newViewportHeight;
|
|
41
|
+
for (const fn of this.listeners) fn();
|
|
42
|
+
return this.getState();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
addListener(fn: () => void): void {
|
|
46
|
+
this.listeners.push(fn);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
removeListener(fn: () => void): void {
|
|
50
|
+
this.listeners = this.listeners.filter((l) => l !== fn);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get listenerCount(): number {
|
|
54
|
+
return this.listeners.length;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("useVisualViewport logic", () => {
|
|
59
|
+
let sim: VisualViewportSimulator;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
// iPhone-like viewport: 844px tall
|
|
63
|
+
sim = new VisualViewportSimulator(844);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("initial state has keyboard closed", () => {
|
|
67
|
+
const state = sim.getState();
|
|
68
|
+
assert.strictEqual(state.viewportHeight, 844);
|
|
69
|
+
assert.strictEqual(state.isKeyboardOpen, false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("keyboard open when viewport shrinks by more than threshold", () => {
|
|
73
|
+
// Typical iOS keyboard: viewport shrinks to ~400px
|
|
74
|
+
const state = sim.resize(400);
|
|
75
|
+
assert.strictEqual(state.isKeyboardOpen, true);
|
|
76
|
+
assert.strictEqual(state.viewportHeight, 400);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("keyboard stays closed for small viewport changes", () => {
|
|
80
|
+
// Address bar collapsing — only ~50px change
|
|
81
|
+
const state = sim.resize(794);
|
|
82
|
+
assert.strictEqual(state.isKeyboardOpen, false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("keyboard closes when viewport restores", () => {
|
|
86
|
+
sim.resize(400);
|
|
87
|
+
const state = sim.resize(844);
|
|
88
|
+
assert.strictEqual(state.isKeyboardOpen, false);
|
|
89
|
+
assert.strictEqual(state.viewportHeight, 844);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("exactly at threshold boundary — not open", () => {
|
|
93
|
+
// innerHeight - viewportHeight === 150, not > 150
|
|
94
|
+
const state = sim.resize(844 - KEYBOARD_THRESHOLD);
|
|
95
|
+
assert.strictEqual(state.isKeyboardOpen, false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("one pixel past threshold — keyboard open", () => {
|
|
99
|
+
const state = sim.resize(844 - KEYBOARD_THRESHOLD - 1);
|
|
100
|
+
assert.strictEqual(state.isKeyboardOpen, true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("listeners are called on resize", () => {
|
|
104
|
+
let callCount = 0;
|
|
105
|
+
sim.addListener(() => {
|
|
106
|
+
callCount++;
|
|
107
|
+
});
|
|
108
|
+
sim.resize(400);
|
|
109
|
+
sim.resize(844);
|
|
110
|
+
assert.strictEqual(callCount, 2);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("removeListener stops notifications", () => {
|
|
114
|
+
let callCount = 0;
|
|
115
|
+
const fn = () => {
|
|
116
|
+
callCount++;
|
|
117
|
+
};
|
|
118
|
+
sim.addListener(fn);
|
|
119
|
+
sim.resize(400);
|
|
120
|
+
assert.strictEqual(callCount, 1);
|
|
121
|
+
|
|
122
|
+
sim.removeListener(fn);
|
|
123
|
+
sim.resize(844);
|
|
124
|
+
assert.strictEqual(callCount, 1);
|
|
125
|
+
assert.strictEqual(sim.listenerCount, 0);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface VisualViewportState {
|
|
4
|
+
/** Current visual viewport height in pixels. */
|
|
5
|
+
viewportHeight: number;
|
|
6
|
+
/** True when the viewport has shrunk significantly (e.g. software keyboard open). */
|
|
7
|
+
isKeyboardOpen: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Minimum shrinkage (px) from window.innerHeight to consider the keyboard open. */
|
|
11
|
+
const KEYBOARD_THRESHOLD = 150;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Tracks `window.visualViewport` resize events and reports whether the
|
|
15
|
+
* software keyboard is likely open. SSR-safe — returns safe defaults
|
|
16
|
+
* when `window` or `visualViewport` are unavailable.
|
|
17
|
+
*/
|
|
18
|
+
export const useVisualViewport = (): VisualViewportState => {
|
|
19
|
+
const [state, setState] = useState<VisualViewportState>(() => {
|
|
20
|
+
if (typeof window === "undefined" || !window.visualViewport) {
|
|
21
|
+
return { viewportHeight: 0, isKeyboardOpen: false };
|
|
22
|
+
}
|
|
23
|
+
const h = window.visualViewport.height;
|
|
24
|
+
return {
|
|
25
|
+
viewportHeight: h,
|
|
26
|
+
isKeyboardOpen: window.innerHeight - h > KEYBOARD_THRESHOLD,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (typeof window === "undefined" || !window.visualViewport) return;
|
|
32
|
+
|
|
33
|
+
const vv = window.visualViewport;
|
|
34
|
+
|
|
35
|
+
const update = () => {
|
|
36
|
+
const h = vv.height;
|
|
37
|
+
setState({
|
|
38
|
+
viewportHeight: h,
|
|
39
|
+
isKeyboardOpen: window.innerHeight - h > KEYBOARD_THRESHOLD,
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Sync on mount
|
|
44
|
+
update();
|
|
45
|
+
|
|
46
|
+
vv.addEventListener("resize", update);
|
|
47
|
+
return () => vv.removeEventListener("resize", update);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
return state;
|
|
51
|
+
};
|
package/src/index.css
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* remit-ui owns @import "tailwindcss", the @theme token block, :root and
|
|
2
|
+
.dark — importing its token sheet gives the web client the full design
|
|
3
|
+
system. The @custom-variant dark declaration inside tokens.css covers
|
|
4
|
+
the .dark ancestor scope used everywhere in this app. */
|
|
5
|
+
@import "@remit/ui/tokens.css";
|
|
6
|
+
|
|
7
|
+
/* Tailwind v4 content scanning: point at the source Tailwind must inspect
|
|
8
|
+
for utility class usage (auto-detection misses linked workspace packages). */
|
|
9
|
+
@source "../../ui/src/**/*.{ts,tsx}";
|
|
10
|
+
|
|
11
|
+
/* Hide scrollbars while maintaining scroll functionality */
|
|
12
|
+
.overflow-y-auto {
|
|
13
|
+
scrollbar-width: none; /* Firefox */
|
|
14
|
+
-ms-overflow-style: none; /* IE/Edge */
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.overflow-y-auto::-webkit-scrollbar {
|
|
18
|
+
display: none; /* Chrome, Safari, Opera */
|
|
19
|
+
}
|