@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.
- package/package.json +1 -1
- package/src/components/settings/CalendarFeedCard.render.test.ts +154 -0
- package/src/components/settings/CalendarFeedCard.stories.tsx +134 -0
- package/src/components/settings/CalendarFeedCard.tsx +282 -0
- package/src/components/settings/CalendarFeedPanel.tsx +34 -0
- package/src/components/settings/QuarantinePanel.delimiter.test.ts +112 -0
- package/src/components/settings/QuarantinePanel.tsx +38 -15
- package/src/components/settings/calendar-feed-flow.render.test.ts +449 -0
- package/src/hooks/calendar/useCalendarFeed.ts +163 -0
- package/src/hooks/useResultFolderIndex.ts +39 -9
- package/src/lib/calendar-feed-copy.ts +29 -0
- package/src/lib/calendar-feed-url.ts +14 -0
- package/src/lib/quarantine-entries.ts +5 -3
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/mail.tsx +1 -1
- package/src/routes/settings/calendars.tsx +73 -0
- package/src/routes/settings.tsx +15 -1
- package/src/hooks/useAccountDelimiters.ts +0 -41
|
@@ -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
|
|
28
|
-
results: {
|
|
29
|
-
|
|
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
|
-
):
|
|
34
|
-
const
|
|
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:
|
|
65
|
+
combine: combineMailboxLists,
|
|
42
66
|
});
|
|
43
67
|
|
|
44
|
-
|
|
68
|
+
const index = useMemo(
|
|
45
69
|
() =>
|
|
46
70
|
buildResultFolderIndex(
|
|
47
71
|
accounts.map((account, i) => ({
|
|
48
72
|
folderAppointments: account.folderAppointments,
|
|
49
|
-
mailboxes:
|
|
73
|
+
mailboxes: mailboxLists.items[i] ?? [],
|
|
50
74
|
})),
|
|
51
75
|
),
|
|
52
|
-
[accounts,
|
|
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
|
|
12
|
-
* account's own
|
|
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,
|
package/src/routeTree.gen.ts
CHANGED
|
@@ -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,
|
package/src/routes/mail.tsx
CHANGED
|
@@ -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
|
+
}
|
package/src/routes/settings.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
}
|