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 { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError";
27import { RSSTiny } from "components/Icons/RSSTiny";
28
29export const SubscribeWithBluesky = (props: {
30 compact?: boolean;
31 pubName: string;
32 pub_uri: string;
33 base_url: string;
34 subscribers: { identity: string }[];
35}) => {
36 let { identity } = useIdentityData();
37 let searchParams = useSearchParams();
38 let [successModalOpen, setSuccessModalOpen] = useState(
39 !!searchParams.has("showSubscribeSuccess"),
40 );
41 let [localSubscribeState, setLocalSubscribeState] = useState<
42 "subscribed" | "unsubscribed"
43 >("subscribed");
44 let subscribed =
45 identity?.atp_did &&
46 localSubscribeState !== "unsubscribed" &&
47 props.subscribers.find((s) => s.identity === identity.atp_did);
48
49 if (successModalOpen)
50 return (
51 <SubscribeSuccessModal
52 open={successModalOpen}
53 setOpen={setSuccessModalOpen}
54 />
55 );
56 if (subscribed) {
57 return (
58 <ManageSubscription
59 {...props}
60 onUnsubscribe={() => setLocalSubscribeState("unsubscribed")}
61 />
62 );
63 }
64 return (
65 <div className="flex flex-col gap-2 text-center justify-center">
66 <div className="flex flex-row gap-2 place-self-center">
67 <BlueskySubscribeButton
68 setLocalSubscribeState={() => setLocalSubscribeState("subscribed")}
69 compact={props.compact}
70 pub_uri={props.pub_uri}
71 setSuccessModalOpen={setSuccessModalOpen}
72 />
73 <a
74 href={`${props.base_url}/rss`}
75 className="flex"
76 target="_blank"
77 aria-label="Subscribe to RSS"
78 >
79 {props.compact ? (
80 <RSSTiny className="self-center" aria-hidden />
81 ) : (
82 <RSSSmall className="self-center" aria-hidden />
83 )}
84 </a>
85 </div>
86 </div>
87 );
88};
89
90export const ManageSubscription = (props: {
91 pub_uri: string;
92 subscribers: { identity: string }[];
93 base_url: string;
94 compact?: boolean;
95 onUnsubscribe?: () => void;
96}) => {
97 let toaster = useToaster();
98 let [hasFeed] = useState(false);
99 let [, unsubscribe, unsubscribePending] = useActionState(async () => {
100 await unsubscribeToPublication(props.pub_uri);
101 toaster({
102 content: "You unsubscribed.",
103 type: "success",
104 });
105 props.onUnsubscribe?.();
106 }, null);
107 return (
108 <Popover
109 trigger={
110 <div
111 className={`text-accent-contrast w-fit ${props.compact ? "text-xs" : "text-sm"}`}
112 >
113 Manage Subscription
114 </div>
115 }
116 >
117 <div
118 className={`max-w-sm flex flex-col gap-1 ${props.compact && "text-sm"}`}
119 >
120 <h4>Update Options</h4>
121
122 {!hasFeed && (
123 <a
124 href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications"
125 target="_blank"
126 className=" place-self-center"
127 >
128 <ButtonPrimary fullWidth compact className="!px-4">
129 Bluesky Custom Feed
130 </ButtonPrimary>
131 </a>
132 )}
133
134 <a
135 href={`${props.base_url}/rss`}
136 className="flex"
137 target="_blank"
138 aria-label="Subscribe to RSS"
139 >
140 <ButtonPrimary fullWidth compact>
141 Get RSS
142 </ButtonPrimary>
143 </a>
144
145 <hr className="border-border-light my-1" />
146
147 <form action={unsubscribe}>
148 <button className="font-bold w-full text-accent-contrast text-center mx-auto">
149 {unsubscribePending ? (
150 <DotLoader className="w-fit mx-auto" />
151 ) : (
152 "Unsubscribe"
153 )}
154 </button>
155 </form>
156 </div>
157 </Popover>
158 );
159};
160
161let BlueskySubscribeButton = (props: {
162 pub_uri: string;
163 setSuccessModalOpen: (open: boolean) => void;
164 compact?: boolean;
165 setLocalSubscribeState: () => void;
166}) => {
167 let { identity } = useIdentityData();
168 let toaster = useToaster();
169 let [oauthError, setOauthError] = useState<
170 import("src/atproto-oauth").OAuthSessionError | null
171 >(null);
172 let [, subscribe, subscribePending] = useActionState(async () => {
173 setOauthError(null);
174 let result = await subscribeToPublication(
175 props.pub_uri,
176 window.location.href + "?refreshAuth",
177 );
178 if (!result.success) {
179 if (isOAuthSessionError(result.error)) {
180 setOauthError(result.error);
181 }
182 return;
183 }
184 if (result.hasFeed === false) {
185 props.setSuccessModalOpen(true);
186 }
187 toaster({ content: <div>You're Subscribed!</div>, type: "success" });
188 props.setLocalSubscribeState();
189 }, null);
190
191 let [isClient, setIsClient] = useState(false);
192 useEffect(() => {
193 setIsClient(true);
194 }, []);
195
196 if (!identity?.atp_did) {
197 return (
198 <Popover
199 asChild
200 className="max-w-xs"
201 trigger={
202 <ButtonPrimary
203 compact={props.compact}
204 className={`place-self-center ${props.compact && "text-sm"}`}
205 >
206 <BlueskyTiny /> Subscribe with Bluesky
207 </ButtonPrimary>
208 }
209 >
210 {isClient && (
211 <LoginForm
212 text="Log in to subscribe to this publication!"
213 noEmail
214 redirectRoute={window?.location.href + "?refreshAuth"}
215 action={{ action: "subscribe", publication: props.pub_uri }}
216 />
217 )}
218 </Popover>
219 );
220 }
221
222 return (
223 <div className="flex flex-col gap-2 place-self-center">
224 <form
225 action={subscribe}
226 className="place-self-center flex flex-row gap-1"
227 >
228 <ButtonPrimary
229 compact={props.compact}
230 className={props.compact ? "text-sm" : ""}
231 >
232 {subscribePending ? (
233 <DotLoader />
234 ) : (
235 <>
236 <BlueskyTiny /> Subscribe with Bluesky
237 </>
238 )}
239 </ButtonPrimary>
240 </form>
241 {oauthError && (
242 <OAuthErrorMessage
243 error={oauthError}
244 className="text-center text-sm text-accent-1"
245 />
246 )}
247 </div>
248 );
249};
250
251const SubscribeSuccessModal = ({
252 open,
253 setOpen,
254}: {
255 open: boolean;
256 setOpen: (open: boolean) => void;
257}) => {
258 let searchParams = useSearchParams();
259 let [loading, setLoading] = useState(false);
260 let toaster = useToaster();
261 return (
262 <Dialog.Root open={open} onOpenChange={setOpen}>
263 <Dialog.Trigger asChild></Dialog.Trigger>
264 <Dialog.Portal>
265 <Dialog.Overlay className="fixed inset-0 bg-primary data-[state=open]:animate-overlayShow opacity-10 blur-xs" />
266 <Dialog.Content
267 className={`
268 z-20 opaque-container
269 fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
270 w-96 px-3 py-4
271 max-w-(--radix-popover-content-available-width)
272 max-h-(--radix-popover-content-available-height)
273 overflow-y-scroll no-scrollbar
274 flex flex-col gap-1 text-center justify-center
275 `}
276 >
277 <Dialog.Title asChild={true}>
278 <h3>Subscribed!</h3>
279 </Dialog.Title>
280 <Dialog.Description className="w-full flex flex-col">
281 You'll get updates about this publication via a Feed just for you.
282 <ButtonPrimary
283 className="place-self-center mt-4"
284 onClick={async () => {
285 if (loading) return;
286
287 setLoading(true);
288 let feedurl =
289 "https://bsky.app/profile/leaflet.pub/feed/subscribedPublications";
290 await addFeed();
291 toaster({ content: "Feed added!", type: "success" });
292 setLoading(false);
293 window.open(feedurl, "_blank");
294 }}
295 >
296 {loading ? <DotLoader /> : "Add Bluesky Feed"}
297 </ButtonPrimary>
298 <button
299 className="text-accent-contrast mt-1"
300 onClick={() => {
301 const newUrl = new URL(window.location.href);
302 newUrl.searchParams.delete("showSubscribeSuccess");
303 window.history.replaceState({}, "", newUrl.toString());
304 setOpen(false);
305 }}
306 >
307 No thanks
308 </button>
309 </Dialog.Description>
310 <Dialog.Close />
311 </Dialog.Content>
312 </Dialog.Portal>
313 </Dialog.Root>
314 );
315};
316
317export const SubscribeOnPost = () => {
318 return <div></div>;
319};