a tool for shared writing and social publishing

Feature/follow via bsky (#140)

* added a subscribe with Bsky thingy

* subscibe success page

* subscribe modal

* some styling tweaks

* WIP publish flow

* publish page is now less WIP

* little border radius tweak

* added a back button lol

* fix nested h tags

* use asChild instead of as

* WIP

* WIP publish page

* make publishing work!

* WIP

* handle signing in to follow

* handle feed status stuff

* split import from server action

* remove refreshAuth query param

---------

Co-authored-by: celine <celine@hyperlink.academy>

authored by awarm.space

celine and committed by
GitHub
0497c109 9eb67809

+985 -40
+9 -7
actions/publishToPublication.ts
··· 17 17 import { Block } from "components/Blocks/Block"; 18 18 import { TID } from "@atproto/common"; 19 19 import { supabaseServerClient } from "supabase/serverClient"; 20 - import { scanIndex, scanIndexLocal } from "src/replicache/utils"; 20 + import { scanIndexLocal } from "src/replicache/utils"; 21 21 import type { Fact } from "src/replicache"; 22 22 import type { Attribute } from "src/replicache/attributes"; 23 23 import { ··· 30 30 import { Json } from "supabase/database.types"; 31 31 import { $Typed, UnicodeString } from "@atproto/api"; 32 32 import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 33 + import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 33 34 34 35 export async function publishToPublication({ 35 36 root_entity, 36 - blocks, 37 37 publication_uri, 38 38 leaflet_id, 39 39 title, 40 40 description, 41 41 }: { 42 42 root_entity: string; 43 - blocks: Block[]; 44 43 publication_uri: string; 45 44 leaflet_id: string; 46 45 title?: string; ··· 48 47 }) { 49 48 const oauthClient = await createOauthClient(); 50 49 let identity = await getIdentityData(); 51 - if (!identity || !identity.atp_did) return null; 50 + if (!identity || !identity.atp_did) throw new Error("No Identity"); 52 51 53 52 let credentialSession = await oauthClient.restore(identity.atp_did); 54 53 let agent = new AtpBaseClient( ··· 60 59 .eq("publication", publication_uri) 61 60 .eq("leaflet", leaflet_id) 62 61 .single(); 63 - if (!draft || identity.atp_did !== draft?.publications?.identity_did) return; 62 + if (!draft || identity.atp_did !== draft?.publications?.identity_did) 63 + throw new Error("No draft or not publisher"); 64 64 let { data } = await supabaseServerClient.rpc("get_facts", { 65 65 root: root_entity, 66 66 }); 67 + let facts = (data as unknown as Fact<Attribute>[]) || []; 68 + let blocks = getBlocksWithTypeLocal(facts, root_entity); 67 69 68 - let scan = scanIndexLocal((data as unknown as Fact<Attribute>[]) || []); 70 + let scan = scanIndexLocal(facts); 69 71 let images = blocks 70 72 .filter((b) => b.type === "image") 71 73 .map((b) => scan.eav(b.value, "block/image")[0]); ··· 130 132 .eq("publication", publication_uri), 131 133 ]); 132 134 133 - return { rkey }; 135 + return { rkey, record }; 134 136 } 135 137 136 138 function blocksToRecord(
+25 -10
app/[leaflet_id]/Actions.tsx
··· 4 4 getPublicationURL, 5 5 } from "app/lish/createPub/getPublicationURL"; 6 6 import { ActionButton } from "components/ActionBar/ActionButton"; 7 - import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 8 7 import { GoBackSmall } from "components/Icons/GoBackSmall"; 9 8 import { PublishSmall } from "components/Icons/PublishSmall"; 10 9 import { useIdentityData } from "components/IdentityProvider"; 11 10 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 12 11 import { useToaster } from "components/Toast"; 13 12 import { DotLoader } from "components/utils/DotLoader"; 14 - import { publications } from "drizzle/schema"; 15 13 import Link from "next/link"; 16 - import { useParams } from "next/navigation"; 14 + import { useParams, useRouter } from "next/navigation"; 15 + import router from "next/router"; 17 16 import { useState } from "react"; 18 17 import { useBlocks } from "src/hooks/queries/useBlocks"; 19 - import { useEntity, useReplicache } from "src/replicache"; 18 + import { useReplicache, useEntity } from "src/replicache"; 20 19 import { Json } from "supabase/database.types"; 20 + 21 21 export const BackToPubButton = (props: { 22 22 publication: { 23 23 identity_did: string; ··· 27 27 uri: string; 28 28 }; 29 29 }) => { 30 - let { identity } = useIdentityData(); 31 30 return ( 32 31 <Link 33 32 href={`${getBasePublicationURL(props.publication)}/dashboard`} ··· 42 41 }; 43 42 44 43 export const PublishButton = () => { 44 + let { data: pub } = useLeafletPublicationData(); 45 + let params = useParams(); 46 + let router = useRouter(); 47 + if (!pub?.doc) 48 + return ( 49 + <ActionButton 50 + primary 51 + icon={<PublishSmall className="shrink-0" />} 52 + label={"Publish!"} 53 + onClick={() => { 54 + router.push(`/${params.leaflet_id}/publish`); 55 + }} 56 + /> 57 + ); 58 + 59 + return <UpdateButton />; 60 + }; 61 + 62 + const UpdateButton = () => { 45 63 let [isLoading, setIsLoading] = useState(false); 46 64 let { data: pub, mutate } = useLeafletPublicationData(); 47 - let identity = useIdentityData(); 48 65 let { permission_token, rootEntity } = useReplicache(); 49 - let rootPage = useEntity(rootEntity, "root/page")[0]; 50 - let blocks = useBlocks(rootPage?.data.value); 51 66 let toaster = useToaster(); 67 + 52 68 return ( 53 69 <ActionButton 54 70 primary 55 71 icon={<PublishSmall className="shrink-0" />} 56 - label={isLoading ? <DotLoader /> : pub?.doc ? "Update!" : "Publish!"} 72 + label={isLoading ? <DotLoader /> : "Update!"} 57 73 onClick={async () => { 58 74 if (!pub || !pub.publications) return; 59 75 setIsLoading(true); 60 76 let doc = await publishToPublication({ 61 77 root_entity: rootEntity, 62 - blocks, 63 78 publication_uri: pub.publications.uri, 64 79 leaflet_id: permission_token.id, 65 80 title: pub.title,
+196
app/[leaflet_id]/publish/PublishPost.tsx
··· 1 + "use client"; 2 + import { publishToPublication } from "actions/publishToPublication"; 3 + import { DotLoader } from "components/utils/DotLoader"; 4 + import { useState } from "react"; 5 + import { ButtonPrimary } from "components/Buttons"; 6 + import { Radio } from "components/Checkbox"; 7 + import { useParams } from "next/navigation"; 8 + import Link from "next/link"; 9 + import { AutosizeTextarea } from "components/utils/AutosizeTextarea"; 10 + import { PubLeafletPublication } from "lexicons/api"; 11 + import { publishPostToBsky } from "./publishBskyPost"; 12 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 13 + import { AtUri } from "@atproto/syntax"; 14 + 15 + type Props = { 16 + title: string; 17 + leaflet_id: string; 18 + root_entity: string; 19 + profile: ProfileViewDetailed; 20 + description: string; 21 + publication_uri: string; 22 + record?: PubLeafletPublication.Record; 23 + }; 24 + 25 + export function PublishPost(props: Props) { 26 + let [publishState, setPublishState] = useState< 27 + { state: "default" } | { state: "success"; post_url: string } 28 + >({ state: "default" }); 29 + return ( 30 + <div className="publishPage w-screen h-screen bg-[#FDFCFA] flex place-items-center justify-center"> 31 + {publishState.state === "default" ? ( 32 + <PublishPostForm setPublishState={setPublishState} {...props} /> 33 + ) : ( 34 + <PublishPostSuccess 35 + record={props.record} 36 + publication_uri={props.publication_uri} 37 + post_url={publishState.post_url} 38 + /> 39 + )} 40 + </div> 41 + ); 42 + } 43 + 44 + const PublishPostForm = ( 45 + props: { 46 + setPublishState: (s: { state: "success"; post_url: string }) => void; 47 + } & Props, 48 + ) => { 49 + let [shareOption, setShareOption] = useState<"bluesky" | "quiet">("bluesky"); 50 + let [postContent, setPostContent] = useState(""); 51 + let [isLoading, setIsLoading] = useState(false); 52 + let params = useParams(); 53 + 54 + async function submit() { 55 + setIsLoading(true); 56 + let doc = await publishToPublication({ 57 + root_entity: props.root_entity, 58 + publication_uri: props.publication_uri, 59 + leaflet_id: props.leaflet_id, 60 + title: props.title, 61 + description: props.description, 62 + }); 63 + if (!doc) return; 64 + 65 + let post_url = `https://${props.record?.base_path}/${doc.rkey}`; 66 + let publishedPost = await publishPostToBsky({ 67 + text: postContent, 68 + title: props.title, 69 + url: post_url, 70 + description: props.description, 71 + record: doc.record, 72 + rkey: doc.rkey, 73 + }); 74 + setIsLoading(false); 75 + props.setPublishState({ state: "success", post_url }); 76 + } 77 + 78 + return ( 79 + <div className="flex flex-col gap-4 w-[640px] max-w-full"> 80 + <h3>Publish Options</h3> 81 + <form 82 + onSubmit={(e) => { 83 + e.preventDefault(); 84 + submit(); 85 + }} 86 + > 87 + <div className="container flex flex-col gap-2 sm:p-3 p-4"> 88 + <Radio 89 + checked={shareOption === "quiet"} 90 + onChange={(e) => { 91 + if (e.target === e.currentTarget) { 92 + setShareOption("quiet"); 93 + } 94 + }} 95 + name="share-options" 96 + id="share-quietly" 97 + value="Share Quietly" 98 + > 99 + <div className="flex flex-col"> 100 + <div className="font-bold">Share Quietly</div> 101 + <div className="text-sm text-tertiary font-normal"> 102 + Subscribers will not be notified about this post 103 + </div> 104 + </div> 105 + </Radio> 106 + <Radio 107 + checked={shareOption === "bluesky"} 108 + onChange={(e) => { 109 + if (e.target === e.currentTarget) { 110 + setShareOption("bluesky"); 111 + } 112 + }} 113 + name="share-options" 114 + id="share-bsky" 115 + value="Share on Bluesky" 116 + > 117 + <div className="flex flex-col"> 118 + <div className="font-bold">Share on Bluesky</div> 119 + <div className="text-sm text-tertiary font-normal"> 120 + Subscribers will get updated via a custom Bluesky feed 121 + </div> 122 + </div> 123 + </Radio> 124 + 125 + <div 126 + className={`w-full pl-5 pb-4 ${shareOption !== "bluesky" ? "opacity-50" : ""}`} 127 + > 128 + <div className="opaque-container p-3 !rounded-lg"> 129 + <div className="flex gap-2"> 130 + <img 131 + className="bg-test rounded-full w-[42px] h-[42px] shrink-0" 132 + src={props.profile.avatar} 133 + /> 134 + <div className="flex flex-col w-full"> 135 + <div className="flex gap-2 pb-1"> 136 + <p className="font-bold">{props.profile.displayName}</p> 137 + <p className="text-tertiary">@{props.profile.handle}</p> 138 + </div> 139 + <AutosizeTextarea 140 + value={postContent} 141 + onChange={(e) => setPostContent(e.currentTarget.value)} 142 + placeholder="Write a post to share your writing!" 143 + /> 144 + <div className="opaque-container overflow-hidden flex flex-col mt-4 w-full"> 145 + {/* <div className="h-[260px] w-full bg-test" /> */} 146 + <div className="flex flex-col p-2"> 147 + <div className="font-bold">{props.title}</div> 148 + <div className="text-tertiary">{props.description}</div> 149 + <hr className="border-border-light mt-2 mb-1" /> 150 + <p className="text-xs text-tertiary"> 151 + {props.record?.base_path} 152 + </p> 153 + </div> 154 + </div> 155 + </div> 156 + </div> 157 + </div> 158 + </div> 159 + <div className="flex justify-between"> 160 + <Link 161 + className="hover:!no-underline font-bold" 162 + href={`/${params.leaflet_id}`} 163 + > 164 + Back 165 + </Link> 166 + <ButtonPrimary type="submit" className="place-self-end h-[30px]"> 167 + {isLoading ? <DotLoader /> : "Publish this Post!"} 168 + </ButtonPrimary> 169 + </div> 170 + </div> 171 + </form> 172 + </div> 173 + ); 174 + }; 175 + 176 + const PublishPostSuccess = (props: { 177 + post_url: string; 178 + publication_uri: string; 179 + record: Props["record"]; 180 + }) => { 181 + let uri = new AtUri(props.publication_uri); 182 + return ( 183 + <div> 184 + <h1>Woo! You published your post!</h1> 185 + <div className="flex gap-2 justify-center"> 186 + <Link 187 + className="hover:!no-underline font-bold" 188 + href={`/lish/${uri.host}/${props.record?.name}/dashboard`} 189 + > 190 + Back To Dashboard 191 + </Link> 192 + <a href={props.post_url}>See post</a> 193 + </div> 194 + </div> 195 + ); 196 + };
+54
app/[leaflet_id]/publish/page.tsx
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; 3 + import { PublishPost } from "./PublishPost"; 4 + import { PubLeafletPublication } from "lexicons/api"; 5 + import { getIdentityData } from "actions/getIdentityData"; 6 + 7 + import { AtpAgent } from "@atproto/api"; 8 + 9 + export const preferredRegion = ["sfo1"]; 10 + export const dynamic = "force-dynamic"; 11 + export const fetchCache = "force-no-store"; 12 + 13 + type Props = { 14 + // this is now a token id not leaflet! Should probs rename 15 + params: Promise<{ leaflet_id: string }>; 16 + }; 17 + export default async function LeafletPage(props: Props) { 18 + let leaflet_id = (await props.params).leaflet_id; 19 + let { result: res } = await get_leaflet_data.handler( 20 + { token_id: leaflet_id }, 21 + { supabase: supabaseServerClient }, 22 + ); 23 + let rootEntity = res.data?.root_entity; 24 + if ( 25 + !rootEntity || 26 + !res.data || 27 + res.data.blocked_by_admin || 28 + !res.data.leaflets_in_publications[0] 29 + ) 30 + return ( 31 + <div> 32 + missin something 33 + <pre>{JSON.stringify(res.data, undefined, 2)}</pre> 34 + </div> 35 + ); 36 + 37 + let identity = await getIdentityData(); 38 + if (!identity || !identity.atp_did) return null; 39 + let pub = res.data.leaflets_in_publications[0]; 40 + let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 41 + 42 + let profile = await agent.getProfile({ actor: identity.atp_did }); 43 + return ( 44 + <PublishPost 45 + leaflet_id={leaflet_id} 46 + root_entity={rootEntity} 47 + profile={profile.data} 48 + title={pub.title} 49 + publication_uri={pub.publication} 50 + description={pub.description} 51 + record={pub.publications?.record as PubLeafletPublication.Record} 52 + /> 53 + ); 54 + }
+70
app/[leaflet_id]/publish/publishBskyPost.ts
··· 1 + "use server"; 2 + 3 + import { Agent as BskyAgent } from "@atproto/api"; 4 + import { TID } from "@atproto/common"; 5 + import { getIdentityData } from "actions/getIdentityData"; 6 + import { AtpBaseClient, PubLeafletDocument } from "lexicons/api"; 7 + import { createOauthClient } from "src/atproto-oauth"; 8 + 9 + export async function publishPostToBsky(bskyPost: { 10 + text: string; 11 + url: string; 12 + title: string; 13 + description: string; 14 + record: PubLeafletDocument.Record; 15 + rkey: string; 16 + }) { 17 + const oauthClient = await createOauthClient(); 18 + let identity = await getIdentityData(); 19 + if (!identity || !identity.atp_did) return null; 20 + 21 + let credentialSession = await oauthClient.restore(identity.atp_did); 22 + let agent = new AtpBaseClient( 23 + credentialSession.fetchHandler.bind(credentialSession), 24 + ); 25 + let newPostUrl = bskyPost.url; 26 + let preview_image = await fetch( 27 + `https://pro.microlink.io/?url=${newPostUrl}&screenshot=true&viewport.width=1400&viewport.height=733&meta=false&embed=screenshot.url&force=true`, 28 + { 29 + headers: { 30 + "x-api-key": process.env.MICROLINK_API_KEY!, 31 + }, 32 + }, 33 + ); 34 + 35 + let binary = await preview_image.blob(); 36 + let blob = await agent.com.atproto.repo.uploadBlob(binary, { 37 + headers: { "Content-Type": binary.type }, 38 + }); 39 + let bsky = new BskyAgent(credentialSession); 40 + let post = await bsky.app.bsky.feed.post.create( 41 + { 42 + repo: credentialSession.did!, 43 + rkey: TID.nextStr(), 44 + }, 45 + { 46 + text: bskyPost.text, 47 + createdAt: new Date().toISOString(), 48 + embed: { 49 + $type: "app.bsky.embed.external", 50 + external: { 51 + uri: bskyPost.url, 52 + title: bskyPost.title, 53 + description: bskyPost.description, 54 + thumb: blob.data.blob, 55 + }, 56 + }, 57 + }, 58 + ); 59 + let record = bskyPost.record; 60 + record.postRef = post; 61 + 62 + let { data: result } = await agent.com.atproto.repo.putRecord({ 63 + rkey: bskyPost.rkey, 64 + repo: credentialSession.did!, 65 + collection: bskyPost.record.$type, 66 + record, 67 + validate: false, //TODO publish the lexicon so we can validate! 68 + }); 69 + return true; 70 + }
+19
app/api/oauth/[route]/afterSignInActions.ts
··· 1 + export type ActionAfterSignIn = { 2 + action: "subscribe"; 3 + publication: string; 4 + }; 5 + 6 + export function encodeActionToSearchParam(actions: ActionAfterSignIn): string { 7 + return encodeURIComponent(JSON.stringify(actions)); 8 + } 9 + 10 + export function parseActionFromSearchParam( 11 + param: string | null, 12 + ): ActionAfterSignIn | null { 13 + if (!param) return null; 14 + try { 15 + return JSON.parse(decodeURIComponent(param)) as ActionAfterSignIn; 16 + } catch { 17 + return null; 18 + } 19 + }
+29 -4
app/api/oauth/[route]/route.ts
··· 1 - import { OAuthClientMetadata } from "@atproto/oauth-client-node"; 2 1 import { createIdentity } from "actions/createIdentity"; 2 + import { subscribeToPublication } from "app/lish/subscribe"; 3 3 import { drizzle } from "drizzle-orm/postgres-js"; 4 4 import { cookies } from "next/headers"; 5 5 import { redirect } from "next/navigation"; ··· 9 9 import { setAuthToken } from "src/auth"; 10 10 11 11 import { supabaseServerClient } from "supabase/serverClient"; 12 + import { URLSearchParams } from "url"; 13 + import { 14 + ActionAfterSignIn, 15 + parseActionFromSearchParam, 16 + } from "./afterSignInActions"; 12 17 13 18 type OauthRequestClientState = { 14 19 redirect: string | null; 20 + action: ActionAfterSignIn | null; 15 21 }; 22 + 16 23 export async function GET( 17 24 req: NextRequest, 18 25 props: { params: Promise<{ route: string; handle?: string }> }, ··· 29 36 const handle = searchParams.get("handle") as string; 30 37 // Put originating page here! 31 38 let redirect = searchParams.get("redirect_url"); 32 - let state: OauthRequestClientState = { redirect }; 39 + let action = parseActionFromSearchParam(searchParams.get("action")); 40 + let state: OauthRequestClientState = { redirect, action }; 33 41 34 42 // Revoke any pending authentication requests if the connection is closed (optional) 35 43 const ac = new AbortController(); ··· 69 77 .from("identities") 70 78 .update({ atp_did: session.did }) 71 79 .eq("id", data.data.identity); 72 - return redirect(redirectPath); 80 + 81 + return handleAction(s.action, redirectPath); 73 82 } 74 83 const client = postgres(process.env.DB_URL as string, { 75 84 idle_timeout: 5, ··· 93 102 console.log("authorize() was called with state:", state); 94 103 95 104 console.log("User authenticated as:", session.did); 105 + return handleAction(s.action, redirectPath); 96 106 } catch (e) { 97 107 redirect(redirectPath); 98 108 } 99 - return redirect(redirectPath); 100 109 } 101 110 default: 102 111 return NextResponse.json({ error: "Invalid route" }, { status: 404 }); 103 112 } 104 113 } 114 + 115 + const handleAction = async ( 116 + action: ActionAfterSignIn | null, 117 + redirectPath: string, 118 + ) => { 119 + let [base, pathparams] = redirectPath.split("?"); 120 + let searchParams = new URLSearchParams(pathparams); 121 + if (action?.action === "subscribe") { 122 + let result = await subscribeToPublication(action.publication); 123 + console.log(result); 124 + if (result.hasFeed === false) 125 + searchParams.set("showSubscribeSuccess", "true"); 126 + } 127 + 128 + return redirect(base + "?" + searchParams.toString()); 129 + };
+191 -2
app/lish/Subscribe.tsx
··· 1 1 "use client"; 2 2 import { ButtonPrimary } from "components/Buttons"; 3 - import { useEffect, useState } from "react"; 3 + import { useActionState, useState } from "react"; 4 4 import { Input } from "components/Input"; 5 5 import { useIdentityData } from "components/IdentityProvider"; 6 - import { SecondaryAuthTokenContextImpl } from "twilio/lib/rest/accounts/v1/secondaryAuthToken"; 7 6 import { 8 7 confirmEmailAuthToken, 9 8 requestAuthEmailToken, ··· 11 10 import { subscribeToPublicationWithEmail } from "actions/subscribeToPublicationWithEmail"; 12 11 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 13 12 import { ShareSmall } from "components/Icons/ShareSmall"; 13 + import { Popover } from "components/Popover"; 14 + import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 15 + import { useToaster } from "components/Toast"; 16 + import * as Dialog from "@radix-ui/react-dialog"; 17 + import { subscribeToPublication, unsubscribeToPublication } from "./subscribe"; 18 + import { DotLoader } from "components/utils/DotLoader"; 19 + import { addFeed } from "./addFeed"; 20 + import { useSearchParams } from "next/navigation"; 14 21 15 22 type State = 16 23 | { state: "email" } ··· 166 173 </div> 167 174 ); 168 175 }; 176 + 177 + export const SubscribeWithBluesky = (props: { 178 + isPost?: boolean; 179 + pubName: string; 180 + pub_uri: string; 181 + subscribers: { identity: string }[]; 182 + }) => { 183 + let { identity } = useIdentityData(); 184 + let searchParams = useSearchParams(); 185 + let [successModalOpen, setSuccessModalOpen] = useState( 186 + !!searchParams.has("showSubscribeSuccess"), 187 + ); 188 + let subscribed = 189 + identity?.atp_did && 190 + props.subscribers.find((s) => s.identity === identity.atp_did); 191 + 192 + if (successModalOpen) 193 + return ( 194 + <SubscribeSuccessModal 195 + open={successModalOpen} 196 + setOpen={setSuccessModalOpen} 197 + /> 198 + ); 199 + if (subscribed) { 200 + return <ManageSubscription {...props} />; 201 + } 202 + return ( 203 + <div className="flex flex-col gap-2 text-center justify-center"> 204 + {props.isPost && ( 205 + <div className="text-sm text-tertiary font-bold"> 206 + Get updates from {props.pubName}! 207 + </div> 208 + )} 209 + <BlueskySubscribeButton 210 + pub_uri={props.pub_uri} 211 + setSuccessModalOpen={setSuccessModalOpen} 212 + /> 213 + </div> 214 + ); 215 + }; 216 + 217 + const ManageSubscription = (props: { 218 + isPost?: boolean; 219 + pubName: string; 220 + pub_uri: string; 221 + subscribers: { identity: string }[]; 222 + }) => { 223 + let toaster = useToaster(); 224 + let [hasFeed] = useState(false); 225 + let [, unsubscribe, unsubscribePending] = useActionState(async () => { 226 + await unsubscribeToPublication(props.pub_uri); 227 + toaster({ 228 + content: "You unsubscribed.", 229 + type: "success", 230 + }); 231 + }, null); 232 + return ( 233 + <div 234 + className={`flex ${props.isPost ? "flex-col " : "gap-2"} justify-center text-center`} 235 + > 236 + <div className="font-bold text-tertiary text-sm"> 237 + You&apos;re Subscribed{props.isPost ? ` to ${props.pubName}` : "!"} 238 + </div> 239 + <Popover 240 + trigger={<div className="text-accent-contrast text-sm">Manage</div>} 241 + > 242 + <div className="max-w-sm flex flex-col gap-3 justify-center text-center"> 243 + {!hasFeed && ( 244 + <> 245 + <div className="flex flex-col gap-2 font-bold text-secondary w-full"> 246 + Updates via Bluseky custom feed! 247 + <a 248 + href="https://bsky.app/profile/leaflet.pub/feed/subscribedPublications" 249 + target="_blank" 250 + className=" place-self-center" 251 + > 252 + <ButtonPrimary>View Feed</ButtonPrimary> 253 + </a> 254 + </div> 255 + <hr className="border-border-light" /> 256 + </> 257 + )} 258 + <form action={unsubscribe}> 259 + <button className="font-bold text-accent-contrast w-max place-self-center"> 260 + {unsubscribePending ? <DotLoader /> : "Unsubscribe"} 261 + </button> 262 + </form> 263 + </div>{" "} 264 + </Popover> 265 + </div> 266 + ); 267 + }; 268 + 269 + let BlueskySubscribeButton = (props: { 270 + pub_uri: string; 271 + setSuccessModalOpen: (open: boolean) => void; 272 + }) => { 273 + let [, subscribe, subscribePending] = useActionState(async () => { 274 + let result = await subscribeToPublication( 275 + props.pub_uri, 276 + window.location.href + "?refreshAuth", 277 + ); 278 + if (result.hasFeed === false) { 279 + props.setSuccessModalOpen(true); 280 + } 281 + }, null); 282 + 283 + return ( 284 + <> 285 + <form action={subscribe} className="place-self-center"> 286 + <ButtonPrimary> 287 + {subscribePending ? ( 288 + <DotLoader /> 289 + ) : ( 290 + <> 291 + <BlueskyTiny /> Subscribe with Bluesky{" "} 292 + </> 293 + )} 294 + </ButtonPrimary> 295 + </form> 296 + </> 297 + ); 298 + }; 299 + 300 + const SubscribeSuccessModal = ({ 301 + open, 302 + setOpen, 303 + }: { 304 + open: boolean; 305 + setOpen: (open: boolean) => void; 306 + }) => { 307 + let searchParams = useSearchParams(); 308 + return ( 309 + <Dialog.Root open={open} onOpenChange={setOpen}> 310 + <Dialog.Trigger asChild></Dialog.Trigger> 311 + <Dialog.Portal> 312 + <Dialog.Overlay className="fixed inset-0 bg-primary data-[state=open]:animate-overlayShow opacity-10 blur-sm" /> 313 + <Dialog.Content 314 + className={` 315 + z-20 opaque-container 316 + fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 317 + w-96 px-3 py-4 318 + max-w-[var(--radix-popover-content-available-width)] 319 + max-h-[var(--radix-popover-content-available-height)] 320 + overflow-y-scroll no-scrollbar 321 + flex flex-col gap-1 text-center justify-center 322 + `} 323 + > 324 + <Dialog.Title asChild={true}> 325 + <h3>Subscribed!</h3> 326 + </Dialog.Title> 327 + <Dialog.Description className="w-full flex flex-col"> 328 + You'll get updates about this publication via a Feed just for you. 329 + <ButtonPrimary 330 + className="place-self-center mt-4" 331 + onClick={async () => { 332 + let feedurl = 333 + "https://bsky.app/profile/leaflet.pub/feed/subscribedPublications"; 334 + await addFeed(); 335 + window.open(feedurl, "_blank"); 336 + }} 337 + > 338 + Add Bluesky Feed 339 + </ButtonPrimary> 340 + <button 341 + className="text-accent-contrast mt-1" 342 + onClick={() => { 343 + const newUrl = new URL(window.location.href); 344 + newUrl.searchParams.delete("showSubscribeSuccess"); 345 + window.history.replaceState({}, "", newUrl.toString()); 346 + setOpen(false); 347 + }} 348 + > 349 + No thanks 350 + </button> 351 + </Dialog.Description> 352 + <Dialog.Close /> 353 + </Dialog.Content> 354 + </Dialog.Portal> 355 + </Dialog.Root> 356 + ); 357 + };
+14 -1
app/lish/[did]/[publication]/[rkey]/page.tsx
··· 15 15 import { TextBlock } from "./TextBlock"; 16 16 import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 17 17 import { BskyAgent } from "@atproto/api"; 18 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 18 19 19 20 export async function generateMetadata(props: { 20 21 params: Promise<{ publication: string; did: string; rkey: string }>; ··· 52 53 let [{ data: document }, { data: profile }] = await Promise.all([ 53 54 supabaseServerClient 54 55 .from("documents") 55 - .select("*, documents_in_publications(publications(*))") 56 + .select( 57 + "*, documents_in_publications(publications(*, publication_subscriptions(*)))", 58 + ) 56 59 .eq( 57 60 "uri", 58 61 AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey), ··· 122 125 return <Block block={b} did={did} key={index} />; 123 126 })} 124 127 </div> 128 + <hr className="border-border-light mb-4 mt-2" /> 129 + <SubscribeWithBluesky 130 + isPost 131 + pub_uri={document.documents_in_publications[0].publications.uri} 132 + subscribers={ 133 + document.documents_in_publications[0].publications 134 + .publication_subscriptions 135 + } 136 + pubName={decodeURIComponent((await props.params).publication)} 137 + /> 125 138 </div> 126 139 </div> 127 140 </div>
+11 -6
app/lish/[did]/[publication]/page.tsx
··· 2 2 import { Metadata } from "next"; 3 3 4 4 import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 5 - import React from "react"; 6 5 import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 7 6 import { AtUri } from "@atproto/syntax"; 8 - import { 9 - AtpBaseClient, 10 - PubLeafletDocument, 11 - PubLeafletPublication, 12 - } from "lexicons/api"; 7 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 13 8 import Link from "next/link"; 14 9 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 15 10 import { BskyAgent } from "@atproto/api"; 11 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 + import React from "react"; 16 13 17 14 export async function generateMetadata(props: { 18 15 params: Promise<{ publication: string; did: string }>; ··· 49 46 .from("publications") 50 47 .select( 51 48 `*, 49 + publication_subscriptions(*), 52 50 documents_in_publications(documents(*)) 53 51 `, 54 52 ) ··· 97 95 </a> 98 96 </p> 99 97 )} 98 + <div className="sm:pt-4 pt-2"> 99 + <SubscribeWithBluesky 100 + pubName={publication.name} 101 + pub_uri={publication.uri} 102 + subscribers={publication.publication_subscriptions} 103 + /> 104 + </div> 100 105 </div> 101 106 <div className="publicationPostList w-full flex flex-col gap-4"> 102 107 {publication.documents_in_publications
+23
app/lish/[did]/[publication]/subscribeSuccess/page.tsx
··· 1 + import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 2 + import { RSSSmall } from "components/Icons/RSSSmall"; 3 + 4 + export default function SubscribeSuccess() { 5 + return ( 6 + <div className="h-screen w-screen bg-[#FDFCFA] flex place-items-center text-center "> 7 + <div className="container p-4 max-w-md mx-auto justify-center place-items-center flex flex-col gap-2"> 8 + <h3 className="text-secondary">You've Subscribed!</h3> 9 + <div className="text-tertiary"> 10 + Add this custom feed to your Bluesky to get the updates from this and 11 + ALL leaflet publications you subscribe to! 12 + </div> 13 + 14 + <div className="flex flex-row gap-3 mt-3"> 15 + <ButtonPrimary>Add Custom Feed</ButtonPrimary> 16 + <button className="text-accent-contrast"> 17 + <RSSSmall /> 18 + </button> 19 + </div> 20 + </div> 21 + </div> 22 + ); 23 + }
+25
app/lish/addFeed.tsx
··· 1 + "use server"; 2 + 3 + import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; 4 + import { getIdentityData } from "actions/getIdentityData"; 5 + import { createOauthClient } from "src/atproto-oauth"; 6 + const leafletFeedURI = 7 + "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; 8 + 9 + export async function addFeed() { 10 + const oauthClient = await createOauthClient(); 11 + let identity = await getIdentityData(); 12 + if (!identity || !identity.atp_did) { 13 + throw new Error("Invalid identity data"); 14 + } 15 + 16 + let credentialSession = await oauthClient.restore(identity.atp_did); 17 + let bsky = new BskyAgent(credentialSession); 18 + await bsky.addSavedFeeds([ 19 + { 20 + value: leafletFeedURI, 21 + pinned: true, 22 + type: "feed", 23 + }, 24 + ]); 25 + }
+84
app/lish/subscribe.ts
··· 1 + "use server"; 2 + 3 + import { AtpBaseClient } from "lexicons/api"; 4 + import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; 5 + import { getIdentityData } from "actions/getIdentityData"; 6 + import { createOauthClient } from "src/atproto-oauth"; 7 + import { TID } from "@atproto/common"; 8 + import { supabaseServerClient } from "supabase/serverClient"; 9 + import { revalidatePath } from "next/cache"; 10 + import { AtUri } from "@atproto/syntax"; 11 + import { redirect } from "next/navigation"; 12 + import { encodeActionToSearchParam } from "app/api/oauth/[route]/afterSignInActions"; 13 + 14 + let leafletFeedURI = 15 + "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; 16 + export async function subscribeToPublication( 17 + publication: string, 18 + redirectRoute?: string, 19 + ) { 20 + const oauthClient = await createOauthClient(); 21 + let identity = await getIdentityData(); 22 + if (!identity || !identity.atp_did) { 23 + return redirect( 24 + `/api/oauth/login?redirect_url=${redirectRoute}&action=${encodeActionToSearchParam({ action: "subscribe", publication })}`, 25 + ); 26 + } 27 + 28 + let credentialSession = await oauthClient.restore(identity.atp_did); 29 + let agent = new AtpBaseClient( 30 + credentialSession.fetchHandler.bind(credentialSession), 31 + ); 32 + let record = await agent.pub.leaflet.graph.subscription.create( 33 + { repo: credentialSession.did!, rkey: TID.nextStr() }, 34 + { 35 + publication, 36 + }, 37 + ); 38 + let { error } = await supabaseServerClient 39 + .from("publication_subscriptions") 40 + .insert({ 41 + uri: record.uri, 42 + record, 43 + publication, 44 + identity: credentialSession.did!, 45 + }); 46 + let bsky = new BskyAgent(credentialSession); 47 + let prefs = await bsky.app.bsky.actor.getPreferences(); 48 + let savedFeeds = prefs.data.preferences.find( 49 + (pref) => pref.$type === "app.bsky.actor.defs#savedFeedsPrefV2", 50 + ) as AppBskyActorDefs.SavedFeedsPrefV2; 51 + revalidatePath("/lish/[did]/[publication]", "layout"); 52 + return { 53 + hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI), 54 + }; 55 + } 56 + 57 + export async function unsubscribeToPublication(publication: string) { 58 + console.log("calling unsubscribe!"); 59 + const oauthClient = await createOauthClient(); 60 + let identity = await getIdentityData(); 61 + if (!identity || !identity.atp_did) return; 62 + 63 + let credentialSession = await oauthClient.restore(identity.atp_did); 64 + let agent = new AtpBaseClient( 65 + credentialSession.fetchHandler.bind(credentialSession), 66 + ); 67 + let { data: existingSubscription } = await supabaseServerClient 68 + .from("publication_subscriptions") 69 + .select("*") 70 + .eq("identity", identity.atp_did) 71 + .eq("publication", publication) 72 + .single(); 73 + if (!existingSubscription) return; 74 + await agent.pub.leaflet.graph.subscription.delete({ 75 + repo: credentialSession.did!, 76 + rkey: new AtUri(existingSubscription.uri).rkey, 77 + }); 78 + await supabaseServerClient 79 + .from("publication_subscriptions") 80 + .delete() 81 + .eq("identity", identity.atp_did) 82 + .eq("publication", publication); 83 + revalidatePath("/lish/[did]/[publication]", "layout"); 84 + }
+1
appview/index.ts
··· 31 31 }, 32 32 }); 33 33 let firehose = new Firehose({ 34 + subscriptionReconnectDelay: 3000, 34 35 excludeAccount: true, 35 36 excludeIdentity: true, 36 37 runner,
+18
components/Icons/RSSSmall.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const RSSSmall = (props: Props) => { 4 + return ( 5 + <svg 6 + width="24" 7 + height="24" 8 + viewBox="0 0 24 24" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + > 12 + <path 13 + d="M4.1123 8.41895C10.5525 8.41901 15.9862 13.4839 15.9863 20.293C15.9863 21.3974 15.0908 22.2929 13.9863 22.293C12.8819 22.2929 11.9864 21.3974 11.9863 20.293C11.9862 15.7699 8.42101 12.419 4.1123 12.4189C3.00778 12.4189 2.11237 11.5235 2.1123 10.4189C2.1123 9.31438 3.00774 8.41895 4.1123 8.41895ZM4.1123 1.70703C14.2057 1.7071 22.6982 9.63232 22.6982 20.293C22.6982 21.3975 21.8028 22.2929 20.6982 22.293C19.5937 22.293 18.6982 21.3975 18.6982 20.293C18.6982 11.9183 12.0743 5.70709 4.1123 5.70703C3.00774 5.70703 2.1123 4.8116 2.1123 3.70703C2.1123 2.60246 3.00774 1.70703 4.1123 1.70703ZM5.88965 16.1738C7.31836 16.174 8.4764 17.332 8.47656 18.7607C8.47656 20.1896 7.31846 21.3484 5.88965 21.3486C4.46067 21.3486 3.30176 20.1897 3.30176 18.7607C3.30192 17.3319 4.46077 16.1738 5.88965 16.1738Z" 14 + fill="currentColor" 15 + /> 16 + </svg> 17 + ); 18 + };
+50
components/Modal.tsx
··· 1 + import * as Dialog from "@radix-ui/react-dialog"; 2 + import React from "react"; 3 + 4 + export const Modal = ({ 5 + className, 6 + open, 7 + onOpenChange, 8 + asChild, 9 + trigger, 10 + title, 11 + children, 12 + }: { 13 + className?: string; 14 + open?: boolean; 15 + onOpenChange?: (open: boolean) => void; 16 + asChild?: boolean; 17 + trigger: React.ReactNode; 18 + title?: React.ReactNode; 19 + children: React.ReactNode; 20 + }) => { 21 + return ( 22 + <Dialog.Root open={open} onOpenChange={onOpenChange}> 23 + <Dialog.Trigger asChild={asChild}>{trigger}</Dialog.Trigger> 24 + <Dialog.Portal> 25 + <Dialog.Overlay className="fixed inset-0 bg-primary data-[state=open]:animate-overlayShow opacity-60" /> 26 + <Dialog.Content 27 + className={` 28 + z-20 fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 29 + overflow-y-scroll no-scrollbar w-max max-w-screen h-fit max-h-screen p-3 30 + `} 31 + > 32 + <div 33 + className={` 34 + opaque-container p-3 35 + flex flex-col gap-1 36 + ${className}`} 37 + > 38 + {title && ( 39 + <Dialog.Title> 40 + <h3>{title}</h3> 41 + </Dialog.Title> 42 + )} 43 + <Dialog.Description>{children}</Dialog.Description> 44 + </div> 45 + <Dialog.Close /> 46 + </Dialog.Content> 47 + </Dialog.Portal> 48 + </Dialog.Root> 49 + ); 50 + };
+1 -1
drizzle/schema.ts
··· 210 210 identity: text("identity").notNull(), 211 211 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 212 212 record: jsonb("record").notNull(), 213 - uri: text("uri"), 213 + uri: text("uri").notNull(), 214 214 }, 215 215 (table) => { 216 216 return {
+7 -2
middleware.ts
··· 42 42 let pub = routes?.publication_domains[0]?.publications; 43 43 if (pub) { 44 44 let cookie = req.cookies.get("external_auth_token"); 45 - if (!cookie && !hostname.includes("leaflet.pub")) { 45 + if ( 46 + (!cookie || req.nextUrl.searchParams.has("refreshAuth")) && 47 + !hostname.includes("leaflet.pub") 48 + ) { 46 49 return initiateAuthCallback(req); 47 50 } 48 51 let aturi = new AtUri(pub?.uri); ··· 74 77 ts: string; 75 78 }; 76 79 async function initiateAuthCallback(req: NextRequest) { 80 + let redirectUrl = new URL(req.url); 81 + redirectUrl.searchParams.delete("refreshAuth"); 77 82 let token: CROSS_SITE_AUTH_REQUEST = { 78 - redirect: req.url, 83 + redirect: redirectUrl.toString(), 79 84 ts: new Date().toISOString(), 80 85 }; 81 86 let payload = btoa(JSON.stringify(token));
+152 -3
package-lock.json
··· 16 16 "@atproto/sync": "^0.1.23", 17 17 "@atproto/syntax": "^0.3.3", 18 18 "@atproto/xrpc": "^0.6.9", 19 + "@atproto/xrpc-server": "^0.7.19", 19 20 "@hono/node-server": "^1.14.3", 20 21 "@mdx-js/loader": "^3.1.0", 21 22 "@mdx-js/react": "^3.1.0", 22 23 "@next/bundle-analyzer": "^15.3.2", 23 24 "@next/mdx": "15.3.2", 25 + "@radix-ui/react-dialog": "^1.1.14", 24 26 "@radix-ui/react-dropdown-menu": "^2.1.14", 25 27 "@radix-ui/react-popover": "^1.1.13", 26 28 "@radix-ui/react-slider": "^1.3.4", ··· 536 538 } 537 539 }, 538 540 "node_modules/@atproto/xrpc-server": { 539 - "version": "0.7.18", 540 - "resolved": "https://registry.npmjs.org/@atproto/xrpc-server/-/xrpc-server-0.7.18.tgz", 541 - "integrity": "sha512-kjlAsI+UNbbm6AK3Y5Hb4BJ7VQHNKiYYu2kX5vhZJZHO8qfO40GPYYb/2TknZV8IG6fDPBQhUpcDRolI86sgag==", 541 + "version": "0.7.19", 542 + "resolved": "https://registry.npmjs.org/@atproto/xrpc-server/-/xrpc-server-0.7.19.tgz", 543 + "integrity": "sha512-YSCl/tU2NDykgDYslFSOYCr96esUgDwncFiADKL59/fyIFPLoT0qY8Uq/budpxUh0qPzjow4HHgVWESOaOpUmA==", 542 544 "license": "MIT", 543 545 "dependencies": { 544 546 "@atproto/common": "^0.4.11", ··· 2945 2947 "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", 2946 2948 "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", 2947 2949 "license": "MIT", 2950 + "peerDependencies": { 2951 + "@types/react": "*", 2952 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2953 + }, 2954 + "peerDependenciesMeta": { 2955 + "@types/react": { 2956 + "optional": true 2957 + } 2958 + } 2959 + }, 2960 + "node_modules/@radix-ui/react-dialog": { 2961 + "version": "1.1.14", 2962 + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", 2963 + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", 2964 + "dependencies": { 2965 + "@radix-ui/primitive": "1.1.2", 2966 + "@radix-ui/react-compose-refs": "1.1.2", 2967 + "@radix-ui/react-context": "1.1.2", 2968 + "@radix-ui/react-dismissable-layer": "1.1.10", 2969 + "@radix-ui/react-focus-guards": "1.1.2", 2970 + "@radix-ui/react-focus-scope": "1.1.7", 2971 + "@radix-ui/react-id": "1.1.1", 2972 + "@radix-ui/react-portal": "1.1.9", 2973 + "@radix-ui/react-presence": "1.1.4", 2974 + "@radix-ui/react-primitive": "2.1.3", 2975 + "@radix-ui/react-slot": "1.2.3", 2976 + "@radix-ui/react-use-controllable-state": "1.2.2", 2977 + "aria-hidden": "^1.2.4", 2978 + "react-remove-scroll": "^2.6.3" 2979 + }, 2980 + "peerDependencies": { 2981 + "@types/react": "*", 2982 + "@types/react-dom": "*", 2983 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 2984 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 2985 + }, 2986 + "peerDependenciesMeta": { 2987 + "@types/react": { 2988 + "optional": true 2989 + }, 2990 + "@types/react-dom": { 2991 + "optional": true 2992 + } 2993 + } 2994 + }, 2995 + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { 2996 + "version": "1.1.10", 2997 + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", 2998 + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", 2999 + "dependencies": { 3000 + "@radix-ui/primitive": "1.1.2", 3001 + "@radix-ui/react-compose-refs": "1.1.2", 3002 + "@radix-ui/react-primitive": "2.1.3", 3003 + "@radix-ui/react-use-callback-ref": "1.1.1", 3004 + "@radix-ui/react-use-escape-keydown": "1.1.1" 3005 + }, 3006 + "peerDependencies": { 3007 + "@types/react": "*", 3008 + "@types/react-dom": "*", 3009 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3010 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3011 + }, 3012 + "peerDependenciesMeta": { 3013 + "@types/react": { 3014 + "optional": true 3015 + }, 3016 + "@types/react-dom": { 3017 + "optional": true 3018 + } 3019 + } 3020 + }, 3021 + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { 3022 + "version": "1.1.7", 3023 + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", 3024 + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", 3025 + "dependencies": { 3026 + "@radix-ui/react-compose-refs": "1.1.2", 3027 + "@radix-ui/react-primitive": "2.1.3", 3028 + "@radix-ui/react-use-callback-ref": "1.1.1" 3029 + }, 3030 + "peerDependencies": { 3031 + "@types/react": "*", 3032 + "@types/react-dom": "*", 3033 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3034 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3035 + }, 3036 + "peerDependenciesMeta": { 3037 + "@types/react": { 3038 + "optional": true 3039 + }, 3040 + "@types/react-dom": { 3041 + "optional": true 3042 + } 3043 + } 3044 + }, 3045 + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { 3046 + "version": "1.1.9", 3047 + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", 3048 + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", 3049 + "dependencies": { 3050 + "@radix-ui/react-primitive": "2.1.3", 3051 + "@radix-ui/react-use-layout-effect": "1.1.1" 3052 + }, 3053 + "peerDependencies": { 3054 + "@types/react": "*", 3055 + "@types/react-dom": "*", 3056 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3057 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3058 + }, 3059 + "peerDependenciesMeta": { 3060 + "@types/react": { 3061 + "optional": true 3062 + }, 3063 + "@types/react-dom": { 3064 + "optional": true 3065 + } 3066 + } 3067 + }, 3068 + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { 3069 + "version": "2.1.3", 3070 + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", 3071 + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", 3072 + "dependencies": { 3073 + "@radix-ui/react-slot": "1.2.3" 3074 + }, 3075 + "peerDependencies": { 3076 + "@types/react": "*", 3077 + "@types/react-dom": "*", 3078 + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", 3079 + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" 3080 + }, 3081 + "peerDependenciesMeta": { 3082 + "@types/react": { 3083 + "optional": true 3084 + }, 3085 + "@types/react-dom": { 3086 + "optional": true 3087 + } 3088 + } 3089 + }, 3090 + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { 3091 + "version": "1.2.3", 3092 + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", 3093 + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", 3094 + "dependencies": { 3095 + "@radix-ui/react-compose-refs": "1.1.2" 3096 + }, 2948 3097 "peerDependencies": { 2949 3098 "@types/react": "*", 2950 3099 "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+3 -1
package.json
··· 7 7 "dev": "next dev --turbo", 8 8 "publish-lexicons": "tsx lexicons/publish.ts", 9 9 "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta", 10 - "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' {} \\;", 10 + "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' {} \\;", 11 11 "wrangler-dev": "wrangler dev", 12 12 "build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node", 13 13 "build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node", ··· 26 26 "@atproto/sync": "^0.1.23", 27 27 "@atproto/syntax": "^0.3.3", 28 28 "@atproto/xrpc": "^0.6.9", 29 + "@atproto/xrpc-server": "^0.7.19", 29 30 "@hono/node-server": "^1.14.3", 30 31 "@mdx-js/loader": "^3.1.0", 31 32 "@mdx-js/react": "^3.1.0", 32 33 "@next/bundle-analyzer": "^15.3.2", 33 34 "@next/mdx": "15.3.2", 35 + "@radix-ui/react-dialog": "^1.1.14", 34 36 "@radix-ui/react-dropdown-menu": "^2.1.14", 35 37 "@radix-ui/react-popover": "^1.1.13", 36 38 "@radix-ui/react-slider": "^1.3.4",
+3 -3
supabase/database.types.ts
··· 690 690 identity: string 691 691 publication: string 692 692 record: Json 693 - uri: string | null 693 + uri: string 694 694 } 695 695 Insert: { 696 696 created_at?: string 697 697 identity: string 698 698 publication: string 699 699 record: Json 700 - uri?: string | null 700 + uri: string 701 701 } 702 702 Update: { 703 703 created_at?: string 704 704 identity?: string 705 705 publication?: string 706 706 record?: Json 707 - uri?: string | null 707 + uri?: string 708 708 } 709 709 Relationships: [ 710 710 {