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 import { Block } from "components/Blocks/Block"; 18 import { TID } from "@atproto/common"; 19 import { supabaseServerClient } from "supabase/serverClient"; 20 - import { scanIndex, scanIndexLocal } from "src/replicache/utils"; 21 import type { Fact } from "src/replicache"; 22 import type { Attribute } from "src/replicache/attributes"; 23 import { ··· 30 import { Json } from "supabase/database.types"; 31 import { $Typed, UnicodeString } from "@atproto/api"; 32 import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 33 34 export async function publishToPublication({ 35 root_entity, 36 - blocks, 37 publication_uri, 38 leaflet_id, 39 title, 40 description, 41 }: { 42 root_entity: string; 43 - blocks: Block[]; 44 publication_uri: string; 45 leaflet_id: string; 46 title?: string; ··· 48 }) { 49 const oauthClient = await createOauthClient(); 50 let identity = await getIdentityData(); 51 - if (!identity || !identity.atp_did) return null; 52 53 let credentialSession = await oauthClient.restore(identity.atp_did); 54 let agent = new AtpBaseClient( ··· 60 .eq("publication", publication_uri) 61 .eq("leaflet", leaflet_id) 62 .single(); 63 - if (!draft || identity.atp_did !== draft?.publications?.identity_did) return; 64 let { data } = await supabaseServerClient.rpc("get_facts", { 65 root: root_entity, 66 }); 67 68 - let scan = scanIndexLocal((data as unknown as Fact<Attribute>[]) || []); 69 let images = blocks 70 .filter((b) => b.type === "image") 71 .map((b) => scan.eav(b.value, "block/image")[0]); ··· 130 .eq("publication", publication_uri), 131 ]); 132 133 - return { rkey }; 134 } 135 136 function blocksToRecord(
··· 17 import { Block } from "components/Blocks/Block"; 18 import { TID } from "@atproto/common"; 19 import { supabaseServerClient } from "supabase/serverClient"; 20 + import { scanIndexLocal } from "src/replicache/utils"; 21 import type { Fact } from "src/replicache"; 22 import type { Attribute } from "src/replicache/attributes"; 23 import { ··· 30 import { Json } from "supabase/database.types"; 31 import { $Typed, UnicodeString } from "@atproto/api"; 32 import { List, parseBlocksToList } from "src/utils/parseBlocksToList"; 33 + import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks"; 34 35 export async function publishToPublication({ 36 root_entity, 37 publication_uri, 38 leaflet_id, 39 title, 40 description, 41 }: { 42 root_entity: string; 43 publication_uri: string; 44 leaflet_id: string; 45 title?: string; ··· 47 }) { 48 const oauthClient = await createOauthClient(); 49 let identity = await getIdentityData(); 50 + if (!identity || !identity.atp_did) throw new Error("No Identity"); 51 52 let credentialSession = await oauthClient.restore(identity.atp_did); 53 let agent = new AtpBaseClient( ··· 59 .eq("publication", publication_uri) 60 .eq("leaflet", leaflet_id) 61 .single(); 62 + if (!draft || identity.atp_did !== draft?.publications?.identity_did) 63 + throw new Error("No draft or not publisher"); 64 let { data } = await supabaseServerClient.rpc("get_facts", { 65 root: root_entity, 66 }); 67 + let facts = (data as unknown as Fact<Attribute>[]) || []; 68 + let blocks = getBlocksWithTypeLocal(facts, root_entity); 69 70 + let scan = scanIndexLocal(facts); 71 let images = blocks 72 .filter((b) => b.type === "image") 73 .map((b) => scan.eav(b.value, "block/image")[0]); ··· 132 .eq("publication", publication_uri), 133 ]); 134 135 + return { rkey, record }; 136 } 137 138 function blocksToRecord(
+25 -10
app/[leaflet_id]/Actions.tsx
··· 4 getPublicationURL, 5 } from "app/lish/createPub/getPublicationURL"; 6 import { ActionButton } from "components/ActionBar/ActionButton"; 7 - import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 8 import { GoBackSmall } from "components/Icons/GoBackSmall"; 9 import { PublishSmall } from "components/Icons/PublishSmall"; 10 import { useIdentityData } from "components/IdentityProvider"; 11 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 12 import { useToaster } from "components/Toast"; 13 import { DotLoader } from "components/utils/DotLoader"; 14 - import { publications } from "drizzle/schema"; 15 import Link from "next/link"; 16 - import { useParams } from "next/navigation"; 17 import { useState } from "react"; 18 import { useBlocks } from "src/hooks/queries/useBlocks"; 19 - import { useEntity, useReplicache } from "src/replicache"; 20 import { Json } from "supabase/database.types"; 21 export const BackToPubButton = (props: { 22 publication: { 23 identity_did: string; ··· 27 uri: string; 28 }; 29 }) => { 30 - let { identity } = useIdentityData(); 31 return ( 32 <Link 33 href={`${getBasePublicationURL(props.publication)}/dashboard`} ··· 42 }; 43 44 export const PublishButton = () => { 45 let [isLoading, setIsLoading] = useState(false); 46 let { data: pub, mutate } = useLeafletPublicationData(); 47 - let identity = useIdentityData(); 48 let { permission_token, rootEntity } = useReplicache(); 49 - let rootPage = useEntity(rootEntity, "root/page")[0]; 50 - let blocks = useBlocks(rootPage?.data.value); 51 let toaster = useToaster(); 52 return ( 53 <ActionButton 54 primary 55 icon={<PublishSmall className="shrink-0" />} 56 - label={isLoading ? <DotLoader /> : pub?.doc ? "Update!" : "Publish!"} 57 onClick={async () => { 58 if (!pub || !pub.publications) return; 59 setIsLoading(true); 60 let doc = await publishToPublication({ 61 root_entity: rootEntity, 62 - blocks, 63 publication_uri: pub.publications.uri, 64 leaflet_id: permission_token.id, 65 title: pub.title,
··· 4 getPublicationURL, 5 } from "app/lish/createPub/getPublicationURL"; 6 import { ActionButton } from "components/ActionBar/ActionButton"; 7 import { GoBackSmall } from "components/Icons/GoBackSmall"; 8 import { PublishSmall } from "components/Icons/PublishSmall"; 9 import { useIdentityData } from "components/IdentityProvider"; 10 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 11 import { useToaster } from "components/Toast"; 12 import { DotLoader } from "components/utils/DotLoader"; 13 import Link from "next/link"; 14 + import { useParams, useRouter } from "next/navigation"; 15 + import router from "next/router"; 16 import { useState } from "react"; 17 import { useBlocks } from "src/hooks/queries/useBlocks"; 18 + import { useReplicache, useEntity } from "src/replicache"; 19 import { Json } from "supabase/database.types"; 20 + 21 export const BackToPubButton = (props: { 22 publication: { 23 identity_did: string; ··· 27 uri: string; 28 }; 29 }) => { 30 return ( 31 <Link 32 href={`${getBasePublicationURL(props.publication)}/dashboard`} ··· 41 }; 42 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 = () => { 63 let [isLoading, setIsLoading] = useState(false); 64 let { data: pub, mutate } = useLeafletPublicationData(); 65 let { permission_token, rootEntity } = useReplicache(); 66 let toaster = useToaster(); 67 + 68 return ( 69 <ActionButton 70 primary 71 icon={<PublishSmall className="shrink-0" />} 72 + label={isLoading ? <DotLoader /> : "Update!"} 73 onClick={async () => { 74 if (!pub || !pub.publications) return; 75 setIsLoading(true); 76 let doc = await publishToPublication({ 77 root_entity: rootEntity, 78 publication_uri: pub.publications.uri, 79 leaflet_id: permission_token.id, 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 import { createIdentity } from "actions/createIdentity"; 3 import { drizzle } from "drizzle-orm/postgres-js"; 4 import { cookies } from "next/headers"; 5 import { redirect } from "next/navigation"; ··· 9 import { setAuthToken } from "src/auth"; 10 11 import { supabaseServerClient } from "supabase/serverClient"; 12 13 type OauthRequestClientState = { 14 redirect: string | null; 15 }; 16 export async function GET( 17 req: NextRequest, 18 props: { params: Promise<{ route: string; handle?: string }> }, ··· 29 const handle = searchParams.get("handle") as string; 30 // Put originating page here! 31 let redirect = searchParams.get("redirect_url"); 32 - let state: OauthRequestClientState = { redirect }; 33 34 // Revoke any pending authentication requests if the connection is closed (optional) 35 const ac = new AbortController(); ··· 69 .from("identities") 70 .update({ atp_did: session.did }) 71 .eq("id", data.data.identity); 72 - return redirect(redirectPath); 73 } 74 const client = postgres(process.env.DB_URL as string, { 75 idle_timeout: 5, ··· 93 console.log("authorize() was called with state:", state); 94 95 console.log("User authenticated as:", session.did); 96 } catch (e) { 97 redirect(redirectPath); 98 } 99 - return redirect(redirectPath); 100 } 101 default: 102 return NextResponse.json({ error: "Invalid route" }, { status: 404 }); 103 } 104 }
··· 1 import { createIdentity } from "actions/createIdentity"; 2 + import { subscribeToPublication } from "app/lish/subscribe"; 3 import { drizzle } from "drizzle-orm/postgres-js"; 4 import { cookies } from "next/headers"; 5 import { redirect } from "next/navigation"; ··· 9 import { setAuthToken } from "src/auth"; 10 11 import { supabaseServerClient } from "supabase/serverClient"; 12 + import { URLSearchParams } from "url"; 13 + import { 14 + ActionAfterSignIn, 15 + parseActionFromSearchParam, 16 + } from "./afterSignInActions"; 17 18 type OauthRequestClientState = { 19 redirect: string | null; 20 + action: ActionAfterSignIn | null; 21 }; 22 + 23 export async function GET( 24 req: NextRequest, 25 props: { params: Promise<{ route: string; handle?: string }> }, ··· 36 const handle = searchParams.get("handle") as string; 37 // Put originating page here! 38 let redirect = searchParams.get("redirect_url"); 39 + let action = parseActionFromSearchParam(searchParams.get("action")); 40 + let state: OauthRequestClientState = { redirect, action }; 41 42 // Revoke any pending authentication requests if the connection is closed (optional) 43 const ac = new AbortController(); ··· 77 .from("identities") 78 .update({ atp_did: session.did }) 79 .eq("id", data.data.identity); 80 + 81 + return handleAction(s.action, redirectPath); 82 } 83 const client = postgres(process.env.DB_URL as string, { 84 idle_timeout: 5, ··· 102 console.log("authorize() was called with state:", state); 103 104 console.log("User authenticated as:", session.did); 105 + return handleAction(s.action, redirectPath); 106 } catch (e) { 107 redirect(redirectPath); 108 } 109 } 110 default: 111 return NextResponse.json({ error: "Invalid route" }, { status: 404 }); 112 } 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 "use client"; 2 import { ButtonPrimary } from "components/Buttons"; 3 - import { useEffect, useState } from "react"; 4 import { Input } from "components/Input"; 5 import { useIdentityData } from "components/IdentityProvider"; 6 - import { SecondaryAuthTokenContextImpl } from "twilio/lib/rest/accounts/v1/secondaryAuthToken"; 7 import { 8 confirmEmailAuthToken, 9 requestAuthEmailToken, ··· 11 import { subscribeToPublicationWithEmail } from "actions/subscribeToPublicationWithEmail"; 12 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 13 import { ShareSmall } from "components/Icons/ShareSmall"; 14 15 type State = 16 | { state: "email" } ··· 166 </div> 167 ); 168 };
··· 1 "use client"; 2 import { ButtonPrimary } from "components/Buttons"; 3 + import { useActionState, useState } from "react"; 4 import { Input } from "components/Input"; 5 import { useIdentityData } from "components/IdentityProvider"; 6 import { 7 confirmEmailAuthToken, 8 requestAuthEmailToken, ··· 10 import { subscribeToPublicationWithEmail } from "actions/subscribeToPublicationWithEmail"; 11 import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 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"; 21 22 type State = 23 | { state: "email" } ··· 173 </div> 174 ); 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 import { TextBlock } from "./TextBlock"; 16 import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 17 import { BskyAgent } from "@atproto/api"; 18 19 export async function generateMetadata(props: { 20 params: Promise<{ publication: string; did: string; rkey: string }>; ··· 52 let [{ data: document }, { data: profile }] = await Promise.all([ 53 supabaseServerClient 54 .from("documents") 55 - .select("*, documents_in_publications(publications(*))") 56 .eq( 57 "uri", 58 AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey), ··· 122 return <Block block={b} did={did} key={index} />; 123 })} 124 </div> 125 </div> 126 </div> 127 </div>
··· 15 import { TextBlock } from "./TextBlock"; 16 import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 17 import { BskyAgent } from "@atproto/api"; 18 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 19 20 export async function generateMetadata(props: { 21 params: Promise<{ publication: string; did: string; rkey: string }>; ··· 53 let [{ data: document }, { data: profile }] = await Promise.all([ 54 supabaseServerClient 55 .from("documents") 56 + .select( 57 + "*, documents_in_publications(publications(*, publication_subscriptions(*)))", 58 + ) 59 .eq( 60 "uri", 61 AtUri.make(did, ids.PubLeafletDocument, (await props.params).rkey), ··· 125 return <Block block={b} did={did} key={index} />; 126 })} 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 + /> 138 </div> 139 </div> 140 </div>
+11 -6
app/lish/[did]/[publication]/page.tsx
··· 2 import { Metadata } from "next"; 3 4 import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 5 - import React from "react"; 6 import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 7 import { AtUri } from "@atproto/syntax"; 8 - import { 9 - AtpBaseClient, 10 - PubLeafletDocument, 11 - PubLeafletPublication, 12 - } from "lexicons/api"; 13 import Link from "next/link"; 14 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 15 import { BskyAgent } from "@atproto/api"; 16 17 export async function generateMetadata(props: { 18 params: Promise<{ publication: string; did: string }>; ··· 49 .from("publications") 50 .select( 51 `*, 52 documents_in_publications(documents(*)) 53 `, 54 ) ··· 97 </a> 98 </p> 99 )} 100 </div> 101 <div className="publicationPostList w-full flex flex-col gap-4"> 102 {publication.documents_in_publications
··· 2 import { Metadata } from "next"; 3 4 import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; 5 import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 6 import { AtUri } from "@atproto/syntax"; 7 + import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 8 import Link from "next/link"; 9 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 10 import { BskyAgent } from "@atproto/api"; 11 + import { SubscribeWithBluesky } from "app/lish/Subscribe"; 12 + import React from "react"; 13 14 export async function generateMetadata(props: { 15 params: Promise<{ publication: string; did: string }>; ··· 46 .from("publications") 47 .select( 48 `*, 49 + publication_subscriptions(*), 50 documents_in_publications(documents(*)) 51 `, 52 ) ··· 95 </a> 96 </p> 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> 105 </div> 106 <div className="publicationPostList w-full flex flex-col gap-4"> 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 }, 32 }); 33 let firehose = new Firehose({ 34 excludeAccount: true, 35 excludeIdentity: true, 36 runner,
··· 31 }, 32 }); 33 let firehose = new Firehose({ 34 + subscriptionReconnectDelay: 3000, 35 excludeAccount: true, 36 excludeIdentity: true, 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 identity: text("identity").notNull(), 211 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 212 record: jsonb("record").notNull(), 213 - uri: text("uri"), 214 }, 215 (table) => { 216 return {
··· 210 identity: text("identity").notNull(), 211 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 212 record: jsonb("record").notNull(), 213 + uri: text("uri").notNull(), 214 }, 215 (table) => { 216 return {
+7 -2
middleware.ts
··· 42 let pub = routes?.publication_domains[0]?.publications; 43 if (pub) { 44 let cookie = req.cookies.get("external_auth_token"); 45 - if (!cookie && !hostname.includes("leaflet.pub")) { 46 return initiateAuthCallback(req); 47 } 48 let aturi = new AtUri(pub?.uri); ··· 74 ts: string; 75 }; 76 async function initiateAuthCallback(req: NextRequest) { 77 let token: CROSS_SITE_AUTH_REQUEST = { 78 - redirect: req.url, 79 ts: new Date().toISOString(), 80 }; 81 let payload = btoa(JSON.stringify(token));
··· 42 let pub = routes?.publication_domains[0]?.publications; 43 if (pub) { 44 let cookie = req.cookies.get("external_auth_token"); 45 + if ( 46 + (!cookie || req.nextUrl.searchParams.has("refreshAuth")) && 47 + !hostname.includes("leaflet.pub") 48 + ) { 49 return initiateAuthCallback(req); 50 } 51 let aturi = new AtUri(pub?.uri); ··· 77 ts: string; 78 }; 79 async function initiateAuthCallback(req: NextRequest) { 80 + let redirectUrl = new URL(req.url); 81 + redirectUrl.searchParams.delete("refreshAuth"); 82 let token: CROSS_SITE_AUTH_REQUEST = { 83 + redirect: redirectUrl.toString(), 84 ts: new Date().toISOString(), 85 }; 86 let payload = btoa(JSON.stringify(token));
+152 -3
package-lock.json
··· 16 "@atproto/sync": "^0.1.23", 17 "@atproto/syntax": "^0.3.3", 18 "@atproto/xrpc": "^0.6.9", 19 "@hono/node-server": "^1.14.3", 20 "@mdx-js/loader": "^3.1.0", 21 "@mdx-js/react": "^3.1.0", 22 "@next/bundle-analyzer": "^15.3.2", 23 "@next/mdx": "15.3.2", 24 "@radix-ui/react-dropdown-menu": "^2.1.14", 25 "@radix-ui/react-popover": "^1.1.13", 26 "@radix-ui/react-slider": "^1.3.4", ··· 536 } 537 }, 538 "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==", 542 "license": "MIT", 543 "dependencies": { 544 "@atproto/common": "^0.4.11", ··· 2945 "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", 2946 "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", 2947 "license": "MIT", 2948 "peerDependencies": { 2949 "@types/react": "*", 2950 "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
··· 16 "@atproto/sync": "^0.1.23", 17 "@atproto/syntax": "^0.3.3", 18 "@atproto/xrpc": "^0.6.9", 19 + "@atproto/xrpc-server": "^0.7.19", 20 "@hono/node-server": "^1.14.3", 21 "@mdx-js/loader": "^3.1.0", 22 "@mdx-js/react": "^3.1.0", 23 "@next/bundle-analyzer": "^15.3.2", 24 "@next/mdx": "15.3.2", 25 + "@radix-ui/react-dialog": "^1.1.14", 26 "@radix-ui/react-dropdown-menu": "^2.1.14", 27 "@radix-ui/react-popover": "^1.1.13", 28 "@radix-ui/react-slider": "^1.3.4", ··· 538 } 539 }, 540 "node_modules/@atproto/xrpc-server": { 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==", 544 "license": "MIT", 545 "dependencies": { 546 "@atproto/common": "^0.4.11", ··· 2947 "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", 2948 "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", 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 + }, 3097 "peerDependencies": { 3098 "@types/react": "*", 3099 "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+3 -1
package.json
··· 7 "dev": "next dev --turbo", 8 "publish-lexicons": "tsx lexicons/publish.ts", 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' {} \\;", 11 "wrangler-dev": "wrangler dev", 12 "build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node", 13 "build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node", ··· 26 "@atproto/sync": "^0.1.23", 27 "@atproto/syntax": "^0.3.3", 28 "@atproto/xrpc": "^0.6.9", 29 "@hono/node-server": "^1.14.3", 30 "@mdx-js/loader": "^3.1.0", 31 "@mdx-js/react": "^3.1.0", 32 "@next/bundle-analyzer": "^15.3.2", 33 "@next/mdx": "15.3.2", 34 "@radix-ui/react-dropdown-menu": "^2.1.14", 35 "@radix-ui/react-popover": "^1.1.13", 36 "@radix-ui/react-slider": "^1.3.4",
··· 7 "dev": "next dev --turbo", 8 "publish-lexicons": "tsx lexicons/publish.ts", 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/*/* ./lexicons/app/bsky/*/* --yes && find './lexicons/api' -type f -exec sed -i 's/\\.js'/'/g' {} \\;", 11 "wrangler-dev": "wrangler dev", 12 "build-appview": "esbuild appview/index.ts --outfile=appview/dist/index.js --bundle --platform=node", 13 "build-feed-service": "esbuild feeds/index.ts --outfile=feeds/dist/index.js --bundle --platform=node", ··· 26 "@atproto/sync": "^0.1.23", 27 "@atproto/syntax": "^0.3.3", 28 "@atproto/xrpc": "^0.6.9", 29 + "@atproto/xrpc-server": "^0.7.19", 30 "@hono/node-server": "^1.14.3", 31 "@mdx-js/loader": "^3.1.0", 32 "@mdx-js/react": "^3.1.0", 33 "@next/bundle-analyzer": "^15.3.2", 34 "@next/mdx": "15.3.2", 35 + "@radix-ui/react-dialog": "^1.1.14", 36 "@radix-ui/react-dropdown-menu": "^2.1.14", 37 "@radix-ui/react-popover": "^1.1.13", 38 "@radix-ui/react-slider": "^1.3.4",
+3 -3
supabase/database.types.ts
··· 690 identity: string 691 publication: string 692 record: Json 693 - uri: string | null 694 } 695 Insert: { 696 created_at?: string 697 identity: string 698 publication: string 699 record: Json 700 - uri?: string | null 701 } 702 Update: { 703 created_at?: string 704 identity?: string 705 publication?: string 706 record?: Json 707 - uri?: string | null 708 } 709 Relationships: [ 710 {
··· 690 identity: string 691 publication: string 692 record: Json 693 + uri: string 694 } 695 Insert: { 696 created_at?: string 697 identity: string 698 publication: string 699 record: Json 700 + uri: string 701 } 702 Update: { 703 created_at?: string 704 identity?: string 705 publication?: string 706 record?: Json 707 + uri?: string 708 } 709 Relationships: [ 710 {