@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.240",
|
|
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": {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import React, { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import type { CalendarFeedState } from "@/hooks/calendar/useCalendarFeed";
|
|
6
|
+
import { CalendarFeedCard } from "./CalendarFeedCard";
|
|
7
|
+
|
|
8
|
+
// The node test loader transpiles remit-ui's `.tsx` with the classic JSX
|
|
9
|
+
// runtime, which references a global `React`. Vite uses the automatic runtime,
|
|
10
|
+
// so this shim only exists for the SSR test harness.
|
|
11
|
+
(globalThis as { React?: typeof React }).React = React;
|
|
12
|
+
|
|
13
|
+
const CREATED = Date.parse("2026-05-04T09:12:00Z");
|
|
14
|
+
const ROTATED = Date.parse("2026-08-19T16:40:00Z");
|
|
15
|
+
const ADDRESS = "webcal://mail.example.com/feeds/calendar/token-here.ics";
|
|
16
|
+
|
|
17
|
+
const render = (
|
|
18
|
+
state: CalendarFeedState,
|
|
19
|
+
overrides: {
|
|
20
|
+
mintedUrl?: string;
|
|
21
|
+
isBusy?: boolean;
|
|
22
|
+
actionError?: unknown;
|
|
23
|
+
} = {},
|
|
24
|
+
) =>
|
|
25
|
+
renderToString(
|
|
26
|
+
createElement(CalendarFeedCard, {
|
|
27
|
+
calendarName: "Work",
|
|
28
|
+
state,
|
|
29
|
+
mintedUrl: overrides.mintedUrl ?? "",
|
|
30
|
+
isBusy: overrides.isBusy ?? false,
|
|
31
|
+
actionError: overrides.actionError,
|
|
32
|
+
onMint: () => undefined,
|
|
33
|
+
onRevoke: () => undefined,
|
|
34
|
+
onDismissMinted: () => undefined,
|
|
35
|
+
onRetry: () => undefined,
|
|
36
|
+
}) as never,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
describe("CalendarFeedCard", () => {
|
|
40
|
+
it("names the calendar in every state", () => {
|
|
41
|
+
assert.match(render({ status: "loading" }), /Work/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("offers to create an address when the calendar is not shared", () => {
|
|
45
|
+
const html = render({ status: "absent" });
|
|
46
|
+
assert.match(html, /Create subscription address/);
|
|
47
|
+
assert.doesNotMatch(html, /Stop sharing/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("says the address is the credential before one exists", () => {
|
|
51
|
+
assert.match(
|
|
52
|
+
render({ status: "absent" }),
|
|
53
|
+
/the address is the credential/i,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("shows when a shared address was created, and offers both takebacks", () => {
|
|
58
|
+
const html = render({
|
|
59
|
+
status: "active",
|
|
60
|
+
createdAt: CREATED,
|
|
61
|
+
rotatedAt: 0,
|
|
62
|
+
});
|
|
63
|
+
assert.match(html, /shared/);
|
|
64
|
+
assert.match(html, /Address created/);
|
|
65
|
+
assert.doesNotMatch(html, /last replaced/);
|
|
66
|
+
assert.match(html, /Replace address/);
|
|
67
|
+
assert.match(html, /Stop sharing/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("names the last replacement once the address has been rotated", () => {
|
|
71
|
+
const html = render({
|
|
72
|
+
status: "active",
|
|
73
|
+
createdAt: CREATED,
|
|
74
|
+
rotatedAt: ROTATED,
|
|
75
|
+
});
|
|
76
|
+
assert.match(html, /last replaced/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The whole point of the state: a shared calendar shows no address, because
|
|
81
|
+
* only a hash of it is stored. A card that could re-display it would mean the
|
|
82
|
+
* server had kept the secret.
|
|
83
|
+
*/
|
|
84
|
+
it("never shows an address for a feed it merely knows exists", () => {
|
|
85
|
+
const html = render({
|
|
86
|
+
status: "active",
|
|
87
|
+
createdAt: CREATED,
|
|
88
|
+
rotatedAt: 0,
|
|
89
|
+
});
|
|
90
|
+
assert.doesNotMatch(html, /webcal:\/\//);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("shows a minted address once, with a copy control and both warnings", () => {
|
|
94
|
+
const html = render(
|
|
95
|
+
{ status: "active", createdAt: CREATED, rotatedAt: 0 },
|
|
96
|
+
{ mintedUrl: ADDRESS },
|
|
97
|
+
);
|
|
98
|
+
assert.match(html, /webcal:\/\/mail\.example\.com/);
|
|
99
|
+
assert.match(html, /Copy address/);
|
|
100
|
+
assert.match(html, /shown once and cannot be read back/);
|
|
101
|
+
assert.match(html, /Anyone holding this address can read every event/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("states a refused write where the control is, rather than going quiet", () => {
|
|
105
|
+
const html = render(
|
|
106
|
+
{ status: "absent" },
|
|
107
|
+
{ actionError: new Error("Calendar not found") },
|
|
108
|
+
);
|
|
109
|
+
assert.match(html, /was not changed/);
|
|
110
|
+
assert.match(html, /Calendar not found/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A read the server refused is not "not shared". Drawing the create button
|
|
115
|
+
* for it would tell the reader their calendar is private while it may be
|
|
116
|
+
* subscribed to right now.
|
|
117
|
+
*/
|
|
118
|
+
it("keeps a failed read distinct from a calendar that is not shared", () => {
|
|
119
|
+
const html = render({
|
|
120
|
+
status: "unreadable",
|
|
121
|
+
error: new Error("Service unavailable"),
|
|
122
|
+
});
|
|
123
|
+
assert.match(html, /Couldn.{0,6}t read whether Work is shared/);
|
|
124
|
+
assert.match(html, /Service unavailable/);
|
|
125
|
+
assert.doesNotMatch(html, /Create subscription address/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("disables the create control while a write is in flight", () => {
|
|
129
|
+
const html = render({ status: "absent" }, { isBusy: true });
|
|
130
|
+
assert.match(html, /disabled/);
|
|
131
|
+
assert.match(html, /Creating…/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The mint's invalidate() re-reads the feed, and that read passes back
|
|
136
|
+
* through "loading" before it settles as "active". A skeleton drawn over
|
|
137
|
+
* the address already on screen carries a name that is a substring of the
|
|
138
|
+
* address input's own label, so the two collide under one accessible name.
|
|
139
|
+
*/
|
|
140
|
+
it("does not draw the loading skeleton over an address just minted", () => {
|
|
141
|
+
const html = render({ status: "loading" }, { mintedUrl: ADDRESS });
|
|
142
|
+
assert.match(html, /webcal:\/\/mail\.example\.com/);
|
|
143
|
+
assert.doesNotMatch(html, /Loading the subscription address/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("does not draw a read failure over an address just minted", () => {
|
|
147
|
+
const html = render(
|
|
148
|
+
{ status: "unreadable", error: new Error("Service unavailable") },
|
|
149
|
+
{ mintedUrl: ADDRESS },
|
|
150
|
+
);
|
|
151
|
+
assert.match(html, /webcal:\/\/mail\.example\.com/);
|
|
152
|
+
assert.doesNotMatch(html, /Couldn.{0,6}t read whether/);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { CalendarFeedCard } from "./CalendarFeedCard";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Settings › Calendars: the per-calendar subscription address (#1067). Every
|
|
6
|
+
* state the server can put the control in, including the one moment the address
|
|
7
|
+
* is legible and the two confirmations that take it away.
|
|
8
|
+
*/
|
|
9
|
+
const meta: Meta<typeof CalendarFeedCard> = {
|
|
10
|
+
title: "Flows/Settings Calendars/Subscription address",
|
|
11
|
+
component: CalendarFeedCard,
|
|
12
|
+
parameters: { layout: "padded" },
|
|
13
|
+
args: {
|
|
14
|
+
calendarName: "Work",
|
|
15
|
+
mintedUrl: "",
|
|
16
|
+
isBusy: false,
|
|
17
|
+
actionError: undefined,
|
|
18
|
+
onMint: () => undefined,
|
|
19
|
+
onRevoke: () => undefined,
|
|
20
|
+
onDismissMinted: () => undefined,
|
|
21
|
+
onRetry: () => undefined,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
export default meta;
|
|
25
|
+
|
|
26
|
+
type Story = StoryObj<typeof CalendarFeedCard>;
|
|
27
|
+
|
|
28
|
+
const CREATED = Date.parse("2026-05-04T09:12:00Z");
|
|
29
|
+
const ROTATED = Date.parse("2026-08-19T16:40:00Z");
|
|
30
|
+
|
|
31
|
+
const ADDRESS =
|
|
32
|
+
"webcal://mail.example.com/feeds/calendar/9Xq2mB7tK1vHs4dLpZ0rY6wJfN3aC8eQuIoT5gRbVkE.ics";
|
|
33
|
+
|
|
34
|
+
/** The read is still out — no control has an answer to draw yet. */
|
|
35
|
+
export const Loading: Story = {
|
|
36
|
+
args: { state: { status: "loading" } },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Not shared. The one control creates the address, and says what it is. */
|
|
40
|
+
export const NotShared: Story = {
|
|
41
|
+
args: { state: { status: "absent" } },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The create is in flight. */
|
|
45
|
+
export const Creating: Story = {
|
|
46
|
+
args: { state: { status: "absent" }, isBusy: true },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Shared, on an address that has never been replaced. */
|
|
50
|
+
export const Shared: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
state: { status: "active", createdAt: CREATED, rotatedAt: 0 },
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Shared, on the dark theme. */
|
|
57
|
+
export const SharedDark: Story = {
|
|
58
|
+
name: "Shared (dark)",
|
|
59
|
+
parameters: { theme: "dark" },
|
|
60
|
+
args: {
|
|
61
|
+
state: { status: "active", createdAt: CREATED, rotatedAt: 0 },
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Shared on an address that has been replaced since it was first created. */
|
|
66
|
+
export const SharedAfterRotation: Story = {
|
|
67
|
+
args: {
|
|
68
|
+
state: { status: "active", createdAt: CREATED, rotatedAt: ROTATED },
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The one moment the address is readable: straight after the write that minted
|
|
74
|
+
* it. It is not stored in the clear, so nothing can show it again.
|
|
75
|
+
*/
|
|
76
|
+
export const AddressShownOnce: Story = {
|
|
77
|
+
args: {
|
|
78
|
+
state: { status: "active", createdAt: CREATED, rotatedAt: 0 },
|
|
79
|
+
mintedUrl: ADDRESS,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The same, on the dark theme. */
|
|
84
|
+
export const AddressShownOnceDark: Story = {
|
|
85
|
+
name: "Address shown once (dark)",
|
|
86
|
+
parameters: { theme: "dark" },
|
|
87
|
+
args: {
|
|
88
|
+
state: { status: "active", createdAt: CREATED, rotatedAt: 0 },
|
|
89
|
+
mintedUrl: ADDRESS,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Replacing the address warns that the current one stops working. */
|
|
94
|
+
export const RotateConfirm: Story = {
|
|
95
|
+
args: {
|
|
96
|
+
state: { status: "active", createdAt: CREATED, rotatedAt: 0 },
|
|
97
|
+
},
|
|
98
|
+
play: async ({ canvasElement }) => {
|
|
99
|
+
const buttons = [...canvasElement.querySelectorAll("button")];
|
|
100
|
+
buttons.find((button) => button.textContent === "Replace address")?.click();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Revoking says the address stops working immediately. */
|
|
105
|
+
export const RevokeConfirm: Story = {
|
|
106
|
+
args: {
|
|
107
|
+
state: { status: "active", createdAt: CREATED, rotatedAt: 0 },
|
|
108
|
+
},
|
|
109
|
+
play: async ({ canvasElement }) => {
|
|
110
|
+
const buttons = [...canvasElement.querySelectorAll("button")];
|
|
111
|
+
buttons.find((button) => button.textContent === "Stop sharing")?.click();
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** The write was refused. The card says so where the button is. */
|
|
116
|
+
export const WriteRefused: Story = {
|
|
117
|
+
args: {
|
|
118
|
+
state: { status: "absent" },
|
|
119
|
+
actionError: new Error("Calendar not found"),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The read itself failed. Not drawn as "not shared": a calendar the server
|
|
125
|
+
* would not answer for may well be shared with somebody right now.
|
|
126
|
+
*/
|
|
127
|
+
export const Unreadable: Story = {
|
|
128
|
+
args: {
|
|
129
|
+
state: {
|
|
130
|
+
status: "unreadable",
|
|
131
|
+
error: new Error("Service unavailable"),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings › Calendars: one calendar's secret subscription address (#1067).
|
|
3
|
+
*
|
|
4
|
+
* Presentational. Every state the server can put this control in is drawn —
|
|
5
|
+
* the read still out, the read refused, not shared, shared, and the one moment
|
|
6
|
+
* the address is legible — because a control that renders nothing for one of
|
|
7
|
+
* them is indistinguishable from a calendar that cannot be shared at all.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
Badge,
|
|
11
|
+
Banner,
|
|
12
|
+
Button,
|
|
13
|
+
Card,
|
|
14
|
+
CardBody,
|
|
15
|
+
CardHeader,
|
|
16
|
+
CardTitle,
|
|
17
|
+
ConfirmDialog,
|
|
18
|
+
} from "@remit/ui";
|
|
19
|
+
import { Check, Copy, Link2, RefreshCw, Trash2 } from "lucide-react";
|
|
20
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
21
|
+
import { ErrorState, formatErrorMessage } from "@/components/ui/ErrorState";
|
|
22
|
+
import type { CalendarFeedState } from "@/hooks/calendar/useCalendarFeed";
|
|
23
|
+
import {
|
|
24
|
+
revokeFeedConfirmCopy,
|
|
25
|
+
rotateFeedConfirmCopy,
|
|
26
|
+
} from "@/lib/calendar-feed-copy";
|
|
27
|
+
import { formatDatePreset } from "@/lib/format";
|
|
28
|
+
|
|
29
|
+
export interface CalendarFeedCardProps {
|
|
30
|
+
calendarName: string;
|
|
31
|
+
state: CalendarFeedState;
|
|
32
|
+
/** The address just minted, shown once. Empty at every other moment. */
|
|
33
|
+
mintedUrl: string;
|
|
34
|
+
isBusy: boolean;
|
|
35
|
+
/** A create, rotate or revoke the server turned down. */
|
|
36
|
+
actionError: unknown;
|
|
37
|
+
onMint: () => void;
|
|
38
|
+
onRevoke: () => void;
|
|
39
|
+
onDismissMinted: () => void;
|
|
40
|
+
onRetry: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Pending = "none" | "rotate" | "revoke";
|
|
44
|
+
|
|
45
|
+
function MintedAddress({
|
|
46
|
+
calendarName,
|
|
47
|
+
url,
|
|
48
|
+
onDismiss,
|
|
49
|
+
}: {
|
|
50
|
+
calendarName: string;
|
|
51
|
+
url: string;
|
|
52
|
+
onDismiss: () => void;
|
|
53
|
+
}) {
|
|
54
|
+
const [copied, setCopied] = useState(false);
|
|
55
|
+
const [copyFailed, setCopyFailed] = useState(false);
|
|
56
|
+
const revert = useRef(0);
|
|
57
|
+
|
|
58
|
+
useEffect(() => () => window.clearTimeout(revert.current), []);
|
|
59
|
+
|
|
60
|
+
const copy = () => {
|
|
61
|
+
navigator.clipboard
|
|
62
|
+
.writeText(url)
|
|
63
|
+
.then(() => {
|
|
64
|
+
setCopyFailed(false);
|
|
65
|
+
setCopied(true);
|
|
66
|
+
revert.current = window.setTimeout(() => setCopied(false), 2000);
|
|
67
|
+
})
|
|
68
|
+
.catch(() => setCopyFailed(true));
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="mt-3 space-y-2 rounded-sm border border-accent-2/40 bg-accent-2/10 px-3 py-2.5">
|
|
73
|
+
<p className="text-sm font-medium text-fg">
|
|
74
|
+
Subscribe with this address. It is shown once and cannot be read back.
|
|
75
|
+
</p>
|
|
76
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
77
|
+
<input
|
|
78
|
+
readOnly
|
|
79
|
+
value={url}
|
|
80
|
+
aria-label={`Subscription address for ${calendarName}`}
|
|
81
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
82
|
+
className="min-w-0 flex-1 rounded-sm border border-line bg-surface px-2 py-1.5 font-mono text-xs text-fg"
|
|
83
|
+
/>
|
|
84
|
+
<Button
|
|
85
|
+
variant="primary"
|
|
86
|
+
size="sm"
|
|
87
|
+
icon={
|
|
88
|
+
copied ? (
|
|
89
|
+
<Check className="size-3.5" />
|
|
90
|
+
) : (
|
|
91
|
+
<Copy className="size-3.5" />
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
onClick={copy}
|
|
95
|
+
>
|
|
96
|
+
{copied ? "Copied" : "Copy address"}
|
|
97
|
+
</Button>
|
|
98
|
+
</div>
|
|
99
|
+
{copyFailed && (
|
|
100
|
+
<Banner tone="warning" variant="soft">
|
|
101
|
+
The address could not be copied to the clipboard. Select it above and
|
|
102
|
+
copy it by hand before leaving this page.
|
|
103
|
+
</Banner>
|
|
104
|
+
)}
|
|
105
|
+
<p className="text-xs text-fg-muted">
|
|
106
|
+
Anyone holding this address can read every event in this calendar,
|
|
107
|
+
without signing in. Rotate it if it reaches the wrong person.
|
|
108
|
+
</p>
|
|
109
|
+
<Button variant="ghost" size="sm" onClick={onDismiss}>
|
|
110
|
+
I've saved it
|
|
111
|
+
</Button>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function ActiveFeed({
|
|
117
|
+
createdAt,
|
|
118
|
+
rotatedAt,
|
|
119
|
+
isBusy,
|
|
120
|
+
onRotate,
|
|
121
|
+
onRevoke,
|
|
122
|
+
}: {
|
|
123
|
+
createdAt: number;
|
|
124
|
+
rotatedAt: number;
|
|
125
|
+
isBusy: boolean;
|
|
126
|
+
onRotate: () => void;
|
|
127
|
+
onRevoke: () => void;
|
|
128
|
+
}) {
|
|
129
|
+
return (
|
|
130
|
+
<div className="space-y-2">
|
|
131
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
132
|
+
<Badge tone="positive">shared</Badge>
|
|
133
|
+
<span className="text-xs text-fg-muted">
|
|
134
|
+
Address created {formatDatePreset(createdAt, "medium")}
|
|
135
|
+
{rotatedAt > 0
|
|
136
|
+
? `, last replaced ${formatDatePreset(rotatedAt, "medium")}`
|
|
137
|
+
: ""}
|
|
138
|
+
</span>
|
|
139
|
+
</div>
|
|
140
|
+
<p className="text-sm text-fg-muted">
|
|
141
|
+
The address itself is not stored in readable form, so it cannot be shown
|
|
142
|
+
again. Replace it if you no longer know where it went.
|
|
143
|
+
</p>
|
|
144
|
+
<div className="flex flex-wrap gap-2">
|
|
145
|
+
<Button
|
|
146
|
+
variant="secondary"
|
|
147
|
+
size="sm"
|
|
148
|
+
disabled={isBusy}
|
|
149
|
+
icon={<RefreshCw className="size-3.5" />}
|
|
150
|
+
onClick={onRotate}
|
|
151
|
+
>
|
|
152
|
+
Replace address
|
|
153
|
+
</Button>
|
|
154
|
+
<Button
|
|
155
|
+
variant="danger"
|
|
156
|
+
size="sm"
|
|
157
|
+
disabled={isBusy}
|
|
158
|
+
icon={<Trash2 className="size-3.5" />}
|
|
159
|
+
onClick={onRevoke}
|
|
160
|
+
>
|
|
161
|
+
Stop sharing
|
|
162
|
+
</Button>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function CalendarFeedCard({
|
|
169
|
+
calendarName,
|
|
170
|
+
state,
|
|
171
|
+
mintedUrl,
|
|
172
|
+
isBusy,
|
|
173
|
+
actionError,
|
|
174
|
+
onMint,
|
|
175
|
+
onRevoke,
|
|
176
|
+
onDismissMinted,
|
|
177
|
+
onRetry,
|
|
178
|
+
}: CalendarFeedCardProps) {
|
|
179
|
+
const [pending, setPending] = useState<Pending>("none");
|
|
180
|
+
const titleId = useId();
|
|
181
|
+
|
|
182
|
+
const confirm = () => {
|
|
183
|
+
const action = pending;
|
|
184
|
+
setPending("none");
|
|
185
|
+
if (action === "rotate") onMint();
|
|
186
|
+
if (action === "revoke") onRevoke();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const copy =
|
|
190
|
+
pending === "revoke"
|
|
191
|
+
? revokeFeedConfirmCopy(calendarName)
|
|
192
|
+
: rotateFeedConfirmCopy(calendarName);
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
// Named region: the settings page draws one of these per calendar, and every
|
|
196
|
+
// control in them is worded identically, so the calendar's name is the only
|
|
197
|
+
// thing telling two cards apart.
|
|
198
|
+
<Card className="max-w-xl" role="region" aria-labelledby={titleId}>
|
|
199
|
+
<CardHeader>
|
|
200
|
+
<CardTitle id={titleId}>{calendarName}</CardTitle>
|
|
201
|
+
</CardHeader>
|
|
202
|
+
<CardBody>
|
|
203
|
+
{state.status === "loading" && mintedUrl === "" && (
|
|
204
|
+
// biome-ignore lint/a11y/useAriaPropsSupportedByRole: aria-label on a loading skeleton is what assistive tech has to go on
|
|
205
|
+
<div
|
|
206
|
+
className="h-12 animate-pulse rounded-sm bg-surface-sunken"
|
|
207
|
+
aria-busy="true"
|
|
208
|
+
aria-label={`Loading the subscription address for ${calendarName}`}
|
|
209
|
+
/>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
{state.status === "unreadable" && mintedUrl === "" && (
|
|
213
|
+
<ErrorState
|
|
214
|
+
variant="inline"
|
|
215
|
+
title={`Couldn't read whether ${calendarName} is shared`}
|
|
216
|
+
error={state.error}
|
|
217
|
+
onRetry={onRetry}
|
|
218
|
+
/>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{state.status === "absent" && mintedUrl === "" && (
|
|
222
|
+
<div className="space-y-2">
|
|
223
|
+
<p className="text-sm text-fg-muted">
|
|
224
|
+
Give this calendar a secret address and Apple Calendar, Google
|
|
225
|
+
Calendar, Outlook and Thunderbird can subscribe to it read-only.
|
|
226
|
+
Nobody signs in — the address is the credential, so treat it like
|
|
227
|
+
a password.
|
|
228
|
+
</p>
|
|
229
|
+
<Button
|
|
230
|
+
variant="primary"
|
|
231
|
+
size="sm"
|
|
232
|
+
disabled={isBusy}
|
|
233
|
+
icon={<Link2 className="size-3.5" />}
|
|
234
|
+
onClick={onMint}
|
|
235
|
+
>
|
|
236
|
+
{isBusy ? "Creating…" : "Create subscription address"}
|
|
237
|
+
</Button>
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
{state.status === "active" && (
|
|
242
|
+
<ActiveFeed
|
|
243
|
+
createdAt={state.createdAt}
|
|
244
|
+
rotatedAt={state.rotatedAt}
|
|
245
|
+
isBusy={isBusy}
|
|
246
|
+
onRotate={() => setPending("rotate")}
|
|
247
|
+
onRevoke={() => setPending("revoke")}
|
|
248
|
+
/>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{actionError !== undefined && actionError !== null && (
|
|
252
|
+
<Banner tone="danger" variant="soft" className="mt-3">
|
|
253
|
+
<p className="font-medium">
|
|
254
|
+
The subscription address for {calendarName} was not changed.
|
|
255
|
+
</p>
|
|
256
|
+
<p className="mt-0.5 break-words">
|
|
257
|
+
{formatErrorMessage(actionError)}
|
|
258
|
+
</p>
|
|
259
|
+
</Banner>
|
|
260
|
+
)}
|
|
261
|
+
|
|
262
|
+
{mintedUrl !== "" && (
|
|
263
|
+
<MintedAddress
|
|
264
|
+
calendarName={calendarName}
|
|
265
|
+
url={mintedUrl}
|
|
266
|
+
onDismiss={onDismissMinted}
|
|
267
|
+
/>
|
|
268
|
+
)}
|
|
269
|
+
</CardBody>
|
|
270
|
+
|
|
271
|
+
<ConfirmDialog
|
|
272
|
+
isOpen={pending !== "none"}
|
|
273
|
+
title={copy.title}
|
|
274
|
+
description={copy.description}
|
|
275
|
+
confirmLabel={pending === "revoke" ? "Stop sharing" : "Replace address"}
|
|
276
|
+
destructive
|
|
277
|
+
onConfirm={confirm}
|
|
278
|
+
onCancel={() => setPending("none")}
|
|
279
|
+
/>
|
|
280
|
+
</Card>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One calendar's subscription address, wired to the server (#1067).
|
|
3
|
+
*
|
|
4
|
+
* A component rather than a loop body in the settings route: each calendar
|
|
5
|
+
* reads and writes its own feed, and a hook cannot be called per iteration.
|
|
6
|
+
*/
|
|
7
|
+
import { CalendarFeedCard } from "@/components/settings/CalendarFeedCard";
|
|
8
|
+
import { useCalendarFeed } from "@/hooks/calendar/useCalendarFeed";
|
|
9
|
+
|
|
10
|
+
export interface CalendarFeedPanelProps {
|
|
11
|
+
calendarId: string;
|
|
12
|
+
calendarName: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function CalendarFeedPanel({
|
|
16
|
+
calendarId,
|
|
17
|
+
calendarName,
|
|
18
|
+
}: CalendarFeedPanelProps) {
|
|
19
|
+
const feed = useCalendarFeed(calendarId);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<CalendarFeedCard
|
|
23
|
+
calendarName={calendarName}
|
|
24
|
+
state={feed.state}
|
|
25
|
+
mintedUrl={feed.mintedUrl}
|
|
26
|
+
isBusy={feed.isBusy}
|
|
27
|
+
actionError={feed.actionError}
|
|
28
|
+
onMint={feed.mint}
|
|
29
|
+
onRevoke={feed.revoke}
|
|
30
|
+
onDismissMinted={feed.dismissMinted}
|
|
31
|
+
onRetry={feed.retry}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|