diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index a56b483d..191a6bc3 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -17,7 +17,7 @@ import { import { Block } from "components/Blocks/Block"; import { TID } from "@atproto/common"; import { supabaseServerClient } from "supabase/serverClient"; -import { scanIndex, scanIndexLocal } from "src/replicache/utils"; +import { scanIndexLocal } from "src/replicache/utils"; import type { Fact } from "src/replicache"; import type { Attribute } from "src/replicache/attributes"; import { @@ -30,17 +30,16 @@ import { AtUri } from "@atproto/syntax"; import { Json } from "supabase/database.types"; import { $Typed, UnicodeString } from "@atproto/api"; import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; +import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; export async function publishToPublication({ root_entity, - blocks, publication_uri, leaflet_id, title, description, }: { root_entity: string; - blocks: Block[]; publication_uri: string; leaflet_id: string; title?: string; @@ -48,7 +47,7 @@ export async function publishToPublication({ }) { const oauthClient = await createOauthClient(); let identity = await getIdentityData(); - if (!identity || !identity.atp_did) return null; + if (!identity || !identity.atp_did) throw new Error("No Identity"); let credentialSession = await oauthClient.restore(identity.atp_did); let agent = new AtpBaseClient( @@ -60,12 +59,15 @@ export async function publishToPublication({ .eq("publication", publication_uri) .eq("leaflet", leaflet_id) .single(); - if (!draft || identity.atp_did !== draft?.publications?.identity_did) return; + if (!draft || identity.atp_did !== draft?.publications?.identity_did) + throw new Error("No draft or not publisher"); let { data } = await supabaseServerClient.rpc("get_facts", { root: root_entity, }); + let facts = (data as unknown as Fact[]) || []; + let blocks = getBlocksWithTypeLocal(facts, root_entity); - let scan = scanIndexLocal((data as unknown as Fact[]) || []); + let scan = scanIndexLocal(facts); let images = blocks .filter((b) => b.type === "image") .map((b) => scan.eav(b.value, "block/image")[0]); @@ -130,7 +132,7 @@ export async function publishToPublication({ .eq("publication", publication_uri), ]); - return { rkey }; + return { rkey, record }; } function blocksToRecord( diff --git a/app/[leaflet_id]/Actions.tsx b/app/[leaflet_id]/Actions.tsx index 578ff4de..06349af8 100644 --- a/app/[leaflet_id]/Actions.tsx +++ b/app/[leaflet_id]/Actions.tsx @@ -4,20 +4,20 @@ import { getPublicationURL, } from "app/lish/createPub/getPublicationURL"; import { ActionButton } from "components/ActionBar/ActionButton"; -import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; import { GoBackSmall } from "components/Icons/GoBackSmall"; import { PublishSmall } from "components/Icons/PublishSmall"; import { useIdentityData } from "components/IdentityProvider"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; import { useToaster } from "components/Toast"; import { DotLoader } from "components/utils/DotLoader"; -import { publications } from "drizzle/schema"; import Link from "next/link"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; +import router from "next/router"; import { useState } from "react"; import { useBlocks } from "src/hooks/queries/useBlocks"; -import { useEntity, useReplicache } from "src/replicache"; +import { useReplicache, useEntity } from "src/replicache"; import { Json } from "supabase/database.types"; + export const BackToPubButton = (props: { publication: { identity_did: string; @@ -27,7 +27,6 @@ export const BackToPubButton = (props: { uri: string; }; }) => { - let { identity } = useIdentityData(); return ( { + let { data: pub } = useLeafletPublicationData(); + let params = useParams(); + let router = useRouter(); + if (!pub?.doc) + return ( + } + label={"Publish!"} + onClick={() => { + router.push(`/${params.leaflet_id}/publish`); + }} + /> + ); + + return ; +}; + +const UpdateButton = () => { let [isLoading, setIsLoading] = useState(false); let { data: pub, mutate } = useLeafletPublicationData(); - let identity = useIdentityData(); let { permission_token, rootEntity } = useReplicache(); - let rootPage = useEntity(rootEntity, "root/page")[0]; - let blocks = useBlocks(rootPage?.data.value); let toaster = useToaster(); + return ( } - label={isLoading ? : pub?.doc ? "Update!" : "Publish!"} + label={isLoading ? : "Update!"} onClick={async () => { if (!pub || !pub.publications) return; setIsLoading(true); let doc = await publishToPublication({ root_entity: rootEntity, - blocks, publication_uri: pub.publications.uri, leaflet_id: permission_token.id, title: pub.title, diff --git a/app/[leaflet_id]/publish/PublishPost.tsx b/app/[leaflet_id]/publish/PublishPost.tsx new file mode 100644 index 00000000..aedca9a0 --- /dev/null +++ b/app/[leaflet_id]/publish/PublishPost.tsx @@ -0,0 +1,196 @@ +"use client"; +import { publishToPublication } from "actions/publishToPublication"; +import { DotLoader } from "components/utils/DotLoader"; +import { useState } from "react"; +import { ButtonPrimary } from "components/Buttons"; +import { Radio } from "components/Checkbox"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { AutosizeTextarea } from "components/utils/AutosizeTextarea"; +import { PubLeafletPublication } from "lexicons/api"; +import { publishPostToBsky } from "./publishBskyPost"; +import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; +import { AtUri } from "@atproto/syntax"; + +type Props = { + title: string; + leaflet_id: string; + root_entity: string; + profile: ProfileViewDetailed; + description: string; + publication_uri: string; + record?: PubLeafletPublication.Record; +}; + +export function PublishPost(props: Props) { + let [publishState, setPublishState] = useState< + { state: "default" } | { state: "success"; post_url: string } + >({ state: "default" }); + return ( +
+ {publishState.state === "default" ? ( + + ) : ( + + )} +
+ ); +} + +const PublishPostForm = ( + props: { + setPublishState: (s: { state: "success"; post_url: string }) => void; + } & Props, +) => { + let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); + let [postContent, setPostContent] = useState(""); + let [isLoading, setIsLoading] = useState(false); + let params = useParams(); + + async function submit() { + setIsLoading(true); + let doc = await publishToPublication({ + root_entity: props.root_entity, + publication_uri: props.publication_uri, + leaflet_id: props.leaflet_id, + title: props.title, + description: props.description, + }); + if (!doc) return; + + let post_url = `https://${props.record?.base_path}/${doc.rkey}`; + let publishedPost = await publishPostToBsky({ + text: postContent, + title: props.title, + url: post_url, + description: props.description, + record: doc.record, + rkey: doc.rkey, + }); + setIsLoading(false); + props.setPublishState({ state: "success", post_url }); + } + + return ( +
+

Publish Options

+
{ + e.preventDefault(); + submit(); + }} + > +
+ { + if (e.target === e.currentTarget) { + setShareOption("quiet"); + } + }} + name="share-options" + id="share-quietly" + value="Share Quietly" + > +
+
Share Quietly
+
+ Subscribers will not be notified about this post +
+
+
+ { + if (e.target === e.currentTarget) { + setShareOption("bluesky"); + } + }} + name="share-options" + id="share-bsky" + value="Share on Bluesky" + > +
+
Share on Bluesky
+
+ Subscribers will get updated via a custom Bluesky feed +
+
+
+ +
+
+
+ +
+
+

{props.profile.displayName}

+

@{props.profile.handle}

+
+ setPostContent(e.currentTarget.value)} + placeholder="Write a post to share your writing!" + /> +
+ {/*
*/} +
+
{props.title}
+
{props.description}
+
+

+ {props.record?.base_path} +

+
+
+
+
+
+
+
+ + Back + + + {isLoading ? : "Publish this Post!"} + +
+
+ +
+ ); +}; + +const PublishPostSuccess = (props: { + post_url: string; + publication_uri: string; + record: Props["record"]; +}) => { + let uri = new AtUri(props.publication_uri); + return ( +
+

Woo! You published your post!

+
+ + Back To Dashboard + + See post +
+
+ ); +}; diff --git a/app/[leaflet_id]/publish/page.tsx b/app/[leaflet_id]/publish/page.tsx new file mode 100644 index 00000000..518c36fc --- /dev/null +++ b/app/[leaflet_id]/publish/page.tsx @@ -0,0 +1,54 @@ +import { supabaseServerClient } from "supabase/serverClient"; +import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; +import { PublishPost } from "./PublishPost"; +import { PubLeafletPublication } from "lexicons/api"; +import { getIdentityData } from "actions/getIdentityData"; + +import { AtpAgent } from "@atproto/api"; + +export const preferredRegion = ["sfo1"]; +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; + +type Props = { + // this is now a token id not leaflet! Should probs rename + params: Promise<{ leaflet_id: string }>; +}; +export default async function LeafletPage(props: Props) { + let leaflet_id = (await props.params).leaflet_id; + let { result: res } = await get_leaflet_data.handler( + { token_id: leaflet_id }, + { supabase: supabaseServerClient }, + ); + let rootEntity = res.data?.root_entity; + if ( + !rootEntity || + !res.data || + res.data.blocked_by_admin || + !res.data.leaflets_in_publications[0] + ) + return ( +
+ missin something +
{JSON.stringify(res.data, undefined, 2)}
+
+ ); + + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) return null; + let pub = res.data.leaflets_in_publications[0]; + let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); + + let profile = await agent.getProfile({ actor: identity.atp_did }); + return ( + + ); +} diff --git a/app/[leaflet_id]/publish/publishBskyPost.ts b/app/[leaflet_id]/publish/publishBskyPost.ts new file mode 100644 index 00000000..d5fa49f0 --- /dev/null +++ b/app/[leaflet_id]/publish/publishBskyPost.ts @@ -0,0 +1,70 @@ +"use server"; + +import { Agent as BskyAgent } from "@atproto/api"; +import { TID } from "@atproto/common"; +import { getIdentityData } from "actions/getIdentityData"; +import { AtpBaseClient, PubLeafletDocument } from "lexicons/api"; +import { createOauthClient } from "src/atproto-oauth"; + +export async function publishPostToBsky(bskyPost: { + text: string; + url: string; + title: string; + description: string; + record: PubLeafletDocument.Record; + rkey: string; +}) { + const oauthClient = await createOauthClient(); + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) return null; + + let credentialSession = await oauthClient.restore(identity.atp_did); + let agent = new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession), + ); + let newPostUrl = bskyPost.url; + let preview_image = await fetch( + `https://pro.microlink.io/?url=${newPostUrl}&screenshot=true&viewport.width=1400&viewport.height=733&meta=false&embed=screenshot.url&force=true`, + { + headers: { + "x-api-key": process.env.MICROLINK_API_KEY!, + }, + }, + ); + + let binary = await preview_image.blob(); + let blob = await agent.com.atproto.repo.uploadBlob(binary, { + headers: { "Content-Type": binary.type }, + }); + let bsky = new BskyAgent(credentialSession); + let post = await bsky.app.bsky.feed.post.create( + { + repo: credentialSession.did!, + rkey: TID.nextStr(), + }, + { + text: bskyPost.text, + createdAt: new Date().toISOString(), + embed: { + $type: "app.bsky.embed.external", + external: { + uri: bskyPost.url, + title: bskyPost.title, + description: bskyPost.description, + thumb: blob.data.blob, + }, + }, + }, + ); + let record = bskyPost.record; + record.postRef = post; + + let { data: result } = await agent.com.atproto.repo.putRecord({ + rkey: bskyPost.rkey, + repo: credentialSession.did!, + collection: bskyPost.record.$type, + record, + validate: false, //TODO publish the lexicon so we can validate! + }); + return true; +} diff --git a/app/api/oauth/[route]/afterSignInActions.ts b/app/api/oauth/[route]/afterSignInActions.ts new file mode 100644 index 00000000..ed3a6852 --- /dev/null +++ b/app/api/oauth/[route]/afterSignInActions.ts @@ -0,0 +1,19 @@ +export type ActionAfterSignIn = { + action: "subscribe"; + publication: string; +}; + +export function encodeActionToSearchParam(actions: ActionAfterSignIn): string { + return encodeURIComponent(JSON.stringify(actions)); +} + +export function parseActionFromSearchParam( + param: string | null, +): ActionAfterSignIn | null { + if (!param) return null; + try { + return JSON.parse(decodeURIComponent(param)) as ActionAfterSignIn; + } catch { + return null; + } +} diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts index 0588d96b..047dcaf5 100644 --- a/app/api/oauth/[route]/route.ts +++ b/app/api/oauth/[route]/route.ts @@ -1,5 +1,5 @@ -import { OAuthClientMetadata } from "@atproto/oauth-client-node"; import { createIdentity } from "actions/createIdentity"; +import { subscribeToPublication } from "app/lish/subscribe"; import { drizzle } from "drizzle-orm/postgres-js"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; @@ -9,10 +9,17 @@ import { createOauthClient } from "src/atproto-oauth"; import { setAuthToken } from "src/auth"; import { supabaseServerClient } from "supabase/serverClient"; +import { URLSearchParams } from "url"; +import { + ActionAfterSignIn, + parseActionFromSearchParam, +} from "./afterSignInActions"; type OauthRequestClientState = { redirect: string | null; + action: ActionAfterSignIn | null; }; + export async function GET( req: NextRequest, props: { params: Promise<{ route: string; handle?: string }> }, @@ -29,7 +36,8 @@ export async function GET( const handle = searchParams.get("handle") as string; // Put originating page here! let redirect = searchParams.get("redirect_url"); - let state: OauthRequestClientState = { redirect }; + let action = parseActionFromSearchParam(searchParams.get("action")); + let state: OauthRequestClientState = { redirect, action }; // Revoke any pending authentication requests if the connection is closed (optional) const ac = new AbortController(); @@ -69,7 +77,8 @@ export async function GET( .from("identities") .update({ atp_did: session.did }) .eq("id", data.data.identity); - return redirect(redirectPath); + + return handleAction(s.action, redirectPath); } const client = postgres(process.env.DB_URL as string, { idle_timeout: 5, @@ -93,12 +102,28 @@ export async function GET( console.log("authorize() was called with state:", state); console.log("User authenticated as:", session.did); + return handleAction(s.action, redirectPath); } catch (e) { redirect(redirectPath); } - return redirect(redirectPath); } default: return NextResponse.json({ error: "Invalid route" }, { status: 404 }); } } + +const handleAction = async ( + action: ActionAfterSignIn | null, + redirectPath: string, +) => { + let [base, pathparams] = redirectPath.split("?"); + let searchParams = new URLSearchParams(pathparams); + if (action?.action === "subscribe") { + let result = await subscribeToPublication(action.publication); + console.log(result); + if (result.hasFeed === false) + searchParams.set("showSubscribeSuccess", "true"); + } + + return redirect(base + "?" + searchParams.toString()); +}; diff --git a/app/lish/Subscribe.tsx b/app/lish/Subscribe.tsx index 35c6bc88..728cb023 100644 --- a/app/lish/Subscribe.tsx +++ b/app/lish/Subscribe.tsx @@ -1,9 +1,8 @@ "use client"; import { ButtonPrimary } from "components/Buttons"; -import { useEffect, useState } from "react"; +import { useActionState, useState } from "react"; import { Input } from "components/Input"; import { useIdentityData } from "components/IdentityProvider"; -import { SecondaryAuthTokenContextImpl } from "twilio/lib/rest/accounts/v1/secondaryAuthToken"; import { confirmEmailAuthToken, requestAuthEmailToken, @@ -11,6 +10,14 @@ import { import { subscribeToPublicationWithEmail } from "actions/subscribeToPublicationWithEmail"; import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; import { ShareSmall } from "components/Icons/ShareSmall"; +import { Popover } from "components/Popover"; +import { BlueskyTiny } from "components/Icons/BlueskyTiny"; +import { useToaster } from "components/Toast"; +import * as Dialog from "@radix-ui/react-dialog"; +import { subscribeToPublication, unsubscribeToPublication } from "./subscribe"; +import { DotLoader } from "components/utils/DotLoader"; +import { addFeed } from "./addFeed"; +import { useSearchParams } from "next/navigation"; type State = | { state: "email" } @@ -166,3 +173,185 @@ const ConfirmCodeInput = (props: {
); }; + +export const SubscribeWithBluesky = (props: { + isPost?: boolean; + pubName: string; + pub_uri: string; + subscribers: { identity: string }[]; +}) => { + let { identity } = useIdentityData(); + let searchParams = useSearchParams(); + let [successModalOpen, setSuccessModalOpen] = useState( + !!searchParams.has("showSubscribeSuccess"), + ); + let subscribed = + identity?.atp_did && + props.subscribers.find((s) => s.identity === identity.atp_did); + + if (successModalOpen) + return ( + + ); + if (subscribed) { + return ; + } + return ( +
+ {props.isPost && ( +
+ Get updates from {props.pubName}! +
+ )} + +
+ ); +}; + +const ManageSubscription = (props: { + isPost?: boolean; + pubName: string; + pub_uri: string; + subscribers: { identity: string }[]; +}) => { + let toaster = useToaster(); + let [hasFeed] = useState(false); + let [, unsubscribe, unsubscribePending] = useActionState(async () => { + await unsubscribeToPublication(props.pub_uri); + toaster({ + content: "You unsubscribed.", + type: "success", + }); + }, null); + return ( +
+
+ You're Subscribed{props.isPost ? ` to ${props.pubName}` : "!"} +
+ Manage
} + > +
+ {!hasFeed && ( + <> +
+ Updates via Bluseky custom feed! + + View Feed + +
+
+ + )} +
+ +
+
{" "} + + + ); +}; + +let BlueskySubscribeButton = (props: { + pub_uri: string; + setSuccessModalOpen: (open: boolean) => void; +}) => { + let [, subscribe, subscribePending] = useActionState(async () => { + let result = await subscribeToPublication( + props.pub_uri, + window.location.href + "?refreshAuth", + ); + if (result.hasFeed === false) { + props.setSuccessModalOpen(true); + } + }, null); + + return ( + <> +
+ + {subscribePending ? ( + + ) : ( + <> + Subscribe with Bluesky{" "} + + )} + +
+ + ); +}; + +const SubscribeSuccessModal = ({ + open, + setOpen, +}: { + open: boolean; + setOpen: (open: boolean) => void; +}) => { + let searchParams = useSearchParams(); + return ( + + + + + + +

Subscribed!

+
+ + You'll get updates about this publication via a Feed just for you. + { + let feedurl = + "https://bsky.app/profile/leaflet.pub/feed/subscribedPublications"; + await addFeed(); + window.open(feedurl, "_blank"); + }} + > + Add Bluesky Feed + + + + +
+
+
+ ); +}; diff --git a/app/lish/[did]/[publication]/[rkey]/page.tsx b/app/lish/[did]/[publication]/[rkey]/page.tsx index a59990ae..17aab9b8 100644 --- a/app/lish/[did]/[publication]/[rkey]/page.tsx +++ b/app/lish/[did]/[publication]/[rkey]/page.tsx @@ -15,6 +15,7 @@ import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { TextBlock } from "./TextBlock"; import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; import { BskyAgent } from "@atproto/api"; +import { SubscribeWithBluesky } from "app/lish/Subscribe"; export async function generateMetadata(props: { params: Promise<{ publication: string; did: string; rkey: string }>; @@ -52,7 +53,9 @@ export default async function Post(props: { let [{ data: document }, { data: profile }] = await Promise.all([ supabaseServerClient .from("documents") - .select("*, documents_in_publications(publications(*))") + .select( + "*, documents_in_publications(publications(*, publication_subscriptions(*)))", + ) .eq( "uri", AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey), @@ -122,6 +125,16 @@ export default async function Post(props: { return ; })} +
+ diff --git a/app/lish/[did]/[publication]/page.tsx b/app/lish/[did]/[publication]/page.tsx index 112be608..841d5a84 100644 --- a/app/lish/[did]/[publication]/page.tsx +++ b/app/lish/[did]/[publication]/page.tsx @@ -2,17 +2,14 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Metadata } from "next"; import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; -import React from "react"; import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; import { AtUri } from "@atproto/syntax"; -import { - AtpBaseClient, - PubLeafletDocument, - PubLeafletPublication, -} from "lexicons/api"; +import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; import Link from "next/link"; import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; import { BskyAgent } from "@atproto/api"; +import { SubscribeWithBluesky } from "app/lish/Subscribe"; +import React from "react"; export async function generateMetadata(props: { params: Promise<{ publication: string; did: string }>; @@ -49,6 +46,7 @@ export default async function Publication(props: { .from("publications") .select( `*, + publication_subscriptions(*), documents_in_publications(documents(*)) `, ) @@ -97,6 +95,13 @@ export default async function Publication(props: {

)} +
+ +
{publication.documents_in_publications diff --git a/app/lish/[did]/[publication]/subscribeSuccess/page.tsx b/app/lish/[did]/[publication]/subscribeSuccess/page.tsx new file mode 100644 index 00000000..d84f4c6d --- /dev/null +++ b/app/lish/[did]/[publication]/subscribeSuccess/page.tsx @@ -0,0 +1,23 @@ +import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; +import { RSSSmall } from "components/Icons/RSSSmall"; + +export default function SubscribeSuccess() { + return ( +
+
+

You've Subscribed!

+
+ Add this custom feed to your Bluesky to get the updates from this and + ALL leaflet publications you subscribe to! +
+ +
+ Add Custom Feed + +
+
+
+ ); +} diff --git a/app/lish/addFeed.tsx b/app/lish/addFeed.tsx new file mode 100644 index 00000000..4609ba5f --- /dev/null +++ b/app/lish/addFeed.tsx @@ -0,0 +1,25 @@ +"use server"; + +import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; +import { getIdentityData } from "actions/getIdentityData"; +import { createOauthClient } from "src/atproto-oauth"; +const leafletFeedURI = + "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; + +export async function addFeed() { + const oauthClient = await createOauthClient(); + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) { + throw new Error("Invalid identity data"); + } + + let credentialSession = await oauthClient.restore(identity.atp_did); + let bsky = new BskyAgent(credentialSession); + await bsky.addSavedFeeds([ + { + value: leafletFeedURI, + pinned: true, + type: "feed", + }, + ]); +} diff --git a/app/lish/subscribe.ts b/app/lish/subscribe.ts new file mode 100644 index 00000000..314daaa8 --- /dev/null +++ b/app/lish/subscribe.ts @@ -0,0 +1,84 @@ +"use server"; + +import { AtpBaseClient } from "lexicons/api"; +import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; +import { getIdentityData } from "actions/getIdentityData"; +import { createOauthClient } from "src/atproto-oauth"; +import { TID } from "@atproto/common"; +import { supabaseServerClient } from "supabase/serverClient"; +import { revalidatePath } from "next/cache"; +import { AtUri } from "@atproto/syntax"; +import { redirect } from "next/navigation"; +import { encodeActionToSearchParam } from "app/api/oauth/[route]/afterSignInActions"; + +let leafletFeedURI = + "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; +export async function subscribeToPublication( + publication: string, + redirectRoute?: string, +) { + const oauthClient = await createOauthClient(); + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) { + return redirect( + `/api/oauth/login?redirect_url=${redirectRoute}&action=${encodeActionToSearchParam({ action: "subscribe", publication })}`, + ); + } + + let credentialSession = await oauthClient.restore(identity.atp_did); + let agent = new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession), + ); + let record = await agent.pub.leaflet.graph.subscription.create( + { repo: credentialSession.did!, rkey: TID.nextStr() }, + { + publication, + }, + ); + let { error } = await supabaseServerClient + .from("publication_subscriptions") + .insert({ + uri: record.uri, + record, + publication, + identity: credentialSession.did!, + }); + let bsky = new BskyAgent(credentialSession); + let prefs = await bsky.app.bsky.actor.getPreferences(); + let savedFeeds = prefs.data.preferences.find( + (pref) => pref.$type === "app.bsky.actor.defs#savedFeedsPrefV2", + ) as AppBskyActorDefs.SavedFeedsPrefV2; + revalidatePath("/lish/[did]/[publication]", "layout"); + return { + hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI), + }; +} + +export async function unsubscribeToPublication(publication: string) { + console.log("calling unsubscribe!"); + const oauthClient = await createOauthClient(); + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) return; + + let credentialSession = await oauthClient.restore(identity.atp_did); + let agent = new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession), + ); + let { data: existingSubscription } = await supabaseServerClient + .from("publication_subscriptions") + .select("*") + .eq("identity", identity.atp_did) + .eq("publication", publication) + .single(); + if (!existingSubscription) return; + await agent.pub.leaflet.graph.subscription.delete({ + repo: credentialSession.did!, + rkey: new AtUri(existingSubscription.uri).rkey, + }); + await supabaseServerClient + .from("publication_subscriptions") + .delete() + .eq("identity", identity.atp_did) + .eq("publication", publication); + revalidatePath("/lish/[did]/[publication]", "layout"); +} diff --git a/appview/index.ts b/appview/index.ts index 1869cf6c..df5a3d63 100644 --- a/appview/index.ts +++ b/appview/index.ts @@ -31,6 +31,7 @@ async function main() { }, }); let firehose = new Firehose({ + subscriptionReconnectDelay: 3000, excludeAccount: true, excludeIdentity: true, runner, diff --git a/components/Icons/RSSSmall.tsx b/components/Icons/RSSSmall.tsx new file mode 100644 index 00000000..7298b0fa --- /dev/null +++ b/components/Icons/RSSSmall.tsx @@ -0,0 +1,18 @@ +import { Props } from "./Props"; + +export const RSSSmall = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/Modal.tsx b/components/Modal.tsx new file mode 100644 index 00000000..2a534a7f --- /dev/null +++ b/components/Modal.tsx @@ -0,0 +1,50 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import React from "react"; + +export const Modal = ({ + className, + open, + onOpenChange, + asChild, + trigger, + title, + children, +}: { + className?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + asChild?: boolean; + trigger: React.ReactNode; + title?: React.ReactNode; + children: React.ReactNode; +}) => { + return ( + + {trigger} + + + +
+ {title && ( + +

{title}

+
+ )} + {children} +
+ +
+
+
+ ); +}; diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 01f8a5cd..cddbb80c 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -210,7 +210,7 @@ export const publication_subscriptions = pgTable("publication_subscriptions", { identity: text("identity").notNull(), created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), record: jsonb("record").notNull(), - uri: text("uri"), + uri: text("uri").notNull(), }, (table) => { return { diff --git a/middleware.ts b/middleware.ts index a20f7a45..fbf5b25c 100644 --- a/middleware.ts +++ b/middleware.ts @@ -42,7 +42,10 @@ export default async function middleware(req: NextRequest) { let pub = routes?.publication_domains[0]?.publications; if (pub) { let cookie = req.cookies.get("external_auth_token"); - if (!cookie && !hostname.includes("leaflet.pub")) { + if ( + (!cookie || req.nextUrl.searchParams.has("refreshAuth")) && + !hostname.includes("leaflet.pub") + ) { return initiateAuthCallback(req); } let aturi = new AtUri(pub?.uri); @@ -74,8 +77,10 @@ type CROSS_SITE_AUTH_RESPONSE = { ts: string; }; async function initiateAuthCallback(req: NextRequest) { + let redirectUrl = new URL(req.url); + redirectUrl.searchParams.delete("refreshAuth"); let token: CROSS_SITE_AUTH_REQUEST = { - redirect: req.url, + redirect: redirectUrl.toString(), ts: new Date().toISOString(), }; let payload = btoa(JSON.stringify(token)); diff --git a/package-lock.json b/package-lock.json index 3cb9d5a2..f74b53fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,13 @@ "@atproto/sync": "^0.1.23", "@atproto/syntax": "^0.3.3", "@atproto/xrpc": "^0.6.9", + "@atproto/xrpc-server": "^0.7.19", "@hono/node-server": "^1.14.3", "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", "@next/bundle-analyzer": "^15.3.2", "@next/mdx": "15.3.2", + "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-slider": "^1.3.4", @@ -536,9 +538,9 @@ } }, "node_modules/@atproto/xrpc-server": { - "version": "0.7.18", - "resolved": "https://registry.npmjs.org/@atproto/xrpc-server/-/xrpc-server-0.7.18.tgz", - "integrity": "sha512-kjlAsI+UNbbm6AK3Y5Hb4BJ7VQHNKiYYu2kX5vhZJZHO8qfO40GPYYb/2TknZV8IG6fDPBQhUpcDRolI86sgag==", + "version": "0.7.19", + "resolved": "https://registry.npmjs.org/@atproto/xrpc-server/-/xrpc-server-0.7.19.tgz", + "integrity": "sha512-YSCl/tU2NDykgDYslFSOYCr96esUgDwncFiADKL59/fyIFPLoT0qY8Uq/budpxUh0qPzjow4HHgVWESOaOpUmA==", "license": "MIT", "dependencies": { "@atproto/common": "^0.4.11", @@ -2955,6 +2957,153 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", diff --git a/package.json b/package.json index e5906e00..9440a591 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "next dev --turbo", "publish-lexicons": "tsx lexicons/publish.ts", "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta", - "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/* ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* --yes && find './lexicons/api' -type f -exec sed -i 's/\\.js'/'/g' {} \\;", + "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/* ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* --yes && find './lexicons/api' -type f -exec sed -i 's/\\.js'/'/g' {} \\;", "wrangler-dev": "wrangler dev", "build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node", "build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node", @@ -26,11 +26,13 @@ "@atproto/sync": "^0.1.23", "@atproto/syntax": "^0.3.3", "@atproto/xrpc": "^0.6.9", + "@atproto/xrpc-server": "^0.7.19", "@hono/node-server": "^1.14.3", "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", "@next/bundle-analyzer": "^15.3.2", "@next/mdx": "15.3.2", + "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-slider": "^1.3.4", diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 6f7b3c44..51ed7e81 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -690,21 +690,21 @@ export type Database = { identity: string publication: string record: Json - uri: string | null + uri: string } Insert: { created_at?: string identity: string publication: string record: Json - uri?: string | null + uri: string } Update: { created_at?: string identity?: string publication?: string record?: Json - uri?: string | null + uri?: string } Relationships: [ {