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