@remit/web-client 0.0.99 → 0.0.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.99",
3
+ "version": "0.0.100",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -15,9 +15,9 @@ import { Drawer } from "vaul";
15
15
  import { ErrorState } from "@/components/ui/ErrorState";
16
16
  import { useFolderAppointments } from "@/hooks/useArchiveMailbox";
17
17
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
18
+ import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
18
19
  import { useIsDesktop } from "@/hooks/useMediaQuery";
19
- import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
20
- import { buildMoveTargets } from "@/lib/move-targets";
20
+ import { buildMoveOptions } from "@/lib/move-options";
21
21
  import { cn } from "@/lib/utils";
22
22
 
23
23
  interface MoveToTriggerProps {
@@ -71,15 +71,7 @@ export const MoveToTrigger = ({
71
71
  const triggerLabel = label ?? "Move to folder";
72
72
  const popoverId = useId();
73
73
  const { t } = useTranslation("mail", { useSuspense: false });
74
- // `labelForMailbox` expects a translator with a positional `(key,
75
- // fallback)` shape; i18next's `t` treats the second argument as an options
76
- // object — passing it raw breaks fallback behavior. Wrap it the same way
77
- // the sidebar adapter does, memoized so it's a stable `useMemo` dependency.
78
- const translator = useCallback(
79
- (key: string, fallback: string): string =>
80
- t(key, { defaultValue: fallback }),
81
- [t],
82
- );
74
+ const translator = useFolderLabelTranslator();
83
75
 
84
76
  const {
85
77
  data: mailboxesResponse,
@@ -98,28 +90,21 @@ export const MoveToTrigger = ({
98
90
  const folderAppointments = useFolderAppointments(accountId);
99
91
  const { createFolder } = useCreateMailbox(accountId);
100
92
 
101
- const options = useMemo<MoveMailboxOption[]>(() => {
102
- const targets = buildMoveTargets(
103
- mailboxesResponse?.items ?? [],
104
- folderAppointments,
105
- );
106
- const roleMap = buildMailboxRoleMap(folderAppointments);
107
- return targets.map((mailbox) => ({
108
- id: mailbox.mailboxId,
109
- label: labelForMailbox(
110
- mailbox,
111
- roleMap.get(mailbox.mailboxId),
93
+ const options = useMemo<MoveMailboxOption[]>(
94
+ () =>
95
+ buildMoveOptions({
96
+ mailboxes: mailboxesResponse?.items ?? [],
97
+ folderAppointments,
98
+ currentMailboxId,
112
99
  translator,
113
- ),
114
- searchValue: mailbox.fullPath,
115
- isCurrent: mailbox.mailboxId === currentMailboxId,
116
- }));
117
- }, [
118
- mailboxesResponse?.items,
119
- folderAppointments,
120
- currentMailboxId,
121
- translator,
122
- ]);
100
+ }),
101
+ [
102
+ mailboxesResponse?.items,
103
+ folderAppointments,
104
+ currentMailboxId,
105
+ translator,
106
+ ],
107
+ );
123
108
 
124
109
  const handleSelect = useCallback(
125
110
  (destinationMailboxId: string) => {
@@ -41,16 +41,16 @@ import {
41
41
  useEscalatedActions,
42
42
  } from "@/hooks/useEscalatedActions";
43
43
  import { useCreateFilter } from "@/hooks/useFilters";
44
+ import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
44
45
  import { useMatchSample, useSearchMatchSample } from "@/hooks/useMatchSample";
45
46
  import { useOrganizeJob } from "@/hooks/useOrganizeJob";
46
47
  import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
47
48
  import { useRulePreview } from "@/hooks/useRulePreview";
48
49
  import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
49
50
  import type { BulkActionProgress, BulkRunOutcome } from "@/lib/bulk-actions";
50
- import { getMailboxDisplayName } from "@/lib/folder-roles";
51
51
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
52
52
  import { useMailContext } from "@/lib/mail-context";
53
- import { buildMoveTargets } from "@/lib/move-targets";
53
+ import { buildMoveOptions } from "@/lib/move-options";
54
54
  import {
55
55
  buildWizardDraft,
56
56
  canBackApplyDraft,
@@ -387,17 +387,16 @@ function SelectionWizardSession({
387
387
  staleTime: Number.POSITIVE_INFINITY,
388
388
  });
389
389
  const folderAppointments = useFolderAppointments(accountId);
390
+ const translator = useFolderLabelTranslator();
390
391
  const mailboxes = useMemo<MoveMailboxOption[]>(
391
392
  () =>
392
- buildMoveTargets(mailboxesData?.items ?? [], folderAppointments).map(
393
- (mailbox) => ({
394
- id: mailbox.mailboxId,
395
- label: getMailboxDisplayName(mailbox.fullPath),
396
- searchValue: mailbox.fullPath,
397
- isCurrent: mailbox.mailboxId === mailboxId,
398
- }),
399
- ),
400
- [mailboxesData?.items, folderAppointments, mailboxId],
393
+ buildMoveOptions({
394
+ mailboxes: mailboxesData?.items ?? [],
395
+ folderAppointments,
396
+ currentMailboxId: mailboxId,
397
+ translator,
398
+ }),
399
+ [mailboxesData?.items, folderAppointments, mailboxId, translator],
401
400
  );
402
401
  const { createFolder } = useCreateMailbox(accountId);
403
402
 
@@ -14,13 +14,12 @@ import {
14
14
  useRef,
15
15
  useState,
16
16
  } from "react";
17
- import { useTranslation } from "react-i18next";
18
17
  import {
19
18
  useFolderAppointments,
20
19
  useInboxMailbox,
21
20
  } from "@/hooks/useArchiveMailbox";
22
- import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
23
- import { buildMoveTargets } from "@/lib/move-targets";
21
+ import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
22
+ import { buildMoveOptions } from "@/lib/move-options";
24
23
  import {
25
24
  recordRescueCandidatesSurfaced,
26
25
  recordRescueCommitted,
@@ -51,12 +50,7 @@ export function SpamRescue({
51
50
  }: SpamRescueProps) {
52
51
  const [open, setOpen] = useState(false);
53
52
  const telemetry = useTelemetry();
54
- const { t } = useTranslation("mail", { useSuspense: false });
55
- const translator = useCallback(
56
- (key: string, fallback: string): string =>
57
- t(key, { defaultValue: fallback }),
58
- [t],
59
- );
53
+ const translator = useFolderLabelTranslator();
60
54
 
61
55
  const { inboxMailboxId } = useInboxMailbox(accountId);
62
56
  const folderAppointments = useFolderAppointments(accountId);
@@ -66,28 +60,21 @@ export function SpamRescue({
66
60
  staleTime: Infinity,
67
61
  });
68
62
 
69
- const folders = useMemo<MoveMailboxOption[]>(() => {
70
- const targets = buildMoveTargets(
71
- mailboxesResponse?.items ?? [],
72
- folderAppointments,
73
- );
74
- const roleMap = buildMailboxRoleMap(folderAppointments);
75
- return targets.map((mailbox) => ({
76
- id: mailbox.mailboxId,
77
- label: labelForMailbox(
78
- mailbox,
79
- roleMap.get(mailbox.mailboxId),
63
+ const folders = useMemo<MoveMailboxOption[]>(
64
+ () =>
65
+ buildMoveOptions({
66
+ mailboxes: mailboxesResponse?.items ?? [],
67
+ folderAppointments,
68
+ currentMailboxId,
80
69
  translator,
81
- ),
82
- searchValue: mailbox.fullPath,
83
- isCurrent: mailbox.mailboxId === currentMailboxId,
84
- }));
85
- }, [
86
- mailboxesResponse?.items,
87
- folderAppointments,
88
- currentMailboxId,
89
- translator,
90
- ]);
70
+ }),
71
+ [
72
+ mailboxesResponse?.items,
73
+ folderAppointments,
74
+ currentMailboxId,
75
+ translator,
76
+ ],
77
+ );
91
78
 
92
79
  const defaultDestinationId = useMemo(() => {
93
80
  if (inboxMailboxId) return inboxMailboxId;
@@ -13,13 +13,10 @@ import { AlertTriangle, FolderInput, Loader2, Trash2, X } from "lucide-react";
13
13
  import { useCallback, useEffect, useMemo, useState } from "react";
14
14
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
15
15
  import { useDeleteFolder } from "@/hooks/useDeleteFolder";
16
- import {
17
- excludeFolder,
18
- initialStage,
19
- moveProgressLabel,
20
- } from "@/lib/delete-folder";
16
+ import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
17
+ import { initialStage, moveProgressLabel } from "@/lib/delete-folder";
21
18
  import { getMailboxDisplayName } from "@/lib/folder-roles";
22
- import { buildMoveTargets } from "@/lib/move-targets";
19
+ import { buildMoveOptions } from "@/lib/move-options";
23
20
 
24
21
  interface DeleteFolderDialogProps {
25
22
  open: boolean;
@@ -61,6 +58,7 @@ export function DeleteFolderDialog({
61
58
  initialStage(folder.messageCount),
62
59
  );
63
60
  const { createFolder } = useCreateMailbox(accountId);
61
+ const translator = useFolderLabelTranslator();
64
62
  const {
65
63
  phase,
66
64
  progress,
@@ -90,15 +88,13 @@ export function DeleteFolderDialog({
90
88
 
91
89
  const destinations = useMemo<MoveMailboxOption[]>(
92
90
  () =>
93
- excludeFolder(
94
- buildMoveTargets(mailboxes, appointments),
95
- folder.mailboxId,
96
- ).map((mailbox) => ({
97
- id: mailbox.mailboxId,
98
- label: getMailboxDisplayName(mailbox.fullPath),
99
- searchValue: mailbox.fullPath,
100
- })),
101
- [mailboxes, appointments, folder.mailboxId],
91
+ buildMoveOptions({
92
+ mailboxes,
93
+ folderAppointments: appointments,
94
+ excludeMailboxId: folder.mailboxId,
95
+ translator,
96
+ }),
97
+ [mailboxes, appointments, folder.mailboxId, translator],
102
98
  );
103
99
 
104
100
  const handleCreateFolder = async (
@@ -0,0 +1,19 @@
1
+ import { useCallback } from "react";
2
+ import { useTranslation } from "react-i18next";
3
+
4
+ /**
5
+ * `labelForMailbox` expects a positional `(key, fallback)` translator; i18next's
6
+ * `t` reads its second argument as an options object, which drops the fallback.
7
+ * Memoized so callers can hold it as a `useMemo` dependency.
8
+ */
9
+ export const useFolderLabelTranslator = (): ((
10
+ key: string,
11
+ fallback: string,
12
+ ) => string) => {
13
+ const { t } = useTranslation("mail", { useSuspense: false });
14
+ return useCallback(
15
+ (key: string, fallback: string): string =>
16
+ t(key, { defaultValue: fallback }),
17
+ [t],
18
+ );
19
+ };
@@ -0,0 +1,140 @@
1
+ import assert from "node:assert";
2
+ import { readFileSync } from "node:fs";
3
+ import { describe, test } from "node:test";
4
+ import { fileURLToPath } from "node:url";
5
+ import type {
6
+ RemitImapFolderAppointment,
7
+ RemitImapMailboxResponse,
8
+ } from "@remit/api-http-client/types.gen.ts";
9
+ import { buildMoveOptions } from "./move-options.js";
10
+
11
+ const englishBundle = JSON.parse(
12
+ readFileSync(
13
+ fileURLToPath(
14
+ new URL("../../public/locales/en/mail.json", import.meta.url),
15
+ ),
16
+ "utf8",
17
+ ),
18
+ ) as { sidebar: Record<string, string> };
19
+
20
+ // The picker is handed the same shipped English bundle the app loads, so the
21
+ // expected labels are the product's own, not strings restated by the test.
22
+ const translate = (key: string, fallback: string): string =>
23
+ englishBundle.sidebar[key.replace("sidebar.", "")] ?? fallback;
24
+
25
+ const make = (
26
+ overrides: Partial<RemitImapMailboxResponse> & {
27
+ mailboxId: string;
28
+ fullPath: string;
29
+ },
30
+ ): RemitImapMailboxResponse =>
31
+ ({
32
+ accountId: "acct-1",
33
+ namespaceType: "personal",
34
+ namespacePrefix: "",
35
+ hierarchyDelimiter: "/",
36
+ messageCount: 0,
37
+ unseenCount: 0,
38
+ deletedCount: 0,
39
+ createdAt: 0,
40
+ updatedAt: 0,
41
+ ...overrides,
42
+ }) as RemitImapMailboxResponse;
43
+
44
+ const appoint = (
45
+ role: RemitImapFolderAppointment["role"],
46
+ mailboxId: string,
47
+ ): RemitImapFolderAppointment => ({ role, mailboxId });
48
+
49
+ const applePaths = [
50
+ make({ mailboxId: "mb-inbox", fullPath: "INBOX" }),
51
+ make({ mailboxId: "mb-trash", fullPath: "INBOX/Deleted Messages" }),
52
+ make({ mailboxId: "mb-junk", fullPath: "INBOX/Junk" }),
53
+ make({ mailboxId: "mb-receipts", fullPath: "INBOX/Receipts" }),
54
+ ];
55
+
56
+ const appleAppointments = [
57
+ appoint("Inbox", "mb-inbox"),
58
+ appoint("Trash", "mb-trash"),
59
+ appoint("Junk", "mb-junk"),
60
+ ];
61
+
62
+ const labelOf = (
63
+ options: ReturnType<typeof buildMoveOptions>,
64
+ id: string,
65
+ ): string | undefined => options.find((option) => option.id === id)?.label;
66
+
67
+ describe("buildMoveOptions", () => {
68
+ test("labels appointed folders by role, not by the provider's leaf", () => {
69
+ const options = buildMoveOptions({
70
+ mailboxes: applePaths,
71
+ folderAppointments: appleAppointments,
72
+ translator: translate,
73
+ });
74
+ assert.equal(labelOf(options, "mb-inbox"), "Inbox");
75
+ assert.equal(labelOf(options, "mb-trash"), "Trash");
76
+ assert.equal(labelOf(options, "mb-junk"), "Spam");
77
+ });
78
+
79
+ test("a displayNameOverride wins over the role label", () => {
80
+ const options = buildMoveOptions({
81
+ mailboxes: [
82
+ make({
83
+ mailboxId: "mb-trash",
84
+ fullPath: "INBOX/Deleted Messages",
85
+ displayNameOverride: "Bin",
86
+ }),
87
+ ],
88
+ folderAppointments: [appoint("Trash", "mb-trash")],
89
+ translator: translate,
90
+ });
91
+ assert.equal(labelOf(options, "mb-trash"), "Bin");
92
+ });
93
+
94
+ test("an unappointed folder keeps its own name", () => {
95
+ const options = buildMoveOptions({
96
+ mailboxes: applePaths,
97
+ folderAppointments: appleAppointments,
98
+ translator: translate,
99
+ });
100
+ assert.equal(labelOf(options, "mb-receipts"), "Receipts");
101
+ });
102
+
103
+ test("the full provider path stays searchable", () => {
104
+ const options = buildMoveOptions({
105
+ mailboxes: applePaths,
106
+ folderAppointments: appleAppointments,
107
+ translator: translate,
108
+ });
109
+ assert.equal(
110
+ options.find((option) => option.id === "mb-trash")?.searchValue,
111
+ "INBOX/Deleted Messages",
112
+ );
113
+ });
114
+
115
+ test("the current mailbox is marked and nothing else is", () => {
116
+ const options = buildMoveOptions({
117
+ mailboxes: applePaths,
118
+ folderAppointments: appleAppointments,
119
+ currentMailboxId: "mb-junk",
120
+ translator: translate,
121
+ });
122
+ assert.deepStrictEqual(
123
+ options.filter((option) => option.isCurrent).map((option) => option.id),
124
+ ["mb-junk"],
125
+ );
126
+ });
127
+
128
+ test("an excluded mailbox is not offered as a destination", () => {
129
+ const options = buildMoveOptions({
130
+ mailboxes: applePaths,
131
+ folderAppointments: appleAppointments,
132
+ excludeMailboxId: "mb-receipts",
133
+ translator: translate,
134
+ });
135
+ assert.equal(
136
+ options.some((option) => option.id === "mb-receipts"),
137
+ false,
138
+ );
139
+ });
140
+ });
@@ -0,0 +1,46 @@
1
+ import type {
2
+ RemitImapFolderAppointment,
3
+ RemitImapMailboxResponse,
4
+ } from "@remit/api-http-client/types.gen.ts";
5
+ import type { MoveMailboxOption } from "@remit/ui";
6
+ import { excludeFolder } from "./delete-folder.js";
7
+ import { buildMailboxRoleMap, labelForMailbox } from "./folder-roles.js";
8
+ import { buildMoveTargets } from "./move-targets.js";
9
+
10
+ type Translator = (key: string, fallback: string) => string;
11
+
12
+ interface MoveOptionsInput {
13
+ mailboxes: readonly RemitImapMailboxResponse[];
14
+ folderAppointments: readonly RemitImapFolderAppointment[];
15
+ /** Rendered as a non-selectable row, so the user sees where mail lives now. */
16
+ currentMailboxId?: string;
17
+ /** Dropped entirely — the folder being deleted is not its own destination. */
18
+ excludeMailboxId?: string;
19
+ translator?: Translator;
20
+ }
21
+
22
+ /**
23
+ * The single shaping of a mailbox list into Move-to picker options. Labels
24
+ * follow the account's role appointments (RFC 032, #976) and any
25
+ * `displayNameOverride`, so a picker reads `Inbox`/`Trash` rather than the
26
+ * provider's leaf; the full path stays searchable.
27
+ */
28
+ export const buildMoveOptions = ({
29
+ mailboxes,
30
+ folderAppointments,
31
+ currentMailboxId,
32
+ excludeMailboxId,
33
+ translator,
34
+ }: MoveOptionsInput): MoveMailboxOption[] => {
35
+ const targets = buildMoveTargets(mailboxes, folderAppointments);
36
+ const roleMap = buildMailboxRoleMap(folderAppointments);
37
+ const destinations = excludeMailboxId
38
+ ? excludeFolder(targets, excludeMailboxId)
39
+ : targets;
40
+ return destinations.map((mailbox) => ({
41
+ id: mailbox.mailboxId,
42
+ label: labelForMailbox(mailbox, roleMap.get(mailbox.mailboxId), translator),
43
+ searchValue: mailbox.fullPath,
44
+ isCurrent: mailbox.mailboxId === currentMailboxId,
45
+ }));
46
+ };