a tool for shared writing and social publishing
1"use client";
2import { ButtonPrimary } from "components/Buttons";
3import { useActionState, useEffect, useState } from "react";
4import { Input } from "components/Input";
5import { useIdentityData } from "components/IdentityProvider";
6import {
7 confirmEmailAuthToken,
8 requestAuthEmailToken,
9} from "actions/emailAuth";
10import { subscribeToPublicationWithEmail } from "actions/subscribeToPublicationWithEmail";
11import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
12import { ShareSmall } from "components/Icons/ShareSmall";
13import { Popover } from "components/Popover";
14import { BlueskyTiny } from "components/Icons/BlueskyTiny";
15import { useToaster } from "components/Toast";
16import * as Dialog from "@radix-ui/react-dialog";
17import {
18 subscribeToPublication,
19 unsubscribeToPublication,
20} from "./subscribeToPublication";
21import { DotLoader } from "components/utils/DotLoader";
22import { addFeed } from "./addFeed";
23import { useSearchParams } from "next/navigation";
24import LoginForm from "app/login/LoginForm";
25import { RSSSmall } from "components/Icons/RSSSmall";
26import { SpeedyLink } from "components/SpeedyLink";
27
28type State =
29 | { state: "email" }
30 | { state: "code"; token: string }
31 | { state: "success" };
32export const SubscribeButton = (props: {
33 compact?: boolean;
34 publication: string;
35}) => {
36 let { identity, mutate } = useIdentityData();
37 let [emailInputValue, setEmailInputValue] = useState("");
38 let [codeInputValue, setCodeInputValue] = useState("");
39 let [state, setState] = useState<State>({ state: "email" });
40
41 if (state.state === "email") {
42 return (
43 <div className="flex gap-2">
44 <div className="flex relative w-full max-w-sm">
45 <Input
46 type="email"
47 className="input-with-border pr-[104px]! py-1! grow w-full"
48 placeholder={
49 props.compact ? "subscribe with email..." : "email here..."
50 }
51 disabled={!!identity?.email}
52 value={identity?.email ? identity.email : emailInputValue}
53 onChange={(e) => {
54 setEmailInputValue(e.currentTarget.value);
55 }}
56 />
57 <ButtonPrimary
58 compact
59 className="absolute right-1 top-1 outline-0!"
60 onClick={async () => {
61 if (identity?.email) {
62 await subscribeToPublicationWithEmail(props.publication);
63 //optimistically could add!
64 await mutate();
65 return;
66 }
67 let tokenID = await requestAuthEmailToken(emailInputValue);
68 setState({ state: "code", token: tokenID });
69 }}
70 >
71 {props.compact ? (
72 <ArrowRightTiny className="w-4 h-6" />
73 ) : (
74 "Subscribe"
75 )}
76 </ButtonPrimary>
77 </div>
78 {/* <ShareButton /> */}
79 </div>
80 );
81 }
82 if (state.state === "code") {
83 return (
84 <div
85 className="w-full flex flex-col justify-center place-items-center p-4 rounded-md"
86 style={{
87 background:
88 "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)",
89 }}
90 >
91 <div className="flex flex-col leading-snug text-secondary">
92 <div>Please enter the code we sent to </div>
93 <div className="italic font-bold">{emailInputValue}</div>
94 </div>
95
96 <ConfirmCodeInput
97 publication={props.publication}
98 token={state.token}
99 codeInputValue={codeInputValue}
100 setCodeInputValue={setCodeInputValue}
101 setState={setState}
102 />
103
104 <button
105 className="text-accent-contrast text-sm mt-1"
106 onClick={() => {
107 setState({ state: "email" });
108 }}
109 >
110 Re-enter Email
111 </button>
112 </div>
113 );
114 }
115
116 if (state.state === "success") {
117 return (
118 <div
119 className={`w-full flex flex-col gap-2 justify-center place-items-center p-4 rounded-md text-secondary ${props.compact ? "py-1 animate-bounce" : "p-4"}`}
120 style={{
121 background:
122 "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-page)) 85%)",
123 }}
124 >
125 <div className="flex gap-2 leading-snug font-bold italic">
126 <div>You're subscribed!</div>
127 {/* <ShareButton /> */}
128 </div>
129 </div>
130 );
131 }
132};
133
134export const ShareButton = () => {
135 return (
136 <button className="text-accent-contrast">
137 <ShareSmall />
138 </button>
139 );
140};
141
142const ConfirmCodeInput = (props: {
143 codeInputValue: string;
144 token: string;
145 setCodeInputValue: (value: string) => void;
146 setState: (state: State) => void;
147 publication: string;
148}) => {
149 let { mutate } = useIdentityData();
150 return (
151 <div className="relative w-fit mt-2">
152 <Input
153 type="text"
154 pattern="[0-9]"
155 className="input-with-border pr-[88px]! py-1! max-w-[156px]"
156 placeholder="000000"
157 value={props.codeInputValue}
158 onChange={(e) => {
159 props.setCodeInputValue(e.currentTarget.value);
160 }}
161 />
162 <ButtonPrimary
163 compact
164 className="absolute right-1 top-1 outline-0!"
165 onClick={async () => {
166 console.log(
167 await confirmEmailAuthToken(props.token, props.codeInputValue),
168 );
169
170 await subscribeToPublicationWithEmail(props.publication);
171 //optimistically could add!
172 await mutate();
173 props.setState({ state: "success" });
174 return;
175 }}
176 >
177 Confirm
178 </ButtonPrimary>
179 </div>
180 );
181};
182
183export const SubscribeWithBluesky = (props: {
184 isPost?: boolean;
185 pubName: string;
186 pub_uri: string;
187 base_url: string;
188 subscribers: { identity: string }[];
189}) => {
190 let { identity } = useIdentityData();
191 let searchParams = useSearchParams();
192 let [successModalOpen, setSuccessModalOpen] = useState(
193 !!searchParams.has("showSubscribeSuccess"),
194 );
195 let subscribed =
196 identity?.atp_did &&
197 props.subscribers.find((s) => s.identity === identity.atp_did);
198
199 if (successModalOpen)
200 return (
201 <SubscribeSuccessModal
202 open={successModalOpen}
203 setOpen={setSuccessModalOpen}
204 />
205 );
206 if (subscribed) {
207 return <ManageSubscription {...props} />;
208 }
209 return (
210 <div className="flex flex-col gap-2 text-center justify-center">
211 {props.isPost && (
212 <div className="text-sm text-tertiary font-bold">
213 Get updates from {props.pubName}!
214 </div>
215 )}
216 <div className="flex flex-row gap-2 place-self-center">
217 <BlueskySubscribeButton
218 pub_uri={props.pub_uri}
219 setSuccessModalOpen={setSuccessModalOpen}
220 />
221 <a
222 href={`${props.base_url}/rss`}
223 className="flex"
224 target="_blank"
225 aria-label="Subscribe to RSS"
226 >
227 <RSSSmall className="self-center" aria-hidden />
228 </a>
229 </div>
230 </div>
231 );
232};
233
234const ManageSubscription = (props: {
235 isPost?: boolean;
236 pubName: string;
237 pub_uri: string;
238 subscribers: { identity: string }[];
239 base_url: string;
240}) => {
241 let toaster = useToaster();
242 let [hasFeed] = useState(false);
243 let [, unsubscribe, unsubscribePending] = useActionState(async () => {
244 await unsubscribeToPublication(props.pub_uri);
245 toaster({
246 content: "You unsubscribed.",
247 type: "success",
248 });
249 }, null);
250 return (
251 <div
252 className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`}
253 >
254 <div className="font-bold text-tertiary text-sm">
255 You're Subscribed{props.isPost ? ` to ` : "!"}
256 {props.isPost && (
257 <SpeedyLink href={props.base_url} className="text-accent-contrast">
258 {props.pubName}
259 </SpeedyLink>
260 )}
261 </div>
262 <Popover
263 trigger={<div className="text-accent-contrast text-sm">Manage</div>}
264 >
265 <div className="max-w-sm flex flex-col gap-1">
266 <h4>Update Options</h4>
267
268 {!hasFeed && (
269 <a
270 href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications"
271 target="_blank"
272 className=" place-self-center"
273 >
274 <ButtonPrimary fullWidth compact className="!px-4">
275 View Bluesky Custom Feed
276 </ButtonPrimary>
277 </a>
278 )}
279
280 <a
281 href={`${props.base_url}/rss`}
282 className="flex"
283 target="_blank"
284 aria-label="Subscribe to RSS"
285 >
286 <ButtonPrimary fullWidth compact>
287 Get RSS
288 </ButtonPrimary>
289 </a>
290
291 <hr className="border-border-light my-1" />
292
293 <form action={unsubscribe}>
294 <button className="font-bold text-accent-contrast w-max place-self-center">
295 {unsubscribePending ? <DotLoader /> : "Unsubscribe"}
296 </button>
297 </form>
298 </div>{" "}
299 </Popover>
300 </div>
301 );
302};
303
304let BlueskySubscribeButton = (props: {
305 pub_uri: string;
306 setSuccessModalOpen: (open: boolean) => void;
307}) => {
308 let { identity } = useIdentityData();
309 let toaster = useToaster();
310 let [, subscribe, subscribePending] = useActionState(async () => {
311 let result = await subscribeToPublication(
312 props.pub_uri,
313 window.location.href + "?refreshAuth",
314 );
315 if (result.hasFeed === false) {
316 props.setSuccessModalOpen(true);
317 }
318 toaster({ content: <div>You're Subscribed!</div>, type: "success" });
319 }, null);
320
321 let [isClient, setIsClient] = useState(false);
322 useEffect(() => {
323 setIsClient(true);
324 }, []);
325
326 if (!identity?.atp_did) {
327 return (
328 <Popover
329 asChild
330 trigger={
331 <ButtonPrimary className="place-self-center">
332 <BlueskyTiny /> Subscribe with Bluesky
333 </ButtonPrimary>
334 }
335 >
336 {isClient && (
337 <LoginForm
338 text="Log in to subscribe to this publication!"
339 noEmail
340 redirectRoute={window?.location.href + "?refreshAuth"}
341 action={{ action: "subscribe", publication: props.pub_uri }}
342 />
343 )}
344 </Popover>
345 );
346 }
347
348 return (
349 <>
350 <form
351 action={subscribe}
352 className="place-self-center flex flex-row gap-1"
353 >
354 <ButtonPrimary>
355 {subscribePending ? (
356 <DotLoader />
357 ) : (
358 <>
359 <BlueskyTiny /> Subscribe with Bluesky
360 </>
361 )}
362 </ButtonPrimary>
363 </form>
364 </>
365 );
366};
367
368const SubscribeSuccessModal = ({
369 open,
370 setOpen,
371}: {
372 open: boolean;
373 setOpen: (open: boolean) => void;
374}) => {
375 let searchParams = useSearchParams();
376 let [loading, setLoading] = useState(false);
377 let toaster = useToaster();
378 return (
379 <Dialog.Root open={open} onOpenChange={setOpen}>
380 <Dialog.Trigger asChild></Dialog.Trigger>
381 <Dialog.Portal>
382 <Dialog.Overlay className="fixed inset-0 bg-primary data-[state=open]:animate-overlayShow opacity-10 blur-xs" />
383 <Dialog.Content
384 className={`
385 z-20 opaque-container
386 fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
387 w-96 px-3 py-4
388 max-w-(--radix-popover-content-available-width)
389 max-h-(--radix-popover-content-available-height)
390 overflow-y-scroll no-scrollbar
391 flex flex-col gap-1 text-center justify-center
392 `}
393 >
394 <Dialog.Title asChild={true}>
395 <h3>Subscribed!</h3>
396 </Dialog.Title>
397 <Dialog.Description className="w-full flex flex-col">
398 You'll get updates about this publication via a Feed just for you.
399 <ButtonPrimary
400 className="place-self-center mt-4"
401 onClick={async () => {
402 if (loading) return;
403
404 setLoading(true);
405 let feedurl =
406 "https://bsky.app/profile/leaflet.pub/feed/subscribedPublications";
407 await addFeed();
408 toaster({ content: "Feed added!", type: "success" });
409 setLoading(false);
410 window.open(feedurl, "_blank");
411 }}
412 >
413 {loading ? <DotLoader /> : "Add Bluesky Feed"}
414 </ButtonPrimary>
415 <button
416 className="text-accent-contrast mt-1"
417 onClick={() => {
418 const newUrl = new URL(window.location.href);
419 newUrl.searchParams.delete("showSubscribeSuccess");
420 window.history.replaceState({}, "", newUrl.toString());
421 setOpen(false);
422 }}
423 >
424 No thanks
425 </button>
426 </Dialog.Description>
427 <Dialog.Close />
428 </Dialog.Content>
429 </Dialog.Portal>
430 </Dialog.Root>
431 );
432};