From cf79767d47adcb04db1351daa0f8cc208c63a390 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Sat, 22 Mar 2025 12:27:02 -0400 Subject: [PATCH] Feature/bsky pubs/demo (#131) * WIP * add super basic lexicon functionality * WIP * init publications review view * added some small state stuff in you're already subbed * added a writer home and did more to the subscribe flow * create publication flow (and some updates to the imput with label component) * add basic bluesky pub integration! A lexicon! Oauth! All the stuff! * rename appview start script * set oauth client redirect state * wired up the home pub list * wiring up the create pub flow * add heading and image blocks, handle image upload * moved some shit around in the nav to get urls to work, wired up the pub page * added publish button to leaflets * add drafts and publish button * add subscribing via email! * wired up post list and post page, styled pst page * cleaning up some comments * add runner to appview * clean up * move oauth-metadata out from route file * add email to subscribers! --------- Co-authored-by: celine --- actions/createIdentity.ts | 7 +- actions/createPublication.ts | 22 + actions/createPublicationDraft.ts | 16 + actions/emailAuth.ts | 36 +- actions/getIdentityData.ts | 21 +- actions/login.ts | 38 +- actions/publishToPublication.ts | 267 ++ actions/signInWithBluesky.ts | 0 actions/subscribeToPublicationWithEmail.ts | 16 + app/[leaflet_id]/page.tsx | 17 +- app/api/oauth/[route]/oauth-metadata.ts | 41 + app/api/oauth/[route]/route.ts | 109 + app/home/AccountSettings.tsx | 8 + app/lish/AlphaBanner.tsx | 10 + app/lish/Footer.tsx | 97 + app/lish/LishHome.tsx | 214 ++ app/lish/PostList.tsx | 86 + app/lish/Subscribe.tsx | 167 ++ .../[publication]/CallToActionButton.tsx | 31 + .../[handle]/[publication]/NewDraftButton.tsx | 20 + .../[handle]/[publication]/[rkey]/page.tsx | 85 + app/lish/[handle]/[publication]/layout.tsx | 30 + app/lish/[handle]/[publication]/page.tsx | 75 + .../usePublicationRelationship.ts | 16 + app/lish/createPub/CreatePubForm.tsx | 61 + app/lish/createPub/page.tsx | 17 + app/lish/page.tsx | 13 + appview/index.ts | 125 + .../Blocks/RSVPBlock/ContactDetailsForm.tsx | 4 +- components/Input.tsx | 27 +- components/Layout.tsx | 17 - components/MobileFooter.tsx | 2 + components/Pages/index.tsx | 17 +- components/Providers/PublicationContext.tsx | 32 + .../ShareOptions/PublicationOptions.tsx | 160 ++ components/ShareOptions/index.tsx | 6 +- components/ThemeManager/ThemeSetter.tsx | 4 +- drizzle/relations.ts | 52 +- drizzle/schema.ts | 62 +- lexicons/blocks.ts | 62 + lexicons/build.ts | 25 + lexicons/com/atproto/label/defs.json | 156 ++ lexicons/com/atproto/repo/applyWrites.json | 131 + lexicons/com/atproto/repo/createRecord.json | 73 + lexicons/com/atproto/repo/defs.json | 14 + lexicons/com/atproto/repo/deleteRecord.json | 57 + lexicons/com/atproto/repo/describeRepo.json | 51 + lexicons/com/atproto/repo/getRecord.json | 49 + lexicons/com/atproto/repo/importRepo.json | 13 + .../com/atproto/repo/listMissingBlobs.json | 44 + lexicons/com/atproto/repo/listRecords.json | 69 + lexicons/com/atproto/repo/putRecord.json | 74 + lexicons/com/atproto/repo/strongRef.json | 15 + lexicons/com/atproto/repo/uploadBlob.json | 23 + lexicons/document.ts | 26 + lexicons/out/pub_leaflet_blocks_header.json | 20 + lexicons/out/pub_leaflet_blocks_image.json | 44 + lexicons/out/pub_leaflet_blocks_text.json | 15 + lexicons/out/pub_leaflet_document.json | 49 + .../out/pub_leaflet_pages_linearDocument.json | 51 + lexicons/out/pub_leaflet_post.json | 32 + lexicons/out/pub_leaflet_publication.json | 27 + lexicons/pages/LinearDocument.ts | 36 + lexicons/pages/index.ts | 1 + lexicons/publication.ts | 35 + lexicons/src/index.ts | 446 +++ lexicons/src/lexicons.ts | 1230 +++++++++ lexicons/src/types/com/atproto/label/defs.ts | 142 + .../src/types/com/atproto/repo/applyWrites.ts | 163 ++ .../types/com/atproto/repo/createRecord.ts | 64 + lexicons/src/types/com/atproto/repo/defs.ts | 27 + .../types/com/atproto/repo/deleteRecord.ts | 59 + .../types/com/atproto/repo/describeRepo.ts | 45 + .../src/types/com/atproto/repo/getRecord.ts | 56 + .../src/types/com/atproto/repo/importRepo.ts | 32 + .../com/atproto/repo/listMissingBlobs.ts | 55 + .../src/types/com/atproto/repo/listRecords.ts | 67 + .../src/types/com/atproto/repo/putRecord.ts | 66 + .../src/types/com/atproto/repo/strongRef.ts | 27 + .../src/types/com/atproto/repo/uploadBlob.ts | 37 + .../src/types/pub/leaflet/blocks/header.ts | 27 + .../src/types/pub/leaflet/blocks/image.ts | 45 + lexicons/src/types/pub/leaflet/blocks/text.ts | 26 + lexicons/src/types/pub/leaflet/document.ts | 32 + .../types/pub/leaflet/pages/linearDocument.ts | 57 + lexicons/src/types/pub/leaflet/post.ts | 30 + lexicons/src/types/pub/leaflet/publication.ts | 28 + lexicons/src/util.ts | 82 + lexicons/utils.ts | 104 + next.config.js | 7 + package-lock.json | 2429 +++++++++++++++-- package.json | 14 +- src/atproto-oauth.ts | 74 + src/utils/generateJoseKey.js | 5 + supabase/database.types.ts | 184 +- ...201458_add_publication_and_bsky_tables.sql | 153 ++ ...00353_add_drafts_in_publications_table.sql | 35 + ..._add_subscribers_to_publications_table.sql | 33 + supabase/serverClient.ts | 6 + tailwind.config.js | 1 + tsconfig.json | 1 + 101 files changed, 8835 insertions(+), 274 deletions(-) create mode 100644 actions/createPublication.ts create mode 100644 actions/createPublicationDraft.ts create mode 100644 actions/publishToPublication.ts create mode 100644 actions/signInWithBluesky.ts create mode 100644 actions/subscribeToPublicationWithEmail.ts create mode 100644 app/api/oauth/[route]/oauth-metadata.ts create mode 100644 app/api/oauth/[route]/route.ts create mode 100644 app/lish/AlphaBanner.tsx create mode 100644 app/lish/Footer.tsx create mode 100644 app/lish/LishHome.tsx create mode 100644 app/lish/PostList.tsx create mode 100644 app/lish/Subscribe.tsx create mode 100644 app/lish/[handle]/[publication]/CallToActionButton.tsx create mode 100644 app/lish/[handle]/[publication]/NewDraftButton.tsx create mode 100644 app/lish/[handle]/[publication]/[rkey]/page.tsx create mode 100644 app/lish/[handle]/[publication]/layout.tsx create mode 100644 app/lish/[handle]/[publication]/page.tsx create mode 100644 app/lish/[handle]/[publication]/usePublicationRelationship.ts create mode 100644 app/lish/createPub/CreatePubForm.tsx create mode 100644 app/lish/createPub/page.tsx create mode 100644 app/lish/page.tsx create mode 100644 appview/index.ts create mode 100644 components/Providers/PublicationContext.tsx create mode 100644 components/ShareOptions/PublicationOptions.tsx create mode 100644 lexicons/blocks.ts create mode 100644 lexicons/build.ts create mode 100644 lexicons/com/atproto/label/defs.json create mode 100644 lexicons/com/atproto/repo/applyWrites.json create mode 100644 lexicons/com/atproto/repo/createRecord.json create mode 100644 lexicons/com/atproto/repo/defs.json create mode 100644 lexicons/com/atproto/repo/deleteRecord.json create mode 100644 lexicons/com/atproto/repo/describeRepo.json create mode 100644 lexicons/com/atproto/repo/getRecord.json create mode 100644 lexicons/com/atproto/repo/importRepo.json create mode 100644 lexicons/com/atproto/repo/listMissingBlobs.json create mode 100644 lexicons/com/atproto/repo/listRecords.json create mode 100644 lexicons/com/atproto/repo/putRecord.json create mode 100644 lexicons/com/atproto/repo/strongRef.json create mode 100644 lexicons/com/atproto/repo/uploadBlob.json create mode 100644 lexicons/document.ts create mode 100644 lexicons/out/pub_leaflet_blocks_header.json create mode 100644 lexicons/out/pub_leaflet_blocks_image.json create mode 100644 lexicons/out/pub_leaflet_blocks_text.json create mode 100644 lexicons/out/pub_leaflet_document.json create mode 100644 lexicons/out/pub_leaflet_pages_linearDocument.json create mode 100644 lexicons/out/pub_leaflet_post.json create mode 100644 lexicons/out/pub_leaflet_publication.json create mode 100644 lexicons/pages/LinearDocument.ts create mode 100644 lexicons/pages/index.ts create mode 100644 lexicons/publication.ts create mode 100644 lexicons/src/index.ts create mode 100644 lexicons/src/lexicons.ts create mode 100644 lexicons/src/types/com/atproto/label/defs.ts create mode 100644 lexicons/src/types/com/atproto/repo/applyWrites.ts create mode 100644 lexicons/src/types/com/atproto/repo/createRecord.ts create mode 100644 lexicons/src/types/com/atproto/repo/defs.ts create mode 100644 lexicons/src/types/com/atproto/repo/deleteRecord.ts create mode 100644 lexicons/src/types/com/atproto/repo/describeRepo.ts create mode 100644 lexicons/src/types/com/atproto/repo/getRecord.ts create mode 100644 lexicons/src/types/com/atproto/repo/importRepo.ts create mode 100644 lexicons/src/types/com/atproto/repo/listMissingBlobs.ts create mode 100644 lexicons/src/types/com/atproto/repo/listRecords.ts create mode 100644 lexicons/src/types/com/atproto/repo/putRecord.ts create mode 100644 lexicons/src/types/com/atproto/repo/strongRef.ts create mode 100644 lexicons/src/types/com/atproto/repo/uploadBlob.ts create mode 100644 lexicons/src/types/pub/leaflet/blocks/header.ts create mode 100644 lexicons/src/types/pub/leaflet/blocks/image.ts create mode 100644 lexicons/src/types/pub/leaflet/blocks/text.ts create mode 100644 lexicons/src/types/pub/leaflet/document.ts create mode 100644 lexicons/src/types/pub/leaflet/pages/linearDocument.ts create mode 100644 lexicons/src/types/pub/leaflet/post.ts create mode 100644 lexicons/src/types/pub/leaflet/publication.ts create mode 100644 lexicons/src/util.ts create mode 100644 lexicons/utils.ts create mode 100644 src/atproto-oauth.ts create mode 100644 src/utils/generateJoseKey.js create mode 100644 supabase/migrations/20250320201458_add_publication_and_bsky_tables.sql create mode 100644 supabase/migrations/20250321200353_add_drafts_in_publications_table.sql create mode 100644 supabase/migrations/20250321233105_add_subscribers_to_publications_table.sql create mode 100644 supabase/serverClient.ts diff --git a/actions/createIdentity.ts b/actions/createIdentity.ts index 51ec0f7d..1b0eed25 100644 --- a/actions/createIdentity.ts +++ b/actions/createIdentity.ts @@ -12,7 +12,10 @@ import postgres from "postgres"; import { v7 } from "uuid"; import { sql } from "drizzle-orm"; import { cookies } from "next/headers"; -export async function createIdentity(db: PostgresJsDatabase) { +export async function createIdentity( + db: PostgresJsDatabase, + data?: { email?: string; atp_did?: string }, +) { return db.transaction(async (tx) => { // Create a new entity set let [entity_set] = await tx.insert(entity_sets).values({}).returning(); @@ -41,7 +44,7 @@ export async function createIdentity(db: PostgresJsDatabase) { .returning(); let [identity] = await tx .insert(identities) - .values({ home_page: permissionToken.id }) + .values({ home_page: permissionToken.id, ...data }) .returning(); return identity; }); diff --git a/actions/createPublication.ts b/actions/createPublication.ts new file mode 100644 index 00000000..868446c9 --- /dev/null +++ b/actions/createPublication.ts @@ -0,0 +1,22 @@ +"use server"; +import { TID } from "@atproto/common"; +import { AtpBaseClient } from "lexicons/src"; +import { createOauthClient } from "src/atproto-oauth"; +import { getIdentityData } from "actions/getIdentityData"; + +export async function createPublication(name: string) { + const oauthClient = await createOauthClient(); + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) return; + let credentialSession = await oauthClient.restore(identity.atp_did); + let agent = new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession), + ); + let result = await agent.pub.leaflet.publication.create( + { repo: credentialSession.did!, rkey: TID.nextStr(), validate: false }, + { + name, + }, + ); + console.log(result); +} diff --git a/actions/createPublicationDraft.ts b/actions/createPublicationDraft.ts new file mode 100644 index 00000000..bd640fcf --- /dev/null +++ b/actions/createPublicationDraft.ts @@ -0,0 +1,16 @@ +"use server"; +import { getIdentityData } from "actions/getIdentityData"; +import { createNewLeaflet } from "./createNewLeaflet"; +import { supabaseServerClient } from "supabase/serverClient"; + +export async function createPublicationDraft(publication_uri: string) { + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) return null; + let newLeaflet = await createNewLeaflet("doc", false); + console.log( + await supabaseServerClient + .from("leaflets_in_publications") + .insert({ publication: publication_uri, leaflet: newLeaflet, doc: null }), + ); + return newLeaflet; +} diff --git a/actions/emailAuth.ts b/actions/emailAuth.ts index 951caf64..4fc6aaee 100644 --- a/actions/emailAuth.ts +++ b/actions/emailAuth.ts @@ -6,9 +6,9 @@ import postgres from "postgres"; import { email_auth_tokens, identities } from "drizzle/schema"; import { and, eq } from "drizzle-orm"; import { cookies } from "next/headers"; +import { createIdentity } from "./createIdentity"; async function sendAuthCode(email: string, code: string) { - console.log(code); if (process.env.NODE_ENV === "development") { console.log("Auth code:", code); return; @@ -72,31 +72,61 @@ export async function confirmEmailAuthToken(tokenId: string, code: string) { .from(email_auth_tokens) .where(eq(email_auth_tokens.id, tokenId)); - if (!token) { + if (!token || !token.email) { + console.log("no token?"); client.end(); return null; } if (token.confirmation_code !== code) { + console.log("wrong code???"); client.end(); return null; } if (token.confirmed) { + console.log("already confirmed?????"); client.end(); return null; } + let authToken = cookies().get("auth_token"); + if (authToken) { + let [existingToken] = await db + .select() + .from(email_auth_tokens) + .rightJoin(identities, eq(identities.id, email_auth_tokens.identity)) + .where(eq(email_auth_tokens.id, authToken.value)); + + if (existingToken) { + if (existingToken.identities?.email) { + console.log("wat"); + } + await db + .update(identities) + .set({ email: token.email }) + .where(eq(identities.id, existingToken.identities.id)); + client.end(); + return existingToken; + } + } + let identityID; let [identity] = await db .select() .from(identities) .where(eq(identities.email, token.email)); + if (!identity) { + let newIdentity = await createIdentity(db, { email: token.email }); + identityID = newIdentity.id; + } else { + identityID = identity.id; + } const [confirmedToken] = await db .update(email_auth_tokens) .set({ confirmed: true, - identity: identity?.id, + identity: identityID, }) .where( and( diff --git a/actions/getIdentityData.ts b/actions/getIdentityData.ts index e3821799..866fd7a0 100644 --- a/actions/getIdentityData.ts +++ b/actions/getIdentityData.ts @@ -1,5 +1,6 @@ "use server"; +import { IdResolver } from "@atproto/identity"; import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; import { Database } from "supabase/database.types"; @@ -9,6 +10,7 @@ let supabase = createServerClient( process.env.SUPABASE_SERVICE_ROLE_KEY as string, { cookies: {} }, ); +let idResolver = new IdResolver(); export async function getIdentityData() { let cookieStore = cookies(); let auth_token = cookieStore.get("auth_token")?.value; @@ -19,6 +21,7 @@ export async function getIdentityData() { `*, identities( *, + subscribers_to_publications(*), custom_domains(*), home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*)), permission_token_on_homepage(created_at, permission_tokens!inner(id, root_entity, permission_token_rights(*))) @@ -29,5 +32,21 @@ export async function getIdentityData() { .single() : null; if (!auth_res?.data?.identities) return null; - return auth_res.data.identities; + if (auth_res.data.identities.atp_did) { + //I should create a relationship table so I can do this in the above query + let { data: publications } = await supabase + .from("publications") + .select("*") + .eq("identity_did", auth_res.data.identities.atp_did); + let resolved_did = await idResolver.did.resolve( + auth_res.data.identities.atp_did, + ); + return { + ...auth_res.data.identities, + publications: publications || [], + resolved_did, + }; + } + + return { ...auth_res.data.identities, publications: [], resolved_did: null }; } diff --git a/actions/login.ts b/actions/login.ts index 20278e6a..0d4d758c 100644 --- a/actions/login.ts +++ b/actions/login.ts @@ -15,6 +15,7 @@ import { and, eq, isNull } from "drizzle-orm"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { v7 } from "uuid"; +import { createIdentity } from "./createIdentity"; export async function loginWithEmailToken( localLeaflets: { token: { id: string }; added_at: string }[], @@ -34,7 +35,7 @@ export async function loginWithEmailToken( eq(email_auth_tokens.confirmed, true), ), ); - if (!token) return null; + if (!token || !token.email) return null; if (token.identity) { let id = token.identity; if (localLeaflets.length > 0) @@ -75,39 +76,8 @@ export async function loginWithEmailToken( identity = existingIdentityFromCookie; } } else { - // Create a new entity set - let [entity_set] = await tx.insert(entity_sets).values({}).returning(); - // Create a root-entity - let [entity] = await tx - .insert(entities) - // And add it to that permission set - .values({ set: entity_set.id, id: v7() }) - .returning(); - //Create a new permission token - let [permissionToken] = await tx - .insert(permission_tokens) - .values({ root_entity: entity.id }) - .returning(); - //and give it all the permission on that entity set - let [rights] = await tx - .insert(permission_token_rights) - .values({ - token: permissionToken.id, - entity_set: entity_set.id, - read: true, - write: true, - create_token: true, - change_entity_set: true, - }) - .returning(); - let [newIdentity] = await tx - .insert(identities) - .values({ - home_page: permissionToken.id, - email: token.email, - }) - .returning(); - identity = newIdentity; + // Create a new identity + identity = await createIdentity(tx, { email: token.email }); } } diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts new file mode 100644 index 00000000..924f4243 --- /dev/null +++ b/actions/publishToPublication.ts @@ -0,0 +1,267 @@ +"use server"; + +import * as Y from "yjs"; +import * as base64 from "base64-js"; +import { createOauthClient } from "src/atproto-oauth"; +import { getIdentityData } from "actions/getIdentityData"; +import { + AtpBaseClient, + PubLeafletBlocksHeader, + PubLeafletBlocksText, + PubLeafletDocument, + PubLeafletPagesLinearDocument, +} from "lexicons/src"; +import { Block } from "components/Blocks/Block"; +import { TID } from "@atproto/common"; +import { supabaseServerClient } from "supabase/serverClient"; +import { scanIndex, scanIndexLocal } from "src/replicache/utils"; +import { Fact } from "src/replicache"; +import { Attributes } from "src/replicache/attributes"; +import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; +import { ids } from "lexicons/src/lexicons"; +import { OmitKey } from "lexicons/src/util"; +import { BlobRef } from "@atproto/lexicon"; +import { IdResolver } from "@atproto/identity"; +import { AtUri } from "@atproto/syntax"; + +const idResolver = new IdResolver(); +export async function publishToPublication( + root_entity: string, + blocks: Block[], + publication_uri: string, +) { + const oauthClient = await createOauthClient(); + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) return null; + + let credentialSession = await oauthClient.restore(identity.atp_did); + let agent = new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession), + ); + let { data } = await supabaseServerClient.rpc("get_facts", { + root: root_entity, + }); + console.log(data); + + let scan = scanIndexLocal( + (data as unknown as Fact[]) || [], + ); + const getBlockContent = (b: string) => { + let [content] = scan.eav(b, "block/text"); + if (!content) return ""; + let doc = new Y.Doc(); + const update = base64.toByteArray(content.data.value); + Y.applyUpdate(doc, update); + let nodes = doc.getXmlElement("prosemirror").toArray(); + let stringValue = YJSFragmentToString(nodes[0]); + return stringValue; + }; + let images = blocks + .filter((b) => b.type === "image") + .map((b) => scan.eav(b.value, "block/image")[0]); + let imageMap = new Map(); + await Promise.all( + images.map(async (b) => { + let data = await fetch(b.data.src); + let binary = await data.blob(); + let blob = await agent.com.atproto.repo.uploadBlob(binary, { + headers: { "Content-Type": binary.type }, + }); + console.log(blob); + imageMap.set(b.data.src, blob.data.blob); + }), + ); + + let title = "Untitled"; + let titleBlock = blocks.find((f) => f.type === "heading"); + if (titleBlock) title = getBlockContent(titleBlock.value); + let b: PubLeafletPagesLinearDocument.Block[] = blocksToRecord( + blocks, + imageMap, + scan, + ); + + let record: OmitKey = { + author: credentialSession.did!, + title, + publication: publication_uri, + pages: [ + { + $type: "pub.leaflet.pages.linearDocument", + blocks: b, + }, + ], + }; + let rkey = TID.nextStr(); + let result = await agent.pub.leaflet.document.create( + { repo: credentialSession.did!, rkey, validate: false }, + record, + ); + let html = blocksToHtml(blocks, imageMap, scan, publication_uri); + await sendPostToEmailSubscribers(publication_uri, { title, content: html }); + let handle = await idResolver.did.resolve(credentialSession.did!); + return { handle, rkey }; +} + +function blocksToRecord( + blocks: Block[], + imageMap: Map, + scan: ReturnType, +) { + const getBlockContent = (b: string) => { + let [content] = scan.eav(b, "block/text"); + if (!content) return ""; + let doc = new Y.Doc(); + const update = base64.toByteArray(content.data.value); + Y.applyUpdate(doc, update); + let nodes = doc.getXmlElement("prosemirror").toArray(); + let stringValue = YJSFragmentToString(nodes[0]); + return stringValue; + }; + return blocks.flatMap((b) => { + if (b.type !== "text" && b.type !== "heading" && b.type !== "image") + return []; + if (b.type === "heading") { + let [headingLevel] = scan.eav(b.value, "block/heading-level"); + + let stringValue = getBlockContent(b.value); + return [ + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.header", + level: headingLevel?.data.value || 1, + plaintext: stringValue, + }, + } as PubLeafletPagesLinearDocument.Block, + ]; + } + + if (b.type == "text") { + let stringValue = getBlockContent(b.value); + return [ + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: ids.PubLeafletBlocksText, + plaintext: stringValue, + }, + } as PubLeafletPagesLinearDocument.Block, + ]; + } + if (b.type == "image") { + let [image] = scan.eav(b.value, "block/image"); + if (!image) return []; + let blobref = imageMap.get(image.data.src); + if (!blobref) return []; + return [ + { + $type: "pub.leaflet.pages.linearDocument#block", + block: { + $type: "pub.leaflet.blocks.image", + image: blobref, + aspectRatio: { + height: image.data.height, + width: image.data.width, + }, + }, + } as PubLeafletPagesLinearDocument.Block, + ]; + } + return []; + }); +} + +function blocksToHtml( + blocks: Block[], + imageMap: Map, + scan: ReturnType, + publication_uri: string, +) { + const getBlockContent = (b: string) => { + let [content] = scan.eav(b, "block/text"); + if (!content) return ""; + let doc = new Y.Doc(); + const update = base64.toByteArray(content.data.value); + Y.applyUpdate(doc, update); + let nodes = doc.getXmlElement("prosemirror").toArray(); + let stringValue = YJSFragmentToString(nodes[0]); + return stringValue; + }; + return blocks + .flatMap((b) => { + if (b.type !== "text" && b.type !== "heading" && b.type !== "image") + return []; + if (b.type === "heading") { + let [headingLevel] = scan.eav(b.value, "block/heading-level"); + + let stringValue = getBlockContent(b.value); + let l = headingLevel?.data.value || 1; + return [`${stringValue}`]; + } + + if (b.type == "text") { + let stringValue = getBlockContent(b.value); + return `

${stringValue}

`; + } + if (b.type == "image") { + let [image] = scan.eav(b.value, "block/image"); + if (!image) return []; + let blobref = imageMap.get(image.data.src); + if (!blobref) return []; + let uri = new AtUri(publication_uri); + return ` + src="https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${uri.hostname}&cid=${(blobref as unknown as { $link: string })["$link"]}" + `; + } + return [""]; + }) + .join("\n"); +} + +async function sendPostToEmailSubscribers( + publication_uri: string, + post: { content: string; title: string }, +) { + let { data: publication } = await supabaseServerClient + .from("publications") + .select("*, subscribers_to_publications(*)") + .eq("uri", publication_uri) + .single(); + + let res = await fetch("https://api.postmarkapp.com/email/batch", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Postmark-Server-Token": process.env.POSTMARK_API_KEY!, + }, + body: JSON.stringify( + publication?.subscribers_to_publications.map((sub) => ({ + Headers: [ + { + Name: "List-Unsubscribe-Post", + Value: "List-Unsubscribe=One-Click", + }, + { + Name: "List-Unsubscribe", + Value: `<${"TODO"}/mail/unsubscribe?sub_id=${sub.identity}>`, + }, + ], + MessageStream: "broadcast", + From: `${publication.name} `, + Subject: post.title, + To: sub.identity, + HtmlBody: ` +

${publication.name}

+
+ ${post.content} +
+ This is a super alpha release! Ask Jared if you want to unsubscribe (sorry) + `, + TextBody: post.content, + })), + ), + }); +} diff --git a/actions/signInWithBluesky.ts b/actions/signInWithBluesky.ts new file mode 100644 index 00000000..e69de29b diff --git a/actions/subscribeToPublicationWithEmail.ts b/actions/subscribeToPublicationWithEmail.ts new file mode 100644 index 00000000..c56c0192 --- /dev/null +++ b/actions/subscribeToPublicationWithEmail.ts @@ -0,0 +1,16 @@ +"use server"; +import { supabaseServerClient } from "supabase/serverClient"; +import { getIdentityData } from "./getIdentityData"; + +export async function subscribeToPublicationWithEmail(publication: string) { + let identity = await getIdentityData(); + console.log("yoohooo"); + console.log(identity); + if (!identity || !identity.email) return null; + //This is an email relation!! + console.log( + await supabaseServerClient + .from("subscribers_to_publications") + .insert({ publication: publication, identity: identity.email }), + ); +} diff --git a/app/[leaflet_id]/page.tsx b/app/[leaflet_id]/page.tsx index e3771fd4..c1200861 100644 --- a/app/[leaflet_id]/page.tsx +++ b/app/[leaflet_id]/page.tsx @@ -12,6 +12,7 @@ import { scanIndexLocal } from "src/replicache/utils"; import { getRSVPData } from "actions/getRSVPData"; import { PageSWRDataProvider } from "components/PageSWRDataProvider"; import { getPollData } from "actions/pollActions"; +import { PublicationContextProvider } from "components/Providers/PublicationContext"; export const preferredRegion = ["sfo1"]; export const dynamic = "force-dynamic"; @@ -30,7 +31,7 @@ export default async function LeafletPage(props: Props) { let res = await supabase .from("permission_tokens") .select( - "*, permission_token_rights(*), custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*) ", + "*, permission_token_rights(*), custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*),leaflets_in_publications(publications(*)) ", ) .eq("id", props.params.leaflet_id) .single(); @@ -68,11 +69,15 @@ export default async function LeafletPage(props: Props) { leaflet_id={res.data.id} domains={res.data.custom_domain_routes} > - + + + ); } diff --git a/app/api/oauth/[route]/oauth-metadata.ts b/app/api/oauth/[route]/oauth-metadata.ts new file mode 100644 index 00000000..1697ded4 --- /dev/null +++ b/app/api/oauth/[route]/oauth-metadata.ts @@ -0,0 +1,41 @@ +import { OAuthClientMetadata } from "@atproto/oauth-client-node"; +const hostname = + process.env.NODE_ENV === "development" + ? "http://localhost:3000" + : "https://leaflet.pub"; + +const localconfig: OAuthClientMetadata = { + client_id: `http://localhost/?redirect_uri=${encodeURI(`http://127.0.0.1:3000/api/oauth/callback`)}&scope=${encodeURIComponent("atproto transition:generic")}`, + client_name: `Leaflet`, + client_uri: hostname, + redirect_uris: [`http://127.0.0.1:3000/api/oauth/callback`], + grant_types: [`authorization_code`, `refresh_token`], + response_types: [`code`], + application_type: `web`, + scope: "atproto transition:generic", + token_endpoint_auth_method: `none`, + dpop_bound_access_tokens: true, +}; + +const prodconfig: OAuthClientMetadata = { + client_id: `${hostname}/api/oauth/metadata`, + client_name: `Leaflet`, + client_uri: hostname, + logo_uri: `${hostname}/logo.png`, + tos_uri: `${hostname}/tos`, + policy_uri: `${hostname}/policy`, + redirect_uris: + process.env.NODE_ENV === "development" + ? [`http://127.0.0.1:3000/api/oauth/callback`] + : [`https://leaflet.pub/api/oauth/callback`], + grant_types: [`authorization_code`, `refresh_token`], + response_types: [`code`], + application_type: `web`, + scope: "atproto transition:generic", + token_endpoint_auth_method: `private_key_jwt`, + token_endpoint_auth_signing_alg: "ES256", + dpop_bound_access_tokens: true, + jwks_uri: `${hostname}/api/oauth/jwks`, +}; +export const oauth_metadata = + process.env.NODE_ENV === "development" ? localconfig : prodconfig; diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts new file mode 100644 index 00000000..c51f4b18 --- /dev/null +++ b/app/api/oauth/[route]/route.ts @@ -0,0 +1,109 @@ +import { OAuthClientMetadata } from "@atproto/oauth-client-node"; +import { createIdentity } from "actions/createIdentity"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { NextRequest, NextResponse } from "next/server"; +import postgres from "postgres"; +import { createOauthClient } from "src/atproto-oauth"; + +import { supabaseServerClient } from "supabase/serverClient"; + +type OauthRequestClientState = { + redirect: string | null; +}; +export async function GET( + req: NextRequest, + { params }: { params: { route: string; handle?: string } }, +) { + let client = await createOauthClient(); + switch (params.route) { + case "metadata": + return NextResponse.json(client.clientMetadata); + case "jwks": + return NextResponse.json(client.jwks); + case "login": { + const searchParams = req.nextUrl.searchParams; + const handle = searchParams.get("handle") as string; + // Put originating page here! + let redirect = searchParams.get("redirect_url"); + let state: OauthRequestClientState = { redirect }; + + // Revoke any pending authentication requests if the connection is closed (optional) + const ac = new AbortController(); + + const url = await client.authorize(handle, { + scope: "atproto transition:generic", + signal: ac.signal, + state: JSON.stringify(state), + // Only supported if OAuth server is openid-compliant + ui_locales: "fr-CA fr en", + }); + + return NextResponse.redirect(url); + } + case "callback": { + const params = new URLSearchParams(req.url.split("?")[1]); + console.log(params); + + let redirectPath = "/lish"; + try { + const { session, state } = await client.callback(params); + let s: OauthRequestClientState = JSON.parse(state || "{}"); + redirectPath = s.redirect || "/lish"; + let { data: identity } = await supabaseServerClient + .from("identities") + .select() + .eq("atp_did", session.did) + .single(); + if (!identity) { + let existingIdentity = cookies().get("auth_token"); + if (existingIdentity) { + let data = await supabaseServerClient + .from("email_auth_tokens") + .select("*, identities(*)") + .single(); + if (data.data?.identity && data.data.confirmed) + await supabaseServerClient + .from("identities") + .update({ atp_did: session.did }) + .eq("id", data.data.identity); + return; + } + const client = postgres(process.env.DB_URL as string, { + idle_timeout: 5, + }); + const db = drizzle(client); + identity = await createIdentity(db, { atp_did: session.did }); + } + let { data: token } = await supabaseServerClient + .from("email_auth_tokens") + .insert({ + identity: identity.id, + confirmed: true, + confirmation_code: "", + }) + .select() + .single(); + + if (token) + cookies().set("auth_token", token.id, { + maxAge: 60 * 60 * 24 * 365, + secure: process.env.NODE_ENV === "production", + httpOnly: true, + sameSite: "lax", + }); + + // Process successful authentication here + console.log("authorize() was called with state:", state); + + console.log("User authenticated as:", session.did); + } catch (e) { + redirect(redirectPath); + } + return redirect(redirectPath); + } + default: + return NextResponse.json({ error: "Invalid route" }, { status: 404 }); + } +} diff --git a/app/home/AccountSettings.tsx b/app/home/AccountSettings.tsx index df688ad3..ac41255f 100644 --- a/app/home/AccountSettings.tsx +++ b/app/home/AccountSettings.tsx @@ -20,6 +20,14 @@ export const AccountSettings = () => { /> } > + { + //I guess redirect them to the bluesky oauth endpoint? + }} + > + Link to Bluesky + + o { await logout(); diff --git a/app/lish/AlphaBanner.tsx b/app/lish/AlphaBanner.tsx new file mode 100644 index 00000000..6a21bb01 --- /dev/null +++ b/app/lish/AlphaBanner.tsx @@ -0,0 +1,10 @@ +import Link from "next/link"; + +export const AlphaBanner = () => { + return ( +
+ We're still in Early Alpha! Sign Up for + Updates :) +
+ ); +}; diff --git a/app/lish/Footer.tsx b/app/lish/Footer.tsx new file mode 100644 index 00000000..62d4d389 --- /dev/null +++ b/app/lish/Footer.tsx @@ -0,0 +1,97 @@ +"use client"; +import { MoreOptionsTiny } from "components/Icons"; +import { Menu, MenuItem } from "components/Layout"; +import { useEffect, useState } from "react"; +import { SubscribeButton } from "./Subscribe"; +import Link from "next/link"; +import { ButtonPrimary } from "components/Buttons"; +import { usePublicationRelationship } from "./[handle]/[publication]/usePublicationRelationship"; +import { usePublicationContext } from "components/Providers/PublicationContext"; + +export const Footer = (props: { pageType: "post" | "pub" }) => { + return ( +
+ +
+ + +
+
+ ); +}; + +const ScrollProgress = () => { + let [scrollPercent, setScrollPercent] = useState(0); + + useEffect(() => { + let post = document.getElementById("post"); + + let onScroll = () => { + if (!post) return; + let currentScroll = post?.scrollTop; + let totalScroll = post?.scrollHeight - post?.clientHeight; + setScrollPercent((currentScroll / totalScroll) * 100); + }; + post?.addEventListener("scroll", onScroll); + return () => post?.removeEventListener("scroll", onScroll); + }, []); + return ( +
+
+
+ ); +}; + +const FooterSubscribeButton = (props: { pageType: "post" | "pub" }) => { + let [pubHeaderIsVisible, setPubHeaderIsVisible] = useState( + props.pageType === "pub" ? true : false, + ); + let rel = usePublicationRelationship(); + let { publication } = usePublicationContext(); + + useEffect(() => { + let pubHeader = document.getElementById("pub-header"); + if (!pubHeader) return; + let observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + setPubHeaderIsVisible(entry.isIntersecting); + }); + }, + { threshold: 0 }, + ); + observer.observe(pubHeader); + return () => observer.unobserve(pubHeader); + }, []); + + if (rel?.isSubscribed || pubHeaderIsVisible || !publication) return; + if (rel?.isAuthor) + return ( +
+ Write a Draft + {/* */} +
+ ); + return ; +}; +const MoreOptionsMenu = () => { + return ( + }> + {}}>Log in +
+ + + Back to... + + {}}> + Leaflet Explorers + + {}}> + Your Feed + +
+ ); +}; diff --git a/app/lish/LishHome.tsx b/app/lish/LishHome.tsx new file mode 100644 index 00000000..773cf4c0 --- /dev/null +++ b/app/lish/LishHome.tsx @@ -0,0 +1,214 @@ +"use client"; +import { ButtonPrimary } from "components/Buttons"; +import Link from "next/link"; +import { useState } from "react"; +import { PostList } from "./PostList"; + +import { Input } from "components/Input"; +import { useIdentityData } from "components/IdentityProvider"; +import { NewDraftButton } from "./[handle]/[publication]/NewDraftButton"; + +export const LishHome = () => { + let [state, setState] = useState<"posts" | "subscriptions">("posts"); + return ( +
+
+
+ +
+ + {/*
+
+ setState("posts")} + /> + setState("subscriptions")} + /> +
+
+ {state === "posts" ? ( + + ) : ( + + )} +
*/} +
+
+ ); +}; + +const Tab = (props: { name: string; active: boolean; onClick: () => void }) => { + return ( +
+ {props.name} +
+ ); +}; + +const MyPublicationList = () => { + let { identity } = useIdentityData(); + if (!identity || !identity?.atp_did) { + return ( +
+
+ Connect to Bluesky
+ to start publishing! +
+ + We use the ATProtocol to store all your publication data on the open + web. That means we cannot lock you into our platform, you will ALWAYS + be free to easily move elsewhere. Learn More. + +
+ + + Connect + +
+
+ ); + } + if (Publications.length === 0) { + return ( +
+ + Start a Publication! + +
+ ); + } + return ( +
+ + + New Publication + +
+ ); +}; + +const PublicationList = (props: { + publications: { + identity_did: string; + indexed_at: string; + name: string; + uri: string; + }[]; +}) => { + let { identity } = useIdentityData(); + + return ( +
+ {props.publications?.map((d) => ( +
+ +
{d.name}
+ + +
+ ))} +
+ ); +}; + +const SubscriptionList = (props: { + publications: { title: string; description: string }[]; +}) => { + if (props.publications.length === 0) + return ( +
+ No subscriptions yet! +
+ ); + return ( +
+ {props.publications.map((pub) => { + return ; + })} +
+ ); +}; + +const SubscriptionListItem = (props: { + title: string; + description: string; +}) => { + return ( + +

{props.title}

+ +
{props.description}
+
+ + ); +}; + +const PostFeed = () => { + return ; +}; + +let Subscriptions = [ + { + title: "vrk loves paper", + description: + "Exploring software that loves paper as much as I do. I'll be documenting my learnings, loves, confusions and creations in this newsletter!", + }, + { + title: "rhrizome r&d", + description: + "Design, research, and complexity. A field guide for novel problems.", + }, + { + title: "Dead Languages Society ", + description: + "A guided tour through the history of English and its relatives.", + }, +]; + +let Publications = [ + { + title: "Leaflet Explorers", + description: + "We're making Leaflet, a fast fun web app for making delightful documents. Sign up to follow along as we build Leaflet! We send updates every week or two", + }, +]; diff --git a/app/lish/PostList.tsx b/app/lish/PostList.tsx new file mode 100644 index 00000000..d4543c19 --- /dev/null +++ b/app/lish/PostList.tsx @@ -0,0 +1,86 @@ +"use client"; +import Link from "next/link"; +import { Separator } from "components/Layout"; +import { Json } from "supabase/database.types"; +import { PubLeafletDocument } from "lexicons/src"; +import { ButtonPrimary } from "components/Buttons"; +import { useIdentityData } from "components/IdentityProvider"; +import { usePublicationRelationship } from "./[handle]/[publication]/usePublicationRelationship"; +import { useParams } from "next/navigation"; +import { AtUri } from "@atproto/syntax"; + +export const PostList = (props: { + isFeed?: boolean; + posts: { + documents: { + data: Json; + indexed_at: string; + uri: string; + } | null; + }[]; +}) => { + if (props.posts.length === 0) { + if (props.isFeed) { + return ( +
+ Subscribe to publications to see posts in your feed +
+ ); + } + return ( +
+
Welcome to your new Publication
+ Start Drafting! +
+ ); + } + + return ( +
+ {props.posts.map((post, index) => { + let p = post.documents?.data as PubLeafletDocument.Record; + let uri = new AtUri(post.documents?.uri!); + + return ( + + ); + })} +
+ ); +}; + +const PostListItem = ( + props: { + isFeed?: boolean; + uri: AtUri; + } & PubLeafletDocument.Record, +) => { + let { identity } = useIdentityData(); + let params = useParams(); + return ( +
+ {props.isFeed && ( + + {props.publication} + + )} + + +

{props.title}

+ {/*
placeholder description
*/} +
+ {/*
{props.publishedAt}
*/} + {/* */} +
by {identity?.resolved_did?.alsoKnownAs?.[0].slice(5)}
+
+
+ +
+ ); +}; diff --git a/app/lish/Subscribe.tsx b/app/lish/Subscribe.tsx new file mode 100644 index 00000000..6f70ebba --- /dev/null +++ b/app/lish/Subscribe.tsx @@ -0,0 +1,167 @@ +"use client"; +import { ButtonPrimary } from "components/Buttons"; +import { ArrowRightTiny, ShareSmall } from "components/Icons"; +import { useEffect, useState } from "react"; +import { Input } from "components/Input"; +import { useIdentityData } from "components/IdentityProvider"; +import { SecondaryAuthTokenContextImpl } from "twilio/lib/rest/accounts/v1/secondaryAuthToken"; +import { + confirmEmailAuthToken, + requestAuthEmailToken, +} from "actions/emailAuth"; +import { subscribeToPublicationWithEmail } from "actions/subscribeToPublicationWithEmail"; + +type State = + | { state: "email" } + | { state: "code"; token: string } + | { state: "success" }; +export const SubscribeButton = (props: { + compact?: boolean; + publication: string; +}) => { + let { identity, mutate } = useIdentityData(); + let [emailInputValue, setEmailInputValue] = useState(""); + let [codeInputValue, setCodeInputValue] = useState(""); + let [state, setState] = useState({ state: "email" }); + + if (state.state === "email") { + return ( +
+
+ { + setEmailInputValue(e.currentTarget.value); + }} + /> + { + if (identity?.email) { + await subscribeToPublicationWithEmail(props.publication); + //optimistically could add! + await mutate(); + return; + } + let tokenID = await requestAuthEmailToken(emailInputValue); + setState({ state: "code", token: tokenID }); + }} + > + {props.compact ? ( + + ) : ( + "Subscribe" + )} + +
+ {/* */} +
+ ); + } + if (state.state === "code") { + return ( +
+
+
Please enter the code we sent to
+
{emailInputValue}
+
+ + + + +
+ ); + } + + if (state.state === "success") { + return ( +
+
+
You're subscribed!
+ {/* */} +
+
+ ); + } +}; + +export const ShareButton = () => { + return ( + + ); +}; + +const ConfirmCodeInput = (props: { + codeInputValue: string; + token: string; + setCodeInputValue: (value: string) => void; + setState: (state: State) => void; + publication: string; +}) => { + let { mutate } = useIdentityData(); + return ( +
+ { + props.setCodeInputValue(e.currentTarget.value); + }} + /> + { + console.log( + await confirmEmailAuthToken(props.token, props.codeInputValue), + ); + + await subscribeToPublicationWithEmail(props.publication); + //optimistically could add! + await mutate(); + props.setState({ state: "success" }); + return; + }} + > + Confirm + +
+ ); +}; diff --git a/app/lish/[handle]/[publication]/CallToActionButton.tsx b/app/lish/[handle]/[publication]/CallToActionButton.tsx new file mode 100644 index 00000000..fd553843 --- /dev/null +++ b/app/lish/[handle]/[publication]/CallToActionButton.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { SubscribeButton } from "app/lish/Subscribe"; +import { usePublicationRelationship } from "./usePublicationRelationship"; +import { usePublicationContext } from "components/Providers/PublicationContext"; +import { NewDraftButton } from "./NewDraftButton"; +import { Menu, MenuItem } from "components/Layout"; +import { ArrowRightTiny, MoreOptionsTiny, ShareSmall } from "components/Icons"; + +export function CallToActionButton() { + let rel = usePublicationRelationship(); + let { publication } = usePublicationContext(); + if (!publication) return null; + if (rel?.isAuthor) return ; + if (rel?.isSubscribed) + return ( +
+
You're Subscribed!
+ +
+ ); + return ; +} + +const ManageSubscriptionMenu = () => { + return ( + }> + {}}>Unsub! + + ); +}; diff --git a/app/lish/[handle]/[publication]/NewDraftButton.tsx b/app/lish/[handle]/[publication]/NewDraftButton.tsx new file mode 100644 index 00000000..ec04bc28 --- /dev/null +++ b/app/lish/[handle]/[publication]/NewDraftButton.tsx @@ -0,0 +1,20 @@ +"use client"; +import { createPublicationDraft } from "actions/createPublicationDraft"; +import { ButtonPrimary } from "components/Buttons"; +import { useRouter } from "next/navigation"; + +export function NewDraftButton(props: { publication: string }) { + let router = useRouter(); + return ( +
+ { + let newLeaflet = await createPublicationDraft(props.publication); + router.push(`/${newLeaflet}`); + }} + > + New Draft + +
+ ); +} diff --git a/app/lish/[handle]/[publication]/[rkey]/page.tsx b/app/lish/[handle]/[publication]/[rkey]/page.tsx new file mode 100644 index 00000000..8d6f9892 --- /dev/null +++ b/app/lish/[handle]/[publication]/[rkey]/page.tsx @@ -0,0 +1,85 @@ +import Link from "next/link"; +import { Footer } from "../../../Footer"; +import { getPds, IdResolver } from "@atproto/identity"; +import { supabaseServerClient } from "supabase/serverClient"; +import { AtUri } from "@atproto/syntax"; +import { ids } from "lexicons/src/lexicons"; +import { + PubLeafletBlocksHeader, + PubLeafletBlocksImage, + PubLeafletBlocksText, + PubLeafletDocument, + PubLeafletPagesLinearDocument, +} from "lexicons/src"; + +const idResolver = new IdResolver(); +export default async function Post(props: { + params: { publication: string; handle: string; rkey: string }; +}) { + let did = await idResolver.handle.resolve(props.params.handle); + if (!did) return
can't resolve handle
; + let { data: document } = await supabaseServerClient + .from("documents") + .select("*") + .eq("uri", AtUri.make(did, ids.PubLeafletDocument, props.params.rkey)) + .single(); + if (!document?.data) return
notfound
; + let record = document.data as PubLeafletDocument.Record; + let firstPage = record.pages[0]; + let blocks: PubLeafletPagesLinearDocument.Block[] = []; + if (PubLeafletPagesLinearDocument.isMain(firstPage)) { + blocks = firstPage.blocks || []; + } + return ( +
+
+
+ + {decodeURIComponent(props.params.publication)} + + {/*

{record.title}

*/} + {blocks.map((b) => { + switch (true) { + case PubLeafletBlocksImage.isMain(b.block): { + return ( + + ); + } + case PubLeafletBlocksText.isMain(b.block): + return

{b.block.plaintext}

; + case PubLeafletBlocksHeader.isMain(b.block): { + if (b.block.level === 1) + return ( +

{b.block.plaintext}

+ ); + if (b.block.level === 2) + return ( +

{b.block.plaintext}

+ ); + if (b.block.level === 3) + return ( +

{b.block.plaintext}

+ ); + // if (b.block.level === 4) return

{b.block.plaintext}

; + // if (b.block.level === 5) return
{b.block.plaintext}
; + return
{b.block.plaintext}
; + } + default: + return null; + } + })} +
+ +
+
+
+ ); +} diff --git a/app/lish/[handle]/[publication]/layout.tsx b/app/lish/[handle]/[publication]/layout.tsx new file mode 100644 index 00000000..52c89b59 --- /dev/null +++ b/app/lish/[handle]/[publication]/layout.tsx @@ -0,0 +1,30 @@ +import { IdResolver } from "@atproto/identity"; +import { PublicationContextProvider } from "components/Providers/PublicationContext"; +import { supabaseServerClient } from "supabase/serverClient"; + +const idResolver = new IdResolver(); +export default async function PublicationLayout(props: { + children: React.ReactNode; + params: { + publication: string; + handle: string; + }; +}) { + let did = await idResolver.handle.resolve(props.params.handle); + if (!did) return <>{props.children}; + let { data: publication } = await supabaseServerClient + .from("publications") + .select( + "*, documents_in_publications(documents(*)), leaflets_in_publications(*, permission_tokens(*, permission_token_rights(*), custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*) ))", + ) + .eq("identity_did", did) + .eq("name", decodeURIComponent(props.params.publication)) + .single(); + + if (!publication) return <>{props.children}; + return ( + + {props.children} + + ); +} diff --git a/app/lish/[handle]/[publication]/page.tsx b/app/lish/[handle]/[publication]/page.tsx new file mode 100644 index 00000000..fa5e0c78 --- /dev/null +++ b/app/lish/[handle]/[publication]/page.tsx @@ -0,0 +1,75 @@ +import { Footer } from "../../Footer"; +import { PostList } from "../../PostList"; +import { getPds, IdResolver } from "@atproto/identity"; +import { supabaseServerClient } from "supabase/serverClient"; +import { AtUri } from "@atproto/syntax"; +import { + AtpBaseClient, + PubLeafletDocument, + PubLeafletPublication, +} from "lexicons/src"; +import { CallToActionButton } from "./CallToActionButton"; + +const idResolver = new IdResolver(); + +export default async function Publication(props: { + params: { publication: string; handle: string }; +}) { + let did = await idResolver.handle.resolve(props.params.handle); + if (!did) return ; + + let { data: publication } = await supabaseServerClient + .from("publications") + .select( + "*, documents_in_publications(documents(*)), leaflets_in_publications(*, permission_tokens(*, permission_token_rights(*), custom_domain_routes!custom_domain_routes_edit_permission_token_fkey(*) ))", + ) + .eq("identity_did", did) + .eq("name", decodeURIComponent(props.params.publication)) + .single(); + if (!publication) return ; + + let repo = await idResolver.did.resolve(did); + if (!repo) return ; + const pds = getPds(repo); + let agent = new AtpBaseClient((url, init) => { + return fetch(new URL(url, pds), init); + }); + + try { + let uri = new AtUri(publication.uri); + let publication_record = await agent.pub.leaflet.publication.get({ + repo: props.params.handle, + rkey: uri.rkey, + }); + if (!PubLeafletPublication.isRecord(publication_record.value)) { + return
not a publication?
; + } + + return ( +
+
+
+
+
+

{publication.name}

+ +
+
+ +
+
+
+
+ ); + } catch (e) { + console.log(e); + return
{JSON.stringify(e, undefined, 2)}
; + } +} + +const PubNotFound = () => { + return
ain't no pub here
; +}; diff --git a/app/lish/[handle]/[publication]/usePublicationRelationship.ts b/app/lish/[handle]/[publication]/usePublicationRelationship.ts new file mode 100644 index 00000000..3986ad13 --- /dev/null +++ b/app/lish/[handle]/[publication]/usePublicationRelationship.ts @@ -0,0 +1,16 @@ +import { AtUri } from "@atproto/syntax"; +import { useIdentityData } from "components/IdentityProvider"; +import { usePublicationContext } from "components/Providers/PublicationContext"; + +export const usePublicationRelationship = () => { + let identity = useIdentityData(); + let publication = usePublicationContext(); + if (!publication.publication) return null; + let pubUri = new AtUri(publication.publication.uri); + let isAuthor = + identity.identity?.atp_did && pubUri.hostname === identity.identity.atp_did; + let isSubscribed = identity.identity?.subscribers_to_publications.find( + (p) => p.publication === publication.publication?.uri, + ); + return { isAuthor, isSubscribed }; +}; diff --git a/app/lish/createPub/CreatePubForm.tsx b/app/lish/createPub/CreatePubForm.tsx new file mode 100644 index 00000000..56fd1cb3 --- /dev/null +++ b/app/lish/createPub/CreatePubForm.tsx @@ -0,0 +1,61 @@ +"use client"; +import { createPublication } from "actions/createPublication"; +import { ButtonPrimary } from "components/Buttons"; +import { useIdentityData } from "components/IdentityProvider"; +import { InputWithLabel } from "components/Input"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useState } from "react"; + +export const CreatePubForm = () => { + let [nameValue, setNameValue] = useState(""); + let [descriptionValue, setDescriptionValue] = useState(""); + + let router = useRouter(); + let { identity } = useIdentityData(); + return ( +
{ + e.preventDefault(); + await createPublication(nameValue); + router.push( + `/lish/${identity?.resolved_did?.alsoKnownAs?.[0].slice(5)}/${nameValue}/`, + ); + }} + > + { + setNameValue(e.currentTarget.value); + }} + /> + + {/* { + setDescriptionValue(e.currentTarget.value); + }} + /> */} + +
+ + Nevermind + + + Create! + +
+ + ); +}; diff --git a/app/lish/createPub/page.tsx b/app/lish/createPub/page.tsx new file mode 100644 index 00000000..a818301d --- /dev/null +++ b/app/lish/createPub/page.tsx @@ -0,0 +1,17 @@ +import { CreatePubForm } from "./CreatePubForm"; +import { createClient } from "@supabase/supabase-js"; +import { Database } from "supabase/database.types"; +import { IdResolver } from "@atproto/identity"; + +export default function CreatePub() { + return ( +
+
+
+

Create a New Publication

+ +
+
+
+ ); +} diff --git a/app/lish/page.tsx b/app/lish/page.tsx new file mode 100644 index 00000000..8e873d87 --- /dev/null +++ b/app/lish/page.tsx @@ -0,0 +1,13 @@ +import { LishHome } from "./LishHome"; +import { createClient } from "@supabase/supabase-js"; +import { Database } from "supabase/database.types"; +import { AtpBaseClient, PubLeafletPagesLinearDocument } from "lexicons/src"; +import { CredentialSession } from "@atproto/api"; +import { createOauthClient } from "src/atproto-oauth"; +import { getIdentityData } from "actions/getIdentityData"; +import Link from "next/link"; +import { IdResolver } from "@atproto/identity"; + +export default function Lish() { + return ; +} diff --git a/appview/index.ts b/appview/index.ts new file mode 100644 index 00000000..070c3557 --- /dev/null +++ b/appview/index.ts @@ -0,0 +1,125 @@ +import { createClient } from "@supabase/supabase-js"; +import { Database, Json } from "supabase/database.types"; +import { IdResolver } from "@atproto/identity"; +const idResolver = new IdResolver(); +import { Firehose, MemoryRunner } from "@atproto/sync"; +import { ids } from "lexicons/src/lexicons"; +import { + PubLeafletDocument, + PubLeafletPost, + PubLeafletPublication, +} from "lexicons/src"; +import { AtUri } from "@atproto/syntax"; +import { writeFile, readFile } from "fs/promises"; + +const cursorFile = "./cursor"; + +let supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, + process.env.SUPABASE_SERVICE_ROLE_KEY as string, +); +async function main() { + let startCursor; + try { + startCursor = parseInt((await readFile(cursorFile)).toString()); + } catch (e) {} + const runner = new MemoryRunner({ + startCursor, + setCursor: async (cursor) => { + await writeFile(cursorFile, cursor.toString()); + // persist cursor + }, + }); + let firehose = new Firehose({ + excludeAccount: true, + excludeIdentity: true, + runner, + idResolver, + filterCollections: [ + ids.PubLeafletDocument, + ids.PubLeafletPublication, + ids.PubLeafletPost, + ], + handleEvent: async (evt) => { + if ( + evt.event == "account" || + evt.event === "identity" || + evt.event === "sync" + ) + return; + if (evt.collection === ids.PubLeafletDocument) { + if (evt.event === "create" || evt.event === "update") { + let record = PubLeafletDocument.validateRecord(evt.record); + if (!record.success) { + console.log(record.error); + return; + } + await supabase.from("documents").upsert({ + uri: evt.uri.toString(), + data: record.value as Json, + }); + let publicationURI = new AtUri(record.value.publication); + + if (publicationURI.host !== evt.uri.host) { + console.log("Unauthorized to create post!"); + return; + } + console.log( + await supabase.from("documents_in_publications").insert({ + publication: record.value.publication, + document: evt.uri.toString(), + }), + ); + } + } + if (evt.collection === ids.PubLeafletPublication) { + if (evt.event === "create" || evt.event === "update") { + let record = PubLeafletPublication.validateRecord(evt.record); + if (!record.success) return; + console.log( + await supabase.from("publications").upsert({ + uri: evt.uri.toString(), + identity_did: evt.did, + name: record.value.name, + }), + ); + } + if (evt.event === "delete") { + console.log("deleting ", evt.rkey); + console.log( + await supabase + .from("publications") + .delete() + .eq("uri", evt.uri.toString()), + ); + } + } + if (evt.collection === ids.PubLeafletPost) { + if (evt.event === "create" || evt.event === "update") { + let record = PubLeafletPost.validateRecord(evt.record); + if (!record.success) return; + } + } + }, + onError: (err) => { + console.error(err); + }, + }); + console.log("starting firehose consumer"); + firehose.start(); + const cleanup = () => { + console.log("shutting down firehose..."); + firehose.destroy(); + runner.destroy(); + process.exit(); + }; + + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); +} + +main(); + +//Should I make a helper for writing these hmmm... I need to make create / updates for all of these +// Creates should basically be the same as updates right? Ya, as long as you make sure to upsert stuff! +// diff --git a/components/Blocks/RSVPBlock/ContactDetailsForm.tsx b/components/Blocks/RSVPBlock/ContactDetailsForm.tsx index 39bcb0ff..a50eda92 100644 --- a/components/Blocks/RSVPBlock/ContactDetailsForm.tsx +++ b/components/Blocks/RSVPBlock/ContactDetailsForm.tsx @@ -9,9 +9,9 @@ import { submitRSVP } from "actions/phone_rsvp_to_event"; import { countryCodes } from "src/constants/countryCodes"; import { Checkbox } from "components/Checkbox"; import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; -import { InputWithLabel, Separator } from "components/Layout"; +import { Separator } from "components/Layout"; import { createPhoneAuthToken } from "actions/phone_auth/request_phone_auth_token"; -import { Input } from "components/Input"; +import { Input, InputWithLabel } from "components/Input"; import { IPLocationContext } from "components/Providers/IPLocationProvider"; import { Popover } from "components/Popover"; import { InfoSmall } from "components/Icons"; diff --git a/components/Input.tsx b/components/Input.tsx index 80f42f48..9f1307e4 100644 --- a/components/Input.tsx +++ b/components/Input.tsx @@ -1,3 +1,4 @@ +"use client"; import { isIOS } from "@react-aria/utils"; import { useCallback, useEffect, useRef } from "react"; import { onMouseDown } from "src/utils/iosInputMouseDown"; @@ -63,22 +64,20 @@ export const focusElement = (el?: HTMLInputElement | null) => { export const InputWithLabel = ( props: { label: string; - } & JSX.IntrinsicElements["input"], + textarea?: boolean; + } & JSX.IntrinsicElements["input"] & + JSX.IntrinsicElements["textarea"], ) => { let { label, ...inputProps } = props; + let style = `appearance-none w-full font-normal bg-transparent text-base text-primary focus:outline-0 ${props.className}`; return ( -
-
- -
-
+