@remit/web-client 0.0.238 → 0.0.240

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.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * A calendar's secret subscription address, as the settings surface holds it
3
+ * (issue #1067).
4
+ *
5
+ * The plaintext token exists in exactly one place: the answer to the write that
6
+ * minted it. It is kept here until the reader dismisses it and is never read
7
+ * back — the server stores a hash, so a lost address is rotated rather than
8
+ * recovered.
9
+ *
10
+ * The read owns its 404 and its 403 and nothing else. "This calendar has no
11
+ * feed" and "this calendar is not yours" are both answers the card states where
12
+ * it stands — the 404 as the offer to create one, the 403 as "couldn't read
13
+ * whether it is shared", never as "not shared yet". A 401 is the session gone,
14
+ * and no banner on this card signs anyone back in, so it escalates.
15
+ */
16
+ import {
17
+ calendarDetailOperationsGetCalendarFeedOptions,
18
+ calendarDetailOperationsGetCalendarFeedQueryKey,
19
+ calendarDetailOperationsPutCalendarFeedMutation,
20
+ calendarDetailOperationsRevokeCalendarFeedMutation,
21
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
22
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
23
+ import { useCallback, useRef, useState } from "react";
24
+ import { calendarFeedUrl } from "@/lib/calendar-feed-url";
25
+ import {
26
+ isNotFound,
27
+ softErrorMeta,
28
+ softErrorStatuses,
29
+ } from "@/lib/error-classifier";
30
+
31
+ export type CalendarFeedState =
32
+ | { status: "loading" }
33
+ /** The server answered, and this calendar is not shared. */
34
+ | { status: "absent" }
35
+ | { status: "active"; createdAt: number; rotatedAt: number }
36
+ /** The server refused to say, which is not the same as "not shared". */
37
+ | { status: "unreadable"; error: unknown };
38
+
39
+ export interface CalendarFeedControls {
40
+ state: CalendarFeedState;
41
+ /** The address just minted, shown once. Empty at every other moment. */
42
+ mintedUrl: string;
43
+ isBusy: boolean;
44
+ /** A create, rotate or revoke the server turned down. */
45
+ actionError: unknown;
46
+ mint: () => void;
47
+ revoke: () => void;
48
+ dismissMinted: () => void;
49
+ retry: () => void;
50
+ }
51
+
52
+ export function useCalendarFeed(calendarId: string): CalendarFeedControls {
53
+ const queryClient = useQueryClient();
54
+ const [mintedUrl, setMintedUrl] = useState("");
55
+
56
+ const queryKey = calendarDetailOperationsGetCalendarFeedQueryKey({
57
+ path: { calendarId },
58
+ });
59
+
60
+ const query = useQuery({
61
+ ...calendarDetailOperationsGetCalendarFeedOptions({
62
+ path: { calendarId },
63
+ }),
64
+ meta: softErrorStatuses(404, 403),
65
+ retry: false,
66
+ });
67
+
68
+ const invalidate = () => {
69
+ queryClient.invalidateQueries({ queryKey });
70
+ };
71
+
72
+ // Each write clears the other's refusal as it starts, so the card never
73
+ // states a failure the reader has already written past. Only one of the two
74
+ // can name the other directly; the revoke is declared first and reaches
75
+ // forward through this ref.
76
+ const resetMint = useRef<() => void>(() => {});
77
+
78
+ const revokeMutation = useMutation({
79
+ ...calendarDetailOperationsRevokeCalendarFeedMutation(),
80
+ meta: softErrorMeta,
81
+ onMutate: () => {
82
+ resetMint.current();
83
+ },
84
+ onSuccess: () => {
85
+ setMintedUrl("");
86
+ invalidate();
87
+ },
88
+ });
89
+
90
+ const mintMutation = useMutation({
91
+ ...calendarDetailOperationsPutCalendarFeedMutation(),
92
+ meta: softErrorMeta,
93
+ // The answer to this write is the plaintext token. Nothing may hold it
94
+ // once the surface showing it lets go, so it is collected the moment the
95
+ // observer detaches — on dismissal, and on leaving the page — instead of
96
+ // sitting in the mutation cache for the default five minutes.
97
+ gcTime: 0,
98
+ onMutate: () => {
99
+ revokeMutation.reset();
100
+ },
101
+ onSuccess: (data) => {
102
+ setMintedUrl(calendarFeedUrl(window.location.host, data.feedToken));
103
+ invalidate();
104
+ },
105
+ });
106
+
107
+ resetMint.current = mintMutation.reset;
108
+
109
+ const { mutate: mintFeed, reset: resetMintMutation } = mintMutation;
110
+ const { mutate: revokeFeed } = revokeMutation;
111
+
112
+ const mint = useCallback(() => {
113
+ mintFeed({ path: { calendarId } });
114
+ }, [calendarId, mintFeed]);
115
+
116
+ const revoke = useCallback(() => {
117
+ revokeFeed({ path: { calendarId } });
118
+ }, [calendarId, revokeFeed]);
119
+
120
+ // Resetting the mutation drops the answer that carried the plaintext token,
121
+ // which otherwise sits in the mutation cache for its whole gcTime after the
122
+ // reader has said they saved it.
123
+ const dismissMinted = useCallback(() => {
124
+ setMintedUrl("");
125
+ resetMintMutation();
126
+ }, [resetMintMutation]);
127
+
128
+ const { refetch } = query;
129
+ const retry = useCallback(() => {
130
+ refetch();
131
+ }, [refetch]);
132
+
133
+ return {
134
+ state: readState(query),
135
+ mintedUrl,
136
+ isBusy: mintMutation.isPending || revokeMutation.isPending,
137
+ actionError: mintMutation.error ?? revokeMutation.error,
138
+ mint,
139
+ revoke,
140
+ dismissMinted,
141
+ retry,
142
+ };
143
+ }
144
+
145
+ interface FeedQuery {
146
+ data?: { createdAt: number; rotatedAt: number };
147
+ error: unknown;
148
+ isPending: boolean;
149
+ }
150
+
151
+ function readState(query: FeedQuery): CalendarFeedState {
152
+ if (query.error) {
153
+ if (isNotFound(query.error)) return { status: "absent" };
154
+ return { status: "unreadable", error: query.error };
155
+ }
156
+ if (query.isPending) return { status: "loading" };
157
+ if (!query.data) return { status: "absent" };
158
+ return {
159
+ status: "active",
160
+ createdAt: query.data.createdAt,
161
+ rotatedAt: query.data.rotatedAt,
162
+ };
163
+ }
@@ -3,6 +3,10 @@
3
3
  * and `useMailboxNameIndex` run — cached forever, react-query dedupes the
4
4
  * identical key across call sites) and reduces it to the mailboxId → folder map
5
5
  * search results resolve their provenance against. See `lib/result-folder.ts`.
6
+ *
7
+ * The read state travels with the index: a folder label cut on a delimiter that
8
+ * has not arrived is wrong rather than late, so a surface that names folders can
9
+ * wait for this, and say so when it fails.
6
10
  */
7
11
  import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
12
  import type {
@@ -18,37 +22,63 @@ import {
18
22
 
19
23
  type MailboxItems = RemitImapMailboxResponse[];
20
24
 
25
+ interface MailboxListsState {
26
+ items: MailboxItems[];
27
+ isPending: boolean;
28
+ error: Error | null;
29
+ }
30
+
31
+ export interface ResultFolderIndexState {
32
+ index: ResultFolderIndex;
33
+ isPending: boolean;
34
+ error: Error | null;
35
+ }
36
+
21
37
  /**
22
38
  * Hoisted, not inline: `useQueries` skips re-running `combine` when it is given
23
39
  * the same function, so a stable reference is what makes this a memo rather than
24
40
  * a per-render reduce. Without it the index — and every search-result memo keyed
25
41
  * on it — rebuilds on every render.
26
42
  */
27
- const combineMailboxItems = (
28
- results: { data?: { items: MailboxItems } }[],
29
- ): MailboxItems[] => results.map((result) => result.data?.items ?? []);
43
+ const combineMailboxLists = (
44
+ results: {
45
+ data?: { items: MailboxItems };
46
+ isPending: boolean;
47
+ error: Error | null;
48
+ }[],
49
+ ): MailboxListsState => ({
50
+ items: results.map((result) => result.data?.items ?? []),
51
+ isPending: results.some((result) => result.isPending),
52
+ error: results.find((result) => result.error)?.error ?? null,
53
+ });
30
54
 
31
55
  export function useResultFolderIndex(
32
56
  accounts: RemitImapAccountResponse[],
33
- ): ResultFolderIndex {
34
- const mailboxItems = useQueries({
57
+ ): ResultFolderIndexState {
58
+ const mailboxLists = useQueries({
35
59
  queries: accounts.map((account) => ({
36
60
  ...mailboxOperationsListMailboxesOptions({
37
61
  path: { accountId: account.accountId },
38
62
  }),
39
63
  staleTime: Infinity,
40
64
  })),
41
- combine: combineMailboxItems,
65
+ combine: combineMailboxLists,
42
66
  });
43
67
 
44
- return useMemo(
68
+ const index = useMemo(
45
69
  () =>
46
70
  buildResultFolderIndex(
47
71
  accounts.map((account, i) => ({
48
72
  folderAppointments: account.folderAppointments,
49
- mailboxes: mailboxItems[i] ?? [],
73
+ mailboxes: mailboxLists.items[i] ?? [],
50
74
  })),
51
75
  ),
52
- [accounts, mailboxItems],
76
+ [accounts, mailboxLists],
53
77
  );
78
+
79
+ return {
80
+ index,
81
+ isPending: mailboxLists.isPending,
82
+ error: mailboxLists.error,
83
+ };
54
84
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * What the two destructive feed actions say before they are taken.
3
+ *
4
+ * Both take a working subscription away from whoever holds the address, and
5
+ * neither can be undone by re-minting — the new address is a different secret.
6
+ * The copy names the calendar, so a confirmation opened from the wrong row is
7
+ * visibly the wrong one.
8
+ */
9
+
10
+ export interface FeedConfirmCopy {
11
+ title: string;
12
+ description: string;
13
+ }
14
+
15
+ export const rotateFeedConfirmCopy = (
16
+ calendarName: string,
17
+ ): FeedConfirmCopy => ({
18
+ title: `Replace the address for ${calendarName}?`,
19
+ description:
20
+ "Every client already subscribed to the current address stops updating and has to be pointed at the new one. The new address is shown once.",
21
+ });
22
+
23
+ export const revokeFeedConfirmCopy = (
24
+ calendarName: string,
25
+ ): FeedConfirmCopy => ({
26
+ title: `Stop sharing ${calendarName}?`,
27
+ description:
28
+ "The address stops working immediately and every client subscribed to it stops updating. Sharing again mints a different address.",
29
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The address a subscriber points its calendar client at (issue #1067).
3
+ *
4
+ * `webcal://` rather than `https://`: Apple Calendar, Outlook and Thunderbird
5
+ * hand a `webcal:` link straight to their subscription dialog, where an https
6
+ * one downloads a file that never refreshes. The host carries its port, so a
7
+ * deployment that is not on 443 is subscribable on the same terms.
8
+ */
9
+
10
+ const FEED_PATH_PREFIX = "/feeds/calendar/";
11
+ const FEED_PATH_SUFFIX = ".ics";
12
+
13
+ export const calendarFeedUrl = (host: string, feedToken: string): string =>
14
+ `webcal://${host}${FEED_PATH_PREFIX}${feedToken}${FEED_PATH_SUFFIX}`;
@@ -8,9 +8,11 @@ import { CANONICAL_TO_NAV_ROLE } from "./folder-roles";
8
8
  * canonical PascalCase the API uses everywhere. Both differences are resolved
9
9
  * here rather than by widening the kit's entry type.
10
10
  *
11
- * The record carries no hierarchy delimiter either, so the caller resolves the
12
- * account's own from its mailbox list and hands it in — the row shows a folder
13
- * name, and a path cannot be cut into one without it.
11
+ * The record carries no hierarchy delimiter either, so the caller joins the
12
+ * mailbox id against the account's own mailbox list and hands the delimiter in
13
+ * — the row shows a folder name, and a path cannot be cut into one without it.
14
+ * An empty delimiter means no account lists that mailbox, and the row keeps its
15
+ * whole path rather than being cut on a guess.
14
16
  */
15
17
  export function toQuarantineEntry(
16
18
  wire: RemitImapQuarantineResponse,
@@ -25,6 +25,7 @@ import { Route as SettingsIndexRouteImport } from './routes/settings/index'
25
25
  import { Route as SettingsAccountsRouteImport } from './routes/settings/accounts'
26
26
  import { Route as SettingsAdvancedRouteImport } from './routes/settings/advanced'
27
27
  import { Route as SettingsAppearanceRouteImport } from './routes/settings/appearance'
28
+ import { Route as SettingsCalendarsRouteImport } from './routes/settings/calendars'
28
29
  import { Route as SettingsFiltersRouteImport } from './routes/settings/filters'
29
30
  import { Route as SettingsFoldersRouteImport } from './routes/settings/folders'
30
31
  import { Route as SettingsLabelsRouteImport } from './routes/settings/labels'
@@ -138,6 +139,11 @@ const SettingsAppearanceRoute = SettingsAppearanceRouteImport.update({
138
139
  path: '/appearance',
139
140
  getParentRoute: () => SettingsRoute,
140
141
  } as any)
142
+ const SettingsCalendarsRoute = SettingsCalendarsRouteImport.update({
143
+ id: '/calendars',
144
+ path: '/calendars',
145
+ getParentRoute: () => SettingsRoute,
146
+ } as any)
141
147
  const SettingsFiltersRoute = SettingsFiltersRouteImport.update({
142
148
  id: '/filters',
143
149
  path: '/filters',
@@ -335,6 +341,7 @@ export interface FileRoutesByFullPath {
335
341
  '/settings/accounts': typeof SettingsAccountsRoute
336
342
  '/settings/advanced': typeof SettingsAdvancedRoute
337
343
  '/settings/appearance': typeof SettingsAppearanceRoute
344
+ '/settings/calendars': typeof SettingsCalendarsRoute
338
345
  '/settings/filters': typeof SettingsFiltersRoute
339
346
  '/settings/folders': typeof SettingsFoldersRoute
340
347
  '/settings/labels': typeof SettingsLabelsRoute
@@ -378,6 +385,7 @@ export interface FileRoutesByTo {
378
385
  '/settings/accounts': typeof SettingsAccountsRoute
379
386
  '/settings/advanced': typeof SettingsAdvancedRoute
380
387
  '/settings/appearance': typeof SettingsAppearanceRoute
388
+ '/settings/calendars': typeof SettingsCalendarsRoute
381
389
  '/settings/filters': typeof SettingsFiltersRoute
382
390
  '/settings/folders': typeof SettingsFoldersRoute
383
391
  '/settings/labels': typeof SettingsLabelsRoute
@@ -424,6 +432,7 @@ export interface FileRoutesById {
424
432
  '/settings/accounts': typeof SettingsAccountsRoute
425
433
  '/settings/advanced': typeof SettingsAdvancedRoute
426
434
  '/settings/appearance': typeof SettingsAppearanceRoute
435
+ '/settings/calendars': typeof SettingsCalendarsRoute
427
436
  '/settings/filters': typeof SettingsFiltersRoute
428
437
  '/settings/folders': typeof SettingsFoldersRoute
429
438
  '/settings/labels': typeof SettingsLabelsRoute
@@ -476,6 +485,7 @@ export interface FileRouteTypes {
476
485
  | '/settings/accounts'
477
486
  | '/settings/advanced'
478
487
  | '/settings/appearance'
488
+ | '/settings/calendars'
479
489
  | '/settings/filters'
480
490
  | '/settings/folders'
481
491
  | '/settings/labels'
@@ -519,6 +529,7 @@ export interface FileRouteTypes {
519
529
  | '/settings/accounts'
520
530
  | '/settings/advanced'
521
531
  | '/settings/appearance'
532
+ | '/settings/calendars'
522
533
  | '/settings/filters'
523
534
  | '/settings/folders'
524
535
  | '/settings/labels'
@@ -564,6 +575,7 @@ export interface FileRouteTypes {
564
575
  | '/settings/accounts'
565
576
  | '/settings/advanced'
566
577
  | '/settings/appearance'
578
+ | '/settings/calendars'
567
579
  | '/settings/filters'
568
580
  | '/settings/folders'
569
581
  | '/settings/labels'
@@ -724,6 +736,13 @@ declare module '@tanstack/react-router' {
724
736
  preLoaderRoute: typeof SettingsAppearanceRouteImport
725
737
  parentRoute: typeof SettingsRoute
726
738
  }
739
+ '/settings/calendars': {
740
+ id: '/settings/calendars'
741
+ path: '/calendars'
742
+ fullPath: '/settings/calendars'
743
+ preLoaderRoute: typeof SettingsCalendarsRouteImport
744
+ parentRoute: typeof SettingsRoute
745
+ }
727
746
  '/settings/filters': {
728
747
  id: '/settings/filters'
729
748
  path: '/filters'
@@ -1177,6 +1196,7 @@ interface SettingsRouteChildren {
1177
1196
  SettingsAccountsRoute: typeof SettingsAccountsRoute
1178
1197
  SettingsAdvancedRoute: typeof SettingsAdvancedRoute
1179
1198
  SettingsAppearanceRoute: typeof SettingsAppearanceRoute
1199
+ SettingsCalendarsRoute: typeof SettingsCalendarsRoute
1180
1200
  SettingsFiltersRoute: typeof SettingsFiltersRoute
1181
1201
  SettingsFoldersRoute: typeof SettingsFoldersRoute
1182
1202
  SettingsLabelsRoute: typeof SettingsLabelsRoute
@@ -1189,6 +1209,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {
1189
1209
  SettingsAccountsRoute: SettingsAccountsRoute,
1190
1210
  SettingsAdvancedRoute: SettingsAdvancedRoute,
1191
1211
  SettingsAppearanceRoute: SettingsAppearanceRoute,
1212
+ SettingsCalendarsRoute: SettingsCalendarsRoute,
1192
1213
  SettingsFiltersRoute: SettingsFiltersRoute,
1193
1214
  SettingsFoldersRoute: SettingsFoldersRoute,
1194
1215
  SettingsLabelsRoute: SettingsLabelsRoute,
@@ -173,7 +173,7 @@ function MailLayout() {
173
173
  [accounts],
174
174
  );
175
175
  const mailboxNameIndex = useMailboxNameIndex(accounts);
176
- const resultFolderIndex = useResultFolderIndex(accounts);
176
+ const resultFolderIndex = useResultFolderIndex(accounts).index;
177
177
  const accountNameIndex = useMemo(
178
178
  () => buildAccountNameIndex(accounts),
179
179
  [accounts],
@@ -0,0 +1,73 @@
1
+ import { SettingsShell } from "@remit/ui";
2
+ import { createFileRoute, useNavigate } from "@tanstack/react-router";
3
+ import { useState } from "react";
4
+ import { CalendarFeedPanel } from "@/components/settings/CalendarFeedPanel";
5
+ import { useCalendars } from "@/hooks/calendar/useCalendars";
6
+ import { SETTINGS_ID_TO_PATH, SETTINGS_NAV_ITEMS } from "@/routes/settings";
7
+
8
+ export const Route = createFileRoute("/settings/calendars")({
9
+ component: CalendarsSettings,
10
+ });
11
+
12
+ const calendarsHelp = (
13
+ <div className="space-y-3">
14
+ <p>
15
+ A subscription address lets Apple Calendar, Google Calendar, Outlook and
16
+ Thunderbird show a calendar from here, read-only and kept up to date.
17
+ </p>
18
+ <p>
19
+ Those clients never sign in, so the address itself is the credential.
20
+ Anyone who has it can read the calendar; nobody who has it can change
21
+ anything.
22
+ </p>
23
+ <p>
24
+ Only a hash of the address is stored, so it is shown once and cannot be
25
+ looked up later. Replacing it breaks every subscription built on the old
26
+ one, which is how a shared address is taken back.
27
+ </p>
28
+ </div>
29
+ );
30
+
31
+ function CalendarsSettings() {
32
+ const navigate = useNavigate();
33
+ const [helpOpen, setHelpOpen] = useState(true);
34
+ const { calendars, isLoading } = useCalendars();
35
+
36
+ return (
37
+ <SettingsShell
38
+ items={SETTINGS_NAV_ITEMS}
39
+ activeId="calendars"
40
+ title="Calendars"
41
+ description="Share a calendar with any client that can subscribe to a URL."
42
+ help={calendarsHelp}
43
+ helpOpen={helpOpen}
44
+ onToggleHelp={() => setHelpOpen((open) => !open)}
45
+ onSelect={(id) => {
46
+ const path = SETTINGS_ID_TO_PATH[id];
47
+ if (path) void navigate({ to: path });
48
+ }}
49
+ onBackToMail={() => void navigate({ to: "/mail" })}
50
+ >
51
+ {isLoading ? (
52
+ // biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label on a loading skeleton is what assistive tech has to go on
53
+ <div
54
+ className="h-24 animate-pulse rounded-sm border border-line bg-surface"
55
+ aria-busy="true"
56
+ aria-label="Loading calendars"
57
+ />
58
+ ) : calendars.length === 0 ? (
59
+ <p className="py-12 text-sm text-fg-muted">No calendars yet.</p>
60
+ ) : (
61
+ <div className="space-y-4">
62
+ {calendars.map((calendar) => (
63
+ <CalendarFeedPanel
64
+ key={calendar.id}
65
+ calendarId={calendar.id}
66
+ calendarName={calendar.name}
67
+ />
68
+ ))}
69
+ </div>
70
+ )}
71
+ </SettingsShell>
72
+ );
73
+ }
@@ -10,7 +10,15 @@
10
10
  */
11
11
  import type { SettingsNavItem } from "@remit/ui";
12
12
  import { createFileRoute, Outlet } from "@tanstack/react-router";
13
- import { Filter, FolderTree, Inbox, Palette, Tag, Users } from "lucide-react";
13
+ import {
14
+ CalendarDays,
15
+ Filter,
16
+ FolderTree,
17
+ Inbox,
18
+ Palette,
19
+ Tag,
20
+ Users,
21
+ } from "lucide-react";
14
22
  import { AdvancedNavIcon } from "@/components/settings/AdvancedNavIcon";
15
23
 
16
24
  /* ------------------------------------------------------------------ */
@@ -39,6 +47,11 @@ export const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
39
47
  label: "Labels",
40
48
  icon: <Tag className="size-4" />,
41
49
  },
50
+ {
51
+ id: "calendars",
52
+ label: "Calendars",
53
+ icon: <CalendarDays className="size-4" />,
54
+ },
42
55
  {
43
56
  id: "appearance",
44
57
  label: "Appearance",
@@ -53,6 +66,7 @@ export const SETTINGS_ID_TO_PATH: Record<string, string> = {
53
66
  folders: "/settings/folders",
54
67
  filters: "/settings/filters",
55
68
  labels: "/settings/labels",
69
+ calendars: "/settings/calendars",
56
70
  appearance: "/settings/appearance",
57
71
  advanced: "/settings/advanced",
58
72
  };
@@ -1,41 +0,0 @@
1
- /**
2
- * The hierarchy separator each account's own server reports, for surfaces that
3
- * hold a provider path but not the mailbox row it came from — quarantine, so
4
- * far. Fans out the same per-account mailbox-list query as
5
- * `useResultFolderIndex` (cached forever, react-query dedupes the identical key
6
- * across call sites) and keeps only the delimiter.
7
- */
8
- import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
9
- import type { RemitImapMailboxResponse } from "@remit/api-http-client/types.gen.ts";
10
- import { useQueries } from "@tanstack/react-query";
11
- import { useMemo } from "react";
12
- import { folderDelimiter } from "@/lib/move-options";
13
-
14
- /**
15
- * Hoisted so `useQueries` treats this as a memo rather than a per-render
16
- * reduce — see `useResultFolderIndex`.
17
- */
18
- const combineDelimiters = (
19
- results: { data?: { items: RemitImapMailboxResponse[] } }[],
20
- ): string[] =>
21
- results.map((result) => folderDelimiter(result.data?.items ?? []));
22
-
23
- export function useAccountDelimiters(
24
- accountIds: readonly string[],
25
- ): ReadonlyMap<string, string> {
26
- const delimiters = useQueries({
27
- queries: accountIds.map((accountId) => ({
28
- ...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
29
- staleTime: Infinity,
30
- })),
31
- combine: combineDelimiters,
32
- });
33
-
34
- return useMemo(
35
- () =>
36
- new Map(
37
- accountIds.map((accountId, i) => [accountId, delimiters[i] ?? "/"]),
38
- ),
39
- [accountIds, delimiters],
40
- );
41
- }