diff --git a/.claude/skills/mirror-editor-block-styles/SKILL.md b/.claude/skills/mirror-editor-block-styles/SKILL.md index 3fd5aed7..772d03b0 100644 --- a/.claude/skills/mirror-editor-block-styles/SKILL.md +++ b/.claude/skills/mirror-editor-block-styles/SKILL.md @@ -9,7 +9,7 @@ user-invocable: true Leaflet renders every block **twice**, from two disconnected code paths: 1. **Editor** (`components/Blocks/…`) — the interactive doc you write in. -2. **Published post** (`app/(app)/lish/[did]/[publication]/[rkey]/…`) — the +2. **Published post** (`app/(app)/(published)/lish/[did]/[publication]/[rkey]/…`) — the read-only version served at `leaflet.pub`, rendered from the AT-Protocol record (facets + block fields), not from the editor components. @@ -56,13 +56,13 @@ anchor an editor overlay. If a change has no read-only visual effect, skip it. ### Published (the targets to update) -- **`app/(app)/lish/[did]/[publication]/[rkey]/PostContent.tsx`** — the primary +- **`app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostContent.tsx`** — the primary target: the interactive published post. The `Block` component's `switch` renders each block type (`text` → `
`, `header` → `
`, lists, image, …) and builds the block-wrapper `className` (margins) + inline `style` (font size). **Most mirroring happens here.** -- **`app/(app)/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx`** — the +- **`app/(app)/(published)/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx`** — the static render for **feeds and email**. Minimal styling, no interactivity. Mirror here only when the change matters in a plain static context (and remember email strips a lot of CSS). Often out of scope; call it out rather than silently skip. diff --git a/.claude/skills/notifications/SKILL.md b/.claude/skills/notifications/SKILL.md index 908953f5..10ddb122 100644 --- a/.claude/skills/notifications/SKILL.md +++ b/.claude/skills/notifications/SKILL.md @@ -145,6 +145,6 @@ if (n.type === "your_type") { See the implementation in: - `src/notifications.ts:88-125` - Hydration logic -- `app/lish/subscribeToPublication.ts:55-68` - Trigger +- `actions/publications/subscribeToPublication.ts:55-68` - Trigger - `app/(home-pages)/notifications/FollowNotification.tsx` - Component - `app/(home-pages)/notifications/NotificationList.tsx:40-42` - Rendering diff --git a/.claude/skills/tests-posts/SKILL.md b/.claude/skills/tests-posts/SKILL.md index 03d0d05d..032bbe8d 100644 --- a/.claude/skills/tests-posts/SKILL.md +++ b/.claude/skills/tests-posts/SKILL.md @@ -35,7 +35,7 @@ The page is organized into three sections, each with its own controls row: ## Source components -- `app/(app)/lish/[did]/[publication]/PublicationPostItem` exports +- `app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem` exports `PublicationPostItemSmall`, `PublicationPostItemMedium`, and `PublicationPostItemLarge`. - Icons come from `components/Icons/`: `CommentTiny`, `TagTiny`, `ShareTiny`, @@ -69,7 +69,7 @@ import { PublicationPostItemSmall, PublicationPostItemMedium, PublicationPostItemLarge, -} from "app/(app)/lish/[did]/[publication]/PublicationPostItem"; +} from "app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem"; import { CommentTiny } from "components/Icons/CommentTiny"; import { TagTiny } from "components/Icons/TagTiny"; import { ShareTiny } from "components/Icons/ShareTiny"; diff --git a/actions/admin/entitlements.ts b/actions/admin/entitlements.ts index cca37e4c..1e47f2b8 100644 --- a/actions/admin/entitlements.ts +++ b/actions/admin/entitlements.ts @@ -1,6 +1,6 @@ "use server"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { supabaseServerClient } from "supabase/serverClient"; import { Ok, Err, type Result } from "src/result"; import { isAdminEmail } from "src/adminAllowlist"; @@ -28,7 +28,7 @@ export type AdminUserSearchResult = { }; async function getAdminIdentity() { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !isAdminEmail(identity.email)) return null; return identity; } diff --git a/actions/admin/importSubscribers.ts b/actions/admin/importSubscribers.ts index 2ba0e836..1da2e4b8 100644 --- a/actions/admin/importSubscribers.ts +++ b/actions/admin/importSubscribers.ts @@ -1,6 +1,6 @@ "use server"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { supabaseServerClient } from "supabase/serverClient"; import { Ok, Err, type Result } from "src/result"; import { isAdminEmail } from "src/adminAllowlist"; @@ -41,7 +41,7 @@ export type ImportPreview = { }; async function getAdminIdentity() { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !isAdminEmail(identity.email)) return null; return identity; } diff --git a/actions/createBillingPortalSession.ts b/actions/createBillingPortalSession.ts index 7dea85a1..c955fbbe 100644 --- a/actions/createBillingPortalSession.ts +++ b/actions/createBillingPortalSession.ts @@ -1,6 +1,6 @@ "use server"; -import { getIdentityData } from "./getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { getStripe } from "stripe/client"; import { supabaseServerClient } from "supabase/serverClient"; import { Ok, Err, type Result } from "src/result"; @@ -8,7 +8,7 @@ import { Ok, Err, type Result } from "src/result"; export async function createBillingPortalSession( returnUrl: string, ): Promise> { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity) { return Err("Not authenticated"); } diff --git a/actions/createCheckoutSession.ts b/actions/createCheckoutSession.ts index c153e2ec..20d9287e 100644 --- a/actions/createCheckoutSession.ts +++ b/actions/createCheckoutSession.ts @@ -1,6 +1,6 @@ "use server"; -import { getIdentityData } from "./getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { getStripe } from "stripe/client"; import { supabaseServerClient } from "supabase/serverClient"; import { getPriceId } from "stripe/products"; @@ -11,7 +11,7 @@ export async function createCheckoutSession( returnUrl?: string, coupon?: string, ): Promise > { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity) { return Err("Not authenticated"); } diff --git a/actions/createPublicationDraft.ts b/actions/createPublicationDraft.ts index 0c97b8e5..73ae0ef0 100644 --- a/actions/createPublicationDraft.ts +++ b/actions/createPublicationDraft.ts @@ -1,11 +1,11 @@ "use server"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { createNewLeaflet } from "./createNewLeaflet"; import { supabaseServerClient } from "supabase/serverClient"; import { isConfirmedContributor } from "src/contributorPermissions"; export async function createPublicationDraft(publication_uri: string) { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !identity.atp_did) return null; let { data: publication } = await supabaseServerClient diff --git a/actions/deleteLeaflet.ts b/actions/deleteLeaflet.ts index 5b3e7393..e056dc1c 100644 --- a/actions/deleteLeaflet.ts +++ b/actions/deleteLeaflet.ts @@ -10,7 +10,7 @@ import { import { eq } from "drizzle-orm"; import { PermissionToken } from "src/replicache"; import { pool } from "supabase/pool"; -import { getIdentityData } from "./getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { supabaseServerClient } from "supabase/serverClient"; import { isConfirmedContributor } from "src/contributorPermissions"; @@ -19,7 +19,7 @@ export async function deleteLeaflet(permission_token: PermissionToken) { const db = drizzle(client); // Get the current user's identity - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); // Check publication and document ownership in one query let { data: tokenData } = await supabaseServerClient @@ -107,7 +107,7 @@ export async function deleteLeaflet(permission_token: PermissionToken) { } export async function archivePost(token: string) { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity) throw new Error("No Identity"); // Archive on homepage @@ -124,7 +124,7 @@ export async function archivePost(token: string) { } export async function unarchivePost(token: string) { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity) throw new Error("No Identity"); // Unarchive on homepage diff --git a/actions/domains/index.ts b/actions/domains/index.ts index f3b7b9bc..dc71eabd 100644 --- a/actions/domains/index.ts +++ b/actions/domains/index.ts @@ -3,7 +3,7 @@ import { Database } from "supabase/database.types"; import { createServerClient } from "@supabase/ssr"; import { getCache } from "@vercel/functions"; import { Vercel } from "@vercel/sdk"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; let supabase = createServerClient ( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, @@ -28,9 +28,15 @@ async function expireDomainCache(domain: string) { } async function assertOwnsDomain(domain: string) { - let identity = await getIdentityData(); - if (!identity || !identity.custom_domains.find((d) => d.domain === domain)) - return null; + let identity = await getAuthIdentity(); + if (!identity) return null; + let { data: owned } = await supabase + .from("custom_domains") + .select("domain") + .eq("identity_id", identity.id) + .eq("domain", domain) + .maybeSingle(); + if (!owned) return null; return identity; } @@ -47,7 +53,7 @@ async function clearAllAssignments(domain: string) { // ============== export async function addDomain(domain: string) { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || (!identity.email && !identity.atp_did)) return {}; if ( domain.includes("leaflet.pub") && @@ -145,9 +151,8 @@ export async function assignDomainToPublication({ domain: string; publication_uri: string; }) { - let identity = await getIdentityData(); + let identity = await assertOwnsDomain(domain); if (!identity || !identity.atp_did) return null; - if (!identity.custom_domains.find((d) => d.domain === domain)) return null; let { data: publication } = await supabase .from("publications") @@ -186,13 +191,17 @@ export async function removeDomainAssignment({ // Remove a single route assignment by ID. export async function removeDomainRoute({ routeId }: { routeId: string }) { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity) return null; - let allRoutes = identity.custom_domains.flatMap( - (d) => d.custom_domain_routes, - ); - let route = allRoutes.find((r) => r.id === routeId); + let { data: domains } = await supabase + .from("custom_domains") + .select("custom_domain_routes(id, domain)") + .eq("identity_id", identity.id); + + let route = (domains || []) + .flatMap((d) => d.custom_domain_routes) + .find((r) => r.id === routeId); if (!route) return null; await supabase.from("custom_domain_routes").delete().eq("id", routeId); diff --git a/actions/getIdentityData.ts b/actions/getIdentityData.ts index c063ce3b..9b741224 100644 --- a/actions/getIdentityData.ts +++ b/actions/getIdentityData.ts @@ -1,18 +1,21 @@ "use server"; -import { cookies } from "next/headers"; import { supabaseServerClient } from "supabase/serverClient"; import { cache } from "react"; import { deduplicateByUri } from "src/utils/deduplicateRecords"; -import { getProfiles, type Profile } from "src/identity"; +import { getProfiles } from "src/identity"; import { AtUri } from "@atproto/syntax"; import { TID } from "@atproto/common"; +import { + bskyProfileFromCache, + ENTITLEMENT_EMBEDS, + getValidAuthToken, + keyEntitlements, + SUBSCRIPTION_STATE_EMBEDS, +} from "src/identityPayload"; export const getIdentityData = cache(uncachedGetIdentityData); async function uncachedGetIdentityData() { - let cookieStore = await cookies(); - let auth_token = - cookieStore.get("auth_token")?.value || - cookieStore.get("external_auth_token")?.value; + let auth_token = await getValidAuthToken(); let auth_res = auth_token ? await supabaseServerClient .from("email_auth_tokens") @@ -20,14 +23,8 @@ async function uncachedGetIdentityData() { `*, identities( *, - notifications(count), - publication_subscriptions(*), - publication_email_subscribers(publication, state), - publication_memberships(publication, tier, status, current_period_end, cancel_at_period_end), + ${SUBSCRIPTION_STATE_EMBEDS}, custom_domains!custom_domains_identity_id_fkey(publication_domains(*, publications(name)), custom_domain_routes(*), *), - home_leaflet:permission_tokens!identities_home_page_fkey(*, permission_token_rights(*, - entity_sets(entities(facts(*))) - )), permission_token_on_homepage( archived, created_at, @@ -37,21 +34,19 @@ async function uncachedGetIdentityData() { title, description, permission_token_rights(*), - leaflets_to_documents(*, documents(*)), - leaflets_in_publications(*, publications(*), documents(*)) + leaflets_to_documents(*, documents(uri, indexed_at, data)), + leaflets_in_publications(*, documents(uri, indexed_at, data), publications(uri, record)) ) ), - user_subscriptions(plan, status, current_period_end), - stripe_connected_accounts(stripe_account_id, charges_enabled, payouts_enabled, details_submitted), - user_entitlements(entitlement_key, granted_at, expires_at, source, metadata), + ${ENTITLEMENT_EMBEDS}, publications!publications_identity_did_fkey(*), leaflet_contributors!leaflet_contributors_contributor_did_fkey( created_at, permission_tokens!leaflet_contributors_leaflet_fkey!inner( id, root_entity, title, description, permission_token_rights(*), - leaflets_to_documents(*, documents(*)), - leaflets_in_publications(*, publications(*), documents(*)) + leaflets_to_documents(*, documents(uri, indexed_at, data)), + leaflets_in_publications(*, documents(uri, indexed_at, data), publications(uri, record)) ) ), publication_contributors!publication_contributors_contributor_did_fkey( @@ -68,42 +63,23 @@ async function uncachedGetIdentityData() { : null; if (!auth_res?.data?.identities) return null; - // Transform embedded entitlements into a keyed record, filtering expired - const now = new Date().toISOString(); - const entitlements: Record< - string, - { - granted_at: string; - expires_at: string | null; - source: string | null; - metadata: unknown; - } - > = {}; - for (const row of auth_res.data.identities.user_entitlements || []) { - if (row.expires_at && row.expires_at < now) continue; - entitlements[row.entitlement_key] = { - granted_at: row.granted_at, - expires_at: row.expires_at, - source: row.source, - metadata: row.metadata, - }; - } - - const subscription = auth_res.data.identities.user_subscriptions ?? null; - const connectedAccount = - auth_res.data.identities.stripe_connected_accounts ?? null; - // Pull the embedded raw rows off the identity. Spreading `identity` below // must not leak these raw embeds as extra top-level keys (the public return // shape exposes them only as the processed `publications`, - // `contributor_publications`, and `contributor_leaflets`). + // `contributor_publications`, `contributor_leaflets`, `entitlements`, + // `subscription`, and `connectedAccount`). const { publications: rawPublications, leaflet_contributors: contributorLeafletRows, publication_contributors: contributorPubRows, + user_entitlements: entitlementRows, + user_subscriptions: subscription, + stripe_connected_accounts: connectedAccount, ...identity } = auth_res.data.identities; + const entitlements = keyEntitlements(entitlementRows); + const atp_did = identity.atp_did; if (atp_did) { // Publications, leaflet_contributors, and publication_contributors are @@ -136,8 +112,8 @@ async function uncachedGetIdentityData() { contributor_publications, contributor_leaflets, entitlements, - subscription, - connectedAccount, + subscription: subscription ?? null, + connectedAccount: connectedAccount ?? null, }; } @@ -148,26 +124,8 @@ async function uncachedGetIdentityData() { contributor_publications: [], contributor_leaflets: [], entitlements, - subscription, - connectedAccount, - }; -} - -// Reshape a cached profile into the legacy `bsky_profiles` row shape that -// consumers (SubscribeButton, PubPreview, PostPreview, …) read off the -// identity, so swapping the table join for the cache stays transparent. -function bskyProfileFromCache(profile: Profile | null) { - if (!profile) return null; - return { - did: profile.did, - handle: profile.handle, - record: { - did: profile.did, - handle: profile.handle, - displayName: profile.displayName ?? undefined, - avatar: profile.avatar ?? undefined, - description: profile.description ?? undefined, - }, + subscription: subscription ?? null, + connectedAccount: connectedAccount ?? null, }; } diff --git a/app/(app)/(home-pages)/(writer)/notifications/getNotifications.ts b/actions/getNotifications.ts similarity index 84% rename from app/(app)/(home-pages)/(writer)/notifications/getNotifications.ts rename to actions/getNotifications.ts index 27d2fb48..09e8b646 100644 --- a/app/(app)/(home-pages)/(writer)/notifications/getNotifications.ts +++ b/actions/getNotifications.ts @@ -1,10 +1,10 @@ "use server"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { hydrateNotifications } from "src/notifications"; import { supabaseServerClient } from "supabase/serverClient"; export async function getNotifications(limit?: number) { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity?.atp_did) return []; let query = supabaseServerClient .from("notifications") @@ -18,7 +18,7 @@ export async function getNotifications(limit?: number) { } export async function markAsRead() { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity?.atp_did) return []; await supabaseServerClient .from("notifications") diff --git a/actions/getPostEditLink.ts b/actions/getPostEditLink.ts new file mode 100644 index 00000000..9ff03766 --- /dev/null +++ b/actions/getPostEditLink.ts @@ -0,0 +1,33 @@ +"use server"; + +import { supabaseServerClient } from "supabase/serverClient"; +import { getViewerIdentity } from "actions/viewerIdentity"; + +// The editing token for a published post, handed out only to the post's owner. +// +// leaflets_in_publications.leaflet is a permission_tokens id, and /[leaflet_id] +// redeems it as a bearer capability with no identity check of its own — so it +// must never ride along in a published page's payload, which is one CDN entry +// shared by every reader. The owner's edit affordance fetches it here instead, +// after this function re-derives ownership server-side. The uri argument is a +// lookup key, not a claim. +export async function getPostEditLink(uri: string): Promise { + const identity = await getViewerIdentity(); + if (!identity?.atp_did) return null; + + const { data: document } = await supabaseServerClient + .from("documents") + .select( + `uri, + leaflets_in_publications(leaflet), + documents_in_publications(publications(identity_did))`, + ) + .eq("uri", uri) + .order("publication", { referencedTable: "documents_in_publications" }) + .maybeSingle(); + + const pub = document?.documents_in_publications[0]?.publications; + if (!pub || pub.identity_did !== identity.atp_did) return null; + + return document?.leaflets_in_publications[0]?.leaflet ?? null; +} diff --git a/actions/getUnlockedPost.ts b/actions/getUnlockedPost.ts new file mode 100644 index 00000000..33c78c20 --- /dev/null +++ b/actions/getUnlockedPost.ts @@ -0,0 +1,91 @@ +"use server"; + +import { AtpAgent, AppBskyFeedDefs } from "@atproto/api"; +import { supabaseServerClient } from "supabase/serverClient"; +import { getViewerIdentity } from "actions/viewerIdentity"; +import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; +import { getDocumentPages } from "lexicons/src/normalize"; +import { isEntitledToGatedPost, postHasMembersDelimiter } from "src/membership"; +import { getReaderMembership } from "src/membership.server"; +import { collectAndFetchBlockResources } from "app/(app)/(published)/lish/[did]/[publication]/[rkey]/collectAndFetchBlockResources"; +import type { PollData } from "app/(app)/(published)/lish/[did]/[publication]/[rkey]/fetchPollData"; +import type { StandardSitePostData } from "app/api/rpc/[command]/get_standard_site_posts"; +import type { PubLeafletContent } from "lexicons/api"; + +export type UnlockedPost = { + pages: PubLeafletContent.Main["pages"]; + bskyPostData: AppBskyFeedDefs.PostView[]; + standardSitePostData: StandardSitePostData[]; + pollData: PollData[]; +}; + +// Companion to getPostPageData's unconditional gating: published pages render +// the truncated variant for everyone, and an entitled reader swaps the full +// post in from here. The entitlement check is the only thing standing between a +// caller and the gated blocks, so it happens here, server-side, over rows this +// function fetches itself — the uri argument is a lookup key, not a claim. +export async function getUnlockedPost( + uri: string, +): Promise<{ entitled: false } | ({ entitled: true } & UnlockedPost)> { + const identity = await getViewerIdentity(); + if (!identity) return { entitled: false }; + + const { data: document } = await supabaseServerClient + .from("documents") + .select( + `data, uri, + documents_in_publications(publications(uri, identity_did, + publication_membership_settings(enabled), + publication_contributors(contributor_did, confirmed)))`, + ) + .eq("uri", uri) + .order("publication", { referencedTable: "documents_in_publications" }) + .maybeSingle(); + const pub = document?.documents_in_publications[0]?.publications; + if (!document || !pub?.publication_membership_settings?.enabled) + return { entitled: false }; + + const record = normalizeDocumentRecord(document.data, document.uri); + if (!record || !postHasMembersDelimiter(record)) return { entitled: false }; + + const rows = { + viewerDid: identity.atp_did, + ownerDid: pub.identity_did, + contributors: pub.publication_contributors, + }; + const entitled = + isEntitledToGatedPost({ ...rows, membership: null }) || + isEntitledToGatedPost({ + ...rows, + membership: await getReaderMembership(pub.uri, identity.id), + }); + if (!entitled) return { entitled: false }; + + const pages = getDocumentPages(record); + if (!pages) return { entitled: false }; + + const agent = new AtpAgent({ + service: "https://public.api.bsky.app", + fetch: (...args) => + fetch(args[0], { ...args[1], next: { revalidate: 3600 } }), + }); + const { bskyPostData, standardSitePostData, pollData } = + await collectAndFetchBlockResources({ + agent, + pages: pages as Parameters< + typeof collectAndFetchBlockResources + >[0]["pages"], + skipCodeBlocks: true, + }); + + return { + entitled: true, + pages, + // Mirrors DocumentPageRenderer's round-trip so the client merges shapes + // identical to the ones the page shipped (the agent's post views carry + // undefined-valued keys that RSC serialization otherwise preserves). + bskyPostData: JSON.parse(JSON.stringify(bskyPostData)), + standardSitePostData: JSON.parse(JSON.stringify(standardSitePostData)), + pollData, + }; +} diff --git a/actions/memberships.ts b/actions/memberships.ts index 9bf9ad17..1e47d416 100644 --- a/actions/memberships.ts +++ b/actions/memberships.ts @@ -1,6 +1,6 @@ "use server"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { getStripe } from "stripe/client"; import { supabaseServerClient } from "supabase/serverClient"; import { @@ -11,7 +11,7 @@ import { walletCheckoutSessionCard, type WalletRow, } from "stripe/wallet"; -import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getPublicationURL } from "src/utils/getPublicationURL"; import { Ok, Err, type Result } from "src/result"; type MembershipError = @@ -34,7 +34,7 @@ async function setCancelAtPeriodEnd( membershipId: string, cancel: boolean, ): Promise > { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity) return Err("not_authenticated"); const m = await loadOwnedMembership(identity.id, membershipId); if (!m?.stripe_subscription_id || !m.stripe_account_id) @@ -78,7 +78,7 @@ export async function switchMembership(args: { tierId: string; cadence: "month" | "year"; }): Promise > { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity) return Err("not_authenticated"); const m = await loadOwnedMembership(identity.id, args.membershipId); if (!m?.stripe_subscription_id || !m.stripe_account_id) @@ -133,7 +133,7 @@ export async function switchMembership(args: { export async function updateWalletCard(sessionId: string): Promise< Result<{ failedPublications: string[] }, MembershipError> > { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity) return Err("not_authenticated"); const stripe = getStripe(); try { @@ -221,7 +221,7 @@ export type MyMembershipsData = { export async function getMyMembershipForPublication( publicationUri: string, ): Promise { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity) return null; const [{ data: row }, { data: tiers }] = await Promise.all([ @@ -271,7 +271,7 @@ export async function getMyMembershipForPublication( } export async function getMyMemberships(): Promise { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity) return null; const [{ data: rows }, { data: wallet }] = await Promise.all([ diff --git a/actions/pollActions.ts b/actions/pollActions.ts index fb4d5115..e68104cd 100644 --- a/actions/pollActions.ts +++ b/actions/pollActions.ts @@ -1,7 +1,7 @@ "use server"; import { cookies } from "next/headers"; import { v7 } from "uuid"; -import { getIdentityData } from "./getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { supabaseServerClient } from "supabase/serverClient"; export async function getPollData(entity_sets: string[]) { @@ -72,7 +72,7 @@ export async function voteOnPoll( ) { let voter_token = (await cookies()).get("poll_voter_token")?.value; if (!voter_token) { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (identity) voter_token = identity.id; else voter_token = v7(); (await cookies()).set("poll_voter_token", voter_token, { diff --git a/actions/publications/contributors.ts b/actions/publications/contributors.ts index 78df871a..6120222e 100644 --- a/actions/publications/contributors.ts +++ b/actions/publications/contributors.ts @@ -1,9 +1,8 @@ "use server"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { supabaseServerClient } from "supabase/serverClient"; import { Ok, Err, type Result } from "src/result"; -import { revalidatePath } from "next/cache"; import { idResolver } from "src/identity"; import { isPro } from "src/entitlements"; @@ -53,7 +52,7 @@ export async function inviteContributor( publication_uri: string, handle: string, ): Promise > { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity?.atp_did) return Err("unauthorized"); let publication = await loadPublication(publication_uri); @@ -97,7 +96,7 @@ export async function removeContributor( publication_uri: string, contributor_did: string, ): Promise > { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity?.atp_did) return Err("unauthorized"); let publication = await loadPublication(publication_uri); @@ -133,14 +132,13 @@ export async function removeContributor( .in("leaflet", leafletIds); } - revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); return Ok(null); } export async function acceptContributorInvitation( publication_uri: string, ): Promise > { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity?.atp_did) return Err("unauthorized"); let { data: invite } = await supabaseServerClient @@ -161,6 +159,5 @@ export async function acceptContributorInvitation( console.error("[contributors] accept failed:", error); return Err("database_error"); } - revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); return Ok(null); } diff --git a/actions/publications/joinMembership.ts b/actions/publications/joinMembership.ts index 708f46fc..67af370c 100644 --- a/actions/publications/joinMembership.ts +++ b/actions/publications/joinMembership.ts @@ -1,6 +1,6 @@ "use server"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { getStripe } from "stripe/client"; import { supabaseServerClient } from "supabase/serverClient"; import { PLATFORM_FEE_BPS } from "stripe/connect"; @@ -34,7 +34,7 @@ export type MembershipJoinViewer = { export async function getMembershipJoinViewer( publicationUri: string, ): Promise { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity) return { loggedIn: false, @@ -80,7 +80,7 @@ export async function createWalletCheckoutSession(args: { tierId?: string; cadence?: "month" | "year"; }): Promise > { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity) return Err("not_authenticated"); try { const wallet = await getOrCreateWallet(identity); @@ -110,7 +110,7 @@ export async function createWalletCheckoutSession(args: { export async function saveWalletCardFromSession( sessionId: string, ): Promise > { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity) return Err("not_authenticated"); try { const [wallet, card] = await Promise.all([ @@ -152,7 +152,7 @@ export async function subscribeToTier(args: { tierId: string; cadence: "month" | "year"; }): Promise > { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity) return Err("not_authenticated"); if (!identity.email) return Err("email_required"); diff --git a/actions/publications/membershipSettings.ts b/actions/publications/membershipSettings.ts index cda9ff2c..ce4e692c 100644 --- a/actions/publications/membershipSettings.ts +++ b/actions/publications/membershipSettings.ts @@ -1,6 +1,6 @@ "use server"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { supabaseServerClient } from "supabase/serverClient"; import { getStripe } from "stripe/client"; import { Ok, Err, type Result } from "src/result"; @@ -20,7 +20,7 @@ type TierError = async function assertPublicationOwner(publicationUri: string) { const [identity, { data: publication }] = await Promise.all([ - getIdentityData(), + getAuthIdentity(), supabaseServerClient .from("publications") .select("identity_did") diff --git a/actions/publications/moveLeafletToPublication.ts b/actions/publications/moveLeafletToPublication.ts index f154fd6a..37d67291 100644 --- a/actions/publications/moveLeafletToPublication.ts +++ b/actions/publications/moveLeafletToPublication.ts @@ -1,6 +1,6 @@ "use server"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { isConfirmedContributor } from "src/contributorPermissions"; import { supabaseServerClient } from "supabase/serverClient"; @@ -10,7 +10,7 @@ export async function moveLeafletToPublication( metadata: { title: string; description: string }, entitiesToDelete: string[], ) { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !identity.atp_did) return null; let { data: publication } = await supabaseServerClient .from("publications") diff --git a/actions/publications/newsletterSettings.tsx b/actions/publications/newsletterSettings.tsx index db1e0e9d..033ce0b8 100644 --- a/actions/publications/newsletterSettings.tsx +++ b/actions/publications/newsletterSettings.tsx @@ -1,6 +1,6 @@ "use server"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { supabaseServerClient } from "supabase/serverClient"; import { LeafletConfirmEmail } from "emails/leafletConfirmEmail"; import { Ok, Err, type Result } from "src/result"; @@ -25,7 +25,7 @@ type ConfirmError = async function assertPublicationOwner(publicationUri: string) { const [identity, { data: publication }] = await Promise.all([ - getIdentityData(), + getAuthIdentity(), supabaseServerClient .from("publications") .select("identity_did") diff --git a/actions/publications/sendPostPreview.tsx b/actions/publications/sendPostPreview.tsx index c7628df6..549e9cf1 100644 --- a/actions/publications/sendPostPreview.tsx +++ b/actions/publications/sendPostPreview.tsx @@ -2,7 +2,7 @@ import { render } from "@react-email/render"; import { BlobRef } from "@atproto/lexicon"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { supabaseServerClient } from "supabase/serverClient"; import { getCurrentDeploymentDomain } from "src/utils/getCurrentDeploymentDomain"; import { Ok, Err, type Result } from "src/result"; @@ -44,7 +44,7 @@ export async function sendPostPreview(args: { description?: string; to: string; }): Promise > { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity?.atp_did) return Err("unauthorized"); const email = args.to.trim().toLowerCase(); @@ -113,7 +113,9 @@ export async function sendPostPreview(args: { // `contributors` field doesn't exist yet at preview time, so we read the // draft's `leaflet_contributors`. Fall back to the current user's handle // (previous behavior) when there are no contributors. - let authorName: string | undefined = identity.bsky_profiles?.handle ?? undefined; + let authorName: string | undefined = + (await getProfiles([identity.atp_did])).get(identity.atp_did)?.handle ?? + undefined; if (args.leaflet_id) { // Verify the leaflet actually belongs to this publication before reading // its contributors, so a client can't pass an arbitrary leaflet_id and diff --git a/actions/publications/subscribeEmail.tsx b/actions/publications/subscribeEmail.tsx index 3918fb4d..da80b2cd 100644 --- a/actions/publications/subscribeEmail.tsx +++ b/actions/publications/subscribeEmail.tsx @@ -1,6 +1,6 @@ "use server"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { mergeEmailIdentityIntoAtpIdentity } from "src/mergeIdentity"; import { supabaseServerClient } from "supabase/serverClient"; import { @@ -20,7 +20,7 @@ import { backfillAtprotoSubscriptionsForIdentity, publishAtprotoSubscriptionForDid, unsubscribeToPublication, -} from "app/(app)/lish/subscribeToPublication"; +} from "actions/publications/subscribeToPublication"; import type { OAuthSessionError } from "src/atproto-oauth"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; import { linkOrphanedEmailSubscribers } from "src/utils/linkOrphanedEmailSubscribers"; @@ -59,7 +59,7 @@ export async function requestPublicationEmailSubscription( .select("record") .eq("uri", publicationUri) .maybeSingle(), - getIdentityData(), + getAuthIdentity(), ]); if (!allowed.ok) return Err(allowed.error); const normalizedPub = normalizePublicationRecord(publication?.record); @@ -183,7 +183,7 @@ export async function confirmPublicationEmailSubscription( if (!linkResult.ok) return linkResult; identityId = linkResult.value; } else { - const current = await getIdentityData(); + const current = await getAuthIdentity(); if (!current) return Err("subscriber_not_found"); identityId = current.id; } @@ -230,7 +230,7 @@ export async function confirmPublicationEmailSubscription( export async function unsubscribeFromPublication( publicationUri: string, ): Promise > { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity?.id) return Err("unauthorized"); // A viewer is "subscribed" if they have EITHER an email subscription OR an @@ -315,7 +315,7 @@ async function linkEmailToCurrentIdentity( email: string, ): Promise > { const [current, { data: existing }] = await Promise.all([ - getIdentityData(), + getAuthIdentity(), supabaseServerClient .from("identities") .select("id, atp_did") diff --git a/app/(app)/lish/subscribeToPublication.ts b/actions/publications/subscribeToPublication.ts similarity index 95% rename from app/(app)/lish/subscribeToPublication.ts rename to actions/publications/subscribeToPublication.ts index 5310e1bc..1908fc68 100644 --- a/app/(app)/lish/subscribeToPublication.ts +++ b/actions/publications/subscribeToPublication.ts @@ -1,11 +1,10 @@ "use server"; import { AtpBaseClient } from "lexicons/api"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; import { TID } from "@atproto/common"; import { supabaseServerClient } from "supabase/serverClient"; -import { revalidatePath } from "next/cache"; import { AtUri } from "@atproto/syntax"; import { redirect } from "next/navigation"; import { headers } from "next/headers"; @@ -31,7 +30,7 @@ export async function subscribeToPublication( publication: string, redirectRoute?: string, ): Promise { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !identity.atp_did) { return redirect( buildOauthLoginUrl( @@ -63,7 +62,6 @@ export async function subscribeToPublication( membershipJoinUrl(publication), ]); if (existingSubscription) { - revalidatePath("/lish/[did]/[publication]", "layout"); return { success: true, hasFeed: true, joinUrl: joinUrl ?? undefined }; } @@ -97,7 +95,6 @@ export async function subscribeToPublication( await pingIdentityToUpdateNotification(publicationOwner); } - revalidatePath("/lish/[did]/[publication]", "layout"); return { success: true, hasFeed: true, @@ -193,7 +190,7 @@ type UnsubscribeResult = export async function unsubscribeToPublication( publication: string, ): Promise { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !identity.atp_did) { return { success: false, @@ -237,6 +234,5 @@ export async function unsubscribeToPublication( .delete() .eq("identity", identity.atp_did) .eq("publication", publication); - revalidatePath("/lish/[did]/[publication]", "layout"); return { success: true }; } diff --git a/app/(app)/lish/createPub/updatePublication.ts b/actions/publications/updatePublication.ts similarity index 96% rename from app/(app)/lish/createPub/updatePublication.ts rename to actions/publications/updatePublication.ts index ba106775..429896cc 100644 --- a/app/(app)/lish/createPub/updatePublication.ts +++ b/actions/publications/updatePublication.ts @@ -1,4 +1,5 @@ "use server"; +import { revalidateAllPublicationPaths } from "src/utils/revalidatePublication"; import { AtpBaseClient, PubLeafletPublication, @@ -6,7 +7,7 @@ import { SiteStandardPublication, } from "lexicons/api"; import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; import { AtUri } from "@atproto/syntax"; @@ -46,7 +47,7 @@ async function withPublicationUpdate( recordBuilder: RecordBuilder, ): Promise { // Get identity and validate authentication - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity || !identity.atp_did) { return { success: false, @@ -117,6 +118,9 @@ async function withPublicationUpdate( .select() .single(); + // Theme/name/base-path changes touch every published page. + await revalidateAllPublicationPaths(uri, [normalizedPub?.name, record.name]); + return { success: true, publication }; } diff --git a/app/(app)/[leaflet_id]/publish/publishBskyPost.ts b/actions/publishBskyPost.ts similarity index 98% rename from app/(app)/[leaflet_id]/publish/publishBskyPost.ts rename to actions/publishBskyPost.ts index 2c4a9b12..da570fb0 100644 --- a/app/(app)/[leaflet_id]/publish/publishBskyPost.ts +++ b/actions/publishBskyPost.ts @@ -8,7 +8,7 @@ import { } from "@atproto/api"; import { TID } from "@atproto/common"; import { AtUri } from "@atproto/syntax"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { AtpBaseClient, SiteStandardDocument } from "lexicons/api"; import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; import { idResolver } from "src/identity"; @@ -50,7 +50,7 @@ export async function publishPostToBsky(args: { prefetchedThumb?: string; langs?: string[]; }): Promise { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !identity.atp_did) { return { success: false, diff --git a/actions/publishPublicationPages.ts b/actions/publishPublicationPages.ts index 3d4a12fa..12a3dd10 100644 --- a/actions/publishPublicationPages.ts +++ b/actions/publishPublicationPages.ts @@ -1,6 +1,6 @@ "use server"; -import { revalidatePath } from "next/cache"; +import { revalidatePublicationPaths } from "src/utils/revalidatePublication"; import { TID } from "@atproto/common"; import { AtUri } from "@atproto/syntax"; import { AtpBaseClient } from "lexicons/api"; @@ -10,7 +10,7 @@ import type { Fact } from "src/replicache"; import type { Attribute } from "src/replicache/attributes"; import { readNavEntries } from "src/utils/publicationNavEntries"; import { OAuthSessionError, restoreOAuthSession } from "src/atproto-oauth"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { leafletToPublicationPageRecord } from "src/utils/leafletToPublicationPageRecord"; import { extractThemeFromFacts, @@ -49,7 +49,7 @@ export async function publishPublicationPages({ }: { publication_uri: string; }): Promise { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity || !identity.atp_did) { return { success: false, @@ -247,7 +247,13 @@ export async function publishPublicationPages({ // Bust the cached reader routes so edits to existing pages show up — without // this only brand-new (uncached) page paths would reflect the latest content. - revalidatePath("/lish/[did]/[publication]", "layout"); + revalidatePublicationPaths(publication_uri, normalizedPub?.name, [ + "", + "/archive", + ...contentPages + .filter((p) => p.route && p.route !== "/") + .map((p) => p.route as string), + ]); return { success: true, published }; } diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index e8051bd4..bea59e94 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -1,7 +1,9 @@ "use server"; +import { revalidatePath } from "next/cache"; +import { revalidatePostPaths } from "src/utils/revalidatePublication"; import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { AtpBaseClient, PubLeafletBlocksBskyPost, @@ -36,6 +38,7 @@ import { } from "src/membership"; import { normalizeDocumentRecord, + normalizePublicationRecord, type NormalizedDocument, } from "src/utils/normalizeRecords"; import { @@ -86,7 +89,7 @@ export async function publishToPublication({ // own record out of those feeds. showInDiscover?: boolean; }): Promise { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !identity.atp_did) { return { success: false, @@ -484,6 +487,24 @@ export async function publishToPublication({ } } + // The standalone view exists for every published document, publication or + // not. + revalidatePath(`/p/${credentialSession.did}/${rkey}`); + if (publication_uri) { + const { data: pubRow } = await supabaseServerClient + .from("publications") + .select("record") + .eq("uri", publication_uri) + .maybeSingle(); + const docPath = (record as { path?: string }).path; + revalidatePostPaths( + publication_uri, + normalizePublicationRecord(pubRow?.record)?.name, + rkey, + docPath, + ); + } + return { success: true, rkey, record: JSON.parse(JSON.stringify(record)) }; } diff --git a/app/(app)/(home-pages)/reader/getHotFeed.ts b/actions/reader/getHotFeed.ts similarity index 97% rename from app/(app)/(home-pages)/reader/getHotFeed.ts rename to actions/reader/getHotFeed.ts index 97e21b7f..5ccf8ea6 100644 --- a/app/(app)/(home-pages)/reader/getHotFeed.ts +++ b/actions/reader/getHotFeed.ts @@ -6,7 +6,7 @@ import { pool } from "supabase/pool"; import Client from "ioredis"; import { AtUri } from "@atproto/api"; import { supabaseServerClient } from "supabase/serverClient"; -import { enrichDocumentToPost } from "./enrichPost"; +import { enrichDocumentToPost } from "src/utils/enrichPost"; import type { Post } from "./getReaderFeed"; let redisClient: Client | null = null; diff --git a/app/(app)/(home-pages)/reader/getNewFeed.ts b/actions/reader/getNewFeed.ts similarity index 97% rename from app/(app)/(home-pages)/reader/getNewFeed.ts rename to actions/reader/getNewFeed.ts index 925a6ca7..b4c30821 100644 --- a/app/(app)/(home-pages)/reader/getNewFeed.ts +++ b/actions/reader/getNewFeed.ts @@ -2,7 +2,7 @@ import { supabaseServerClient } from "supabase/serverClient"; import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; -import { enrichDocumentToPost } from "./enrichPost"; +import { enrichDocumentToPost } from "src/utils/enrichPost"; import type { Cursor, Post } from "./getReaderFeed"; export async function getNewFeed( diff --git a/app/(app)/(home-pages)/reader/getReaderFeed.ts b/actions/reader/getReaderFeed.ts similarity index 94% rename from app/(app)/(home-pages)/reader/getReaderFeed.ts rename to actions/reader/getReaderFeed.ts index 834a8898..7b89532a 100644 --- a/app/(app)/(home-pages)/reader/getReaderFeed.ts +++ b/actions/reader/getReaderFeed.ts @@ -1,13 +1,13 @@ "use server"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { supabaseServerClient } from "supabase/serverClient"; import type { NormalizedDocument, NormalizedPublication, } from "src/utils/normalizeRecords"; import type { BylineProfile } from "src/utils/byline"; -import { enrichDocumentToPost } from "./enrichPost"; +import { enrichDocumentToPost } from "src/utils/enrichPost"; export type Cursor = { timestamp: string; @@ -17,7 +17,7 @@ export type Cursor = { export async function getReaderFeed( cursor?: Cursor | null, ): Promise<{ posts: Post[]; nextCursor: Cursor | null }> { - let auth_res = await getIdentityData(); + let auth_res = await getAuthIdentity(); if (!auth_res?.atp_did) return { posts: [], nextCursor: null }; const { data: rawFeed, error } = await supabaseServerClient.rpc( diff --git a/app/(app)/(home-pages)/reader/getSubscriptions.ts b/actions/reader/getSubscriptions.ts similarity index 96% rename from app/(app)/(home-pages)/reader/getSubscriptions.ts rename to actions/reader/getSubscriptions.ts index 82a568cd..345eb877 100644 --- a/app/(app)/(home-pages)/reader/getSubscriptions.ts +++ b/actions/reader/getSubscriptions.ts @@ -2,7 +2,7 @@ import { AtpAgent } from "@atproto/api"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { Json } from "supabase/database.types"; import { supabaseServerClient } from "supabase/serverClient"; import { idResolver } from "src/identity"; @@ -22,7 +22,7 @@ export async function getSubscriptions( // If no DID provided, use logged-in user's DID let identity = did; if (!identity) { - const auth_res = await getIdentityData(); + const auth_res = await getAuthIdentity(); if (!auth_res?.atp_did) return { subscriptions: [], nextCursor: null }; identity = auth_res.atp_did; } diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts b/actions/recommendAction.ts similarity index 97% rename from app/(app)/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts rename to actions/recommendAction.ts index 07477395..a0a2d583 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts +++ b/actions/recommendAction.ts @@ -1,7 +1,7 @@ "use server"; import { AtpBaseClient, SiteStandardGraphRecommend } from "lexicons/api"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; import { TID } from "@atproto/common"; import { AtUri, Un$Typed } from "@atproto/api"; @@ -23,7 +23,7 @@ type RecommendResult = export async function recommendAction(args: { document: string; }): Promise { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !identity.atp_did) { return { success: false, @@ -110,7 +110,7 @@ export async function recommendAction(args: { export async function unrecommendAction(args: { document: string; }): Promise { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !identity.atp_did) { return { success: false, diff --git a/actions/refreshStripeConnectAccount.ts b/actions/refreshStripeConnectAccount.ts index 406b892e..99d49e6b 100644 --- a/actions/refreshStripeConnectAccount.ts +++ b/actions/refreshStripeConnectAccount.ts @@ -1,6 +1,6 @@ "use server"; -import { getIdentityData } from "./getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { supabaseServerClient } from "supabase/serverClient"; import { Ok, Err, type Result } from "src/result"; import { @@ -11,7 +11,7 @@ import { export async function refreshStripeConnectAccount(): Promise< Result > { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity) return Err("Not authenticated"); const { data: row } = await supabaseServerClient diff --git a/actions/startStripeConnectOnboarding.ts b/actions/startStripeConnectOnboarding.ts index 61f19ae7..9c2464af 100644 --- a/actions/startStripeConnectOnboarding.ts +++ b/actions/startStripeConnectOnboarding.ts @@ -1,6 +1,7 @@ "use server"; -import { getIdentityData } from "./getIdentityData"; +import { getAuthIdentity } from "src/auth"; +import { getProfiles } from "src/identity"; import { supabaseServerClient } from "supabase/serverClient"; import { Ok, Err, type Result } from "src/result"; import { @@ -11,7 +12,7 @@ import { export async function startStripeConnectOnboarding( returnUrl: string, ): Promise > { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity) return Err("Not authenticated"); if (!identity.email) return Err("Add an email to your account before setting up payments"); @@ -27,11 +28,14 @@ export async function startStripeConnectOnboarding( if (existing?.stripe_account_id) { stripeAccountId = existing.stripe_account_id; } else { + const handle = identity.atp_did + ? (await getProfiles([identity.atp_did])).get(identity.atp_did)?.handle + : undefined; let account; try { account = await createConnectedMerchantAccount({ email: identity.email, - displayName: identity.bsky_profiles?.handle || identity.email, + displayName: handle || identity.email, identityId: identity.id, }); } catch (e) { diff --git a/actions/updateIdentityInterfaceState.ts b/actions/updateIdentityInterfaceState.ts index 5ed8cbc7..f6ef87b6 100644 --- a/actions/updateIdentityInterfaceState.ts +++ b/actions/updateIdentityInterfaceState.ts @@ -1,13 +1,13 @@ "use server"; import { InterfaceState } from "components/IdentityProvider"; -import { getIdentityData } from "./getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { supabaseServerClient } from "supabase/serverClient"; export async function updateIdentityInterfaceState( interfaceState: InterfaceState, ) { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity) return; await supabaseServerClient .from("identities") diff --git a/actions/viewerIdentity.ts b/actions/viewerIdentity.ts new file mode 100644 index 00000000..89ea7abb --- /dev/null +++ b/actions/viewerIdentity.ts @@ -0,0 +1,74 @@ +"use server"; + +import { supabaseServerClient } from "supabase/serverClient"; +import { cache } from "react"; +import { getProfiles } from "src/identity"; +import { + bskyProfileFromCache, + ENTITLEMENT_EMBEDS, + getValidAuthToken, + keyEntitlements, + SUBSCRIPTION_STATE_EMBEDS, +} from "src/identityPayload"; +import type { getIdentityData } from "./getIdentityData"; + +type Identity = Awaited >; + +// The published-page counterpart to getIdentityData: everything interaction +// components need (viewer profile, subscriptions, memberships, entitlements) +// without the dashboard-only embeds. Returns the exact Identity shape with the +// omitted embeds empty so every consumer type-checks against either source; +// the `Promise ` annotation is the drift guard. +export const getViewerIdentity = cache(uncachedGetViewerIdentity); +async function uncachedGetViewerIdentity(): Promise { + let auth_token = await getValidAuthToken(); + let auth_res = auth_token + ? await supabaseServerClient + .from("email_auth_tokens") + .select( + `*, + identities( + *, + ${SUBSCRIPTION_STATE_EMBEDS}, + ${ENTITLEMENT_EMBEDS} + )`, + ) + .eq("identities.notifications.read", false) + .eq("id", auth_token) + .eq("confirmed", true) + .single() + : null; + if (!auth_res?.data?.identities) return null; + + // Spreading `identity` below must not leak these raw embeds as extra + // top-level keys alongside the processed `entitlements` / `subscription` / + // `connectedAccount`. + const { + user_entitlements: entitlementRows, + user_subscriptions: subscription, + stripe_connected_accounts: connectedAccount, + ...identity + } = auth_res.data.identities; + + const entitlements = keyEntitlements(entitlementRows); + + const profiles = identity.atp_did + ? await getProfiles([identity.atp_did]) + : null; + + return { + ...identity, + custom_domains: [], + permission_token_on_homepage: [], + bsky_profiles: + identity.atp_did && profiles + ? bskyProfileFromCache(profiles.get(identity.atp_did) ?? null) + : null, + publications: [], + contributor_publications: [], + contributor_leaflets: [], + entitlements, + subscription: subscription ?? null, + connectedAccount: connectedAccount ?? null, + }; +} diff --git a/app/(app)/(home-pages)/layout.tsx b/app/(app)/(home-pages)/layout.tsx deleted file mode 100644 index 13135142..00000000 --- a/app/(app)/(home-pages)/layout.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { getIdentityData } from "actions/getIdentityData"; -import { EntitySetProvider } from "components/EntitySetProvider"; -import { NavStateTracker } from "components/NavStateTracker"; -import { - ThemeProvider, - ThemeBackgroundProvider, -} from "components/ThemeManager/ThemeProvider"; -import { ReplicacheProvider, type Fact } from "src/replicache"; - -export default async function HomePagesLayout(props: { - children: React.ReactNode; -}) { - let identityData = await getIdentityData(); - if (!identityData?.home_leaflet) - return ( - <> - - {props.children} - > - ); - let facts = - (identityData?.home_leaflet?.permission_token_rights[0].entity_sets?.entities.flatMap( - (e) => e.facts, - ) || []) as Fact[]; - - let root_entity = identityData.home_leaflet.root_entity; - return ( - - - ); -} diff --git a/app/(app)/(home-pages)/(writer)/WriterShell.tsx b/app/(app)/(identity)/(home-pages)/(writer)/WriterShell.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/WriterShell.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/WriterShell.tsx diff --git a/app/(app)/(home-pages)/(writer)/home/Actions/Actions.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/Actions/Actions.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/home/Actions/Actions.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/home/Actions/Actions.tsx diff --git a/app/(app)/(home-pages)/(writer)/home/Actions/CreateNewButton.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/Actions/CreateNewButton.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/home/Actions/CreateNewButton.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/home/Actions/CreateNewButton.tsx diff --git a/app/(app)/(home-pages)/(writer)/home/Actions/HomeThemeSetter.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/Actions/HomeThemeSetter.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/home/Actions/HomeThemeSetter.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/home/Actions/HomeThemeSetter.tsx diff --git a/app/(app)/(home-pages)/(writer)/home/HomeEmpty/HomeEmpty.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/HomeEmpty/HomeEmpty.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/home/HomeEmpty/HomeEmpty.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/home/HomeEmpty/HomeEmpty.tsx diff --git a/app/(app)/(home-pages)/(writer)/home/HomeEmpty/WelcomeToLeafletIllo.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/HomeEmpty/WelcomeToLeafletIllo.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/home/HomeEmpty/WelcomeToLeafletIllo.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/home/HomeEmpty/WelcomeToLeafletIllo.tsx diff --git a/app/(app)/(home-pages)/(writer)/home/HomeLayout.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/HomeLayout.tsx similarity index 89% rename from app/(app)/(home-pages)/(writer)/home/HomeLayout.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/home/HomeLayout.tsx index d4db8d92..68c95d56 100644 --- a/app/(app)/(home-pages)/(writer)/home/HomeLayout.tsx +++ b/app/(app)/(identity)/(home-pages)/(writer)/home/HomeLayout.tsx @@ -1,10 +1,10 @@ "use client"; -import { getHomeDocs } from "./storage"; +import { getHomeDocs } from "src/utils/homeDocsStorage"; import useSWR from "swr"; import { PermissionToken, useEntity } from "src/replicache"; import { LeafletListItem } from "./LeafletList/LeafletListItem"; -import { useIdentityData } from "components/IdentityProvider"; +import { useIdentityData, type Identity } from "components/IdentityProvider"; import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; import { type DashboardState, @@ -36,6 +36,25 @@ export type Leaflet = { }; }; +// The casts feeding StaticLeafletDataContext below widen getIdentityData's rows +// to get_leaflet_data's much richer shape, which means a narrowed select in +// getIdentityData type-checks fine and only shows up as wrong output — +// useLeafletPublicationStatus silently falling back from the publication's own +// URL to the /p/ permalink. Pin the columns it reads. +type LeafletsInPublications = NonNullable< + Exclude< + Identity, + null + >["permission_token_on_homepage"][number]["permission_tokens"]["leaflets_in_publications"] +>[number]; +type _ShareLinkColumnsPresent = LeafletsInPublications extends { + documents: { data: unknown } | null; + publications: { uri: string; record: unknown } | null; +} + ? true + : never; +const _shareLinkColumnsPresent: _ShareLinkColumnsPresent = true; + export const HomeContent = (props: { entityID: string | null; titles: { [root_entity: string]: string }; @@ -67,9 +86,7 @@ export const HomeContent = (props: { (leaflet) => leaflet.archived === true, ) || (identity.contributor_leaflets ?? []).some((row) => - row.permission_tokens.leaflets_in_publications?.some( - (l) => l.archived, - ), + row.permission_tokens.leaflets_in_publications?.some((l) => l.archived), )); return ( diff --git a/app/(app)/(home-pages)/(writer)/home/LeafletList/HomeLeafletFactsProvider.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/HomeLeafletFactsProvider.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/home/LeafletList/HomeLeafletFactsProvider.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/HomeLeafletFactsProvider.tsx diff --git a/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletCardReplicache.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletCardReplicache.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletCardReplicache.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletCardReplicache.tsx diff --git a/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletContent.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletContent.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletContent.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletContent.tsx diff --git a/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletInfo.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletInfo.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletInfo.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletInfo.tsx diff --git a/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletListItem.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletListItem.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletListItem.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletListItem.tsx diff --git a/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx similarity index 97% rename from app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx index 97dc94cb..d065ea4c 100644 --- a/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx +++ b/app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletOptions.tsx @@ -16,10 +16,10 @@ import { UnpublishSmall } from "components/Icons/UnpublishSmall"; import { deletePost, unpublishPost, -} from "app/(app)/lish/[did]/[publication]/dashboard/deletePost"; +} from "app/(app)/(published)/lish/[did]/[publication]/dashboard/deletePost"; import { ShareSmall } from "components/Icons/ShareSmall"; import { HideSmall } from "components/Icons/HideSmall"; -import { hideDoc } from "../storage"; +import { hideDoc } from "src/utils/homeDocsStorage"; import { useIdentityData, @@ -28,8 +28,8 @@ import { import { usePublicationData, mutatePublicationData, -} from "app/(app)/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; -import { ShareButton } from "app/(app)/[leaflet_id]/actions/ShareOptions"; +} from "app/(app)/(published)/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; +import { ShareButton } from "app/(app)/(identity)/[leaflet_id]/actions/ShareOptions"; import { useLeafletPublicationStatus } from "components/PageSWRDataProvider"; // A member (owner or confirmed contributor) of the publication a leaflet diff --git a/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletPreview.module.css b/app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletPreview.module.css similarity index 100% rename from app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletPreview.module.css rename to app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletPreview.module.css diff --git a/app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletPreview.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletPreview.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletPreview.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletPreview.tsx diff --git a/app/(app)/(home-pages)/(writer)/home/icon.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/icon.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/home/icon.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/home/icon.tsx diff --git a/app/(app)/(identity)/(home-pages)/(writer)/home/loading.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/loading.tsx new file mode 100644 index 00000000..f9434e2b --- /dev/null +++ b/app/(app)/(identity)/(home-pages)/(writer)/home/loading.tsx @@ -0,0 +1,5 @@ +import { DashboardLoading } from "components/PageLayouts/DashboardLoading"; + +export default function Loading() { + return- -- -- -- {props.children} - ; +} diff --git a/app/(app)/(home-pages)/(writer)/home/page.tsx b/app/(app)/(identity)/(home-pages)/(writer)/home/page.tsx similarity index 82% rename from app/(app)/(home-pages)/(writer)/home/page.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/home/page.tsx index f7c47efc..6a299643 100644 --- a/app/(app)/(home-pages)/(writer)/home/page.tsx +++ b/app/(app)/(identity)/(home-pages)/(writer)/home/page.tsx @@ -1,9 +1,13 @@ import { getIdentityData } from "actions/getIdentityData"; +import { getHomeLeaflet } from "src/homeLeaflet"; import { HomeContent } from "./HomeLayout"; export default async function Home() { - let auth_res = await getIdentityData(); + let [auth_res, home_leaflet] = await Promise.all([ + getIdentityData(), + getHomeLeaflet(), + ]); let titleOf = (pt: { leaflets_in_publications?: { title: string | null }[] | null; @@ -30,7 +34,7 @@ export default async function Home() { return ( ); } diff --git a/app/(app)/(home-pages)/(writer)/layout.tsx b/app/(app)/(identity)/(home-pages)/(writer)/layout.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/layout.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/layout.tsx diff --git a/app/(app)/(home-pages)/(writer)/looseleafs/LooseleafsLayout.tsx b/app/(app)/(identity)/(home-pages)/(writer)/looseleafs/LooseleafsLayout.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/looseleafs/LooseleafsLayout.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/looseleafs/LooseleafsLayout.tsx diff --git a/app/(app)/(identity)/(home-pages)/(writer)/looseleafs/loading.tsx b/app/(app)/(identity)/(home-pages)/(writer)/looseleafs/loading.tsx new file mode 100644 index 00000000..f9434e2b --- /dev/null +++ b/app/(app)/(identity)/(home-pages)/(writer)/looseleafs/loading.tsx @@ -0,0 +1,5 @@ +import { DashboardLoading } from "components/PageLayouts/DashboardLoading"; + +export default function Loading() { + return ; +} diff --git a/app/(app)/(home-pages)/(writer)/looseleafs/page.tsx b/app/(app)/(identity)/(home-pages)/(writer)/looseleafs/page.tsx similarity index 76% rename from app/(app)/(home-pages)/(writer)/looseleafs/page.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/looseleafs/page.tsx index b3e19c6d..baed733b 100644 --- a/app/(app)/(home-pages)/(writer)/looseleafs/page.tsx +++ b/app/(app)/(identity)/(home-pages)/(writer)/looseleafs/page.tsx @@ -1,8 +1,12 @@ import { getIdentityData } from "actions/getIdentityData"; +import { getHomeLeaflet } from "src/homeLeaflet"; import { LooseleafsContent } from "./LooseleafsLayout"; export default async function Home() { - let auth_res = await getIdentityData(); + let [auth_res, home_leaflet] = await Promise.all([ + getIdentityData(), + getHomeLeaflet(), + ]); let titles = auth_res?.permission_token_on_homepage.reduce( @@ -20,7 +24,7 @@ export default async function Home() { return ( ); diff --git a/app/(app)/(home-pages)/(writer)/memberships/MembershipsManager.tsx b/app/(app)/(identity)/(home-pages)/(writer)/memberships/MembershipsManager.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/memberships/MembershipsManager.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/memberships/MembershipsManager.tsx diff --git a/app/(app)/(identity)/(home-pages)/(writer)/memberships/loading.tsx b/app/(app)/(identity)/(home-pages)/(writer)/memberships/loading.tsx new file mode 100644 index 00000000..f9434e2b --- /dev/null +++ b/app/(app)/(identity)/(home-pages)/(writer)/memberships/loading.tsx @@ -0,0 +1,5 @@ +import { DashboardLoading } from "components/PageLayouts/DashboardLoading"; + +export default function Loading() { + return ; +} diff --git a/app/(app)/(home-pages)/(writer)/memberships/page.tsx b/app/(app)/(identity)/(home-pages)/(writer)/memberships/page.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/memberships/page.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/memberships/page.tsx diff --git a/app/(app)/(home-pages)/(writer)/notifications/BskyPostEmbedNotification.tsx b/app/(app)/(identity)/(home-pages)/(writer)/notifications/BskyPostEmbedNotification.tsx similarity index 94% rename from app/(app)/(home-pages)/(writer)/notifications/BskyPostEmbedNotification.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/notifications/BskyPostEmbedNotification.tsx index 185fb849..dbdb117b 100644 --- a/app/(app)/(home-pages)/(writer)/notifications/BskyPostEmbedNotification.tsx +++ b/app/(app)/(identity)/(home-pages)/(writer)/notifications/BskyPostEmbedNotification.tsx @@ -2,7 +2,7 @@ import { BlueskyTiny } from "components/Icons/BlueskyTiny"; import { ContentLayout, Notification } from "./Notification"; import { HydratedBskyPostEmbedNotification } from "src/notifications"; import { AtUri } from "@atproto/api"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getDocumentURL } from "src/utils/getPublicationURL"; export const BskyPostEmbedNotification = ( props: HydratedBskyPostEmbedNotification, diff --git a/app/(app)/(home-pages)/(writer)/notifications/CommentMentionNotification.tsx b/app/(app)/(identity)/(home-pages)/(writer)/notifications/CommentMentionNotification.tsx similarity index 96% rename from app/(app)/(home-pages)/(writer)/notifications/CommentMentionNotification.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/notifications/CommentMentionNotification.tsx index a01ee752..3e777a16 100644 --- a/app/(app)/(home-pages)/(writer)/notifications/CommentMentionNotification.tsx +++ b/app/(app)/(identity)/(home-pages)/(writer)/notifications/CommentMentionNotification.tsx @@ -6,7 +6,7 @@ import { ContentLayout, Notification, } from "./Notification"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getDocumentURL } from "src/utils/getPublicationURL"; export const CommentMentionNotification = ( props: HydratedCommentMentionNotification, diff --git a/app/(app)/(home-pages)/(writer)/notifications/CommentNotication.tsx b/app/(app)/(identity)/(home-pages)/(writer)/notifications/CommentNotication.tsx similarity index 94% rename from app/(app)/(home-pages)/(writer)/notifications/CommentNotication.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/notifications/CommentNotication.tsx index 73f0afbe..7b8297b9 100644 --- a/app/(app)/(home-pages)/(writer)/notifications/CommentNotication.tsx +++ b/app/(app)/(identity)/(home-pages)/(writer)/notifications/CommentNotication.tsx @@ -6,7 +6,7 @@ import { ContentLayout, Notification, } from "./Notification"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getDocumentURL } from "src/utils/getPublicationURL"; export const CommentNotification = (props: HydratedCommentNotification) => { const docRecord = props.normalizedDocument; diff --git a/app/(app)/(home-pages)/(writer)/notifications/FollowNotification.tsx b/app/(app)/(identity)/(home-pages)/(writer)/notifications/FollowNotification.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/notifications/FollowNotification.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/notifications/FollowNotification.tsx diff --git a/app/(app)/(home-pages)/(writer)/notifications/MentionNotification.tsx b/app/(app)/(identity)/(home-pages)/(writer)/notifications/MentionNotification.tsx similarity index 96% rename from app/(app)/(home-pages)/(writer)/notifications/MentionNotification.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/notifications/MentionNotification.tsx index ed941d0c..f8e069cc 100644 --- a/app/(app)/(home-pages)/(writer)/notifications/MentionNotification.tsx +++ b/app/(app)/(identity)/(home-pages)/(writer)/notifications/MentionNotification.tsx @@ -2,7 +2,7 @@ import { MentionTiny } from "components/Icons/MentionTiny"; import { ContentLayout, Notification } from "./Notification"; import { HydratedMentionNotification } from "src/notifications"; import { AtUri } from "@atproto/api"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getDocumentURL } from "src/utils/getPublicationURL"; export const MentionNotification = (props: HydratedMentionNotification) => { const docRecord = props.normalizedDocument; diff --git a/app/(app)/(home-pages)/(writer)/notifications/NewMemberNotification.tsx b/app/(app)/(identity)/(home-pages)/(writer)/notifications/NewMemberNotification.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/notifications/NewMemberNotification.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/notifications/NewMemberNotification.tsx diff --git a/app/(app)/(home-pages)/(writer)/notifications/Notification.tsx b/app/(app)/(identity)/(home-pages)/(writer)/notifications/Notification.tsx similarity index 96% rename from app/(app)/(home-pages)/(writer)/notifications/Notification.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/notifications/Notification.tsx index ba317a64..99aa2b77 100644 --- a/app/(app)/(home-pages)/(writer)/notifications/Notification.tsx +++ b/app/(app)/(identity)/(home-pages)/(writer)/notifications/Notification.tsx @@ -1,6 +1,6 @@ "use client"; import { Avatar } from "components/Avatar"; -import { BaseTextBlock } from "app/(app)/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; +import { BaseTextBlock } from "app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock"; import { PubLeafletRichtextFacet } from "lexicons/api"; import { timeAgo } from "src/utils/timeAgo"; import { useReplicache, useEntity } from "src/replicache"; diff --git a/app/(app)/(home-pages)/(writer)/notifications/NotificationList.tsx b/app/(app)/(identity)/(home-pages)/(writer)/notifications/NotificationList.tsx similarity index 97% rename from app/(app)/(home-pages)/(writer)/notifications/NotificationList.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/notifications/NotificationList.tsx index 63c18411..9c60cff3 100644 --- a/app/(app)/(home-pages)/(writer)/notifications/NotificationList.tsx +++ b/app/(app)/(identity)/(home-pages)/(writer)/notifications/NotificationList.tsx @@ -3,7 +3,7 @@ import { HydratedNotification } from "src/notifications"; import { CommentNotification } from "./CommentNotication"; import { useEffect, createContext } from "react"; -import { markAsRead } from "./getNotifications"; +import { markAsRead } from "actions/getNotifications"; import { ReplyNotification } from "./ReplyNotification"; import { useIdentityData } from "components/IdentityProvider"; import { FollowNotification } from "./FollowNotification"; diff --git a/app/(app)/(home-pages)/(writer)/notifications/QuoteNotification.tsx b/app/(app)/(identity)/(home-pages)/(writer)/notifications/QuoteNotification.tsx similarity index 94% rename from app/(app)/(home-pages)/(writer)/notifications/QuoteNotification.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/notifications/QuoteNotification.tsx index 4f208e4c..7abc2db8 100644 --- a/app/(app)/(home-pages)/(writer)/notifications/QuoteNotification.tsx +++ b/app/(app)/(identity)/(home-pages)/(writer)/notifications/QuoteNotification.tsx @@ -3,7 +3,7 @@ import { ContentLayout, Notification } from "./Notification"; import { HydratedQuoteNotification } from "src/notifications"; import { AtUri } from "@atproto/api"; import { Avatar } from "components/Avatar"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getDocumentURL } from "src/utils/getPublicationURL"; export const QuoteNotification = (props: HydratedQuoteNotification) => { const postView = props.bskyPost.post_view as any; diff --git a/app/(app)/(home-pages)/(writer)/notifications/RecommendNotification.tsx b/app/(app)/(identity)/(home-pages)/(writer)/notifications/RecommendNotification.tsx similarity index 92% rename from app/(app)/(home-pages)/(writer)/notifications/RecommendNotification.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/notifications/RecommendNotification.tsx index 784912e8..f8e7526c 100644 --- a/app/(app)/(home-pages)/(writer)/notifications/RecommendNotification.tsx +++ b/app/(app)/(identity)/(home-pages)/(writer)/notifications/RecommendNotification.tsx @@ -1,7 +1,7 @@ import { ContentLayout, Notification } from "./Notification"; import { HydratedRecommendNotification } from "src/notifications"; import { RecommendFilledTiny } from "components/Icons/RecommendTiny"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getDocumentURL } from "src/utils/getPublicationURL"; export const RecommendNotification = ( props: HydratedRecommendNotification, diff --git a/app/(app)/(home-pages)/(writer)/notifications/ReplyNotification.tsx b/app/(app)/(identity)/(home-pages)/(writer)/notifications/ReplyNotification.tsx similarity index 96% rename from app/(app)/(home-pages)/(writer)/notifications/ReplyNotification.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/notifications/ReplyNotification.tsx index 0ee190dc..5c9663ca 100644 --- a/app/(app)/(home-pages)/(writer)/notifications/ReplyNotification.tsx +++ b/app/(app)/(identity)/(home-pages)/(writer)/notifications/ReplyNotification.tsx @@ -6,7 +6,7 @@ import { import { HydratedCommentNotification } from "src/notifications"; import { PubLeafletComment } from "lexicons/api"; import { ReplyTiny } from "components/Icons/ReplyTiny"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getDocumentURL } from "src/utils/getPublicationURL"; export const ReplyNotification = (props: HydratedCommentNotification) => { const docRecord = props.normalizedDocument; diff --git a/app/(app)/(identity)/(home-pages)/(writer)/notifications/loading.tsx b/app/(app)/(identity)/(home-pages)/(writer)/notifications/loading.tsx new file mode 100644 index 00000000..f9434e2b --- /dev/null +++ b/app/(app)/(identity)/(home-pages)/(writer)/notifications/loading.tsx @@ -0,0 +1,5 @@ +import { DashboardLoading } from "components/PageLayouts/DashboardLoading"; + +export default function Loading() { + return ; +} diff --git a/app/(app)/(home-pages)/(writer)/notifications/page.tsx b/app/(app)/(identity)/(home-pages)/(writer)/notifications/page.tsx similarity index 100% rename from app/(app)/(home-pages)/(writer)/notifications/page.tsx rename to app/(app)/(identity)/(home-pages)/(writer)/notifications/page.tsx diff --git a/app/(app)/(identity)/(home-pages)/layout.tsx b/app/(app)/(identity)/(home-pages)/layout.tsx new file mode 100644 index 00000000..bdd94813 --- /dev/null +++ b/app/(app)/(identity)/(home-pages)/layout.tsx @@ -0,0 +1,61 @@ +import { Suspense } from "react"; +import { getHomeLeaflet } from "src/homeLeaflet"; +import { EntitySetProvider } from "components/EntitySetProvider"; +import { NavStateTracker } from "components/NavStateTracker"; +import { FullPageLoading } from "components/PageLayouts/DashboardLoading"; +import { + ThemeProvider, + ThemeBackgroundProvider, +} from "components/ThemeManager/ThemeProvider"; +import { ReplicacheProvider, type Fact } from "src/replicache"; + +// Synchronous shell + suspended inner, same as the (identity) layout above: +// navigations that mount this segment fresh (e.g. editor → home) suspend +// inside the already-committed (identity) boundary, which won't re-show its +// fallback mid-transition — so this segment needs its own boundary to commit +// against while the home leaflet resolves. +export default function HomePagesLayout(props: { + children: React.ReactNode; +}) { + return ( + }> + {props.children} + + ); +} + +async function HomePagesLayoutInner(props: { children: React.ReactNode }) { + let home_leaflet = await getHomeLeaflet(); + if (!home_leaflet) + return ( + <> ++ {props.children} + > + ); + let facts = + (home_leaflet.permission_token_rights[0].entity_sets?.entities.flatMap( + (e) => e.facts, + ) || []) as Fact[]; + + let root_entity = home_leaflet.root_entity; + return ( + + + ); +} diff --git a/app/(app)/(home-pages)/p/[didOrHandle]/PostsContent.tsx b/app/(app)/(identity)/(home-pages)/p/[didOrHandle]/PostsContent.tsx similarity index 97% rename from app/(app)/(home-pages)/p/[didOrHandle]/PostsContent.tsx rename to app/(app)/(identity)/(home-pages)/p/[didOrHandle]/PostsContent.tsx index 19f708dd..f6b9b000 100644 --- a/app/(app)/(home-pages)/p/[didOrHandle]/PostsContent.tsx +++ b/app/(app)/(identity)/(home-pages)/p/[didOrHandle]/PostsContent.tsx @@ -2,7 +2,7 @@ import { EmptyState } from "components/EmptyState"; import { PostListing } from "components/PostListing"; -import type { Post } from "app/(app)/(home-pages)/reader/getReaderFeed"; +import type { Post } from "actions/reader/getReaderFeed"; import type { Cursor } from "./getProfilePosts"; import { getProfilePosts } from "./getProfilePosts"; import useSWRInfinite from "swr/infinite"; diff --git a/app/(app)/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx b/app/(app)/(identity)/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx similarity index 100% rename from app/(app)/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx rename to app/(app)/(identity)/(home-pages)/p/[didOrHandle]/ProfileHeader.tsx diff --git a/app/(app)/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx b/app/(app)/(identity)/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx similarity index 100% rename from app/(app)/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx rename to app/(app)/(identity)/(home-pages)/p/[didOrHandle]/ProfileLayout.tsx diff --git a/app/(app)/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx b/app/(app)/(identity)/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx similarity index 99% rename from app/(app)/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx rename to app/(app)/(identity)/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx index 829d497e..ba2eaa45 100644 --- a/app/(app)/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx +++ b/app/(app)/(identity)/(home-pages)/p/[didOrHandle]/ProfileTabs.tsx @@ -108,6 +108,7 @@ export const ProfileTabs = (props: { didOrHandle: string }) => { const TabLink = (props: { href: string; name: string; selected: boolean }) => { return (+ ++ ++ ++ {props.children} + ; + children: React.ReactNode; +}) { + return ( + }> + + + ); +} + +async function ProfilePageLayoutInner(props: { params: Promise<{ didOrHandle: string }>; children: React.ReactNode; }) { diff --git a/app/(app)/(identity)/(home-pages)/p/[didOrHandle]/loading.tsx b/app/(app)/(identity)/(home-pages)/p/[didOrHandle]/loading.tsx new file mode 100644 index 00000000..f9434e2b --- /dev/null +++ b/app/(app)/(identity)/(home-pages)/p/[didOrHandle]/loading.tsx @@ -0,0 +1,5 @@ +import { DashboardLoading } from "components/PageLayouts/DashboardLoading"; + +export default function Loading() { + return ; +} diff --git a/app/(app)/(home-pages)/p/[didOrHandle]/page.tsx b/app/(app)/(identity)/(home-pages)/p/[didOrHandle]/page.tsx similarity index 100% rename from app/(app)/(home-pages)/p/[didOrHandle]/page.tsx rename to app/(app)/(identity)/(home-pages)/p/[didOrHandle]/page.tsx diff --git a/app/(app)/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx b/app/(app)/(identity)/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx similarity index 93% rename from app/(app)/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx rename to app/(app)/(identity)/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx index e09e37dd..2422bbdf 100644 --- a/app/(app)/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx +++ b/app/(app)/(identity)/(home-pages)/p/[didOrHandle]/subscriptions/SubscriptionsContent.tsx @@ -3,12 +3,12 @@ import { EmptyState } from "components/EmptyState"; import { useEffect, useRef } from "react"; import useSWRInfinite from "swr/infinite"; -import { PubListing } from "app/(app)/(home-pages)/p/[didOrHandle]/PubListing"; +import { PubListing } from "app/(app)/(identity)/(home-pages)/p/[didOrHandle]/PubListing"; import { getSubscriptions, type PublicationSubscription, -} from "app/(app)/(home-pages)/reader/getSubscriptions"; -import { Cursor } from "app/(app)/(home-pages)/reader/getReaderFeed"; +} from "actions/reader/getSubscriptions"; +import { Cursor } from "actions/reader/getReaderFeed"; export const ProfileSubscriptionsContent = (props: { did: string; diff --git a/app/(app)/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx b/app/(app)/(identity)/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx similarity index 90% rename from app/(app)/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx rename to app/(app)/(identity)/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx index 92ebcc13..5c86bdbf 100644 --- a/app/(app)/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx +++ b/app/(app)/(identity)/(home-pages)/p/[didOrHandle]/subscriptions/page.tsx @@ -1,5 +1,5 @@ import { idResolver } from "src/identity"; -import { getSubscriptions } from "app/(app)/(home-pages)/reader/getSubscriptions"; +import { getSubscriptions } from "actions/reader/getSubscriptions"; import { ProfileSubscriptionsContent } from "./SubscriptionsContent"; export default async function ProfileSubscriptionsPage(props: { diff --git a/app/(app)/(home-pages)/reader/FeedLayout.tsx b/app/(app)/(identity)/(home-pages)/reader/FeedLayout.tsx similarity index 100% rename from app/(app)/(home-pages)/reader/FeedLayout.tsx rename to app/(app)/(identity)/(home-pages)/reader/FeedLayout.tsx diff --git a/app/(app)/(home-pages)/reader/FeedSkeleton.tsx b/app/(app)/(identity)/(home-pages)/reader/FeedSkeleton.tsx similarity index 100% rename from app/(app)/(home-pages)/reader/FeedSkeleton.tsx rename to app/(app)/(identity)/(home-pages)/reader/FeedSkeleton.tsx diff --git a/app/(app)/(home-pages)/reader/GlobalContent.tsx b/app/(app)/(identity)/(home-pages)/reader/GlobalContent.tsx similarity index 96% rename from app/(app)/(home-pages)/reader/GlobalContent.tsx rename to app/(app)/(identity)/(home-pages)/reader/GlobalContent.tsx index 71fe1324..9fe1481b 100644 --- a/app/(app)/(home-pages)/reader/GlobalContent.tsx +++ b/app/(app)/(identity)/(home-pages)/reader/GlobalContent.tsx @@ -3,7 +3,7 @@ import { use } from "react"; import useSWR from "swr"; import { EmptyState } from "components/EmptyState"; import { PostListing } from "components/PostListing"; -import type { Post } from "./getReaderFeed"; +import type { Post } from "actions/reader/getReaderFeed"; import { useSelectedPostListing } from "src/useSelectedPostState"; export const GlobalContent = (props: { diff --git a/app/(app)/(home-pages)/reader/InboxContent.tsx b/app/(app)/(identity)/(home-pages)/reader/InboxContent.tsx similarity index 96% rename from app/(app)/(home-pages)/reader/InboxContent.tsx rename to app/(app)/(identity)/(home-pages)/reader/InboxContent.tsx index c7e9bdcb..7108638d 100644 --- a/app/(app)/(home-pages)/reader/InboxContent.tsx +++ b/app/(app)/(identity)/(home-pages)/reader/InboxContent.tsx @@ -2,9 +2,9 @@ import { use } from "react"; import { ButtonPrimary } from "components/Buttons"; import { DiscoverSmall } from "components/Icons/DiscoverSmall"; -import type { Cursor, Post } from "./getReaderFeed"; +import type { Cursor, Post } from "actions/reader/getReaderFeed"; import useSWRInfinite from "swr/infinite"; -import { getReaderFeed } from "./getReaderFeed"; +import { getReaderFeed } from "actions/reader/getReaderFeed"; import { useEffect, useRef } from "react"; import Link from "next/link"; import { PostListing } from "components/PostListing"; diff --git a/app/(app)/(home-pages)/reader/NewContent.tsx b/app/(app)/(identity)/(home-pages)/reader/NewContent.tsx similarity index 95% rename from app/(app)/(home-pages)/reader/NewContent.tsx rename to app/(app)/(identity)/(home-pages)/reader/NewContent.tsx index 74f383f7..d4d3fd68 100644 --- a/app/(app)/(home-pages)/reader/NewContent.tsx +++ b/app/(app)/(identity)/(home-pages)/reader/NewContent.tsx @@ -2,9 +2,9 @@ import { use } from "react"; import { EmptyState } from "components/EmptyState"; -import type { Cursor, Post } from "./getReaderFeed"; +import type { Cursor, Post } from "actions/reader/getReaderFeed"; import useSWRInfinite from "swr/infinite"; -import { getNewFeed } from "./getNewFeed"; +import { getNewFeed } from "actions/reader/getNewFeed"; import { useEffect, useRef } from "react"; import { PostListing } from "components/PostListing"; import { useSelectedPostListing } from "src/useSelectedPostState"; diff --git a/app/(app)/(home-pages)/reader/hot/page.tsx b/app/(app)/(identity)/(home-pages)/reader/hot/page.tsx similarity index 100% rename from app/(app)/(home-pages)/reader/hot/page.tsx rename to app/(app)/(identity)/(home-pages)/reader/hot/page.tsx diff --git a/app/(app)/(home-pages)/reader/layout.tsx b/app/(app)/(identity)/(home-pages)/reader/layout.tsx similarity index 56% rename from app/(app)/(home-pages)/reader/layout.tsx rename to app/(app)/(identity)/(home-pages)/reader/layout.tsx index f6022786..60bfdb86 100644 --- a/app/(app)/(home-pages)/reader/layout.tsx +++ b/app/(app)/(identity)/(home-pages)/reader/layout.tsx @@ -1,14 +1,27 @@ +import { Suspense } from "react"; import { getIdentityData } from "actions/getIdentityData"; import { PageTitle } from "components/ActionBar/DesktopNavigation"; import { DashboardShell } from "components/PageLayouts/DashboardShell"; +import { FullPageLoading } from "components/PageLayouts/DashboardLoading"; import { ReaderUnreadSmall } from "components/Icons/ReaderSmall"; import { NewSmall } from "components/Icons/NewSmall"; import { TrendingSmall } from "components/Icons/TrendingSmall"; import { BlockMailboxSmall } from "components/Icons/BlockMailboxSmall"; -export default async function ReaderLayout(props: { - children: React.ReactNode; -}) { +// Synchronous shell + suspended inner, same as the (identity) and +// (home-pages) layouts above: a fresh mount of this segment (e.g. home → +// /reader) suspends inside their already-committed boundaries, which won't +// re-show their fallback mid-transition — so this segment needs its own +// boundary to commit against while getIdentityData resolves. +export default function ReaderLayout(props: { children: React.ReactNode }) { + return ( + }> + {props.children} + + ); +} + +async function ReaderLayoutInner(props: { children: React.ReactNode }) { const identity = await getIdentityData(); const tabs: { [name: string]: { href: string; icon: React.ReactNode } } = {}; if (identity?.atp_did) diff --git a/app/(app)/(identity)/(home-pages)/reader/loading.tsx b/app/(app)/(identity)/(home-pages)/reader/loading.tsx new file mode 100644 index 00000000..36d6a563 --- /dev/null +++ b/app/(app)/(identity)/(home-pages)/reader/loading.tsx @@ -0,0 +1,13 @@ +import { FeedSkeleton } from "./FeedSkeleton"; + +// The reader pages already suspend their feed behind FeedSkeleton, so use the +// same skeleton here rather than the shared spinner — otherwise a cold +// navigation flashes spinner → skeleton → posts. Padding mirrors +// DashboardPageLayout's content box, which the page mounts a beat later. +export default function Loading() { + return ( +++ ); +} diff --git a/app/(app)/(home-pages)/reader/new/page.tsx b/app/(app)/(identity)/(home-pages)/reader/new/page.tsx similarity index 91% rename from app/(app)/(home-pages)/reader/new/page.tsx rename to app/(app)/(identity)/(home-pages)/reader/new/page.tsx index 3edde47c..c56a3d72 100644 --- a/app/(app)/(home-pages)/reader/new/page.tsx +++ b/app/(app)/(identity)/(home-pages)/reader/new/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; -import { getNewFeed } from "../getNewFeed"; +import { getNewFeed } from "actions/reader/getNewFeed"; import { NewContent } from "../NewContent"; import { FeedSkeleton } from "../FeedSkeleton"; import { FeedLayout } from "../FeedLayout"; diff --git a/app/(app)/(home-pages)/reader/page.tsx b/app/(app)/(identity)/(home-pages)/reader/page.tsx similarity index 94% rename from app/(app)/(home-pages)/reader/page.tsx rename to app/(app)/(identity)/(home-pages)/reader/page.tsx index 207c0d76..7661466e 100644 --- a/app/(app)/(home-pages)/reader/page.tsx +++ b/app/(app)/(identity)/(home-pages)/reader/page.tsx @@ -2,7 +2,7 @@ import { Suspense } from "react"; import { redirect } from "next/navigation"; import { getIdentityData } from "actions/getIdentityData"; import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; -import { getReaderFeed } from "./getReaderFeed"; +import { getReaderFeed } from "actions/reader/getReaderFeed"; import { InboxContent } from "./InboxContent"; import { FeedSkeleton } from "./FeedSkeleton"; import { FeedLayout } from "./FeedLayout"; diff --git a/app/(app)/(home-pages)/reader/trending/page.tsx b/app/(app)/(identity)/(home-pages)/reader/trending/page.tsx similarity index 91% rename from app/(app)/(home-pages)/reader/trending/page.tsx rename to app/(app)/(identity)/(home-pages)/reader/trending/page.tsx index b87f1216..74d5f4eb 100644 --- a/app/(app)/(home-pages)/reader/trending/page.tsx +++ b/app/(app)/(identity)/(home-pages)/reader/trending/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; -import { getHotFeed } from "../getHotFeed"; +import { getHotFeed } from "actions/reader/getHotFeed"; import { GlobalContent } from "../GlobalContent"; import { FeedSkeleton } from "../FeedSkeleton"; import { FeedLayout } from "../FeedLayout"; diff --git a/app/(app)/(home-pages)/tag/[tag]/getDocumentsByTag.ts b/app/(app)/(identity)/(home-pages)/tag/[tag]/getDocumentsByTag.ts similarity index 96% rename from app/(app)/(home-pages)/tag/[tag]/getDocumentsByTag.ts rename to app/(app)/(identity)/(home-pages)/tag/[tag]/getDocumentsByTag.ts index 2e50d497..e932a38b 100644 --- a/app/(app)/(home-pages)/tag/[tag]/getDocumentsByTag.ts +++ b/app/(app)/(identity)/(home-pages)/tag/[tag]/getDocumentsByTag.ts @@ -1,10 +1,10 @@ "use server"; -import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getPublicationURL } from "src/utils/getPublicationURL"; import { supabaseServerClient } from "supabase/serverClient"; import { AtUri } from "@atproto/api"; import { idResolver } from "src/identity"; -import type { Post } from "app/(app)/(home-pages)/reader/getReaderFeed"; +import type { Post } from "actions/reader/getReaderFeed"; import { getDocumentPages, normalizeDocumentRecord, diff --git a/app/(app)/(home-pages)/tag/[tag]/layout.tsx b/app/(app)/(identity)/(home-pages)/tag/[tag]/layout.tsx similarity index 100% rename from app/(app)/(home-pages)/tag/[tag]/layout.tsx rename to app/(app)/(identity)/(home-pages)/tag/[tag]/layout.tsx diff --git a/app/(app)/(identity)/(home-pages)/tag/[tag]/loading.tsx b/app/(app)/(identity)/(home-pages)/tag/[tag]/loading.tsx new file mode 100644 index 00000000..f9434e2b --- /dev/null +++ b/app/(app)/(identity)/(home-pages)/tag/[tag]/loading.tsx @@ -0,0 +1,5 @@ +import { DashboardLoading } from "components/PageLayouts/DashboardLoading"; + +export default function Loading() { + return+ ; +} diff --git a/app/(app)/(home-pages)/tag/[tag]/page.tsx b/app/(app)/(identity)/(home-pages)/tag/[tag]/page.tsx similarity index 100% rename from app/(app)/(home-pages)/tag/[tag]/page.tsx rename to app/(app)/(identity)/(home-pages)/tag/[tag]/page.tsx diff --git a/app/(app)/[leaflet_id]/AddToHomeEffect.tsx b/app/(app)/(identity)/[leaflet_id]/AddToHomeEffect.tsx similarity index 94% rename from app/(app)/[leaflet_id]/AddToHomeEffect.tsx rename to app/(app)/(identity)/[leaflet_id]/AddToHomeEffect.tsx index 71613dbe..34ae2bae 100644 --- a/app/(app)/[leaflet_id]/AddToHomeEffect.tsx +++ b/app/(app)/(identity)/[leaflet_id]/AddToHomeEffect.tsx @@ -3,7 +3,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useRef } from "react"; import { mutate as swrMutate } from "swr"; -import { addDocToHome } from "app/(app)/(home-pages)/(writer)/home/storage"; +import { addDocToHome } from "src/utils/homeDocsStorage"; import { useIdentityData } from "components/IdentityProvider"; import { useReplicache } from "src/replicache"; import { replaceWithoutParams } from "src/utils/replaceWithoutParams"; diff --git a/app/(app)/[leaflet_id]/Footer.tsx b/app/(app)/(identity)/[leaflet_id]/Footer.tsx similarity index 95% rename from app/(app)/[leaflet_id]/Footer.tsx rename to app/(app)/(identity)/[leaflet_id]/Footer.tsx index f98e1196..2cac8ae4 100644 --- a/app/(app)/[leaflet_id]/Footer.tsx +++ b/app/(app)/(identity)/[leaflet_id]/Footer.tsx @@ -5,11 +5,11 @@ import { Media } from "components/Media"; import { ThemePopover } from "components/ThemeManager/ThemeSetter"; import { Toolbar } from "components/Toolbar"; import { FootnoteToolbar } from "components/Toolbar/FootnoteToolbarWrapper"; -import { ShareOptions } from "app/(app)/[leaflet_id]/actions/ShareOptions"; +import { ShareOptions } from "app/(app)/(identity)/[leaflet_id]/actions/ShareOptions"; import { AddToHomeButton, HomeButton, -} from "app/(app)/[leaflet_id]/actions/HomeButton"; +} from "app/(app)/(identity)/[leaflet_id]/actions/HomeButton"; import { PublishButton } from "./actions/PublishButton"; import { useEntitySetContext } from "components/EntitySetProvider"; import { Watermark } from "components/Watermark"; @@ -19,7 +19,7 @@ import { useIdentityData } from "components/IdentityProvider"; import { useEntity, useReplicache } from "src/replicache"; import { PostSettings } from "components/PostSettings"; import useSWR from "swr"; -import { getHomeDocs } from "app/(app)/(home-pages)/(writer)/home/storage"; +import { getHomeDocs } from "src/utils/homeDocsStorage"; import { useAddToHomeParam } from "./AddToHomeEffect"; export function hasBlockToolbar(blockType: string | null | undefined) { diff --git a/app/(app)/[leaflet_id]/Leaflet.tsx b/app/(app)/(identity)/[leaflet_id]/Leaflet.tsx similarity index 100% rename from app/(app)/[leaflet_id]/Leaflet.tsx rename to app/(app)/(identity)/[leaflet_id]/Leaflet.tsx diff --git a/app/(app)/[leaflet_id]/Sidebar.tsx b/app/(app)/(identity)/[leaflet_id]/Sidebar.tsx similarity index 91% rename from app/(app)/[leaflet_id]/Sidebar.tsx rename to app/(app)/(identity)/[leaflet_id]/Sidebar.tsx index e9a8a418..425e8202 100644 --- a/app/(app)/[leaflet_id]/Sidebar.tsx +++ b/app/(app)/(identity)/[leaflet_id]/Sidebar.tsx @@ -1,17 +1,17 @@ "use client"; import { Sidebar } from "components/ActionBar/Sidebar"; import { useEntitySetContext } from "components/EntitySetProvider"; -import { HelpButton } from "app/(app)/[leaflet_id]/actions/HelpButton"; +import { HelpButton } from "app/(app)/(identity)/[leaflet_id]/actions/HelpButton"; import { AddToHomeButton, HomeButton, -} from "app/(app)/[leaflet_id]/actions/HomeButton"; +} from "app/(app)/(identity)/[leaflet_id]/actions/HomeButton"; import { Media } from "components/Media"; import { useLeafletPublicationData, useLeafletPublicationPage, } from "components/PageSWRDataProvider"; -import { ShareOptions } from "app/(app)/[leaflet_id]/actions/ShareOptions"; +import { ShareOptions } from "app/(app)/(identity)/[leaflet_id]/actions/ShareOptions"; import { ThemePopover } from "components/ThemeManager/ThemeSetter"; import { PublishButton } from "./actions/PublishButton"; import { PostSettings } from "components/PostSettings"; @@ -20,7 +20,7 @@ import { BackToPubButton } from "./actions/BackToPubButton"; import { useIdentityData } from "components/IdentityProvider"; import { useReplicache } from "src/replicache"; import useSWR from "swr"; -import { getHomeDocs } from "app/(app)/(home-pages)/(writer)/home/storage"; +import { getHomeDocs } from "src/utils/homeDocsStorage"; import { useAddToHomeParam } from "./AddToHomeEffect"; export function LeafletSidebar() { diff --git a/app/(app)/[leaflet_id]/WelcomeModal.tsx b/app/(app)/(identity)/[leaflet_id]/WelcomeModal.tsx similarity index 100% rename from app/(app)/[leaflet_id]/WelcomeModal.tsx rename to app/(app)/(identity)/[leaflet_id]/WelcomeModal.tsx diff --git a/app/(app)/[leaflet_id]/actions/BackToPubButton.tsx b/app/(app)/(identity)/[leaflet_id]/actions/BackToPubButton.tsx similarity index 88% rename from app/(app)/[leaflet_id]/actions/BackToPubButton.tsx rename to app/(app)/(identity)/[leaflet_id]/actions/BackToPubButton.tsx index 699495be..5b4e617d 100644 --- a/app/(app)/[leaflet_id]/actions/BackToPubButton.tsx +++ b/app/(app)/(identity)/[leaflet_id]/actions/BackToPubButton.tsx @@ -1,4 +1,4 @@ -import { getBasePublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getBasePublicationURL } from "src/utils/getPublicationURL"; import { ActionButton } from "components/ActionBar/ActionButton"; import { GoBackSmall } from "components/Icons/GoBackSmall"; import { SpeedyLink } from "components/SpeedyLink"; diff --git a/app/(app)/[leaflet_id]/actions/HelpButton.tsx b/app/(app)/(identity)/[leaflet_id]/actions/HelpButton.tsx similarity index 100% rename from app/(app)/[leaflet_id]/actions/HelpButton.tsx rename to app/(app)/(identity)/[leaflet_id]/actions/HelpButton.tsx diff --git a/app/(app)/[leaflet_id]/actions/HomeButton.tsx b/app/(app)/(identity)/[leaflet_id]/actions/HomeButton.tsx similarity index 96% rename from app/(app)/[leaflet_id]/actions/HomeButton.tsx rename to app/(app)/(identity)/[leaflet_id]/actions/HomeButton.tsx index 56009706..e90444bf 100644 --- a/app/(app)/[leaflet_id]/actions/HomeButton.tsx +++ b/app/(app)/(identity)/[leaflet_id]/actions/HomeButton.tsx @@ -10,7 +10,7 @@ import { useSmoker } from "components/Toast"; import { AddToHomeSmall } from "components/Icons/AddToHomeSmall"; import { HomeSmall } from "components/Icons/HomeSmall"; import { produce } from "immer"; -import { addDocToHome } from "app/(app)/(home-pages)/(writer)/home/storage"; +import { addDocToHome } from "src/utils/homeDocsStorage"; import { mutate as swrMutate } from "swr"; export function HomeButton() { diff --git a/app/(app)/[leaflet_id]/actions/PublishButton.tsx b/app/(app)/(identity)/[leaflet_id]/actions/PublishButton.tsx similarity index 99% rename from app/(app)/[leaflet_id]/actions/PublishButton.tsx rename to app/(app)/(identity)/[leaflet_id]/actions/PublishButton.tsx index 200ded8d..36a27523 100644 --- a/app/(app)/[leaflet_id]/actions/PublishButton.tsx +++ b/app/(app)/(identity)/[leaflet_id]/actions/PublishButton.tsx @@ -1,6 +1,6 @@ "use client"; import { publishToPublication } from "actions/publishToPublication"; -import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getPublicationURL } from "src/utils/getPublicationURL"; import { ActionButton } from "components/ActionBar/ActionButton"; import { PubIcon, diff --git a/app/(app)/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx b/app/(app)/(identity)/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx similarity index 100% rename from app/(app)/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx rename to app/(app)/(identity)/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx diff --git a/app/(app)/[leaflet_id]/actions/ShareOptions/getShareLink.ts b/app/(app)/(identity)/[leaflet_id]/actions/ShareOptions/getShareLink.ts similarity index 100% rename from app/(app)/[leaflet_id]/actions/ShareOptions/getShareLink.ts rename to app/(app)/(identity)/[leaflet_id]/actions/ShareOptions/getShareLink.ts diff --git a/app/(app)/[leaflet_id]/actions/ShareOptions/index.tsx b/app/(app)/(identity)/[leaflet_id]/actions/ShareOptions/index.tsx similarity index 98% rename from app/(app)/[leaflet_id]/actions/ShareOptions/index.tsx rename to app/(app)/(identity)/[leaflet_id]/actions/ShareOptions/index.tsx index 45477c70..e2423638 100644 --- a/app/(app)/[leaflet_id]/actions/ShareOptions/index.tsx +++ b/app/(app)/(identity)/[leaflet_id]/actions/ShareOptions/index.tsx @@ -14,10 +14,7 @@ import { useLeafletPublicationData, } from "components/PageSWRDataProvider"; import { ShareSmall } from "components/Icons/ShareSmall"; -import { - getPublicationURL, - getDocumentURL, -} from "app/(app)/lish/createPub/getPublicationURL"; +import { getPublicationURL, getDocumentURL } from "src/utils/getPublicationURL"; import { AtUri } from "@atproto/syntax"; import { useIsMobile } from "src/hooks/isMobile"; import { LoginModal } from "components/LoginButton"; diff --git a/app/(app)/[leaflet_id]/icon.tsx b/app/(app)/(identity)/[leaflet_id]/icon.tsx similarity index 100% rename from app/(app)/[leaflet_id]/icon.tsx rename to app/(app)/(identity)/[leaflet_id]/icon.tsx diff --git a/app/(app)/[leaflet_id]/opengraph-image.tsx b/app/(app)/(identity)/[leaflet_id]/opengraph-image.tsx similarity index 100% rename from app/(app)/[leaflet_id]/opengraph-image.tsx rename to app/(app)/(identity)/[leaflet_id]/opengraph-image.tsx diff --git a/app/(app)/[leaflet_id]/page.tsx b/app/(app)/(identity)/[leaflet_id]/page.tsx similarity index 85% rename from app/(app)/[leaflet_id]/page.tsx rename to app/(app)/(identity)/[leaflet_id]/page.tsx index 5a90a3b5..b1fe3dd0 100644 --- a/app/(app)/[leaflet_id]/page.tsx +++ b/app/(app)/(identity)/[leaflet_id]/page.tsx @@ -1,4 +1,5 @@ import { Metadata } from "next"; +import { cache } from "react"; import * as Y from "yjs"; import * as base64 from "base64-js"; @@ -24,11 +25,19 @@ type Props = { // this is now a token id not leaflet! Should probs rename params: Promise<{ leaflet_id: string }>; }; + +// The page and its generateMetadata both need the token and the whole fact set; +// these are the two heaviest queries in the app, so they run once per request. +const getLeafletData = cache((token_id: string) => + get_leaflet_data.handler({ token_id }, { supabase: supabaseServerClient }), +); +const getInitialFacts = cache(async (root: string) => { + let { data } = await supabaseServerClient.rpc("get_facts", { root }); + return (data as unknown as Fact []) || []; +}); + export default async function LeafletPage(props: Props) { - let { result: res } = await get_leaflet_data.handler( - { token_id: (await props.params).leaflet_id }, - { supabase: supabaseServerClient }, - ); + let { result: res } = await getLeafletData((await props.params).leaflet_id); let rootEntity = res.data?.root_entity; if (!rootEntity || !res.data || res.data.blocked_by_admin) return ( @@ -41,13 +50,10 @@ export default async function LeafletPage(props: Props) { ); - let [{ data }, poll_data] = await Promise.all([ - supabaseServerClient.rpc("get_facts", { - root: rootEntity, - }), + let [initialFacts, poll_data] = await Promise.all([ + getInitialFacts(rootEntity), getPollData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), ]); - let initialFacts = (data as unknown as Fact []) || []; // Extract font settings from facts for server-side font loading const { headingFontId, bodyFontId } = extractFontsFromFacts( @@ -77,10 +83,7 @@ export default async function LeafletPage(props: Props) { } export async function generateMetadata(props: Props): Promise { - let { result: res } = await get_leaflet_data.handler( - { token_id: (await props.params).leaflet_id }, - { supabase: supabaseServerClient }, - ); + let { result: res } = await getLeafletData((await props.params).leaflet_id); let rootEntity = res.data?.root_entity; if (!rootEntity || !res.data) return { title: "Leaflet not found" }; let publication_data = getPublicationMetadataFromLeafletData(res.data); @@ -90,10 +93,7 @@ export async function generateMetadata(props: Props): Promise { description: publication_data.description, }; } - let { data } = await supabaseServerClient.rpc("get_facts", { - root: rootEntity, - }); - let initialFacts = (data as unknown as Fact []) || []; + let initialFacts = await getInitialFacts(rootEntity); let scan = scanIndexLocal(initialFacts); let firstPage = scan.eav(rootEntity, "root/page")[0]?.data.value || rootEntity; diff --git a/app/(app)/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx b/app/(app)/(identity)/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx similarity index 100% rename from app/(app)/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx rename to app/(app)/(identity)/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx diff --git a/app/(app)/[leaflet_id]/publish/PublishIllustration/PublishIllustration.tsx b/app/(app)/(identity)/[leaflet_id]/publish/PublishIllustration/PublishIllustration.tsx similarity index 98% rename from app/(app)/[leaflet_id]/publish/PublishIllustration/PublishIllustration.tsx rename to app/(app)/(identity)/[leaflet_id]/publish/PublishIllustration/PublishIllustration.tsx index 9f547e53..3ae89c71 100644 --- a/app/(app)/[leaflet_id]/publish/PublishIllustration/PublishIllustration.tsx +++ b/app/(app)/(identity)/[leaflet_id]/publish/PublishIllustration/PublishIllustration.tsx @@ -14,7 +14,7 @@ import { Star2, Star3, } from "./Stars"; -import { usePublicationData } from "app/(app)/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; +import { usePublicationData } from "app/(app)/(published)/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; // TODO diff --git a/app/(app)/[leaflet_id]/publish/PublishIllustration/Stars.tsx b/app/(app)/(identity)/[leaflet_id]/publish/PublishIllustration/Stars.tsx similarity index 100% rename from app/(app)/[leaflet_id]/publish/PublishIllustration/Stars.tsx rename to app/(app)/(identity)/[leaflet_id]/publish/PublishIllustration/Stars.tsx diff --git a/app/(app)/[leaflet_id]/publish/PublishPost.tsx b/app/(app)/(identity)/[leaflet_id]/publish/PublishPost.tsx similarity index 99% rename from app/(app)/[leaflet_id]/publish/PublishPost.tsx rename to app/(app)/(identity)/[leaflet_id]/publish/PublishPost.tsx index 7ed685c6..b4829198 100644 --- a/app/(app)/[leaflet_id]/publish/PublishPost.tsx +++ b/app/(app)/(identity)/[leaflet_id]/publish/PublishPost.tsx @@ -9,7 +9,7 @@ import { useParams } from "next/navigation"; import Link from "next/link"; import type { NormalizedPublication } from "src/utils/normalizeRecords"; -import { publishPostToBsky } from "./publishBskyPost"; +import { publishPostToBsky } from "actions/publishBskyPost"; import { viewerPostLangs } from "src/utils/bskyPostLangs"; import { ShareOptions, type ShareState } from "./ShareOptions"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; @@ -40,7 +40,7 @@ import { PublicationBackgroundProvider, } from "components/ThemeManager/PublicationThemeProvider"; import { useEntity } from "src/replicache"; -import { LeafletContent } from "app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletContent"; +import { LeafletContent } from "app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletContent"; import { Contributor } from "lexicons/api/types/site/standard/document"; import { BskyEmbed, diff --git a/app/(app)/[leaflet_id]/publish/ShareOptions.tsx b/app/(app)/(identity)/[leaflet_id]/publish/ShareOptions.tsx similarity index 100% rename from app/(app)/[leaflet_id]/publish/ShareOptions.tsx rename to app/(app)/(identity)/[leaflet_id]/publish/ShareOptions.tsx diff --git a/app/(app)/[leaflet_id]/publish/page.tsx b/app/(app)/(identity)/[leaflet_id]/publish/page.tsx similarity index 100% rename from app/(app)/[leaflet_id]/publish/page.tsx rename to app/(app)/(identity)/[leaflet_id]/publish/page.tsx diff --git a/app/(app)/admin/AdminEntitlements.tsx b/app/(app)/(identity)/admin/AdminEntitlements.tsx similarity index 100% rename from app/(app)/admin/AdminEntitlements.tsx rename to app/(app)/(identity)/admin/AdminEntitlements.tsx diff --git a/app/(app)/admin/import-subscribers/AdminImportSubscribers.tsx b/app/(app)/(identity)/admin/import-subscribers/AdminImportSubscribers.tsx similarity index 100% rename from app/(app)/admin/import-subscribers/AdminImportSubscribers.tsx rename to app/(app)/(identity)/admin/import-subscribers/AdminImportSubscribers.tsx diff --git a/app/(app)/admin/import-subscribers/page.tsx b/app/(app)/(identity)/admin/import-subscribers/page.tsx similarity index 100% rename from app/(app)/admin/import-subscribers/page.tsx rename to app/(app)/(identity)/admin/import-subscribers/page.tsx diff --git a/app/(app)/admin/page.tsx b/app/(app)/(identity)/admin/page.tsx similarity index 100% rename from app/(app)/admin/page.tsx rename to app/(app)/(identity)/admin/page.tsx diff --git a/app/(app)/checkout/pro/ProCheckoutLogin.tsx b/app/(app)/(identity)/checkout/pro/ProCheckoutLogin.tsx similarity index 100% rename from app/(app)/checkout/pro/ProCheckoutLogin.tsx rename to app/(app)/(identity)/checkout/pro/ProCheckoutLogin.tsx diff --git a/app/(app)/checkout/pro/ProCheckoutTrigger.tsx b/app/(app)/(identity)/checkout/pro/ProCheckoutTrigger.tsx similarity index 100% rename from app/(app)/checkout/pro/ProCheckoutTrigger.tsx rename to app/(app)/(identity)/checkout/pro/ProCheckoutTrigger.tsx diff --git a/app/(app)/checkout/pro/page.tsx b/app/(app)/(identity)/checkout/pro/page.tsx similarity index 100% rename from app/(app)/checkout/pro/page.tsx rename to app/(app)/(identity)/checkout/pro/page.tsx diff --git a/app/(app)/(identity)/layout.tsx b/app/(app)/(identity)/layout.tsx new file mode 100644 index 00000000..dd654292 --- /dev/null +++ b/app/(app)/(identity)/layout.tsx @@ -0,0 +1,56 @@ +import { headers } from "next/headers"; +import { Suspense } from "react"; +import { IdentityProviderServer } from "components/IdentityProviderServer"; +import { FullPageLoading } from "components/PageLayouts/DashboardLoading"; +import { RequestHeadersProvider } from "components/Providers/RequestHeadersProvider"; +import { RouteUIStateManager } from "components/RouteUIStateManger"; +import { SubscriptionSuccessModal } from "components/SubscriptionSuccessModal"; +import { SubscribeConfirmationModal } from "components/Subscribe/SubscribeConfirmationModal"; +import { RecommendConfirmationToast } from "components/Interactions/RecommendConfirmationToast"; + +// Identity-bearing surfaces (dashboard, editor, account flows): rendered +// per-request with the viewer's identity so chrome and theme are correct on +// the first frame. Published pages live in the (published) sibling group, +// which must stay free of request-coupled reads. +// +// The layout itself must stay synchronous, with the request-coupled work in a +// child behind Suspense: everything below reads request data, so a boundary +// above those awaits is the only committable shell this group has. Without it +// a client navigation into the group can't commit until the whole dynamic +// tree has rendered — the previous page just freezes with no feedback. (A +// loading.tsx can't provide this; it only covers segments below the blocking +// layout.) +export default function IdentityLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + }> + {children} + + ); +} + +async function IdentityLayoutInner({ + children, +}: { + children: React.ReactNode; +}) { + let headersList = await headers(); + let acceptLanguage = headersList.get("accept-language"); + let ipTimezone = headersList.get("X-Vercel-IP-Timezone"); + return ( ++ + ); +} diff --git a/app/(app)/merge-accounts/page.tsx b/app/(app)/(identity)/merge-accounts/page.tsx similarity index 100% rename from app/(app)/merge-accounts/page.tsx rename to app/(app)/(identity)/merge-accounts/page.tsx diff --git a/app/(app)/new/route.ts b/app/(app)/(identity)/new/route.ts similarity index 100% rename from app/(app)/new/route.ts rename to app/(app)/(identity)/new/route.ts diff --git a/app/(app)/(published)/layout.tsx b/app/(app)/(published)/layout.tsx new file mode 100644 index 00000000..f1232ba2 --- /dev/null +++ b/app/(app)/(published)/layout.tsx @@ -0,0 +1,29 @@ +import { Suspense } from "react"; +import { ViewerIdentityProvider } from "components/IdentityProvider"; +import { RouteUIStateManager } from "components/RouteUIStateManger"; +import { SubscriptionSuccessModal } from "components/SubscriptionSuccessModal"; +import { SubscribeConfirmationModal } from "components/Subscribe/SubscribeConfirmationModal"; +import { RecommendConfirmationToast } from "components/Interactions/RecommendConfirmationToast"; + +// Published, cacheable surfaces. Nothing in this layout (or reachable from any +// page in this group) may read cookies()/headers()/searchParams outside a +// Suspense boundary — identity arrives client-side via ViewerIdentityProvider. +// Identity-gated routes nested in this tree (pub dashboard/edit/etc.) mount +// their own server-fed IdentityContextProvider, which shadows this one. +export default function PublishedLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( ++ {children} + ++ ++ + + + + {children} + + ); +} diff --git a/app/(app)/lish/[did]/[publication]/.well-known/site.standard.publication/route.ts b/app/(app)/(published)/lish/[did]/[publication]/.well-known/site.standard.publication/route.ts similarity index 100% rename from app/(app)/lish/[did]/[publication]/.well-known/site.standard.publication/route.ts rename to app/(app)/(published)/lish/[did]/[publication]/.well-known/site.standard.publication/route.ts diff --git a/app/(app)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx b/app/(app)/(published)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx similarity index 96% rename from app/(app)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx rename to app/(app)/(published)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx index f3ae2aa9..4c1f4182 100644 --- a/app/(app)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx @@ -6,13 +6,13 @@ import { FontLoader } from "components/FontLoader"; import { SpeedyLink } from "components/SpeedyLink"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { wordmarkFromTheme } from "src/utils/wordmark"; -import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getPublicationURL } from "src/utils/getPublicationURL"; import { publishedNavPages } from "src/utils/publishedPageMetadata"; import { PublicationPostsList, type PublicationPostsListFakePost, } from "./PublicationPostsList"; -import { type PublicationPostsListPost } from "./buildPublicationPosts"; +import { type PublicationPostsListPost } from "src/utils/buildPublicationPosts"; type FakePost = PublicationPostsListFakePost; diff --git a/app/(app)/lish/[did]/[publication]/LocalizedDate.tsx b/app/(app)/(published)/lish/[did]/[publication]/LocalizedDate.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/LocalizedDate.tsx rename to app/(app)/(published)/lish/[did]/[publication]/LocalizedDate.tsx diff --git a/app/(app)/lish/[did]/[publication]/PaginatedPublicationPostsList.tsx b/app/(app)/(published)/lish/[did]/[publication]/PaginatedPublicationPostsList.tsx similarity index 96% rename from app/(app)/lish/[did]/[publication]/PaginatedPublicationPostsList.tsx rename to app/(app)/(published)/lish/[did]/[publication]/PaginatedPublicationPostsList.tsx index f242b097..82e65b91 100644 --- a/app/(app)/lish/[did]/[publication]/PaginatedPublicationPostsList.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/PaginatedPublicationPostsList.tsx @@ -4,11 +4,11 @@ import React, { useEffect, useRef } from "react"; import useSWRInfinite from "swr/infinite"; import { type NormalizedPublication } from "src/utils/normalizeRecords"; import { PublicationPostsList } from "./PublicationPostsList"; -import type { PublicationPostsListPost } from "./buildPublicationPosts"; +import type { PublicationPostsListPost } from "src/utils/buildPublicationPosts"; import { POSTS_LIST_PAGE_SIZE, type LoadPostsBatch, -} from "./postsListPagination"; +} from "src/utils/postsListPagination"; export function PaginatedPublicationPostsList({ publication, diff --git a/app/(app)/lish/[did]/[publication]/PublicationAuthor.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationAuthor.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/PublicationAuthor.tsx rename to app/(app)/(published)/lish/[did]/[publication]/PublicationAuthor.tsx diff --git a/app/(app)/lish/[did]/[publication]/PublicationHeader.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationHeader.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/PublicationHeader.tsx rename to app/(app)/(published)/lish/[did]/[publication]/PublicationHeader.tsx diff --git a/app/(app)/lish/[did]/[publication]/PublicationHomeLayout.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationHomeLayout.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/PublicationHomeLayout.tsx rename to app/(app)/(published)/lish/[did]/[publication]/PublicationHomeLayout.tsx diff --git a/app/(app)/lish/[did]/[publication]/PublicationNav.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationNav.tsx similarity index 89% rename from app/(app)/lish/[did]/[publication]/PublicationNav.tsx rename to app/(app)/(published)/lish/[did]/[publication]/PublicationNav.tsx index f1b1be75..daf2b53b 100644 --- a/app/(app)/lish/[did]/[publication]/PublicationNav.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/PublicationNav.tsx @@ -7,7 +7,7 @@ import { SubscribeButton, SubscribeInput, } from "components/Subscribe/SubscribeButton"; -import { useNavBackgroundFade } from "./useNavBackgroundFade"; +import { useNavBackgroundFade } from "src/hooks/useNavBackgroundFade"; import { ExternalLinkTiny } from "components/Icons/ExternalLinkTiny"; // Pages here are published snapshots (publishedNavPages output), where path @@ -96,11 +96,14 @@ export function PublicationNav(props: { })} -+ ++ + + + - {props.subscribe && props.hideSubscribeInHeader && ( + {props.subscribe && props.hideSubscribeInHeader && ( + /* Wide enough for the widest state the button resolves into (the + "Subscribed / Manage" row), so identity landing can't take width off + the tab strip when the tabs are crowded enough to scroll. */ ++ )}+- )} - ); } + // See PublicationNav: the slot is as wide as the widest state the button + // resolves into, so nothing beside it moves when identity lands. return ( -+); diff --git a/app/(app)/lish/[did]/[publication]/PublicationNavTabLink.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationNavTabLink.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/PublicationNavTabLink.tsx rename to app/(app)/(published)/lish/[did]/[publication]/PublicationNavTabLink.tsx diff --git a/app/(app)/lish/[did]/[publication]/PublicationPostItem/MembersBadge.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/MembersBadge.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/PublicationPostItem/MembersBadge.tsx rename to app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/MembersBadge.tsx diff --git a/app/(app)/lish/[did]/[publication]/PublicationPostItem/MetaRow.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/MetaRow.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/PublicationPostItem/MetaRow.tsx rename to app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/MetaRow.tsx diff --git a/app/(app)/lish/[did]/[publication]/PublicationPostItem/PostLink.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PostLink.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/PublicationPostItem/PostLink.tsx rename to app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PostLink.tsx diff --git a/app/(app)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemLarge.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemLarge.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemLarge.tsx rename to app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemLarge.tsx diff --git a/app/(app)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemMedium.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemMedium.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemMedium.tsx rename to app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemMedium.tsx diff --git a/app/(app)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemSmall.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemSmall.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemSmall.tsx rename to app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/PublicationPostItemSmall.tsx diff --git a/app/(app)/lish/[did]/[publication]/PublicationPostItem/index.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/index.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/PublicationPostItem/index.tsx rename to app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/index.tsx diff --git a/app/(app)/lish/[did]/[publication]/PublicationPostItem/types.ts b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/types.ts similarity index 100% rename from app/(app)/lish/[did]/[publication]/PublicationPostItem/types.ts rename to app/(app)/(published)/lish/[did]/[publication]/PublicationPostItem/types.ts diff --git a/app/(app)/lish/[did]/[publication]/PublicationPostsList.tsx b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostsList.tsx similarity index 98% rename from app/(app)/lish/[did]/[publication]/PublicationPostsList.tsx rename to app/(app)/(published)/lish/[did]/[publication]/PublicationPostsList.tsx index af3dab89..2325aa60 100644 --- a/app/(app)/lish/[did]/[publication]/PublicationPostsList.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/PublicationPostsList.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from "react"; import { AtUri } from "@atproto/api"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getDocumentURL } from "src/utils/getPublicationURL"; import { InteractionPreview } from "components/Interactions/InteractionsPreview"; import { LocalizedDate } from "./LocalizedDate"; import { PublicationPostItem } from "./DefaultPublicationHomepage"; @@ -23,7 +23,7 @@ import { import { buildPublicationPosts, type PublicationPostsListPost, -} from "./buildPublicationPosts"; +} from "src/utils/buildPublicationPosts"; // Re-exported so existing client-side imports keep working; the canonical // definitions live in the non-client `buildPublicationPosts` module. diff --git a/app/(app)/lish/[did]/[publication]/UpgradeModal.tsx b/app/(app)/(published)/lish/[did]/[publication]/UpgradeModal.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/UpgradeModal.tsx rename to app/(app)/(published)/lish/[did]/[publication]/UpgradeModal.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/BaseTextBlock.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PubCodeBlock.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PubCodeBlock.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PubCodeBlock.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PubCodeBlock.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishBskyPostBlock.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedImageGallery.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPollBlock.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPollBlock.tsx similarity index 77% rename from app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPollBlock.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPollBlock.tsx index ddd512d5..8f5e4c8d 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPollBlock.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPollBlock.tsx @@ -17,6 +17,7 @@ import { getVoterIdentities, VoterIdentity } from "../getVoterIdentities"; import { Json } from "supabase/database.types"; import { InfoSmall } from "components/Icons/InfoSmall"; import { LoginModal } from "components/LoginButton"; +import { ReservedGeometry } from "components/utils/ReservedGeometry"; // Helper function to extract the first option from a vote record const getVoteOption = (voteRecord: any): string | null => { @@ -92,6 +93,51 @@ export const PublishedPollBlock = (props: { identity?.atp_did && props.pollData.uri.includes(identity?.atp_did); const displayResults = showResults || hasVoted; + const votingView = ( + + {pollRecord.options.map((option, index) => ( ++ ); + return (setSelectedOption(index.toString())} + disabled={!identity?.atp_did} + /> + ))} + ++All votes are public++ + {identity?.atp_did ? ( +++ {isVoting ? "Voting..." : "Vote!"} + + ) : ( ++ Login to vote + + } + /> + )} + {displayResults ? ( - <> -); diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/StaticMathBlock.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/StaticMathBlock.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/Blocks/StaticMathBlock.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/StaticMathBlock.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/TextBlock.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx similarity index 99% rename from app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx index dc56f536..a5605bdc 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/BskyPostContent.tsx @@ -13,7 +13,7 @@ import { BlueskyLinkTiny } from "components/Icons/BlueskyLinkTiny"; import { Avatar } from "components/Avatar"; import { timeAgo } from "src/utils/timeAgo"; import { ProfilePopover } from "components/ProfilePopover"; -import { QuotePosition } from "./quotePosition"; +import { QuotePosition } from "src/utils/quotePosition"; import { QuoteContent } from "./Interactions/Quotes"; type PostView = AppBskyFeedDefs.PostView; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/CanvasPage.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/CanvasPage.tsx similarity index 99% rename from app/(app)/lish/[did]/[publication]/[rkey]/CanvasPage.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/CanvasPage.tsx index d3845609..673c88f1 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/CanvasPage.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/CanvasPage.tsx @@ -4,7 +4,7 @@ import { PubLeafletPagesLinearDocument, PubLeafletPublication, } from "lexicons/api"; -import { PostPageData } from "./getPostPageData"; +import { PostPageData } from "src/utils/getPostPageData"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { AppBskyFeedDefs } from "@atproto/api"; import { useMemo } from "react"; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx similarity index 52% rename from app/(app)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx index 535b4b9f..906ec760 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx @@ -4,18 +4,18 @@ import { PubLeafletPagesCanvas, } from "lexicons/api"; import { Suspense } from "react"; +import { notFound } from "next/navigation"; import { QuoteHandler } from "./QuoteHandler"; import { PublicationBackgroundProvider, PublicationThemeProvider, } from "components/ThemeManager/PublicationThemeProvider"; -import { getPostPageData } from "./getPostPageData"; +import { getPostPageData } from "src/utils/getPostPageData"; import { PostPages } from "./PostPages"; import { collectAndFetchBlockResources } from "./collectAndFetchBlockResources"; import { LeafletLayout } from "components/LeafletLayout"; import { getDocumentPages } from "src/utils/normalizeRecords"; -import { DocumentProvider } from "contexts/DocumentContext"; -import { LeafletContentProvider } from "contexts/LeafletContentContext"; +import { PostDataProvider } from "./PostDataProvider"; import { FontLoader } from "components/FontLoader"; import { mergePreferences } from "src/utils/mergePreferences"; import { CommentsSection } from "./Interactions/Comments/CommentsSection"; @@ -58,24 +58,7 @@ export async function DocumentPageRenderer({ const pages = record ? getDocumentPages(record) : undefined; - console.log("-------------"); - console.log(document?.data, record, pages); - console.log("-------------"); - - if (!document?.data || !record || !pages) - return ( -- {!hasVoted && ( - - -- )} - > - ) : ( - <> - {pollRecord.options.map((option, index) => ( -setSelectedOption(index.toString())} - disabled={!identity?.atp_did} + // The ballot is what static HTML ships; a viewer who turns out to have + // voted already gets results in its place, in the same box, so the rest + // of the post doesn't jump when identity resolves. + + + ) : ( + votingView )}+- > +- ))} - -+ )}All votes are public-- - {identity?.atp_did ? ( -+ Back to Voting + ++ - ) : ( -- Login to vote - - } - /> - )} - -- ); + if (!document?.data || !record || !pages) notFound(); // Resolve byline contributors. When the document has a non-empty // `contributors` array, render those profiles; otherwise fall back to the @@ -110,53 +93,54 @@ export async function DocumentPageRenderer({ let isStandalone = !pubRecord; return ( -----Sorry, post not found!
-- This may be a glitch on our end. If the issue persists please{" "} - send us a note. -
-- +- -- + + + -- - -- +- - - } - /> - + -+ + + } + /> + - + + + ); } diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotePopover.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotePopover.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotePopover.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotePopover.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnoteSideColumn.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnoteSideColumn.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnoteSideColumn.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnoteSideColumn.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotes.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotes.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotes.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Footnotes/PublishedFootnotes.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/GlobalImageLightbox.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/GlobalImageLightbox.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/GlobalImageLightbox.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/GlobalImageLightbox.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx similarity index 99% rename from app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx index b231ebcf..66b72990 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox.tsx @@ -34,7 +34,7 @@ import { decodeQuotePosition, QUOTE_PARAM, QuotePosition, -} from "../../quotePosition"; +} from "src/utils/quotePosition"; import { QuoteContent } from "../Quotes"; import { create } from "zustand"; import { CloseTiny } from "components/Icons/CloseTiny"; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentsSection.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentsSection.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentsSection.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentsSection.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts similarity index 98% rename from app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts index caa256a4..6baa53ac 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Comments/commentAction.ts @@ -1,7 +1,7 @@ "use server"; import { AtpBaseClient, PubLeafletComment } from "lexicons/api"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { PubLeafletRichtextFacet } from "lexicons/api"; import { restoreOAuthSession, @@ -36,7 +36,7 @@ export async function publishComment(args: { attachment: PubLeafletComment.Record["attachment"]; }; }): Promise { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !identity.atp_did) { return { success: false, diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx similarity index 99% rename from app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx index e3fb6cb4..524cc31f 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/InteractionDrawer.tsx @@ -8,7 +8,7 @@ import { popDrawerThreadToRoot, } from "./Interactions"; import { SandwichSpacer } from "components/LeafletLayout"; -import { decodeQuotePosition } from "../quotePosition"; +import { decodeQuotePosition } from "src/utils/quotePosition"; import { CloseTiny } from "components/Icons/CloseTiny"; import { GoBackTiny } from "components/Icons/GoBackTiny"; import { DoubleArrowRightTiny } from "components/Icons/DoubleArrowRightTiny"; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx similarity index 91% rename from app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx index 8f49a23f..ef500027 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Interactions.tsx @@ -3,26 +3,19 @@ import { flushSync } from "react-dom"; import type { Json } from "supabase/database.types"; import { create } from "zustand"; import type { Comment } from "./Comments"; -import { decodeQuotePosition, QuotePosition } from "../quotePosition"; +import { decodeQuotePosition, QuotePosition } from "src/utils/quotePosition"; import { useDocument } from "contexts/DocumentContext"; import { scrollIntoView } from "src/utils/scrollIntoView"; -import { TagTiny } from "components/Icons/TagTiny"; import { Tag } from "components/Tags"; -import { Popover } from "components/Popover"; import { prefetchQuotesData } from "./Quotes"; -import { useIdentityData } from "components/IdentityProvider"; -import { ManageSubscription } from "components/Subscribe/ManageSubscribe"; -import { useViewerSubscription } from "components/Subscribe/viewerSubscription"; +import { usePostEditLink } from "../usePostEditLink"; import { EditTiny } from "components/Icons/EditTiny"; import { RecommendButton } from "components/Interactions/RecommendButton"; import { DiscussionButton } from "components/Interactions/DiscussionButton"; -import { ButtonSecondary } from "components/Buttons"; -import { Separator } from "components/Layout"; import { useMemo } from "react"; import { type DrawerThread, DrawerThreadContext } from "./drawerThreadContext"; -import { ShareButton } from "app/(app)/[leaflet_id]/actions/ShareOptions"; import { InteractionShareButton } from "components/Interactions/InteractionShareButton"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getDocumentURL } from "src/utils/getPublicationURL"; import { ShareSmall } from "components/Icons/ShareSmall"; export type InteractionState = { @@ -187,7 +180,6 @@ export const Interactions = (props: { normalizedPublication, publication, } = useDocument(); - let { identity } = useIdentityData(); let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); @@ -288,11 +280,9 @@ export const ExpandedInteractions = (props: { normalizedDocument, normalizedPublication, publication, - leafletId, } = useDocument(); let { drawerOpen, drawer, pageId } = useInteractionState(document_uri); - let viewer = useViewerSubscription(publication?.uri ?? ""); // See Interactions above: share the canonical post url, not location.href. let postUrl = getDocumentURL( @@ -345,7 +335,7 @@ export const ExpandedInteractions = (props: { )}
-+@@ -446,21 +436,25 @@ function getQuoteCountFromArray( const EditButton = (props: { publication: { identity_did: string } | null; - leafletId: string | null; + documentUri: string; }) => { - let { identity } = useIdentityData(); - if ( - identity && - identity.atp_did === props.publication?.identity_did && - props.leafletId - ) + let editLink = usePostEditLink( + props.documentUri, + props.publication?.identity_did, + ); + if (editLink) return ( + // Trails the row out of flow: the row is centered, so on a published page + // (where identity only resolves after first paint) an in-flow pill would + // re-center every other interaction button underneath the reader.{noInteractions ? ( ) : ( @@ -398,7 +388,7 @@ export const ExpandedInteractions = (props: {} /> -+ )} - Edit + {/* Out of flow it can't shrink the row to make room, so on phone-width + cards it drops to the icon alone rather than running off the edge. */} + Edit ); return null; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx similarity index 99% rename from app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx index 2a1cefab..7567d029 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Quotes.tsx @@ -18,7 +18,7 @@ import { getDocumentUrls, matchDocumentUrl, QuotePosition, -} from "../quotePosition"; +} from "src/utils/quotePosition"; import { useActiveHighlightState } from "../useHighlight"; import { PostContent } from "../PostContent"; import { ProfileViewBasic } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/StandardSitePostDrawerView.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/StandardSitePostDrawerView.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/Interactions/StandardSitePostDrawerView.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/StandardSitePostDrawerView.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/drawerThreadContext.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDocumentDiscussionData.ts b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/useDocumentDiscussionData.ts similarity index 96% rename from app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDocumentDiscussionData.ts rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/useDocumentDiscussionData.ts index fdb901ac..38762514 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDocumentDiscussionData.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/useDocumentDiscussionData.ts @@ -8,7 +8,7 @@ import { type NormalizedDocument, type NormalizedPublication, } from "src/utils/normalizeRecords"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getDocumentURL } from "src/utils/getPublicationURL"; import type { Comment } from "./Comments"; type DocumentInteractionsData = { @@ -67,7 +67,6 @@ export function useDocumentDiscussionData( commentsCount: data?.comments.length ?? 0, commentsCountByPage, mentions: [], - leafletId: null, recommendsCount: 0, } as unknown as DocumentContextValue) : null; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDrawerOpen.ts b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/useDrawerOpen.ts similarity index 91% rename from app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDrawerOpen.ts rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/useDrawerOpen.ts index 528c5993..690cf255 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/Interactions/useDrawerOpen.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/useDrawerOpen.ts @@ -1,5 +1,5 @@ "use client"; -import { useSearchParams } from "next/navigation"; +import { useClientSearchParams } from "src/hooks/useClientSearchParams"; import { useIsMobile } from "src/hooks/isMobile"; import { InteractionState, useInteractionState } from "./Interactions"; @@ -14,7 +14,7 @@ export const useInlineDrawer = (uri: string) => { }; export const useDrawerOpen = (uri: string) => { - let params = useSearchParams(); + let params = useClientSearchParams(); let interactionDrawerSearchParam = params.get("interactionDrawer"); let pageParam = params.get("page"); let { drawerOpen: open, drawer, pageId } = useInteractionState(uri); diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx similarity index 98% rename from app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx index d757f146..d53e4502 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/LinearDocumentPage.tsx @@ -11,7 +11,7 @@ import { AppBskyFeedDefs } from "@atproto/api"; import { useInlineDrawer } from "./Interactions/useDrawerOpen"; import { DrawerThreadPageProvider } from "./Interactions/drawerThreadContext"; import { PageWrapper } from "components/Pages/Page"; -import { decodeQuotePosition } from "./quotePosition"; +import { decodeQuotePosition } from "src/utils/quotePosition"; import { PollData } from "./fetchPollData"; import { SharedPageProps } from "./PostPages"; import { PostPrevNextButtons } from "./PostPrevNextButtons"; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/MembersOnlyPaywall.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/MembersOnlyPaywall.tsx similarity index 73% rename from app/(app)/lish/[did]/[publication]/[rkey]/MembersOnlyPaywall.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/MembersOnlyPaywall.tsx index b59cb077..71460c4e 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/MembersOnlyPaywall.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/MembersOnlyPaywall.tsx @@ -3,7 +3,9 @@ import { useEffect, useState } from "react"; import { useDocumentOptional } from "contexts/DocumentContext"; import { ButtonPrimary } from "components/Buttons"; import { LockTiny } from "components/Icons/LockTiny"; +import { LoadingTiny } from "components/Icons/LoadingTiny"; import { JoinMembershipModal } from "components/Memberships/JoinMembershipModal"; +import { useUnlockStatus } from "./PostDataProvider"; const formatPrice = (cents: number) => (cents / 100).toLocaleString("en-US", { @@ -16,6 +18,7 @@ const formatPrice = (cents: number) => // active membership; the gated blocks were already dropped server-side. export const MembersOnlyPaywall = () => { let document = useDocumentOptional(); + let unlockStatus = useUnlockStatus(); let [joinOpen, setJoinOpen] = useState(false); // Reopen the join modal after flows that leave the page: the Stripe hosted @@ -38,6 +41,22 @@ export const MembersOnlyPaywall = () => { let pub = document?.publication; if (!pub) return null; + + // The membership check runs client-side (the page itself is cached and + // anonymous). While it's pending the viewer may turn out to be an entitled + // member, so hold the join pitch — flashing it and then swapping in the full + // post reads as a glitch. "loading" only happens for viewers with a session + // marker; definitely-anonymous readers get the pitch immediately. + if (unlockStatus === "loading") + return ( + ++ ); + let tiers = document?.membersOnly?.tiers ?? []; let cheapest = tiers[0] ? tiers.reduce((min, t) => diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PostContent.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostContent.tsx similarity index 99% rename from app/(app)/lish/[did]/[publication]/[rkey]/PostContent.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostContent.tsx index da1496d8..2cbf79e7 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PostContent.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostContent.tsx @@ -27,7 +27,7 @@ import { } from "lexicons/api"; import { type PublicationPostsListPost } from "../PublicationPostsList"; import { PaginatedPublicationPostsList } from "../PaginatedPublicationPostsList"; -import { postsListFilterKey } from "../postsListPagination"; +import { postsListFilterKey } from "src/utils/postsListPagination"; import { getPostsByUris } from "../getPostsByUris"; import type { NormalizedPublication } from "src/utils/normalizeRecords"; @@ -235,9 +235,9 @@ export let Block = ({ ? "" : PubLeafletBlocksHeader.isMain(prevBlock) ? "mt-1" - : ({ 1: "mt-5 sm:mt-6", 2: "mt-4 sm:mt-5", 3: "mt-2 sm:mt-3" }[ + : { 1: "mt-5 sm:mt-6", 2: "mt-4 sm:mt-5", 3: "mt-2 sm:mt-3" }[ b.block.level ?? 1 - ] ?? "mt-2 sm:mt-3"); + ] ?? "mt-2 sm:mt-3"; } else { topMargin = isFirst ? "mt-0" : "mt-1"; } @@ -559,9 +559,7 @@ export let Block = ({ type="button" className={`block ${isFullBleed ? "w-full" : "w-fit"} ${canOpenLightbox ? "cursor-pointer" : ""}`} onClick={ - canOpenLightbox - ? () => openLightbox?.(pageId, src) - : undefined + canOpenLightbox ? () => openLightbox?.(pageId, src) : undefined } >+++ Checking access + (null); +const UnlockStatusContext = createContext
("idle"); + +export function usePostResources() { + const ctx = useContext(PostResourcesContext); + if (!ctx) + throw new Error("usePostResources must be used within PostDataProvider"); + return ctx; +} + +export function useUnlockStatus() { + return useContext(UnlockStatusContext); +} + +// Owns everything the members-only unlock replaces. The gated flag, the pages +// and the three block-resource channels all hang off one piece of state, so an +// unlock flips them in a single render — the paywall can never be on screen +// next to the blocks it's supposed to be hiding, and vice versa. +export function PostDataProvider(props: { + document: NonNullable ; + initial: PostResources; + children: React.ReactNode; +}) { + const { document, initial } = props; + const { identity, identityPending } = useIdentityData(); + + const documentUri = document.uri; + const gated = !!document.membersOnly?.gated; + // Keyed per (post, viewer) in the SWR module cache so the unlock survives + // navigations — a member returning to a post they already unlocked renders + // the full content on the first frame instead of re-flashing the paywall + // while a fresh entitlement check round-trips. A join completes via a full + // page reload, so a cached { entitled: false } can't go stale mid-session. + const { data, error } = useSWRImmutable( + gated && identity?.id ? ["unlocked-post", documentUri, identity.id] : null, + () => getUnlockedPost(documentUri), + ); + const unlocked: UnlockedPost | null = data?.entitled ? data : null; + + // While the viewer identity or the unlock itself is still resolving, an + // entitled member would otherwise see a fully-enabled join CTA for a post + // they can read — surface the pending state until the check settles. + const effectiveStatus: UnlockStatus = + !gated || unlocked + ? "idle" + : error + ? "error" + : identityPending || (identity?.id && !data) + ? "loading" + : "idle"; + + const resources = unlocked ?? initial; + const documentValue = useMemo( + () => + unlocked && document.membersOnly + ? { + ...document, + membersOnly: { ...document.membersOnly, gated: false }, + } + : document, + [document, unlocked], + ); + const content = useMemo( + () => ({ pages: resources.pages }), + [resources.pages], + ); + + return ( + + + ); +} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx similarity index 86% rename from app/(app)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx index 983cb7c9..43ce47eb 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader.tsx @@ -1,13 +1,12 @@ "use client"; -import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getPublicationURL } from "src/utils/getPublicationURL"; import { Interactions, getQuoteCount } from "../Interactions/Interactions"; -import { PostPageData } from "../getPostPageData"; +import { PostPageData } from "src/utils/getPostPageData"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; -import { useIdentityData } from "components/IdentityProvider"; +import { usePostEditLink } from "../usePostEditLink"; import { EditTiny } from "components/Icons/EditTiny"; import { SpeedyLink } from "components/SpeedyLink"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; -import Post from "app/(app)/p/[didOrHandle]/[rkey]/l-quote/[quote]/page"; import { Separator } from "components/Layout"; import { ProfilePopover } from "components/ProfilePopover"; import { Fragment } from "react"; @@ -36,12 +35,12 @@ export function PostHeader(props: { }; isCanvas?: boolean; }) { - let { identity } = useIdentityData(); let document = props.data; const record = document?.normalizedDocument; let profile = props.profile; let pub = props.data?.documents_in_publications[0]?.publications; + let editLink = usePostEditLink(document?.uri, pub?.identity_did); if (!document?.data || !record) return null; return ( @@ -56,17 +55,18 @@ export function PostHeader(props: { {pub?.name} )} - {identity && - pub && - identity.atp_did === pub.identity_did && - document.leaflets_in_publications[0] && ( - -+ ++ ++ {props.children} + +- - )} + {editLink && ( + // Out of flow, in the header's own padding: identity only + // resolves after first paint on a published page, and in flow this + // takes width off the pub name — enough to wrap it and push the + // title and byline down. + + + + )} > } postTitle={record.title} @@ -178,7 +178,7 @@ export const PostHeaderLayout = (props: { className="postHeader w-full flex flex-col px-3 sm:px-4 sm:pt-3 pt-2 pb-5" id="post-header" > - +{props.pubLink}{props.postTitle && ( diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PostLinks.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostLinks.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/PostLinks.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostLinks.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PostPages.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostPages.tsx similarity index 96% rename from app/(app)/lish/[did]/[publication]/[rkey]/PostPages.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostPages.tsx index f845cb50..3400cc6e 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PostPages.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostPages.tsx @@ -7,7 +7,7 @@ import { import { type NormalizedPublication } from "src/utils/normalizeRecords"; import { useLeafletContent } from "contexts/LeafletContentContext"; import { useDocument } from "contexts/DocumentContext"; -import { PostPageData } from "./getPostPageData"; +import { PostPageData } from "src/utils/getPostPageData"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { AppBskyFeedDefs } from "@atproto/api"; import { InteractionDrawer } from "./Interactions/InteractionDrawer"; @@ -31,6 +31,7 @@ import { closePage, } from "./postPageState"; import { IframePageView } from "components/Pages/IframePageView"; +import { usePostResources } from "./PostDataProvider"; import type { BylineProfile } from "./PostHeader/PostHeader"; export type { OpenPage }; @@ -99,10 +100,7 @@ export function PostPages({ preferences, pubRecord, prerenderedCodeBlocks, - bskyPostData, - standardSitePostData, document_uri, - pollData, commentsSlot, }: { document_uri: string; @@ -112,8 +110,6 @@ export function PostPages({ pubRecord?: NormalizedPublication | null; did: string; prerenderedCodeBlocks?: Map; - bskyPostData: AppBskyFeedDefs.PostView[]; - standardSitePostData: StandardSitePostData[]; preferences: { showComments?: boolean; showMentions?: boolean; @@ -122,13 +118,15 @@ export function PostPages({ showFirstLast?: boolean; prevNextDirection?: string; }; - pollData: PollData[]; commentsSlot: React.ReactNode; }) { let drawer = useInlineDrawer(document_uri); useInitializeOpenPages(); let openPageIds = useOpenPages(); const { pages } = useLeafletContent(); + // Not props: a members-only unlock swaps the pages and these three channels + // together, and only PostDataProvider knows when that happened. + const { bskyPostData, standardSitePostData, pollData } = usePostResources(); const { quotesAndMentions } = useDocument(); const record = document?.normalizedDocument; if (!document || !record) return null; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx similarity index 98% rename from app/(app)/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx index bbcff84d..efd797c3 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostPrevNextButtons.tsx @@ -1,5 +1,5 @@ "use client"; -import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getPublicationURL } from "src/utils/getPublicationURL"; import { AtUri } from "@atproto/api"; import { useDocument } from "contexts/DocumentContext"; import { SpeedyLink } from "components/SpeedyLink"; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx similarity index 96% rename from app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx index c6ba4714..9b6cbcbd 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx @@ -19,16 +19,16 @@ import { FontLoader } from "components/FontLoader"; import { LeafletContentProvider } from "contexts/LeafletContentContext"; import { DocumentProvider } from "contexts/DocumentContext"; import type { DocumentContextValue } from "contexts/DocumentContext"; -import { buildPublicationPosts } from "../buildPublicationPosts"; +import { buildPublicationPosts } from "src/utils/buildPublicationPosts"; import { fetchPublicationPostRows } from "../getPublicationForPage"; import { POSTS_LIST_PAGE_SIZE, postsListFilterKey, sortPostsForList, filterPostsByTags, -} from "../postsListPagination"; +} from "src/utils/postsListPagination"; import { PublicationHomeLayout } from "../PublicationHomeLayout"; -import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getPublicationURL } from "src/utils/getPublicationURL"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { wordmarkFromTheme } from "src/utils/wordmark"; import { publishedNavPages } from "src/utils/publishedPageMetadata"; @@ -174,14 +174,11 @@ export async function PublicationPageRenderer({ record: publication.record as NonNullable< DocumentContextValue["publication"] >["record"], - publication_subscriptions: [], newsletterMode: !!publication.publication_newsletter_settings?.enabled, - pages: (publication.publication_pages ?? []).filter((p) => p.record_uri), }, commentsCount: 0, commentsCountByPage: {}, mentions: [], - leafletId: null, recommendsCount: 0, }; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx similarity index 99% rename from app/(app)/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx index a6ef861c..e19a246b 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/QuoteHandler.tsx @@ -8,7 +8,7 @@ import { encodeQuotePosition, decodeQuotePosition, QuotePosition, -} from "./quotePosition"; +} from "src/utils/quotePosition"; import { useIdentityData } from "components/IdentityProvider"; import { CommentTiny } from "components/Icons/CommentTiny"; import { setInteractionState } from "./Interactions/Interactions"; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx similarity index 99% rename from app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx index 86cb695a..3b739895 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/ThreadPage.tsx @@ -29,7 +29,7 @@ import { getDocumentUrls, matchDocumentUrl, type QuotePosition, -} from "./quotePosition"; +} from "src/utils/quotePosition"; type ThreadViewPost = AppBskyFeedDefs.ThreadViewPost; type NotFoundPost = AppBskyFeedDefs.NotFoundPost; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/collectAndFetchBlockResources.ts b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/collectAndFetchBlockResources.ts similarity index 89% rename from app/(app)/lish/[did]/[publication]/[rkey]/collectAndFetchBlockResources.ts rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/collectAndFetchBlockResources.ts index f7f208bd..2166a50d 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/collectAndFetchBlockResources.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/collectAndFetchBlockResources.ts @@ -13,7 +13,7 @@ import { get_standard_site_posts, type StandardSitePostData, } from "app/api/rpc/[command]/get_standard_site_posts"; -import { extractBlocksByType } from "./extractBlocksByType"; +import { extractBlocksByType } from "src/utils/extractBlocksByType"; import { extractCodeBlocks } from "./extractCodeBlocks"; import { fetchPollData, type PollData } from "./fetchPollData"; import { fetchBskyPosts } from "src/utils/fetchBskyPosts"; @@ -26,10 +26,15 @@ export async function collectAndFetchBlockResources({ agent, pages, openPageId, + skipCodeBlocks, }: { agent: AtpAgent; pages: Page[]; openPageId?: string; + // For callers whose output never reaches the SSR HTML (the members-only + // unlock action): PubCodeBlock re-highlights on mount anyway, so running + // shiki server-side would be pure waste. + skipCodeBlocks?: boolean; }): Promise<{ bskyPostData: AppBskyFeedDefs.PostView[]; standardSitePostData: StandardSitePostData[]; @@ -78,6 +83,7 @@ export async function collectAndFetchBlockResources({ const prerenderedCodeBlocks = new Map (); await Promise.all( pages.map(async (page, pageIndex) => { + if (skipCodeBlocks) return; const isServerRendered = pageIndex === 0 || (!!openPageId && page.id === openPageId); if (!isServerRendered) return; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/collectPostImages.ts b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/collectPostImages.ts similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/collectPostImages.ts rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/collectPostImages.ts diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/extractCodeBlocks.ts b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/extractCodeBlocks.ts similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/extractCodeBlocks.ts rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/extractCodeBlocks.ts diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/fetchPollData.ts b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/fetchPollData.ts similarity index 71% rename from app/(app)/lish/[did]/[publication]/[rkey]/fetchPollData.ts rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/fetchPollData.ts index 26311d55..4b668f67 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/fetchPollData.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/fetchPollData.ts @@ -1,6 +1,5 @@ "use server"; -import { getIdentityData } from "actions/getIdentityData"; import { Json } from "supabase/database.types"; import { supabaseServerClient } from "supabase/serverClient"; @@ -12,10 +11,6 @@ export type PollData = { }; export async function fetchPollData(pollUris: string[]): Promise { - // Get current user's identity to check if they've voted - const identity = await getIdentityData(); - const userDid = identity?.atp_did; - const { data } = await supabaseServerClient .from("atp_poll_records") .select(`*, atp_poll_votes(*)`) diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/getVoterIdentities.ts b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/getVoterIdentities.ts similarity index 100% rename from app/(app)/lish/[did]/[publication]/[rkey]/getVoterIdentities.ts rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/getVoterIdentities.ts diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/l-quote/[quote]/opengraph-image.ts b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/l-quote/[quote]/opengraph-image.ts similarity index 87% rename from app/(app)/lish/[did]/[publication]/[rkey]/l-quote/[quote]/opengraph-image.ts rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/l-quote/[quote]/opengraph-image.ts index 8624963c..a93a208d 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/l-quote/[quote]/opengraph-image.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/l-quote/[quote]/opengraph-image.ts @@ -1,11 +1,16 @@ import { ogScreenshotResponse } from "src/utils/screenshotPage"; -import { decodeQuotePosition } from "../../quotePosition"; +import { decodeQuotePosition } from "src/utils/quotePosition"; // OG content is effectively immutable post-publish, and each regeneration is a // multi-second remote-browser render billed for its full wall time — unfurl // bots re-fetch these constantly. export const revalidate = 86400; +export async function generateStaticParams() { + return []; +} + + export default async function OpenGraphImage(props: { params: Promise<{ publication: string; diff --git a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/l-quote/[quote]/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/l-quote/[quote]/page.tsx new file mode 100644 index 00000000..3ebba44e --- /dev/null +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/l-quote/[quote]/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from "next/navigation"; +import { decodeQuotePosition } from "src/utils/quotePosition"; +import PostPage, { + generateMetadata, +} from "app/(app)/(published)/lish/[did]/[publication]/[rkey]/page"; + +// On-demand ISR: rendered on first request, then served from the CDN and +// re-rendered in the background. The empty generateStaticParams is what opts a +// dynamic-params route into caching at all — `revalidate` alone leaves it +// fully dynamic. Writes invalidate eagerly via revalidatePublicationPaths. +export const revalidate = 300; +export async function generateStaticParams() { + return []; +} + +export { generateMetadata } from "app/(app)/(published)/lish/[did]/[publication]/[rkey]/page"; +export default async function Post(props: { + params: Promise<{ + publication: string; + did: string; + rkey: string; + quote: string; + }>; +}) { + // Garbage quote params would each mint a permanent ISR entry. + if (!decodeQuotePosition(decodeURIComponent((await props.params).quote))) + notFound(); + return ; +} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/opengraph-image.ts b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/opengraph-image.ts similarity index 94% rename from app/(app)/lish/[did]/[publication]/[rkey]/opengraph-image.ts rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/opengraph-image.ts index 96a96618..e0b0032d 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/opengraph-image.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/opengraph-image.ts @@ -3,13 +3,18 @@ import { supabaseServerClient } from "supabase/serverClient"; import { jsonToLex } from "@atproto/lexicon"; import { fetchAtprotoBlob } from "app/api/atproto_images/route"; import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; -import { resolveDocumentFilter } from "../resolveDocumentFilter"; +import { resolveDocumentFilter } from "src/utils/resolveDocumentFilter"; // OG content is effectively immutable post-publish, and each regeneration is a // multi-second remote-browser render billed for its full wall time — unfurl // bots re-fetch these constantly. export const revalidate = 86400; +export async function generateStaticParams() { + return []; +} + + export default async function OpenGraphImage(props: { params: Promise<{ publication: string; did: string; rkey: string }>; }) { diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/page.tsx similarity index 75% rename from app/(app)/lish/[did]/[publication]/[rkey]/page.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/page.tsx index 5aa7d891..6c971d71 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/page.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/page.tsx @@ -1,9 +1,19 @@ import { Metadata } from "next"; +import { notFound } from "next/navigation"; import { DocumentPageRenderer } from "./DocumentPageRenderer"; import { fetchPublicationForPage } from "../getPublicationForPage"; import { tryRenderPublicationPage } from "../tryRenderPublicationPage"; import { postPageMetadata } from "./postPageMetadata"; -import { decodeQuotePosition } from "./quotePosition"; +import { decodeQuotePosition } from "src/utils/quotePosition"; + +// On-demand ISR: rendered on first request, then served from the CDN and +// re-rendered in the background. The empty generateStaticParams is what opts a +// dynamic-params route into caching at all — `revalidate` alone leaves it +// fully dynamic. Writes invalidate eagerly via revalidatePublicationPaths. +export const revalidate = 300; +export async function generateStaticParams() { + return []; +} export async function generateMetadata(props: { params: Promise<{ publication: string; did: string; rkey: string }>; @@ -31,23 +41,12 @@ export default async function Post(props: { let did = decodeURIComponent(params.did); let publication_name = decodeURIComponent(params.publication); let rkey = decodeURIComponent(params.rkey); - console.log("yo what"); - if (!did) - return ( - -- ); + if (!did) notFound(); // A /Sorry, we can't find that handle!
-- This may be a glitch on our end. If the issue persists please{" "} - send us a note. -
-URL is either a published publication page or a post; render the // matching page, else fall back to the single-post renderer. const publication = await fetchPublicationForPage(did, publication_name); - console.log(publication); if (publication) { const pageRender = tryRenderPublicationPage({ did, diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/postPageMetadata.ts b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/postPageMetadata.ts similarity index 82% rename from app/(app)/lish/[did]/[publication]/[rkey]/postPageMetadata.ts rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/postPageMetadata.ts index 4dc2b7a0..897cd0d2 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/postPageMetadata.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/postPageMetadata.ts @@ -4,11 +4,11 @@ import { normalizeDocumentRecord, normalizePublicationRecord, } from "src/utils/normalizeRecords"; -import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; -import { resolveDocumentFilter } from "../resolveDocumentFilter"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { resolveDocumentFilter } from "src/utils/resolveDocumentFilter"; +import { getDocumentURL } from "src/utils/getPublicationURL"; import { findPublishedPage } from "src/utils/publishedPageMetadata"; import { publicationAlternates } from "../publicationAlternates"; +import { fetchPublicationForPage } from "../getPublicationForPage"; /** * Metadata for whatever is published at / of a publication — a @@ -27,21 +27,16 @@ export async function postPageMetadata(props: { let publication_name = decodeURIComponent(props.publication); let segment = decodeURIComponent(props.segment); - let { data: pubs } = await supabaseServerClient - .from("publications") - .select("name, record, publication_pages(path, title, record_uri)") - .eq("identity_did", did) - .or(publicationNameOrUriFilter(did, publication_name)) - .order("uri", { ascending: false }) - .limit(1); + // The same fetcher the page body uses, so the two share one query. + let pub = await fetchPublicationForPage(did, publication_name); // Match the same way the page body does (tryRenderPublicationPage), so // metadata and body never disagree about which page a URL serves. - let match = findPublishedPage(pubs?.[0]?.publication_pages, "/" + segment); + let match = findPublishedPage(pub?.publication_pages, "/" + segment); if (match && match.record_uri) { return { - title: `${match.title || match.path} - ${pubs?.[0]?.name}`, + title: `${match.title || match.path} - ${pub?.name}`, alternates: publicationAlternates( - normalizePublicationRecord(pubs?.[0]?.record), + normalizePublicationRecord(pub?.record), "/" + segment, ), }; diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/postPageState.ts b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/postPageState.ts similarity index 86% rename from app/(app)/lish/[did]/[publication]/[rkey]/postPageState.ts rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/postPageState.ts index e91c62a7..a4529ca9 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/postPageState.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/postPageState.ts @@ -1,9 +1,10 @@ import { create } from "zustand"; import { flushSync } from "react-dom"; import { scrollIntoView } from "src/utils/scrollIntoView"; -import { useParams, useSearchParams } from "next/navigation"; -import { decodeQuotePosition } from "./quotePosition"; +import { useParams } from "next/navigation"; +import { decodeQuotePosition } from "src/utils/quotePosition"; import { useEffect } from "react"; +import { useClientSearchParams } from "src/hooks/useClientSearchParams"; // Page types export type DocPage = { type: "doc"; id: string }; @@ -28,8 +29,7 @@ const usePostPageUIState = create(() => ({ export const useOpenPages = (): OpenPage[] => { const { quote } = useParams(); const state = usePostPageUIState((s) => s); - const searchParams = useSearchParams(); - const pageParam = searchParams.get("page"); + const pageParam = useClientSearchParams().get("page"); if (!state.initialized) { // Check for page search param first (for comment links) @@ -50,12 +50,15 @@ export const useOpenPages = (): OpenPage[] => { export const useInitializeOpenPages = () => { const { quote } = useParams(); - const searchParams = useSearchParams(); - const pageParam = searchParams.get("page"); useEffect(() => { const state = usePostPageUIState.getState(); if (!state.initialized) { + // Read the live URL rather than a rendered value: the hydration render + // deliberately sees no search params (see useClientSearchParams), and + // initializing from that snapshot would discard a ?page= deep link + // permanently, since initialization only ever happens once. + const pageParam = new URLSearchParams(window.location.search).get("page"); // Check for page search param first (for comment links) if (pageParam) { usePostPageUIState.setState({ @@ -78,7 +81,7 @@ export const useInitializeOpenPages = () => { // Mark as initialized even if no pageId found usePostPageUIState.setState({ initialized: true }); } - }, [quote, pageParam]); + }, [quote]); }; export const openPage = ( diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/useHighlight.tsx b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/useHighlight.tsx similarity index 96% rename from app/(app)/lish/[did]/[publication]/[rkey]/useHighlight.tsx rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/useHighlight.tsx index 7ccee1ea..fed49262 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/useHighlight.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/useHighlight.tsx @@ -3,7 +3,7 @@ import { useParams } from "next/navigation"; import { create } from "zustand"; -import { decodeQuotePosition, QuotePosition } from "./quotePosition"; +import { decodeQuotePosition, QuotePosition } from "src/utils/quotePosition"; export const useActiveHighlightState = create(() => ({ activeHighlight: null as null | QuotePosition, diff --git a/app/(app)/(published)/lish/[did]/[publication]/[rkey]/usePostEditLink.ts b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/usePostEditLink.ts new file mode 100644 index 00000000..7354babc --- /dev/null +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/usePostEditLink.ts @@ -0,0 +1,26 @@ +"use client"; +import useSWR from "swr"; +import { getPostEditLink } from "actions/getPostEditLink"; +import { useIdentityData } from "components/IdentityProvider"; + +// Owner-only edit affordance on a published post. The token behind the link is +// a bearer capability, so it can't ship with the page (one cached payload, +// every reader) — we fetch it after identity resolves, and only for a viewer +// whose DID matches the publication owner's. That client-side match just avoids +// a pointless request; getPostEditLink re-checks ownership server-side. +export function usePostEditLink( + documentUri: string | undefined, + ownerDid: string | undefined, +) { + let { identity } = useIdentityData(); + let isOwner = + !!identity?.atp_did && !!ownerDid && identity.atp_did === ownerDid; + let { data } = useSWR( + isOwner && documentUri + ? ["post-edit-link", documentUri, identity!.id] + : null, + ([, uri]) => getPostEditLink(uri), + { revalidateOnFocus: false, revalidateIfStale: false }, + ); + return data ? `https://leaflet.pub/${data}` : null; +} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts similarity index 94% rename from app/(app)/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts rename to app/(app)/(published)/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts index e7abd3f2..bdd53f94 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/[rkey]/voteOnPublishedPoll.ts @@ -4,7 +4,7 @@ import { restoreOAuthSession, OAuthSessionError, } from "src/atproto-oauth"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { AtpBaseClient, AtUri } from "@atproto/api"; import { PubLeafletPollVote } from "lexicons/api"; import { supabaseServerClient } from "supabase/serverClient"; @@ -19,7 +19,7 @@ export async function voteOnPublishedPoll( { success: true } | { success: false; error: string | OAuthSessionError } > { try { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity?.atp_did) { return { success: false, error: "Not authenticated" }; diff --git a/app/(app)/lish/[did]/[publication]/archive/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/archive/page.tsx similarity index 79% rename from app/(app)/lish/[did]/[publication]/archive/page.tsx rename to app/(app)/(published)/lish/[did]/[publication]/archive/page.tsx index 325c9a47..8f8752ae 100644 --- a/app/(app)/lish/[did]/[publication]/archive/page.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/archive/page.tsx @@ -1,34 +1,39 @@ import React from "react"; import type { Metadata } from "next"; +import { notFound } from "next/navigation"; import { supabaseServerClient } from "supabase/serverClient"; -import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; import { FontLoader } from "components/FontLoader"; import { PublicationThemeProvider, PublicationBackgroundProvider, } from "components/ThemeManager/PublicationThemeProvider"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; -import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; import { publishedNavPages } from "src/utils/publishedPageMetadata"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { wordmarkFromTheme } from "src/utils/wordmark"; -import { - getDocumentURL, - getPublicationURL, -} from "app/(app)/lish/createPub/getPublicationURL"; +import { getDocumentURL, getPublicationURL } from "src/utils/getPublicationURL"; import { DocumentPageRenderer } from "../[rkey]/DocumentPageRenderer"; import { postPageMetadata } from "../[rkey]/postPageMetadata"; -import { buildPublicationPosts } from "../buildPublicationPosts"; +import { buildPublicationPosts } from "src/utils/buildPublicationPosts"; import { fetchPublicationForPage, fetchPublicationPostRows, } from "../getPublicationForPage"; import { tryRenderPublicationPage } from "../tryRenderPublicationPage"; -import { resolveDocumentFilter } from "../resolveDocumentFilter"; +import { resolveDocumentFilter } from "src/utils/resolveDocumentFilter"; import { publicationAlternates } from "../publicationAlternates"; import { PublicationHomeLayout } from "../PublicationHomeLayout"; import { LocalizedDate } from "../LocalizedDate"; +// On-demand ISR: rendered on first request, then served from the CDN and +// re-rendered in the background. The empty generateStaticParams is what opts a +// dynamic-params route into caching at all — `revalidate` alone leaves it +// fully dynamic. Writes invalidate eagerly via revalidatePublicationPaths. +export const revalidate = 300; +export async function generateStaticParams() { + return []; +} + // The archive exists so every post is reachable through plain server-rendered // anchors — crawlers can't drive the home page's infinite scroll past its // first batch. @@ -49,14 +54,10 @@ export async function generateMetadata(props: { }); if (published) return published; - let { data: publications } = await supabaseServerClient - .from("publications") - .select("name, record") - .eq("identity_did", did) - .or(publicationNameOrUriFilter(did, decodeURIComponent(params.publication))) - .order("uri", { ascending: false }) - .limit(1); - let publication = publications?.[0]; + let publication = await fetchPublicationForPage( + did, + decodeURIComponent(params.publication), + ); if (!publication) return { title: "Publication 404" }; let pubRecord = normalizePublicationRecord(publication.record); @@ -72,10 +73,10 @@ export default async function PublicationArchive(props: { let params = await props.params; let did = decodeURIComponent(params.did); let publication_name = decodeURIComponent(params.publication); - if (!did) return ; + if (!did) notFound(); const publication = await fetchPublicationForPage(did, publication_name); - if (!publication) return ; + if (!publication) notFound(); // This static segment wins over the [rkey] route, so anything published at // the /archive path would be shadowed — render it instead, the way [rkey] @@ -104,16 +105,15 @@ export default async function PublicationArchive(props: { ); const record = normalizePublicationRecord(publication.record); - const posts = buildPublicationPosts(postRows) - .sort((a, b) => { - const aDate = a.record.publishedAt - ? new Date(a.record.publishedAt).getTime() - : 0; - const bDate = b.record.publishedAt - ? new Date(b.record.publishedAt).getTime() - : 0; - return bDate - aDate; - }); + const posts = buildPublicationPosts(postRows).sort((a, b) => { + const aDate = a.record.publishedAt + ? new Date(a.record.publishedAt).getTime() + : 0; + const bDate = b.record.publishedAt + ? new Date(b.record.publishedAt).getTime() + : 0; + return bDate - aDate; + }); return ( ); } - -const ArchiveNotFound = () => { - return ( - - - ); -}; diff --git a/app/(app)/lish/[did]/[publication]/atom/route.ts b/app/(app)/(published)/lish/[did]/[publication]/atom/route.ts similarity index 100% rename from app/(app)/lish/[did]/[publication]/atom/route.ts rename to app/(app)/(published)/lish/[did]/[publication]/atom/route.ts diff --git a/app/(app)/lish/[did]/[publication]/contributor_accept/AcceptContent.tsx b/app/(app)/(published)/lish/[did]/[publication]/contributor_accept/AcceptContent.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/contributor_accept/AcceptContent.tsx rename to app/(app)/(published)/lish/[did]/[publication]/contributor_accept/AcceptContent.tsx diff --git a/app/(app)/lish/[did]/[publication]/contributor_accept/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/contributor_accept/page.tsx similarity index 84% rename from app/(app)/lish/[did]/[publication]/contributor_accept/page.tsx rename to app/(app)/(published)/lish/[did]/[publication]/contributor_accept/page.tsx index f119b59f..d40fafa8 100644 --- a/app/(app)/lish/[did]/[publication]/contributor_accept/page.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/contributor_accept/page.tsx @@ -1,3 +1,4 @@ +import { IdentityProviderServer } from "components/IdentityProviderServer"; import type { Metadata } from "next"; import { supabaseServerClient } from "supabase/serverClient"; import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; @@ -14,7 +15,7 @@ type Params = { did: string; publication: string }; export const metadata: Metadata = { robots: { index: false } }; -export default async function ContributorAcceptPage(props: { +async function ContributorAcceptPageInner(props: { params: PromiseSorry, we can't find this publication!
-- This may be a glitch on our end. If the issue persists please{" "} - send us a note. -
-; }) { let params = await props.params; @@ -109,3 +110,20 @@ export default async function ContributorAcceptPage(props: { > ); } + +// Explicit rather than incidental (via cookies()): guards against a future +// revalidate/generateStaticParams landing on a shared segment above. +export const dynamic = "force-dynamic"; + +// Identity-gated surface inside the (published) group: shadow the +// client-fetched viewer identity with a server-fed provider so chrome +// renders correctly on the first frame. +export default async function ContributorAcceptPage(props: { + params: Promise ; +}) { + return ( + + + ); +} diff --git a/app/(app)/lish/[did]/[publication]/dashboard/Actions.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/Actions.tsx similarity index 96% rename from app/(app)/lish/[did]/[publication]/dashboard/Actions.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/Actions.tsx index ff082f4d..1b6566c9 100644 --- a/app/(app)/lish/[did]/[publication]/dashboard/Actions.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/dashboard/Actions.tsx @@ -4,7 +4,7 @@ import { NewDraftActionButton } from "./NewDraftButton"; import { ActionButton } from "components/ActionBar/ActionButton"; import { ShareSmall } from "components/Icons/ShareSmall"; import { Menu, MenuItem } from "components/Menu"; -import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getPublicationURL } from "src/utils/getPublicationURL"; import { usePublicationData } from "./PublicationSWRProvider"; import { useSmoker } from "components/Toast"; import { useIsMobile } from "src/hooks/isMobile"; diff --git a/app/(app)/lish/[did]/[publication]/dashboard/DraftList.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/DraftList.tsx similarity index 96% rename from app/(app)/lish/[did]/[publication]/dashboard/DraftList.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/DraftList.tsx index 15b8fdb1..98edbab5 100644 --- a/app/(app)/lish/[did]/[publication]/dashboard/DraftList.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/dashboard/DraftList.tsx @@ -5,7 +5,7 @@ import { usePublicationData, useNormalizedPublicationRecord, } from "./PublicationSWRProvider"; -import { LeafletList } from "app/(app)/(home-pages)/(writer)/home/HomeLayout"; +import { LeafletList } from "app/(app)/(identity)/(home-pages)/(writer)/home/HomeLayout"; import { useIdentityData } from "components/IdentityProvider"; export function DraftList(props: { diff --git a/app/(app)/lish/[did]/[publication]/dashboard/DraftsTab.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/DraftsTab.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/DraftsTab.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/DraftsTab.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/EditPagesNavLink.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/EditPagesNavLink.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/EditPagesNavLink.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/EditPagesNavLink.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/NewDraftButton.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/NewDraftButton.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/NewDraftButton.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/NewDraftButton.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/PublicationSWRProvider.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/PublicationSubscribers.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx similarity index 98% rename from app/(app)/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx index e61b424a..4026df46 100644 --- a/app/(app)/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx @@ -9,14 +9,11 @@ import { } from "./PublicationSWRProvider"; import { Fragment } from "react"; import { useParams } from "next/navigation"; -import { - getPublicationURL, - getDocumentURL, -} from "app/(app)/lish/createPub/getPublicationURL"; +import { getPublicationURL, getDocumentURL } from "src/utils/getPublicationURL"; import { SpeedyLink } from "components/SpeedyLink"; import { InteractionPreview } from "components/Interactions/InteractionsPreview"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; -import { LeafletOptions } from "app/(app)/(home-pages)/(writer)/home/LeafletList/LeafletOptions"; +import { LeafletOptions } from "app/(app)/(identity)/(home-pages)/(writer)/home/LeafletList/LeafletOptions"; import { StaticLeafletDataContext } from "components/PageSWRDataProvider"; import { useIdentityData } from "components/IdentityProvider"; import { EmailTiny } from "components/Icons/EmailTiny"; diff --git a/app/(app)/lish/[did]/[publication]/dashboard/analytics/BskyPostsList.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/BskyPostsList.tsx similarity index 98% rename from app/(app)/lish/[did]/[publication]/dashboard/analytics/BskyPostsList.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/BskyPostsList.tsx index 69a3bf12..125f2efe 100644 --- a/app/(app)/lish/[did]/[publication]/dashboard/analytics/BskyPostsList.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/BskyPostsList.tsx @@ -13,7 +13,7 @@ import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; import { CommentTiny } from "components/Icons/CommentTiny"; import { QuoteTiny } from "components/Icons/QuoteTiny"; import { RecommendEmptyTiny } from "components/Icons/RecommendTiny"; -import { PostInfo } from "app/(app)/lish/[did]/[publication]/[rkey]/BskyPostContent"; +import { PostInfo } from "app/(app)/(published)/lish/[did]/[publication]/[rkey]/BskyPostContent"; import type { PublicationBskyPost } from "app/api/rpc/[command]/get_publication_bsky_posts"; import { usePublicationData } from "../PublicationSWRProvider"; import { ListSkeleton } from "./Skeletons"; diff --git a/app/(app)/lish/[did]/[publication]/dashboard/analytics/ChartTooltip.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/ChartTooltip.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/analytics/ChartTooltip.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/ChartTooltip.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/analytics/PublicationAnalytics.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/PublicationAnalytics.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/analytics/PublicationAnalytics.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/PublicationAnalytics.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/analytics/Skeletons.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/Skeletons.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/analytics/Skeletons.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/Skeletons.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/analytics/SubscribersChart.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/SubscribersChart.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/analytics/SubscribersChart.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/SubscribersChart.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/analytics/TrafficChart.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/TrafficChart.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/analytics/TrafficChart.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/TrafficChart.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/analytics/dates.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/dates.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/analytics/dates.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/dates.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/analytics/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/page.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/analytics/page.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/analytics/page.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/deletePost.ts b/app/(app)/(published)/lish/[did]/[publication]/dashboard/deletePost.ts similarity index 78% rename from app/(app)/lish/[did]/[publication]/dashboard/deletePost.ts rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/deletePost.ts index 9b6be8bf..0b8b2298 100644 --- a/app/(app)/lish/[did]/[publication]/dashboard/deletePost.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/dashboard/deletePost.ts @@ -1,7 +1,7 @@ "use server"; import { AtpBaseClient } from "lexicons/api"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { restoreOAuthSession, OAuthSessionError, @@ -10,6 +10,8 @@ import { AtUri } from "@atproto/syntax"; import { supabaseServerClient } from "supabase/serverClient"; import { isConfirmedContributor } from "src/contributorPermissions"; import { revalidatePath } from "next/cache"; +import { revalidatePostPaths } from "src/utils/revalidatePublication"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; // An authorization failure, distinct from a (recoverable) expired OAuth // session. Callers surface session errors with a "sign in again" affordance; @@ -52,10 +54,34 @@ async function resolveDocumentAuthority( return { ok: true, ownerDid: pdsOwner }; } + +// Invalidate the cached reader pages that listed or rendered this post. +// Looked up before the delete removes the join rows. +async function revalidateDocPublications(document_uri: string) { + const docUri = new AtUri(document_uri); + const { data: rows } = await supabaseServerClient + .from("documents_in_publications") + .select("publications(uri, record), documents(data)") + .eq("document", document_uri); + for (const row of rows ?? []) { + if (!row.publications) continue; + // Documents publish under record.path (usually "/+ ", but other + // clients can write any path) — drop both spellings. + const docPath = (row.documents?.data as { path?: string } | null)?.path; + revalidatePostPaths( + row.publications.uri, + normalizePublicationRecord(row.publications.record)?.name, + docUri.rkey, + docPath, + ); + } + revalidatePath(`/p/${docUri.host}/${docUri.rkey}`); +} + export async function deletePost( document_uri: string ): Promise<{ success: true } | { success: false; error: PostMutationError }> { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !identity.atp_did) { return { success: false, @@ -83,6 +109,7 @@ export async function deletePost( ); let uri = new AtUri(document_uri); + await revalidateDocPublications(document_uri); await Promise.all([ // Delete from both PDS collections (document exists in one or the other) agent.pub.leaflet.document.delete({ @@ -99,15 +126,13 @@ export async function deletePost( .delete() .eq("doc", document_uri), ]); - - revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); return { success: true }; } export async function unpublishPost( document_uri: string ): Promise<{ success: true } | { success: false; error: PostMutationError }> { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !identity.atp_did) { return { success: false, @@ -135,6 +160,7 @@ export async function unpublishPost( ); let uri = new AtUri(document_uri); + await revalidateDocPublications(document_uri); await Promise.all([ // Delete from both PDS collections (document exists in one or the other) agent.pub.leaflet.document.delete({ @@ -147,6 +173,5 @@ export async function unpublishPost( }).catch(() => {}), supabaseServerClient.from("documents").delete().eq("uri", document_uri), ]); - revalidatePath("/lish/[did]/[publication]/dashboard", "layout"); return { success: true }; } diff --git a/app/(app)/lish/[did]/[publication]/dashboard/layout.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/layout.tsx similarity index 88% rename from app/(app)/lish/[did]/[publication]/dashboard/layout.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/layout.tsx index dc0f1f49..2df1bd11 100644 --- a/app/(app)/lish/[did]/[publication]/dashboard/layout.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/dashboard/layout.tsx @@ -1,3 +1,4 @@ +import { IdentityProviderServer } from "components/IdentityProviderServer"; import { supabaseServerClient } from "supabase/serverClient"; import { Metadata } from "next"; import { getIdentityData } from "actions/getIdentityData"; @@ -38,7 +39,7 @@ export async function generateMetadata(props: { return { title: record?.name || "Untitled Publication", robots }; } -export default async function PublicationDashboardLayout(props: { +async function PublicationDashboardLayoutInner(props: { children: React.ReactNode; params: Promise<{ publication: string; did: string }>; }) { @@ -157,3 +158,21 @@ const PubNotFound = () => { ); }; + +// Explicit rather than incidental (via cookies()): guards against a future +// revalidate/generateStaticParams landing on a shared segment above. +export const dynamic = "force-dynamic"; + +// Identity-gated surface inside the (published) group: shadow the +// client-fetched viewer identity with a server-fed provider so chrome +// renders correctly on the first frame. +export default async function PublicationDashboardLayout(props: { + children: React.ReactNode; + params: Promise<{ publication: string; did: string }>; +}) { + return ( + + + ); +} diff --git a/app/(app)/lish/[did]/[publication]/dashboard/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/page.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/page.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/page.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/posts/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/posts/page.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/posts/page.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/posts/page.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/settings/ContributorSettings.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/ContributorSettings.tsx similarity index 99% rename from app/(app)/lish/[did]/[publication]/dashboard/settings/ContributorSettings.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/ContributorSettings.tsx index 213440fb..96985d0d 100644 --- a/app/(app)/lish/[did]/[publication]/dashboard/settings/ContributorSettings.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/ContributorSettings.tsx @@ -24,7 +24,7 @@ import { type ContributorActionError, } from "actions/publications/contributors"; import { UpgradeToProButton } from "../../UpgradeModal"; -import { getBasePublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getBasePublicationURL } from "src/utils/getPublicationURL"; const ERROR_MESSAGES: Record+ = { unauthorized: "You're not signed in.", diff --git a/app/(app)/lish/[did]/[publication]/dashboard/settings/GeneralSettings.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/GeneralSettings.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/settings/GeneralSettings.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/GeneralSettings.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/settings/MembershipSettings.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/MembershipSettings.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/settings/MembershipSettings.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/MembershipSettings.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/settings/PostSettings.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/PostSettings.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/settings/PostSettings.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/PostSettings.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/ProSettings.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/settings/PubDomainSettings.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/PubDomainSettings.tsx similarity index 99% rename from app/(app)/lish/[did]/[publication]/dashboard/settings/PubDomainSettings.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/PubDomainSettings.tsx index 15bc7973..354fa7c5 100644 --- a/app/(app)/lish/[did]/[publication]/dashboard/settings/PubDomainSettings.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/PubDomainSettings.tsx @@ -16,7 +16,7 @@ import { usePublicationData, useNormalizedPublicationRecord, } from "../PublicationSWRProvider"; -import { updatePublicationBasePath } from "app/(app)/lish/createPub/updatePublication"; +import { updatePublicationBasePath } from "actions/publications/updatePublication"; import { assignDomainToPublication, removeDomainAssignment, diff --git a/app/(app)/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx similarity index 99% rename from app/(app)/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx index 2d1f490a..d118239d 100644 --- a/app/(app)/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/SettingsContent.tsx @@ -8,7 +8,7 @@ import { usePublicationData, useNormalizedPublicationRecord, } from "../PublicationSWRProvider"; -import { updatePublication } from "app/(app)/lish/createPub/updatePublication"; +import { updatePublication } from "actions/publications/updatePublication"; import { PubDomainSettings } from "./PubDomainSettings"; import { GeneralSettings } from "./GeneralSettings"; import { PostSettings } from "./PostSettings"; diff --git a/app/(app)/lish/[did]/[publication]/dashboard/settings/ThemeSettings.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/ThemeSettings.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/settings/ThemeSettings.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/ThemeSettings.tsx diff --git a/app/(app)/lish/[did]/[publication]/dashboard/settings/deletePublication.ts b/app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/deletePublication.ts similarity index 88% rename from app/(app)/lish/[did]/[publication]/dashboard/settings/deletePublication.ts rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/deletePublication.ts index 489d5090..a5014ad1 100644 --- a/app/(app)/lish/[did]/[publication]/dashboard/settings/deletePublication.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/deletePublication.ts @@ -1,7 +1,7 @@ "use server"; import { AtpBaseClient } from "lexicons/api"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; import { AtUri } from "@atproto/syntax"; import { supabaseServerClient } from "supabase/serverClient"; @@ -13,7 +13,8 @@ import { } from "drizzle/schema"; import { eq, inArray } from "drizzle-orm"; import { pool } from "supabase/pool"; -import { revalidatePath } from "next/cache"; +import { revalidateAllPublicationPaths } from "src/utils/revalidatePublication"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; import { cancelPublicationMemberSubscriptions } from "src/membership.server"; export async function deletePublication( @@ -21,7 +22,7 @@ export async function deletePublication( ): Promise< { success: true } | { success: false; error: string | OAuthSessionError } > { - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !identity.atp_did) { return { success: false, error: "Not authenticated" }; } @@ -130,11 +131,20 @@ export async function deletePublication( .delete() .in("uri", documentUris); } + const { data: pubRow } = await supabaseServerClient + .from("publications") + .select("record") + .eq("uri", publication_uri) + .maybeSingle(); + // Enumerates published pages and post URLs — must run while the join rows + // still exist. + await revalidateAllPublicationPaths(publication_uri, [ + normalizePublicationRecord(pubRow?.record)?.name, + ]); await supabaseServerClient .from("publications") .delete() .eq("uri", publication_uri); - revalidatePath("/lish/[did]/[publication]", "layout"); return { success: true }; } diff --git a/app/(app)/lish/[did]/[publication]/dashboard/settings/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/page.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/dashboard/settings/page.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/settings/page.tsx diff --git a/app/(app)/(published)/lish/[did]/[publication]/dashboard/subs/loading.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/subs/loading.tsx new file mode 100644 index 00000000..f9434e2b --- /dev/null +++ b/app/(app)/(published)/lish/[did]/[publication]/dashboard/subs/loading.tsx @@ -0,0 +1,5 @@ +import { DashboardLoading } from "components/PageLayouts/DashboardLoading"; + +export default function Loading() { + return ; +} diff --git a/app/(app)/lish/[did]/[publication]/dashboard/subs/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/dashboard/subs/page.tsx similarity index 98% rename from app/(app)/lish/[did]/[publication]/dashboard/subs/page.tsx rename to app/(app)/(published)/lish/[did]/[publication]/dashboard/subs/page.tsx index 38f81c5d..575a96b2 100644 --- a/app/(app)/lish/[did]/[publication]/dashboard/subs/page.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/dashboard/subs/page.tsx @@ -3,7 +3,7 @@ import { supabaseServerClient } from "supabase/serverClient"; import { getProfiles } from "src/identity"; import { getIdentityData } from "actions/getIdentityData"; import { get_publication_data } from "app/api/rpc/[command]/get_publication_data"; -import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getPublicationURL } from "src/utils/getPublicationURL"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; import { isActiveMembership } from "src/membership"; import { diff --git a/app/(app)/lish/[did]/[publication]/edit/PublicationDraftEditor.tsx b/app/(app)/(published)/lish/[did]/[publication]/edit/PublicationDraftEditor.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/edit/PublicationDraftEditor.tsx rename to app/(app)/(published)/lish/[did]/[publication]/edit/PublicationDraftEditor.tsx diff --git a/app/(app)/lish/[did]/[publication]/edit/PublicationEditHeader.tsx b/app/(app)/(published)/lish/[did]/[publication]/edit/PublicationEditHeader.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/edit/PublicationEditHeader.tsx rename to app/(app)/(published)/lish/[did]/[publication]/edit/PublicationEditHeader.tsx diff --git a/app/(app)/lish/[did]/[publication]/edit/PublicationEditMobileFooter.tsx b/app/(app)/(published)/lish/[did]/[publication]/edit/PublicationEditMobileFooter.tsx similarity index 95% rename from app/(app)/lish/[did]/[publication]/edit/PublicationEditMobileFooter.tsx rename to app/(app)/(published)/lish/[did]/[publication]/edit/PublicationEditMobileFooter.tsx index ecf075e8..26f990fd 100644 --- a/app/(app)/lish/[did]/[publication]/edit/PublicationEditMobileFooter.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/edit/PublicationEditMobileFooter.tsx @@ -4,7 +4,7 @@ import { FooterLayout } from "components/ActionBar/Footer"; import { Media } from "components/Media"; import { Toolbar } from "components/Toolbar"; import { FootnoteToolbar } from "components/Toolbar/FootnoteToolbarWrapper"; -import { hasBlockToolbar } from "app/(app)/[leaflet_id]/Footer"; +import { hasBlockToolbar } from "app/(app)/(identity)/[leaflet_id]/Footer"; import { useEntitySetContext } from "components/EntitySetProvider"; import { useEntity } from "src/replicache"; diff --git a/app/(app)/lish/[did]/[publication]/edit/PublicationPagesEditNav.tsx b/app/(app)/(published)/lish/[did]/[publication]/edit/PublicationPagesEditNav.tsx similarity index 99% rename from app/(app)/lish/[did]/[publication]/edit/PublicationPagesEditNav.tsx rename to app/(app)/(published)/lish/[did]/[publication]/edit/PublicationPagesEditNav.tsx index c1f1c819..e056d377 100644 --- a/app/(app)/lish/[did]/[publication]/edit/PublicationPagesEditNav.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/edit/PublicationPagesEditNav.tsx @@ -35,7 +35,7 @@ import { useReplicache } from "src/replicache"; import { useEntitySetContext } from "components/EntitySetProvider"; import { PublicationNavSubscribe } from "../PublicationNavSubscribe"; import { type SubscribeData } from "../PublicationHeader"; -import { useNavBackgroundFade } from "../useNavBackgroundFade"; +import { useNavBackgroundFade } from "src/hooks/useNavBackgroundFade"; import { useToaster } from "components/Toast"; import { Checkbox } from "components/Checkbox"; import { normalizeExternalLink } from "src/utils/externalPublicationLink"; diff --git a/app/(app)/lish/[did]/[publication]/edit/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/edit/page.tsx similarity index 92% rename from app/(app)/lish/[did]/[publication]/edit/page.tsx rename to app/(app)/(published)/lish/[did]/[publication]/edit/page.tsx index 2706bdfc..ffd5e118 100644 --- a/app/(app)/lish/[did]/[publication]/edit/page.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/edit/page.tsx @@ -1,3 +1,4 @@ +import { IdentityProviderServer } from "components/IdentityProviderServer"; import React from "react"; import type { Metadata } from "next"; import type { Fact } from "src/replicache"; @@ -49,7 +50,7 @@ type Props = { params: Promise<{ did: string; publication: string }>; }; -export default async function PublicationEditPage(props: Props) { +async function PublicationEditPageInner(props: Props) { let params = await props.params; let did = decodeURIComponent(params.did); let publicationName = decodeURIComponent(params.publication); @@ -159,3 +160,14 @@ const PageNotFound = () => ( Sorry, we can't find this publication!
); + +// Identity-gated surface inside the (published) group: shadow the +// client-fetched viewer identity with a server-fed provider so chrome +// renders correctly on the first frame. +export default async function PublicationEditPage(props: Props) { + return ( ++ + ); +} diff --git a/app/(app)/lish/[did]/[publication]/edit/usePublicationNavEntries.ts b/app/(app)/(published)/lish/[did]/[publication]/edit/usePublicationNavEntries.ts similarity index 100% rename from app/(app)/lish/[did]/[publication]/edit/usePublicationNavEntries.ts rename to app/(app)/(published)/lish/[did]/[publication]/edit/usePublicationNavEntries.ts diff --git a/app/(app)/lish/[did]/[publication]/feedResponse.ts b/app/(app)/(published)/lish/[did]/[publication]/feedResponse.ts similarity index 100% rename from app/(app)/lish/[did]/[publication]/feedResponse.ts rename to app/(app)/(published)/lish/[did]/[publication]/feedResponse.ts diff --git a/app/(app)/lish/[did]/[publication]/generateFeed.ts b/app/(app)/(published)/lish/[did]/[publication]/generateFeed.ts similarity index 99% rename from app/(app)/lish/[did]/[publication]/generateFeed.ts rename to app/(app)/(published)/lish/[did]/[publication]/generateFeed.ts index 3fade679..f35e6e58 100644 --- a/app/(app)/lish/[did]/[publication]/generateFeed.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/generateFeed.ts @@ -15,7 +15,7 @@ import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; import { getDocumentURL, getPublicationURL, -} from "app/(app)/lish/createPub/getPublicationURL"; +} from "src/utils/getPublicationURL"; import { truncateBlocksAtMembersDelimiter } from "src/membership"; // Readers poll feeds constantly and every item is a full React SSR render diff --git a/app/(app)/lish/[did]/[publication]/getPostsByUris.ts b/app/(app)/(published)/lish/[did]/[publication]/getPostsByUris.ts similarity index 97% rename from app/(app)/lish/[did]/[publication]/getPostsByUris.ts rename to app/(app)/(published)/lish/[did]/[publication]/getPostsByUris.ts index 7cb24cdb..9dd81b52 100644 --- a/app/(app)/lish/[did]/[publication]/getPostsByUris.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/getPostsByUris.ts @@ -6,7 +6,7 @@ import { attachBylineProfiles, bylineDidsForPosts } from "src/utils/byline"; import { buildPublicationPosts, type PublicationPostsListPost, -} from "./buildPublicationPosts"; +} from "src/utils/buildPublicationPosts"; /** * Hydrates a batch of document URIs into renderable posts (counts + bylines diff --git a/app/(app)/(published)/lish/[did]/[publication]/getPublicationForPage.ts b/app/(app)/(published)/lish/[did]/[publication]/getPublicationForPage.ts new file mode 100644 index 00000000..f09ec22e --- /dev/null +++ b/app/(app)/(published)/lish/[did]/[publication]/getPublicationForPage.ts @@ -0,0 +1,45 @@ +import { cache } from "react"; +import { supabaseServerClient } from "supabase/serverClient"; +import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; + +// Memoized per request: the page body, its metadata, and any nested renderer +// all resolve the publication from the same route params. +export const fetchPublicationForPage = cache( + async function fetchPublicationForPage(did: string, publicationName: string) { + const { data } = await supabaseServerClient + .from("publications") + .select( + `uri, name, identity_did, record, + publication_newsletter_settings(enabled), + publication_pages(id, path, title, record, record_uri, sort_order)`, + ) + .eq("identity_did", did) + .or(publicationNameOrUriFilter(did, publicationName)) + .order("uri", { ascending: false }) + .limit(1); + return data?.[0] ?? null; + }, +); + +export type PublicationForPage = NonNullable< + Awaited+ > +>; + +// Fetched separately from the publication row because most requests through +// fetchPublicationForPage (every post URL, any page without a posts list) +// never render the post list — loading every document's full record jsonb up +// front made the common path pay for the rare one. +export const fetchPublicationPostRows = cache( + async function fetchPublicationPostRows(publicationUri: string) { + const { data } = await supabaseServerClient + .from("documents_in_publications") + .select( + `members_only, documents(uri, data, + comments_on_documents(count), + document_mentions_in_bsky(count), + recommends_on_documents(count))`, + ) + .eq("publication", publicationUri); + return data ?? []; + }, +); diff --git a/app/(app)/lish/[did]/[publication]/icon/route.ts b/app/(app)/(published)/lish/[did]/[publication]/icon/route.ts similarity index 100% rename from app/(app)/lish/[did]/[publication]/icon/route.ts rename to app/(app)/(published)/lish/[did]/[publication]/icon/route.ts diff --git a/app/(app)/lish/[did]/[publication]/join/JoinTiers.tsx b/app/(app)/(published)/lish/[did]/[publication]/join/JoinTiers.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/join/JoinTiers.tsx rename to app/(app)/(published)/lish/[did]/[publication]/join/JoinTiers.tsx diff --git a/app/(app)/lish/[did]/[publication]/join/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/join/page.tsx similarity index 84% rename from app/(app)/lish/[did]/[publication]/join/page.tsx rename to app/(app)/(published)/lish/[did]/[publication]/join/page.tsx index 17390f60..6aadfc40 100644 --- a/app/(app)/lish/[did]/[publication]/join/page.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/join/page.tsx @@ -1,10 +1,11 @@ +import { IdentityProviderServer } from "components/IdentityProviderServer"; import { Metadata } from "next"; import { notFound } from "next/navigation"; import { supabaseServerClient } from "supabase/serverClient"; import { getIdentityData } from "actions/getIdentityData"; import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; -import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getPublicationURL } from "src/utils/getPublicationURL"; import { isActiveMembership } from "src/membership"; import { getReaderMembership } from "src/membership.server"; import { @@ -40,7 +41,7 @@ export async function generateMetadata(props: { return { title: `Join ${publication.name}` }; } -export default async function JoinPage(props: { +async function JoinPageInner(props: { params: Promise<{ publication: string; did: string }>; }) { const params = await props.params; @@ -110,3 +111,20 @@ export default async function JoinPage(props: { ); } + +// Explicit rather than incidental (via cookies()): guards against a future +// revalidate/generateStaticParams landing on a shared segment above. +export const dynamic = "force-dynamic"; + +// Identity-gated surface inside the (published) group: shadow the +// client-fetched viewer identity with a server-fed provider so chrome +// renders correctly on the first frame. +export default async function JoinPage(props: { + params: Promise<{ publication: string; did: string }>; +}) { + return ( + + + ); +} diff --git a/app/(app)/lish/[did]/[publication]/json/route.ts b/app/(app)/(published)/lish/[did]/[publication]/json/route.ts similarity index 100% rename from app/(app)/lish/[did]/[publication]/json/route.ts rename to app/(app)/(published)/lish/[did]/[publication]/json/route.ts diff --git a/app/(app)/lish/[did]/[publication]/layout.tsx b/app/(app)/(published)/lish/[did]/[publication]/layout.tsx similarity index 79% rename from app/(app)/lish/[did]/[publication]/layout.tsx rename to app/(app)/(published)/lish/[did]/[publication]/layout.tsx index 6a33cfde..f821a6e8 100644 --- a/app/(app)/lish/[did]/[publication]/layout.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/layout.tsx @@ -1,7 +1,6 @@ -import { supabaseServerClient } from "supabase/serverClient"; import { Metadata } from "next"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; -import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; +import { fetchPublicationForPage } from "./getPublicationForPage"; export default async function PublicationLayout(props: { children: React.ReactNode; @@ -20,14 +19,8 @@ export async function generateMetadata(props: { if (!params.did || !params.publication) return { title: "Publication 404" }; let publication_name = decodeURIComponent(params.publication); - let { data: publications } = await supabaseServerClient - .from("publications") - .select(`uri, record`) - .eq("identity_did", did) - .or(publicationNameOrUriFilter(did, publication_name)) - .order("uri", { ascending: false }) - .limit(1); - let publication = publications?.[0]; + // Same fetcher the page body uses, so the two share one query. + let publication = await fetchPublicationForPage(did, publication_name); if (!publication) return { title: "Publication 404" }; const pubRecord = normalizePublicationRecord(publication?.record); diff --git a/app/(app)/lish/[did]/[publication]/opengraph-image.ts b/app/(app)/(published)/lish/[did]/[publication]/opengraph-image.ts similarity index 89% rename from app/(app)/lish/[did]/[publication]/opengraph-image.ts rename to app/(app)/(published)/lish/[did]/[publication]/opengraph-image.ts index 97f70c0d..41ca17f2 100644 --- a/app/(app)/lish/[did]/[publication]/opengraph-image.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/opengraph-image.ts @@ -5,6 +5,11 @@ import { ogScreenshotResponse } from "src/utils/screenshotPage"; // bots re-fetch these constantly. export const revalidate = 86400; +export async function generateStaticParams() { + return []; +} + + export default async function OpenGraphImage(props: { params: Promise<{ publication: string; did: string }>; }) { diff --git a/app/(app)/(published)/lish/[did]/[publication]/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/page.tsx new file mode 100644 index 00000000..9fbdc782 --- /dev/null +++ b/app/(app)/(published)/lish/[did]/[publication]/page.tsx @@ -0,0 +1,105 @@ +import { BskyAgent } from "@atproto/api"; +import React from "react"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { publicationAlternates } from "./publicationAlternates"; +import { DefaultPublicationHomepage } from "./DefaultPublicationHomepage"; +import { buildPublicationPosts } from "src/utils/buildPublicationPosts"; +import { + fetchPublicationForPage, + fetchPublicationPostRows, +} from "./getPublicationForPage"; +import { tryRenderPublicationPage } from "./tryRenderPublicationPage"; +import { getProfiles } from "src/identity"; +import { attachBylineProfiles, bylineDidsForPosts } from "src/utils/byline"; +import { + PublicationThemeProvider, + PublicationBackgroundProvider, +} from "components/ThemeManager/PublicationThemeProvider"; + +// On-demand ISR: rendered on first request, then served from the CDN and +// re-rendered in the background. The empty generateStaticParams is what opts a +// dynamic-params route into caching at all — `revalidate` alone leaves it +// fully dynamic. Writes invalidate eagerly via revalidatePublicationPaths. +export const revalidate = 300; +export async function generateStaticParams() { + return []; +} + +export async function generateMetadata(props: { + params: Promise<{ publication: string; did: string }>; +}): Promise+ { + let params = await props.params; + let did = decodeURIComponent(params.did); + if (!did || !params.publication) return {}; + + // Same fetcher the page body uses, so the two share one query. + let publication = await fetchPublicationForPage( + did, + decodeURIComponent(params.publication), + ); + + let alternates = publicationAlternates( + normalizePublicationRecord(publication?.record), + "/", + ); + return alternates ? { alternates } : {}; +} + +export default async function Publication(props: { + params: Promise<{ publication: string; did: string }>; +}) { + let params = await props.params; + const did = decodeURIComponent(params.did); + if (!did) notFound(); + const publication_name = decodeURIComponent(params.publication); + + const publication = await fetchPublicationForPage(did, publication_name); + if (!publication) notFound(); + + // Render a published "/" page when one exists; otherwise fall back to the + // legacy post-listing homepage. + const homePageRender = tryRenderPublicationPage({ + did, + publication, + path: "/", + }); + if (homePageRender) return homePageRender; + + const record = normalizePublicationRecord(publication.record); + // Resolve the author profile and post bylines server-side so they're in the + // SSR HTML. The profile lookup is independent of the post queries, so it + // runs alongside the rows → bylines chain. + const agent = new BskyAgent({ service: "https://public.api.bsky.app" }); + const [{ data: profile }, posts] = await Promise.all([ + agent.getProfile({ actor: did }), + fetchPublicationPostRows(publication.uri).then(async (rows) => { + const posts = buildPublicationPosts(rows); + return attachBylineProfiles( + posts, + await getProfiles(bylineDidsForPosts(posts)), + ); + }), + ]); + return ( + + + ); +} diff --git a/app/(app)/lish/[did]/[publication]/publicationAlternates.ts b/app/(app)/(published)/lish/[did]/[publication]/publicationAlternates.ts similarity index 100% rename from app/(app)/lish/[did]/[publication]/publicationAlternates.ts rename to app/(app)/(published)/lish/[did]/[publication]/publicationAlternates.ts diff --git a/app/(app)/lish/[did]/[publication]/robots.txt/route.ts b/app/(app)/(published)/lish/[did]/[publication]/robots.txt/route.ts similarity index 89% rename from app/(app)/lish/[did]/[publication]/robots.txt/route.ts rename to app/(app)/(published)/lish/[did]/[publication]/robots.txt/route.ts index 2b649fab..f4e50535 100644 --- a/app/(app)/lish/[did]/[publication]/robots.txt/route.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/robots.txt/route.ts @@ -31,6 +31,9 @@ Sitemap: https://${host}/sitemap.xml "Content-Type": "text/plain", "Cache-Control": "s-maxage=300, stale-while-revalidate=3600", "CDN-Cache-Control": "s-maxage=300, stale-while-revalidate=3600", + // Body embeds the request host; a pub reachable on two domains must + // not serve one domain's URLs from the other's cache entry. + Vary: "Host", }, }); } diff --git a/app/(app)/lish/[did]/[publication]/rss/route.ts b/app/(app)/(published)/lish/[did]/[publication]/rss/route.ts similarity index 100% rename from app/(app)/lish/[did]/[publication]/rss/route.ts rename to app/(app)/(published)/lish/[did]/[publication]/rss/route.ts diff --git a/app/(app)/lish/[did]/[publication]/sitemap.xml/route.ts b/app/(app)/(published)/lish/[did]/[publication]/sitemap.xml/route.ts similarity index 96% rename from app/(app)/lish/[did]/[publication]/sitemap.xml/route.ts rename to app/(app)/(published)/lish/[did]/[publication]/sitemap.xml/route.ts index 68e147b0..c82fcc87 100644 --- a/app/(app)/lish/[did]/[publication]/sitemap.xml/route.ts +++ b/app/(app)/(published)/lish/[did]/[publication]/sitemap.xml/route.ts @@ -104,6 +104,9 @@ export async function GET( "Content-Type": "application/xml", "Cache-Control": "s-maxage=300, stale-while-revalidate=3600", "CDN-Cache-Control": "s-maxage=300, stale-while-revalidate=3600", + // Body embeds the request host; a pub reachable on two domains must + // not serve one domain's URLs from the other's cache entry. + Vary: "Host", }, }); } diff --git a/app/(app)/lish/[did]/[publication]/sortPublicationPages.ts b/app/(app)/(published)/lish/[did]/[publication]/sortPublicationPages.ts similarity index 100% rename from app/(app)/lish/[did]/[publication]/sortPublicationPages.ts rename to app/(app)/(published)/lish/[did]/[publication]/sortPublicationPages.ts diff --git a/app/(app)/lish/[did]/[publication]/subscribe/SubscribeCard.tsx b/app/(app)/(published)/lish/[did]/[publication]/subscribe/SubscribeCard.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/subscribe/SubscribeCard.tsx rename to app/(app)/(published)/lish/[did]/[publication]/subscribe/SubscribeCard.tsx diff --git a/app/(app)/lish/[did]/[publication]/subscribe/SubscribePage.tsx b/app/(app)/(published)/lish/[did]/[publication]/subscribe/SubscribePage.tsx similarity index 76% rename from app/(app)/lish/[did]/[publication]/subscribe/SubscribePage.tsx rename to app/(app)/(published)/lish/[did]/[publication]/subscribe/SubscribePage.tsx index d701d6da..d23ebf42 100644 --- a/app/(app)/lish/[did]/[publication]/subscribe/SubscribePage.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/subscribe/SubscribePage.tsx @@ -1,7 +1,7 @@ import { supabaseServerClient } from "supabase/serverClient"; import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; -import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; +import { notFound } from "next/navigation"; import { PublicationThemeProvider, PublicationBackgroundProvider, @@ -17,13 +17,12 @@ export async function SubscribePage(props: { identifier: string; }) { const { did, identifier } = props; - if (!did || !identifier) return+ ++ ; + if (!did || !identifier) notFound(); const { data: publications } = await supabaseServerClient .from("publications") .select( - `*, - publication_subscriptions(*), + `uri, record, identity_did, publication_newsletter_settings(enabled)`, ) .eq("identity_did", did) @@ -34,7 +33,7 @@ export async function SubscribePage(props: { const publication = publications?.[0]; const record = normalizePublicationRecord(publication?.record); - if (!publication || !record) return ; + if (!publication || !record) notFound(); return ( ); } - -export const PubNotFound = () => { - return ( - - - ); -}; diff --git a/app/(app)/lish/[did]/[publication]/subscribe/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/subscribe/page.tsx similarity index 54% rename from app/(app)/lish/[did]/[publication]/subscribe/page.tsx rename to app/(app)/(published)/lish/[did]/[publication]/subscribe/page.tsx index 4714d29c..e0aee657 100644 --- a/app/(app)/lish/[did]/[publication]/subscribe/page.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/subscribe/page.tsx @@ -1,6 +1,15 @@ import type { Metadata } from "next"; import { SubscribePage } from "./SubscribePage"; +// On-demand ISR: rendered on first request, then served from the CDN and +// re-rendered in the background. The empty generateStaticParams is what opts a +// dynamic-params route into caching at all — `revalidate` alone leaves it +// fully dynamic. Writes invalidate eagerly via revalidatePublicationPaths. +export const revalidate = 3600; +export async function generateStaticParams() { + return []; +} + export const metadata: Metadata = { robots: { index: false } }; export default async function PublicationSubscribePage(props: { diff --git a/app/(app)/lish/[did]/[publication]/subscribeSuccess/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/subscribeSuccess/page.tsx similarity index 71% rename from app/(app)/lish/[did]/[publication]/subscribeSuccess/page.tsx rename to app/(app)/(published)/lish/[did]/[publication]/subscribeSuccess/page.tsx index e78e89e7..955841f9 100644 --- a/app/(app)/lish/[did]/[publication]/subscribeSuccess/page.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/subscribeSuccess/page.tsx @@ -2,6 +2,15 @@ import type { Metadata } from "next"; import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; import { RSSSmall } from "components/Icons/RSSSmall"; +// On-demand ISR: rendered on first request, then served from the CDN and +// re-rendered in the background. The empty generateStaticParams is what opts a +// dynamic-params route into caching at all — `revalidate` alone leaves it +// fully dynamic. Writes invalidate eagerly via revalidatePublicationPaths. +export const revalidate = 3600; +export async function generateStaticParams() { + return []; +} + export const metadata: Metadata = { robots: { index: false } }; export default function SubscribeSuccess() { diff --git a/app/(app)/lish/[did]/[publication]/theme-settings/PostPreview.tsx b/app/(app)/(published)/lish/[did]/[publication]/theme-settings/PostPreview.tsx similarity index 86% rename from app/(app)/lish/[did]/[publication]/theme-settings/PostPreview.tsx rename to app/(app)/(published)/lish/[did]/[publication]/theme-settings/PostPreview.tsx index f2e0c0f8..494fccf4 100644 --- a/app/(app)/lish/[did]/[publication]/theme-settings/PostPreview.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/theme-settings/PostPreview.tsx @@ -1,6 +1,6 @@ "use client"; -import { LinearDocumentPage } from "app/(app)/lish/[did]/[publication]/[rkey]/LinearDocumentPage"; +import { LinearDocumentPage } from "app/(app)/(published)/lish/[did]/[publication]/[rkey]/LinearDocumentPage"; import { LeafletContentProvider } from "contexts/LeafletContentContext"; import { DocumentProvider, @@ -9,7 +9,7 @@ import { import { useIdentityData } from "components/IdentityProvider"; import type { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import type { PubLeafletPagesLinearDocument } from "lexicons/api"; -import type { PostPageData } from "app/(app)/lish/[did]/[publication]/[rkey]/getPostPageData"; +import type { PostPageData } from "src/utils/getPostPageData"; import type { DocumentContextValue } from "contexts/DocumentContext"; import { usePublicationData, @@ -52,8 +52,6 @@ function makeFakeDocument( comments_on_documents: [], mentions: [], document_mentions_in_bsky: [], - leaflets_in_publications: [], - leafletId: null, recommendsCount: 0, documents_in_publications: publication ? [{ publications: publication }] @@ -86,18 +84,7 @@ export function PostPreview(props: { name: publication.name, identity_did: publication.identity_did, record: publication.record as NonNullableSorry, we can't find this publication!
-- This may be a glitch on our end. If the issue persists please{" "} - send us a note. -
-["record"], - publication_subscriptions: ( - publication.publication_subscriptions || [] - ).map((s) => ({ - created_at: s.created_at, - identity: s.identity, - publication: s.publication, - record: s.record, - uri: s.uri, - })), - newsletterMode: - !!publication.publication_newsletter_settings?.enabled, - pages: [], + newsletterMode: !!publication.publication_newsletter_settings?.enabled, } : null; @@ -115,7 +102,6 @@ export function PostPreview(props: { commentsCount: 0, commentsCountByPage: {}, mentions: [], - leafletId: null, recommendsCount: 0, }; diff --git a/app/(app)/lish/[did]/[publication]/theme-settings/PubPreview.tsx b/app/(app)/(published)/lish/[did]/[publication]/theme-settings/PubPreview.tsx similarity index 95% rename from app/(app)/lish/[did]/[publication]/theme-settings/PubPreview.tsx rename to app/(app)/(published)/lish/[did]/[publication]/theme-settings/PubPreview.tsx index 1f660beb..746a84db 100644 --- a/app/(app)/lish/[did]/[publication]/theme-settings/PubPreview.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/theme-settings/PubPreview.tsx @@ -3,11 +3,11 @@ import { usePublicationData, useNormalizedPublicationRecord, -} from "app/(app)/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; +} from "app/(app)/(published)/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; import { useIdentityData } from "components/IdentityProvider"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { DefaultPublicationHomepage } from "../DefaultPublicationHomepage"; -import { buildPublicationPosts } from "../buildPublicationPosts"; +import { buildPublicationPosts } from "src/utils/buildPublicationPosts"; import { LocalizedDate } from "../LocalizedDate"; import type { ReactNode } from "react"; diff --git a/app/(app)/lish/[did]/[publication]/theme-settings/ThemeSettingsContent.tsx b/app/(app)/(published)/lish/[did]/[publication]/theme-settings/ThemeSettingsContent.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/theme-settings/ThemeSettingsContent.tsx rename to app/(app)/(published)/lish/[did]/[publication]/theme-settings/ThemeSettingsContent.tsx diff --git a/app/(app)/lish/[did]/[publication]/theme-settings/page.tsx b/app/(app)/(published)/lish/[did]/[publication]/theme-settings/page.tsx similarity index 81% rename from app/(app)/lish/[did]/[publication]/theme-settings/page.tsx rename to app/(app)/(published)/lish/[did]/[publication]/theme-settings/page.tsx index 49e5c10d..02a41774 100644 --- a/app/(app)/lish/[did]/[publication]/theme-settings/page.tsx +++ b/app/(app)/(published)/lish/[did]/[publication]/theme-settings/page.tsx @@ -1,3 +1,4 @@ +import { IdentityProviderServer } from "components/IdentityProviderServer"; import type { Metadata } from "next"; import { supabaseServerClient } from "supabase/serverClient"; import { getIdentityData } from "actions/getIdentityData"; @@ -12,7 +13,7 @@ import { LoginModal } from "components/LoginButton"; export const metadata: Metadata = { robots: { index: false } }; -export default async function ThemeSettingsPage(props: { +async function ThemeSettingsPageInner(props: { params: Promise<{ publication: string; did: string }>; }) { let params = await props.params; @@ -84,3 +85,20 @@ const ThemeNotFound = () => { ); }; + +// Explicit rather than incidental (via cookies()): guards against a future +// revalidate/generateStaticParams landing on a shared segment above. +export const dynamic = "force-dynamic"; + +// Identity-gated surface inside the (published) group: shadow the +// client-fetched viewer identity with a server-fed provider so chrome +// renders correctly on the first frame. +export default async function ThemeSettingsPage(props: { + params: Promise<{ publication: string; did: string }>; +}) { + return ( + + + ); +} diff --git a/app/(app)/lish/[did]/[publication]/theme-settings/postPreviewFakeBlocks.ts b/app/(app)/(published)/lish/[did]/[publication]/theme-settings/postPreviewFakeBlocks.ts similarity index 100% rename from app/(app)/lish/[did]/[publication]/theme-settings/postPreviewFakeBlocks.ts rename to app/(app)/(published)/lish/[did]/[publication]/theme-settings/postPreviewFakeBlocks.ts diff --git a/app/(app)/lish/[did]/[publication]/tryRenderPublicationPage.tsx b/app/(app)/(published)/lish/[did]/[publication]/tryRenderPublicationPage.tsx similarity index 100% rename from app/(app)/lish/[did]/[publication]/tryRenderPublicationPage.tsx rename to app/(app)/(published)/lish/[did]/[publication]/tryRenderPublicationPage.tsx diff --git a/app/(app)/lish/createPub/CreatePubForm.tsx b/app/(app)/(published)/lish/createPub/CreatePubForm.tsx similarity index 98% rename from app/(app)/lish/createPub/CreatePubForm.tsx rename to app/(app)/(published)/lish/createPub/CreatePubForm.tsx index e2573b45..b61e5c2c 100644 --- a/app/(app)/lish/createPub/CreatePubForm.tsx +++ b/app/(app)/(published)/lish/createPub/CreatePubForm.tsx @@ -3,13 +3,15 @@ import { callRPC } from "app/api/rpc/client"; import { createPublication } from "./createPublication"; import { ButtonPrimary } from "components/Buttons"; import { AddSmall } from "components/Icons/AddSmall"; -import { useIdentityData } from "components/IdentityProvider"; import { Input, InputWithLabel } from "components/Input"; import { useRouter } from "next/navigation"; import { useState, useRef, useEffect } from "react"; import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; import { theme } from "tailwind.config"; -import { getBasePublicationURL, getPublicationURL } from "./getPublicationURL"; +import { + getBasePublicationURL, + getPublicationURL, +} from "src/utils/getPublicationURL"; import { string } from "zod"; import { DotLoader } from "components/utils/DotLoader"; import { Checkbox } from "components/Checkbox"; diff --git a/app/(app)/lish/createPub/createPublication.ts b/app/(app)/(published)/lish/createPub/createPublication.ts similarity index 98% rename from app/(app)/lish/createPub/createPublication.ts rename to app/(app)/(published)/lish/createPub/createPublication.ts index 0f2d36c7..5bddfe71 100644 --- a/app/(app)/lish/createPub/createPublication.ts +++ b/app/(app)/(published)/lish/createPub/createPublication.ts @@ -6,7 +6,7 @@ import { SiteStandardPublication, } from "lexicons/api"; import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; import { Vercel } from "@vercel/sdk"; @@ -47,7 +47,7 @@ export async function createPublication({ if (!isSubdomainValid.success) { return { success: false }; } - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !identity.atp_did) { return { success: false, diff --git a/app/(app)/lish/createPub/page.tsx b/app/(app)/(published)/lish/createPub/page.tsx similarity index 68% rename from app/(app)/lish/createPub/page.tsx rename to app/(app)/(published)/lish/createPub/page.tsx index b007b529..0c456727 100644 --- a/app/(app)/lish/createPub/page.tsx +++ b/app/(app)/(published)/lish/createPub/page.tsx @@ -1,9 +1,10 @@ +import { IdentityProviderServer } from "components/IdentityProviderServer"; import { ThemeProvider } from "components/ThemeManager/ThemeProvider"; import { CreatePubForm } from "./CreatePubForm"; import { getIdentityData } from "actions/getIdentityData"; import { LoginContent } from "components/LoginButton"; -export default async function CreatePub() { +async function CreatePubInner() { let identity = await getIdentityData(); if (!identity) return ( @@ -29,3 +30,18 @@ export default async function CreatePub() { ); } + +// Explicit rather than incidental (via cookies()): guards against a future +// revalidate/generateStaticParams landing on a shared segment above. +export const dynamic = "force-dynamic"; + +// Identity-gated surface inside the (published) group: shadow the +// client-fetched viewer identity with a server-fed provider so chrome +// renders correctly on the first frame. +export default async function CreatePub() { + return ( ++ + + ); +} diff --git a/app/(app)/lish/feeds/[...path]/route.ts b/app/(app)/(published)/lish/feeds/[...path]/route.ts similarity index 100% rename from app/(app)/lish/feeds/[...path]/route.ts rename to app/(app)/(published)/lish/feeds/[...path]/route.ts diff --git a/app/(app)/lish/sorry-boris/fixFeeds.ts b/app/(app)/(published)/lish/sorry-boris/fixFeeds.ts similarity index 91% rename from app/(app)/lish/sorry-boris/fixFeeds.ts rename to app/(app)/(published)/lish/sorry-boris/fixFeeds.ts index abca7b02..acc7fdbf 100644 --- a/app/(app)/lish/sorry-boris/fixFeeds.ts +++ b/app/(app)/(published)/lish/sorry-boris/fixFeeds.ts @@ -1,14 +1,14 @@ "use server"; import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { createOauthClient } from "src/atproto-oauth"; const leafletFeedURI = "at://did:plc:btxrwcaeyodrap5mnjw2fvmz/app.bsky.feed.generator/subscribedPublications"; export async function fixFeeds() { const oauthClient = await createOauthClient(); - let identity = await getIdentityData(); + let identity = await getAuthIdentity(); if (!identity || !identity.atp_did) { throw new Error("Invalid identity data"); } diff --git a/app/(app)/lish/sorry-boris/fixFeedsButton.tsx b/app/(app)/(published)/lish/sorry-boris/fixFeedsButton.tsx similarity index 100% rename from app/(app)/lish/sorry-boris/fixFeedsButton.tsx rename to app/(app)/(published)/lish/sorry-boris/fixFeedsButton.tsx diff --git a/app/(app)/lish/sorry-boris/page.tsx b/app/(app)/(published)/lish/sorry-boris/page.tsx similarity index 79% rename from app/(app)/lish/sorry-boris/page.tsx rename to app/(app)/(published)/lish/sorry-boris/page.tsx index 905748d0..8422e448 100644 --- a/app/(app)/lish/sorry-boris/page.tsx +++ b/app/(app)/(published)/lish/sorry-boris/page.tsx @@ -1,10 +1,11 @@ +import { IdentityProviderServer } from "components/IdentityProviderServer"; import { getIdentityData } from "actions/getIdentityData"; import { codeToHtml } from "shiki"; import { FixFeedsButton } from "./fixFeedsButton"; import { LoginModal } from "components/LoginButton"; import { ButtonPrimary } from "components/Buttons"; -export default async function Page() { +async function PageInner() { let identity = await getIdentityData(); if (!identity) { return ( @@ -73,3 +74,18 @@ let source = ` ]); } `; + +// Explicit rather than incidental (via cookies()): guards against a future +// revalidate/generateStaticParams landing on a shared segment above. +export const dynamic = "force-dynamic"; + +// Identity-gated surface inside the (published) group: shadow the +// client-fetched viewer identity with a server-fed provider so chrome +// renders correctly on the first frame. +export default async function Page() { + return ( ++ + + ); +} diff --git a/app/(app)/lish/uri/[uri]/route.ts b/app/(app)/(published)/lish/uri/[uri]/route.ts similarity index 97% rename from app/(app)/lish/uri/[uri]/route.ts rename to app/(app)/(published)/lish/uri/[uri]/route.ts index 04784001..9179a3e5 100644 --- a/app/(app)/lish/uri/[uri]/route.ts +++ b/app/(app)/(published)/lish/uri/[uri]/route.ts @@ -6,7 +6,7 @@ import { normalizePublicationRecord, type NormalizedPublication, } from "src/utils/normalizeRecords"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getDocumentURL } from "src/utils/getPublicationURL"; import { isDocumentCollection, isPublicationCollection, diff --git a/app/(app)/lish/url/[url]/route.tsx b/app/(app)/(published)/lish/url/[url]/route.tsx similarity index 100% rename from app/(app)/lish/url/[url]/route.tsx rename to app/(app)/(published)/lish/url/[url]/route.tsx diff --git a/app/(app)/(published)/not-found.tsx b/app/(app)/(published)/not-found.tsx new file mode 100644 index 00000000..360caca7 --- /dev/null +++ b/app/(app)/(published)/not-found.tsx @@ -0,0 +1,16 @@ +import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; + +// The 404 body for every published surface: pages in this group call +// notFound() for genuinely-missing content so a miss is a real 404 status +// rather than a 200 the CDN would happily cache for the revalidate window. +export default function PublishedNotFound() { + return ( ++ + + ); +} diff --git a/app/(app)/p/[didOrHandle]/[rkey]/l-quote/[quote]/opengraph-image.ts b/app/(app)/(published)/p/[didOrHandle]/[rkey]/l-quote/[quote]/opengraph-image.ts similarity index 86% rename from app/(app)/p/[didOrHandle]/[rkey]/l-quote/[quote]/opengraph-image.ts rename to app/(app)/(published)/p/[didOrHandle]/[rkey]/l-quote/[quote]/opengraph-image.ts index 8182b647..04f78fc1 100644 --- a/app/(app)/p/[didOrHandle]/[rkey]/l-quote/[quote]/opengraph-image.ts +++ b/app/(app)/(published)/p/[didOrHandle]/[rkey]/l-quote/[quote]/opengraph-image.ts @@ -1,11 +1,16 @@ import { ogScreenshotResponse } from "src/utils/screenshotPage"; -import { decodeQuotePosition } from "app/(app)/lish/[did]/[publication]/[rkey]/quotePosition"; +import { decodeQuotePosition } from "src/utils/quotePosition"; // OG content is effectively immutable post-publish, and each regeneration is a // multi-second remote-browser render billed for its full wall time — unfurl // bots re-fetch these constantly. export const revalidate = 86400; +export async function generateStaticParams() { + return []; +} + + export default async function OpenGraphImage(props: { params: Promise<{ didOrHandle: string; rkey: string; quote: string }>; }) { diff --git a/app/(app)/(published)/p/[didOrHandle]/[rkey]/l-quote/[quote]/page.tsx b/app/(app)/(published)/p/[didOrHandle]/[rkey]/l-quote/[quote]/page.tsx new file mode 100644 index 00000000..841001d1 --- /dev/null +++ b/app/(app)/(published)/p/[didOrHandle]/[rkey]/l-quote/[quote]/page.tsx @@ -0,0 +1,22 @@ +import { notFound } from "next/navigation"; +import { decodeQuotePosition } from "src/utils/quotePosition"; +import PostPage from "app/(app)/(published)/p/[didOrHandle]/[rkey]/page"; + +// On-demand ISR: rendered on first request, then served from the CDN and +// re-rendered in the background. The empty generateStaticParams is what opts a +// dynamic-params route into caching at all — `revalidate` alone leaves it +// fully dynamic. Writes invalidate eagerly via revalidatePublicationPaths. +export const revalidate = 300; +export async function generateStaticParams() { + return []; +} + +export { generateMetadata } from "app/(app)/(published)/p/[didOrHandle]/[rkey]/page"; +export default async function Post(props: { + params: Promise<{ didOrHandle: string; rkey: string; quote: string }>; +}) { + // Garbage quote params would each mint a permanent ISR entry. + if (!decodeQuotePosition(decodeURIComponent((await props.params).quote))) + notFound(); + returnSorry, we can't find that page!
++ This may be a glitch on our end. If the issue persists please{" "} + send us a note. +
+; +} diff --git a/app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts b/app/(app)/(published)/p/[didOrHandle]/[rkey]/opengraph-image.ts similarity index 97% rename from app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts rename to app/(app)/(published)/p/[didOrHandle]/[rkey]/opengraph-image.ts index 56358199..fa199f63 100644 --- a/app/(app)/p/[didOrHandle]/[rkey]/opengraph-image.ts +++ b/app/(app)/(published)/p/[didOrHandle]/[rkey]/opengraph-image.ts @@ -11,6 +11,11 @@ import { documentUriFilter } from "src/utils/uriHelpers"; // bots re-fetch these constantly. export const revalidate = 86400; +export async function generateStaticParams() { + return []; +} + + export default async function OpenGraphImage(props: { params: Promise<{ rkey: string; didOrHandle: string }>; }) { diff --git a/app/(app)/p/[didOrHandle]/[rkey]/page.tsx b/app/(app)/(published)/p/[didOrHandle]/[rkey]/page.tsx similarity index 65% rename from app/(app)/p/[didOrHandle]/[rkey]/page.tsx rename to app/(app)/(published)/p/[didOrHandle]/[rkey]/page.tsx index 1762f0da..66d989c2 100644 --- a/app/(app)/p/[didOrHandle]/[rkey]/page.tsx +++ b/app/(app)/(published)/p/[didOrHandle]/[rkey]/page.tsx @@ -1,12 +1,29 @@ import { supabaseServerClient } from "supabase/serverClient"; import { Metadata } from "next"; +import { cache } from "react"; import { idResolver } from "src/identity"; -import { DocumentPageRenderer } from "app/(app)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; -import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; +import { notFound } from "next/navigation"; +import { DocumentPageRenderer } from "app/(app)/(published)/lish/[did]/[publication]/[rkey]/DocumentPageRenderer"; import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; import { documentUriFilter } from "src/utils/uriHelpers"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; -import { decodeQuotePosition } from "app/(app)/lish/[did]/[publication]/[rkey]/quotePosition"; +import { getDocumentURL } from "src/utils/getPublicationURL"; +import { decodeQuotePosition } from "src/utils/quotePosition"; + +// On-demand ISR: rendered on first request, then served from the CDN and +// re-rendered in the background. The empty generateStaticParams is what opts a +// dynamic-params route into caching at all — `revalidate` alone leaves it +// fully dynamic. Writes invalidate eagerly via revalidatePublicationPaths. +export const revalidate = 300; +export async function generateStaticParams() { + return []; +} + +// Memoized per request so the metadata and the page body resolve the handle +// once. Failures stay failures — the memoized rejection re-throws, and each +// caller treats an unresolvable handle as a 404. +const resolveHandle = cache((handle: string) => + idResolver.handle.resolve(handle), +); export async function generateMetadata(props: { params: Promise<{ didOrHandle: string; rkey: string }>; @@ -18,7 +35,7 @@ export async function generateMetadata(props: { let did = didOrHandle; if (!didOrHandle.startsWith("did:")) { try { - let resolved = await idResolver.handle.resolve(didOrHandle); + let resolved = await resolveHandle(didOrHandle); if (resolved) did = resolved; } catch (e) { return { title: "404" }; @@ -72,31 +89,9 @@ export default async function StandaloneDocumentPage(props: { // Resolve handle to DID if necessary let did = didOrHandle; if (!didOrHandle.startsWith("did:")) { - try { - let resolved = await idResolver.handle.resolve(didOrHandle); - if (!resolved) { - return ( - - - ); - } - did = resolved; - } catch (e) { - return ( -Sorry, we can't find this handle!
-- This may be a glitch on our end. If the issue persists please{" "} - send us a note. -
-- - ); - } + let resolved = await resolveHandle(didOrHandle).catch(() => null); + if (!resolved) notFound(); + did = resolved; } return ( diff --git a/app/(app)/subscribe/[did]/[rkey]/opengraph-image.ts b/app/(app)/(published)/subscribe/[did]/[rkey]/opengraph-image.ts similarity index 90% rename from app/(app)/subscribe/[did]/[rkey]/opengraph-image.ts rename to app/(app)/(published)/subscribe/[did]/[rkey]/opengraph-image.ts index e9b4ca0a..1cc8534a 100644 --- a/app/(app)/subscribe/[did]/[rkey]/opengraph-image.ts +++ b/app/(app)/(published)/subscribe/[did]/[rkey]/opengraph-image.ts @@ -5,6 +5,11 @@ import { ogScreenshotResponse } from "src/utils/screenshotPage"; // bots re-fetch these constantly. export const revalidate = 86400; +export async function generateStaticParams() { + return []; +} + + export default async function OpenGraphImage(props: { params: Promise<{ did: string; rkey: string }>; }) { diff --git a/app/(app)/subscribe/[did]/[rkey]/page.tsx b/app/(app)/(published)/subscribe/[did]/[rkey]/page.tsx similarity index 60% rename from app/(app)/subscribe/[did]/[rkey]/page.tsx rename to app/(app)/(published)/subscribe/[did]/[rkey]/page.tsx index 349168bf..f64aa90c 100644 --- a/app/(app)/subscribe/[did]/[rkey]/page.tsx +++ b/app/(app)/(published)/subscribe/[did]/[rkey]/page.tsx @@ -1,23 +1,34 @@ -import { - SubscribePage, - PubNotFound, -} from "app/(app)/lish/[did]/[publication]/subscribe/SubscribePage"; +import { SubscribePage } from "app/(app)/(published)/lish/[did]/[publication]/subscribe/SubscribePage"; import { Metadata } from "next"; +import { notFound, redirect } from "next/navigation"; +import { cache } from "react"; import { idResolver } from "src/identity"; import { supabaseServerClient } from "supabase/serverClient"; import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +// On-demand ISR: rendered on first request, then served from the CDN and +// re-rendered in the background. The empty generateStaticParams is what opts a +// dynamic-params route into caching at all — `revalidate` alone leaves it +// fully dynamic. Writes invalidate eagerly via revalidatePublicationPaths. +export const revalidate = 3600; +export async function generateStaticParams() { + return []; +} + // The first path segment may be a DID or a handle — handles are resolved to -// a DID before looking up the publication. -async function resolveDid(didOrHandle: string): PromiseSorry, we can't find this leaflet!
-- This may be a glitch on our end. If the issue persists please{" "} - send us a note. -
-{ +// a DID before looking up the publication. Memoized per request: the metadata +// and the page body resolve the same segment. +const resolveDid = cache(async function resolveDid( + didOrHandle: string, +): Promise { if (didOrHandle.startsWith("did:")) return didOrHandle; try { return (await idResolver.handle.resolve(didOrHandle)) ?? null; } catch (e) { return null; } -} +}); export async function generateMetadata(props: { params: Promise<{ did: string; rkey: string }>; @@ -54,8 +65,13 @@ export default async function SubscribeByRkeyPage(props: { }) { const params = await props.params; const rkey = decodeURIComponent(params.rkey); - const did = await resolveDid(decodeURIComponent(params.did)); - if (!did) return ; + const didOrHandle = decodeURIComponent(params.did); + const did = await resolveDid(didOrHandle); + if (!did) notFound(); + // Canonicalize handle URLs onto the DID form so each publication has one + // cache entry instead of one per handle spelling. + if (didOrHandle !== did) + redirect(`/subscribe/${encodeURIComponent(did)}/${params.rkey}`); return ; } diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx deleted file mode 100644 index e13eec4e..00000000 --- a/app/(app)/layout.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { headers } from "next/headers"; -import { Suspense } from "react"; -import { IdentityProviderServer } from "components/IdentityProviderServer"; -import { RequestHeadersProvider } from "components/Providers/RequestHeadersProvider"; -import { RouteUIStateManager } from "components/RouteUIStateManger"; -import { SubscriptionSuccessModal } from "components/SubscriptionSuccessModal"; -import { SubscribeConfirmationModal } from "components/Subscribe/SubscribeConfirmationModal"; -import { RecommendConfirmationToast } from "components/Interactions/RecommendConfirmationToast"; - -export default async function AppLayout({ - children, -}: { - children: React.ReactNode; -}) { - let headersList = await headers(); - let ipLocation = headersList.get("X-Vercel-IP-Country"); - let acceptLanguage = headersList.get("accept-language"); - let ipTimezone = headersList.get("X-Vercel-IP-Timezone"); - return ( - - - ); -} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/l-quote/[quote]/page.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/l-quote/[quote]/page.tsx deleted file mode 100644 index dbd2bcca..00000000 --- a/app/(app)/lish/[did]/[publication]/[rkey]/l-quote/[quote]/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import PostPage, { - generateMetadata, -} from "app/(app)/lish/[did]/[publication]/[rkey]/page"; - -export { generateMetadata } from "app/(app)/lish/[did]/[publication]/[rkey]/page"; -export default async function Post(props: { - params: Promise<{ - publication: string; - did: string; - rkey: string; - quote: string; - }>; -}) { - return- {children} - -- -- - - - ; -} diff --git a/app/(app)/lish/[did]/[publication]/getPublicationForPage.ts b/app/(app)/lish/[did]/[publication]/getPublicationForPage.ts deleted file mode 100644 index bab9f441..00000000 --- a/app/(app)/lish/[did]/[publication]/getPublicationForPage.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { supabaseServerClient } from "supabase/serverClient"; -import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; - -export async function fetchPublicationForPage( - did: string, - publicationName: string, -) { - const { data } = await supabaseServerClient - .from("publications") - .select( - `uri, name, identity_did, record, - publication_newsletter_settings(enabled), - publication_pages(id, path, title, record, record_uri, sort_order)`, - ) - .eq("identity_did", did) - .or(publicationNameOrUriFilter(did, publicationName)) - .order("uri", { ascending: false }) - .limit(1); - return data?.[0] ?? null; -} - -export type PublicationForPage = NonNullable< - Awaited > ->; - -// Fetched separately from the publication row because most requests through -// fetchPublicationForPage (every post URL, any page without a posts list) -// never render the post list — loading every document's full record jsonb up -// front made the common path pay for the rare one. -export async function fetchPublicationPostRows(publicationUri: string) { - const { data } = await supabaseServerClient - .from("documents_in_publications") - .select( - `members_only, documents(uri, data, - comments_on_documents(count), - document_mentions_in_bsky(count), - recommends_on_documents(count))`, - ) - .eq("publication", publicationUri); - return data ?? []; -} diff --git a/app/(app)/lish/[did]/[publication]/page.tsx b/app/(app)/lish/[did]/[publication]/page.tsx deleted file mode 100644 index d17f9fe5..00000000 --- a/app/(app)/lish/[did]/[publication]/page.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { BskyAgent } from "@atproto/api"; -import React from "react"; -import type { Metadata } from "next"; -import { supabaseServerClient } from "supabase/serverClient"; -import { publicationNameOrUriFilter } from "src/utils/uriHelpers"; -import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; -import { normalizePublicationRecord } from "src/utils/normalizeRecords"; -import { publicationAlternates } from "./publicationAlternates"; -import { DefaultPublicationHomepage } from "./DefaultPublicationHomepage"; -import { buildPublicationPosts } from "./buildPublicationPosts"; -import { - fetchPublicationForPage, - fetchPublicationPostRows, -} from "./getPublicationForPage"; -import { tryRenderPublicationPage } from "./tryRenderPublicationPage"; -import { getProfiles } from "src/identity"; -import { attachBylineProfiles, bylineDidsForPosts } from "src/utils/byline"; -import { - PublicationThemeProvider, - PublicationBackgroundProvider, -} from "components/ThemeManager/PublicationThemeProvider"; - -export async function generateMetadata(props: { - params: Promise<{ publication: string; did: string }>; -}): Promise { - let params = await props.params; - let did = decodeURIComponent(params.did); - if (!did || !params.publication) return {}; - - let { data: publications } = await supabaseServerClient - .from("publications") - .select("uri, record") - .eq("identity_did", did) - .or(publicationNameOrUriFilter(did, decodeURIComponent(params.publication))) - .order("uri", { ascending: false }) - .limit(1); - - let alternates = publicationAlternates( - normalizePublicationRecord(publications?.[0]?.record), - "/", - ); - return alternates ? { alternates } : {}; -} - -export default async function Publication(props: { - params: Promise<{ publication: string; did: string }>; -}) { - console.log("are we getting here??"); - let params = await props.params; - const did = decodeURIComponent(params.did); - if (!did) return ; - const publication_name = decodeURIComponent(params.publication); - - const publication = await fetchPublicationForPage(did, publication_name); - console.log(publication); - if (!publication) return ; - - try { - // Render a published "/" page when one exists; otherwise fall back to the - // legacy post-listing homepage. - const homePageRender = tryRenderPublicationPage({ - did, - publication, - path: "/", - }); - if (homePageRender) return homePageRender; - - const record = normalizePublicationRecord(publication.record); - // Resolve the author profile and post bylines server-side so they're in the - // SSR HTML. The profile lookup is independent of the post queries, so it - // runs alongside the rows → bylines chain. - const agent = new BskyAgent({ service: "https://public.api.bsky.app" }); - const [{ data: profile }, posts] = await Promise.all([ - agent.getProfile({ actor: did }), - fetchPublicationPostRows(publication.uri).then(async (rows) => { - const posts = buildPublicationPosts(rows); - return attachBylineProfiles( - posts, - await getProfiles(bylineDidsForPosts(posts)), - ); - }), - ]); - return ( - - - ); - } catch (e) { - console.log(e); - return- -- {JSON.stringify(e, undefined, 2)}; - } -} - -const PubNotFound = () => { - return ( -- - ); -}; diff --git a/app/(app)/p/[didOrHandle]/[rkey]/l-quote/[quote]/page.tsx b/app/(app)/p/[didOrHandle]/[rkey]/l-quote/[quote]/page.tsx deleted file mode 100644 index 6cfe1864..00000000 --- a/app/(app)/p/[didOrHandle]/[rkey]/l-quote/[quote]/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import PostPage from "app/(app)/p/[didOrHandle]/[rkey]/page"; - -export { generateMetadata } from "app/(app)/p/[didOrHandle]/[rkey]/page"; -export default async function Post(props: { - params: Promise<{ didOrHandle: string; rkey: string; quote: string }>; -}) { - returnSorry, we can't find this publication!
-- This may be a glitch on our end. If the issue persists please{" "} - send us a note. -
-; -} diff --git a/app/about/Examples.tsx b/app/about/Examples.tsx index 72e0edce..c352c687 100644 --- a/app/about/Examples.tsx +++ b/app/about/Examples.tsx @@ -1,7 +1,7 @@ import { GoToArrow } from "components/Icons/GoToArrow"; import { supabaseServerClient } from "supabase/serverClient"; import { normalizePublicationRecord } from "src/utils/normalizeRecords"; -import { PubListing } from "app/(app)/(home-pages)/p/[didOrHandle]/PubListing"; +import { PubListing } from "app/(app)/(identity)/(home-pages)/p/[didOrHandle]/PubListing"; import { idResolver } from "src/identity"; import { SpeedyLink } from "components/SpeedyLink"; diff --git a/app/about/LandingCTA.tsx b/app/about/LandingCTA.tsx index 5d51f1f5..db1f9f48 100644 --- a/app/about/LandingCTA.tsx +++ b/app/about/LandingCTA.tsx @@ -4,13 +4,13 @@ import { useEffect, useState } from "react"; import { LoginModal } from "components/LoginButton"; import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; import { Input } from "components/Input"; -import { getIdentityData } from "actions/getIdentityData"; +import { getViewerIdentity } from "actions/viewerIdentity"; export function LandingCTA() { let [loginOpen, setLoginOpen] = useState(false); let [signedIn, setSignedIn] = useState(false); useEffect(() => { - getIdentityData().then((identity) => setSignedIn(!!identity?.atp_did)); + getViewerIdentity().then((identity) => setSignedIn(!!identity?.atp_did)); }, []); return ( diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 5bac2852..aae75e64 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -44,6 +44,20 @@ export async function GET(req: NextRequest) { "Set-Cookie", `external_auth_token=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict`, ); + // The session marker mirrors wherever an auth cookie may live: domain-set + // (setAuthToken), dot-domain (legacy), and host-only (middleware mirror). + response.headers.append( + "Set-Cookie", + `leaflet_session=; Path=/; Domain=${domain}; Max-Age=0; Secure; SameSite=Lax`, + ); + response.headers.append( + "Set-Cookie", + `leaflet_session=; Path=/; Domain=.${domain}; Max-Age=0; Secure; SameSite=Lax`, + ); + response.headers.append( + "Set-Cookie", + `leaflet_session=; Path=/; Max-Age=0; Secure; SameSite=Lax`, + ); return response; } diff --git a/app/api/bsky/agent.ts b/app/api/bsky/agent.ts index 22d75025..b7ffc400 100644 --- a/app/api/bsky/agent.ts +++ b/app/api/bsky/agent.ts @@ -15,6 +15,8 @@ export function getPublicAgent(): Agent { async function getAuthenticatedAgent(): Promise{ try { + // published-purity-ok: published pages reach this module only for + // getPublicAgent (profileCache/byline); nothing in that tree calls getAgent. const cookieStore = await cookies(); const authToken = cookieStore.get("auth_token")?.value || diff --git a/app/api/hot_feed/route.ts b/app/api/hot_feed/route.ts index 4f37c82c..aaac8afb 100644 --- a/app/api/hot_feed/route.ts +++ b/app/api/hot_feed/route.ts @@ -1,6 +1,6 @@ export const runtime = "nodejs"; -import { getHotFeed } from "app/(app)/(home-pages)/reader/getHotFeed"; +import { getHotFeed } from "actions/reader/getHotFeed"; // The hot feed is identical for every user and already served 5 minutes // stale from redis. Exposing it as a CDN-cacheable GET collapses every diff --git a/app/api/oauth/[route]/route.ts b/app/api/oauth/[route]/route.ts index 37c421f0..63181999 100644 --- a/app/api/oauth/[route]/route.ts +++ b/app/api/oauth/[route]/route.ts @@ -1,8 +1,8 @@ import { backfillAtprotoSubscriptionsForIdentity, subscribeToPublication, -} from "app/(app)/lish/subscribeToPublication"; -import { recommendAction } from "app/(app)/lish/[did]/[publication]/[rkey]/Interactions/recommendAction"; +} from "actions/publications/subscribeToPublication"; +import { recommendAction } from "actions/recommendAction"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { NextRequest, NextResponse } from "next/server"; diff --git a/app/api/rpc/[command]/get_document_interactions.ts b/app/api/rpc/[command]/get_document_interactions.ts index c4b812d6..80770e6c 100644 --- a/app/api/rpc/[command]/get_document_interactions.ts +++ b/app/api/rpc/[command]/get_document_interactions.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import { makeRoute } from "../lib"; import type { Env } from "./route"; -import { getConstellationBacklinks } from "app/(app)/lish/[did]/[publication]/[rkey]/getPostPageData"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getConstellationBacklinks } from "src/utils/getPostPageData"; +import { getDocumentURL } from "src/utils/getPublicationURL"; import { normalizeDocumentRecord, normalizePublicationRecord, diff --git a/app/api/rpc/[command]/get_hot_feed.ts b/app/api/rpc/[command]/get_hot_feed.ts index 6825a898..eed48b43 100644 --- a/app/api/rpc/[command]/get_hot_feed.ts +++ b/app/api/rpc/[command]/get_hot_feed.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import { makeRoute } from "../lib"; import type { Env } from "./route"; -import { getHotFeed } from "app/(app)/(home-pages)/reader/getHotFeed"; -import type { Post } from "app/(app)/(home-pages)/reader/getReaderFeed"; +import { getHotFeed } from "actions/reader/getHotFeed"; +import type { Post } from "actions/reader/getReaderFeed"; export type GetHotFeedReturnType = Awaited< ReturnType<(typeof get_hot_feed)["handler"]> diff --git a/app/api/rpc/[command]/get_profile_data.ts b/app/api/rpc/[command]/get_profile_data.ts index 81c5b7ad..e572be81 100644 --- a/app/api/rpc/[command]/get_profile_data.ts +++ b/app/api/rpc/[command]/get_profile_data.ts @@ -4,7 +4,7 @@ import type { Env } from "./route"; import { idResolver } from "src/identity"; import { supabaseServerClient } from "supabase/serverClient"; import { Agent } from "@atproto/api"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { restoreOAuthSession } from "src/atproto-oauth"; import { normalizePublicationRow, @@ -33,7 +33,7 @@ export const get_profile_data = makeRoute({ did = resolved; } let agent; - let authed_identity = await getIdentityData(); + let authed_identity = await getAuthIdentity(); if (authed_identity?.atp_did) { const restored = await restoreOAuthSession(authed_identity.atp_did); agent = restored.ok diff --git a/app/api/rpc/[command]/get_publication_analytics.ts b/app/api/rpc/[command]/get_publication_analytics.ts index 4fc49e5d..d6e1bb94 100644 --- a/app/api/rpc/[command]/get_publication_analytics.ts +++ b/app/api/rpc/[command]/get_publication_analytics.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { makeRoute } from "../lib"; import type { Env } from "./route"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { tinybird } from "lib/tinybird"; export type GetPublicationAnalyticsReturnType = Awaited< @@ -24,7 +24,7 @@ export const get_publication_analytics = makeRoute({ { publication_uri, from, to, path, referrer_host, bsky_post }, { supabase }: Pick , ) => { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if ( !identity?.atp_did || !identity.entitlements?.publication_analytics || diff --git a/app/api/rpc/[command]/get_publication_bsky_posts.ts b/app/api/rpc/[command]/get_publication_bsky_posts.ts index 453095b9..8c1a8ffd 100644 --- a/app/api/rpc/[command]/get_publication_bsky_posts.ts +++ b/app/api/rpc/[command]/get_publication_bsky_posts.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { makeRoute } from "../lib"; import type { Env } from "./route"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { tinybird } from "lib/tinybird"; import { AtpAgent, AppBskyFeedDefs } from "@atproto/api"; import { fetchBskyPosts } from "src/utils/fetchBskyPosts"; @@ -35,7 +35,7 @@ export const get_publication_bsky_posts = makeRoute({ { publication_uri, from, to, path }, { supabase }: Pick , ) => { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if ( !identity?.atp_did || !identity.entitlements?.publication_analytics || diff --git a/app/api/rpc/[command]/get_publication_data.ts b/app/api/rpc/[command]/get_publication_data.ts index 8f85be91..41cfedb8 100644 --- a/app/api/rpc/[command]/get_publication_data.ts +++ b/app/api/rpc/[command]/get_publication_data.ts @@ -3,7 +3,7 @@ import { makeRoute } from "../lib"; import type { Env } from "./route"; import { AtUri } from "@atproto/syntax"; import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { ids } from "lexicons/api/lexicons"; export type GetPublicationDataReturnType = Awaited< @@ -34,7 +34,7 @@ export const get_publication_data = makeRoute({ ).toString(); } let [identity, { data: publication }] = await Promise.all([ - getIdentityData(), + getAuthIdentity(), supabase .from("publications") .select( diff --git a/app/api/rpc/[command]/get_publication_subscribers_timeseries.ts b/app/api/rpc/[command]/get_publication_subscribers_timeseries.ts index d2a5a592..3cb34dec 100644 --- a/app/api/rpc/[command]/get_publication_subscribers_timeseries.ts +++ b/app/api/rpc/[command]/get_publication_subscribers_timeseries.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { makeRoute } from "../lib"; import type { Env } from "./route"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; export type GetPublicationSubscribersTimeseriesReturnType = Awaited< ReturnType<(typeof get_publication_subscribers_timeseries)["handler"]> @@ -18,7 +18,7 @@ export const get_publication_subscribers_timeseries = makeRoute({ { publication_uri, from, to }, { supabase }: Pick , ) => { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity?.atp_did || !identity.entitlements?.publication_analytics || !identity.entitlements?.pro_plan_visible) { return { error: "unauthorized" as const }; } diff --git a/app/api/rpc/[command]/get_user_mention_services.ts b/app/api/rpc/[command]/get_user_mention_services.ts index b78b8161..3f6c76d4 100644 --- a/app/api/rpc/[command]/get_user_mention_services.ts +++ b/app/api/rpc/[command]/get_user_mention_services.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { makeRoute } from "../lib"; import type { Env } from "./route"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import type * as MentionConfig from "lexicons/api/types/parts/page/mention/config"; import type * as MentionService from "lexicons/api/types/parts/page/mention/service"; @@ -25,7 +25,7 @@ export const get_user_mention_services = makeRoute({ route: "get_user_mention_services", input: z.object({}), handler: async (_input, { supabase }: Pick ) => { - let user = await getIdentityData(); + let user = await getAuthIdentity(); if (!user?.atp_did) return { result: { services: [] } }; let services: string[]; const cachedConfig = configCache.get(user.atp_did); diff --git a/app/api/rpc/[command]/get_user_recommendations.ts b/app/api/rpc/[command]/get_user_recommendations.ts index 1c608695..4a96bf36 100644 --- a/app/api/rpc/[command]/get_user_recommendations.ts +++ b/app/api/rpc/[command]/get_user_recommendations.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { makeRoute } from "../lib"; import type { Env } from "./route"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; export type GetUserRecommendationsReturnType = Awaited< ReturnType<(typeof get_user_recommendations)["handler"]> @@ -13,7 +13,7 @@ export const get_user_recommendations = makeRoute({ documentUris: z.array(z.string()), }), handler: async ({ documentUris }, { supabase }: Pick ) => { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); const currentUserDid = identity?.atp_did; if (!currentUserDid || documentUris.length === 0) { diff --git a/app/api/rpc/[command]/proxy_mention_search.ts b/app/api/rpc/[command]/proxy_mention_search.ts index 8d3b313c..8b4b3f7a 100644 --- a/app/api/rpc/[command]/proxy_mention_search.ts +++ b/app/api/rpc/[command]/proxy_mention_search.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { makeRoute } from "../lib"; import type { Env } from "./route"; -import { getIdentityData } from "actions/getIdentityData"; +import { getAuthIdentity } from "src/auth"; import { restoreOAuthSession } from "src/atproto-oauth"; import { AtpBaseClient } from "lexicons/api"; import type * as SearchService from "lexicons/api/types/parts/page/mention/search"; @@ -30,7 +30,7 @@ export const proxy_mention_search = makeRoute({ { supabase }: Pick , ) => { try { - const identity = await getIdentityData(); + const identity = await getAuthIdentity(); if (!identity?.atp_did) throw new Error("Not authenticated"); let record: MentionService.Record; diff --git a/app/api/rpc/[command]/search_publication_documents.ts b/app/api/rpc/[command]/search_publication_documents.ts index 7be59c7f..0a2d21dd 100644 --- a/app/api/rpc/[command]/search_publication_documents.ts +++ b/app/api/rpc/[command]/search_publication_documents.ts @@ -2,7 +2,7 @@ import { AtUri } from "@atproto/api"; import { z } from "zod"; import { makeRoute } from "../lib"; import type { Env } from "./route"; -import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getDocumentURL } from "src/utils/getPublicationURL"; import { normalizeDocumentRecord } from "src/utils/normalizeRecords"; export type SearchPublicationDocumentsReturnType = Awaited< diff --git a/app/api/rpc/[command]/search_publication_names.ts b/app/api/rpc/[command]/search_publication_names.ts index d480b4d7..c94f1b8f 100644 --- a/app/api/rpc/[command]/search_publication_names.ts +++ b/app/api/rpc/[command]/search_publication_names.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { makeRoute } from "../lib"; import type { Env } from "./route"; -import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getPublicationURL } from "src/utils/getPublicationURL"; import { deduplicateByUri } from "src/utils/deduplicateRecords"; export type SearchPublicationNamesReturnType = Awaited< diff --git a/app/api/subscribe_email/route.ts b/app/api/subscribe_email/route.ts index 50e60ce5..01481700 100644 --- a/app/api/subscribe_email/route.ts +++ b/app/api/subscribe_email/route.ts @@ -3,7 +3,7 @@ import { supabaseServerClient } from "supabase/serverClient"; import { getPublicationURL, getBasePublicationURL, -} from "app/(app)/lish/createPub/getPublicationURL"; +} from "src/utils/getPublicationURL"; import { isProductionDomain } from "src/utils/isProductionDeployment"; import { encodeActionToSearchParam } from "app/api/oauth/[route]/afterSignInActions"; import { mainSiteAuthBase } from "src/utils/customDomain"; diff --git a/app/sitemap.xml/route.ts b/app/sitemap.xml/route.ts index 23fd47f1..626aa8dd 100644 --- a/app/sitemap.xml/route.ts +++ b/app/sitemap.xml/route.ts @@ -1,6 +1,6 @@ import { supabaseServerClient } from "supabase/serverClient"; import { isMainSiteHost } from "src/utils/customDomain"; -import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { getPublicationURL } from "src/utils/getPublicationURL"; function xmlEscape(s: string): string { return s diff --git a/app/test/subscribe-geometry/page.tsx b/app/test/subscribe-geometry/page.tsx new file mode 100644 index 00000000..e1274266 --- /dev/null +++ b/app/test/subscribe-geometry/page.tsx @@ -0,0 +1,343 @@ +"use client"; +// Geometry harness for identity-resolve layout shift. Renders each real +// surface that hosts an identity-dependent control, with a +// sentinel element after it, and lets a puppeteer script flip the identity in +// place through the real SWR key — exactly what ViewerIdentityProvider does when +// its fetch resolves. +import { useEffect, useState } from "react"; +import { mutate } from "swr"; +import { + ViewerIdentityProvider, + VIEWER_IDENTITY_KEY, + type Identity, +} from "components/IdentityProvider"; +import { + SubscribeButton, + SubscribeInput, + SubscribePanel, + type SubscribeProps, +} from "components/Subscribe/SubscribeButton"; +import { + PublicationHeader, + NewPublicationHeader, +} from "app/(app)/(published)/lish/[did]/[publication]/PublicationHeader"; +import { PublicationNav } from "app/(app)/(published)/lish/[did]/[publication]/PublicationNav"; +import { PublicationNavSubscribe } from "app/(app)/(published)/lish/[did]/[publication]/PublicationNavSubscribe"; +import { PostHeader } from "app/(app)/(published)/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; +import { ExpandedInteractions } from "app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Interactions"; +import { PublishedPollBlock } from "app/(app)/(published)/lish/[did]/[publication]/[rkey]/Blocks/PublishedPollBlock"; +import { DocumentProvider } from "contexts/DocumentContext"; + +const PUBLICATION_URI = "at://did:plc:example/pub.leaflet.publication/test"; +const PUBLICATION_URL = "https://example.leaflet.pub"; + +const baseProps: Omit = { + publicationUri: PUBLICATION_URI, + publicationUrl: PUBLICATION_URL, + publicationName: "Test Publication", + publicationDescription: + "A publication for previewing the subscribe affordances.", +}; + +type StateName = + | "loggedOut" + | "inHandle" + | "inEmail" + | "inBoth" + | "subAtproto" + | "subEmail" + | "subAtprotoOnly"; + +function makeIdentity(state: StateName): Identity { + if (state === "loggedOut") return null; + const hasHandle = state !== "inEmail" && state !== "subEmail"; + const hasEmail = state !== "inHandle" && state !== "subAtprotoOnly"; + return { + atp_did: hasHandle ? "did:plc:example" : null, + email: hasEmail ? "reader@example.com" : null, + bsky_profiles: hasHandle + ? { + did: "did:plc:example", + handle: "reader.bsky.social", + record: { did: "did:plc:example", handle: "reader.bsky.social" }, + } + : null, + publication_subscriptions: + state === "subAtproto" || state === "subAtprotoOnly" + ? [{ publication: PUBLICATION_URI }] + : [], + publication_email_subscribers: + state === "subEmail" + ? [{ publication: PUBLICATION_URI, state: "confirmed" }] + : [], + } as unknown as Identity; +} + +const tabs = [ + { id: 1, path: "/", title: "Home", sort_order: "a" }, + { id: 2, path: "/archive", title: "Archive", sort_order: "b" }, + { id: 3, path: "/about", title: "About", sort_order: "c" }, +]; +const manyTabs = [ + ...tabs, + { + id: 4, + path: "/newsletter-archive", + title: "Newsletter Archive", + sort_order: "d", + }, + { + id: 5, + path: "/contributors", + title: "Contributors and friends", + sort_order: "e", + }, + { + id: 6, + path: "/very-long-tab-name", + title: "A Very Long Tab Name Here", + sort_order: "f", + }, +]; + +function Surface(props: { name: string; children: React.ReactNode }) { + return ( + ++ ); +} + +function StateSwitcher() { + let [state, setState] = useState{props.children}+ +("loggedOut"); + useEffect(() => { + (window as any).__setIdentityState = (s: StateName) => { + setState(s); + return mutate(VIEWER_IDENTITY_KEY, makeIdentity(s), { + revalidate: false, + }); + }; + }, []); + return ( + + {state} ++ ); +} + +const OWNER_DID = "did:plc:example"; +const PUB = { + uri: PUBLICATION_URI, + name: "Test Publication", + identity_did: OWNER_DID, + record: { $type: "pub.leaflet.publication", name: "Test Publication" }, +}; +const LONG_PUB = { + ...PUB, + name: "A Publication With A Genuinely Very Long Name That Fills The Row", + record: { + $type: "pub.leaflet.publication", + name: "A Publication With A Genuinely Very Long Name That Fills The Row", + }, +}; +const DOC_URI = "at://did:plc:example/pub.leaflet.document/doc"; +const normalizedDocument = { + $type: "site.standard.document", + title: "A Post Title", + description: "A post description", + publishedAt: "2026-01-02T03:04:05.000Z", + tags: ["one", "two"], + path: "/doc", + site: PUBLICATION_URL, +} as any; + +const docContext = { + uri: DOC_URI, + normalizedDocument, + normalizedPublication: null, + postUrl: `${PUBLICATION_URL}/doc`, + theme: null, + prevNext: { prev: null, next: null }, + quotesAndMentions: [], + publication: PUB, + commentsCount: 0, + commentsCountByPage: { "": 2 }, + mentions: [], + leafletId: "leaflet-id", + recommendsCount: 3, +} as any; + +const postData = (pub: typeof PUB) => + ({ + data: {}, + uri: DOC_URI, + normalizedDocument, + documents_in_publications: [{ publications: pub }], + leaflets_in_publications: [{ leaflet: "leaflet-id" }], + quotesAndMentions: [], + commentsCountByPage: { "": 2 }, + recommendsCount: 3, + }) as any; + +const pollBlock = { + $type: "pub.leaflet.blocks.poll", + pollRef: { + uri: "at://did:plc:example/pub.leaflet.poll.definition/p", + cid: "cid", + }, +} as any; +const pollData = (options: number) => + ({ + uri: "at://did:plc:example/pub.leaflet.poll.definition/p", + cid: "cid", + record: { + $type: "pub.leaflet.poll.definition", + options: Array.from({ length: options }, (_, i) => ({ + text: `Option number ${i + 1}`, + })), + }, + atp_poll_votes: [ + { + voter_did: OWNER_DID, + record: { $type: "pub.leaflet.poll.vote", option: ["0"] }, + }, + { + voter_did: "did:plc:someone", + record: { $type: "pub.leaflet.poll.vote", option: ["1"] }, + }, + ], + }) as any; + +function PostSurfaces() { + return ( ++ + ); +} + +export default function SubscribeGeometryPage() { + return ( ++++ ++ + ++ + ++++ + ++ + ++++ + ++ + ++ + + ); +} diff --git a/app/test/subscribe/page.tsx b/app/test/subscribe/page.tsx new file mode 100644 index 00000000..2acfab48 --- /dev/null +++ b/app/test/subscribe/page.tsx @@ -0,0 +1,172 @@ +"use client"; +import { useState } from "react"; +import { SWRConfig } from "swr"; +import { + IdentityContextProvider, + type Identity, +} from "components/IdentityProvider"; +import { + SubscribeButton, + SubscribeInput, + type SubscribeProps, +} from "components/Subscribe/SubscribeButton"; + +const PUBLICATION_URI = "at://did:plc:example/pub.leaflet.publication/test"; +const PUBLICATION_URL = "https://example.leaflet.pub"; + +const baseProps: Omit+++ + {[true, false].map((newsletterMode) => { + const subscribe = { ...baseProps, newsletterMode }; + const suffix = newsletterMode ? "nl" : "at"; + return ( + ++ ); + })} ++ ++ + ++ + ++++ + ++ + ++ + ++ + ++++ + ++++ + ++++ = { + publicationUri: PUBLICATION_URI, + publicationUrl: PUBLICATION_URL, + publicationName: "Test Publication", + publicationDescription: + "A publication for previewing the subscribe affordances.", +}; + +type Render = (p: SubscribeProps) => React.ReactNode; + +// Build a logged-in identity the viewer is NOT subscribed to. The handle lives +// on bsky_profiles (and implies an atp_did); the email is independent, so an +// email-only account has no handle/atp_did. +function makeIdentity(opts: { + hasHandle: boolean; + hasEmail: boolean; + subscribed?: boolean; +}): Identity { + const profile = { did: "did:plc:example", handle: "reader.bsky.social" }; + return { + atp_did: opts.hasHandle ? profile.did : null, + email: opts.hasEmail ? "reader@example.com" : null, + bsky_profiles: opts.hasHandle ? { ...profile, record: profile } : null, + publication_subscriptions: opts.subscribed + ? [{ publication: PUBLICATION_URI }] + : [], + publication_email_subscribers: + opts.subscribed && opts.hasEmail + ? [{ publication: PUBLICATION_URI, state: "confirmed" }] + : [], + } as unknown as Identity; +} + +// The identity context isn't exported, so the mock goes in through the real +// provider — seeded, never fetching. Each cell gets its own SWR cache so the +// cells' identities don't fight over the shared "identity" key, and remounts +// when the mock changes (a provider cache is built once per SWRConfig). +function MockIdentity(props: { + identity: Identity; + children: React.ReactNode; +}) { + return ( + new Map() }} + > + + ); +} + +function Checkbox(props: { + label: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( + + ); +} + +function Cell(props: { label: string; children: React.ReactNode }) { + return ( ++ {props.children} + +++ ); +} + +// One cell per newsletterMode, all sharing the same mocked identity. +function NewsletterGrid(props: { identity: Identity; render: Render }) { + return ( +{props.label}+ {props.children} ++ {[true, false].map((newsletterMode) => ( ++ ); +} + +function LoggedInSubsection(props: { render: Render }) { + let [hasEmail, setHasEmail] = useState(true); + let [hasHandle, setHasHandle] = useState(true); + let [subscribed, setSubscribed] = useState(false); + return ( ++ + ))} ++ {props.render({ ...baseProps, newsletterMode })} + | +++ ); +} + +function LoggedOutSubsection(props: { render: Render }) { + return ( +++Logged in
++ + + + ++ ); +} + +function ComponentSection(props: { title: string; render: Render }) { + return ( +Logged out
++ ++ ); +} + +export default function SubscribePreviewPage() { + return ( +{props.title}
++ + ++ ); +} diff --git a/blueprint/cached-profile-resolver/plan-cached-profile-resolver.md b/blueprint/cached-profile-resolver/plan-cached-profile-resolver.md index 33044b9f..ab1d8bdf 100644 --- a/blueprint/cached-profile-resolver/plan-cached-profile-resolver.md +++ b/blueprint/cached-profile-resolver/plan-cached-profile-resolver.md @@ -36,7 +36,7 @@ ## Changes **New `src/identity/` module** -- Move `idResolver` here from `app/(app)/(home-pages)/reader/idResolver.ts`; update all import sites. +- Move `idResolver` here from `app/(app)/(identity)/(home-pages)/reader/idResolver.ts`; update all import sites. - Add a Redis-backed profile cache: `bsky-profile:` key prefix, 7d stale / 30d max, negative results cached at the same TTL. - Export only the batched `getProfiles(dids[])` form — no single-DID convenience method (callers always batch). - `Profile` type is a `PickSubscribe Variants
+} + /> + } + /> + ` (or similar narrow shape) — only the fields consumers actually use. @@ -59,9 +59,9 @@ - Wrap the comments list in Suspense with the same inline skeleton fallback; the top-level author header stays blocking. **Consumer shape migration (all surfaces)** -- The shared `Comment` type in `app/(app)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx` changes from `bsky_profiles: { record: Json; did: string } | null` to `profile: { did, displayName, avatar, handle } | null`. Consumers read `profile.avatar` as a flat CDN URL string and `profile.displayName` directly. +- The shared `Comment` type in `app/(app)/(published)/lish/[did]/[publication]/[rkey]/Interactions/Comments/index.tsx` changes from `bsky_profiles: { record: Json; did: string } | null` to `profile: { did, displayName, avatar, handle } | null`. Consumers read `profile.avatar` as a flat CDN URL string and `profile.displayName` directly. - `p/[didOrHandle]/comments/CommentsContent.tsx`: replace `blobRefToSrc(profile.record.avatar.ref, did)` with the flat `avatar` string; replace `comment.bsky_profiles?.record.displayName` reads with `comment.profile?.displayName`. -- Notification components (`CommentNotication.tsx`, `ReplyNotification.tsx`, `CommentMentionNotification.tsx`, `RecommendNotification.tsx`, `FollowNotification.tsx`, and any others under `app/(app)/(home-pages)/(writer)/notifications/`) drop their `blobRefToSrc(profileRecord?.avatar?.ref, ...)` calls and read `profile.avatar` and `profile.displayName` off the flat shape attached by `hydrateNotifications`. Each touched component should also drop the now-unused `blobRefToSrc` import. +- Notification components (`CommentNotication.tsx`, `ReplyNotification.tsx`, `CommentMentionNotification.tsx`, `RecommendNotification.tsx`, `FollowNotification.tsx`, and any others under `app/(app)/(identity)/(home-pages)/(writer)/notifications/`) drop their `blobRefToSrc(profileRecord?.avatar?.ref, ...)` calls and read `profile.avatar` and `profile.displayName` off the flat shape attached by `hydrateNotifications`. Each touched component should also drop the now-unused `blobRefToSrc` import. **Appview firehose invalidation** - Add an `ioredis` client to the appview process, configured from the same `REDIS_URL` env var the web app uses. diff --git a/blueprint/membership-direct-charges-wallet/plan-membership-direct-charges-wallet.md b/blueprint/membership-direct-charges-wallet/plan-membership-direct-charges-wallet.md index b31a3011..3b1eea49 100644 --- a/blueprint/membership-direct-charges-wallet/plan-membership-direct-charges-wallet.md +++ b/blueprint/membership-direct-charges-wallet/plan-membership-direct-charges-wallet.md @@ -71,7 +71,7 @@ - Existing platform `route.ts` + handlers: remove the `publication_membership` branches from `handle_checkout_completed.ts` and `handle_subscription_updated.ts` (memberships no longer occur on the platform account); Leaflet Pro paths untouched. - Existing `connect/route.ts` (v2 account status) unchanged. -### Join page UI (`app/(app)/lish/[did]/[publication]/join/`) +### Join page UI (`app/(app)/(published)/lish/[did]/[publication]/join/`) - `page.tsx`: also load the viewer's wallet card + email presence. - `JoinTiers.tsx`: rework into a two-step flow. - Step 1: tier cards as today. Saved card ⇒ button reads "Join · price with ···last4" and calls `subscribeToTier` directly, handling `requires_action` via `stripe.confirmPayment`-style confirmation (Stripe.js initialized with the publisher's `stripeAccount` for the connected-account PaymentIntent). @@ -80,7 +80,7 @@ - New small client util `src/stripeClient.ts`: memoized `loadStripe` for the platform key and per-`stripeAccount` instances. ### Reader management UI (new) -- New route on leaflet.pub, e.g. `app/(app)/(home-pages)/(writer)/memberships/page.tsx` — "Memberships & billing": membership list (cancel/resume/switch controls) + wallet card display with "Update card" (Payment Element in a modal, then `updateWalletCard`). Linked from home layout nav and from `/join` when already a member. +- New route on leaflet.pub, e.g. `app/(app)/(identity)/(home-pages)/(writer)/memberships/page.tsx` — "Memberships & billing": membership list (cancel/resume/switch controls) + wallet card display with "Update card" (Payment Element in a modal, then `updateWalletCard`). Linked from home layout nav and from `/join` when already a member. ### Publisher surfaces - `dashboard/subs`: server loader joins active `publication_memberships` (+ tier name); `MergedSubscriber` gains `memberTier?: string`; `SubscriberListItem` renders a member badge; `SubscriberStatusFilter` gains a "Members" checkbox (`dashboardState`). diff --git a/components/ActionBar/DesktopNavigation.tsx b/components/ActionBar/DesktopNavigation.tsx index d64484e3..a7c23eb8 100644 --- a/components/ActionBar/DesktopNavigation.tsx +++ b/components/ActionBar/DesktopNavigation.tsx @@ -88,7 +88,12 @@ export const NavigationContent = (props: NavigationProps) => { {props.tabs && ( <> {Object.entries(props.tabs).map(([name, { href, icon }]) => ( - + } @@ -224,7 +229,7 @@ export const PageTitle = (props: { return ( {props.showBackButton && ( -+