From bc29d30b7a55e9df49cc85561b7ca7cc5f75712d Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 8 Sep 2026 12:55:30 -0400 Subject: [PATCH] Add post-subscribe follow-ups and fix subscribe success feed links The atproto and email success modals now share a SubscribeSuccess frame with follow-up actions: share the publication on Bluesky (via a lazily loaded composer and a shared BskyPostSubmit) and recommend it from one of the viewer's own publications. Subscribe/unsubscribe writes are mirrored into the cached identity (viewerSubscription helpers) so every subscribe surface on a page flips together instead of refetching identity. The "Get the RSS Feed" and "Pin Custom Feed in Bluesky" links on the atproto success screen had empty hrefs; they now point at the publication's /rss feed and the Bluesky subscribed-publications feed, sharing that URL with the manage-subscription panel. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013tYNJvzxvce46Kwo1QN1oq --- actions/getIdentityData.ts | 33 +- actions/publications/recommendPublication.ts | 112 +++++++ actions/publications/updatePublication.ts | 58 +--- actions/publishBskyPost.ts | 308 ++++++++++++------ .../settings/RecommendedPubsSetting.tsx | 3 +- .../BlueskyPostComposer/BskyPostSubmit.tsx | 100 ++++++ .../usePrefetchedScreenshot.ts | 51 +++ components/IdentityProvider.tsx | 19 ++ .../Interactions/InteractionShareButton.tsx | 192 +++-------- components/Memberships/JoinMembershipFlow.tsx | 14 +- components/OAuthError.tsx | 10 + components/Subscribe/EmailSubscribe.tsx | 19 +- .../Subscribe/EmailSubscribeSuccess.tsx | 64 ---- components/Subscribe/HandleSubscribe.tsx | 54 +-- components/Subscribe/ManageSubscribe.tsx | 41 +-- components/Subscribe/SubscribeButton.tsx | 52 ++- .../Subscribe/SubscribeConfirmationModal.tsx | 3 +- components/Subscribe/SubscribeSuccess.tsx | 178 ++++++++++ .../Subscribe/SubscribeSuccessFollowUps.tsx | 190 +++++++++++ components/Subscribe/blueskyFeed.ts | 4 + .../Subscribe/useSubscribeSuccessData.ts | 6 +- components/Subscribe/viewerSubscription.ts | 89 ++++- src/utils/isLeafletManagedPublication.ts | 31 ++ src/utils/publicationRecommendations.ts | 3 + src/utils/writeRecommendations.ts | 56 ++++ 25 files changed, 1163 insertions(+), 527 deletions(-) create mode 100644 actions/publications/recommendPublication.ts create mode 100644 components/BlueskyPostComposer/BskyPostSubmit.tsx create mode 100644 components/BlueskyPostComposer/usePrefetchedScreenshot.ts delete mode 100644 components/Subscribe/EmailSubscribeSuccess.tsx create mode 100644 components/Subscribe/SubscribeSuccess.tsx create mode 100644 components/Subscribe/SubscribeSuccessFollowUps.tsx create mode 100644 components/Subscribe/blueskyFeed.ts create mode 100644 src/utils/isLeafletManagedPublication.ts create mode 100644 src/utils/publicationRecommendations.ts create mode 100644 src/utils/writeRecommendations.ts diff --git a/actions/getIdentityData.ts b/actions/getIdentityData.ts index 38fb7b38..41c33ed8 100644 --- a/actions/getIdentityData.ts +++ b/actions/getIdentityData.ts @@ -3,9 +3,8 @@ import { supabaseServerClient } from "supabase/serverClient"; import { cache } from "react"; import { deduplicateByUri } from "src/utils/deduplicateRecords"; +import { isLeafletManagedPublication } from "src/utils/isLeafletManagedPublication"; import { getProfiles } from "src/identity"; -import { AtUri } from "@atproto/syntax"; -import { TID } from "@atproto/common"; import { bskyProfileFromCache, ENTITLEMENT_EMBEDS, @@ -92,7 +91,7 @@ async function uncachedGetIdentityData() { // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces, // then filter to only publications created by Leaflet const publications = deduplicateByUri(rawPublications || []).filter( - isLeafletPublication, + isLeafletManagedPublication, ); const contributor_leaflets = (contributorLeafletRows ?? []).filter( ( @@ -104,8 +103,9 @@ async function uncachedGetIdentityData() { const rawContributorPubs = (contributorPubRows ?? []) .map((r) => r.publications) .filter((p): p is NonNullable => !!p); - const contributor_publications = - deduplicateByUri(rawContributorPubs).filter(isLeafletPublication); + const contributor_publications = deduplicateByUri( + rawContributorPubs, + ).filter(isLeafletManagedPublication); return { ...identity, // Orders identity snapshots by when they were fetched, so the client @@ -134,26 +134,3 @@ async function uncachedGetIdentityData() { connectedAccount: processConnectedAccount(connectedAccount), }; } - -function isLeafletPublication(p: { uri: string; record: unknown }): boolean { - try { - const rkey = new AtUri(p.uri).rkey; - if (!TID.is(rkey)) return false; - } catch { - return false; - } - - const record = p.record as Record | null; - if (!record) return true; - - if (record.preferences?.greengale) return false; - - if ( - record.theme && - record.theme.$type && - record.theme.$type !== "pub.leaflet.publication#theme" - ) - return false; - - return true; -} diff --git a/actions/publications/recommendPublication.ts b/actions/publications/recommendPublication.ts new file mode 100644 index 00000000..d7af8e86 --- /dev/null +++ b/actions/publications/recommendPublication.ts @@ -0,0 +1,112 @@ +"use server"; + +import { AtUri } from "@atproto/syntax"; +import { AtpBaseClient } from "lexicons/api"; +import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; +import { getAuthIdentity } from "src/auth"; +import { Err, Ok, Result } from "src/result"; +import { deduplicateByUri } from "src/utils/deduplicateRecords"; +import { isLeafletManagedPublication } from "src/utils/isLeafletManagedPublication"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { MAX_RECOMMENDATIONS } from "src/utils/publicationRecommendations"; +import { writeRecommendations } from "src/utils/writeRecommendations"; +import { supabaseServerClient } from "supabase/serverClient"; + +export type ViewerOwnedPublication = { + uri: string; + name: string; + icon: string | null; + recommendations: string[]; +}; + +// The viewer's own Leaflet publications (same set the dashboard identity +// exposes) with each one's current recommendation list, so a picker can show +// which already recommend a given publication and which are full. Fetched on +// demand because the slim viewer identity on published pages leaves +// `publications` empty. +export async function getViewerOwnedPublications(): Promise< + ViewerOwnedPublication[] +> { + const identity = await getAuthIdentity(); + if (!identity?.atp_did) return []; + + const { data: rows } = await supabaseServerClient + .from("publications") + .select( + "uri, name, record, publication_recommendations(recommendation, sort_order)", + ) + .eq("identity_did", identity.atp_did); + + return deduplicateByUri(rows ?? []) + .filter(isLeafletManagedPublication) + .map((p) => { + const icon = normalizePublicationRecord(p.record)?.icon; + return { + uri: p.uri, + name: p.name, + icon: icon ? blobRefToSrc(icon.ref, new AtUri(p.uri).host) : null, + recommendations: orderedRecommendations(p.publication_recommendations), + }; + }); +} + +function orderedRecommendations( + rows: { recommendation: string; sort_order: number }[], +) { + return [...rows] + .sort((a, b) => a.sort_order - b.sort_order) + .map((r) => r.recommendation); +} + +export type RecommendPublicationError = + | { type: "not_owner" | "at_limit" | "invalid" } + | OAuthSessionError; + +// Appends `recommendation` to `publicationUri`'s recommendation list. A no-op +// (still ok) when it's already there. +export async function addPublicationRecommendation(args: { + publicationUri: string; + recommendation: string; +}): Promise> { + const identity = await getAuthIdentity(); + if (!identity?.atp_did) + return Err({ + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }); + try { + new AtUri(args.recommendation); + } catch { + return Err({ type: "invalid" }); + } + if (args.recommendation === args.publicationUri) + return Err({ type: "invalid" }); + + const { data: pub } = await supabaseServerClient + .from("publications") + .select( + "uri, identity_did, publication_recommendations(recommendation, sort_order)", + ) + .eq("uri", args.publicationUri) + .single(); + if (!pub || pub.identity_did !== identity.atp_did) + return Err({ type: "not_owner" }); + + const existing = orderedRecommendations(pub.publication_recommendations); + if (existing.includes(args.recommendation)) + return Ok({ recommendations: existing }); + if (existing.length >= MAX_RECOMMENDATIONS) return Err({ type: "at_limit" }); + + const sessionResult = await restoreOAuthSession(identity.atp_did); + if (!sessionResult.ok) return Err(sessionResult.error); + const credentialSession = sessionResult.value; + const agent = new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession), + ); + + const recommendations = [...existing, args.recommendation]; + await writeRecommendations(agent, args.publicationUri, recommendations); + return Ok({ recommendations }); +} diff --git a/actions/publications/updatePublication.ts b/actions/publications/updatePublication.ts index 8e2d96fe..2d0181bf 100644 --- a/actions/publications/updatePublication.ts +++ b/actions/publications/updatePublication.ts @@ -2,12 +2,10 @@ import { revalidateAllPublicationPaths } from "src/utils/revalidatePublication"; import { AtpBaseClient, - PubLeafletGraphRecommendations, PubLeafletPublication, PubLeafletThemeColor, SiteStandardPublication, } from "lexicons/api"; -import { ids } from "lexicons/api/lexicons"; import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; import { getAuthIdentity } from "src/auth"; import { supabaseServerClient } from "supabase/serverClient"; @@ -23,6 +21,8 @@ import { buildRecord, type PublicationType, } from "src/utils/buildPublicationRecord"; +import { MAX_RECOMMENDATIONS } from "src/utils/publicationRecommendations"; +import { writeRecommendations } from "src/utils/writeRecommendations"; type UpdatePublicationResult = | { success: true; publication: any } @@ -126,58 +126,6 @@ async function withPublicationUpdate( return { success: true, publication }; } -// Recommendations live in a standalone pub.leaflet.graph.recommendations -// record — one per publication, keyed by the publication's rkey so an update -// is a plain putRecord overwrite. An empty list deletes the record. The -// supabase writes mirror what the appview indexes from the firehose: one row -// per recommendation edge, replaced wholesale on update. -async function writeRecommendations( - agent: AtpBaseClient, - publicationUri: string, - recommendations: string[], -) { - const pubUri = new AtUri(publicationUri); - const repo = pubUri.host; - const collection = ids.PubLeafletGraphRecommendations; - const recordUri = AtUri.make(repo, collection, pubUri.rkey).toString(); - - if (recommendations.length === 0) { - await agent.com.atproto.repo - .deleteRecord({ repo, collection, rkey: pubUri.rkey }) - .catch(() => {}); - await supabaseServerClient - .from("publication_recommendations") - .delete() - .eq("uri", recordUri); - return; - } - - const record: PubLeafletGraphRecommendations.Record = { - $type: "pub.leaflet.graph.recommendations", - publication: publicationUri, - recommendations, - }; - await agent.com.atproto.repo.putRecord({ - repo, - collection, - rkey: pubUri.rkey, - record, - validate: false, - }); - await supabaseServerClient - .from("publication_recommendations") - .delete() - .eq("uri", recordUri); - await supabaseServerClient.from("publication_recommendations").insert( - recommendations.map((recommendation, sort_order) => ({ - uri: recordUri, - publication: publicationUri, - recommendation, - sort_order, - })), - ); -} - export async function updatePublication({ uri, name, @@ -206,7 +154,7 @@ export async function updatePublication({ return false; } }) - .slice(0, 3); + .slice(0, MAX_RECOMMENDATIONS); } return withPublicationUpdate( uri, diff --git a/actions/publishBskyPost.ts b/actions/publishBskyPost.ts index da570fb0..fae38c96 100644 --- a/actions/publishBskyPost.ts +++ b/actions/publishBskyPost.ts @@ -11,6 +11,7 @@ import { AtUri } from "@atproto/syntax"; import { getAuthIdentity } from "src/auth"; import { AtpBaseClient, SiteStandardDocument } from "lexicons/api"; import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; +import type { OAuthSession } from "@atproto/oauth-client-node"; import { idResolver } from "src/identity"; import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; @@ -19,6 +20,8 @@ import { uploadCoverImageThumb } from "src/utils/uploadCoverImageThumb"; import { maybeOffloadPagesToBlob } from "src/utils/offloadPagesToBlob"; import { truncateDocumentRecordForPDS } from "src/membership"; import { addBskyPostUtm, BskyUtmCampaign } from "src/utils/bskyPostUtm"; +import { normalizePublicationRecord } from "src/utils/normalizeRecords"; +import { Err, Ok, Result } from "src/result"; type StrongRef = { $type: "com.atproto.repo.strongRef"; @@ -26,10 +29,60 @@ type StrongRef = { cid: string; }; -type PublishBskyResult = +export type PublishBskyResult = | { success: true; uri: string } | { success: false; error: OAuthSessionError }; +type UploadThumb = (bytes: Buffer) => Promise; + +// Restores the OAuth session for `did` (the viewer's own by default) along +// with the client and thumbnail uploader both publishers need. +async function postingSession(did?: string): Promise< + Result< + { + postAuthorDid: string; + credentialSession: OAuthSession; + agent: AtpBaseClient; + uploadThumb: UploadThumb; + }, + OAuthSessionError + > +> { + let identity = await getAuthIdentity(); + if (!identity || !identity.atp_did) + return Err({ + type: "oauth_session_expired", + message: "Not authenticated", + did: "", + }); + let postAuthorDid = did || identity.atp_did; + let sessionResult = await restoreOAuthSession(postAuthorDid); + if (!sessionResult.ok) return Err(sessionResult.error); + let credentialSession = sessionResult.value; + let agent = new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession), + ); + let uploadThumb: UploadThumb = (bytes) => + agent.com.atproto.repo + .uploadBlob(bytes, { headers: { "Content-Type": "image/webp" } }) + .then((r) => r.data.blob); + return Ok({ postAuthorDid, credentialSession, agent, uploadThumb }); +} + +// The client's prefetched screenshot, uploaded as the card thumb; undefined +// when there is none or the upload fails so the caller falls through to its +// next thumb source. +async function uploadPrefetchedThumb( + prefetchedThumb: string | undefined, + uploadThumb: UploadThumb, +): Promise { + if (!prefetchedThumb) return undefined; + return uploadThumb(Buffer.from(prefetchedThumb, "base64")).catch((e) => { + console.error("Failed to upload prefetched bsky card thumbnail:", e); + return undefined; + }); +} + export async function publishPostToBsky(args: { text: string; url: string; @@ -50,32 +103,9 @@ export async function publishPostToBsky(args: { prefetchedThumb?: string; langs?: string[]; }): Promise { - let identity = await getAuthIdentity(); - if (!identity || !identity.atp_did) { - return { - success: false, - error: { - type: "oauth_session_expired", - message: "Not authenticated", - did: "", - }, - }; - } - let postAuthorDid = args.ownerDid || identity.atp_did; - - const sessionResult = await restoreOAuthSession(postAuthorDid); - if (!sessionResult.ok) { - return { success: false, error: sessionResult.error }; - } - let credentialSession = sessionResult.value; - let agent = new AtpBaseClient( - credentialSession.fetchHandler.bind(credentialSession), - ); - - let uploadThumb = (bytes: Buffer) => - agent.com.atproto.repo - .uploadBlob(bytes, { headers: { "Content-Type": "image/webp" } }) - .then((r) => r.data.blob); + let session = await postingSession(args.ownerDid); + if (!session.ok) return { success: false, error: session.error }; + let { postAuthorDid, credentialSession, agent, uploadThumb } = session.value; let documentRecord = args.document_record; let rkey = args.rkey; @@ -92,15 +122,7 @@ export async function publishPostToBsky(args: { // When the share wants a screenshot card (quote share, or no cover image), // use the client's prefetched screenshot (or take one now if the prefetch // failed) and fall back to the cover only if that fails too. - let thumb: BlobRef | undefined; - if (args.prefetchedThumb) { - thumb = await uploadThumb( - Buffer.from(args.prefetchedThumb, "base64"), - ).catch((e) => { - console.error("Failed to upload prefetched bsky card thumbnail:", e); - return undefined; - }); - } + let thumb = await uploadPrefetchedThumb(args.prefetchedThumb, uploadThumb); if (!thumb && args.preferUrlScreenshot) { thumb = await screenshotCardThumb(args.url, uploadThumb); } @@ -108,43 +130,6 @@ export async function publishPostToBsky(args: { (await uploadCoverImageThumb(coverImage, coverImageDid, uploadThumb)) ?? undefined; - // The post's rkey is minted before the record is created so the shared link - // can carry the post's identity in its utm params: analytics later resolves - // utm_content back to this post's at-uri to attribute traffic to it. - // Screenshots keep using the clean args.url — the params don't change the - // page and would only bust the client's prefetch. - let postRkey = TID.nextStr(); - let campaign: BskyUtmCampaign = args.url.includes("/l-quote/") - ? "quote" - : rkey - ? "publish" - : "share"; - let taggedUrl = addBskyPostUtm(args.url, { - did: credentialSession.did!, - rkey: postRkey, - campaign, - }); - // Any link facet in the composed text pointing at the shared page gets the - // same tagged url (byte ranges are untouched — only the link target changes). - let facets = args.facets.map((facet) => ({ - ...facet, - features: facet.features.map((feature) => - AppBskyRichtextFacet.isLink(feature) && feature.uri === args.url - ? { ...feature, uri: taggedUrl } - : feature, - ), - })); - - // associatedRefs hangs off the external embed card alongside uri/title/etc. - // It isn't in the published @atproto/api types yet, so widen External here. - let external: AppBskyEmbedExternal.External & { - associatedRefs?: StrongRef[]; - } = { - uri: taggedUrl, - title, - description: description ?? "", - }; - // On publish, fall back to a page screenshot when there's no other thumb. if (rkey && !thumb) { thumb = await screenshotCardThumb(args.url, uploadThumb); @@ -165,34 +150,26 @@ export async function publishPostToBsky(args: { publicationRefUri = args.publicationUri; } - let associatedRefs: StrongRef[] = []; - for (let uri of [documentRefUri, publicationRefUri]) { - if (!uri) continue; - let ref = await getRecordStrongRef(uri); - if (ref) associatedRefs.push(ref); - } - if (associatedRefs.length > 0) external.associatedRefs = associatedRefs; + let associatedRefs = await resolveStrongRefs([ + documentRefUri, + publicationRefUri, + ]); - if (thumb) external.thumb = thumb; - - let bsky = new BskyAgent(credentialSession); - let post = await bsky.app.bsky.feed.post.create( - { - repo: credentialSession.did!, - rkey: postRkey, - }, - { - text: args.text, - createdAt: new Date().toISOString(), - facets, - // The post lexicon caps langs at 3 entries. - langs: args.langs?.filter(Boolean).slice(0, 3), - embed: { - $type: "app.bsky.embed.external", - external, - }, - }, - ); + let post = await createExternalCardPost(credentialSession, { + text: args.text, + facets: args.facets, + url: args.url, + title, + description, + thumb, + associatedRefs, + campaign: args.url.includes("/l-quote/") + ? "quote" + : rkey + ? "publish" + : "share", + langs: args.langs, + }); if (rkey) { let record = documentRecord; @@ -224,12 +201,141 @@ export async function publishPostToBsky(args: { return { success: true, uri: post.uri }; } +// Shares a publication's home page (rather than a post) to Bluesky. The card +// carries the publication's name/description and a strong ref to the +// publication record, with a screenshot of the page as its thumbnail (the +// publication icon when that fails). +export async function publishPublicationShareToBsky(args: { + text: string; + facets: AppBskyRichtextFacet.Main[]; + url: string; + publicationUri: string; + prefetchedThumb?: string; + langs?: string[]; +}): Promise { + let session = await postingSession(); + if (!session.ok) return { success: false, error: session.error }; + let { credentialSession, uploadThumb } = session.value; + + let { data: pub } = await supabaseServerClient + .from("publications") + .select("uri, record") + .eq("uri", args.publicationUri) + .single(); + let record = normalizePublicationRecord(pub?.record); + if (!pub || !record) throw new Error("Publication not found"); + + let thumb = await uploadPrefetchedThumb(args.prefetchedThumb, uploadThumb); + thumb ??= await screenshotCardThumb(args.url, uploadThumb); + thumb ??= + (await uploadCoverImageThumb( + record.icon, + new AtUri(pub.uri).host, + uploadThumb, + )) ?? undefined; + + let post = await createExternalCardPost(credentialSession, { + text: args.text, + facets: args.facets, + url: args.url, + title: record.name, + description: record.description, + thumb, + associatedRefs: await resolveStrongRefs([pub.uri]), + campaign: "share", + langs: args.langs, + }); + return { success: true, uri: post.uri }; +} + +// Creates the app.bsky.feed.post carrying an external-link card for `url`. +async function createExternalCardPost( + credentialSession: OAuthSession, + args: { + text: string; + facets: AppBskyRichtextFacet.Main[]; + url: string; + title: string; + description?: string; + thumb?: BlobRef; + associatedRefs: StrongRef[]; + campaign: BskyUtmCampaign; + langs?: string[]; + }, +) { + // The post's rkey is minted before the record is created so the shared link + // can carry the post's identity in its utm params: analytics later resolves + // utm_content back to this post's at-uri to attribute traffic to it. + // Screenshots keep using the clean args.url — the params don't change the + // page and would only bust the client's prefetch. + let postRkey = TID.nextStr(); + let taggedUrl = addBskyPostUtm(args.url, { + did: credentialSession.did!, + rkey: postRkey, + campaign: args.campaign, + }); + // Any link facet in the composed text pointing at the shared page gets the + // same tagged url (byte ranges are untouched — only the link target changes). + let facets = args.facets.map((facet) => ({ + ...facet, + features: facet.features.map((feature) => + AppBskyRichtextFacet.isLink(feature) && feature.uri === args.url + ? { ...feature, uri: taggedUrl } + : feature, + ), + })); + + // associatedRefs hangs off the external embed card alongside uri/title/etc. + // It isn't in the published @atproto/api types yet, so widen External here. + let external: AppBskyEmbedExternal.External & { + associatedRefs?: StrongRef[]; + } = { + uri: taggedUrl, + title: args.title, + description: args.description ?? "", + }; + if (args.associatedRefs.length > 0) + external.associatedRefs = args.associatedRefs; + if (args.thumb) external.thumb = args.thumb; + + let bsky = new BskyAgent(credentialSession); + return bsky.app.bsky.feed.post.create( + { + repo: credentialSession.did!, + rkey: postRkey, + }, + { + text: args.text, + createdAt: new Date().toISOString(), + facets, + // The post lexicon caps langs at 3 entries. + langs: args.langs?.filter(Boolean).slice(0, 3), + embed: { + $type: "app.bsky.embed.external", + external, + }, + }, + ); +} + +async function resolveStrongRefs( + uris: (string | undefined)[], +): Promise { + let refs: StrongRef[] = []; + for (let uri of uris) { + if (!uri) continue; + let ref = await getRecordStrongRef(uri); + if (ref) refs.push(ref); + } + return refs; +} + // Screenshot `url` and upload it as a 1200x630 webp external-card thumbnail. // Returns undefined (rather than throwing) so a failed screenshot degrades to a // card without an image instead of failing the whole post. async function screenshotCardThumb( url: string, - uploadThumb: (bytes: Buffer) => Promise, + uploadThumb: UploadThumb, ): Promise { let image = await screenshotBskyCardImage(url); if (!image) return undefined; diff --git a/app/(app)/(identity)/lish/[did]/[publication]/dashboard/settings/RecommendedPubsSetting.tsx b/app/(app)/(identity)/lish/[did]/[publication]/dashboard/settings/RecommendedPubsSetting.tsx index a374b02d..7b5b7486 100644 --- a/app/(app)/(identity)/lish/[did]/[publication]/dashboard/settings/RecommendedPubsSetting.tsx +++ b/app/(app)/(identity)/lish/[did]/[publication]/dashboard/settings/RecommendedPubsSetting.tsx @@ -7,8 +7,7 @@ import { Combobox, ComboboxResult } from "components/Combobox"; import { InputSetting, SettingsSection } from "components/SettingsLayout"; import { DeleteTiny } from "components/Icons/DeleteTiny"; import { StandardSitePublicationItem } from "components/Blocks/StandardSitePublicationBlock/StandardSitePublicationItem"; - -const MAX_RECOMMENDATIONS = 3; +import { MAX_RECOMMENDATIONS } from "src/utils/publicationRecommendations"; export function RecommendationSettings(props: { publicationUri: string; diff --git a/components/BlueskyPostComposer/BskyPostSubmit.tsx b/components/BlueskyPostComposer/BskyPostSubmit.tsx new file mode 100644 index 00000000..b3c79aa9 --- /dev/null +++ b/components/BlueskyPostComposer/BskyPostSubmit.tsx @@ -0,0 +1,100 @@ +"use client"; +import { useRef, useState } from "react"; +import dynamic from "next/dynamic"; +import { AtUri } from "@atproto/syntax"; +import type { AppBskyRichtextFacet } from "@atproto/api"; +import type { EditorState } from "prosemirror-state"; +import type { PublishBskyResult } from "actions/publishBskyPost"; +import { ButtonPrimary } from "components/Buttons"; +import { DotLoader } from "components/utils/DotLoader"; +import { BlueskyTiny } from "components/Icons/BlueskyTiny"; +import { useToaster } from "components/Toast"; +import { actionErrorContent } from "components/OAuthError"; + +// Loaded on demand rather than bundled: the composer drags in prosemirror, +// which every reader would otherwise download for a button most never click. +export const LazyBlueskyPostComposer = dynamic( + () => import("./BlueskyPostComposer").then((m) => m.BlueskyPostComposer), + { ssr: false, loading: () => }, +); + +// Editor state and submit flow shared by every "post to Bluesky" form. `post` +// serializes the composed text and hands it to `publish`, which calls the +// relevant server action; success and failure are toasted here. +export function useBskyPostSubmit(args: { onPosted: () => void }) { + let toaster = useToaster(); + let editorStateRef = useRef(null); + let [charCount, setCharCount] = useState(0); + let [posting, setPosting] = useState(false); + + let post = async ( + publish: ( + text: string, + facets: AppBskyRichtextFacet.Main[], + ) => Promise, + ) => { + if (!editorStateRef.current || posting) return; + setPosting(true); + // Runtime import keeps the prosemirror module graph out of the static + // bundle; by submit time the composer chunk (same module) is loaded. + let { editorStateToFacetedText } = await import("./ProsemirrorEditor"); + let [text, facets] = editorStateToFacetedText(editorStateRef.current); + let res = await publish(text, facets); + setPosting(false); + if (!res.success) { + toaster({ + content: actionErrorContent( + res.error, + "Hmm… Something went wrong. Try again!", + ), + type: "error", + }); + return; + } + toaster({ content: sharedToBskyToast(res.uri), type: "success" }); + args.onPosted(); + }; + + return { editorStateRef, charCount, setCharCount, posting, post }; +} + +export function BskyPostSubmitButton(props: { + posting: boolean; + charCount: number; + onClick: () => void; +}) { + return ( + 300} + > + {props.posting ? ( + + ) : ( + <> + Post + + )} + + ); +} + +function sharedToBskyToast(postAtUriString: string) { + let postAtUri = new AtUri(postAtUriString); + let postUrl = `https://bsky.app/profile/${postAtUri.host}/post/${postAtUri.rkey}`; + return ( + + Shared to Bluesky!{" "} + + View it here. + + + ); +} diff --git a/components/BlueskyPostComposer/usePrefetchedScreenshot.ts b/components/BlueskyPostComposer/usePrefetchedScreenshot.ts new file mode 100644 index 00000000..9e972018 --- /dev/null +++ b/components/BlueskyPostComposer/usePrefetchedScreenshot.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef, useState } from "react"; + +// Kicks off the card screenshot (a slow browser render) the moment the share +// modal opens (`enabled`), so it's ready by the time the user finishes +// composing. `promiseRef` resolves to the base64 webp handed to the publish +// action (null on failure); `previewSrc` is an object URL for the compose card +// preview once the image lands. +export function usePrefetchedScreenshot( + url: string | undefined, + enabled: boolean, +) { + let promiseRef = useRef | null>(null); + let [previewSrc, setPreviewSrc] = useState(null); + + useEffect(() => { + if (!enabled || !url) return; + let cancelled = false; + let objectUrl: string | null = null; + promiseRef.current = fetch( + `/api/quote_screenshot?url=${encodeURIComponent(url)}`, + ) + .then(async (res) => { + if (!res.ok) return null; + let blob = await res.blob(); + if (!cancelled) { + objectUrl = URL.createObjectURL(blob); + setPreviewSrc(objectUrl); + } + return blobToBase64(blob); + }) + .catch(() => null); + return () => { + cancelled = true; + promiseRef.current = null; + setPreviewSrc(null); + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [url, enabled]); + + return { promiseRef, previewSrc }; +} + +function blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + let reader = new FileReader(); + // reader.result is a data: URL; the base64 payload follows the comma. + reader.onload = () => resolve((reader.result as string).split(",")[1]); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); +} diff --git a/components/IdentityProvider.tsx b/components/IdentityProvider.tsx index e9e81abb..e4d5e19e 100644 --- a/components/IdentityProvider.tsx +++ b/components/IdentityProvider.tsx @@ -73,6 +73,25 @@ export function mutateIdentityData( { revalidate: false }, ); } + +// The global counterpart to mutateIdentityData: applies the recipe to whichever +// identity snapshots are cached (both provider keys, so every consumer on the +// page observes the change at once) and then revalidates so the server copy +// wins. Stamps fetched_at so the edited snapshot outranks any older seed a +// nav payload might replay over it. +export function updateIdentityData( + recipe: (draft: Draft>) => void, +) { + const apply = (data: Identity | undefined) => { + if (!data) return data; + return produce(data, (draft) => { + recipe(draft); + draft.fetched_at = Date.now(); + }); + }; + mutate("identity", apply, { revalidate: true }); + mutate(VIEWER_IDENTITY_KEY, apply, { revalidate: true }); +} export function IdentityContextProvider(props: { children: React.ReactNode; // A promise lets the server mount this provider without awaiting identity diff --git a/components/Interactions/InteractionShareButton.tsx b/components/Interactions/InteractionShareButton.tsx index bfea6010..7f26851e 100644 --- a/components/Interactions/InteractionShareButton.tsx +++ b/components/Interactions/InteractionShareButton.tsx @@ -1,38 +1,27 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import dynamic from "next/dynamic"; +import { useState } from "react"; import { AtUri } from "@atproto/syntax"; -import type { EditorState } from "prosemirror-state"; -import { useSmoker, useToaster } from "../Toast"; +import { useSmoker } from "../Toast"; import { Menu, MenuItem } from "../Menu"; -import { ButtonPrimary } from "../Buttons"; import { Modal } from "../Modal"; import { ShareTiny } from "../Icons/ShareTiny"; -import { DotLoader } from "../utils/DotLoader"; import { useIdentityData } from "../IdentityProvider"; import { publishPostToBsky } from "actions/publishBskyPost"; import { viewerPostLangs } from "src/utils/bskyPostLangs"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { bskyPostEmbed } from "src/utils/bskyPostEmbed"; -import { BlueskyTiny } from "components/Icons/BlueskyTiny"; import { LoginContent } from "components/LoginButton"; -import { isOAuthSessionError, OAuthErrorMessage } from "components/OAuthError"; +import { usePrefetchedScreenshot } from "components/BlueskyPostComposer/usePrefetchedScreenshot"; +import { + BskyPostSubmitButton, + LazyBlueskyPostComposer, + useBskyPostSubmit, +} from "components/BlueskyPostComposer/BskyPostSubmit"; import { NormalizedDocument, NormalizedPublication, } from "lexicons/src/normalize"; -// Loaded on share-modal open rather than bundled: the composer drags in -// prosemirror, which every anonymous reader of a public post would otherwise -// download for a button most never click. -const BlueskyPostComposer = dynamic( - () => - import("../BlueskyPostComposer/BlueskyPostComposer").then( - (m) => m.BlueskyPostComposer, - ), - { ssr: false, loading: () => }, -); - export const InteractionShareButton = (props: { postRecord: NormalizedDocument; postUrl?: string; @@ -125,10 +114,8 @@ export const BskyShareModal = (props: { setShareModalOpen: (s: boolean) => void; }) => { let { identity } = useIdentityData(); - let toaster = useToaster(); - let editorStateRef = useRef(null); - let [charCount, setCharCount] = useState(0); - let [posting, setPosting] = useState(false); + let { editorStateRef, charCount, setCharCount, posting, post } = + useBskyPostSubmit({ onPosted: props.onPosted }); let profile = { avatar: identity?.bsky_profiles?.record.avatar, @@ -152,63 +139,28 @@ export const BskyShareModal = (props: { useScreenshot && props.shareModalOpen, ); - let post = async () => { - if (!editorStateRef.current || !props.postUrl || posting) return; - setPosting(true); - // Block on the prefetched screenshot (kicked off when the modal opened) so - // the post's card ships with it. If the prefetch failed, - // preferUrlScreenshot has the server take its own screenshot instead. - let prefetchedThumb = screenshot.promiseRef.current - ? ((await screenshot.promiseRef.current) ?? undefined) - : undefined; - // Runtime import keeps the prosemirror module graph out of the static - // bundle; by submit time the composer chunk (same module) is loaded. - let { editorStateToFacetedText } = await import( - "../BlueskyPostComposer/ProsemirrorEditor" - ); - let [text, facets] = editorStateToFacetedText(editorStateRef.current); - let res = await publishPostToBsky({ - text, - facets, - url: props.postUrl, - document_record: props.docRecord, - documentUri: props.documentUri, - publicationUri: props.pubUri, - preferUrlScreenshot: useScreenshot, - prefetchedThumb, - langs: viewerPostLangs(), - }); - setPosting(false); - if (!res.success) { - toaster({ - content: isOAuthSessionError(res.error) ? ( - - ) : ( - "Hmm… Something went wrong. Try again!" - ), - type: "error", + let share = () => { + let url = props.postUrl; + if (!url) return; + post(async (text, facets) => { + // Block on the prefetched screenshot (kicked off when the modal opened) + // so the post's card ships with it. If the prefetch failed, + // preferUrlScreenshot has the server take its own screenshot instead. + let prefetchedThumb = screenshot.promiseRef.current + ? (await screenshot.promiseRef.current) ?? undefined + : undefined; + return publishPostToBsky({ + text, + facets, + url, + document_record: props.docRecord, + documentUri: props.documentUri, + publicationUri: props.pubUri, + preferUrlScreenshot: useScreenshot, + prefetchedThumb, + langs: viewerPostLangs(), }); - return; - } - let postAtUri = new AtUri(res.uri); - let postUrl = `https://bsky.app/profile/${postAtUri.host}/post/${postAtUri.rkey}`; - toaster({ - content: ( - - Shared to Bluesky!{" "} - - View it here. - - - ), - type: "success", }); - props.onPosted(); }; // The #view embed wants a resolvable thumbnail URL, not the raw blob ref, so @@ -224,7 +176,7 @@ export const BskyShareModal = (props: { url: props.postUrl ?? "", title, description, - thumb: useScreenshot ? (screenshot.previewSrc ?? undefined) : coverThumb, + thumb: useScreenshot ? screenshot.previewSrc ?? undefined : coverThumb, thumbPending: useScreenshot && !screenshot.previewSrc, publishedAt, publication: props.publication, @@ -232,20 +184,11 @@ export const BskyShareModal = (props: { }); let submitButton = ( - 300} - > - {posting ? ( - - ) : ( - <> - Post - - )} - + ); let loggedIn = identity && identity.atp_did; @@ -256,15 +199,13 @@ export const BskyShareModal = (props: { className="sm:w-full!" /> ) : ( - <> - - + ); return ( @@ -285,50 +226,3 @@ export const BskyShareModal = (props: { ); }; - -// Kicks off the card screenshot (a slow browser render) the moment the share -// modal opens (`enabled`), so it's ready by the time the user finishes -// composing. `promiseRef` resolves to the base64 webp handed to -// publishPostToBsky (null on failure); `previewSrc` is an object URL for the -// compose card preview once the image lands. -function usePrefetchedScreenshot(url: string | undefined, enabled: boolean) { - let promiseRef = useRef | null>(null); - let [previewSrc, setPreviewSrc] = useState(null); - - useEffect(() => { - if (!enabled || !url) return; - let cancelled = false; - let objectUrl: string | null = null; - promiseRef.current = fetch( - `/api/quote_screenshot?url=${encodeURIComponent(url)}`, - ) - .then(async (res) => { - if (!res.ok) return null; - let blob = await res.blob(); - if (!cancelled) { - objectUrl = URL.createObjectURL(blob); - setPreviewSrc(objectUrl); - } - return blobToBase64(blob); - }) - .catch(() => null); - return () => { - cancelled = true; - promiseRef.current = null; - setPreviewSrc(null); - if (objectUrl) URL.revokeObjectURL(objectUrl); - }; - }, [url, enabled]); - - return { promiseRef, previewSrc }; -} - -function blobToBase64(blob: Blob): Promise { - return new Promise((resolve, reject) => { - let reader = new FileReader(); - // reader.result is a data: URL; the base64 payload follows the comma. - reader.onload = () => resolve((reader.result as string).split(",")[1]); - reader.onerror = () => reject(reader.error); - reader.readAsDataURL(blob); - }); -} diff --git a/components/Memberships/JoinMembershipFlow.tsx b/components/Memberships/JoinMembershipFlow.tsx index 9cfb4b83..69030113 100644 --- a/components/Memberships/JoinMembershipFlow.tsx +++ b/components/Memberships/JoinMembershipFlow.tsx @@ -7,13 +7,13 @@ import { Modal } from "components/Modal"; import { useToaster, useSmoker } from "components/Toast"; import { useIdentityData } from "components/IdentityProvider"; import { EmailInput, EmailConfirm } from "components/Subscribe/EmailSubscribe"; -import { EmailSubscribeSuccess } from "components/Subscribe/EmailSubscribeSuccess"; -import { useSubscribeSuccessData } from "components/Subscribe/useSubscribeSuccessData"; -import { HandleSearchInput } from "components/HandleSearchInput"; import { - AtmosphericHandleInfo, AtSubscribeSuccess, -} from "components/Subscribe/HandleSubscribe"; + EmailSubscribeSuccess, +} from "components/Subscribe/SubscribeSuccess"; +import { useSubscribeSuccessData } from "components/Subscribe/useSubscribeSuccessData"; +import { HandleSearchInput } from "components/HandleSearchInput"; +import { AtmosphericHandleInfo } from "components/Subscribe/HandleSubscribe"; import { SubscribeInputModeMenu } from "components/Subscribe/SubscribeButton"; import { LinkIdentityModal } from "components/Subscribe/LinkIdentityModal"; import { useViewerSubscription } from "components/Subscribe/viewerSubscription"; @@ -604,10 +604,6 @@ export function JoinMembershipFlow(props: { ) - ) : viewer?.isOwner ? ( -

- This is your publication — this is what readers see. -

) : identityPending ? (
diff --git a/components/OAuthError.tsx b/components/OAuthError.tsx index efe6316d..8d37cf86 100644 --- a/components/OAuthError.tsx +++ b/components/OAuthError.tsx @@ -42,6 +42,16 @@ export function OAuthErrorMessage({ ); } +// Toast body for a failed action: the sign-in prompt when the atproto session +// has expired, otherwise the caller's generic message. +export function actionErrorContent(error: unknown, fallback: string) { + return isOAuthSessionError(error) ? ( + + ) : ( + fallback + ); +} + export function isOAuthSessionError( error: unknown, ): error is OAuthSessionError { diff --git a/components/Subscribe/EmailSubscribe.tsx b/components/Subscribe/EmailSubscribe.tsx index c2fa24ab..7e036ec9 100644 --- a/components/Subscribe/EmailSubscribe.tsx +++ b/components/Subscribe/EmailSubscribe.tsx @@ -9,20 +9,18 @@ import { DotLoader } from "components/utils/DotLoader"; import { useToaster } from "components/Toast"; import { requestPublicationEmailSubscription } from "actions/publications/subscribeEmail"; import { subscribeToPublication } from "actions/publications/subscribeToPublication"; -import { isOAuthSessionError, OAuthErrorMessage } from "components/OAuthError"; +import { actionErrorContent } from "components/OAuthError"; import { SUBSCRIBE_ERROR_MESSAGES } from "./subscribeErrors"; import { onMouseDown as iosOnPointerDown } from "src/utils/iosInputMouseDown"; import { theme } from "tailwind.config"; import { EmailTiny } from "components/Icons/EmailTiny"; import { Avatar } from "components/Avatar"; -import { - refreshIdentityData, - useIdentityData, -} from "components/IdentityProvider"; +import { useIdentityData } from "components/IdentityProvider"; import { useRecordFromDid } from "src/utils/useRecordFromDid"; import { Tooltip } from "components/Tooltip"; import { SubscribeButtonModeMenu } from "./SubscribeButton"; import { INPUT_HIGHLIGHT_CLASS } from "./inputHighlight"; +import { markLocallySubscribed } from "./viewerSubscription"; import type { SubscriptionSource } from "src/subscriptionSource"; export const EmailInput = (props: { @@ -120,7 +118,6 @@ export const EmailButton = (props: { email: string; handle?: string; compact?: boolean; - onSubscribed?: () => void; // The dropdown can also subscribe via the linked atproto handle, so the // caller needs the mode to open the matching success modal. Falls back to a // toast when omitted. @@ -166,10 +163,9 @@ export const EmailButton = (props: { if (!res.success) { toaster({ type: "error", - content: isOAuthSessionError(res.error) ? ( - - ) : ( - "We couldn't subscribe you. Try again." + content: actionErrorContent( + res.error, + "We couldn't subscribe you. Try again.", ), }); return false; @@ -186,8 +182,7 @@ export const EmailButton = (props: { if (!ok) return; if (props.onSuccess) props.onSuccess(mode); else toaster({ content:
You're Subscribed!
, type: "success" }); - props.onSubscribed?.(); - refreshIdentityData(); + markLocallySubscribed(props.publicationUri, mode); router.refresh(); }; diff --git a/components/Subscribe/EmailSubscribeSuccess.tsx b/components/Subscribe/EmailSubscribeSuccess.tsx deleted file mode 100644 index 1932f0e4..00000000 --- a/components/Subscribe/EmailSubscribeSuccess.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import { SpeedyLink } from "components/SpeedyLink"; -import { DotLoader } from "components/utils/DotLoader"; -import { LinkHandle } from "./HandleSubscribe"; -import { RecommendedPublications } from "./RecommendedPublications"; -import { useSubscribeSuccessData } from "./useSubscribeSuccessData"; - -export const EmailSubscribeSuccess = (props: { - email: string | undefined; - handle: string | undefined; - publicationUri?: string; -}) => { - let { loading, publicationName, listings } = useSubscribeSuccessData( - props.publicationUri, - ); - if (loading) - return ( -
- -
- ); - return ( -
-

- {publicationName - ? `You've subscribed to ${publicationName}!` - : "You've Subscribed!"} -

- You'll receive new posts to
- {props.email ? props.email : "your email"} - {!props.handle ? ( - <> -
-
- -
- - ) : ( - <> -
- -
- You also get updates in the
- - Leaflet Reader - -
- - or any atmospheric reader! - - - )} - -
- ); -}; diff --git a/components/Subscribe/HandleSubscribe.tsx b/components/Subscribe/HandleSubscribe.tsx index e86e51c9..96846e54 100644 --- a/components/Subscribe/HandleSubscribe.tsx +++ b/components/Subscribe/HandleSubscribe.tsx @@ -2,7 +2,6 @@ import { buildOauthLoginUrl } from "src/utils/customDomain"; import { ButtonPrimary, ButtonSecondary } from "components/Buttons"; import { Popover } from "components/Popover"; -import Link from "next/link"; import { useState } from "react"; import { encodeActionToSearchParam } from "app/api/oauth/[route]/afterSignInActions"; import { subscribeToPublication } from "actions/publications/subscribeToPublication"; @@ -12,17 +11,13 @@ import { DotLoader } from "components/utils/DotLoader"; import type { OAuthSessionError } from "src/atproto-oauth"; import { HandleSearchInput } from "components/HandleSearchInput"; import { Avatar } from "components/Avatar"; -import { - useIdentityData, - refreshIdentityData, -} from "components/IdentityProvider"; +import { useIdentityData } from "components/IdentityProvider"; import { useRecordFromDid } from "src/utils/useRecordFromDid"; import { LinkIdentityModal } from "./LinkIdentityModal"; +import { markLocallySubscribed } from "./viewerSubscription"; import { RSSTiny } from "components/Icons/RSSTiny"; import { Tooltip } from "components/Tooltip"; import { SubscribeButtonModeMenu } from "./SubscribeButton"; -import { RecommendedPublications } from "./RecommendedPublications"; -import { useSubscribeSuccessData } from "./useSubscribeSuccessData"; import type { SubscriptionSource } from "src/subscriptionSource"; const apps = [ { name: "Leaflet", logo: "https://leaflet.pub/logos/leaflet.svg" }, @@ -65,7 +60,6 @@ export const SubscribeWithHandle = (props: { publicationUri: string; publicationUrl?: string; source?: SubscriptionSource; - onSubscribed?: () => void; onAtSuccess?: () => void; leading?: React.ReactNode; user: { @@ -154,11 +148,7 @@ export const SubscribeWithHandle = (props: { type: "success", }); } - props.onSubscribed?.(); - // onSubscribed only flips this instance's local state; the subscription - // now on the identity is what every other SubscribeButton on the page - // reads, and on a published page nothing else will refetch it. - refreshIdentityData(); + markLocallySubscribed(props.publicationUri, "atproto"); setSubscribing(false); }; let subscribeButton = ( @@ -353,44 +343,6 @@ export const LinkHandle = (props: { compact?: boolean }) => { ); }; -export const AtSubscribeSuccess = (props: { publicationUri?: string }) => { - let { loading, publicationName, listings } = useSubscribeSuccessData( - props.publicationUri, - ); - if (loading) - return ( -
- -
- ); - return ( -
-

- {publicationName - ? `You've subscribed to ${publicationName}!` - : "You've Subscribed!"} -

- You'll receive new posts in the
- Leaflet Reader -
- - or any standard.site enabled reader! - -
-
-

Other ways to follow

- Get the RSS Feed - Pin Custom Feed in Bluesky -
- -
- ); -}; - export const AtmosphericHandleInfo = (props: { trigger?: React.ReactNode }) => { return ( {props.triggerLabel ? (
{props.triggerLabel}
@@ -220,10 +222,6 @@ const ManageSubscriptionContent = (props: { toaster({ type, content:
{message}
}); const notifyError = (error: string, fallback: string) => notify("error", ERROR_MESSAGES[error] ?? fallback); - const refreshSubscription = () => { - refreshIdentityData(); - router.refresh(); - }; const emailEnabled = emailOverride ?? user.emailEnabled; useEffect(() => { @@ -243,7 +241,15 @@ const ManageSubscriptionContent = (props: { ); return; } - refreshSubscription(); + markLocalEmailEnabled(props.publicationUri, next); + router.refresh(); + }; + + const onEmailLinked = () => { + notify("success", "Email Linked!"); + setLinkEmailOpen(false); + markLocallySubscribed(props.publicationUri, "email"); + router.refresh(); }; const onRequestLink = async () => { @@ -259,9 +265,7 @@ const ManageSubscriptionContent = (props: { return; } if (res.value.confirmed) { - notify("success", "Email Linked!"); - setLinkEmailOpen(false); - refreshSubscription(); + onEmailLinked(); return; } setLinkEmailOpen(true); @@ -280,9 +284,7 @@ const ManageSubscriptionContent = (props: { notifyError(res.error, "We couldn't confirm the code. Please try again!"); return; } - notify("success", "Email Linked!"); - setLinkEmailOpen(false); - refreshSubscription(); + onEmailLinked(); }; const onUnsubscribe = async () => { @@ -296,7 +298,8 @@ const ManageSubscriptionContent = (props: { } notify("success", "Unsubscribed!"); mutateMyMembership(props.publicationUri); - refreshSubscription(); + markLocallyUnsubscribed(props.publicationUri); + router.refresh(); }; if (isLoading) @@ -373,7 +376,7 @@ const ManageSubscriptionContent = (props: { )} { let toaster = useToaster(); let router = useRouter(); const user = useViewerSubscription(props.publicationUri); - const { identity, mutate: mutateIdentity } = useIdentityData(); + const { identity } = useIdentityData(); let [email, setEmail] = useState(user.email ?? ""); // On a published page identity resolves after first paint, so the initial // value above is always the logged-out one. Without this the email field @@ -117,7 +117,6 @@ export const SubscribeInput = (props: SubscribeProps) => { // merge from any existing email-only identity) instead of creating a // disconnected email-only account. let [linkToCurrent, setLinkToCurrent] = useState(false); - let [locallySubscribed, setLocallySubscribed] = useState(false); let [linkModalOpen, setLinkModalOpen] = useState(false); let [subscribeMode, setSubscribeMode] = useState("email"); const membershipTiers = useMembershipTiers(props.publicationUri); @@ -148,13 +147,12 @@ export const SubscribeInput = (props: SubscribeProps) => { } if (res.value.confirmed) { setConfirmState("success"); - refreshIdentityData(); + markLocallySubscribed(props.publicationUri, "email"); router.refresh(); } setConfirmOpen(true); }; - const isSubscribed = user.subscribed || locallySubscribed; const modeMenu = ( ); @@ -190,14 +188,16 @@ export const SubscribeInput = (props: SubscribeProps) => { ); return ( <> - {isSubscribed ? ( + {user.subscribed ? ( <> - +
+ +
{props.newsletterMode && user.atprotoSubscribed && @@ -249,7 +249,6 @@ export const SubscribeInput = (props: SubscribeProps) => { source={props.source} email={user.email} handle={user.handle} - onSubscribed={() => setLocallySubscribed(true)} onSuccess={(mode) => { if (mode === "email") { setConfirmState("success"); @@ -276,7 +275,6 @@ export const SubscribeInput = (props: SubscribeProps) => { publicationUri={props.publicationUri} publicationUrl={props.publicationUrl} source={props.source} - onSubscribed={() => setLocallySubscribed(true)} onAtSuccess={() => setAtSuccessOpen(true)} /> )} @@ -300,11 +298,7 @@ export const SubscribeInput = (props: SubscribeProps) => { open={atSuccessOpen} onOpenChange={(open) => { setAtSuccessOpen(open); - if (!open) { - setLocallySubscribed(true); - mutateIdentity(); - router.refresh(); - } + if (!open) router.refresh(); }} > @@ -315,7 +309,6 @@ export const SubscribeInput = (props: SubscribeProps) => { onOpenChange={(open) => { setConfirmOpen(open); if (!open) { - if (confirmState === "success") setLocallySubscribed(true); setConfirmState("confirm"); setLinkToCurrent(false); } @@ -352,7 +345,7 @@ export const SubscribeInput = (props: SubscribeProps) => { return; } setConfirmState("success"); - refreshIdentityData(); + markLocallySubscribed(props.publicationUri, "email"); router.refresh(); }} /> @@ -365,7 +358,6 @@ export const SubscribeInput = (props: SubscribeProps) => { export const SubscribeButton = (props: SubscribeProps) => { const user = useViewerSubscription(props.publicationUri); - let [locallySubscribed, setLocallySubscribed] = useState(false); let [atSuccessOpen, setAtSuccessOpen] = useState(false); let [emailSuccessOpen, setEmailSuccessOpen] = useState(false); const membershipTiers = useMembershipTiers(props.publicationUri); @@ -384,7 +376,7 @@ export const SubscribeButton = (props: SubscribeProps) => { return ( <> - {user.subscribed || locallySubscribed ? ( + {user.subscribed ? ( { publicationUri={props.publicationUri} publicationUrl={props.publicationUrl} source={props.source} - onSubscribed={() => setLocallySubscribed(true)} onAtSuccess={() => setAtSuccessOpen(true)} /> ) : props.newsletterMode && user.loggedIn && user.email ? ( @@ -409,7 +400,6 @@ export const SubscribeButton = (props: SubscribeProps) => { source={props.source} email={user.email} handle={user.handle} - onSubscribed={() => setLocallySubscribed(true)} onSuccess={(mode) => { if (mode === "email") setEmailSuccessOpen(true); else setAtSuccessOpen(true); diff --git a/components/Subscribe/SubscribeConfirmationModal.tsx b/components/Subscribe/SubscribeConfirmationModal.tsx index ed8217f5..60cb0526 100644 --- a/components/Subscribe/SubscribeConfirmationModal.tsx +++ b/components/Subscribe/SubscribeConfirmationModal.tsx @@ -4,8 +4,7 @@ import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { Modal } from "components/Modal"; import { useToaster } from "components/Toast"; import { useIdentityData } from "components/IdentityProvider"; -import { EmailSubscribeSuccess } from "./EmailSubscribeSuccess"; -import { AtSubscribeSuccess } from "./HandleSubscribe"; +import { AtSubscribeSuccess, EmailSubscribeSuccess } from "./SubscribeSuccess"; import { SUBSCRIBE_ERROR_MESSAGES as ERROR_MESSAGES, type SubscribeError, diff --git a/components/Subscribe/SubscribeSuccess.tsx b/components/Subscribe/SubscribeSuccess.tsx new file mode 100644 index 00000000..5f49e5bf --- /dev/null +++ b/components/Subscribe/SubscribeSuccess.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useState } from "react"; +import useSWR from "swr"; +import Link from "next/link"; +import { SpeedyLink } from "components/SpeedyLink"; +import { useStandardSitePublication } from "components/StandardSitePublicationDataProvider"; +import { getPublicationURL } from "src/utils/getPublicationURL"; +import { useIdentityData } from "components/IdentityProvider"; +import { useModalBack } from "components/Modal"; +import { ButtonSecondary } from "components/Buttons"; +import { DotLoader } from "components/utils/DotLoader"; +import { ShareTiny } from "components/Icons/ShareTiny"; +import { RecommendEmptyTiny } from "components/Icons/RecommendTiny"; +import { getViewerOwnedPublications } from "actions/publications/recommendPublication"; +import { LinkHandle } from "./HandleSubscribe"; +import { BLUESKY_SUBSCRIBED_FEED_URL } from "./blueskyFeed"; +import { RecommendedPublications } from "./RecommendedPublications"; +import { useSubscribeSuccessData } from "./useSubscribeSuccessData"; +import { + RecommendPublicationPicker, + SharePublicationComposer, +} from "./SubscribeSuccessFollowUps"; + +// Shared frame for the post-subscribe success modals: the heading, the +// subscription-specific body passed as children, follow-up actions (share the +// publication on Bluesky, recommend it from one of the viewer's own +// publications — each swaps the modal's content in place), and the +// publication's own recommendations. +export function SubscribeSuccess(props: { + publicationUri: string | undefined; + children: React.ReactNode; +}) { + let { loading, publication, listings } = useSubscribeSuccessData( + props.publicationUri, + ); + let { identity } = useIdentityData(); + let [view, setView] = useState<"share" | "recommend" | null>(null); + let back = () => setView(null); + useModalBack(view ? back : null); + + let viewerDid = identity?.atp_did ?? null; + let { data: ownedPubs, mutate: mutateOwnedPubs } = useSWR( + viewerDid ? ["viewer-owned-publications", viewerDid] : null, + () => getViewerOwnedPublications(), + { revalidateOnFocus: false }, + ); + // A publication can't recommend itself. + let candidatePubs = (ownedPubs ?? []).filter( + (p) => p.uri !== props.publicationUri, + ); + + if (loading) + return ( +
+ +
+ ); + + if (view === "share" && publication) + return ( + + ); + + if (view === "recommend" && publication) + return ( + { + mutateOwnedPubs(); + back(); + }} + /> + ); + + let canShare = !!viewerDid && !!publication; + let canRecommend = canShare && candidatePubs.length > 0; + return ( +
+

+ {publication + ? `You've subscribed to ${publication.record.name}!` + : "You've Subscribed!"} +

+ {props.children} + {canShare && ( + <> +
+
+ setView("share")}> + Share this publication + + {canRecommend && ( + setView("recommend")} + > + Recommend to your subscribers + + )} +
+ + )} + +
+ ); +} + +export const AtSubscribeSuccess = (props: { publicationUri?: string }) => { + let { data: publication } = useStandardSitePublication(props.publicationUri); + let rssUrl = publication ? `${getPublicationURL(publication)}/rss` : null; + return ( + + You'll receive new posts in the
+ Leaflet Reader +
+ + or any standard.site enabled reader! + +
+
+ + ); +}; + +export const EmailSubscribeSuccess = (props: { + email: string | undefined; + handle: string | undefined; + publicationUri?: string; +}) => { + return ( + + You'll receive new posts to
+ {props.email ? props.email : "your email"} +
+ {!props.handle ? ( +
+ +
+ ) : ( + <> +
+ You also get updates in the
+ + Leaflet Reader + +
+ + or any atmospheric reader! + + + )} +
+ ); +}; diff --git a/components/Subscribe/SubscribeSuccessFollowUps.tsx b/components/Subscribe/SubscribeSuccessFollowUps.tsx new file mode 100644 index 00000000..fa7a0821 --- /dev/null +++ b/components/Subscribe/SubscribeSuccessFollowUps.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useState } from "react"; +import { mutate as mutateGlobal } from "swr"; +import { useIdentityData } from "components/IdentityProvider"; +import { ButtonPrimary } from "components/Buttons"; +import { useToaster } from "components/Toast"; +import { DotLoader } from "components/utils/DotLoader"; +import { PubIcon } from "components/ActionBar/Publications"; +import { RecommendEmptyTiny } from "components/Icons/RecommendTiny"; +import { CheckTiny } from "components/Icons/CheckTiny"; +import { actionErrorContent } from "components/OAuthError"; +import type { StandardSitePublicationData } from "app/api/rpc/[command]/get_standard_site_publications"; +import { + BskyPostSubmitButton, + LazyBlueskyPostComposer, + useBskyPostSubmit, +} from "components/BlueskyPostComposer/BskyPostSubmit"; +import { usePrefetchedScreenshot } from "components/BlueskyPostComposer/usePrefetchedScreenshot"; +import { publishPublicationShareToBsky } from "actions/publishBskyPost"; +import { + addPublicationRecommendation, + type ViewerOwnedPublication, +} from "actions/publications/recommendPublication"; +import { MAX_RECOMMENDATIONS } from "src/utils/publicationRecommendations"; +import { bskyPostEmbed } from "src/utils/bskyPostEmbed"; +import { viewerPostLangs } from "src/utils/bskyPostLangs"; + +export function SharePublicationComposer(props: { + publication: StandardSitePublicationData; + onPosted: () => void; +}) { + let { identity } = useIdentityData(); + let { editorStateRef, charCount, setCharCount, posting, post } = + useBskyPostSubmit({ onPosted: props.onPosted }); + + // The record's url is the publication's canonical public address (custom + // domain included), which is what a shared post should point at even when + // the viewer subscribed from a dev host or a mirrored page. + let url = props.publication.record.url; + let screenshot = usePrefetchedScreenshot(url, true); + + let share = () => + post(async (text, facets) => + publishPublicationShareToBsky({ + text, + facets, + url, + publicationUri: props.publication.uri, + prefetchedThumb: (await screenshot.promiseRef.current) ?? undefined, + langs: viewerPostLangs(), + }), + ); + + return ( +
+

Share on Bluesky

+ + +
+ ); +} + +export function RecommendPublicationPicker(props: { + publicationUri: string; + publicationName: string | undefined; + publications: ViewerOwnedPublication[]; + onRecommended: () => void; +}) { + let toaster = useToaster(); + let [selected, setSelected] = useState(null); + let [saving, setSaving] = useState(false); + let name = props.publicationName ?? "this publication"; + + let recommend = async () => { + let pub = props.publications.find((p) => p.uri === selected); + if (!pub || saving) return; + setSaving(true); + let res = await addPublicationRecommendation({ + publicationUri: pub.uri, + recommendation: props.publicationUri, + }); + setSaving(false); + if (!res.ok) { + toaster({ + type: "error", + content: actionErrorContent( + res.error, + "Hmm… Something went wrong. Try again!", + ), + }); + return; + } + // The dashboard settings form reads this key for the same publication. + mutateGlobal( + ["publication_recommendations", pub.uri], + res.value.recommendations, + { revalidate: false }, + ); + toaster({ + type: "success", + content: `${pub.name} now recommends ${name}!`, + }); + props.onRecommended(); + }; + + return ( +
+
+

Recommend to your subscribers

+

+ Pick which of your publications should recommend {name}. + Recommendations are shown to readers after they subscribe. +

+
+
+ {props.publications.map((pub) => { + let status = pub.recommendations.includes(props.publicationUri) + ? "Already recommends this publication" + : pub.recommendations.length >= MAX_RECOMMENDATIONS + ? `Already recommends ${MAX_RECOMMENDATIONS} publications` + : null; + let isSelected = selected === pub.uri; + return ( + + ); + })} +
+ + {saving ? ( + + ) : ( + <> + Recommend + + )} + +
+ ); +} diff --git a/components/Subscribe/blueskyFeed.ts b/components/Subscribe/blueskyFeed.ts new file mode 100644 index 00000000..e26663d4 --- /dev/null +++ b/components/Subscribe/blueskyFeed.ts @@ -0,0 +1,4 @@ +// The Bluesky custom feed of new posts from every publication the viewer +// subscribes to (served by feeds/index.ts as the default skeleton). +export const BLUESKY_SUBSCRIBED_FEED_URL = + "https://bsky.app/profile/leaflet.pub/feed/subscribedPublications"; diff --git a/components/Subscribe/useSubscribeSuccessData.ts b/components/Subscribe/useSubscribeSuccessData.ts index 7d941246..0229cda0 100644 --- a/components/Subscribe/useSubscribeSuccessData.ts +++ b/components/Subscribe/useSubscribeSuccessData.ts @@ -5,8 +5,8 @@ import { callRPC } from "app/api/rpc/client"; import { getPublicationsByUris } from "actions/reader/getSubscriptions"; import { useStandardSitePublication } from "components/StandardSitePublicationDataProvider"; -// Everything the post-subscribe success modals need about the publication: its -// name for the heading, and its recommendations hydrated into listings. +// Everything the post-subscribe success modals need about the publication: the +// publication itself, and its recommendations hydrated into listings. // `loading` covers all fetches so the modal can gate on a single flag. export function useSubscribeSuccessData(publicationUri: string | undefined) { let { data: publication, isLoading: publicationLoading } = @@ -38,7 +38,7 @@ export function useSubscribeSuccessData(publicationUri: string | undefined) { return { loading: publicationLoading || recommendationsLoading || listingsLoading, - publicationName: publication?.record.name, + publication: publication ?? undefined, listings: listings ?? [], }; } diff --git a/components/Subscribe/viewerSubscription.ts b/components/Subscribe/viewerSubscription.ts index 40226272..74089763 100644 --- a/components/Subscribe/viewerSubscription.ts +++ b/components/Subscribe/viewerSubscription.ts @@ -1,11 +1,98 @@ "use client"; import { useMemo } from "react"; -import { useIdentityData } from "components/IdentityProvider"; +import type { Draft } from "immer"; +import { + updateIdentityData, + useIdentityData, + type Identity, +} from "components/IdentityProvider"; import { deriveSubscriptionState, type SubscriptionState, } from "src/subscriptions/state"; +type IdentityRows = NonNullable; +// Helpers take the immer draft directly: checking a Draft<> against the plain +// row type is a deep structural comparison tsc gives up on. +type IdentityDraft = Draft; + +// Local mirrors of the server's subscribe/unsubscribe writes, applied to the +// cached identity so every subscribe surface on the page (nav, header, footer +// panel, subscribe block) flips together instead of each one guessing from +// its own state. Only the columns deriveSubscriptionState reads are filled in +// faithfully; the revalidate that follows replaces the rows wholesale. + +function setLocalEmailState( + draft: IdentityDraft, + publicationUri: string, + state: "confirmed" | "unsubscribed", +) { + const rows = (draft.publication_email_subscribers ??= []); + const existing = rows.filter((r) => r.publication === publicationUri); + if (existing.length === 0) { + if (state === "confirmed") + rows.push({ + publication: publicationUri, + state, + } as (typeof rows)[number]); + return; + } + for (const row of existing) row.state = state; +} + +function addLocalAtprotoRow(draft: IdentityDraft, publicationUri: string) { + const rows = (draft.publication_subscriptions ??= []); + if (rows.some((s) => s.publication === publicationUri)) return; + // `record` is the recursive Json type, which immer's Draft<> expands past + // tsc's instantiation limit if the literal is typed against the row. + const row: unknown = { + publication: publicationUri, + identity: draft.atp_did ?? "", + uri: "", + record: {}, + created_at: new Date().toISOString(), + }; + rows.push(row as never); +} + +export function markLocallySubscribed( + publicationUri: string, + method: "email" | "atproto", +) { + updateIdentityData((draft) => { + if (method === "email") { + setLocalEmailState(draft, publicationUri, "confirmed"); + // A confirmed email subscription on a linked account also publishes the + // atproto record (onEmailSubscriptionConfirmed). + if (draft.atp_did) addLocalAtprotoRow(draft, publicationUri); + } else addLocalAtprotoRow(draft, publicationUri); + }); +} + +export function markLocallyUnsubscribed(publicationUri: string) { + updateIdentityData((draft) => { + const subs = draft.publication_subscriptions ?? []; + for (let i = subs.length - 1; i >= 0; i--) + if (subs[i].publication === publicationUri) subs.splice(i, 1); + setLocalEmailState(draft, publicationUri, "unsubscribed"); + for (const m of draft.publication_memberships ?? []) + if (m.publication === publicationUri) m.status = "canceled"; + }); +} + +export function markLocalEmailEnabled( + publicationUri: string, + enabled: boolean, +) { + updateIdentityData((draft) => + setLocalEmailState( + draft, + publicationUri, + enabled ? "confirmed" : "unsubscribed", + ), + ); +} + export type ViewerUser = { loggedIn: boolean; email: string | undefined; diff --git a/src/utils/isLeafletManagedPublication.ts b/src/utils/isLeafletManagedPublication.ts new file mode 100644 index 00000000..92da30dc --- /dev/null +++ b/src/utils/isLeafletManagedPublication.ts @@ -0,0 +1,31 @@ +import { AtUri } from "@atproto/syntax"; +import { TID } from "@atproto/common"; + +// A publication Leaflet created and manages (either record namespace), as +// opposed to one only indexed from the firehose: Leaflet mints TID rkeys, and +// third-party sites mark themselves via preferences or a foreign theme type. +export function isLeafletManagedPublication(p: { + uri: string; + record: unknown; +}): boolean { + try { + const rkey = new AtUri(p.uri).rkey; + if (!TID.is(rkey)) return false; + } catch { + return false; + } + + const record = p.record as Record | null; + if (!record) return true; + + if (record.preferences?.greengale) return false; + + if ( + record.theme && + record.theme.$type && + record.theme.$type !== "pub.leaflet.publication#theme" + ) + return false; + + return true; +} diff --git a/src/utils/publicationRecommendations.ts b/src/utils/publicationRecommendations.ts new file mode 100644 index 00000000..04c0d436 --- /dev/null +++ b/src/utils/publicationRecommendations.ts @@ -0,0 +1,3 @@ +// Shared by the client pickers and the server writers; keep this module free +// of server-only imports. +export const MAX_RECOMMENDATIONS = 3; diff --git a/src/utils/writeRecommendations.ts b/src/utils/writeRecommendations.ts new file mode 100644 index 00000000..1fb5a826 --- /dev/null +++ b/src/utils/writeRecommendations.ts @@ -0,0 +1,56 @@ +import { AtUri } from "@atproto/syntax"; +import { AtpBaseClient, PubLeafletGraphRecommendations } from "lexicons/api"; +import { ids } from "lexicons/api/lexicons"; +import { supabaseServerClient } from "supabase/serverClient"; + +// Recommendations live in a standalone pub.leaflet.graph.recommendations +// record — one per publication, keyed by the publication's rkey so an update +// is a plain putRecord overwrite. An empty list deletes the record. The +// supabase writes mirror what the appview indexes from the firehose: one row +// per recommendation edge, replaced wholesale on update. +export async function writeRecommendations( + agent: AtpBaseClient, + publicationUri: string, + recommendations: string[], +) { + const pubUri = new AtUri(publicationUri); + const repo = pubUri.host; + const collection = ids.PubLeafletGraphRecommendations; + const recordUri = AtUri.make(repo, collection, pubUri.rkey).toString(); + + if (recommendations.length === 0) { + await agent.com.atproto.repo + .deleteRecord({ repo, collection, rkey: pubUri.rkey }) + .catch(() => {}); + await supabaseServerClient + .from("publication_recommendations") + .delete() + .eq("uri", recordUri); + return; + } + + const record: PubLeafletGraphRecommendations.Record = { + $type: "pub.leaflet.graph.recommendations", + publication: publicationUri, + recommendations, + }; + await agent.com.atproto.repo.putRecord({ + repo, + collection, + rkey: pubUri.rkey, + record, + validate: false, + }); + await supabaseServerClient + .from("publication_recommendations") + .delete() + .eq("uri", recordUri); + await supabaseServerClient.from("publication_recommendations").insert( + recommendations.map((recommendation, sort_order) => ({ + uri: recordUri, + publication: publicationUri, + recommendation, + sort_order, + })), + ); +} -- 2.54.0