a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'main' into feature/pub-contributors

Jared Pereira (May 29, 2026, 11:21 AM EDT) de0bd83d 0cc6cc9a

+2526 -1259
+3 -3
.github/workflows/main.yml
··· 13 13 - uses: actions/checkout@v4 14 14 - uses: actions/setup-node@v4 15 15 with: 16 - node-version: 20 16 + node-version: 24 17 17 cache: "npm" 18 18 - run: "npm i" 19 19 - run: "npx tsc" ··· 28 28 - uses: actions/checkout@v4 29 29 - uses: actions/setup-node@v4 30 30 with: 31 - node-version: 20 31 + node-version: 24 32 32 cache: "npm" 33 33 - run: "npm i" 34 34 - run: "npm run publish-lexicons" ··· 42 42 PRODUCTION_PROJECT_ID: bdefzwcumgzjwllsnaej 43 43 44 44 steps: 45 - - uses: actions/checkout@v3 45 + - uses: actions/checkout@v4 46 46 - uses: supabase/setup-cli@v1 47 47 - run: supabase link --project-ref $PRODUCTION_PROJECT_ID 48 48 - run: supabase db push
+1 -1
actions/createNewLeaflet.ts
··· 8 8 import { cookies } from "next/headers"; 9 9 import { pool } from "supabase/pool"; 10 10 11 - type DefaultBlockType = "h1" | "text" | "posts-list"; 11 + export type DefaultBlockType = "h1" | "text" | "posts-list" | "signup"; 12 12 13 13 type FactRow = { 14 14 id: string;
+8 -2
actions/createPublicationPage.ts
··· 1 1 "use server"; 2 2 import { generateKeyBetween } from "fractional-indexing"; 3 3 import { getIdentityData } from "actions/getIdentityData"; 4 - import { createNewLeaflet } from "./createNewLeaflet"; 4 + import { createNewLeaflet, type DefaultBlockType } from "./createNewLeaflet"; 5 5 import { supabaseServerClient } from "supabase/serverClient"; 6 6 7 7 export async function createPublicationPage(args: { ··· 10 10 title?: string; 11 11 sort_order?: string; 12 12 includePostsList?: boolean; 13 + includeSignup?: boolean; 13 14 }) { 14 15 let identity = await getIdentityData(); 15 16 if (!identity || !identity.atp_did) return null; ··· 34 35 sort_order = generateKeyBetween(last?.sort_order ?? null, null); 35 36 } 36 37 38 + let firstBlocks: DefaultBlockType[] = [ 39 + "text", 40 + ...(args.includeSignup ? (["signup"] as const) : []), 41 + ...(args.includePostsList ? (["posts-list"] as const) : []), 42 + ]; 37 43 let leaflet_src = await createNewLeaflet({ 38 44 pageType: "doc", 39 45 redirectUser: false, 40 - firstBlocks: args.includePostsList ? ["text", "posts-list"] : ["text"], 46 + firstBlocks, 41 47 addToHomepage: false, 42 48 }); 43 49
+34
actions/deletePublicationPage.ts
··· 1 + "use server"; 2 + import { getIdentityData } from "actions/getIdentityData"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + 5 + export async function deletePublicationPage(args: { 6 + publication_uri: string; 7 + page_id: number; 8 + }): Promise<{ success: boolean }> { 9 + let identity = await getIdentityData(); 10 + if (!identity || !identity.atp_did) return { success: false }; 11 + 12 + let { data: publication } = await supabaseServerClient 13 + .from("publications") 14 + .select("uri, identity_did") 15 + .eq("uri", args.publication_uri) 16 + .single(); 17 + if (!publication || publication.identity_did !== identity.atp_did) 18 + return { success: false }; 19 + 20 + let { count } = await supabaseServerClient 21 + .from("publication_pages") 22 + .select("id", { count: "exact", head: true }) 23 + .eq("publication", args.publication_uri); 24 + if ((count ?? 0) <= 1) return { success: false }; 25 + 26 + let { error } = await supabaseServerClient 27 + .from("publication_pages") 28 + .delete() 29 + .eq("id", args.page_id) 30 + .eq("publication", args.publication_uri); 31 + if (error) return { success: false }; 32 + 33 + return { success: true }; 34 + }
+35 -10
actions/getIdentityData.ts
··· 4 4 import { supabaseServerClient } from "supabase/serverClient"; 5 5 import { cache } from "react"; 6 6 import { deduplicateByUri } from "src/utils/deduplicateRecords"; 7 + import { getProfiles, type Profile } from "src/identity"; 7 8 import { AtUri } from "@atproto/syntax"; 8 9 import { TID } from "@atproto/common"; 9 10 export const getIdentityData = cache(uncachedGetIdentityData); ··· 19 20 `*, 20 21 identities( 21 22 *, 22 - bsky_profiles(*), 23 23 notifications(count), 24 24 publication_subscriptions(*), 25 25 publication_email_subscribers(publication, state), ··· 74 74 75 75 const subscription = auth_res.data.identities.user_subscriptions ?? null; 76 76 77 - if (auth_res.data.identities.atp_did) { 77 + const atp_did = auth_res.data.identities.atp_did; 78 + if (atp_did) { 78 79 //I should create a relationship table so I can do this in the above query 79 80 let [ 80 81 { data: rawPublications }, 81 82 { data: contributorLeafletRows }, 82 83 { data: contributorPubRows }, 84 + profiles, 83 85 ] = await Promise.all([ 84 86 supabaseServerClient 85 87 .from("publications") 86 88 .select("*") 87 - .eq("identity_did", auth_res.data.identities.atp_did), 89 + .eq("identity_did", atp_did), 88 90 supabaseServerClient 89 91 .from("leaflet_contributors") 90 92 .select( ··· 96 98 leaflets_in_publications(*, publications(*)) 97 99 )`, 98 100 ) 99 - .eq("contributor_did", auth_res.data.identities.atp_did), 101 + .eq("contributor_did", atp_did), 100 102 supabaseServerClient 101 103 .from("publication_contributors") 102 104 .select("created_at, publications!inner(*)") 103 - .eq("contributor_did", auth_res.data.identities.atp_did) 105 + .eq("contributor_did", atp_did) 104 106 .eq("confirmed", true), 107 + getProfiles([atp_did]), 105 108 ]); 106 109 // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces, 107 110 // then filter to only publications created by Leaflet ··· 109 112 isLeafletPublication, 110 113 ); 111 114 const contributor_leaflets = (contributorLeafletRows ?? []).filter( 112 - (r): r is typeof r & { permission_tokens: NonNullable<typeof r.permission_tokens> } => 113 - !!r.permission_tokens, 115 + ( 116 + r, 117 + ): r is typeof r & { 118 + permission_tokens: NonNullable<typeof r.permission_tokens>; 119 + } => !!r.permission_tokens, 114 120 ); 115 121 const rawContributorPubs = (contributorPubRows ?? []) 116 122 .map((r) => r.publications) 117 123 .filter((p): p is NonNullable<typeof p> => !!p); 118 - const contributor_publications = deduplicateByUri(rawContributorPubs).filter( 119 - isLeafletPublication, 120 - ); 124 + const contributor_publications = 125 + deduplicateByUri(rawContributorPubs).filter(isLeafletPublication); 121 126 return { 122 127 ...auth_res.data.identities, 128 + bsky_profiles: bskyProfileFromCache(profiles.get(atp_did) ?? null), 123 129 publications, 124 130 contributor_publications, 125 131 contributor_leaflets, ··· 130 136 131 137 return { 132 138 ...auth_res.data.identities, 139 + bsky_profiles: null, 133 140 publications: [], 134 141 contributor_publications: [], 135 142 contributor_leaflets: [], 136 143 entitlements, 137 144 subscription, 145 + }; 146 + } 147 + 148 + // Reshape a cached profile into the legacy `bsky_profiles` row shape that 149 + // consumers (SubscribeButton, PubPreview, PostPreview, …) read off the 150 + // identity, so swapping the table join for the cache stays transparent. 151 + function bskyProfileFromCache(profile: Profile | null) { 152 + if (!profile) return null; 153 + return { 154 + did: profile.did, 155 + handle: profile.handle, 156 + record: { 157 + did: profile.did, 158 + handle: profile.handle, 159 + displayName: profile.displayName ?? undefined, 160 + avatar: profile.avatar ?? undefined, 161 + description: profile.description ?? undefined, 162 + }, 138 163 }; 139 164 } 140 165
+3 -1
actions/publishToPublication.ts
··· 61 61 entitiesToDelete, 62 62 publishedAt, 63 63 postPreferences, 64 + sendEmail = true, 64 65 }: { 65 66 root_entity: string; 66 67 publication_uri?: string; ··· 76 77 showMentions?: boolean; 77 78 showRecommends?: boolean; 78 79 } | null; 80 + sendEmail?: boolean; 79 81 }): Promise<PublishResult> { 80 82 let identity = await getIdentityData(); 81 83 if (!identity || !identity.atp_did) { ··· 422 424 // Fire newsletter broadcast on first publish to a newsletter-enabled pub. 423 425 // The composite PK on publication_post_sends is the real idempotency guard — 424 426 // ignoreDuplicates makes re-publishes and concurrent runs a no-op. 425 - if (publication_uri && !existingDocUri) { 427 + if (publication_uri && !existingDocUri && sendEmail) { 426 428 const { data: settings } = await supabaseServerClient 427 429 .from("publication_newsletter_settings") 428 430 .select("enabled")
+30
actions/updatePublicationPage.ts
··· 1 + "use server"; 2 + import { getIdentityData } from "actions/getIdentityData"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + 5 + export async function updatePublicationPage(args: { 6 + publication_uri: string; 7 + page_id: number; 8 + title: string; 9 + path: string; 10 + }): Promise<{ success: boolean }> { 11 + let identity = await getIdentityData(); 12 + if (!identity || !identity.atp_did) return { success: false }; 13 + 14 + let { data: publication } = await supabaseServerClient 15 + .from("publications") 16 + .select("uri, identity_did") 17 + .eq("uri", args.publication_uri) 18 + .single(); 19 + if (!publication || publication.identity_did !== identity.atp_did) 20 + return { success: false }; 21 + 22 + let { error } = await supabaseServerClient 23 + .from("publication_pages") 24 + .update({ title: args.title, path: args.path }) 25 + .eq("id", args.page_id) 26 + .eq("publication", args.publication_uri); 27 + if (error) return { success: false }; 28 + 29 + return { success: true }; 30 + }
+4 -15
app/(app)/(home-pages)/(writer)/notifications/CommentMentionNotification.tsx
··· 1 - import { AppBskyActorProfile, PubLeafletComment } from "lexicons/api"; 1 + import { PubLeafletComment } from "lexicons/api"; 2 2 import { HydratedCommentMentionNotification } from "src/notifications"; 3 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 4 3 import { MentionTiny } from "components/Icons/MentionTiny"; 5 4 import { 6 5 CommentInNotification, 7 6 ContentLayout, 8 7 Notification, 9 8 } from "./Notification"; 10 - import { AtUri } from "@atproto/api"; 11 9 import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; 12 10 13 11 export const CommentMentionNotification = ( ··· 17 15 if (!docRecord) return null; 18 16 19 17 const commentRecord = props.commentData.record as PubLeafletComment.Record; 20 - const profileRecord = props.commentData.bsky_profiles 21 - ?.record as AppBskyActorProfile.Record; 18 + const profile = props.commentData.profile; 22 19 const pubRecord = props.normalizedPublication; 23 20 24 21 const href = ··· 66 63 <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 67 64 <CommentInNotification 68 65 className="" 69 - avatar={ 70 - profileRecord?.avatar?.ref && 71 - blobRefToSrc( 72 - profileRecord?.avatar?.ref, 73 - props.commentData.bsky_profiles?.did || "", 74 - ) 75 - } 66 + avatar={profile?.avatar ?? undefined} 76 67 displayName={ 77 - profileRecord?.displayName || 78 - props.commentData.bsky_profiles?.handle || 79 - "Someone" 68 + profile?.displayName || profile?.handle || "Someone" 80 69 } 81 70 index={[]} 82 71 plaintext={commentRecord.plaintext}
+4 -17
app/(app)/(home-pages)/(writer)/notifications/CommentNotication.tsx
··· 1 - import { BaseTextBlock } from "app/(app)/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 2 - import { AppBskyActorProfile, PubLeafletComment } from "lexicons/api"; 1 + import { PubLeafletComment } from "lexicons/api"; 3 2 import { HydratedCommentNotification } from "src/notifications"; 4 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 5 - import { Avatar } from "components/Avatar"; 6 3 import { CommentTiny } from "components/Icons/CommentTiny"; 7 4 import { 8 5 CommentInNotification, 9 6 ContentLayout, 10 7 Notification, 11 8 } from "./Notification"; 12 - import { AtUri } from "@atproto/api"; 13 9 import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; 14 10 15 11 export const CommentNotification = (props: HydratedCommentNotification) => { 16 12 const docRecord = props.normalizedDocument; 17 13 const commentRecord = props.commentData.record as PubLeafletComment.Record; 18 - const profileRecord = props.commentData.bsky_profiles 19 - ?.record as AppBskyActorProfile.Record; 14 + const profile = props.commentData.profile; 20 15 21 16 if (!docRecord) return null; 22 17 23 18 const displayName = 24 - profileRecord?.displayName || 25 - props.commentData.bsky_profiles?.handle || 26 - "Someone"; 19 + profile?.displayName || profile?.handle || "Someone"; 27 20 const pubRecord = props.normalizedPublication; 28 21 29 22 const href = ··· 40 33 <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 41 34 <CommentInNotification 42 35 className="" 43 - avatar={ 44 - profileRecord?.avatar?.ref && 45 - blobRefToSrc( 46 - profileRecord?.avatar?.ref, 47 - props.commentData.bsky_profiles?.did || "", 48 - ) 49 - } 36 + avatar={profile?.avatar ?? undefined} 50 37 displayName={displayName} 51 38 index={[]} 52 39 plaintext={commentRecord.plaintext}
+3 -13
app/(app)/(home-pages)/(writer)/notifications/FollowNotification.tsx
··· 1 1 import { Avatar } from "components/Avatar"; 2 2 import { Notification } from "./Notification"; 3 3 import { HydratedSubscribeNotification } from "src/notifications"; 4 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 5 - import { AppBskyActorProfile } from "lexicons/api"; 6 4 7 5 export const FollowNotification = (props: HydratedSubscribeNotification) => { 8 - const profileRecord = props.subscriptionData?.identities?.bsky_profiles 9 - ?.record as AppBskyActorProfile.Record; 6 + const profile = props.subscriptionData?.profile; 10 7 const displayName = 11 - profileRecord?.displayName || 12 - props.subscriptionData?.identities?.bsky_profiles?.handle || 13 - "Someone"; 8 + profile?.displayName || profile?.handle || "Someone"; 14 9 const pubRecord = props.normalizedPublication; 15 - const avatarSrc = 16 - profileRecord?.avatar?.ref && 17 - blobRefToSrc( 18 - profileRecord.avatar.ref, 19 - props.subscriptionData?.identity || "", 20 - ); 10 + const avatarSrc = profile?.avatar ?? undefined; 21 11 22 12 return ( 23 13 <Notification
+2 -15
app/(app)/(home-pages)/(writer)/notifications/RecommendNotification.tsx
··· 1 1 import { ContentLayout, Notification } from "./Notification"; 2 2 import { HydratedRecommendNotification } from "src/notifications"; 3 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 4 - import { AppBskyActorProfile } from "lexicons/api"; 5 - import { Avatar } from "components/Avatar"; 6 - import { AtUri } from "@atproto/api"; 7 3 import { RecommendTinyFilled } from "components/Icons/RecommendTiny"; 8 4 import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; 9 5 10 6 export const RecommendNotification = ( 11 7 props: HydratedRecommendNotification, 12 8 ) => { 13 - const profileRecord = props.recommendData?.identities?.bsky_profiles 14 - ?.record as AppBskyActorProfile.Record; 9 + const profile = props.recommendData?.profile; 15 10 const displayName = 16 - profileRecord?.displayName || 17 - props.recommendData?.identities?.bsky_profiles?.handle || 18 - "Someone"; 11 + profile?.displayName || profile?.handle || "Someone"; 19 12 const docRecord = props.normalizedDocument; 20 13 const pubRecord = props.normalizedPublication; 21 - const avatarSrc = 22 - profileRecord?.avatar?.ref && 23 - blobRefToSrc( 24 - profileRecord.avatar.ref, 25 - props.recommendData?.recommender_did || "", 26 - ); 27 14 28 15 if (!docRecord) return null; 29 16
+7 -29
app/(app)/(home-pages)/(writer)/notifications/ReplyNotification.tsx
··· 1 - import { Avatar } from "components/Avatar"; 2 - import { BaseTextBlock } from "app/(app)/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 3 - import { ReplyTiny } from "components/Icons/ReplyTiny"; 4 1 import { 5 2 CommentInNotification, 6 3 ContentLayout, ··· 8 5 } from "./Notification"; 9 6 import { HydratedCommentNotification } from "src/notifications"; 10 7 import { PubLeafletComment } from "lexicons/api"; 11 - import { AppBskyActorProfile, AtUri } from "@atproto/api"; 12 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 8 + import { ReplyTiny } from "components/Icons/ReplyTiny"; 13 9 import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; 14 10 15 11 export const ReplyNotification = (props: HydratedCommentNotification) => { 16 12 const docRecord = props.normalizedDocument; 17 13 const commentRecord = props.commentData.record as PubLeafletComment.Record; 18 - const profileRecord = props.commentData.bsky_profiles 19 - ?.record as AppBskyActorProfile.Record; 14 + const profile = props.commentData.profile; 20 15 21 16 if (!docRecord) return null; 22 17 23 18 const displayName = 24 - profileRecord?.displayName || 25 - props.commentData.bsky_profiles?.handle || 26 - "Someone"; 19 + profile?.displayName || profile?.handle || "Someone"; 27 20 28 21 const parentRecord = props.parentData?.record as PubLeafletComment.Record; 29 - const parentProfile = props.parentData?.bsky_profiles 30 - ?.record as AppBskyActorProfile.Record; 22 + const parentProfile = props.parentData?.profile; 31 23 const parentDisplayName = 32 - parentProfile?.displayName || 33 - props.parentData?.bsky_profiles?.handle || 34 - "Someone"; 24 + parentProfile?.displayName || parentProfile?.handle || "Someone"; 35 25 36 26 const pubRecord = props.normalizedPublication; 37 27 ··· 49 39 <ContentLayout postTitle={docRecord.title} pubRecord={pubRecord}> 50 40 <CommentInNotification 51 41 className="" 52 - avatar={ 53 - parentProfile?.avatar?.ref && 54 - blobRefToSrc( 55 - parentProfile?.avatar?.ref, 56 - props.parentData?.bsky_profiles?.did || "", 57 - ) 58 - } 42 + avatar={parentProfile?.avatar ?? undefined} 59 43 displayName={parentDisplayName} 60 44 index={[]} 61 45 plaintext={parentRecord.plaintext} ··· 64 48 <div className="h-3 -mt-[1px] ml-[10px] border-l border-border" /> 65 49 <CommentInNotification 66 50 className="" 67 - avatar={ 68 - profileRecord?.avatar?.ref && 69 - blobRefToSrc( 70 - profileRecord?.avatar?.ref, 71 - props.commentData.bsky_profiles?.did || "", 72 - ) 73 - } 51 + avatar={profile?.avatar ?? undefined} 74 52 displayName={displayName} 75 53 index={[]} 76 54 plaintext={commentRecord.plaintext}
+1 -1
app/(app)/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx
··· 107 107 uri: string; 108 108 }) => { 109 109 const { record, uri } = props; 110 - const { bgLeaflet, bgPage, primary } = usePubTheme(record.theme); 110 + const { bgLeaflet, bgPage, primary } = usePubTheme(record); 111 111 112 112 return ( 113 113 <a
+1 -1
app/(app)/(home-pages)/p/[didOrHandle]/PubListing.tsx
··· 23 23 24 24 export const PubListing = (props: PubListingProps) => { 25 25 let record = props.record; 26 - let theme = usePubTheme(record?.theme); 26 + let theme = usePubTheme(record); 27 27 let backgroundImage = record?.theme?.backgroundImage?.image?.ref 28 28 ? blobRefToSrc( 29 29 record?.theme?.backgroundImage?.image?.ref,
+6 -16
app/(app)/(home-pages)/p/[didOrHandle]/comments/CommentsContent.tsx
··· 3 3 import { EmptyState } from "components/EmptyState"; 4 4 import { useEffect, useRef, useMemo } from "react"; 5 5 import useSWRInfinite from "swr/infinite"; 6 - import { AppBskyActorProfile, AtUri } from "@atproto/api"; 6 + import { AtUri } from "@atproto/api"; 7 7 import { PubLeafletComment, PubLeafletDocument } from "lexicons/api"; 8 8 import { ReplyTiny } from "components/Icons/ReplyTiny"; 9 9 import { Avatar } from "components/Avatar"; 10 10 import { BaseTextBlock } from "app/(app)/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; 11 - import { blobRefToSrc } from "src/utils/blobRefToSrc"; 12 11 import { 13 12 getProfileComments, 14 13 type ProfileComment, ··· 105 104 106 105 const CommentItem = ({ comment }: { comment: ProfileComment }) => { 107 106 const record = comment.record as PubLeafletComment.Record; 108 - const profile = comment.bsky_profiles?.record as 109 - | AppBskyActorProfile.Record 110 - | undefined; 107 + const profile = comment.profile; 111 108 const displayName = 112 - profile?.displayName || comment.bsky_profiles?.handle || "Unknown"; 113 - 114 - // Get commenter DID from comment URI 115 - const commenterDid = new AtUri(comment.uri).host; 109 + profile?.displayName || profile?.handle || "Unknown"; 116 110 117 111 const isReply = !!record.reply; 118 112 ··· 126 120 const parentRecord = comment.parentComment?.record as 127 121 | PubLeafletComment.Record 128 122 | undefined; 129 - const parentProfile = comment.parentComment?.bsky_profiles?.record as 130 - | AppBskyActorProfile.Record 131 - | undefined; 123 + const parentProfile = comment.parentComment?.profile; 132 124 const parentDisplayName = 133 - parentProfile?.displayName || comment.parentComment?.bsky_profiles?.handle; 125 + parentProfile?.displayName || parentProfile?.handle; 134 126 135 127 // Build direct link to the comment 136 128 const commentLink = useMemo(() => { ··· 167 159 }, [comment.document, comment.publication, comment.uri, record.onPage]); 168 160 169 161 // Get avatar source 170 - const avatarSrc = profile?.avatar?.ref 171 - ? blobRefToSrc(profile.avatar.ref, commenterDid) 172 - : undefined; 162 + const avatarSrc = profile?.avatar ?? undefined; 173 163 174 164 return ( 175 165 <div id={comment.uri} className="w-full flex flex-col text-left mb-8">
+28 -42
app/(app)/(home-pages)/p/[didOrHandle]/comments/getProfileComments.ts
··· 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 4 import { Json } from "supabase/database.types"; 5 5 import { PubLeafletComment } from "lexicons/api"; 6 + import { getProfiles, type Profile } from "src/identity"; 6 7 7 8 export type Cursor = { 8 9 indexed_at: string; ··· 13 14 uri: string; 14 15 record: Json; 15 16 indexed_at: string; 16 - bsky_profiles: { record: Json; handle: string | null } | null; 17 + profile: Profile | null; 17 18 document: { 18 19 uri: string; 19 20 data: Json; ··· 26 27 parentComment: { 27 28 uri: string; 28 29 record: Json; 29 - bsky_profiles: { record: Json; handle: string | null } | null; 30 + profile: Profile | null; 30 31 } | null; 31 32 }; 32 33 ··· 40 41 .from("comments_on_documents") 41 42 .select( 42 43 `*, 43 - bsky_profiles(record, handle), 44 44 documents(uri, data, documents_in_publications(publications(*)))`, 45 45 ) 46 46 .eq("profile", did) ··· 65 65 .map((c) => (c.record as PubLeafletComment.Record).reply?.parent) 66 66 .filter((uri): uri is string => !!uri); 67 67 68 - // Fetch parent comments if there are any replies 69 - let parentCommentsMap = new Map< 70 - string, 71 - { 72 - uri: string; 73 - record: Json; 74 - bsky_profiles: { record: Json; handle: string | null } | null; 75 - } 76 - >(); 68 + const { data: parentComments } = parentUris.length 69 + ? await supabaseServerClient 70 + .from("comments_on_documents") 71 + .select("uri, record, profile") 72 + .in("uri", parentUris) 73 + : { data: null }; 77 74 78 - if (parentUris.length > 0) { 79 - const { data: parentComments } = await supabaseServerClient 80 - .from("comments_on_documents") 81 - .select(`uri, record, bsky_profiles(record, handle)`) 82 - .in("uri", parentUris); 83 - 84 - if (parentComments) { 85 - for (const pc of parentComments) { 86 - parentCommentsMap.set(pc.uri, { 87 - uri: pc.uri, 88 - record: pc.record, 89 - bsky_profiles: pc.bsky_profiles, 90 - }); 91 - } 92 - } 75 + const allDids = new Set<string>([did]); 76 + for (const pc of parentComments ?? []) { 77 + if (pc.profile) allDids.add(pc.profile); 93 78 } 79 + const profiles = await getProfiles([...allDids]); 94 80 95 - // Transform to ProfileComment format 81 + const parentMap = new Map( 82 + (parentComments ?? []).map((pc) => [ 83 + pc.uri, 84 + { 85 + uri: pc.uri, 86 + record: pc.record, 87 + profile: pc.profile ? profiles.get(pc.profile) ?? null : null, 88 + }, 89 + ]), 90 + ); 91 + 96 92 const comments: ProfileComment[] = rawComments.map((comment) => { 97 93 const record = comment.record as PubLeafletComment.Record; 98 94 const doc = comment.documents; ··· 102 98 uri: comment.uri, 103 99 record: comment.record, 104 100 indexed_at: comment.indexed_at, 105 - bsky_profiles: comment.bsky_profiles, 106 - document: doc 107 - ? { 108 - uri: doc.uri, 109 - data: doc.data, 110 - } 111 - : null, 112 - publication: pub 113 - ? { 114 - uri: pub.uri, 115 - record: pub.record, 116 - } 117 - : null, 101 + profile: profiles.get(did) ?? null, 102 + document: doc ? { uri: doc.uri, data: doc.data } : null, 103 + publication: pub ? { uri: pub.uri, record: pub.record } : null, 118 104 parentComment: record.reply?.parent 119 - ? parentCommentsMap.get(record.reply.parent) || null 105 + ? parentMap.get(record.reply.parent) ?? null 120 106 : null, 121 107 }; 122 108 });
+10 -2
app/(app)/(home-pages)/p/[didOrHandle]/comments/page.tsx
··· 1 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 1 + import { Suspense } from "react"; 2 + import { idResolver } from "src/identity"; 2 3 import { getProfileComments } from "./getProfileComments"; 3 4 import { ProfileCommentsContent } from "./CommentsContent"; 4 5 ··· 16 17 did = resolved; 17 18 } 18 19 19 - const { comments, nextCursor } = await getProfileComments(did); 20 + return ( 21 + <Suspense fallback={null}> 22 + <ProfileCommentsLoader did={did} /> 23 + </Suspense> 24 + ); 25 + } 20 26 27 + async function ProfileCommentsLoader({ did }: { did: string }) { 28 + const { comments, nextCursor } = await getProfileComments(did); 21 29 return ( 22 30 <ProfileCommentsContent 23 31 did={did}
+5 -7
app/(app)/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
··· 8 8 normalizePublicationRecord, 9 9 } from "src/utils/normalizeRecords"; 10 10 import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; 11 + import { idResolver } from "src/identity"; 11 12 12 13 export type Cursor = { 13 14 sort_date: string; ··· 20 21 ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { 21 22 const limit = 20; 22 23 23 - let [{ data: rawFeed, error }, { data: profile }] = await Promise.all([ 24 + let [{ data: rawFeed, error }, resolved] = await Promise.all([ 24 25 supabaseServerClient.rpc("get_profile_posts", { 25 26 p_did: did, 26 27 p_cursor_sort_date: cursor?.sort_date ?? undefined, 27 28 p_cursor_uri: cursor?.uri ?? undefined, 28 29 p_limit: limit, 29 30 }), 30 - supabaseServerClient 31 - .from("bsky_profiles") 32 - .select("handle") 33 - .eq("did", did) 34 - .single(), 31 + idResolver.did.resolve(did).catch(() => null), 35 32 ]); 36 33 37 34 if (error) { ··· 42 39 let feed = deduplicateByUriOrdered(rawFeed || []); 43 40 if (feed.length === 0) return { posts: [], nextCursor: null }; 44 41 45 - let handle = profile?.handle ? `@${profile.handle}` : null; 42 + let resolvedHandle = resolved?.alsoKnownAs?.[0]?.slice(5); 43 + let handle = resolvedHandle ? `@${resolvedHandle}` : null; 46 44 let posts: Post[] = []; 47 45 48 46 for (let row of feed) {
+1 -1
app/(app)/(home-pages)/p/[didOrHandle]/layout.tsx
··· 1 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 1 + import { idResolver } from "src/identity"; 2 2 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 4 import { Json } from "supabase/database.types";
+1 -1
app/(app)/(home-pages)/p/[didOrHandle]/page.tsx
··· 1 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 1 + import { idResolver } from "src/identity"; 2 2 import { getProfilePosts } from "./getProfilePosts"; 3 3 import { ProfilePostsContent } from "./PostsContent"; 4 4
+1 -1
app/(app)/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx
··· 1 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 1 + import { idResolver } from "src/identity"; 2 2 import { getSubscriptions } from "app/(app)/(home-pages)/reader/getSubscriptions"; 3 3 import { ProfileSubscriptionsContent } from "./SubscriptionsContent"; 4 4
+1 -1
app/(app)/(home-pages)/reader/enrichPost.ts
··· 6 6 normalizeDocumentRecord, 7 7 normalizePublicationRecord, 8 8 } from "src/utils/normalizeRecords"; 9 - import { idResolver } from "./idResolver"; 9 + import { idResolver } from "src/identity"; 10 10 import type { Post } from "./getReaderFeed"; 11 11 12 12 type RawDocument = {
+1 -1
app/(app)/(home-pages)/reader/getSubscriptions.ts
··· 5 5 import { getIdentityData } from "actions/getIdentityData"; 6 6 import { Json } from "supabase/database.types"; 7 7 import { supabaseServerClient } from "supabase/serverClient"; 8 - import { idResolver } from "./idResolver"; 8 + import { idResolver } from "src/identity"; 9 9 import { Cursor } from "./getReaderFeed"; 10 10 import { 11 11 normalizePublicationRecord,
+1 -3
app/(app)/(home-pages)/reader/idResolver.ts src/identity/idResolver.ts
··· 1 1 import { IdResolver } from "@atproto/identity"; 2 2 import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; 3 3 import Client from "ioredis"; 4 - // Create Redis client for DID caching 4 + 5 5 let redisClient: Client | null = null; 6 6 if (process.env.REDIS_URL && process.env.NODE_ENV === "production") { 7 7 redisClient = new Client(process.env.REDIS_URL); 8 8 } 9 9 10 - // Redis-based DID cache implementation 11 10 class RedisDidCache implements DidCache { 12 11 private staleTTL: number; 13 12 private maxTTL: number; ··· 72 71 } 73 72 } 74 73 75 - // Create IdResolver with Redis-based DID cache 76 74 export const idResolver = new IdResolver({ 77 75 didCache: redisClient ? new RedisDidCache(redisClient) : undefined, 78 76 });
+1 -1
app/(app)/(home-pages)/tag/[tag]/getDocumentsByTag.ts
··· 3 3 import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; 4 4 import { supabaseServerClient } from "supabase/serverClient"; 5 5 import { AtUri } from "@atproto/api"; 6 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 6 + import { idResolver } from "src/identity"; 7 7 import type { Post } from "app/(app)/(home-pages)/reader/getReaderFeed"; 8 8 import { 9 9 normalizeDocumentRecord,
+3 -2
app/(app)/[leaflet_id]/publish/PublishPost.tsx
··· 157 157 entitiesToDelete: props.entitiesToDelete, 158 158 publishedAt: localPublishedAt?.toISOString() || new Date().toISOString(), 159 159 postPreferences, 160 + sendEmail: shareState.email, 160 161 }); 161 162 162 163 if (!result.success) { ··· 446 447 const pub_creator = new AtUri(props.publication_uri).host; 447 448 return ( 448 449 <PublicationThemeProvider 449 - theme={props.record.theme} 450 + record={props.record} 450 451 pub_creator={pub_creator} 451 452 > 452 453 <PublicationBackgroundProvider 453 - theme={props.record.theme} 454 + record={props.record} 454 455 pub_creator={pub_creator} 455 456 > 456 457 <SocialPreviewFrame
+68 -6
app/(app)/[leaflet_id]/publish/publishBskyPost.ts
··· 1 1 "use server"; 2 2 3 3 import { 4 + AppBskyEmbedExternal, 4 5 AppBskyRichtextFacet, 5 6 Agent as BskyAgent, 6 7 UnicodeString, 7 8 } from "@atproto/api"; 8 9 import sharp from "sharp"; 9 10 import { TID } from "@atproto/common"; 11 + import { AtUri } from "@atproto/syntax"; 10 12 import { getIdentityData } from "actions/getIdentityData"; 11 13 import { AtpBaseClient, SiteStandardDocument } from "lexicons/api"; 12 14 import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; ··· 18 20 } from "src/utils/getMicroLinkOgImage"; 19 21 import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 20 22 import { maybeOffloadPagesToBlob } from "src/utils/offloadPagesToBlob"; 23 + 24 + type StrongRef = { 25 + $type: "com.atproto.repo.strongRef"; 26 + uri: string; 27 + cid: string; 28 + }; 21 29 22 30 type PublishBskyResult = 23 31 | { success: true } ··· 91 99 let blob = await agent.com.atproto.repo.uploadBlob(resizedImage, { 92 100 headers: { "Content-Type": "image/webp" }, 93 101 }); 102 + 103 + // Reference the document and, when it lives in a publication, the publication 104 + // record on the post so the appview can index the relationship. Our DB 105 + // doesn't store CIDs, so read each strong ref straight from the author's repo. 106 + // (The document ref is the snapshot taken before we write bskyPostRef back to 107 + // it below — the post→document→post cycle can't reference its own next CID.) 108 + let did = credentialSession.did!; 109 + let documentUri = `at://${did}/${args.document_record.$type}/${args.rkey}`; 110 + 111 + let { data: docInPub } = await supabaseServerClient 112 + .from("documents_in_publications") 113 + .select("publication") 114 + .eq("document", documentUri) 115 + .maybeSingle(); 116 + 117 + let associatedRefs: StrongRef[] = []; 118 + for (let uri of [documentUri, docInPub?.publication]) { 119 + if (!uri) continue; 120 + let ref = await getRecordStrongRef(agent, uri); 121 + if (ref) associatedRefs.push(ref); 122 + } 123 + 124 + // associatedRefs hangs off the external embed card alongside uri/title/etc. 125 + // It isn't in the published @atproto/api types yet, so widen External here. 126 + let external: AppBskyEmbedExternal.External & { 127 + associatedRefs?: StrongRef[]; 128 + } = { 129 + uri: args.url, 130 + title: args.title, 131 + description: args.description, 132 + thumb: blob.data.blob, 133 + }; 134 + if (associatedRefs.length > 0) external.associatedRefs = associatedRefs; 135 + 94 136 let bsky = new BskyAgent(credentialSession); 95 137 let post = await bsky.app.bsky.feed.post.create( 96 138 { ··· 103 145 facets: args.facets, 104 146 embed: { 105 147 $type: "app.bsky.embed.external", 106 - external: { 107 - uri: args.url, 108 - title: args.title, 109 - description: args.description, 110 - thumb: blob.data.blob, 111 - }, 148 + external, 112 149 }, 113 150 }, 114 151 ); ··· 135 172 .eq("uri", result.uri); 136 173 return { success: true }; 137 174 } 175 + 176 + // Resolve a record URI to a strong ref ({ uri, cid }) by reading its current 177 + // CID from the repo. Returns null if the record is missing or has no CID so a 178 + // failed lookup degrades to a missing associatedRef rather than blocking the post. 179 + async function getRecordStrongRef( 180 + agent: AtpBaseClient, 181 + uri: string, 182 + ): Promise<StrongRef | null> { 183 + try { 184 + let { host, collection, rkey } = new AtUri(uri); 185 + let { data } = await agent.com.atproto.repo.getRecord({ 186 + repo: host, 187 + collection, 188 + rkey, 189 + }); 190 + if (!data.cid) return null; 191 + return { 192 + $type: "com.atproto.repo.strongRef", 193 + uri: data.uri, 194 + cid: data.cid, 195 + }; 196 + } catch { 197 + return null; 198 + } 199 + }
+25 -54
app/(app)/lish/[did]/[publication]/PublicationContent.tsx
··· 1 1 import React from "react"; 2 2 import { PublicationHomeLayout } from "./PublicationHomeLayout"; 3 3 import { PublicationAuthor } from "./PublicationAuthor"; 4 - import { PublicationHeader } from "./PublicationHeader"; 5 - import { PublicationNav } from "./PublicationNav"; 6 - import { PublicationStickyHeader } from "./PublicationStickyHeader"; 7 - import { PublicationFullHeader } from "./PublicationFullHeader"; 8 4 import { 9 5 normalizePublicationRecord, 10 6 normalizeDocumentRecord, ··· 91 87 <PublicationHomeLayout 92 88 uri={publication.uri} 93 89 showPageBackground={!!showPageBackground} 94 - stickyHeader={ 95 - navPages.length > 0 ? ( 96 - <PublicationStickyHeader 97 - nav={ 98 - <PublicationNav 99 - publicationUrl={getPublicationURL(publication)} 100 - pages={navPages} 101 - activePath="/" 102 - /> 103 - } 104 - > 105 - <PublicationHeader 106 - variant="inline" 107 - iconUrl={ 108 - record?.icon ? blobRefToSrc(record.icon.ref, did) : undefined 109 - } 110 - publicationName={publication.name} 111 - /> 112 - </PublicationStickyHeader> 113 - ) : ( 114 - <PublicationFullHeader> 115 - <PublicationHeader 116 - iconUrl={ 117 - record?.icon ? blobRefToSrc(record.icon.ref, did) : undefined 118 - } 119 - publicationName={publication.name} 120 - description={record?.description} 121 - author={ 122 - profile ? ( 123 - <PublicationAuthor 124 - did={profile.did} 125 - displayName={profile.displayName} 126 - handle={profile.handle} 127 - /> 128 - ) : undefined 129 - } 130 - subscribeButton={ 131 - <div className="max-w-sm mx-auto"> 132 - <SubscribeInput 133 - publicationUri={publication.uri} 134 - publicationUrl={record?.url} 135 - publicationName={record?.name ?? publication.name} 136 - publicationDescription={record?.description} 137 - newsletterMode={newsletterMode} 138 - /> 139 - </div> 140 - } 141 - /> 142 - </PublicationFullHeader> 143 - ) 90 + iconUrl={record?.icon ? blobRefToSrc(record.icon.ref, did) : undefined} 91 + publicationName={publication.name} 92 + description={record?.description} 93 + navPages={navPages} 94 + publicationUrl={getPublicationURL(publication)} 95 + activePath="/" 96 + author={ 97 + profile ? ( 98 + <PublicationAuthor 99 + did={profile.did} 100 + displayName={profile.displayName} 101 + handle={profile.handle} 102 + /> 103 + ) : undefined 104 + } 105 + subscribeButton={ 106 + <div className="max-w-sm mx-auto"> 107 + <SubscribeInput 108 + publicationUri={publication.uri} 109 + publicationUrl={record?.url} 110 + publicationName={record?.name ?? publication.name} 111 + publicationDescription={record?.description} 112 + newsletterMode={newsletterMode} 113 + /> 114 + </div> 144 115 } 145 116 > 146 117 <PublicationPostsList
-11
app/(app)/lish/[did]/[publication]/PublicationFullHeader.tsx
··· 1 - import React from "react"; 2 - 3 - export function PublicationFullHeader(props: { children: React.ReactNode }) { 4 - return ( 5 - <div className="pubFullHeader shrink-0"> 6 - <div className="sm:max-w-(--page-width-units) w-full mx-auto px-3 sm:px-4 pt-5"> 7 - {props.children} 8 - </div> 9 - </div> 10 - ); 11 - }
+45 -2
app/(app)/lish/[did]/[publication]/PublicationHomeLayout.tsx
··· 1 1 "use client"; 2 2 3 + import React from "react"; 3 4 import { usePreserveScroll } from "src/hooks/usePreserveScroll"; 5 + import { PublicationHeader } from "./PublicationHeader"; 6 + import { PublicationNav, type PublicationNavPage } from "./PublicationNav"; 7 + import { PublicationStickyHeader } from "./PublicationStickyHeader"; 4 8 5 9 export function PublicationHomeLayout(props: { 6 10 uri: string; 7 11 showPageBackground: boolean; 8 - stickyHeader?: React.ReactNode; 12 + iconUrl?: string; 13 + publicationName: string; 14 + description?: string; 15 + author?: React.ReactNode; 16 + subscribeButton?: React.ReactNode; 17 + navPages: PublicationNavPage[]; 18 + publicationUrl: string; 19 + activePath: string; 9 20 children: React.ReactNode; 10 21 }) { 11 22 let { ref } = usePreserveScroll<HTMLDivElement>(props.uri); 23 + let hasNav = props.navPages.length > 0; 24 + 25 + let header = hasNav ? ( 26 + <PublicationStickyHeader 27 + nav={ 28 + <PublicationNav 29 + publicationUrl={props.publicationUrl} 30 + pages={props.navPages} 31 + activePath={props.activePath} 32 + /> 33 + } 34 + > 35 + <PublicationHeader 36 + variant="inline" 37 + iconUrl={props.iconUrl} 38 + publicationName={props.publicationName} 39 + /> 40 + </PublicationStickyHeader> 41 + ) : ( 42 + <div className="pubFullHeader shrink-0"> 43 + <div className="sm:max-w-(--page-width-units) w-full mx-auto px-3 sm:px-4 pt-5"> 44 + <PublicationHeader 45 + iconUrl={props.iconUrl} 46 + publicationName={props.publicationName} 47 + description={props.description} 48 + author={props.author} 49 + subscribeButton={props.subscribeButton} 50 + /> 51 + </div> 52 + </div> 53 + ); 54 + 12 55 let inner = ( 13 56 <> 14 - {props.stickyHeader} 57 + {header} 15 58 <div className="pubContent sm:max-w-(--page-width-units) w-full mx-auto px-3 sm:px-4 pb-5"> 16 59 {props.children} 17 60 </div>
+46 -48
app/(app)/lish/[did]/[publication]/PublicationPostItem.tsx
··· 66 66 return ( 67 67 <SpeedyLink 68 68 href={href} 69 - className="publishedPost no-underline! flex flex-col" 69 + className="publishedPost no-underline! flex flex-col grow" 70 70 > 71 71 {children} 72 72 </SpeedyLink> 73 73 ); 74 74 } 75 75 return ( 76 - <div className="publishedPost no-underline! flex flex-col">{children}</div> 76 + <div className="publishedPost no-underline! flex flex-col grow"> 77 + {children} 78 + </div> 77 79 ); 78 80 } 79 81 80 82 export function PublicationPostItemSmall(props: CommonProps) { 81 83 return ( 82 - <div className="flex w-full grow flex-col gap-1 px-3 py-2"> 84 + <div className="flex w-full grow flex-col px-3 py-2"> 83 85 <PostLink href={props.href}> 84 - {props.title && <h3 className="text-primary">{props.title}</h3>} 86 + {props.title && ( 87 + <h3 className="text-primary leading-snug pb-1">{props.title}</h3> 88 + )} 85 89 </PostLink> 86 90 <MetaRow 87 91 author={props.author} ··· 89 93 interactions={props.interactions} 90 94 textClassName="text-sm" 91 95 /> 92 - <hr className=" border-border-light mt-1" /> 93 96 {props.footer} 94 97 </div> 95 98 ); ··· 97 100 98 101 export function PublicationPostItemMedium(props: MediumProps) { 99 102 const hasCoverImage = !!props.coverImageSrc; 103 + 100 104 return ( 101 - <div className="flex w-full flex-col"> 102 - <div className="flex w-full gap-3 items-stretch sm:min-h-36"> 103 - <div className="flex w-full gap-2 grow flex-col justify-between min-w-0 pl-3 pr-3 py-2"> 104 - <PostLink href={props.href}> 105 - {props.title && ( 106 - <h3 className="text-primary line-clamp-2">{props.title}</h3> 107 - )} 108 - <p className="text-secondary line-clamp-3">{props.description}</p> 109 - </PostLink> 110 - <MetaRow 111 - author={props.author} 112 - date={props.date} 113 - interactions={props.interactions} 114 - textClassName="text-sm place-self-end" 105 + <div className="flex w-full items-stretch "> 106 + <div className="flex w-full grow flex-col justify-between min-w-0 pl-3 pr-3 py-2"> 107 + <PostLink href={props.href}> 108 + {props.title && ( 109 + <h3 className="text-primary leading-snug line-clamp-2 pb-1"> 110 + {props.title} 111 + </h3> 112 + )} 113 + <p className="text-secondary line-clamp-3 grow mb-2"> 114 + {props.description} 115 + </p> 116 + </PostLink> 117 + <MetaRow 118 + author={props.author} 119 + date={props.date} 120 + interactions={props.interactions} 121 + textClassName="text-sm place-self-end" 122 + /> 123 + <div className="shrink-0">{props.footer}</div> 124 + </div> 125 + {hasCoverImage && ( 126 + <div 127 + className={`self-start shrink-0 w-16 border-l border-border-light ${props.footer ? " w-[182px] h-[182px]" : "sm:h-36 sm:w-36"}`} 128 + > 129 + <img 130 + src={props.coverImageSrc} 131 + alt={props.coverImageAlt || props.title || ""} 132 + className="w-full aspect-square object-cover rounded" 115 133 /> 116 134 </div> 117 - {hasCoverImage && ( 118 - <div 119 - className={`self-start shrink-0 w-16 border-l border-border-light ${props.footer ? "w-[182px]" : "sm:w-36"}`} 120 - > 121 - <img 122 - src={props.coverImageSrc} 123 - alt={props.coverImageAlt || props.title || ""} 124 - className="w-full aspect-square object-cover rounded" 125 - /> 126 - </div> 127 - )} 128 - </div> 129 - <div className="shrink-0 px-3 pb-2"> 130 - <hr className=" border-border-light mt-2 mb-1" /> 131 - {props.footer} 132 - </div> 135 + )} 133 136 </div> 134 137 ); 135 138 } ··· 139 142 const widePage = (props.pageWidth ?? 0) >= 768; 140 143 const body = ( 141 144 <div 142 - className={`flex w-full grow flex-col ${widePage ? " px-3 py-2 sm:pb-3" : "px-3 py-2 "}`} 145 + className={`min-w-0 flex w-full grow flex-col ${widePage ? " px-3 py-2 sm:pb-3" : "px-3 py-2 "}`} 143 146 > 144 147 <div className="flex flex-col grow w-full min-w-0 justify-between gap-2"> 145 148 <PostLink href={props.href}> 146 149 {props.title && ( 147 150 <h3 148 - className={`text-primary text-lg clamp-2 ${widePage ? "sm:text-xl " : ""}`} 151 + className={`text-primary leading-snug text-lg pb-1 clamp-2 ${widePage ? "sm:text-xl " : ""}`} 149 152 > 150 153 {props.title} 151 154 </h3> ··· 163 166 textClassName={`${widePage ? "text-sm sm:text-base " : "text-sm "} `} 164 167 /> 165 168 </div> 166 - <div className="shrink-0 flex flex-col shrink-0"> 167 - <hr className=" border-border-light mt-2 mb-1" /> 168 - {props.footer} 169 - </div> 169 + <div className="shrink-0 flex flex-col ">{props.footer}</div> 170 170 </div> 171 171 ); 172 172 ··· 176 176 className={`flex flex-col items-stretch ${widePage ? "sm:flex-row sm:gap-2 gap-0" : ""} w-full items-start`} 177 177 > 178 178 {hasCoverImage && ( 179 - <div className="w-fit"> 180 - <img 181 - src={props.coverImageSrc} 182 - alt={props.coverImageAlt || props.title || ""} 183 - className={`${widePage ? "h-[244px]" : "h-full"} aspect-[1.91/1] object-cover rounded`} 184 - /> 185 - </div> 179 + <img 180 + src={props.coverImageSrc} 181 + alt={props.coverImageAlt || props.title || ""} 182 + className={`${widePage ? "sm:h-[244px] aspect-[3/2]" : "h-full aspect-[1.91/1]"} object-cover rounded`} 183 + /> 186 184 )} 187 185 {body} 188 186 </div>
+38 -32
app/(app)/lish/[did]/[publication]/PublicationPostsList.tsx
··· 53 53 highlightFirstPost?: boolean; 54 54 }) { 55 55 return ( 56 - <div className="publicationPostList w-full flex flex-col gap-4"> 56 + <div className="publicationPostList w-full flex flex-col gap-2"> 57 57 {fakePosts 58 58 ? fakePosts.map((post, i) => ( 59 59 <PublicationPostItem ··· 129 129 130 130 if (Variant === "large") { 131 131 return ( 132 - <PublicationPostItemLarge 133 - key={post.uri} 134 - href={docUrl} 135 - title={doc_record.title} 136 - description={ 137 - doc_record.description || getFirstParagraph(doc_record) 138 - } 139 - date={date} 140 - interactions={interactions} 141 - coverImageSrc={coverImageSrc} 142 - coverImageAlt={doc_record.title} 143 - pageWidth={publicationRecord?.theme?.pageWidth} 144 - /> 132 + <React.Fragment key={post.uri}> 133 + <PublicationPostItemLarge 134 + href={docUrl} 135 + title={doc_record.title} 136 + description={ 137 + doc_record.description || getFirstParagraph(doc_record) 138 + } 139 + date={date} 140 + interactions={interactions} 141 + coverImageSrc={coverImageSrc} 142 + coverImageAlt={doc_record.title} 143 + pageWidth={publicationRecord?.theme?.pageWidth} 144 + /> 145 + <hr className="last:hidden border-border-light" /> 146 + </React.Fragment> 145 147 ); 146 148 } 147 149 148 150 if (Variant === "small") { 149 151 return ( 150 - <PublicationPostItemSmall 151 - key={post.uri} 152 + <React.Fragment key={post.uri}> 153 + <PublicationPostItemSmall 154 + href={docUrl} 155 + title={doc_record.title} 156 + date={date} 157 + interactions={interactions} 158 + /> 159 + <hr className="last:hidden border-border-light" /> 160 + </React.Fragment> 161 + ); 162 + } 163 + 164 + return ( 165 + <React.Fragment key={post.uri}> 166 + <PublicationPostItemMedium 152 167 href={docUrl} 153 168 title={doc_record.title} 169 + description={ 170 + doc_record.description || getFirstParagraph(doc_record) 171 + } 154 172 date={date} 155 173 interactions={interactions} 174 + coverImageSrc={coverImageSrc} 175 + coverImageAlt={doc_record.title} 156 176 /> 157 - ); 158 - } 159 - 160 - return ( 161 - <PublicationPostItemMedium 162 - key={post.uri} 163 - href={docUrl} 164 - title={doc_record.title} 165 - description={ 166 - doc_record.description || getFirstParagraph(doc_record) 167 - } 168 - date={date} 169 - interactions={interactions} 170 - coverImageSrc={coverImageSrc} 171 - coverImageAlt={doc_record.title} 172 - /> 177 + <hr className="last:hidden border-border-light" /> 178 + </React.Fragment> 173 179 ); 174 180 })} 175 181 </div>
+1 -7
app/(app)/lish/[did]/[publication]/PublicationStickyHeader.tsx
··· 3 3 import { usePathname } from "next/navigation"; 4 4 5 5 export function PublicationStickyHeader(props: { 6 - sticky?: boolean; 7 6 nav: React.ReactNode; 8 7 children: React.ReactNode; 9 8 }) { 10 9 let ref = useRef<HTMLDivElement>(null); 11 10 let pathname = usePathname(); 12 - let sticky = props.sticky ?? true; 13 11 14 12 useEffect(() => { 15 13 let el = ref.current; ··· 62 60 return ( 63 61 <div 64 62 ref={ref} 65 - className={ 66 - sticky 67 - ? "pubStickyHeader sticky top-0 z-10 bg-bg-page shrink-0" 68 - : "pubStickyHeader shrink-0" 69 - } 63 + className="pubStickyHeader sticky top-0 z-10 bg-bg-page shrink-0" 70 64 > 71 65 <div 72 66 className="sm:max-w-(--page-width-units) w-full mx-auto px-3 sm:px-4"
+1 -4
app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx
··· 208 208 } 209 209 210 210 const Interactions = (props: { pageId: string; parentPageId?: string }) => { 211 - const { uri: document_uri, comments: allComments, mentions } = useDocument(); 212 - let comments = allComments.filter( 213 - (c) => (c.record as PubLeafletComment.Record)?.onPage === props.pageId, 214 - ).length; 211 + const { uri: document_uri, commentsCount: comments, mentions } = useDocument(); 215 212 let quotes = mentions.filter((q) => q.link.includes(props.pageId)).length; 216 213 217 214 let { drawerOpen, drawer, pageId } = useInteractionState(document_uri);
+2 -6
app/(app)/lish/[did]/[publication]/[rkey]/CanvasPage.tsx
··· 10 10 import { PageWrapper } from "components/Pages/Page"; 11 11 import { Block } from "./PostContent"; 12 12 import { CanvasBackgroundPattern } from "components/Canvas"; 13 - import { 14 - getCommentCount, 15 - getQuoteCount, 16 - Interactions, 17 - } from "./Interactions/Interactions"; 13 + import { getQuoteCount, Interactions } from "./Interactions/Interactions"; 18 14 import { Separator } from "components/Layout"; 19 15 import { Popover } from "components/Popover"; 20 16 import { InfoSmall } from "components/Icons/InfoSmall"; ··· 71 67 data={document} 72 68 profile={profile} 73 69 preferences={preferences} 74 - commentsCount={getCommentCount(document.comments_on_documents, pageId)} 70 + commentsCount={document.commentsCount} 75 71 quotesCount={getQuoteCount(document.quotesAndMentions, pageId)} 76 72 recommendsCount={document.recommendsCount} 77 73 />
+9 -11
app/(app)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
··· 3 3 PubLeafletPagesLinearDocument, 4 4 PubLeafletPagesCanvas, 5 5 } from "lexicons/api"; 6 + import { Suspense } from "react"; 6 7 import { QuoteHandler } from "./QuoteHandler"; 7 8 import { 8 9 PublicationBackgroundProvider, ··· 17 18 import { LeafletContentProvider } from "contexts/LeafletContentContext"; 18 19 import { FontLoader } from "components/FontLoader"; 19 20 import { mergePreferences } from "src/utils/mergePreferences"; 20 - import { PublicationNav } from "../PublicationNav"; 21 - import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; 21 + import { CommentsSection } from "./Interactions/Comments/CommentsSection"; 22 22 23 23 export async function DocumentPageRenderer({ 24 24 did, ··· 87 87 bodyFontId={document.theme?.bodyFont} 88 88 /> 89 89 <PublicationThemeProvider 90 - theme={document.theme} 90 + record={{ theme: document.theme }} 91 91 pub_creator={pub_creator} 92 92 isStandalone={isStandalone} 93 93 > 94 94 <PublicationBackgroundProvider 95 - theme={document.theme} 95 + record={{ theme: document.theme }} 96 96 pub_creator={pub_creator} 97 97 > 98 98 <LeafletLayout> 99 - {document.publication?.pages?.length ? ( 100 - <PublicationNav 101 - publicationUrl={getPublicationURL(document.publication)} 102 - pages={document.publication.pages} 103 - activePath={null} 104 - /> 105 - ) : null} 106 99 <PostPages 107 100 document_uri={document.uri} 108 101 preferences={mergePreferences( ··· 121 114 did={did} 122 115 prerenderedCodeBlocks={prerenderedCodeBlocks} 123 116 pollData={pollData} 117 + commentsSlot={ 118 + <Suspense fallback={null}> 119 + <CommentsSection document_uri={document.uri} /> 120 + </Suspense> 121 + } 124 122 /> 125 123 </LeafletLayout> 126 124
+7 -2
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx
··· 217 217 { 218 218 record: result.record, 219 219 uri: result.uri, 220 - bsky_profiles: { 221 - record: result.profile as Json, 220 + profile: { 222 221 did: new AtUri(result.uri).host, 222 + handle: null, 223 + displayName: 224 + (result.profile as { displayName?: string } | null) 225 + ?.displayName ?? null, 226 + avatar: null, 227 + description: null, 223 228 }, 224 229 }, 225 230 ],
+28
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentsSection.tsx
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { getProfiles } from "src/identity"; 3 + import { CommentsDrawerContent, type Comment } from "./index"; 4 + import { AtUri } from "@atproto/syntax"; 5 + 6 + export async function CommentsSection({ 7 + document_uri, 8 + }: { 9 + document_uri: string; 10 + }) { 11 + const { data: rows } = await supabaseServerClient 12 + .from("comments_on_documents") 13 + .select("uri, record ") 14 + .eq("document", document_uri); 15 + 16 + const dids = (rows ?? []).map((c) => new AtUri(c.uri).host); 17 + const profiles = await getProfiles([...new Set(dids)]); 18 + 19 + const comments: Comment[] = (rows ?? []).map((c) => ({ 20 + uri: c.uri, 21 + record: c.record, 22 + profile: profiles.get(new AtUri(c.uri).host) ?? null, 23 + })); 24 + 25 + return ( 26 + <CommentsDrawerContent document_uri={document_uri} comments={comments} /> 27 + ); 28 + }
-4
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts
··· 78 78 ), 79 79 ]); 80 80 81 - await supabaseServerClient.from("bsky_profiles").upsert({ 82 - did: credentialSession.did!, 83 - record: profile.value as Json, 84 - }); 85 81 let { data, error } = await supabaseServerClient 86 82 .from("comments_on_documents") 87 83 .insert({
+17 -22
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx
··· 1 1 "use client"; 2 - import { CloseTiny } from "components/Icons/CloseTiny"; 3 2 import { useInteractionState, setInteractionState } from "../Interactions"; 4 3 import { useIdentityData } from "components/IdentityProvider"; 5 4 import { CommentBox } from "./CommentBox"; ··· 9 8 import { useMemo, useState } from "react"; 10 9 import { CommentTiny } from "components/Icons/CommentTiny"; 11 10 import { Separator } from "components/Layout"; 12 - import { ButtonPrimary } from "components/Buttons"; 13 - import { BlueskyTiny } from "components/Icons/BlueskyTiny"; 14 11 import { Popover } from "components/Popover"; 15 - import { AppBskyActorProfile, AtUri } from "@atproto/api"; 12 + import { AtUri } from "@atproto/api"; 16 13 import { usePathname } from "next/navigation"; 17 14 import { QuoteContent } from "../Quotes"; 18 15 import { timeAgo } from "src/utils/timeAgo"; 19 16 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 20 17 import { ProfilePopover } from "components/ProfilePopover"; 21 18 import { LoginModal } from "components/LoginButton"; 19 + import { type Profile } from "src/identity"; 22 20 23 21 export type Comment = { 24 22 record: Json; 25 23 uri: string; 26 - bsky_profiles: { record: Json; did: string } | null; 24 + profile: Profile | null; 27 25 }; 28 26 export function CommentsDrawerContent(props: { 29 27 document_uri: string; 30 28 comments: Comment[]; 31 - pageId?: string; 32 29 noCommentBox?: boolean; 33 30 }) { 34 31 let { identity } = useIdentityData(); 35 - let { localComments } = useInteractionState(props.document_uri); 32 + let { localComments, pageId } = useInteractionState(props.document_uri); 36 33 let comments = useMemo(() => { 34 + let filtered = props.comments.filter( 35 + (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, 36 + ); 37 37 return [ 38 38 ...localComments.filter( 39 - (c) => (c.record as any)?.onPage === props.pageId, 39 + (c) => (c.record as any)?.onPage === pageId, 40 40 ), 41 - ...props.comments, 41 + ...filtered, 42 42 ]; 43 - }, [props.comments, localComments]); 43 + }, [props.comments, localComments, pageId]); 44 44 let pathname = usePathname(); 45 45 let redirectRoute = useMemo(() => { 46 46 if (typeof window === "undefined") return; ··· 59 59 {!props.noCommentBox && ( 60 60 <> 61 61 {identity?.atp_did ? ( 62 - <CommentBox doc_uri={props.document_uri} pageId={props.pageId} /> 62 + <CommentBox doc_uri={props.document_uri} pageId={pageId} /> 63 63 ) : ( 64 64 <div className="w-full accent-container text-tertiary text-center italic p-3 gap-2"> 65 65 <span className="text-accent-contrast font-bold"> ··· 91 91 ) 92 92 .map((comment) => { 93 93 let record = comment.record as PubLeafletComment.Record; 94 - let profile = comment.bsky_profiles 95 - ?.record as AppBskyActorProfile.Record; 96 94 return ( 97 95 <Comment 98 - pageId={props.pageId} 99 - profile={profile} 96 + pageId={pageId} 97 + profile={comment.profile} 100 98 document={props.document_uri} 101 99 comment={comment} 102 100 record={record} ··· 114 112 document: string; 115 113 comment: Comment; 116 114 comments: Comment[]; 117 - profile: AppBskyActorProfile.Record; 115 + profile: Profile | null; 118 116 record: PubLeafletComment.Record; 119 117 pageId?: string; 120 118 }) => { 121 - const did = props.comment.bsky_profiles?.did; 119 + const did = props.profile?.did; 122 120 123 121 let timeAgoDate = timeAgo(props.record.createdAt, { compact: true }); 124 122 ··· 130 128 didOrHandle={did} 131 129 trigger={ 132 130 <div className="text-sm text-secondary font-bold hover:underline"> 133 - {props.profile.displayName} 131 + {props.profile?.displayName} 134 132 </div> 135 133 } 136 134 /> ··· 270 268 document={props.document} 271 269 key={reply.uri} 272 270 comment={reply} 273 - profile={ 274 - reply.bsky_profiles 275 - ?.record as AppBskyActorProfile.Record 276 - } 271 + profile={reply.profile} 277 272 record={reply.record as PubLeafletComment.Record} 278 273 comments={props.comments} 279 274 />
+3 -15
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx
··· 1 1 "use client"; 2 - import { Media } from "components/Media"; 3 2 import { MentionsDrawerContent } from "./Quotes"; 4 3 import { 5 4 InteractionState, 6 5 setInteractionState, 7 6 useInteractionState, 8 7 } from "./Interactions"; 9 - import { Json } from "supabase/database.types"; 10 - import { Comment, CommentsDrawerContent } from "./Comments"; 11 8 import { useSearchParams } from "next/navigation"; 12 9 import { SandwichSpacer } from "components/LeafletLayout"; 13 10 import { decodeQuotePosition } from "../quotePosition"; ··· 17 14 showPageBackground: boolean | undefined; 18 15 document_uri: string; 19 16 quotesAndMentions: { uri: string; link?: string }[]; 20 - comments: Comment[]; 17 + commentsSlot: React.ReactNode; 21 18 did: string; 22 19 pageId?: string; 23 20 }) => { 24 21 let drawer = useDrawerOpen(props.document_uri); 25 22 if (!drawer) return null; 26 23 27 - // Filter comments and quotes based on pageId 28 - const filteredComments = props.comments.filter( 29 - (c) => (c.record as any)?.onPage === props.pageId, 30 - ); 31 - 32 24 const filteredQuotesAndMentions = props.quotesAndMentions.filter((q) => { 33 25 if (!q.link) return !props.pageId; // Direct mentions without quote context go to main page 34 26 const url = new URL(q.link); ··· 57 49 <CloseTiny /> 58 50 </button> 59 51 <MentionsDrawerContent 60 - {...props} 52 + did={props.did} 61 53 quotesAndMentions={filteredQuotesAndMentions} 62 54 /> 63 55 </> ··· 76 68 <CloseTiny /> 77 69 </button> 78 70 </div> 79 - <CommentsDrawerContent 80 - document_uri={props.document_uri} 81 - comments={filteredComments} 82 - pageId={props.pageId} 83 - /> 71 + {props.commentsSlot} 84 72 </> 85 73 )} 86 74 </div>
-15
app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx
··· 11 11 import { TagTiny } from "components/Icons/TagTiny"; 12 12 import { Tag } from "components/Tags"; 13 13 import { Popover } from "components/Popover"; 14 - import { PubLeafletComment } from "lexicons/api"; 15 - import { type CommentOnDocument } from "contexts/DocumentContext"; 16 14 import { prefetchQuotesData } from "./Quotes"; 17 15 import { useIdentityData } from "components/IdentityProvider"; 18 16 import { ManageSubscription } from "components/Subscribe/ManageSubscribe"; ··· 368 366 } 369 367 } 370 368 371 - export function getCommentCount( 372 - comments: CommentOnDocument[], 373 - pageId?: string, 374 - ) { 375 - if (pageId) 376 - return comments.filter( 377 - (c) => (c.record as PubLeafletComment.Record)?.onPage === pageId, 378 - ).length; 379 - else 380 - return comments.filter( 381 - (c) => !(c.record as PubLeafletComment.Record)?.onPage, 382 - ).length; 383 - } 384 369 385 370 const EditButton = (props: { 386 371 publication: { identity_did: string } | null;
+1 -4
app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx
··· 3 3 import { useLeafletContent } from "contexts/LeafletContentContext"; 4 4 import { 5 5 ExpandedInteractions, 6 - getCommentCount, 7 6 getQuoteCount, 8 7 } from "./Interactions/Interactions"; 9 8 import { PostContent } from "./PostContent"; ··· 101 100 showComments={preferences.showComments !== false} 102 101 showMentions={preferences.showMentions !== false} 103 102 showRecommends={preferences.showRecommends !== false} 104 - commentsCount={ 105 - getCommentCount(document.comments_on_documents, pageId) || 0 106 - } 103 + commentsCount={document.commentsCount} 107 104 quotesCount={getQuoteCount(document.quotesAndMentions, pageId) || 0} 108 105 recommendsCount={document.recommendsCount} 109 106 />
+34 -9
app/(app)/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 19 19 PubLeafletBlocksPoll, 20 20 PubLeafletBlocksPostsList, 21 21 PubLeafletBlocksButton, 22 + PubLeafletBlocksSignup, 22 23 } from "lexicons/api"; 23 24 import { 24 25 PublicationPostsList, ··· 35 36 import { PubCodeBlock } from "./Blocks/PubCodeBlock"; 36 37 import { AppBskyFeedDefs } from "@atproto/api"; 37 38 import { PubBlueskyPostBlock } from "./Blocks/PublishBskyPostBlock"; 38 - import { StandardSitePostItemView } from "components/Blocks/StandardSitePostBlock/StandardSitePostItem"; 39 + import { 40 + StandardSitePostItemView, 41 + WithStandardSitePostPublicationTheme, 42 + } from "components/Blocks/StandardSitePostBlock/StandardSitePostItem"; 39 43 import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; 40 44 import { PublishedPageLinkBlock } from "./Blocks/PublishedPageBlock"; 41 45 import { PublishedPollBlock } from "./Blocks/PublishedPollBlock"; 42 46 import { PollData } from "./fetchPollData"; 43 47 import { ButtonPrimary } from "components/Buttons"; 48 + import { SubscribePanel } from "components/Subscribe/SubscribeButton"; 44 49 import { blockTextSize } from "src/utils/blockTextSize"; 45 50 import { slugify } from "src/utils/slugify"; 46 51 import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; ··· 154 159 isLast?: boolean; 155 160 }) => { 156 161 let b = block; 157 - let currentPublicationUri = 158 - useDocumentOptional()?.publication?.uri ?? null; 162 + let document = useDocumentOptional(); 163 + let currentPublicationUri = document?.publication?.uri ?? null; 159 164 let blockProps = { 160 165 style: { 161 166 scrollMarginTop: "4rem", ··· 251 256 : "small"; 252 257 return ( 253 258 <div className={className} {...blockProps}> 254 - <StandardSitePostItemView 259 + <WithStandardSitePostPublicationTheme 255 260 post={post} 256 - size={size} 257 - showPubTheme={b.block.showPublicationTheme !== false} 258 - currentPublicationUri={currentPublicationUri} 259 - /> 261 + enabled={b.block.showPublicationTheme !== false} 262 + > 263 + <StandardSitePostItemView 264 + post={post} 265 + size={size} 266 + currentPublicationUri={currentPublicationUri} 267 + /> 268 + </WithStandardSitePostPublicationTheme> 260 269 </div> 261 270 ); 262 271 } ··· 272 281 } 273 282 case PubLeafletBlocksHorizontalRule.isMain(b.block): { 274 283 return <hr className="my-2 w-full border-border-light" />; 284 + } 285 + case PubLeafletBlocksSignup.isMain(b.block): { 286 + if (!document?.publication?.uri) return null; 287 + return ( 288 + <div className={className} {...blockProps}> 289 + <SubscribePanel 290 + publicationUri={document.publication.uri} 291 + publicationUrl={document.normalizedPublication?.url} 292 + publicationName={ 293 + document.normalizedPublication?.name ?? document.publication.name 294 + } 295 + publicationDescription={document.normalizedPublication?.description} 296 + newsletterMode={document.publication.newsletterMode} 297 + /> 298 + </div> 299 + ); 275 300 } 276 301 case PubLeafletBlocksPostsList.isMain(b.block): { 277 302 if (!postsListData) return null; ··· 589 614 }); 590 615 591 616 let { theme } = useDocument(); 592 - let pubTheme = usePubTheme(theme); 617 + let pubTheme = usePubTheme({ theme }); 593 618 let iframeSrc = new URL(props.url); 594 619 iframeSrc.searchParams.set("parts.page.embed.ctx.mode", "view"); 595 620 iframeSrc.searchParams.set(
+1 -4
app/(app)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx
··· 3 3 import { 4 4 Interactions, 5 5 getQuoteCount, 6 - getCommentCount, 7 6 } from "../Interactions/Interactions"; 8 7 import { PostPageData } from "../getPostPageData"; 9 8 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; ··· 97 96 quotesCount={ 98 97 getQuoteCount(document?.quotesAndMentions || []) || 0 99 98 } 100 - commentsCount={ 101 - getCommentCount(document?.comments_on_documents || []) || 0 102 - } 99 + commentsCount={document?.commentsCount || 0} 103 100 recommendsCount={document?.recommendsCount || 0} 104 101 /> 105 102 )}
+6 -8
app/(app)/lish/[did]/[publication]/[rkey]/PostPages.tsx
··· 112 112 standardSitePostData, 113 113 document_uri, 114 114 pollData, 115 + commentsSlot, 115 116 }: { 116 117 document_uri: string; 117 118 document: PostPageData; ··· 128 129 showPrevNext?: boolean; 129 130 }; 130 131 pollData: PollData[]; 132 + commentsSlot: React.ReactNode; 131 133 }) { 132 134 let drawer = useDrawerOpen(document_uri); 133 135 useInitializeOpenPages(); ··· 188 190 <InteractionDrawer 189 191 showPageBackground={pubRecord?.theme?.showPageBackground} 190 192 document_uri={document.uri} 191 - comments={ 192 - preferences.showComments === false 193 - ? [] 194 - : document.comments_on_documents 193 + commentsSlot={ 194 + preferences.showComments === false ? null : commentsSlot 195 195 } 196 196 quotesAndMentions={ 197 197 preferences.showMentions === false ? [] : quotesAndMentions ··· 303 303 showPageBackground={pubRecord?.theme?.showPageBackground} 304 304 pageId={page.id} 305 305 document_uri={document.uri} 306 - comments={ 307 - preferences.showComments === false 308 - ? [] 309 - : document.comments_on_documents 306 + commentsSlot={ 307 + preferences.showComments === false ? null : commentsSlot 310 308 } 311 309 quotesAndMentions={ 312 310 preferences.showMentions === false ? [] : quotesAndMentions
+16 -43
app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx
··· 10 10 normalizePublicationRecord, 11 11 type NormalizedPublication, 12 12 } from "src/utils/normalizeRecords"; 13 + import { resolvePublicationTheme } from "lexicons/src/normalize"; 13 14 14 15 import { 15 16 PublicationBackgroundProvider, ··· 22 23 import { 23 24 type PublicationPostsListPost, 24 25 } from "../PublicationPostsList"; 25 - import { PublicationNav } from "../PublicationNav"; 26 - import { PublicationHeader } from "../PublicationHeader"; 27 26 import { PublicationHomeLayout } from "../PublicationHomeLayout"; 28 - import { PublicationStickyHeader } from "../PublicationStickyHeader"; 29 - import { PublicationFullHeader } from "../PublicationFullHeader"; 30 27 import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; 31 28 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 32 29 ··· 40 37 name: string; 41 38 identity_did: string; 42 39 record: unknown; 40 + publication_newsletter_settings?: { enabled: boolean } | null; 43 41 publication_pages?: { 44 42 id: number; 45 43 path: string | null; ··· 130 128 } 131 129 : undefined; 132 130 133 - const theme = normalizedPublication?.theme; 131 + const theme = resolvePublicationTheme(normalizedPublication); 134 132 const showPageBackground = !!theme?.showPageBackground; 135 133 136 134 const documentContextValue: DocumentContextValue = { ··· 148 146 DocumentContextValue["publication"] 149 147 >["record"], 150 148 publication_subscriptions: [], 151 - newsletterMode: false, 149 + newsletterMode: !!publication.publication_newsletter_settings?.enabled, 152 150 pages: (publication.publication_pages ?? []).filter((p) => p.record_uri), 153 151 }, 154 - comments: [], 152 + commentsCount: 0, 155 153 mentions: [], 156 154 leafletId: null, 157 155 recommendsCount: 0, ··· 169 167 bodyFontId={theme?.bodyFont} 170 168 /> 171 169 <PublicationThemeProvider 172 - theme={theme} 170 + record={normalizedPublication} 173 171 pub_creator={publication.identity_did} 174 172 > 175 173 <PublicationBackgroundProvider 176 - theme={theme} 174 + record={normalizedPublication} 177 175 pub_creator={publication.identity_did} 178 176 > 179 177 <PublicationHomeLayout 180 178 uri={publication.uri} 181 179 showPageBackground={showPageBackground} 182 - stickyHeader={ 183 - navPages.length > 0 ? ( 184 - <PublicationStickyHeader 185 - nav={ 186 - <PublicationNav 187 - publicationUrl={getPublicationURL(publication)} 188 - pages={navPages} 189 - activePath={page.path} 190 - /> 191 - } 192 - > 193 - <PublicationHeader 194 - variant="inline" 195 - iconUrl={ 196 - normalizedPublication?.icon 197 - ? blobRefToSrc(normalizedPublication.icon.ref, did) 198 - : undefined 199 - } 200 - publicationName={publication.name} 201 - /> 202 - </PublicationStickyHeader> 203 - ) : ( 204 - <PublicationFullHeader> 205 - <PublicationHeader 206 - iconUrl={ 207 - normalizedPublication?.icon 208 - ? blobRefToSrc(normalizedPublication.icon.ref, did) 209 - : undefined 210 - } 211 - publicationName={publication.name} 212 - description={normalizedPublication?.description} 213 - /> 214 - </PublicationFullHeader> 215 - ) 180 + iconUrl={ 181 + normalizedPublication?.icon 182 + ? blobRefToSrc(normalizedPublication.icon.ref, did) 183 + : undefined 216 184 } 185 + publicationName={publication.name} 186 + description={normalizedPublication?.description} 187 + navPages={navPages} 188 + publicationUrl={getPublicationURL(publication)} 189 + activePath={page.path} 217 190 > 218 191 <div className="pubPageContent pt-6"> 219 192 <PostContent
+6 -3
app/(app)/lish/[did]/[publication]/[rkey]/getPostPageData.ts
··· 4 4 normalizeDocumentRecord, 5 5 normalizePublicationRecord, 6 6 } from "src/utils/normalizeRecords"; 7 + import { resolvePublicationTheme } from "lexicons/src/normalize"; 7 8 import { PubLeafletPublication, SiteStandardPublication } from "lexicons/api"; 8 9 import { documentUriFilter } from "src/utils/uriHelpers"; 9 10 import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; ··· 15 16 ` 16 17 data, 17 18 uri, 18 - comments_on_documents(*, bsky_profiles(*)), 19 + comments_on_documents(count), 19 20 documents_in_publications(publications(*, 20 21 documents_in_publications(documents(uri, data)), 21 22 publication_subscriptions(*), ··· 70 71 ...uniqueBacklinks, 71 72 ]; 72 73 73 - let theme = normalizedPublication?.theme || normalizedDocument?.theme; 74 + let theme = 75 + resolvePublicationTheme(normalizedPublication) || normalizedDocument?.theme; 74 76 75 77 // Calculate prev/next documents from the fetched publication documents 76 78 let prevNext: ··· 148 150 } 149 151 : null; 150 152 const recommendsCount = document.recommends_on_documents?.[0]?.count ?? 0; 153 + const commentsCount = document.comments_on_documents?.[0]?.count ?? 0; 151 154 152 155 return { 153 156 ...document, ··· 159 162 prevNext, 160 163 // Explicit relational data for DocumentContext 161 164 publication, 162 - comments: document.comments_on_documents, 165 + commentsCount, 163 166 mentions: document.document_mentions_in_bsky, 164 167 leafletId: document.leaflets_in_publications[0]?.leaflet || null, 165 168 // Recommends data
+1 -1
app/(app)/lish/[did]/[publication]/[rkey]/getVoterIdentities.ts
··· 1 1 "use server"; 2 2 3 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 3 + import { idResolver } from "src/identity"; 4 4 5 5 export type VoterIdentity = { 6 6 did: string;
+1
app/(app)/lish/[did]/[publication]/dashboard/EditPagesNavLink.tsx
··· 32 32 path: "/", 33 33 title: "home", 34 34 includePostsList: true, 35 + includeSignup: true, 35 36 }); 36 37 setLoading(false); 37 38 if (!created) return;
+2 -5
app/(app)/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx
··· 2 2 3 3 import type { GetPublicationDataReturnType } from "app/api/rpc/[command]/get_publication_data"; 4 4 import { callRPC } from "app/api/rpc/client"; 5 - import { createContext, useContext, useEffect, useMemo } from "react"; 6 - import useSWR, { SWRConfig, KeyedMutator, mutate } from "swr"; 5 + import { createContext, useContext, useMemo } from "react"; 6 + import useSWR, { SWRConfig, KeyedMutator } from "swr"; 7 7 import { produce, Draft as ImmerDraft } from "immer"; 8 8 import { 9 9 normalizePublicationRecord, ··· 24 24 children: React.ReactNode; 25 25 }) { 26 26 let key = `publication-data-${props.publication_did}-${props.publication_rkey}`; 27 - useEffect(() => { 28 - mutate(key, props.publication_data); 29 - }, [props.publication_data]); 30 27 return ( 31 28 <PublicationContext 32 29 value={{ name: props.publication_rkey, did: props.publication_did }}
+148 -204
app/(app)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx
··· 1 1 "use client"; 2 - import { usePublicationData } from "./PublicationSWRProvider"; 3 2 import { ButtonPrimary } from "components/Buttons"; 4 - import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; 5 3 import { useSmoker } from "components/Toast"; 6 - import { Menu, MenuItem } from "components/Menu"; 7 4 import { Separator } from "components/Layout"; 8 - import { MoreOptionsVerticalTiny } from "components/Icons/MoreOptionsVerticalTiny"; 9 5 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 10 - import { useDashboardState } from "components/PageLayouts/dashboardState"; 6 + import { 7 + useDashboardState, 8 + useSetDashboardState, 9 + } from "components/PageLayouts/dashboardState"; 11 10 import { AtmosphereAccount } from "components/Icons/AtmosphereAccount"; 12 11 import { EmailTiny } from "components/Icons/EmailTiny"; 13 - 14 - type subscriber = { email: string | undefined; did: string | undefined }; 12 + import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 13 + import { Popover } from "components/Popover"; 14 + import { Checkbox } from "components/Checkbox"; 15 15 16 - type SubscriberStatus = "subscribed" | "unconfirmed" | "unsubscribed"; 16 + export type SubscriberStatus = "subscribed" | "unconfirmed" | "unsubscribed"; 17 17 18 - type MergedSubscriber = { 18 + export type MergedSubscriber = { 19 19 key: string; 20 20 did: string | undefined; 21 21 handle: string | undefined; ··· 24 24 status: SubscriberStatus; 25 25 }; 26 26 27 - export function useMergedSubscribers(): MergedSubscriber[] | null { 28 - let { data: publication } = usePublicationData(); 29 - let { subscriberStatus } = useDashboardState(); 30 - 31 - if (!publication) return null; 32 - // ATProto subscribers have no email lifecycle state — they're just present 33 - // or absent, so they only count under the "subscribed" status filter. 34 - let atprotoSubs = subscriberStatus.subscribed 35 - ? publication.publication?.publication_subscriptions || [] 36 - : []; 37 - let newsletterEnabled = 38 - !!publication.publication?.publication_newsletter_settings?.enabled; 39 - let emailSubs = newsletterEnabled 40 - ? (publication.publication?.publication_email_subscribers || []).filter( 41 - (s) => { 42 - if (s.state === "confirmed") return subscriberStatus.subscribed; 43 - if (s.state === "pending") return subscriberStatus.unconfirmed; 44 - if (s.state === "unsubscribed") return subscriberStatus.unsubscribed; 45 - return false; 46 - }, 47 - ) 48 - : []; 49 - 50 - let byDid = new Map<string, MergedSubscriber>(); 51 - let emailOnly: MergedSubscriber[] = []; 52 - for (let s of atprotoSubs) { 53 - let did = s.identities?.bsky_profiles?.did; 54 - if (!did) continue; 55 - byDid.set(did, { 56 - key: `did:${did}`, 57 - did, 58 - handle: s.identities?.bsky_profiles?.handle ?? undefined, 59 - email: undefined, 60 - created_at: s.created_at, 61 - status: "subscribed", 62 - }); 63 - } 64 - for (let s of emailSubs) { 65 - let status: SubscriberStatus = 66 - s.state === "pending" 67 - ? "unconfirmed" 68 - : s.state === "unsubscribed" 69 - ? "unsubscribed" 70 - : "subscribed"; 71 - let linkedDid = s.identities?.atp_did ?? undefined; 72 - let existing = linkedDid ? byDid.get(linkedDid) : undefined; 73 - if (existing && status === "subscribed") { 74 - existing.email = s.email; 75 - continue; 76 - } 77 - emailOnly.push({ 78 - key: `email:${s.id}`, 79 - did: linkedDid, 80 - handle: s.identities?.bsky_profiles?.handle ?? undefined, 81 - email: s.email, 82 - created_at: s.created_at, 83 - status, 84 - }); 85 - } 86 - return [...byDid.values(), ...emailOnly]; 87 - } 88 - 89 27 export function PublicationSubscribers(props: { 90 - showPageBackground?: boolean; 28 + subscribers: MergedSubscriber[]; 29 + publicationShareUrl: string; 30 + publicationUri: string; 31 + showPageBackground: boolean; 91 32 }) { 92 33 let smoker = useSmoker(); 93 - let { data: publication } = usePublicationData(); 94 34 let { subscriberStatus } = useDashboardState(); 95 - let subscribers = useMergedSubscribers(); 96 - 97 - if (!publication || !subscribers) return <div>null</div>; 98 - 99 - // useEffect(() => { 100 - // const allSubscribersSelected = subscribers.every((subscriber) => 101 - // checkedSubscribers.some( 102 - // (checked) => 103 - // checked.email === "dummyemail@email.com" && 104 - // checked.did === subscriber.identities?.bsky_profiles?.did, 105 - // ), 106 - // ); 107 - 108 - // if (allSubscribersSelected && subscribers.length > 0) { 109 - // setCheckAll(true); 110 - // } else { 111 - // setCheckAll(false); 112 - // } 113 - // }, [checkedSubscribers]); 35 + let filtered = props.subscribers.filter((s) => subscriberStatus[s.status]); 114 36 115 37 let activeStatuses = ( 116 38 Object.keys(subscriberStatus) as SubscriberStatus[] ··· 118 40 let isDefaultStatusFilter = 119 41 activeStatuses.length === 1 && activeStatuses[0] === "subscribed"; 120 42 121 - if (subscribers.length === 0) { 122 - if (!isDefaultStatusFilter) { 123 - let label = 124 - activeStatuses.length === 0 125 - ? "any status" 126 - : activeStatuses 127 - .map((s) => (s === "unconfirmed" ? "unconfirmed" : s)) 128 - .join(", "); 129 - return ( 130 - <div 131 - className={`italic text-tertiary 132 - flex flex-col gap-0 text-center justify-center py-4 border rounded-md ${props.showPageBackground ? "border-border-light p-2" : "border-transparent"}`} 133 - style={ 134 - props.showPageBackground 135 - ? { 136 - backgroundColor: 137 - "rgba(var(--bg-page), var(--bg-page-alpha)) ", 138 - } 139 - : { backgroundColor: "transparent" } 140 - } 141 - > 142 - <p className="font-bold">No subscribers match your filters!</p> 143 - </div> 144 - ); 145 - } 146 - return ( 147 - <div 148 - className={`italic text-tertiary flex flex-col gap-0 text-center justify-center py-4 border rounded-md ${props.showPageBackground ? "border-border-light p-2" : "border-transparent"}`} 149 - style={ 150 - props.showPageBackground 151 - ? { 152 - backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha)) ", 153 - } 154 - : { backgroundColor: "transparent" } 155 - } 156 - > 157 - <p className="font-bold"> No subscribers yet </p> 158 - <p>Start sharing your publication!</p> 159 - <ButtonPrimary 160 - className="mx-auto mt-2" 161 - onClick={(e) => { 162 - e.preventDefault(); 163 - let rect = (e.currentTarget as Element)?.getBoundingClientRect(); 164 - navigator.clipboard.writeText( 165 - getPublicationURL(publication.publication!), 166 - ); 167 - smoker({ 168 - position: { 169 - x: rect ? rect.left + (rect.right - rect.left) / 2 : 0, 170 - y: rect ? rect.top + 26 : 0, 171 - }, 172 - text: "Copied Publication URL!", 173 - }); 174 - }} 175 - > 176 - Copy Share Link 177 - </ButtonPrimary> 178 - </div> 179 - ); 180 - } 43 + let bgStyle = props.showPageBackground 44 + ? { backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha)) " } 45 + : { backgroundColor: "transparent" }; 46 + let bgBorder = props.showPageBackground 47 + ? "border-border-light p-2" 48 + : "border-transparent"; 181 49 182 50 return ( 183 - <div 184 - className={`rounded-md ${props.showPageBackground ? "border-border-light p-2" : "border-transparent"}`} 185 - style={ 186 - props.showPageBackground 187 - ? { 188 - backgroundColor: "rgba(var(--bg-page), var(--bg-page-alpha)) ", 189 - } 190 - : { backgroundColor: "transparent" } 51 + <DashboardPageLayout 52 + scrollKey={`dashboard-${props.publicationUri}-Subs`} 53 + pageTitle="Subscribers" 54 + mobileActions={<SubscriberStatusFilter />} 55 + publication={props.publicationUri} 56 + showHeader={true} 57 + controls={ 58 + <div className="flex items-center justify-between gap-3 text-sm"> 59 + <div className="font-bold text-secondary px-1"> 60 + {filtered.length} Subscriber{filtered.length !== 1 && "s"} 61 + </div> 62 + <SubscriberStatusFilter /> 63 + </div> 191 64 } 192 65 > 193 - <div className="subscriberListContent flex gap-2 flex-col "> 194 - {subscribers 195 - .sort((a, b) => b.created_at.localeCompare(a.created_at)) 196 - .map((subscriber) => ( 66 + {filtered.length === 0 ? ( 67 + <div 68 + className={`italic text-tertiary flex flex-col gap-0 text-center justify-center py-4 border rounded-md ${bgBorder}`} 69 + style={bgStyle} 70 + > 71 + {isDefaultStatusFilter ? ( 197 72 <> 198 - <SubscriberListItem 199 - key={subscriber.key} 200 - handle={subscriber.handle} 201 - did={subscriber.did} 202 - email={subscriber.email} 203 - createdAt={subscriber.created_at} 204 - status={subscriber.status} 205 - /> 206 - <hr className="border-border-light last:hidden" /> 73 + <p className="font-bold"> No subscribers yet </p> 74 + <p>Start sharing your publication!</p> 75 + <ButtonPrimary 76 + className="mx-auto mt-2" 77 + onClick={(e) => { 78 + e.preventDefault(); 79 + let rect = ( 80 + e.currentTarget as Element 81 + )?.getBoundingClientRect(); 82 + navigator.clipboard.writeText(props.publicationShareUrl); 83 + smoker({ 84 + position: { 85 + x: rect ? rect.left + (rect.right - rect.left) / 2 : 0, 86 + y: rect ? rect.top + 26 : 0, 87 + }, 88 + text: "Copied Publication URL!", 89 + }); 90 + }} 91 + > 92 + Copy Share Link 93 + </ButtonPrimary> 207 94 </> 208 - ))} 209 - </div> 210 - </div> 95 + ) : ( 96 + <p className="font-bold">No subscribers match your filters!</p> 97 + )} 98 + </div> 99 + ) : ( 100 + <div className={`rounded-md ${bgBorder}`} style={bgStyle}> 101 + <div className="subscriberListContent flex gap-2 flex-col "> 102 + {filtered 103 + .sort((a, b) => b.created_at.localeCompare(a.created_at)) 104 + .map((subscriber) => ( 105 + <div key={subscriber.key}> 106 + <SubscriberListItem 107 + handle={subscriber.handle} 108 + did={subscriber.did} 109 + email={subscriber.email} 110 + createdAt={subscriber.created_at} 111 + status={subscriber.status} 112 + /> 113 + <hr className="border-border-light mt-2 last:hidden" /> 114 + </div> 115 + ))} 116 + </div> 117 + </div> 118 + )} 119 + </DashboardPageLayout> 211 120 ); 212 121 } 213 122 ··· 227 136 return ( 228 137 <div className="flex flex-row justify-between gap-2 w-full"> 229 138 <div className="flex flex-col gap-0.5 grow min-w-0 w-full"> 230 - {props.handle && ( 139 + {(props.handle || props.did) && ( 231 140 <a 232 141 target="_blank" 233 142 href={`https://bsky.app/profile/${props.did}`} 234 143 className={`${contactClassName} ${props.status === "subscribed" ? subscribedClassName : props.status === "unconfirmed" ? unconfirmedClassName : mutedClassName}`} 235 144 > 236 145 <AtmosphereAccount className="text-tertiary shrink-0" /> 237 - {props.handle} 146 + <div className="truncate min-w-0">{props.handle ?? props.did}</div> 238 147 </a> 239 148 )} 240 - {props.handle && props.email && ( 149 + {(props.handle || props.did) && props.email && ( 241 150 <Separator classname="sm:block hidden" /> 242 151 )} 243 152 {props.email && ( ··· 263 172 ); 264 173 }; 265 174 266 - const SubscriberOptions = (props: { 267 - checkedSubscribers: subscriber[]; 268 - allSelected: boolean; 269 - }) => { 270 - return ( 271 - <Menu 272 - asChild 273 - className="" 274 - trigger={ 275 - <ButtonPrimary compact className="-mt-[1px]"> 276 - {props.allSelected ? "All" : props.checkedSubscribers.length} Selected{" "} 277 - <MoreOptionsVerticalTiny /> 278 - </ButtonPrimary> 279 - } 280 - > 281 - <MenuItem className="justify-center" onSelect={() => {}}> 282 - Export {props.allSelected ? "All" : "Selected"} 283 - </MenuItem> 284 - <MenuItem className="justify-center" onSelect={() => {}}> 285 - Remove {props.allSelected ? "All" : "Selected"} 286 - </MenuItem> 287 - </Menu> 288 - ); 289 - }; 290 - 291 175 function SubscriberDate(props: { createdAt: string }) { 292 176 const formattedDate = useLocalizedDate(props.createdAt, { 293 177 year: "2-digit", ··· 300 184 </div> 301 185 ); 302 186 } 187 + 188 + const SubscriberStatusFilter = () => { 189 + let { subscriberStatus } = useDashboardState(); 190 + let setState = useSetDashboardState(); 191 + let count = Object.values(subscriberStatus).filter(Boolean).length; 192 + 193 + return ( 194 + <Popover 195 + className="text-sm px-2! py-1!" 196 + trigger={ 197 + <div className="text-sm text-tertiary"> 198 + Filters {count > 0 && `(${count})`} 199 + </div> 200 + } 201 + > 202 + <Checkbox 203 + small 204 + checked={subscriberStatus.subscribed} 205 + onChange={(e) => 206 + setState({ 207 + subscriberStatus: { 208 + ...subscriberStatus, 209 + subscribed: !!e.target.checked, 210 + }, 211 + }) 212 + } 213 + > 214 + Subscribed 215 + </Checkbox> 216 + <Checkbox 217 + small 218 + checked={subscriberStatus.unconfirmed} 219 + onChange={(e) => 220 + setState({ 221 + subscriberStatus: { 222 + ...subscriberStatus, 223 + unconfirmed: !!e.target.checked, 224 + }, 225 + }) 226 + } 227 + > 228 + Unconfirmed 229 + </Checkbox> 230 + <Checkbox 231 + small 232 + checked={subscriberStatus.unsubscribed} 233 + onChange={(e) => 234 + setState({ 235 + subscriberStatus: { 236 + ...subscriberStatus, 237 + unsubscribed: !!e.target.checked, 238 + }, 239 + }) 240 + } 241 + > 242 + Unsubscribed 243 + </Checkbox> 244 + </Popover> 245 + ); 246 + };
+5 -3
app/(app)/lish/[did]/[publication]/dashboard/analytics/TrafficChart.tsx
··· 325 325 return ( 326 326 <Fragment key={ref.referrer_host}> 327 327 <button 328 - className={`w-full flex justify-between gap-4 px-1 py-1.5 items-center text-right text-sm rounded-md ${selected ? "text-accent-contrast bg-[var(--accent-light)]" : ""}`} 328 + className={`w-full flex justify-between gap-4 px-1 py-1.5 items-center text-sm rounded-md ${selected ? "text-accent-contrast bg-[var(--accent-light)]" : ""}`} 329 329 onClick={() => { 330 330 if (selected) { 331 331 props.setSelectedReferrer(undefined); ··· 334 334 } 335 335 }} 336 336 > 337 - <div className="flex gap-2 items-center grow"> 337 + <div className="min-w-0 truncate text-left"> 338 338 {ref.referrer_host} 339 339 </div> 340 - {ref.pageviews.toLocaleString()} 340 + <div className="shrink-0 tabular-nums"> 341 + {ref.pageviews.toLocaleString()} 342 + </div> 341 343 </button> 342 344 <hr className="border-border-light last:hidden" /> 343 345 </Fragment>
+45
app/(app)/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx
··· 260 260 </p> 261 261 )} 262 262 </div> 263 + 264 + <hr className="border-border-light" /> 265 + 266 + <EmbedFormSnippet publicationUri={publicationUri} /> 263 267 </div> 264 268 265 269 <Modal ··· 297 301 /> 298 302 </Modal> 299 303 </DashboardContainer> 304 + ); 305 + }; 306 + 307 + const EmbedFormSnippet = (props: { publicationUri: string }) => { 308 + let toaster = useToaster(); 309 + let appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://leaflet.pub"; 310 + let actionUrl = `${appUrl.replace(/\/$/, "")}/api/subscribe_email`; 311 + let snippet = `<form action="${actionUrl}" method="post"> 312 + <input type="hidden" name="publication" value="${props.publicationUri}" /> 313 + <input type="email" name="email" placeholder="you@example.com" required /> 314 + <button type="submit">Subscribe</button> 315 + </form>`; 316 + 317 + return ( 318 + <div className="flex flex-col gap-1"> 319 + <p className="text-secondary font-bold">Embed Subscribe Form</p> 320 + <p className="text-tertiary text-sm leading-snug"> 321 + Paste this HTML into any webpage to let readers subscribe to your 322 + publication. After submitting, they're redirected here to confirm with 323 + a code we email them. 324 + </p> 325 + <div className="relative max-w-prose"> 326 + <pre className="input-with-border bg-border-light text-primary text-sm rounded-md p-2 pr-16 overflow-x-auto whitespace-pre"> 327 + <code>{snippet}</code> 328 + </pre> 329 + <ButtonSecondary 330 + compact 331 + className="absolute top-1.5 right-1.5 text-xs!" 332 + onClick={async () => { 333 + try { 334 + await navigator.clipboard.writeText(snippet); 335 + toaster({ type: "success", content: "Copied!" }); 336 + } catch { 337 + toaster({ type: "error", content: "Couldn't copy to clipboard." }); 338 + } 339 + }} 340 + > 341 + Copy 342 + </ButtonSecondary> 343 + </div> 344 + </div> 300 345 ); 301 346 }; 302 347
+90 -96
app/(app)/lish/[did]/[publication]/dashboard/subs/page.tsx
··· 1 - "use client"; 2 - 3 - import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { getProfiles } from "src/identity"; 3 + import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; 4 + import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; 5 + import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 4 6 import { 5 7 PublicationSubscribers, 6 - useMergedSubscribers, 8 + type MergedSubscriber, 9 + type SubscriberStatus, 7 10 } from "../PublicationSubscribers"; 8 - import { 9 - usePublicationData, 10 - useNormalizedPublicationRecord, 11 - } from "../PublicationSWRProvider"; 12 - import { Popover } from "components/Popover"; 13 - import { Checkbox } from "components/Checkbox"; 14 - import { 15 - useDashboardState, 16 - useSetDashboardState, 17 - } from "components/PageLayouts/dashboardState"; 11 + 12 + export default async function SubsPage(props: { 13 + params: Promise<{ did: string; publication: string }>; 14 + }) { 15 + const params = await props.params; 16 + const did = decodeURIComponent(params.did); 17 + const publication = decodeURIComponent(params.publication); 18 + 19 + const { result } = await get_publication_data.handler( 20 + { did, publication_name: publication }, 21 + { supabase: supabaseServerClient }, 22 + ); 23 + const pub = result.publication; 24 + if (!pub) { 25 + return ( 26 + <PublicationSubscribers 27 + subscribers={[]} 28 + publicationShareUrl="" 29 + publicationUri="" 30 + showPageBackground={false} 31 + /> 32 + ); 33 + } 18 34 19 - export default function SubsPage() { 20 - let { data } = usePublicationData(); 21 - let record = useNormalizedPublicationRecord(); 22 - let pubUri = data?.publication?.uri || ""; 35 + const record = normalizePublicationRecord(pub.record); 23 36 const showPageBackground = !!record?.theme?.showPageBackground; 24 - let subscribers = useMergedSubscribers(); 25 - let count = subscribers?.length ?? 0; 37 + const atprotoSubs = pub.publication_subscriptions || []; 38 + const newsletterEnabled = !!pub.publication_newsletter_settings?.enabled; 39 + const emailSubs = newsletterEnabled 40 + ? pub.publication_email_subscribers || [] 41 + : []; 26 42 27 - return ( 28 - <DashboardPageLayout 29 - scrollKey={`dashboard-${pubUri}-Subs`} 30 - pageTitle="Subscribers" 31 - mobileActions={<SubscriberStatusFilter />} 32 - publication={pubUri} 33 - showHeader={true} 34 - controls={ 35 - <div className="flex items-center justify-between gap-3 text-sm"> 36 - <div className="font-bold text-secondary px-1"> 37 - {count} Subscriber{count !== 1 && "s"} 38 - </div> 39 - <SubscriberStatusFilter /> 40 - </div> 41 - } 42 - > 43 - <PublicationSubscribers showPageBackground={showPageBackground} /> 44 - </DashboardPageLayout> 45 - ); 46 - } 43 + const dids = new Set<string>(); 44 + for (const s of atprotoSubs) { 45 + if (s.identities?.atp_did) dids.add(s.identities.atp_did); 46 + } 47 + for (const s of emailSubs) { 48 + if (s.identities?.atp_did) dids.add(s.identities.atp_did); 49 + } 50 + 51 + const profiles = await getProfiles(Array.from(dids)); 47 52 48 - const SubscriberStatusFilter = () => { 49 - let { subscriberStatus } = useDashboardState(); 50 - let setState = useSetDashboardState(); 51 - let count = Object.values(subscriberStatus).filter(Boolean).length; 53 + const byDid = new Map<string, MergedSubscriber>(); 54 + const emailOnly: MergedSubscriber[] = []; 55 + 56 + for (const s of atprotoSubs) { 57 + const d = s.identities?.atp_did ?? undefined; 58 + if (!d) continue; 59 + byDid.set(d, { 60 + key: `did:${d}`, 61 + did: d, 62 + handle: profiles.get(d)?.handle ?? undefined, 63 + email: undefined, 64 + created_at: s.created_at, 65 + status: "subscribed", 66 + }); 67 + } 68 + 69 + for (const s of emailSubs) { 70 + const status: SubscriberStatus = 71 + s.state === "pending" 72 + ? "unconfirmed" 73 + : s.state === "unsubscribed" 74 + ? "unsubscribed" 75 + : "subscribed"; 76 + const linkedDid = s.identities?.atp_did ?? undefined; 77 + const existing = linkedDid ? byDid.get(linkedDid) : undefined; 78 + if (existing && status === "subscribed") { 79 + existing.email = s.email; 80 + continue; 81 + } 82 + emailOnly.push({ 83 + key: `email:${s.id}`, 84 + did: linkedDid, 85 + handle: linkedDid ? (profiles.get(linkedDid)?.handle ?? undefined) : undefined, 86 + email: s.email, 87 + created_at: s.created_at, 88 + status, 89 + }); 90 + } 52 91 53 92 return ( 54 - <Popover 55 - className="text-sm px-2! py-1!" 56 - trigger={ 57 - <div className="text-sm text-tertiary"> 58 - Filters {count > 0 && `(${count})`} 59 - </div> 60 - } 61 - > 62 - <Checkbox 63 - small 64 - checked={subscriberStatus.subscribed} 65 - onChange={(e) => 66 - setState({ 67 - subscriberStatus: { 68 - ...subscriberStatus, 69 - subscribed: !!e.target.checked, 70 - }, 71 - }) 72 - } 73 - > 74 - Subscribed 75 - </Checkbox> 76 - <Checkbox 77 - small 78 - checked={subscriberStatus.unconfirmed} 79 - onChange={(e) => 80 - setState({ 81 - subscriberStatus: { 82 - ...subscriberStatus, 83 - unconfirmed: !!e.target.checked, 84 - }, 85 - }) 86 - } 87 - > 88 - Unconfirmed 89 - </Checkbox> 90 - <Checkbox 91 - small 92 - checked={subscriberStatus.unsubscribed} 93 - onChange={(e) => 94 - setState({ 95 - subscriberStatus: { 96 - ...subscriberStatus, 97 - unsubscribed: !!e.target.checked, 98 - }, 99 - }) 100 - } 101 - > 102 - Unsubscribed 103 - </Checkbox> 104 - </Popover> 93 + <PublicationSubscribers 94 + subscribers={[...byDid.values(), ...emailOnly]} 95 + publicationShareUrl={getPublicationURL(pub)} 96 + publicationUri={pub.uri} 97 + showPageBackground={showPageBackground} 98 + /> 105 99 ); 106 - }; 100 + }
+100
app/(app)/lish/[did]/[publication]/edit/[[...route]]/LeafletDirtyReporter.tsx
··· 1 + "use client"; 2 + import { useEffect, useMemo, useRef } from "react"; 3 + import { Fact, useReplicache } from "src/replicache"; 4 + import type { Attribute } from "src/replicache/attributes"; 5 + import { useSubscribe } from "src/replicache/useSubscribe"; 6 + import { leafletToPublicationPageRecord } from "src/utils/leafletToPublicationPageRecord"; 7 + import { 8 + dirtyCheckHooks, 9 + deepEqual, 10 + normalizePageRecordForDiff, 11 + } from "src/utils/publicationPageDiff"; 12 + import { usePublicationData } from "../../dashboard/PublicationSWRProvider"; 13 + import { useSetPublicationEditDirtyState } from "./dirtyContext"; 14 + 15 + const DEBOUNCE_MS = 500; 16 + 17 + export function LeafletDirtyReporter(props: { 18 + leaflet_id: string; 19 + publication_uri: string; 20 + path: string; 21 + title: string; 22 + }) { 23 + let { rep, initialFacts } = useReplicache(); 24 + let setDirty = useSetPublicationEditDirtyState(); 25 + let { data } = usePublicationData(); 26 + let publishedRecord = 27 + data?.publication?.publication_pages?.find((p) => p.path === props.path) 28 + ?.record ?? null; 29 + 30 + let facts = useSubscribe( 31 + rep, 32 + async (tx) => { 33 + let initialized = await tx.get("initialized"); 34 + if (!initialized) return null; 35 + return await tx 36 + .scan<Fact<Attribute>>({ indexName: "eav" }) 37 + .toArray(); 38 + }, 39 + { default: null as Fact<Attribute>[] | null, dependencies: [] }, 40 + ); 41 + 42 + let effectiveFacts = facts ?? initialFacts; 43 + 44 + let normalizedPublished = useMemo( 45 + () => normalizePageRecordForDiff(publishedRecord as any), 46 + [publishedRecord], 47 + ); 48 + 49 + let runIdRef = useRef(0); 50 + useEffect(() => { 51 + // No published record yet → there's something to publish. 52 + if (!publishedRecord) { 53 + setDirty("dirty"); 54 + return; 55 + } 56 + 57 + let cancelled = false; 58 + let runId = ++runIdRef.current; 59 + let timer = window.setTimeout(async () => { 60 + try { 61 + let record = await leafletToPublicationPageRecord({ 62 + facts: effectiveFacts, 63 + root_entity: props.leaflet_id, 64 + publication_uri: props.publication_uri, 65 + path: props.path, 66 + title: props.title, 67 + hooks: dirtyCheckHooks, 68 + }); 69 + if (cancelled || runId !== runIdRef.current) return; 70 + let normalizedCurrent = normalizePageRecordForDiff( 71 + record as unknown as Record<string, unknown>, 72 + ); 73 + setDirty(deepEqual(normalizedCurrent, normalizedPublished) ? "clean" : "dirty"); 74 + } catch { 75 + if (cancelled || runId !== runIdRef.current) return; 76 + setDirty("dirty"); 77 + } 78 + }, DEBOUNCE_MS); 79 + 80 + return () => { 81 + cancelled = true; 82 + window.clearTimeout(timer); 83 + }; 84 + }, [ 85 + effectiveFacts, 86 + props.leaflet_id, 87 + props.publication_uri, 88 + props.path, 89 + props.title, 90 + publishedRecord, 91 + normalizedPublished, 92 + setDirty, 93 + ]); 94 + 95 + useEffect(() => { 96 + return () => setDirty("unknown"); 97 + }, [setDirty]); 98 + 99 + return null; 100 + }
+15 -6
app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationEditHeader.tsx
··· 4 4 import { GoToArrowLined } from "components/Icons/GoToArrowLined"; 5 5 import { publishPublicationPages } from "actions/publishPublicationPages"; 6 6 import { useToaster } from "components/Toast"; 7 + import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 7 8 import { usePublicationData } from "../../dashboard/PublicationSWRProvider"; 9 + import { usePublicationEditDirtyState } from "./dirtyContext"; 8 10 9 11 type Status = "idle" | "publishing" | "success"; 10 12 ··· 12 14 did: string; 13 15 publicationName: string; 14 16 }) { 15 - let { data } = usePublicationData(); 17 + let { data, mutate } = usePublicationData(); 16 18 let publicationUri = data?.publication?.uri; 17 19 let [status, setStatus] = useState<Status>("idle"); 18 20 let toaster = useToaster(); 21 + let dirtyState = usePublicationEditDirtyState(); 19 22 20 23 let dashboardHref = `/lish/${props.did}/${props.publicationName}/dashboard`; 21 24 ··· 28 31 }); 29 32 if (result.success) { 30 33 setStatus("success"); 34 + mutate(); 31 35 setTimeout(() => setStatus("idle"), 2000); 32 36 } else { 33 37 setStatus("idle"); 34 38 toaster({ 35 39 type: "error", 36 - content: 37 - result.error.type === "oauth_session_expired" 38 - ? "Sign in again to publish" 39 - : result.error.message, 40 + content: isOAuthSessionError(result.error) ? ( 41 + <OAuthErrorMessage error={result.error} /> 42 + ) : ( 43 + result.error.message 44 + ), 40 45 }); 41 46 } 42 47 } catch (e) { ··· 68 73 <button 69 74 type="button" 70 75 onClick={handlePublish} 71 - disabled={status === "publishing" || !publicationUri} 76 + disabled={ 77 + status === "publishing" || 78 + !publicationUri || 79 + dirtyState === "clean" 80 + } 72 81 className="bg-accent-2 text-accent-1 font-bold px-3 py-1 rounded-md text-sm shrink-0 disabled:opacity-60" 73 82 > 74 83 {label}
+12 -2
app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationPageLeaflet.tsx
··· 12 12 import { LeafletLayout } from "components/LeafletLayout"; 13 13 import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 14 14 import { Json } from "supabase/database.types"; 15 + import { LeafletDirtyReporter } from "./LeafletDirtyReporter"; 15 16 16 17 export function PublicationPageLeaflet(props: { 17 18 token: PermissionToken; ··· 19 20 leaflet_id: string; 20 21 publicationRecord: Json | null; 21 22 publicationCreator: string; 23 + publicationUri: string; 24 + pagePath: string; 25 + pageTitle: string; 22 26 }) { 23 27 let normalizedPub = normalizePublicationRecord(props.publicationRecord); 24 28 return ( ··· 32 36 set={props.token.permission_token_rights[0].entity_set} 33 37 > 34 38 <PublicationThemeProvider 35 - theme={normalizedPub?.theme} 39 + record={normalizedPub} 36 40 pub_creator={props.publicationCreator} 37 41 > 38 42 <PublicationBackgroundProvider 39 - theme={normalizedPub?.theme} 43 + record={normalizedPub} 40 44 pub_creator={props.publicationCreator} 41 45 > 42 46 <UpdateLeafletTitle entityID={props.leaflet_id} /> 43 47 <SelectionManager /> 48 + <LeafletDirtyReporter 49 + leaflet_id={props.leaflet_id} 50 + publication_uri={props.publicationUri} 51 + path={props.pagePath} 52 + title={props.pageTitle} 53 + /> 44 54 <LeafletLayout className="!pb-6"> 45 55 <Pages rootPage={props.leaflet_id} /> 46 56 </LeafletLayout>
+193 -15
app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesNav.tsx
··· 22 22 import { CSS } from "@dnd-kit/utilities"; 23 23 import { generateKeyBetween } from "fractional-indexing"; 24 24 import { SpeedyLink } from "components/SpeedyLink"; 25 - import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; 25 + import { ButtonPrimary, ButtonSecondary, ButtonTertiary } from "components/Buttons"; 26 26 import { InputWithLabel } from "components/Input"; 27 27 import { Popover } from "components/Popover"; 28 28 import { AddTiny } from "components/Icons/AddTiny"; 29 + import { EditTiny } from "components/Icons/EditTiny"; 29 30 import { createPublicationPage } from "actions/createPublicationPage"; 31 + import { deletePublicationPage } from "actions/deletePublicationPage"; 32 + import { updatePublicationPage } from "actions/updatePublicationPage"; 30 33 import { reorderPublicationPages } from "actions/reorderPublicationPages"; 31 34 import { 32 35 usePublicationData, ··· 142 145 items={sortableIds} 143 146 strategy={horizontalListSortingStrategy} 144 147 > 145 - {sortedPages.map((page) => ( 146 - <SortableTab 147 - key={page.id} 148 - page={page} 149 - href={hrefForPath(page.path)} 150 - active={ 151 - decodeURIComponent(pathname) === 152 - decodeURIComponent(hrefForPath(page.path)) 153 - } 154 - /> 155 - ))} 148 + {sortedPages.map((page, idx) => { 149 + let isActive = 150 + decodeURIComponent(pathname) === 151 + decodeURIComponent(hrefForPath(page.path)); 152 + let neighbor = 153 + sortedPages[idx - 1] ?? sortedPages[idx + 1]; 154 + let redirectHrefOnDelete = 155 + isActive && neighbor ? hrefForPath(neighbor.path) : null; 156 + return ( 157 + <SortableTab 158 + key={page.id} 159 + page={page} 160 + href={hrefForPath(page.path)} 161 + active={isActive} 162 + publicationUri={publicationUri} 163 + canDelete={sortedPages.length > 1} 164 + onUpdated={(updated) => { 165 + mutatePublicationData(mutate, (draft) => { 166 + let p = draft.publication?.publication_pages.find( 167 + (p) => p.id === page.id, 168 + ); 169 + if (p) { 170 + p.title = updated.title; 171 + p.path = updated.path; 172 + } 173 + }); 174 + if (isActive && updated.path !== page.path) 175 + router.push(hrefForPath(updated.path)); 176 + }} 177 + onDeleted={() => { 178 + mutatePublicationData(mutate, (draft) => { 179 + let pages = draft.publication?.publication_pages; 180 + if (!pages) return; 181 + let i = pages.findIndex((p) => p.id === page.id); 182 + if (i !== -1) pages.splice(i, 1); 183 + }); 184 + if (redirectHrefOnDelete) 185 + router.push(redirectHrefOnDelete); 186 + }} 187 + /> 188 + ); 189 + })} 156 190 </SortableContext> 157 191 </DndContext> 158 192 </div> ··· 169 203 </ButtonSecondary> 170 204 } 171 205 > 172 - <form onSubmit={handleSubmit} className="flex flex-col gap-2"> 206 + <form 207 + onSubmit={handleSubmit} 208 + onKeyDown={(e) => { 209 + if (e.key === "Tab") e.stopPropagation(); 210 + }} 211 + className="flex flex-col gap-2" 212 + > 173 213 <InputWithLabel 174 214 label="Name" 175 215 type="text" 216 + name="page-title" 217 + autoComplete="off" 176 218 value={name} 177 219 onChange={(e) => setName(e.currentTarget.value)} 178 220 autoFocus ··· 180 222 <InputWithLabel 181 223 label="Path" 182 224 type="text" 225 + name="page-path" 226 + autoComplete="off" 183 227 value={path} 184 228 onChange={(e) => setPath(e.currentTarget.value)} 185 229 placeholder="/about" ··· 198 242 page: SortablePage; 199 243 href: string; 200 244 active: boolean; 245 + publicationUri: string | undefined; 246 + canDelete: boolean; 247 + onUpdated: (updated: { title: string; path: string }) => void; 248 + onDeleted: () => void; 201 249 }) { 202 250 let { 203 251 attributes, ··· 208 256 transition, 209 257 isDragging, 210 258 } = useSortable({ id: props.page.id }); 259 + let [popoverOpen, setPopoverOpen] = useState(false); 260 + let [mode, setMode] = useState<"edit" | "confirm">("edit"); 261 + let [name, setName] = useState(props.page.title); 262 + let [path, setPath] = useState(props.page.path ?? "/"); 263 + let [saving, setSaving] = useState(false); 264 + let [deleting, setDeleting] = useState(false); 211 265 212 266 let style = { 213 267 transform: CSS.Translate.toString(transform), ··· 215 269 zIndex: isDragging ? 1 : undefined, 216 270 }; 217 271 272 + function handleOpenChange(o: boolean) { 273 + setPopoverOpen(o); 274 + if (o) { 275 + setName(props.page.title); 276 + setPath(props.page.path ?? "/"); 277 + setMode("edit"); 278 + } 279 + } 280 + 281 + async function handleSave(e: React.FormEvent) { 282 + e.preventDefault(); 283 + if (!props.publicationUri || saving) return; 284 + let trimmedPath = path.trim(); 285 + if (!trimmedPath) return; 286 + if (!trimmedPath.startsWith("/")) trimmedPath = "/" + trimmedPath; 287 + let trimmedTitle = name.trim(); 288 + setSaving(true); 289 + let result = await updatePublicationPage({ 290 + publication_uri: props.publicationUri, 291 + page_id: props.page.id, 292 + title: trimmedTitle, 293 + path: trimmedPath, 294 + }); 295 + setSaving(false); 296 + if (!result.success) return; 297 + setPopoverOpen(false); 298 + props.onUpdated({ title: trimmedTitle, path: trimmedPath }); 299 + } 300 + 301 + async function handleDelete() { 302 + if (!props.publicationUri || deleting) return; 303 + setDeleting(true); 304 + let result = await deletePublicationPage({ 305 + publication_uri: props.publicationUri, 306 + page_id: props.page.id, 307 + }); 308 + setDeleting(false); 309 + if (!result.success) return; 310 + setPopoverOpen(false); 311 + props.onDeleted(); 312 + } 313 + 218 314 return ( 219 315 <div 220 316 ref={setNodeRef} ··· 231 327 ref={setActivatorNodeRef} 232 328 type="button" 233 329 aria-label="Drag to reorder" 234 - className="shrink-0 w-[9px] h-4 ml-1 cursor-grab touch-none opacity-0 group-hover/sortable-tab:opacity-100 focus:opacity-100" 330 + className={`shrink-0 w-[9px] h-[15px] ml-1 cursor-grab touch-none focus:opacity-100 ${ 331 + props.active 332 + ? "opacity-100" 333 + : "opacity-0 group-hover/sortable-tab:opacity-100" 334 + }`} 235 335 style={{ 236 336 maskImage: "var(--gripperSVG)", 237 337 maskRepeat: "repeat", ··· 242 342 /> 243 343 <SpeedyLink 244 344 href={props.href} 245 - className="block pr-2 pl-1 py-1 rounded-md text-sm text-inherit hover:no-underline! select-none" 345 + className="block pl-1 py-1 rounded-md text-sm text-inherit hover:no-underline! select-none" 246 346 > 247 347 {props.page.title || props.page.path || "/"} 248 348 </SpeedyLink> 349 + <Popover 350 + asChild 351 + align="end" 352 + open={popoverOpen} 353 + onOpenChange={handleOpenChange} 354 + className="w-64" 355 + trigger={ 356 + <button 357 + type="button" 358 + aria-label="Edit page" 359 + className="shrink-0 mx-1 p-0.5 rounded text-inherit opacity-0 group-hover/sortable-tab:opacity-100 focus:opacity-100 hover:bg-border-light" 360 + > 361 + <EditTiny className="w-3 h-3" /> 362 + </button> 363 + } 364 + > 365 + {mode === "edit" ? ( 366 + <form 367 + onSubmit={handleSave} 368 + onKeyDown={(e) => { 369 + if (e.key === "Tab") e.stopPropagation(); 370 + }} 371 + className="flex flex-col gap-2" 372 + > 373 + <InputWithLabel 374 + label="Name" 375 + type="text" 376 + name="page-title" 377 + autoComplete="off" 378 + value={name} 379 + onChange={(e) => setName(e.currentTarget.value)} 380 + autoFocus 381 + /> 382 + <InputWithLabel 383 + label="Path" 384 + type="text" 385 + name="page-path" 386 + autoComplete="off" 387 + value={path} 388 + onChange={(e) => setPath(e.currentTarget.value)} 389 + placeholder="/about" 390 + /> 391 + <div className="flex gap-2 justify-between items-center"> 392 + {props.canDelete ? ( 393 + <ButtonTertiary 394 + type="button" 395 + onClick={() => setMode("confirm")} 396 + > 397 + Delete 398 + </ButtonTertiary> 399 + ) : ( 400 + <div /> 401 + )} 402 + <ButtonPrimary type="submit" disabled={saving}> 403 + {saving ? "Saving..." : "Save"} 404 + </ButtonPrimary> 405 + </div> 406 + </form> 407 + ) : ( 408 + <div className="flex flex-col gap-2"> 409 + <div className="text-secondary text-sm pb-1"> 410 + This will permanently delete{" "} 411 + <span className="font-bold text-primary"> 412 + {props.page.title || props.page.path || "/"} 413 + </span> 414 + . 415 + </div> 416 + <div className="flex gap-2 justify-end items-center"> 417 + <ButtonTertiary type="button" onClick={() => setMode("edit")}> 418 + Cancel 419 + </ButtonTertiary> 420 + <ButtonPrimary onClick={handleDelete} disabled={deleting}> 421 + {deleting ? "Deleting..." : "Delete"} 422 + </ButtonPrimary> 423 + </div> 424 + </div> 425 + )} 426 + </Popover> 249 427 </div> 250 428 ); 251 429 }
+34
app/(app)/lish/[did]/[publication]/edit/[[...route]]/dirtyContext.tsx
··· 1 + "use client"; 2 + import { createContext, useContext, useMemo, useState } from "react"; 3 + 4 + type DirtyState = "unknown" | "clean" | "dirty"; 5 + 6 + type DirtyContextValue = { 7 + state: DirtyState; 8 + setState: (state: DirtyState) => void; 9 + }; 10 + 11 + const PublicationEditDirtyContext = createContext<DirtyContextValue>({ 12 + state: "unknown", 13 + setState: () => {}, 14 + }); 15 + 16 + export function PublicationEditDirtyProvider(props: { 17 + children: React.ReactNode; 18 + }) { 19 + let [state, setState] = useState<DirtyState>("unknown"); 20 + let value = useMemo(() => ({ state, setState }), [state]); 21 + return ( 22 + <PublicationEditDirtyContext.Provider value={value}> 23 + {props.children} 24 + </PublicationEditDirtyContext.Provider> 25 + ); 26 + } 27 + 28 + export function usePublicationEditDirtyState() { 29 + return useContext(PublicationEditDirtyContext).state; 30 + } 31 + 32 + export function useSetPublicationEditDirtyState() { 33 + return useContext(PublicationEditDirtyContext).setState; 34 + }
+23 -23
app/(app)/lish/[did]/[publication]/edit/[[...route]]/layout.tsx
··· 12 12 import { PublicationPagesNav } from "./PublicationPagesNav"; 13 13 import { PublicationEditHeader } from "./PublicationEditHeader"; 14 14 import { PublicationHeader } from "../../PublicationHeader"; 15 - import { PublicationStickyHeader } from "../../PublicationStickyHeader"; 15 + import { PublicationEditDirtyProvider } from "./dirtyContext"; 16 16 17 17 export async function generateMetadata(props: { 18 18 params: Promise<{ publication: string; did: string }>; ··· 90 90 publication_rkey={uri.rkey} 91 91 publication_data={publication_data} 92 92 > 93 - <PublicationThemeProviderDashboard> 94 - <div className="flex flex-col h-full w-full bg-accent-1"> 95 - <PublicationEditHeader 96 - did={params.did} 97 - publicationName={params.publication} 98 - /> 99 - <div className="pubWrapper flex flex-col grow min-h-0 bg-bg-page rounded-t-lg overflow-hidden"> 100 - <PublicationStickyHeader 101 - sticky={false} 102 - nav={ 93 + <PublicationEditDirtyProvider> 94 + <PublicationThemeProviderDashboard> 95 + <div className="flex flex-col h-full w-full bg-accent-1"> 96 + <PublicationEditHeader 97 + did={params.did} 98 + publicationName={params.publication} 99 + /> 100 + <div className="pubWrapper flex flex-col grow min-h-0 bg-bg-page rounded-t-lg overflow-hidden"> 101 + <div className="shrink-0"> 102 + <div className="sm:max-w-(--page-width-units) w-full mx-auto px-3 sm:px-4 pt-5"> 103 + <PublicationHeader 104 + variant="inline" 105 + iconUrl={iconUrl} 106 + publicationName={publication.name} 107 + description={record?.description} 108 + /> 109 + </div> 103 110 <PublicationPagesNav 104 111 did={params.did} 105 112 publicationName={params.publication} 106 113 /> 107 - } 108 - > 109 - <PublicationHeader 110 - variant="inline" 111 - iconUrl={iconUrl} 112 - publicationName={publication.name} 113 - description={record?.description} 114 - /> 115 - </PublicationStickyHeader> 116 - <div className="grow min-h-0 flex flex-col">{props.children}</div> 114 + </div> 115 + <div className="grow min-h-0 flex flex-col">{props.children}</div> 116 + </div> 117 117 </div> 118 - </div> 119 - </PublicationThemeProviderDashboard> 118 + </PublicationThemeProviderDashboard> 119 + </PublicationEditDirtyProvider> 120 120 </PublicationSWRDataProvider> 121 121 ); 122 122 }
+3
app/(app)/lish/[did]/[publication]/edit/[[...route]]/page.tsx
··· 71 71 token={res.data} 72 72 publicationRecord={publication.record} 73 73 publicationCreator={publication.identity_did} 74 + publicationUri={publication.uri} 75 + pagePath={path} 76 + pageTitle={page.title ?? ""} 74 77 /> 75 78 </PageSWRDataProvider> 76 79 </React.Fragment>
+10 -4
app/(app)/lish/[did]/[publication]/layout.tsx
··· 49 49 sizes: "32x32", 50 50 type: "image/png", 51 51 }, 52 - other: { 53 - rel: "alternate", 54 - url: publication.uri, 55 - }, 52 + other: [ 53 + { 54 + rel: "alternate", 55 + url: publication.uri, 56 + }, 57 + { 58 + rel: "site.standard.publication", 59 + url: publication.uri, 60 + }, 61 + ], 56 62 }, 57 63 alternates: pubRecord?.url 58 64 ? {
+2 -2
app/(app)/lish/[did]/[publication]/page.tsx
··· 62 62 if (homePageRender) return homePageRender; 63 63 return ( 64 64 <PublicationThemeProvider 65 - theme={record?.theme} 65 + record={record} 66 66 pub_creator={publication.identity_did} 67 67 > 68 68 <PublicationBackgroundProvider 69 - theme={record?.theme} 69 + record={record} 70 70 pub_creator={publication.identity_did} 71 71 > 72 72 <PublicationContent
+1 -1
app/(app)/lish/[did]/[publication]/sortPublicationPages.ts
··· 3 3 >(pages: T[]): T[] { 4 4 return [...pages].sort((a, b) => { 5 5 if (a.sort_order === b.sort_order) return a.id - b.id; 6 - return a.sort_order.localeCompare(b.sort_order); 6 + return a.sort_order > b.sort_order ? 1 : -1; 7 7 }); 8 8 }
+2 -2
app/(app)/lish/[did]/[publication]/theme-settings/PostPreview.tsx
··· 47 47 theme: null, 48 48 prevNext: undefined, 49 49 publication: publication || null, 50 - comments: [], 50 + commentsCount: 0, 51 51 comments_on_documents: [], 52 52 mentions: [], 53 53 document_mentions_in_bsky: [], ··· 110 110 prevNext: undefined, 111 111 quotesAndMentions: [], 112 112 publication: pubInfo, 113 - comments: [], 113 + commentsCount: 0, 114 114 mentions: [], 115 115 leafletId: null, 116 116 recommendsCount: 0,
+1 -1
app/(app)/lish/[did]/[publication]/theme-settings/ThemeSettingsContent.tsx
··· 100 100 {/* Full-page Preview */} 101 101 <PublicationBackgroundProvider 102 102 className="rounded-t-lg grow! min-h-0 overflow-y-auto" 103 - theme={record?.theme} 103 + record={record} 104 104 pub_creator={publication?.identity_did || ""} 105 105 localBgImage={pubBGImage} 106 106 localBgImageRepeat={leafletBGRepeat}
-43
app/(app)/lish/subscribeToPublication.ts
··· 1 1 "use server"; 2 2 3 3 import { AtpBaseClient } from "lexicons/api"; 4 - import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; 5 4 import { getIdentityData } from "actions/getIdentityData"; 6 5 import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 7 6 import { TID } from "@atproto/common"; ··· 10 9 import { AtUri } from "@atproto/syntax"; 11 10 import { redirect } from "next/navigation"; 12 11 import { encodeActionToSearchParam } from "app/api/oauth/[route]/afterSignInActions"; 13 - import { Json } from "supabase/database.types"; 14 - import { IdResolver } from "@atproto/identity"; 15 12 import { 16 13 Notification, 17 14 pingIdentityToUpdateNotification, ··· 20 17 21 18 let leafletFeedURI = 22 19 "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; 23 - let idResolver = new IdResolver(); 24 20 25 21 type SubscribeResult = 26 22 | { success: true; hasFeed: boolean } ··· 87 83 await pingIdentityToUpdateNotification(publicationOwner); 88 84 } 89 85 90 - let bsky = new BskyAgent(credentialSession); 91 - let [profile, resolveDid] = await Promise.all([ 92 - bsky.app.bsky.actor.profile 93 - .get({ 94 - repo: credentialSession.did!, 95 - rkey: "self", 96 - }) 97 - .catch(), 98 - idResolver.did.resolve(credentialSession.did!), 99 - ]); 100 - if (!identity.bsky_profiles && profile.value) { 101 - await supabaseServerClient.from("bsky_profiles").insert({ 102 - did: identity.atp_did, 103 - record: profile.value as Json, 104 - handle: resolveDid?.alsoKnownAs?.[0]?.slice(5), 105 - }); 106 - } 107 86 revalidatePath("/lish/[did]/[publication]", "layout"); 108 87 return { 109 88 success: true, ··· 159 138 }; 160 139 await supabaseServerClient.from("notifications").insert(notification); 161 140 await pingIdentityToUpdateNotification(publicationOwner); 162 - } 163 - 164 - let { data: existingProfile } = await supabaseServerClient 165 - .from("bsky_profiles") 166 - .select("did") 167 - .eq("did", atp_did) 168 - .maybeSingle(); 169 - if (!existingProfile) { 170 - let bsky = new BskyAgent(credentialSession); 171 - let [profile, resolveDid] = await Promise.all([ 172 - bsky.app.bsky.actor.profile 173 - .get({ repo: atp_did, rkey: "self" }) 174 - .catch(() => null), 175 - idResolver.did.resolve(atp_did).catch(() => null), 176 - ]); 177 - if (profile?.value) { 178 - await supabaseServerClient.from("bsky_profiles").insert({ 179 - did: atp_did, 180 - record: profile.value as Json, 181 - handle: resolveDid?.alsoKnownAs?.[0]?.slice(5), 182 - }); 183 - } 184 141 } 185 142 } catch (e) { 186 143 console.error(
+5 -7
app/(app)/merge-accounts/page.tsx
··· 11 11 PENDING_MERGE_TOKEN_COOKIE, 12 12 resolveAuthToken, 13 13 } from "src/auth"; 14 + import { getProfiles } from "src/identity"; 14 15 15 16 type SearchParams = { [key: string]: string | string[] | undefined }; 16 17 ··· 59 60 ); 60 61 } 61 62 62 - const [{ data: bsky }, { count: docCount }] = await Promise.all([ 63 - supabaseServerClient 64 - .from("bsky_profiles") 65 - .select("handle") 66 - .eq("did", target.identity.atp_did!) 67 - .maybeSingle(), 63 + const [profiles, { count: docCount }] = await Promise.all([ 64 + getProfiles([target.identity.atp_did!]), 68 65 supabaseServerClient 69 66 .from("permission_token_on_homepage") 70 67 .select("token", { count: "exact", head: true }) 71 68 .eq("identity", source.identity.id) 72 69 .not("archived", "is", true), 73 70 ]); 74 - const targetHandle = bsky?.handle ?? target.identity.atp_did!; 71 + const targetHandle = 72 + profiles.get(target.identity.atp_did!)?.handle ?? target.identity.atp_did!; 75 73 const sourceEmail = source.identity.email!; 76 74 const targetEmail = target.identity.email; 77 75 const documents = docCount ?? 0;
+1 -1
app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts
··· 1 1 import { getMicroLinkOgImage } from "src/utils/getMicroLinkOgImage"; 2 2 import { supabaseServerClient } from "supabase/serverClient"; 3 3 import { jsonToLex } from "@atproto/lexicon"; 4 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 4 + import { idResolver } from "src/identity"; 5 5 import { fetchAtprotoBlob } from "app/api/atproto_images/route"; 6 6 import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 7 7 import { documentUriFilter } from "src/utils/uriHelpers";
+1 -1
app/(app)/p/[didOrHandle]/[rkey]/page.tsx
··· 1 1 import { supabaseServerClient } from "supabase/serverClient"; 2 2 import { Metadata } from "next"; 3 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 3 + import { idResolver } from "src/identity"; 4 4 import { DocumentPageRenderer } from "app/(app)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; 5 5 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 6 6 import { normalizeDocumentRecord } from "src/utils/normalizeRecords";
+12 -3
app/about/AboutPage.tsx
··· 1 1 import "./about.css"; 2 2 import { IBM_Plex_Serif } from "next/font/google"; 3 3 import { LandingCTA, LandingCTABottom } from "./LandingCTA"; 4 - import { Demo } from "./Demo"; 4 + import { InteractiveAppDemo } from "./InteractiveAppDemo"; 5 + import { InteractiveAppDemoMobile } from "./InteractiveAppDemoMobile"; 6 + import { Media } from "components/Media"; 5 7 import { Features } from "./Features"; 6 8 import { Examples } from "./Examples"; 7 9 import { Pricing } from "./Pricing"; ··· 18 20 export function AboutPage() { 19 21 return ( 20 22 <main 21 - className={`${ibmPlexSerif.variable} aboutPage w-full bg-[#FDFCFA] flex flex-col justify-center sm:px-12 px-4 mx-auto`} 23 + className={`${ibmPlexSerif.variable} aboutPage w-full bg-[#FDFCFA] flex flex-col justify-center sm:px-12 px-4 mx-auto overflow-x-clip overflow-y-auto`} 22 24 > 23 25 <div className="spacer h-[96px] sm:h-[160px]" /> 24 26 <div className="aboutCover relative max-w-full mx-auto w-[800px] flex flex-col gap-3 sm:gap-4"> ··· 45 47 community — whether you're writing a blog, newsletter, or secret third 46 48 thing. 47 49 </p> 48 - {/*<Demo />*/} 50 + <div className="pt-12"> 51 + <Media mobile={false}> 52 + <InteractiveAppDemo /> 53 + </Media> 54 + <Media mobile={true}> 55 + <InteractiveAppDemoMobile /> 56 + </Media> 57 + </div> 49 58 <Features /> 50 59 <Examples /> 51 60 <Pricing />
+1 -1
app/about/Examples.tsx
··· 2 2 import { supabaseServerClient } from "supabase/serverClient"; 3 3 import { normalizePublicationRecord } from "src/utils/normalizeRecords"; 4 4 import { PubListing } from "app/(app)/(home-pages)/p/[didOrHandle]/PubListing"; 5 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 5 + import { idResolver } from "src/identity"; 6 6 import { SpeedyLink } from "components/SpeedyLink"; 7 7 8 8 const pubs = [
+80
app/about/InteractiveAppDemo.tsx
··· 1 + "use client"; 2 + import { useEffect, useState } from "react"; 3 + 4 + type Page = "drafts" | "posts" | "analytics" | "settings"; 5 + 6 + type Hit = { 7 + id: Page; 8 + label: string; 9 + topPct: number; 10 + leftPct: number; 11 + }; 12 + 13 + const HIT_WIDTH_PCT = 16.33; 14 + const HIT_HEIGHT_PCT = 2.8; 15 + const AUTOPLAY_INTERVAL_MS = 5000; 16 + 17 + const PAGES: Hit[] = [ 18 + { id: "drafts", label: "Drafts", topPct: 14.83, leftPct: 2.28 }, 19 + { id: "posts", label: "Posts", topPct: 18.0, leftPct: 2.28 }, 20 + { id: "analytics", label: "Analytics", topPct: 23.95, leftPct: 2.28 }, 21 + { id: "settings", label: "Settings", topPct: 27.15, leftPct: 2.28 }, 22 + ]; 23 + 24 + export function InteractiveAppDemo() { 25 + let [page, setPage] = useState<Page>("drafts"); 26 + let [autoplay, setAutoplay] = useState(true); 27 + let [animate, setAnimate] = useState(true); 28 + 29 + useEffect(() => { 30 + if (!autoplay) return; 31 + const interval = setInterval(() => { 32 + setAnimate(true); 33 + setPage((p) => { 34 + const idx = PAGES.findIndex((x) => x.id === p); 35 + return PAGES[(idx + 1) % PAGES.length].id; 36 + }); 37 + }, AUTOPLAY_INTERVAL_MS); 38 + return () => clearInterval(interval); 39 + }, [autoplay]); 40 + 41 + const handleClick = (id: Page) => { 42 + setAnimate(false); 43 + setAutoplay(false); 44 + setPage(id); 45 + }; 46 + 47 + return ( 48 + <div className="relative w-full max-w-[900px] mx-auto aspect-[2249/1798] rounded-lg shadow-md overflow-hidden"> 49 + {PAGES.map((p) => ( 50 + <img 51 + key={p.id} 52 + src={`/about/landing-${p.id}.webp`} 53 + alt={p.id === page ? `Leaflet ${p.label} page` : ""} 54 + aria-hidden={p.id !== page} 55 + className="absolute inset-0 w-full h-full" 56 + style={{ 57 + opacity: page === p.id ? 1 : 0, 58 + transition: animate ? "opacity 700ms ease-in-out" : "none", 59 + }} 60 + /> 61 + ))} 62 + {PAGES.map((p) => ( 63 + <button 64 + key={p.id} 65 + type="button" 66 + aria-label={`Show ${p.label}`} 67 + aria-current={page === p.id ? "page" : undefined} 68 + onClick={() => handleClick(p.id)} 69 + className="absolute cursor-pointer rounded-sm" 70 + style={{ 71 + top: `${p.topPct}%`, 72 + left: `${p.leftPct}%`, 73 + width: `${HIT_WIDTH_PCT}%`, 74 + height: `${HIT_HEIGHT_PCT}%`, 75 + }} 76 + /> 77 + ))} 78 + </div> 79 + ); 80 + }
+67
app/about/InteractiveAppDemoMobile.tsx
··· 1 + "use client"; 2 + import { useEffect, useState } from "react"; 3 + import { ToggleGroup } from "components/ToggleGroup"; 4 + 5 + type Page = "drafts" | "posts" | "analytics" | "settings"; 6 + 7 + const PAGES: { id: Page; label: string }[] = [ 8 + { id: "drafts", label: "Drafts" }, 9 + { id: "posts", label: "Posts" }, 10 + { id: "analytics", label: "Analytics" }, 11 + { id: "settings", label: "Settings" }, 12 + ]; 13 + 14 + const AUTOPLAY_INTERVAL_MS = 5000; 15 + 16 + export function InteractiveAppDemoMobile() { 17 + let [page, setPage] = useState<Page>("drafts"); 18 + let [autoplay, setAutoplay] = useState(true); 19 + let [animate, setAnimate] = useState(true); 20 + 21 + useEffect(() => { 22 + if (!autoplay) return; 23 + const interval = setInterval(() => { 24 + setAnimate(true); 25 + setPage((p) => { 26 + const idx = PAGES.findIndex((x) => x.id === p); 27 + return PAGES[(idx + 1) % PAGES.length].id; 28 + }); 29 + }, AUTOPLAY_INTERVAL_MS); 30 + return () => clearInterval(interval); 31 + }, [autoplay]); 32 + 33 + const handleClick = (id: Page) => { 34 + setAnimate(false); 35 + setAutoplay(false); 36 + setPage(id); 37 + }; 38 + 39 + return ( 40 + <div className="flex flex-col gap-3 w-full max-w-[320px] mx-auto"> 41 + <ToggleGroup 42 + fullWidth 43 + className="bg-[#F1EDE5]! w-fill mx-auto" 44 + selectedOptionClassName="bg-[#686153]! text-white!" 45 + optionClassName="text-[#969696]! text-sm" 46 + value={page} 47 + onChange={handleClick} 48 + options={PAGES.map((p) => ({ value: p.id, label: p.label }))} 49 + /> 50 + <div className="relative w-full aspect-[752/1334] rounded-lg shadow-md overflow-hidden"> 51 + {PAGES.map((p) => ( 52 + <img 53 + key={p.id} 54 + src={`/about/mobile-landing-${p.id}.webp`} 55 + alt={p.id === page ? `Leaflet ${p.label} page` : ""} 56 + aria-hidden={p.id !== page} 57 + className="absolute inset-0 w-full h-full" 58 + style={{ 59 + opacity: page === p.id ? 1 : 0, 60 + transition: animate ? "opacity 700ms ease-in-out" : "none", 61 + }} 62 + /> 63 + ))} 64 + </div> 65 + </div> 66 + ); 67 + }
-3
app/api/inngest/client.ts
··· 7 7 feedsIndexFollows: eventType("feeds/index-follows", { 8 8 schema: staticSchema<{ did: string }>(), 9 9 }), 10 - appviewProfileUpdate: eventType("appview/profile-update", { 11 - schema: staticSchema<{ record: any; did: string }>(), 12 - }), 13 10 appviewIndexBskyPostMention: eventType("appview/index-bsky-post-mention", { 14 11 schema: staticSchema<{ post_uri: string; document_link: string }>(), 15 12 }),
-37
app/api/inngest/functions/batched_update_profiles.ts
··· 1 - import { supabaseServerClient } from "supabase/serverClient"; 2 - import { inngest, events } from "../client"; 3 - 4 - export const batched_update_profiles = inngest.createFunction( 5 - { 6 - id: "batched_update_profiles", 7 - batchEvents: { 8 - maxSize: 100, 9 - timeout: "10s", 10 - }, 11 - triggers: [events.appviewProfileUpdate], 12 - }, 13 - async ({ events, step }) => { 14 - let existingProfiles = await supabaseServerClient 15 - .from("bsky_profiles") 16 - .select("did") 17 - .in( 18 - "did", 19 - events.map((event) => event.data.did), 20 - ); 21 - if (!existingProfiles.data) return { error: existingProfiles.error }; 22 - 23 - const profileUpdates = events.map((event) => ({ 24 - did: event.data.did, 25 - record: event.data.record, 26 - })); 27 - 28 - let { error } = await supabaseServerClient 29 - .from("bsky_profiles") 30 - .upsert(profileUpdates); 31 - return { 32 - done: true, 33 - profiles_updated: existingProfiles.data.length, 34 - error, 35 - }; 36 - }, 37 - );
+1 -1
app/api/inngest/functions/index_post_mention.ts
··· 8 8 pingIdentityToUpdateNotification, 9 9 } from "src/notifications"; 10 10 import { v7 } from "uuid"; 11 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 11 + import { idResolver } from "src/identity"; 12 12 import { documentUriFilter } from "src/utils/uriHelpers"; 13 13 14 14 export const index_post_mention = inngest.createFunction(
+4 -7
app/api/inngest/functions/send_post_broadcast.ts
··· 15 15 resolveReplyToEmail, 16 16 } from "src/utils/newsletterSender"; 17 17 import { PubLeafletPagesLinearDocument } from "lexicons/api"; 18 + import { getProfiles } from "src/identity"; 18 19 import type { Json } from "supabase/database.types"; 19 20 20 21 const BATCH_SIZE = 500; ··· 48 49 const authorDid = new AtUri(document_uri).host; 49 50 50 51 const loaded = await step.run("load-pub-and-doc", async () => { 51 - const [pubRes, docRes, profileRes] = await Promise.all([ 52 + const [pubRes, docRes, profiles] = await Promise.all([ 52 53 supabaseServerClient 53 54 .from("publications") 54 55 .select( ··· 61 62 .select("data") 62 63 .eq("uri", document_uri) 63 64 .maybeSingle(), 64 - supabaseServerClient 65 - .from("bsky_profiles") 66 - .select("handle") 67 - .eq("did", authorDid) 68 - .maybeSingle(), 65 + getProfiles([authorDid]), 69 66 ]); 70 67 return { 71 68 pub: pubRes.data, 72 69 doc: docRes.data, 73 - profile: profileRes.data, 70 + profile: profiles.get(authorDid) ?? null, 74 71 }; 75 72 }); 76 73
+1 -1
app/api/inngest/functions/sync_document_metadata.ts
··· 1 1 import { inngest, events } from "../client"; 2 2 import { supabaseServerClient } from "supabase/serverClient"; 3 3 import { AtpAgent, AtUri } from "@atproto/api"; 4 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 4 + import { idResolver } from "src/identity"; 5 5 import type { Json } from "supabase/database.types"; 6 6 7 7 // 1m, 2m, 4m, 8m, 16m, 32m, 1h, 2h, 4h, 8h, 8h, 8h (~37h total)
-2
app/api/inngest/route.tsx
··· 2 2 import { inngest } from "app/api/inngest/client"; 3 3 import { index_post_mention } from "./functions/index_post_mention"; 4 4 import { come_online } from "./functions/come_online"; 5 - import { batched_update_profiles } from "./functions/batched_update_profiles"; 6 5 import { index_follows } from "./functions/index_follows"; 7 6 import { migrate_user_to_standard } from "./functions/migrate_user_to_standard"; 8 7 import { fix_standard_document_publications } from "./functions/fix_standard_document_publications"; ··· 21 20 functions: [ 22 21 index_post_mention, 23 22 come_online, 24 - batched_update_profiles, 25 23 index_follows, 26 24 migrate_user_to_standard, 27 25 fix_standard_document_publications,
+24 -2
app/api/rpc/[command]/get_document_interactions.ts
··· 7 7 normalizeDocumentRecord, 8 8 normalizePublicationRecord, 9 9 } from "src/utils/normalizeRecords"; 10 + import { AtUri } from "@atproto/syntax"; 11 + import { getProfiles } from "src/identity"; 10 12 11 13 export const get_document_interactions = makeRoute({ 12 14 route: "get_document_interactions", ··· 23 25 ` 24 26 data, 25 27 uri, 26 - comments_on_documents(*, bsky_profiles(*)), 28 + comments_on_documents(*), 27 29 document_mentions_in_bsky(*), 28 30 documents_in_publications(publications(*)) 29 31 `, ··· 81 83 ...uniqueBacklinks.filter((b) => !dbMentionUris.has(b.uri)), 82 84 ]; 83 85 86 + const commentDids = Array.from( 87 + new Set(document.comments_on_documents.map((c) => new AtUri(c.uri).host)), 88 + ); 89 + const profiles = await getProfiles(commentDids); 90 + const comments = document.comments_on_documents.map((c) => { 91 + const did = new AtUri(c.uri).host; 92 + const p = profiles.get(did); 93 + return { 94 + ...c, 95 + profile: p 96 + ? { 97 + did: p.did, 98 + handle: p.handle, 99 + displayName: p.displayName, 100 + avatar: p.avatar, 101 + } 102 + : null, 103 + }; 104 + }); 105 + 84 106 return { 85 - comments: document.comments_on_documents, 107 + comments, 86 108 quotesAndMentions, 87 109 totalMentionsCount: quotesAndMentions.length, 88 110 };
+6 -13
app/api/rpc/[command]/get_profile_data.ts
··· 1 1 import { z } from "zod"; 2 2 import { makeRoute } from "../lib"; 3 3 import type { Env } from "./route"; 4 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 4 + import { idResolver } from "src/identity"; 5 5 import { supabaseServerClient } from "supabase/serverClient"; 6 6 import { Agent } from "@atproto/api"; 7 7 import { getIdentityData } from "actions/getIdentityData"; 8 - import { createOauthClient } from "src/atproto-oauth"; 8 + import { restoreOAuthSession } from "src/atproto-oauth"; 9 9 import { 10 10 normalizePublicationRow, 11 11 hasValidPublication, ··· 35 35 let agent; 36 36 let authed_identity = await getIdentityData(); 37 37 if (authed_identity?.atp_did) { 38 - try { 39 - const oauthClient = await createOauthClient(); 40 - let credentialSession = await oauthClient.restore( 41 - authed_identity.atp_did, 42 - ); 43 - agent = new Agent(credentialSession); 44 - } catch (e) { 45 - agent = new Agent({ 46 - service: "https://public.api.bsky.app", 47 - }); 48 - } 38 + const restored = await restoreOAuthSession(authed_identity.atp_did); 39 + agent = restored.ok 40 + ? new Agent(restored.value) 41 + : new Agent({ service: "https://public.api.bsky.app" }); 49 42 } else { 50 43 agent = new Agent({ 51 44 service: "https://public.api.bsky.app",
+3 -3
app/api/rpc/[command]/get_publication_data.ts
··· 32 32 publication_name, 33 33 ).toString(); 34 34 } 35 - let { data: publication, error } = await supabase 35 + let { data: publication } = await supabase 36 36 .from("publications") 37 37 .select( 38 38 `*, ··· 43 43 recommends_on_documents(count), 44 44 publication_post_sends(status, subscriber_count) 45 45 )), 46 - publication_subscriptions(*, identities(bsky_profiles(*))), 47 - publication_email_subscribers(*, identities(atp_did, bsky_profiles(*))), 46 + publication_subscriptions(*, identities(atp_did)), 47 + publication_email_subscribers(*, identities(atp_did)), 48 48 publication_domains(*), 49 49 publication_newsletter_settings(enabled, reply_to_email, reply_to_verified_at), 50 50 leaflets_in_publications(*,
+2 -2
app/api/rpc/[command]/get_publication_subscribers_timeseries.ts
··· 47 47 const [{ data: atprotoSubs }, { data: emailSubs }] = await Promise.all([ 48 48 supabase 49 49 .from("publication_subscriptions") 50 - .select("created_at, identities(bsky_profiles(did))") 50 + .select("created_at, identities(atp_did)") 51 51 .eq("publication", publication_uri), 52 52 newsletterEnabled 53 53 ? supabase ··· 63 63 // matching the UI merge in PublicationSubscribers.tsx. 64 64 const subscribers = new Map<string, string>(); 65 65 for (const s of atprotoSubs || []) { 66 - const did = s.identities?.bsky_profiles?.did; 66 + const did = s.identities?.atp_did; 67 67 if (!did) continue; 68 68 subscribers.set(`did:${did}`, s.created_at); 69 69 }
+4 -15
app/api/rpc/[command]/get_standard_site_posts.ts
··· 8 8 type NormalizedDocument, 9 9 type NormalizedPublication, 10 10 } from "src/utils/normalizeRecords"; 11 + import { getProfiles } from "src/identity"; 11 12 12 13 export type StandardSitePostData = { 13 14 uri: string; ··· 66 67 ), 67 68 ); 68 69 69 - const { data: profiles } = dids.length 70 - ? await supabase 71 - .from("bsky_profiles") 72 - .select("did, handle, record") 73 - .in("did", dids) 74 - : { data: [] as { did: string; handle: string | null; record: unknown }[] }; 75 - 76 - const profileByDid = new Map( 77 - (profiles || []).map((p) => [p.did, p] as const), 78 - ); 70 + const profiles = await getProfiles(dids); 79 71 80 72 const posts: StandardSitePostData[] = (documents || []) 81 73 .map((d): StandardSitePostData | null => { ··· 93 85 } catch { 94 86 did = null; 95 87 } 96 - const profile = did ? profileByDid.get(did) : undefined; 97 - const profileRecord = (profile?.record ?? null) as 98 - | { displayName?: string } 99 - | null; 88 + const profile = did ? profiles.get(did) : null; 100 89 const author = did 101 90 ? { 102 91 did, 103 92 handle: profile?.handle ?? null, 104 - displayName: profileRecord?.displayName ?? null, 93 + displayName: profile?.displayName ?? null, 105 94 } 106 95 : null; 107 96
+126
app/api/subscribe_email/route.ts
··· 1 + import { NextRequest, NextResponse } from "next/server"; 2 + import { requestPublicationEmailSubscription } from "actions/publications/subscribeEmail"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { 5 + getPublicationURL, 6 + getBasePublicationURL, 7 + } from "app/(app)/lish/createPub/getPublicationURL"; 8 + import { isProductionDomain } from "src/utils/isProductionDeployment"; 9 + 10 + export const dynamic = "force-dynamic"; 11 + 12 + const SUBSCRIBE_QUERY = "subscribe_email"; 13 + const ERROR_QUERY = "subscribe_email_error"; 14 + 15 + export async function POST(req: NextRequest) { 16 + const contentType = req.headers.get("content-type") || ""; 17 + let email: string | null = null; 18 + let publicationUri: string | null = null; 19 + let returnTo: string | null = null; 20 + 21 + if (contentType.includes("application/json")) { 22 + const body = (await req.json().catch(() => null)) as 23 + | Record<string, unknown> 24 + | null; 25 + if (body && typeof body === "object") { 26 + email = typeof body.email === "string" ? body.email : null; 27 + publicationUri = 28 + typeof body.publication === "string" ? body.publication : null; 29 + returnTo = 30 + typeof body.return_to === "string" ? body.return_to : null; 31 + } 32 + } else { 33 + const form = await req.formData(); 34 + const e = form.get("email"); 35 + const p = form.get("publication"); 36 + const r = form.get("return_to"); 37 + email = typeof e === "string" ? e : null; 38 + publicationUri = typeof p === "string" ? p : null; 39 + returnTo = typeof r === "string" ? r : null; 40 + } 41 + 42 + if (!publicationUri) { 43 + return new NextResponse("Missing publication", { status: 400 }); 44 + } 45 + const requestOrigin = new URL(req.url).origin; 46 + if (!email) { 47 + return redirectWithError( 48 + publicationUri, 49 + returnTo, 50 + requestOrigin, 51 + "invalid_email", 52 + ); 53 + } 54 + 55 + const result = await requestPublicationEmailSubscription( 56 + publicationUri, 57 + email, 58 + ); 59 + 60 + if (!result.ok) { 61 + return redirectWithError( 62 + publicationUri, 63 + returnTo, 64 + requestOrigin, 65 + result.error, 66 + ); 67 + } 68 + 69 + const target = await resolveReturnUrl(publicationUri, returnTo, requestOrigin); 70 + if (!target) { 71 + return new NextResponse("Publication not found", { status: 404 }); 72 + } 73 + target.searchParams.set(SUBSCRIBE_QUERY, email); 74 + if (result.value.confirmed) { 75 + target.searchParams.set("subscribe_email_confirmed", "1"); 76 + } 77 + return NextResponse.redirect(target.toString(), 303); 78 + } 79 + 80 + async function redirectWithError( 81 + publicationUri: string, 82 + returnTo: string | null, 83 + requestOrigin: string, 84 + error: string, 85 + ) { 86 + const target = await resolveReturnUrl(publicationUri, returnTo, requestOrigin); 87 + if (!target) { 88 + return new NextResponse(`Subscription failed: ${error}`, { status: 400 }); 89 + } 90 + target.searchParams.set(ERROR_QUERY, error); 91 + return NextResponse.redirect(target.toString(), 303); 92 + } 93 + 94 + async function resolveReturnUrl( 95 + publicationUri: string, 96 + returnTo: string | null, 97 + requestOrigin: string, 98 + ): Promise<URL | null> { 99 + const { data: publication } = await supabaseServerClient 100 + .from("publications") 101 + .select("uri, record") 102 + .eq("uri", publicationUri) 103 + .maybeSingle(); 104 + if (!publication) return null; 105 + 106 + // In dev/preview, the publication's recorded URL points at the production 107 + // domain (or a custom domain) — neither serves this dev server. Force the 108 + // local `/lish/...` path so the redirect lands somewhere we can test. 109 + const pubUrl = isProductionDomain() 110 + ? getPublicationURL(publication) 111 + : getBasePublicationURL(publication); 112 + const base = 113 + pubUrl.startsWith("http://") || pubUrl.startsWith("https://") 114 + ? pubUrl 115 + : `${requestOrigin}${pubUrl}`; 116 + 117 + if (returnTo) { 118 + try { 119 + const candidate = new URL(returnTo, base); 120 + const baseOrigin = new URL(base).origin; 121 + if (candidate.origin === baseOrigin) return candidate; 122 + } catch {} 123 + } 124 + 125 + return new URL(base); 126 + }
+41 -18
appview/index.ts
··· 1 1 import { createClient } from "@supabase/supabase-js"; 2 2 import { Database, Json } from "supabase/database.types"; 3 3 import { IdResolver } from "@atproto/identity"; 4 + import Client from "ioredis"; 4 5 const idResolver = new IdResolver(); 5 6 import { Firehose, MemoryRunner, Event } from "@atproto/sync"; 6 7 import { ids } from "lexicons/api/lexicons"; ··· 27 28 import { AtUri } from "@atproto/syntax"; 28 29 import { writeFile, readFile } from "fs/promises"; 29 30 import { inngest } from "app/api/inngest/client"; 31 + import { stripThemeWithoutType } from "src/utils/stripThemeWithoutType"; 30 32 31 33 const cursorFile = process.env.CURSOR_FILE || "/cursor/cursor"; 32 34 ··· 34 36 process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, 35 37 process.env.SUPABASE_SERVICE_ROLE_KEY as string, 36 38 ); 39 + 40 + const redisClient: Client | null = process.env.REDIS_URL 41 + ? new Client(process.env.REDIS_URL) 42 + : null; 43 + 44 + class RedisProfileCache { 45 + constructor(private client: Client) {} 46 + async clearEntry(did: string): Promise<void> { 47 + await this.client.del(`bsky-profile:${did}`); 48 + } 49 + } 50 + 51 + const profileCache: RedisProfileCache | null = redisClient 52 + ? new RedisProfileCache(redisClient) 53 + : null; 54 + 37 55 const QUOTE_PARAM = "/l-quote/"; 38 56 async function main() { 39 57 const runner = new MemoryRunner({}); ··· 41 59 service: "wss://relay1.us-west.bsky.network", 42 60 subscriptionReconnectDelay: 3000, 43 61 excludeAccount: true, 44 - excludeIdentity: true, 62 + excludeIdentity: false, 45 63 runner, 46 64 idResolver, 47 65 filterCollections: [ ··· 52 70 ids.PubLeafletPollVote, 53 71 ids.PubLeafletPollDefinition, 54 72 ids.PubLeafletInteractionsRecommend, 55 - // ids.AppBskyActorProfile, 73 + ids.AppBskyActorProfile, 56 74 "app.bsky.feed.post", 57 75 ids.SiteStandardDocument, 58 76 ids.SiteStandardPublication, ··· 86 104 87 105 async function handleEvent(evt: Event) { 88 106 if (evt.event === "identity") { 89 - if (evt.handle) 90 - await supabase 91 - .from("bsky_profiles") 92 - .update({ handle: evt.handle }) 93 - .eq("did", evt.did); 107 + if (profileCache) { 108 + try { 109 + await profileCache.clearEntry(evt.did); 110 + } catch (err) { 111 + console.error("Failed to clear profile cache for", evt.did, err); 112 + } 113 + } 94 114 } 95 115 if ( 96 116 evt.event == "account" || ··· 335 355 // site.standard.publication records go into the main "publications" table 336 356 if (evt.collection === ids.SiteStandardPublication) { 337 357 if (evt.event === "create" || evt.event === "update") { 338 - let record = SiteStandardPublication.validateRecord(evt.record); 358 + let record = SiteStandardPublication.validateRecord( 359 + stripThemeWithoutType(evt.record), 360 + ); 339 361 if (!record.success) return; 340 362 await supabase 341 363 .from("identities") 342 364 .upsert({ atp_did: evt.did }, { onConflict: "atp_did" }); 343 - await supabase.from("publications").upsert({ 365 + let { error } = await supabase.from("publications").upsert({ 344 366 uri: evt.uri.toString(), 345 367 identity_did: evt.did, 346 368 name: record.value.name, 347 369 record: record.value as Json, 348 370 }); 371 + if (error) console.log(error); 349 372 } 350 373 if (evt.event === "delete") { 351 374 await supabase ··· 401 424 .eq("uri", evt.uri.toString()); 402 425 } 403 426 } 404 - // if (evt.collection === ids.AppBskyActorProfile) { 405 - // //only listen to updates because we should fetch it for the first time when they subscribe! 406 - // if (evt.event === "update") { 407 - // await supabaseServerClient 408 - // .from("bsky_profiles") 409 - // .update({ record: evt.record as Json }) 410 - // .eq("did", evt.did); 411 - // } 412 - // } 427 + if (evt.collection === ids.AppBskyActorProfile) { 428 + if (profileCache) { 429 + try { 430 + await profileCache.clearEntry(evt.did); 431 + } catch (err) { 432 + console.error("Failed to clear profile cache for", evt.did, err); 433 + } 434 + } 435 + } 413 436 if (evt.collection === "parts.page.mention.service") { 414 437 if (evt.event === "create" || evt.event === "update") { 415 438 let { error } = await supabase.from("mention_services").upsert({
+3
components/ActionBar/DesktopNavigation.tsx
··· 122 122 asChild 123 123 trigger={ 124 124 <ActionButton 125 + onClick={() => { 126 + console.log("hello"); 127 + }} 125 128 className="w-full! grow" 126 129 secondary 127 130 icon={<AccountSmall />}
+11 -9
components/Blocks/Block.tsx
··· 32 32 import { CodeBlock } from "./CodeBlock"; 33 33 import { HorizontalRule } from "./HorizontalRule"; 34 34 import { PostsListBlock } from "./PostsListBlock"; 35 + import { SignupBlock } from "./SignupBlock"; 35 36 import { deepEquals } from "src/utils/deepEquals"; 36 37 import { isTextBlock } from "src/utils/isTextBlock"; 37 38 import { DeleteTiny } from "components/Icons/DeleteTiny"; ··· 363 364 "standard-site-post": StandardSitePostBlock, 364 365 "horizontal-rule": HorizontalRule, 365 366 "posts-list": PostsListBlock, 367 + signup: SignupBlock, 366 368 }; 367 369 368 370 export const BlockMultiselectIndicator = (props: BlockProps) => { ··· 423 425 className={`nonTextBlock ${props.className} p-2 sm:p-3 overflow-hidden 424 426 ${props.hasAlignment ? "w-fit" : "w-full"} 425 427 ${props.isSelected ? "block-border-selected " : "block-border"} 426 - ${props.borderOnHover && "hover:border-accent-contrast! hover:outline-accent-contrast! focus-within:border-accent-contrast! focus-within:outline-accent-contrast!"}`} 427 - style={{ 428 - backgroundColor: 429 - props.hasBackground === "accent" 430 - ? "var(--accent-light)" 431 - : props.hasBackground === "page" 432 - ? "rgb(var(--bg-page))" 433 - : "transparent", 434 - }} 428 + ${props.borderOnHover && "hover:border-accent-contrast! hover:outline-accent-contrast! focus-within:border-accent-contrast! focus-within:outline-accent-contrast!"} 429 + ${ 430 + props.hasBackground === "accent" 431 + ? "bg-[var(--accent-light)]" 432 + : props.hasBackground === "page" 433 + ? "bg-page" 434 + : "transparent" 435 + } 436 + `} 435 437 > 436 438 {props.children} 437 439 </div>
+2 -2
components/Blocks/BlockCommands.tsx
··· 458 458 name: "Subscribe Form", 459 459 icon: <BlockMailboxSmall />, 460 460 type: "publication", 461 - alternateNames: ["subscribe", "newsletter", "email"], 461 + alternateNames: ["subscribe", "newsletter", "email", "signup"], 462 462 publicationOnly: true, 463 463 onSelect: async (rep, props) => { 464 464 props.entityID && clearCommandSearchText(props.entityID); 465 - await createBlockWithType(rep, props, "text"); 465 + await createBlockWithType(rep, props, "signup"); 466 466 }, 467 467 }, 468 468 ];
+57
components/Blocks/SignupBlock.tsx
··· 1 + import { useState } from "react"; 2 + import { useUIState } from "src/useUIState"; 3 + import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 4 + import { BlockProps, BlockLayout } from "./Block"; 5 + import { EmailInput } from "components/Subscribe/EmailSubscribe"; 6 + import { ButtonPrimary } from "components/Buttons"; 7 + 8 + // SignupBlock is a static, non-interactive preview of the publication subscribe 9 + // form, styled to match SubscribePanel (components/Subscribe/SubscribeButton.tsx). 10 + // It is shown in the editor — the real, working form is rendered on the 11 + // published page (see PostContent.tsx's signup case). No user data is fetched. 12 + export const SignupBlock = ( 13 + props: BlockProps & { 14 + areYouSure?: boolean; 15 + setAreYouSure?: (value: boolean) => void; 16 + }, 17 + ) => { 18 + let isSelected = useUIState((s) => 19 + s.selectedBlocks.find((b) => b.value === props.entityID), 20 + ); 21 + let { normalizedPublication } = useLeafletPublicationData(); 22 + let [email, setEmail] = useState(""); 23 + 24 + let publicationName = normalizedPublication?.name || "Subscribe"; 25 + let publicationDescription = normalizedPublication?.description; 26 + 27 + return ( 28 + <BlockLayout 29 + isSelected={!!isSelected} 30 + areYouSure={props.areYouSure} 31 + setAreYouSure={props.setAreYouSure} 32 + className="accent-container rounded-lg! border-none! p-0! text-center justify-center" 33 + > 34 + <div className="px-3 pt-3 pb-4 sm:px-4 sm:pt-4 sm:pb-5"> 35 + <h3 className="leading-snug text-secondary">{publicationName}</h3> 36 + {publicationDescription && ( 37 + <div className="text-tertiary pb-1">{publicationDescription}</div> 38 + )} 39 + <div className="max-w-sm w-full mx-auto pt-2"> 40 + <EmailInput 41 + value={email} 42 + onChange={setEmail} 43 + action={ 44 + <ButtonPrimary 45 + type="button" 46 + compact 47 + className="leading-tight! outline-none! text-sm!" 48 + > 49 + Subscribe 50 + </ButtonPrimary> 51 + } 52 + /> 53 + </div> 54 + </div> 55 + </BlockLayout> 56 + ); 57 + };
+28 -38
components/Blocks/StandardSitePostBlock/StandardSitePostItem.tsx
··· 25 25 export function StandardSitePostItem({ 26 26 uri, 27 27 size = "medium", 28 - showPubTheme = true, 29 28 currentPublicationUri, 30 29 }: { 31 30 uri: string; 32 31 size?: StandardSitePostSize; 33 - showPubTheme?: boolean; 34 32 currentPublicationUri?: string | null; 35 33 }) { 36 34 const { data, isLoading } = useStandardSitePost(uri); ··· 51 49 <StandardSitePostItemView 52 50 post={data} 53 51 size={size} 54 - showPubTheme={showPubTheme} 55 52 currentPublicationUri={currentPublicationUri} 56 53 /> 57 54 ); ··· 66 63 enabled: boolean; 67 64 children: React.ReactNode; 68 65 }) { 69 - if (!enabled || !post.publication?.record?.theme) return <>{children}</>; 66 + const record = post.publication?.record; 67 + if (!enabled || !record || (!record.theme && !record.basicTheme)) { 68 + return <>{children}</>; 69 + } 70 70 let pubCreator: string; 71 71 try { 72 - pubCreator = new AtUri(post.publication.uri).host; 72 + pubCreator = new AtUri(post.publication!.uri).host; 73 73 } catch { 74 74 return <>{children}</>; 75 75 } 76 76 return ( 77 - <PublicationThemeProvider 78 - local 79 - theme={post.publication.record.theme} 80 - pub_creator={pubCreator} 81 - > 77 + <PublicationThemeProvider local record={record} pub_creator={pubCreator}> 82 78 {children} 83 79 </PublicationThemeProvider> 84 80 ); ··· 94 90 if (size === "small") { 95 91 return ( 96 92 <> 97 - <div className="flex w-full grow flex-col gap-1 px-3 py-2"> 93 + <div className="transparent-container flex w-full grow flex-col gap-1 p-3 "> 98 94 <div className="h-7 w-2/3 bg-border-light rounded animate-pulse" /> 99 95 <div className="h-4 w-32 bg-border-light rounded animate-pulse" /> 100 96 </div> ··· 106 102 if (size === "medium") { 107 103 return ( 108 104 <> 109 - <div className="flex w-full gap-3 items-stretch sm:min-h-36"> 110 - <div className="flex w-full gap-2 grow flex-col justify-between min-w-0 pl-3 pr-3 py-2"> 105 + <div className="transparent-container flex w-full gap-3 items-stretch sm:min-h-36"> 106 + <div className="flex w-full gap-2 grow flex-col justify-between min-w-0 pl-3 p-3"> 111 107 <div className="flex flex-col gap-2"> 112 108 <div className="h-7 w-2/3 bg-border-light rounded animate-pulse" /> 113 109 <div className="h-4 w-full bg-border-light rounded animate-pulse" /> ··· 126 122 return ( 127 123 <> 128 124 <div 129 - className={`flex flex-col items-stretch ${widePage ? "sm:flex-row sm:gap-2 gap-0" : ""} w-full items-start`} 125 + className={`transparent-container flex flex-col items-stretch ${widePage ? "sm:flex-row sm:gap-2 gap-0" : ""} w-full items-start`} 130 126 > 131 127 <div 132 - className={`bg-border-light rounded animate-pulse shrink-0 ${widePage ? "w-full sm:w-auto sm:h-[244px] aspect-[1.91/1]" : "w-full aspect-[1.91/1]"}`} 128 + className={`bg-border-light rounded animate-pulse shrink-0 ${widePage ? "w-full sm:w-auto sm:h-[244px] aspect-[3/2]" : "w-full aspect-[1.91/1]"}`} 133 129 /> 134 130 <div 135 - className={`flex w-full grow flex-col gap-2 justify-between px-3 py-2 ${widePage ? "sm:pb-3" : ""}`} 131 + className={`flex w-full grow flex-col gap-2 justify-between p-3 pb-2 ${widePage ? "sm:pb-3" : ""}`} 136 132 > 137 133 <div className="flex flex-col gap-2"> 138 134 <div 139 - className={`h-7 w-2/3 bg-border-light rounded animate-pulse ${widePage ? "sm:h-8" : ""}`} 135 + className={`h-7 w-1/3 bg-border-light rounded animate-pulse ${widePage ? "sm:h-8" : ""}`} 140 136 /> 141 137 <div 142 138 className={`h-5 w-full bg-border-light rounded animate-pulse ${widePage ? "sm:h-6" : ""}`} ··· 158 154 export function StandardSitePostItemView({ 159 155 post, 160 156 size = "medium", 161 - showPubTheme = true, 162 157 currentPublicationUri, 163 158 }: { 164 159 post: StandardSitePostData; 165 160 size?: StandardSitePostSize; 166 - showPubTheme?: boolean; 167 161 currentPublicationUri?: string | null; 168 162 }) { 169 163 const docUrl = getDocumentURL( ··· 234 228 footer: pubFooter, 235 229 }; 236 230 237 - let item: React.ReactNode; 238 231 if (size === "large") { 239 - item = ( 232 + return ( 240 233 <PublicationPostItemLarge 241 234 {...commonProps} 242 235 description={description} ··· 245 238 pageWidth={pageWidth} 246 239 /> 247 240 ); 248 - } else if (size === "medium") { 249 - item = ( 241 + } 242 + if (size === "medium") { 243 + return ( 250 244 <PublicationPostItemMedium 251 245 {...commonProps} 252 246 description={description} ··· 254 248 coverImageAlt={post.record.title} 255 249 /> 256 250 ); 257 - } else { 258 - item = <PublicationPostItemSmall {...commonProps} />; 259 251 } 260 - 261 - return ( 262 - <WithStandardSitePostPublicationTheme post={post} enabled={showPubTheme}> 263 - {item} 264 - </WithStandardSitePostPublicationTheme> 265 - ); 252 + return <PublicationPostItemSmall {...commonProps} />; 266 253 } 267 254 268 255 function PubFooter({ ··· 273 260 if (!publication.record) return null; 274 261 const pubUrl = getPublicationURL(publication); 275 262 return ( 276 - <Link 277 - href={pubUrl} 278 - className="flex items-center gap-1.5 text-accent-contrast font-bold no-underline! text-sm -mb-0.5" 279 - > 280 - <PubIcon tiny record={publication.record} uri={publication.uri} /> 281 - <span className="min-w-0 truncate">{publication.record.name}</span> 282 - </Link> 263 + <div className="flex flex-col"> 264 + <hr className=" border-border-light mt-2 mb-1" /> 265 + <Link 266 + href={pubUrl} 267 + className="flex items-center gap-1.5 text-accent-contrast font-bold no-underline! text-sm -mb-0.5" 268 + > 269 + <PubIcon tiny record={publication.record} uri={publication.uri} /> 270 + <span className="min-w-0 truncate">{publication.record.name}</span> 271 + </Link> 272 + </div> 283 273 ); 284 274 }
+136 -93
components/Blocks/StandardSitePostBlock/index.tsx
··· 6 6 import { SettingsTriggerButton } from "../SettingsTriggerButton"; 7 7 import { 8 8 StandardSitePostItem, 9 + WithStandardSitePostPublicationTheme, 9 10 type StandardSitePostSize, 10 11 } from "./StandardSitePostItem"; 12 + import { useStandardSitePost } from "components/StandardSitePostDataProvider"; 11 13 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 12 14 13 15 export const StandardSitePostBlock = ( ··· 26 28 let showPubTheme = showPubThemeFact?.data.value !== false; 27 29 let editorPub = useLeafletPublicationData(); 28 30 let currentPublicationUri = editorPub.data?.publications?.uri ?? null; 31 + let { data: post } = useStandardSitePost(uri); 29 32 30 33 if (!uri) return null; 31 34 35 + if (!post) 36 + return ( 37 + <StandardSitePostItem 38 + uri={uri} 39 + size={size} 40 + currentPublicationUri={currentPublicationUri} 41 + /> 42 + ); 43 + 32 44 return ( 33 45 <BlockLayout 34 46 isSelected={!!isSelected} 35 - hasBackground="page" 36 47 borderOnHover 37 48 className="standardSitePostBlock p-0!" 38 49 extraOptions={ 39 50 <StandardSitePostSettingsButton entityID={props.entityID} /> 40 51 } 41 52 > 42 - <StandardSitePostItem 43 - uri={uri} 44 - size={size} 45 - showPubTheme={showPubTheme} 46 - currentPublicationUri={currentPublicationUri} 47 - /> 53 + <WithStandardSitePostPublicationTheme post={post} enabled={showPubTheme}> 54 + <div className="bg-bg-page"> 55 + <StandardSitePostItem 56 + uri={uri} 57 + size={size} 58 + currentPublicationUri={currentPublicationUri} 59 + /> 60 + </div> 61 + </WithStandardSitePostPublicationTheme> 48 62 </BlockLayout> 49 63 ); 50 64 }; ··· 58 72 "standard-site-post/show-publication-theme", 59 73 ); 60 74 let showPubTheme = showPubThemeFact?.data.value !== false; 75 + let popoverKey = `${props.entityID}-settings`; 76 + let setOpenPopover = useUIState((s) => s.setOpenPopover); 77 + let isOpen = useUIState((s) => s.openPopover === popoverKey); 61 78 62 79 return ( 63 80 <Popover 64 81 asChild 65 82 side="top" 66 83 align="end" 67 - className="flex flex-col gap-2 w-xs pb-3!" 84 + className="p-0!" 85 + open={isOpen} 86 + onOpenChange={(o) => setOpenPopover(o ? popoverKey : null)} 68 87 onOpenAutoFocus={(e) => e.preventDefault()} 69 88 trigger={ 70 89 <SettingsTriggerButton aria-label="Standard Site Post Settings" /> 71 90 } 72 91 > 73 - <h4>Post Size</h4> 74 - <div className="flex flex-col gap-3 w-full"> 75 - {( 76 - [ 77 - { value: "small", Icon: SmallIcon }, 78 - { value: "medium", Icon: MedIcon }, 79 - { value: "large", Icon: LargeIcon }, 80 - ] as { 81 - value: StandardSitePostSize; 82 - Icon: (props: { selected: boolean }) => React.ReactNode; 83 - }[] 84 - ).map((option) => { 85 - let selected = 86 - size === option.value || 87 - (option.value === "medium" && size !== "small" && size !== "large"); 88 - return ( 89 - <button 90 - className="text-left" 91 - key={option.value} 92 - type="button" 93 - aria-pressed={selected} 94 - onClick={() => { 95 - if (!rep) return; 96 - rep.mutate.assertFact({ 97 - entity: props.entityID, 98 - attribute: "standard-site-post/size", 99 - data: { 100 - type: "standard-site-post-size-union", 101 - value: option.value, 102 - }, 103 - }); 104 - }} 105 - > 106 - <option.Icon selected={selected} /> 107 - </button> 108 - ); 109 - })} 92 + <div className="flex flex-col gap-2 w-xs pt-1 p-3! overflow-y-auto"> 93 + <div> 94 + <h4>Post Size</h4> 95 + <div className="text-sm text-tertiary italic"> 96 + This block links to a Standard Site article. 97 + </div> 98 + </div> 99 + <div className="flex flex-col gap-3 w-full"> 100 + {( 101 + [ 102 + { value: "small", Icon: SmallIcon }, 103 + { value: "medium", Icon: MedIcon }, 104 + { value: "large", Icon: LargeIcon }, 105 + ] as { 106 + value: StandardSitePostSize; 107 + Icon: (props: { selected: boolean }) => React.ReactNode; 108 + }[] 109 + ).map((option) => { 110 + let selected = 111 + size === option.value || 112 + (option.value === "medium" && 113 + size !== "small" && 114 + size !== "large"); 115 + return ( 116 + <button 117 + className="text-left" 118 + key={option.value} 119 + type="button" 120 + aria-pressed={selected} 121 + onClick={() => { 122 + if (!rep) return; 123 + rep.mutate.assertFact({ 124 + entity: props.entityID, 125 + attribute: "standard-site-post/size", 126 + data: { 127 + type: "standard-site-post-size-union", 128 + value: option.value, 129 + }, 130 + }); 131 + }} 132 + > 133 + <option.Icon selected={selected} /> 134 + </button> 135 + ); 136 + })} 137 + </div> 138 + <hr className="border-border-light my-1" /> 139 + <Toggle 140 + toggle={showPubTheme} 141 + onToggle={() => { 142 + if (!rep) return; 143 + rep.mutate.assertFact({ 144 + entity: props.entityID, 145 + attribute: "standard-site-post/show-publication-theme", 146 + data: { type: "boolean", value: !showPubTheme }, 147 + }); 148 + }} 149 + > 150 + <div className="font-bold">Use Publication Theme</div> 151 + </Toggle> 110 152 </div> 111 - <hr className="border-border-light my-1" /> 112 - <Toggle 113 - toggle={showPubTheme} 114 - onToggle={() => { 115 - if (!rep) return; 116 - rep.mutate.assertFact({ 117 - entity: props.entityID, 118 - attribute: "standard-site-post/show-publication-theme", 119 - data: { type: "boolean", value: !showPubTheme }, 120 - }); 121 - }} 122 - > 123 - <div className="font-bold">Use Publication Theme</div> 124 - </Toggle> 125 153 </Popover> 126 154 ); 127 155 } ··· 129 157 const SmallIcon = ({ selected }: { selected: boolean }) => { 130 158 return ( 131 159 <div 132 - className={`flex gap-2 p-2 w-full opaque-container outline-2 outline-offset-1 border-border! ${selected ? "outline-accent-contrast border-accent-contrast!" : "outline-transparent"}`} 160 + className={`flex flex-col pt-1 p-2 outline-2 outline-offset-1 border ${selected ? "accent-container outline-accent-contrast border-accent-contrast " : "opaque-container outline-transparent"}`} 133 161 > 134 - <div className="flex flex-col gap-1 grow min-w-0"> 135 - <div className="w-full h-4 bg-tertiary rounded-[2px]" /> 162 + <div className="text-xs font-bold text-secondary">SMALL</div> 163 + <div 164 + className={`flex gap-2 p-2 w-full overflow-hidden opaque-container border-tertiary! ${selected && "border-accent-contrast!"}`} 165 + > 166 + <div className="flex flex-col gap-1 grow min-w-0"> 167 + <div className="w-full h-4 bg-border rounded-[2px]" /> 136 168 137 - <div className="flex justify-between mt-1 w-full"> 138 - <div className="w-[60%] h-2 bg-border rounded-[2px]" /> 139 - <div className="w-6 h-2 bg-border rounded-[2px]" /> 169 + <div className="flex justify-between mt-1 w-full"> 170 + <div className="w-[60%] h-2 bg-border-light rounded-[2px]" /> 171 + <div className="w-6 h-2 bg-border-light rounded-[2px]" /> 172 + </div> 140 173 </div> 141 174 </div> 142 175 </div> ··· 146 179 const MedIcon = ({ selected }: { selected: boolean }) => { 147 180 return ( 148 181 <div 149 - className={`flex gap-2 w-full opaque-container outline-2 outline-offset-1 bg-bg-page overflow-hidden border-border! ${selected ? "outline-accent-contrast border-accent-contrast!" : "outline-transparent "}`} 182 + className={`flex flex-col pt-1 p-2 outline-2 outline-offset-1 border ${selected ? "accent-container outline-accent-contrast border-accent-contrast " : "opaque-container outline-transparent"}`} 150 183 > 151 - <div className="flex flex-col gap-1 p-2 grow min-w-0"> 152 - <div className="w-full h-4 bg-tertiary rounded-[2px]" /> 153 - <div className="w-full h-2 bg-tertiary mt-1 rounded-[2px]" /> 154 - <div className="w-full h-2 bg-tertiary rounded-[2px]" /> 155 - <div className="flex justify-between mt-2 w-full"> 156 - <div className="w-[60%] h-2 bg-border rounded-[2px]" /> 157 - <div className="w-6 h-2 bg-border rounded-[2px]" /> 184 + <div className="text-xs font-bold text-secondary">MEDIUM</div> 185 + <div 186 + className={`flex opaque-container border-tertiary! overflow-hidden ${selected && "border-accent-contrast!"}`} 187 + > 188 + <div className="flex flex-col gap-1 p-2 grow min-w-0"> 189 + <div className="w-full h-4 bg-border rounded-[2px]" /> 190 + <div className="w-full h-2 bg-border mt-1 rounded-[2px]" /> 191 + <div className="w-full h-2 bg-border rounded-[2px]" /> 192 + <div className="flex justify-between mt-2 w-full"> 193 + <div className="w-[60%] h-2 bg-border-light rounded-[2px]" /> 194 + <div className="w-6 h-2 bg-border-light rounded-[2px]" /> 195 + </div> 158 196 </div> 197 + <div 198 + className="aspect-square h-[82px] bg-border border-l border-border shrink-0 bg-cover bg-center" 199 + style={{ 200 + backgroundImage: "url(/imagePlaceholder.png)", 201 + backgroundBlendMode: "hard-light", 202 + }} 203 + /> 159 204 </div> 160 - <div 161 - className="aspect-square h-[82px] bg-border border-l border-border shrink-0 bg-cover bg-center" 162 - style={{ 163 - backgroundImage: "url(/imagePlaceholder.png)", 164 - backgroundBlendMode: "hard-light", 165 - }} 166 - /> 167 205 </div> 168 206 ); 169 207 }; ··· 171 209 const LargeIcon = ({ selected }: { selected: boolean }) => { 172 210 return ( 173 211 <div 174 - className={`flex flex-col gap-2 w-full outline-2 outline-offset-1 opaque-container overflow-hidden border-border! ${selected ? "outline-accent-contrast border-accent-contrast!" : "outline-transparent"}`} 212 + className={`flex flex-col pt-1 p-2 outline-2 outline-offset-1 border ${selected ? "accent-container outline-accent-contrast border-accent-contrast " : "opaque-container outline-transparent"}`} 175 213 > 214 + <div className="text-xs font-bold text-secondary">LARGE</div> 176 215 <div 177 - className="w-full aspect-video bg-border bg-cover bg-center border-b border-border" 178 - style={{ 179 - backgroundImage: "url(/imagePlaceholder.png)", 180 - backgroundBlendMode: "hard-light", 181 - }} 182 - /> 216 + className={`flex flex-col gap-1 opaque-container border-tertiary! overflow-hidden ${selected && "border-accent-contrast!"}`} 217 + > 218 + <div 219 + className="w-full aspect-video bg-border bg-cover bg-center border-b border-border" 220 + style={{ 221 + backgroundImage: "url(/imagePlaceholder.png)", 222 + backgroundBlendMode: "hard-light", 223 + }} 224 + /> 183 225 184 - <div className="flex flex-col gap-1 p-2 pt-0.5!"> 185 - <div className="w-full h-4 bg-tertiary rounded-[2px]" /> 186 - <div className="w-full h-2 bg-tertiary mt-1 rounded-[2px]" /> 187 - <div className="flex justify-between mt-2 w-full"> 188 - <div className="w-[60%] h-2 bg-border rounded-[2px]" /> 189 - <div className="w-6 h-2 bg-border rounded-[2px]" /> 226 + <div className="flex flex-col gap-1 p-2 pt-0.5!"> 227 + <div className="w-full h-4 bg-border rounded-[2px]" /> 228 + <div className="w-full h-2 bg-border mt-1 rounded-[2px]" /> 229 + <div className="flex justify-between mt-2 w-full"> 230 + <div className="w-[60%] h-2 bg-border-light rounded-[2px]" /> 231 + <div className="w-6 h-2 bg-border-light rounded-[2px]" /> 232 + </div> 190 233 </div> 191 234 </div> 192 235 </div>
+1
components/Popover/index.tsx
··· 65 65 sideOffset={props.sideOffset ? props.sideOffset : 4} 66 66 collisionPadding={16} 67 67 onOpenAutoFocus={props.onOpenAutoFocus} 68 + arrowPadding={12} 68 69 > 69 70 {props.children} 70 71 {!props.noArrow && (
+3 -1
components/PostListing.tsx
··· 42 42 43 43 // For standalone documents (no publication), pass isStandalone to get correct defaults 44 44 let isStandalone = !pubRecord; 45 - let theme = usePubTheme(pubRecord?.theme || postRecord?.theme, isStandalone); 45 + let themeSource = 46 + pubRecord?.theme || pubRecord?.basicTheme ? pubRecord : postRecord; 47 + let theme = usePubTheme(themeSource, isStandalone); 46 48 let themeRecord = pubRecord?.theme || postRecord?.theme; 47 49 let elRef = useRef<HTMLDivElement>(null); 48 50 let [hasBackgroundImage, setHasBackgroundImage] = useState(false);
+41 -2
components/Subscribe/SubscribeButton.tsx
··· 1 1 "use client"; 2 - import { useState } from "react"; 3 - import { useRouter } from "next/navigation"; 2 + import { useEffect, useState } from "react"; 3 + import { useRouter, useSearchParams, usePathname } from "next/navigation"; 4 4 import { SubscribeWithHandle, AtSubscribeSuccess } from "./HandleSubscribe"; 5 5 import { EmailInput, EmailConfirm } from "./EmailSubscribe"; 6 6 import { EmailSubscribeSuccess } from "./EmailSubscribeSuccess"; ··· 55 55 export const SubscribeInput = (props: SubscribeProps) => { 56 56 let toaster = useToaster(); 57 57 let router = useRouter(); 58 + let pathname = usePathname(); 59 + let searchParams = useSearchParams(); 58 60 const user = useViewerSubscription(props.publicationUri); 59 61 const { identity, mutate: mutateIdentity } = useIdentityData(); 60 62 let [email, setEmail] = useState(user.email ?? ""); ··· 81 83 // account with no email yet. The modal asks them to link the typed email 82 84 // (or log out) before we send a confirmation code. 83 85 const needsLinkConfirmation = !!viewerAtpDid && !viewerEmail && !!email; 86 + 87 + // Embedded subscribe forms (see /api/subscribe_email) redirect back here 88 + // with `subscribe_email=<email>` after sending the confirmation code. Open 89 + // the confirm modal so the user can paste the code from their inbox without 90 + // re-entering their email. `subscribe_email_confirmed=1` means the user was 91 + // already verified server-side and the modal jumps straight to success. 92 + useEffect(() => { 93 + if (!props.newsletterMode) return; 94 + const incomingEmail = searchParams.get("subscribe_email"); 95 + const alreadyConfirmed = 96 + searchParams.get("subscribe_email_confirmed") === "1"; 97 + const errorCode = searchParams.get("subscribe_email_error"); 98 + if (!incomingEmail && !errorCode) return; 99 + 100 + if (errorCode) { 101 + const message = 102 + ERROR_MESSAGES[errorCode as SubscribeError] ?? 103 + "We couldn't process that subscription. Try again."; 104 + toaster({ type: "error", content: message }); 105 + } else if (incomingEmail) { 106 + setEmail(incomingEmail); 107 + setConfirmState(alreadyConfirmed ? "success" : "confirm"); 108 + setConfirmOpen(true); 109 + if (alreadyConfirmed) { 110 + setLocallySubscribed(true); 111 + router.refresh(); 112 + } 113 + } 114 + 115 + const next = new URLSearchParams(searchParams.toString()); 116 + next.delete("subscribe_email"); 117 + next.delete("subscribe_email_confirmed"); 118 + next.delete("subscribe_email_error"); 119 + const qs = next.toString(); 120 + router.replace(qs ? `${pathname}?${qs}` : pathname); 121 + // eslint-disable-next-line react-hooks/exhaustive-deps 122 + }, [props.newsletterMode]); 84 123 85 124 const sendRequest = async (link: boolean) => { 86 125 setRequesting(true);
+23 -12
components/ThemeManager/PublicationThemeProvider.tsx
··· 5 5 import { useColorAttribute, colorToString } from "./useColorAttribute"; 6 6 import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider"; 7 7 import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api"; 8 + import type * as SiteStandardThemeBasic from "lexicons/api/types/site/standard/theme/basic"; 9 + import { resolvePublicationTheme } from "lexicons/src/normalize"; 8 10 import { 9 11 usePublicationData, 10 12 useNormalizedPublicationRecord, 11 13 } from "app/(app)/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; 12 14 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 13 15 import { PubThemeDefaults } from "./themeDefaults"; 16 + 17 + export type PubThemeSource = 18 + | { 19 + theme?: PubLeafletPublication.Theme | null; 20 + basicTheme?: SiteStandardThemeBasic.Main | null; 21 + } 22 + | null 23 + | undefined; 14 24 15 25 // Default page background for standalone leaflets (matches editor default) 16 26 const StandalonePageBackground = "#FFFFFF"; ··· 52 62 return ( 53 63 <PublicationThemeProvider 54 64 pub_creator={pub?.identity_did || ""} 55 - theme={normalizedPub?.theme} 65 + record={normalizedPub} 56 66 > 57 67 <PublicationBackgroundProvider 58 - theme={normalizedPub?.theme} 68 + record={normalizedPub} 59 69 pub_creator={pub?.identity_did || ""} 60 70 > 61 71 {props.children} ··· 65 75 } 66 76 67 77 export function PublicationBackgroundProvider(props: { 68 - theme?: PubLeafletPublication.Record["theme"] | null; 78 + record?: PubThemeSource; 69 79 pub_creator: string; 70 80 className?: string; 71 81 children: React.ReactNode; ··· 75 85 let backgroundImage = 76 86 props.localBgImage !== undefined 77 87 ? props.localBgImage 78 - : props.theme?.backgroundImage?.image?.ref 88 + : props.record?.theme?.backgroundImage?.image?.ref 79 89 ? blobRefToSrc( 80 - props.theme?.backgroundImage?.image?.ref, 90 + props.record?.theme?.backgroundImage?.image?.ref, 81 91 props.pub_creator, 82 92 ) 83 93 : null; ··· 85 95 let backgroundImageRepeat = 86 96 props.localBgImageRepeat !== undefined 87 97 ? !!props.localBgImageRepeat 88 - : props.theme?.backgroundImage?.repeat; 98 + : props.record?.theme?.backgroundImage?.repeat; 89 99 let backgroundImageSize = 90 100 (props.localBgImageRepeat !== undefined 91 101 ? props.localBgImageRepeat 92 - : props.theme?.backgroundImage?.width) || 500; 102 + : props.record?.theme?.backgroundImage?.width) || 500; 93 103 94 104 return ( 95 105 <div ··· 109 119 export function PublicationThemeProvider(props: { 110 120 local?: boolean; 111 121 children: React.ReactNode; 112 - theme?: PubLeafletPublication.Record["theme"] | null; 122 + record?: PubThemeSource; 113 123 pub_creator: string; 114 124 isStandalone?: boolean; 115 125 }) { 116 - let theme = usePubTheme(props.theme, props.isStandalone); 126 + let theme = usePubTheme(props.record, props.isStandalone); 117 127 let cardBorderHidden = !theme.showPageBackground; 118 - let hasBackgroundImage = !!props.theme?.backgroundImage?.image?.ref; 128 + let hasBackgroundImage = !!props.record?.theme?.backgroundImage?.image?.ref; 119 129 120 130 return ( 121 131 <CardBorderHiddenContext.Provider value={cardBorderHidden}> ··· 131 141 } 132 142 133 143 export const usePubTheme = ( 134 - theme?: PubLeafletPublication.Record["theme"] | null, 144 + source?: PubThemeSource, 135 145 isStandalone?: boolean, 136 146 ) => { 147 + const theme = useMemo(() => resolvePublicationTheme(source), [source]); 137 148 let bgLeaflet = useColor(theme, "backgroundColor"); 138 149 let bgPage = useColor(theme, "pageBackground"); 139 150 // For standalone documents, use the editor default page background (#FFFFFF) ··· 182 193 theme: PubLeafletPublication.Record["theme"] | undefined, 183 194 showPageBackground?: boolean, 184 195 ) => { 185 - const pubTheme = usePubTheme(theme); 196 + const pubTheme = usePubTheme({ theme }); 186 197 const [localOverrides, setTheme] = useState<Partial<typeof pubTheme>>({}); 187 198 188 199 const mergedTheme = useMemo(() => {
+2 -2
components/ThemeManager/ThemeProvider.tsx
··· 60 60 return ( 61 61 <PublicationThemeProvider 62 62 {...props} 63 - theme={normalizedPublication?.theme} 63 + record={normalizedPublication} 64 64 pub_creator={pub.publications?.identity_did} 65 65 /> 66 66 ); ··· 474 474 return ( 475 475 <PublicationBackgroundProvider 476 476 pub_creator={pub?.publications.identity_did || ""} 477 - theme={normalizedPublication?.theme} 477 + record={normalizedPublication} 478 478 > 479 479 {props.children} 480 480 </PublicationBackgroundProvider>
+1 -2
contexts/DocumentContext.tsx
··· 5 5 // Derive types from PostPageData 6 6 type NonNullPostPageData = NonNullable<PostPageData>; 7 7 export type PublicationContext = NonNullPostPageData["publication"]; 8 - export type CommentOnDocument = NonNullPostPageData["comments"][number]; 9 8 export type DocumentMention = NonNullPostPageData["mentions"][number]; 10 9 export type QuotesAndMentions = NonNullPostPageData["quotesAndMentions"]; 11 10 ··· 18 17 | "prevNext" 19 18 | "quotesAndMentions" 20 19 | "publication" 21 - | "comments" 20 + | "commentsCount" 22 21 | "mentions" 23 22 | "leafletId" 24 23 | "recommendsCount"
+7 -12
drizzle/relations.ts
··· 65 65 fields: [comments_on_documents.document], 66 66 references: [documents.uri] 67 67 }), 68 - bsky_profile: one(bsky_profiles, { 69 - fields: [comments_on_documents.profile], 70 - references: [bsky_profiles.did] 71 - }), 72 68 })); 73 69 74 70 export const documentsRelations = relations(documents, ({many}) => ({ ··· 80 76 leaflets_to_documents: many(leaflets_to_documents), 81 77 leaflets_in_publications: many(leaflets_in_publications), 82 78 publication_pages: many(publication_pages), 83 - })); 84 - 85 - export const bsky_profilesRelations = relations(bsky_profiles, ({one, many}) => ({ 86 - comments_on_documents: many(comments_on_documents), 87 - identity: one(identities, { 88 - fields: [bsky_profiles.did], 89 - references: [identities.atp_did] 90 - }), 91 79 })); 92 80 93 81 export const entitiesRelations = relations(entities, ({one, many}) => ({ ··· 133 121 }), 134 122 identity: one(identities, { 135 123 fields: [recommends_on_documents.recommender_did], 124 + references: [identities.atp_did] 125 + }), 126 + })); 127 + 128 + export const bsky_profilesRelations = relations(bsky_profiles, ({one}) => ({ 129 + identity: one(identities, { 130 + fields: [bsky_profiles.did], 136 131 references: [identities.atp_did] 137 132 }), 138 133 }));
+1 -1
drizzle/schema.ts
··· 49 49 record: jsonb("record").notNull(), 50 50 document: text("document").references(() => documents.uri, { onDelete: "cascade", onUpdate: "cascade" } ), 51 51 indexed_at: timestamp("indexed_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 52 - profile: text("profile").references(() => bsky_profiles.did, { onDelete: "set null", onUpdate: "cascade" } ), 52 + profile: text("profile"), 53 53 }, 54 54 (table) => { 55 55 return {
+9 -1
emails/post.tsx
··· 483 483 assetsBaseUrl={p.assetsBaseUrl} 484 484 theme={theme} 485 485 colors={c} 486 + postUrl={p.postUrl} 486 487 /> 487 488 ))} 488 489 ··· 687 688 assetsBaseUrl, 688 689 theme, 689 690 colors, 691 + postUrl, 690 692 }: { 691 693 block: PubLeafletPagesLinearDocument.Block["block"]; 692 694 alignment?: string; ··· 694 696 assetsBaseUrl: string; 695 697 theme: EmailTheme; 696 698 colors: ResolvedColors; 699 + postUrl: string; 697 700 }) => { 698 701 if (PubLeafletBlocksText.isMain(block)) { 699 702 return ( ··· 862 865 /> 863 866 ); 864 867 } 865 - return <BlockNotSupported theme={theme} colors={colors} />; 868 + return ( 869 + <BlockNotSupported theme={theme} colors={colors} postUrl={postUrl} /> 870 + ); 866 871 }; 867 872 868 873 // Helpers used by the confirm-email templates inside their <Tailwind> ··· 1276 1281 export const BlockNotSupported = ({ 1277 1282 theme = defaultEmailTheme, 1278 1283 colors, 1284 + postUrl, 1279 1285 }: { 1280 1286 theme?: EmailTheme; 1281 1287 colors?: ResolvedColors; 1288 + postUrl?: string; 1282 1289 } = {}) => { 1283 1290 const c = colors ?? resolveColors(theme); 1284 1291 return ( ··· 1312 1319 }} 1313 1320 > 1314 1321 <Link 1322 + href={postUrl} 1315 1323 style={{ 1316 1324 color: theme.accentBackground, 1317 1325 fontWeight: "bold",
+2
lexicons/api/index.ts
··· 40 40 import * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 41 41 import * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 42 42 import * as PubLeafletBlocksPostsList from './types/pub/leaflet/blocks/postsList' 43 + import * as PubLeafletBlocksSignup from './types/pub/leaflet/blocks/signup' 43 44 import * as PubLeafletBlocksStandardSitePost from './types/pub/leaflet/blocks/standardSitePost' 44 45 import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 45 46 import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' ··· 96 97 export * as PubLeafletBlocksPage from './types/pub/leaflet/blocks/page' 97 98 export * as PubLeafletBlocksPoll from './types/pub/leaflet/blocks/poll' 98 99 export * as PubLeafletBlocksPostsList from './types/pub/leaflet/blocks/postsList' 100 + export * as PubLeafletBlocksSignup from './types/pub/leaflet/blocks/signup' 99 101 export * as PubLeafletBlocksStandardSitePost from './types/pub/leaflet/blocks/standardSitePost' 100 102 export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 101 103 export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList'
+16
lexicons/api/lexicons.ts
··· 1590 1590 }, 1591 1591 }, 1592 1592 }, 1593 + PubLeafletBlocksSignup: { 1594 + lexicon: 1, 1595 + id: 'pub.leaflet.blocks.signup', 1596 + defs: { 1597 + main: { 1598 + type: 'object', 1599 + description: 1600 + "A subscribe/signup form for the publication. Renders the publication's subscribe form; carries no configurable data.", 1601 + required: [], 1602 + properties: {}, 1603 + }, 1604 + }, 1605 + }, 1593 1606 PubLeafletBlocksStandardSitePost: { 1594 1607 lexicon: 1, 1595 1608 id: 'pub.leaflet.blocks.standardSitePost', ··· 2001 2014 'lex:pub.leaflet.blocks.poll', 2002 2015 'lex:pub.leaflet.blocks.button', 2003 2016 'lex:pub.leaflet.blocks.postsList', 2017 + 'lex:pub.leaflet.blocks.signup', 2004 2018 ], 2005 2019 }, 2006 2020 x: { ··· 2105 2119 'lex:pub.leaflet.blocks.poll', 2106 2120 'lex:pub.leaflet.blocks.button', 2107 2121 'lex:pub.leaflet.blocks.postsList', 2122 + 'lex:pub.leaflet.blocks.signup', 2108 2123 ], 2109 2124 }, 2110 2125 alignment: { ··· 2981 2996 PubLeafletBlocksPage: 'pub.leaflet.blocks.page', 2982 2997 PubLeafletBlocksPoll: 'pub.leaflet.blocks.poll', 2983 2998 PubLeafletBlocksPostsList: 'pub.leaflet.blocks.postsList', 2999 + PubLeafletBlocksSignup: 'pub.leaflet.blocks.signup', 2984 3000 PubLeafletBlocksStandardSitePost: 'pub.leaflet.blocks.standardSitePost', 2985 3001 PubLeafletBlocksText: 'pub.leaflet.blocks.text', 2986 3002 PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList',
+30
lexicons/api/types/pub/leaflet/blocks/signup.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'pub.leaflet.blocks.signup' 16 + 17 + /** A subscribe/signup form for the publication. Renders the publication's subscribe form; carries no configurable data. */ 18 + export interface Main { 19 + $type?: 'pub.leaflet.blocks.signup' 20 + } 21 + 22 + const hashMain = 'main' 23 + 24 + export function isMain<V>(v: V) { 25 + return is$typed(v, id, hashMain) 26 + } 27 + 28 + export function validateMain<V>(v: V) { 29 + return validate<Main & V>(v, id, hashMain) 30 + }
+2
lexicons/api/types/pub/leaflet/pages/canvas.ts
··· 26 26 import type * as PubLeafletBlocksPoll from '../blocks/poll' 27 27 import type * as PubLeafletBlocksButton from '../blocks/button' 28 28 import type * as PubLeafletBlocksPostsList from '../blocks/postsList' 29 + import type * as PubLeafletBlocksSignup from '../blocks/signup' 29 30 30 31 const is$typed = _is$typed, 31 32 validate = _validate ··· 67 68 | $Typed<PubLeafletBlocksPoll.Main> 68 69 | $Typed<PubLeafletBlocksButton.Main> 69 70 | $Typed<PubLeafletBlocksPostsList.Main> 71 + | $Typed<PubLeafletBlocksSignup.Main> 70 72 | { $type: string } 71 73 x: number 72 74 y: number
+2
lexicons/api/types/pub/leaflet/pages/linearDocument.ts
··· 26 26 import type * as PubLeafletBlocksPoll from '../blocks/poll' 27 27 import type * as PubLeafletBlocksButton from '../blocks/button' 28 28 import type * as PubLeafletBlocksPostsList from '../blocks/postsList' 29 + import type * as PubLeafletBlocksSignup from '../blocks/signup' 29 30 30 31 const is$typed = _is$typed, 31 32 validate = _validate ··· 67 68 | $Typed<PubLeafletBlocksPoll.Main> 68 69 | $Typed<PubLeafletBlocksButton.Main> 69 70 | $Typed<PubLeafletBlocksPostsList.Main> 71 + | $Typed<PubLeafletBlocksSignup.Main> 70 72 | { $type: string } 71 73 alignment?: 72 74 | 'lex:pub.leaflet.pages.linearDocument#textAlignLeft'
+12
lexicons/pub/leaflet/blocks/signup.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.blocks.signup", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "A subscribe/signup form for the publication. Renders the publication's subscribe form; carries no configurable data.", 8 + "required": [], 9 + "properties": {} 10 + } 11 + } 12 + }
+2 -1
lexicons/pub/leaflet/pages/canvas.json
··· 48 48 "pub.leaflet.blocks.page", 49 49 "pub.leaflet.blocks.poll", 50 50 "pub.leaflet.blocks.button", 51 - "pub.leaflet.blocks.postsList" 51 + "pub.leaflet.blocks.postsList", 52 + "pub.leaflet.blocks.signup" 52 53 ] 53 54 }, 54 55 "x": {
+2 -1
lexicons/pub/leaflet/pages/linearDocument.json
··· 45 45 "pub.leaflet.blocks.page", 46 46 "pub.leaflet.blocks.poll", 47 47 "pub.leaflet.blocks.button", 48 - "pub.leaflet.blocks.postsList" 48 + "pub.leaflet.blocks.postsList", 49 + "pub.leaflet.blocks.signup" 49 50 ] 50 51 }, 51 52 "alignment": {
+15
lexicons/src/blocks.ts
··· 382 382 }, 383 383 }; 384 384 385 + export const PubLeafletBlocksSignup: LexiconDoc = { 386 + lexicon: 1, 387 + id: "pub.leaflet.blocks.signup", 388 + defs: { 389 + main: { 390 + type: "object", 391 + description: 392 + "A subscribe/signup form for the publication. Renders the publication's subscribe form; carries no configurable data.", 393 + required: [], 394 + properties: {}, 395 + }, 396 + }, 397 + }; 398 + 385 399 export const BlockLexicons = [ 386 400 PubLeafletBlocksIFrame, 387 401 PubLeafletBlocksText, ··· 400 414 PubLeafletBlocksPoll, 401 415 PubLeafletBlocksButton, 402 416 PubLeafletBlocksPostsList, 417 + PubLeafletBlocksSignup, 403 418 ]; 404 419 export const BlockUnion: LexRefUnion = { 405 420 type: "union",
+52
lexicons/src/normalize.ts
··· 19 19 import type * as SiteStandardDocument from "../api/types/site/standard/document"; 20 20 import type * as SiteStandardPublication from "../api/types/site/standard/publication"; 21 21 import type * as SiteStandardThemeBasic from "../api/types/site/standard/theme/basic"; 22 + import type * as SiteStandardThemeColor from "../api/types/site/standard/theme/color"; 22 23 import type * as PubLeafletThemeColor from "../api/types/pub/leaflet/theme/color"; 23 24 import type { $Typed } from "../api/util"; 24 25 import { AtUri } from "@atproto/syntax"; ··· 118 119 ) { 119 120 return { r: c.r, g: c.g, b: c.b }; 120 121 } 122 + return undefined; 123 + } 124 + 125 + /** 126 + * Converts a site.standard.theme.basic into a partial pub.leaflet theme, 127 + * tagging colors with the pub.leaflet $type so PublicationThemeProvider can 128 + * consume them. Used as a fallback when a publication record carries 129 + * basicTheme but no full theme. 130 + */ 131 + export function basicThemeToLeafletTheme( 132 + basic: SiteStandardThemeBasic.Main | undefined | null, 133 + ): $Typed<PubLeafletPublication.Theme> | undefined { 134 + if (!basic) return undefined; 135 + const toLeafletColor = ( 136 + c: $Typed<SiteStandardThemeColor.Rgb> | { $type: string }, 137 + ): $Typed<PubLeafletThemeColor.Rgb> | undefined => { 138 + const rgb = extractRgb(c); 139 + if (!rgb) return undefined; 140 + return { $type: "pub.leaflet.theme.color#rgb", ...rgb }; 141 + }; 142 + const backgroundColor = toLeafletColor(basic.background); 143 + const primary = toLeafletColor(basic.foreground); 144 + const accentBackground = toLeafletColor(basic.accent); 145 + const accentText = toLeafletColor(basic.accentForeground); 146 + if (!backgroundColor) return undefined; 147 + return { 148 + $type: "pub.leaflet.publication#theme", 149 + backgroundColor, 150 + primary, 151 + accentBackground, 152 + accentText, 153 + showPageBackground: false, 154 + }; 155 + } 156 + 157 + /** 158 + * Returns the effective pub.leaflet theme for a publication record, preferring 159 + * the full theme when present and falling back to basicTheme otherwise. 160 + */ 161 + export function resolvePublicationTheme( 162 + record: 163 + | { 164 + theme?: PubLeafletPublication.Theme | null; 165 + basicTheme?: SiteStandardThemeBasic.Main | null; 166 + } 167 + | null 168 + | undefined, 169 + ): PubLeafletPublication.Theme | undefined { 170 + if (!record) return undefined; 171 + if (record.theme) return record.theme; 172 + if (record.basicTheme) return basicThemeToLeafletTheme(record.basicTheme); 121 173 return undefined; 122 174 } 123 175
+1 -1
package.json
··· 5 5 "main": "index.js", 6 6 "scripts": { 7 7 "lint": "next lint", 8 - "dev": "TZ=UTC next dev --turbo", 8 + "dev": "TZ=UTC next dev --turbo -H 127.0.0.1", 9 9 "publish-lexicons": "tsx lexicons/publish.ts", 10 10 "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta", 11 11 "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/comment.json ./lexicons/pub/leaflet/publication.json ./lexicons/pub/leaflet/publicationPage.json ./lexicons/pub/leaflet/content.json ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* ./lexicons/site/*/* ./lexicons/site/*/*/* ./lexicons/parts/*/* ./lexicons/parts/*/*/* --yes && tsx ./lexicons/fix-extensions.ts ./lexicons/api",
public/about/landing-analytics.webp

This is a binary file and will not be displayed.

public/about/landing-drafts.webp

This is a binary file and will not be displayed.

public/about/landing-posts.webp

This is a binary file and will not be displayed.

public/about/landing-settings.webp

This is a binary file and will not be displayed.

public/about/mobile-landing-analytics.webp

This is a binary file and will not be displayed.

public/about/mobile-landing-drafts.webp

This is a binary file and will not be displayed.

public/about/mobile-landing-posts.webp

This is a binary file and will not be displayed.

public/about/mobile-landing-settings.webp

This is a binary file and will not be displayed.

+54 -3
src/atproto-oauth.ts
··· 12 12 import Client from "ioredis"; 13 13 import Redlock from "redlock"; 14 14 import { Result, Ok, Err } from "./result"; 15 - export async function createOauthClient() { 15 + 16 + // Module-scoped singleton: NodeOAuthClient, ioredis connection, and Redlock 17 + // have no per-request state — keys/stores live above the user — so building 18 + // them once per Node instance avoids reconnect + keyset re-import on every call. 19 + // Stashed on globalThis so Next.js dev hot-reload doesn't leak Redis sockets. 20 + const globalForOauth = globalThis as unknown as { 21 + __oauthClient?: Promise<NodeOAuthClient>; 22 + }; 23 + 24 + export function createOauthClient(): Promise<NodeOAuthClient> { 25 + if (!globalForOauth.__oauthClient) { 26 + globalForOauth.__oauthClient = buildOauthClient(); 27 + } 28 + return globalForOauth.__oauthClient; 29 + } 30 + 31 + async function buildOauthClient(): Promise<NodeOAuthClient> { 16 32 let keyset = 17 33 process.env.NODE_ENV === "production" 18 34 ? await Promise.all([ ··· 99 115 did: string; 100 116 }; 101 117 118 + // In-process dedupe: collapse concurrent restore() calls for the same DID into 119 + // one underlying restore + Redlock acquisition. Successful entries linger 120 + // briefly so a burst of requests (e.g. hover-fired ProfilePopovers) share one 121 + // result; rejected promises evict immediately so a transient failure doesn't 122 + // stick around poisoning subsequent calls. 123 + const RESTORE_DEDUPE_TTL_MS = 5_000; 124 + const inFlightRestores = new Map<string, Promise<OAuthSession>>(); 125 + 126 + function dedupedRestore(did: string): Promise<OAuthSession> { 127 + let existing = inFlightRestores.get(did); 128 + if (existing) return existing; 129 + 130 + const promise = (async () => { 131 + const oauthClient = await createOauthClient(); 132 + return oauthClient.restore(did); 133 + })(); 134 + inFlightRestores.set(did, promise); 135 + 136 + promise.then( 137 + () => { 138 + setTimeout(() => { 139 + if (inFlightRestores.get(did) === promise) { 140 + inFlightRestores.delete(did); 141 + } 142 + }, RESTORE_DEDUPE_TTL_MS); 143 + }, 144 + () => { 145 + if (inFlightRestores.get(did) === promise) { 146 + inFlightRestores.delete(did); 147 + } 148 + }, 149 + ); 150 + 151 + return promise; 152 + } 153 + 102 154 export async function restoreOAuthSession( 103 155 did: string 104 156 ): Promise<Result<OAuthSession, OAuthSessionError>> { 105 157 try { 106 - const oauthClient = await createOauthClient(); 107 - const session = await oauthClient.restore(did); 158 + const session = await dedupedRestore(did); 108 159 return Ok(session); 109 160 } catch (error) { 110 161 return Err({
+2
src/identity/index.ts
··· 1 + export { idResolver } from "./idResolver"; 2 + export { getProfiles, type Profile } from "./profileCache";
+129
src/identity/profileCache.ts
··· 1 + import { cache } from "react"; 2 + import Client from "ioredis"; 3 + import { getAgent } from "app/api/bsky/agent"; 4 + 5 + export type Profile = { 6 + did: string; 7 + handle: string | null; 8 + displayName: string | null; 9 + avatar: string | null; 10 + description: string | null; 11 + }; 12 + 13 + const KEY_PREFIX = "bsky-profile:"; 14 + // 30d TTL — the firehose keeps entries fresh; TTL is just GC. 15 + const MAX_TTL = 60 * 60 * 24 * 30; 16 + const BATCH_SIZE = 25; 17 + 18 + let redisClient: Client | null = null; 19 + if (process.env.REDIS_URL && process.env.NODE_ENV === "production") { 20 + redisClient = new Client(process.env.REDIS_URL); 21 + } 22 + 23 + type CachedEntry = { profile: Profile | null; updatedAt: number }; 24 + 25 + function profileKey(did: string) { 26 + return `${KEY_PREFIX}${did}`; 27 + } 28 + 29 + async function readCache(dids: string[]): Promise<Map<string, Profile | null>> { 30 + const out = new Map<string, Profile | null>(); 31 + if (!redisClient || dids.length === 0) return out; 32 + 33 + const keys = dids.map(profileKey); 34 + const raw = await redisClient.mget(...keys); 35 + raw.forEach((value, i) => { 36 + if (!value) return; 37 + try { 38 + const entry = JSON.parse(value) as CachedEntry; 39 + const age = Date.now() - entry.updatedAt; 40 + if (age <= MAX_TTL * 1000) { 41 + out.set(dids[i], entry.profile); 42 + } 43 + } catch { 44 + // skip malformed entry 45 + } 46 + }); 47 + return out; 48 + } 49 + 50 + async function writeCache(entries: Map<string, Profile | null>): Promise<void> { 51 + if (!redisClient || entries.size === 0) return; 52 + 53 + const pipeline = redisClient.pipeline(); 54 + const now = Date.now(); 55 + for (const [did, profile] of entries) { 56 + const entry: CachedEntry = { profile, updatedAt: now }; 57 + pipeline.setex(profileKey(did), MAX_TTL, JSON.stringify(entry)); 58 + } 59 + await pipeline.exec(); 60 + } 61 + 62 + type FetchResult = { 63 + results: Map<string, Profile | null>; 64 + toCache: Map<string, Profile | null>; 65 + }; 66 + 67 + async function fetchProfiles(dids: string[]): Promise<FetchResult> { 68 + const results = new Map<string, Profile | null>(); 69 + const toCache = new Map<string, Profile | null>(); 70 + for (const did of dids) results.set(did, null); 71 + if (dids.length === 0) return { results, toCache }; 72 + 73 + const agent = await getAgent(); 74 + const batches: string[][] = []; 75 + for (let i = 0; i < dids.length; i += BATCH_SIZE) { 76 + batches.push(dids.slice(i, i + BATCH_SIZE)); 77 + } 78 + 79 + await Promise.all( 80 + batches.map(async (batch) => { 81 + try { 82 + const res = await agent.app.bsky.actor.getProfiles({ actors: batch }); 83 + const returned = new Set<string>(); 84 + for (const p of res.data.profiles) { 85 + const profile: Profile = { 86 + did: p.did, 87 + handle: p.handle ?? null, 88 + displayName: p.displayName ?? null, 89 + avatar: p.avatar ?? null, 90 + description: p.description ?? null, 91 + }; 92 + results.set(p.did, profile); 93 + toCache.set(p.did, profile); 94 + returned.add(p.did); 95 + } 96 + // DIDs the API confirmed don't exist — safe to negative-cache. 97 + for (const did of batch) { 98 + if (!returned.has(did)) toCache.set(did, null); 99 + } 100 + } catch (err) { 101 + // Transient failure — leave results as null but don't write to 102 + // cache, so the next request retries instead of getting a stale miss. 103 + console.error("[profileCache] getProfiles failed:", err); 104 + } 105 + }), 106 + ); 107 + 108 + return { results, toCache }; 109 + } 110 + 111 + export const getProfiles = cache( 112 + async (dids: string[]): Promise<Map<string, Profile | null>> => { 113 + const unique = Array.from(new Set(dids)); 114 + const result = new Map<string, Profile | null>(); 115 + if (unique.length === 0) return result; 116 + 117 + const cached = await readCache(unique); 118 + for (const [did, profile] of cached) result.set(did, profile); 119 + 120 + const missing = unique.filter((did) => !cached.has(did)); 121 + if (missing.length === 0) return result; 122 + 123 + const { results: fetched, toCache } = await fetchProfiles(missing); 124 + for (const [did, profile] of fetched) result.set(did, profile); 125 + 126 + await writeCache(toCache); 127 + return result; 128 + }, 129 + );
+98 -33
src/notifications.ts
··· 3 3 import { supabaseServerClient } from "supabase/serverClient"; 4 4 import { Tables, TablesInsert } from "supabase/database.types"; 5 5 import { AtUri } from "@atproto/syntax"; 6 - import { idResolver } from "app/(app)/(home-pages)/reader/idResolver"; 6 + import { idResolver, getProfiles, type Profile } from "src/identity"; 7 7 import { 8 8 normalizeDocumentRecord, 9 9 normalizePublicationRecord, 10 - type NormalizedDocument, 11 - type NormalizedPublication, 12 10 } from "src/utils/normalizeRecords"; 13 11 14 12 type NotificationRow = Tables<"notifications">; ··· 16 14 export type Notification = Omit<TablesInsert<"notifications">, "data"> & { 17 15 data: NotificationData; 18 16 }; 17 + 18 + export type NotificationProfile = Pick< 19 + Profile, 20 + "did" | "handle" | "displayName" | "avatar" 21 + >; 22 + 23 + function toNotificationProfile( 24 + p: Profile | null | undefined, 25 + ): NotificationProfile | null { 26 + if (!p) return null; 27 + return { 28 + did: p.did, 29 + handle: p.handle, 30 + displayName: p.displayName, 31 + avatar: p.avatar, 32 + }; 33 + } 19 34 20 35 export type NotificationData = 21 36 | { type: "comment"; comment_uri: string; parent_uri?: string } ··· 93 108 const { data: comments } = await supabaseServerClient 94 109 .from("comments_on_documents") 95 110 .select( 96 - "*,bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", 111 + "*, documents(*, documents_in_publications(publications(*)))", 97 112 ) 98 113 .in("uri", commentUris); 99 114 115 + const commenterDids = Array.from( 116 + new Set((comments ?? []).map((c) => new AtUri(c.uri).host)), 117 + ); 118 + const profiles = await getProfiles(commenterDids); 119 + 120 + type CommentRow = NonNullable<typeof comments>[number]; 121 + const attachProfile = (c: CommentRow) => ({ 122 + ...c, 123 + profile: toNotificationProfile(profiles.get(new AtUri(c.uri).host)), 124 + }); 125 + 100 126 return commentNotifications 101 127 .map((notification) => { 102 - const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); 103 - if (!commentData) return null; 128 + const commentRow = comments?.find((c) => c.uri === notification.data.comment_uri); 129 + if (!commentRow) return null; 130 + const commentData = attachProfile(commentRow); 131 + const parentRow = notification.data.parent_uri 132 + ? comments?.find((c) => c.uri === notification.data.parent_uri) 133 + : undefined; 104 134 return { 105 135 id: notification.id, 106 136 recipient: notification.recipient, 107 137 created_at: notification.created_at, 108 138 type: "comment" as const, 109 139 comment_uri: notification.data.comment_uri, 110 - parentData: notification.data.parent_uri 111 - ? comments?.find((c) => c.uri === notification.data.parent_uri) 112 - : undefined, 140 + parentData: parentRow ? attachProfile(parentRow) : undefined, 113 141 commentData, 114 142 normalizedDocument: normalizeDocumentRecord(commentData.documents?.data, commentData.documents?.uri), 115 143 normalizedPublication: normalizePublicationRecord( ··· 142 170 ); 143 171 const { data: subscriptions } = await supabaseServerClient 144 172 .from("publication_subscriptions") 145 - .select("*, identities(bsky_profiles(*)), publications(*)") 173 + .select("*, identities(atp_did), publications(*)") 146 174 .in("uri", subscriptionUris); 147 175 176 + const subscriberDids = Array.from( 177 + new Set( 178 + (subscriptions ?? []) 179 + .map((s) => s.identities?.atp_did) 180 + .filter((d): d is string => !!d), 181 + ), 182 + ); 183 + const profiles = await getProfiles(subscriberDids); 184 + 148 185 return subscribeNotifications 149 186 .map((notification) => { 150 187 const subscriptionData = subscriptions?.find((s) => s.uri === notification.data.subscription_uri); 151 188 if (!subscriptionData) return null; 189 + const subscriberDid = subscriptionData.identities?.atp_did ?? null; 152 190 return { 153 191 id: notification.id, 154 192 recipient: notification.recipient, 155 193 created_at: notification.created_at, 156 194 type: "subscribe" as const, 157 195 subscription_uri: notification.data.subscription_uri, 158 - subscriptionData, 196 + subscriptionData: { 197 + ...subscriptionData, 198 + profile: subscriberDid 199 + ? toNotificationProfile(profiles.get(subscriberDid)) 200 + : null, 201 + }, 159 202 normalizedPublication: normalizePublicationRecord(subscriptionData.publications?.record), 160 203 }; 161 204 }) ··· 436 479 const { data: comments } = await supabaseServerClient 437 480 .from("comments_on_documents") 438 481 .select( 439 - "*, bsky_profiles(*), documents(*, documents_in_publications(publications(*)))", 482 + "*, documents(*, documents_in_publications(publications(*)))", 440 483 ) 441 484 .in("uri", commentUris); 442 485 443 486 // Extract unique DIDs from comment URIs to resolve handles 444 487 const commenterDids = [...new Set(commentUris.map((uri) => new AtUri(uri).host))]; 445 488 446 - // Resolve DIDs to handles in parallel 489 + // Resolve DIDs to handles in parallel + batch profile fetch 447 490 const didToHandleMap = new Map<string, string | null>(); 448 - await Promise.all( 449 - commenterDids.map(async (did) => { 450 - try { 451 - const resolved = await idResolver.did.resolve(did); 452 - const handle = resolved?.alsoKnownAs?.[0] 453 - ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 454 - : null; 455 - didToHandleMap.set(did, handle); 456 - } catch (error) { 457 - console.error(`Failed to resolve DID ${did}:`, error); 458 - didToHandleMap.set(did, null); 459 - } 460 - }), 461 - ); 491 + const [profiles] = await Promise.all([ 492 + getProfiles(commenterDids), 493 + Promise.all( 494 + commenterDids.map(async (did) => { 495 + try { 496 + const resolved = await idResolver.did.resolve(did); 497 + const handle = resolved?.alsoKnownAs?.[0] 498 + ? resolved.alsoKnownAs[0].slice(5) // Remove "at://" prefix 499 + : null; 500 + didToHandleMap.set(did, handle); 501 + } catch (error) { 502 + console.error(`Failed to resolve DID ${did}:`, error); 503 + didToHandleMap.set(did, null); 504 + } 505 + }), 506 + ), 507 + ]); 462 508 463 509 // Fetch mentioned publications and documents 464 510 const mentionedPublicationUris = commentMentionNotifications ··· 486 532 487 533 return commentMentionNotifications 488 534 .map((notification) => { 489 - const commentData = comments?.find((c) => c.uri === notification.data.comment_uri); 490 - if (!commentData) return null; 535 + const commentRow = comments?.find((c) => c.uri === notification.data.comment_uri); 536 + if (!commentRow) return null; 537 + const commenterDid = new AtUri(commentRow.uri).host; 538 + const commentData = { 539 + ...commentRow, 540 + profile: toNotificationProfile(profiles.get(commenterDid)), 541 + }; 491 542 492 543 const mentionedUri = notification.data.mention_type !== "did" 493 544 ? (notification.data as Extract<ExtractNotificationType<"comment_mention">, { mentioned_uri: string }>).mentioned_uri 494 545 : undefined; 495 546 496 - const commenterDid = new AtUri(notification.data.comment_uri).host; 497 547 const commenterHandle = didToHandleMap.get(commenterDid) ?? null; 498 548 499 549 const mentionedPublication = mentionedUri ? mentionedPublications?.find((p) => p.uri === mentionedUri) : undefined; ··· 543 593 const [{ data: recommends }, { data: documents }] = await Promise.all([ 544 594 supabaseServerClient 545 595 .from("recommends_on_documents") 546 - .select("*, identities(bsky_profiles(*))") 596 + .select("*") 547 597 .in("uri", recommendUris), 548 598 supabaseServerClient 549 599 .from("documents") ··· 551 601 .in("uri", documentUris), 552 602 ]); 553 603 604 + const recommenderDids = Array.from( 605 + new Set( 606 + (recommends ?? []) 607 + .map((r) => r.recommender_did) 608 + .filter((d): d is string => !!d), 609 + ), 610 + ); 611 + const profiles = await getProfiles(recommenderDids); 612 + 554 613 return recommendNotifications 555 614 .map((notification) => { 556 - const recommendData = recommends?.find((r) => r.uri === notification.data.recommend_uri); 615 + const recommendRow = recommends?.find((r) => r.uri === notification.data.recommend_uri); 557 616 const document = documents?.find((d) => d.uri === notification.data.document_uri); 558 - if (!recommendData || !document) return null; 617 + if (!recommendRow || !document) return null; 618 + const recommendData = { 619 + ...recommendRow, 620 + profile: recommendRow.recommender_did 621 + ? toNotificationProfile(profiles.get(recommendRow.recommender_did)) 622 + : null, 623 + }; 559 624 return { 560 625 id: notification.id, 561 626 recipient: notification.recipient,
+2 -1
src/replicache/attributes.ts
··· 403 403 | "code" 404 404 | "blockquote" 405 405 | "horizontal-rule" 406 - | "posts-list"; 406 + | "posts-list" 407 + | "signup"; 407 408 }; 408 409 "canvas-pattern-union": { 409 410 type: "canvas-pattern-union";
+7
src/utils/factsToPagesRecord.ts
··· 18 18 PubLeafletBlocksPage, 19 19 PubLeafletBlocksPoll, 20 20 PubLeafletBlocksPostsList, 21 + PubLeafletBlocksSignup, 21 22 PubLeafletBlocksText, 22 23 PubLeafletBlocksUnorderedList, 23 24 PubLeafletBlocksWebsite, ··· 566 567 ...(viewFact && { view: viewFact.data.value }), 567 568 ...(highlightFact && { highlightFirstPost: highlightFact.data.value }), 568 569 ...(filterTagFact && { filterByTag: filterTagFact.data.value }), 570 + }; 571 + return block; 572 + } 573 + if (b.type === "signup") { 574 + const block: $Typed<PubLeafletBlocksSignup.Main> = { 575 + $type: ids.PubLeafletBlocksSignup, 569 576 }; 570 577 return block; 571 578 }
+1
src/utils/getBlocksAsHTML.tsx
··· 88 88 mailbox: async () => null, 89 89 poll: async () => null, 90 90 embed: async () => null, 91 + signup: async () => null, 91 92 "bluesky-post": async (b, tx) => { 92 93 let [post] = await scanIndex(tx).eav(b.value, "block/bluesky-post"); 93 94 if (!post) return null;
+85
src/utils/publicationPageDiff.ts
··· 1 + import { BlobRef } from "@atproto/lexicon"; 2 + import type { PubLeafletPublicationPage } from "lexicons/api"; 3 + import type { ProcessBlocksToPagesHooks } from "src/utils/factsToPagesRecord"; 4 + 5 + // The published record stores BlobRef CIDs for images and at-uri/cid for polls, 6 + // neither of which we can recompute client-side without uploading. For the 7 + // dirty check we generate the would-be-record using the placeholder hooks 8 + // below, then strip both records of those opaque fields before comparing. 9 + 10 + export const dirtyCheckHooks: ProcessBlocksToPagesHooks = { 11 + uploadImage: async (src) => 12 + ({ 13 + ref: { $link: src }, 14 + mimeType: "image/*", 15 + size: 0, 16 + }) as unknown as BlobRef, 17 + uploadPoll: async (entityId) => ({ 18 + uri: `at://dirty-check/${entityId}`, 19 + cid: "dirty-check", 20 + }), 21 + }; 22 + 23 + function stripVolatile(value: unknown): unknown { 24 + if (Array.isArray(value)) return value.map(stripVolatile); 25 + if (value && typeof value === "object") { 26 + const obj = value as Record<string, unknown>; 27 + const type = obj["$type"]; 28 + if (type === "pub.leaflet.blocks.image" || type === "pub.leaflet.blocks.website") { 29 + const { image: _image, previewImage: _previewImage, ...rest } = obj as { 30 + image?: unknown; 31 + previewImage?: unknown; 32 + } & Record<string, unknown>; 33 + return Object.fromEntries( 34 + Object.entries(rest).map(([k, v]) => [k, stripVolatile(v)]), 35 + ); 36 + } 37 + if (type === "pub.leaflet.blocks.poll") { 38 + const { pollRef: _pollRef, ...rest } = obj as { 39 + pollRef?: unknown; 40 + } & Record<string, unknown>; 41 + return Object.fromEntries( 42 + Object.entries(rest).map(([k, v]) => [k, stripVolatile(v)]), 43 + ); 44 + } 45 + return Object.fromEntries( 46 + Object.entries(obj).map(([k, v]) => [k, stripVolatile(v)]), 47 + ); 48 + } 49 + return value; 50 + } 51 + 52 + // `publishedAt` is set to "now" on every publish, so a freshly generated 53 + // record will always differ from the stored one on that field alone. 54 + export function normalizePageRecordForDiff( 55 + record: PubLeafletPublicationPage.Record | Record<string, unknown> | null | undefined, 56 + ): unknown { 57 + if (!record) return null; 58 + const { publishedAt: _publishedAt, ...rest } = record as Record<string, unknown>; 59 + return stripVolatile(rest); 60 + } 61 + 62 + export function deepEqual(a: unknown, b: unknown): boolean { 63 + if (a === b) return true; 64 + if (typeof a !== typeof b) return false; 65 + if (a === null || b === null) return false; 66 + if (typeof a !== "object") return false; 67 + if (Array.isArray(a) || Array.isArray(b)) { 68 + if (!Array.isArray(a) || !Array.isArray(b)) return false; 69 + if (a.length !== b.length) return false; 70 + for (let i = 0; i < a.length; i++) { 71 + if (!deepEqual(a[i], b[i])) return false; 72 + } 73 + return true; 74 + } 75 + const ao = a as Record<string, unknown>; 76 + const bo = b as Record<string, unknown>; 77 + const ak = Object.keys(ao); 78 + const bk = Object.keys(bo); 79 + if (ak.length !== bk.length) return false; 80 + for (const k of ak) { 81 + if (!Object.prototype.hasOwnProperty.call(bo, k)) return false; 82 + if (!deepEqual(ao[k], bo[k])) return false; 83 + } 84 + return true; 85 + }
+43 -16
src/utils/resolveStandardSitePostUrl.ts
··· 1 1 import type { SupabaseClient } from "@supabase/supabase-js"; 2 2 import type { Database, Json } from "supabase/database.types"; 3 3 import { parseStandardSitePostInput } from "components/Blocks/StandardSitePostBlock/parseStandardSitePostInput"; 4 - import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; 4 + import { 5 + normalizeDocumentRecord, 6 + normalizePublicationRecord, 7 + } from "src/utils/normalizeRecords"; 5 8 6 9 /** 7 10 * Resolve a URL or AT URI to a standard-site-post AT URI by checking our DB. ··· 29 32 return null; 30 33 } 31 34 const origin = `${url.protocol}//${url.host}`; 32 - const pathOnly = url.pathname.replace(/\/$/, "") || "/"; 35 + const requestedPath = url.pathname.replace(/\/$/, "") || "/"; 36 + const requestedUrl = origin + (requestedPath === "/" ? "" : requestedPath); 33 37 34 - // Find a publication whose URL matches this origin. Supports both 38 + // Publications can live at a sub-path (e.g. https://example.com/blog), so we 39 + // can't match the origin exactly. Prefix-match on the host to gather every 40 + // publication that could own this URL, then filter to the one whose url is 41 + // the longest prefix of the requested URL. Supports both 35 42 // pub.leaflet.publication (base_path) and site.standard.publication (url). 36 - // Descending uri order prefers site.standard.publication over pub.leaflet.publication. 37 - const { data: publication } = await supabase 43 + // Descending uri order prefers site.standard.publication over 44 + // pub.leaflet.publication when base paths are equally specific. 45 + const { data: publications } = await supabase 38 46 .from("publications") 39 - .select("uri") 47 + .select("uri, record") 40 48 .or( 41 - `record->>base_path.eq.${url.host},record->>url.eq.${origin},record->>url.eq.${origin}/`, 49 + [ 50 + `record->>base_path.like.${url.host}*`, 51 + `record->>url.like.${origin}*`, 52 + ].join(","), 42 53 ) 43 - .order("uri", { ascending: false }) 44 - .limit(1) 45 - .maybeSingle(); 46 - console.log(publication); 47 - if (!publication) return null; 54 + .order("uri", { ascending: false }); 55 + 56 + if (!publications?.length) return null; 57 + 58 + // Pick the publication whose url is the longest prefix of the requested URL, 59 + // and compute the document path relative to that publication's base path. 60 + let best: { uri: string; path: string; urlLength: number } | null = null; 61 + for (const pub of publications) { 62 + const normalized = normalizePublicationRecord(pub.record); 63 + if (!normalized?.url) continue; 64 + const pubUrl = normalized.url.replace(/\/$/, ""); 65 + if (requestedUrl !== pubUrl && !requestedUrl.startsWith(`${pubUrl}/`)) 66 + continue; 67 + const basePath = new URL(pubUrl).pathname.replace(/\/$/, ""); 68 + const relative = requestedPath.slice(basePath.length) || "/"; 69 + const path = relative.startsWith("/") ? relative : `/${relative}`; 70 + if (!best || pubUrl.length > best.urlLength) 71 + best = { uri: pub.uri, path, urlLength: pubUrl.length }; 72 + } 73 + 74 + if (!best) return null; 48 75 49 - // Find documents in that publication where data.path matches pathOnly 76 + // Find documents in that publication where data.path matches the requested 77 + // path (relative to the publication's base path). 50 78 const { data: docsInPub } = await supabase 51 79 .from("documents_in_publications") 52 80 .select("documents(uri, data)") 53 - .eq("publication", publication.uri); 81 + .eq("publication", best.uri); 54 82 55 83 if (!docsInPub) return null; 56 84 ··· 65 93 : `/${normalized.path}` 66 94 : null; 67 95 const docPath = rawPath ? rawPath.replace(/\/$/, "") || "/" : null; 68 - console.log(docPath, pathOnly); 69 - if (docPath === pathOnly) return doc.uri; 96 + if (docPath === best.path) return doc.uri; 70 97 } 71 98 return null; 72 99 }
+20
src/utils/stripThemeWithoutType.ts
··· 1 + /** 2 + * site.standard.publication records carry an optional `theme` field that the 3 + * lexicon types as a union requiring a `$type` discriminator. Some records in 4 + * the wild have a `theme` object missing `$type`, which makes 5 + * `validateRecord` reject the entire publication — dropping it from the index. 6 + * 7 + * Strip a malformed (no-`$type`) theme so the rest of the record can still be 8 + * validated and indexed. Returns the record unchanged when there's nothing to 9 + * strip, and never mutates the input. 10 + */ 11 + export function stripThemeWithoutType<T>(record: T): T { 12 + if (record && typeof record === "object" && "theme" in record) { 13 + const theme = (record as Record<string, unknown>).theme; 14 + if (theme && typeof theme === "object" && !("$type" in theme)) { 15 + const { theme: _theme, ...rest } = record as Record<string, unknown>; 16 + return rest as T; 17 + } 18 + } 19 + return record; 20 + }
-7
supabase/database.types.ts
··· 200 200 referencedRelation: "documents" 201 201 referencedColumns: ["uri"] 202 202 }, 203 - { 204 - foreignKeyName: "comments_on_documents_profile_fkey" 205 - columns: ["profile"] 206 - isOneToOne: false 207 - referencedRelation: "bsky_profiles" 208 - referencedColumns: ["did"] 209 - }, 210 203 ] 211 204 } 212 205 custom_domain_routes: {
+8
supabase/migrations/20260528000000_drop_comments_profile_fkey.sql
··· 1 + -- comments_on_documents.profile stores the commenter's DID, but it no longer 2 + -- needs to reference a bsky_profiles row: commenter profiles are now resolved 3 + -- through the profile cache (getProfiles), and nothing writes to bsky_profiles 4 + -- anymore. Drop the foreign key so comment inserts don't require a pre-existing 5 + -- bsky_profiles row. The profile column itself stays populated (read by 6 + -- getProfileComments via .eq("profile", did)). 7 + alter table "public"."comments_on_documents" 8 + drop constraint if exists "comments_on_documents_profile_fkey";