diff --git a/app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx b/app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx index 54476e7c..6c6de80d 100644 --- a/app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx +++ b/app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx @@ -1,5 +1,7 @@ import { UnicodeString } from "@atproto/api"; import { PubLeafletRichtextFacet } from "lexicons/api"; +import { didToBlueskyUrl } from "src/utils/mentionUtils"; +import { AtMentionLink } from "components/AtMentionLink"; type Facet = PubLeafletRichtextFacet.Main; export function BaseTextBlock(props: { @@ -25,6 +27,9 @@ export function BaseTextBlock(props: { let isDidMention = segment.facet?.find( PubLeafletRichtextFacet.isDidMention, ); + let isAtMention = segment.facet?.find( + PubLeafletRichtextFacet.isAtMention, + ); let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); let isHighlighted = segment.facet?.find( @@ -51,6 +56,28 @@ export function BaseTextBlock(props: { {renderedText} , ); + } else if (isDidMention) { + children.push( + + {renderedText} + , + ); + } else if (isAtMention) { + children.push( + + {renderedText} + , + ); } else if (link) { children.push( } +) { + try { + const { uri: uriParam } = await params; + const atUriString = decodeURIComponent(uriParam); + const uri = new AtUri(atUriString); + + if (uri.collection === "pub.leaflet.publication") { + // Get the publication record to retrieve base_path + const { data: publication } = await supabaseServerClient + .from("publications") + .select("record") + .eq("uri", atUriString) + .single(); + + if (!publication?.record) { + return new NextResponse("Publication not found", { status: 404 }); + } + + const record = publication.record as PubLeafletPublication.Record; + const basePath = record.base_path; + + if (!basePath) { + return new NextResponse("Publication has no base_path", { status: 404 }); + } + + // Redirect to the publication's hosted domain (temporary redirect since base_path can change) + return NextResponse.redirect(basePath, 307); + } else if (uri.collection === "pub.leaflet.document") { + // Document link - need to find the publication it belongs to + const { data: docInPub } = await supabaseServerClient + .from("documents_in_publications") + .select("publication, publications!inner(record)") + .eq("document", atUriString) + .single(); + + if (docInPub?.publication && docInPub.publications) { + // Document is in a publication - redirect to domain/rkey + const record = docInPub.publications.record as PubLeafletPublication.Record; + const basePath = record.base_path; + + if (!basePath) { + return new NextResponse("Publication has no base_path", { status: 404 }); + } + + // Ensure basePath ends without trailing slash + const cleanBasePath = basePath.endsWith("/") + ? basePath.slice(0, -1) + : basePath; + + // Redirect to the document on the publication's domain (temporary redirect since base_path can change) + return NextResponse.redirect(`${cleanBasePath}/${uri.rkey}`, 307); + } + + // If not in a publication, check if it's a standalone document + const { data: doc } = await supabaseServerClient + .from("documents") + .select("uri") + .eq("uri", atUriString) + .single(); + + if (doc) { + // Standalone document - redirect to /p/did/rkey (temporary redirect) + return NextResponse.redirect( + new URL(`/p/${uri.host}/${uri.rkey}`, request.url), + 307 + ); + } + + // Document not found + return new NextResponse("Document not found", { status: 404 }); + } + + // Unsupported collection type + return new NextResponse("Unsupported URI type", { status: 400 }); + } catch (error) { + console.error("Error resolving AT URI:", error); + return new NextResponse("Invalid URI", { status: 400 }); + } +} diff --git a/components/AtMentionLink.tsx b/components/AtMentionLink.tsx new file mode 100644 index 00000000..2df31919 --- /dev/null +++ b/components/AtMentionLink.tsx @@ -0,0 +1,46 @@ +import { AtUri } from "@atproto/api"; +import { atUriToUrl } from "src/utils/mentionUtils"; + +/** + * Component for rendering at-uri mentions (publications and documents) as clickable links. + * NOTE: This component's styling and behavior should match the ProseMirror schema rendering + * in components/Blocks/TextBlock/schema.ts (atMention mark). If you update one, update the other. + */ +export function AtMentionLink({ + atURI, + children, + className = "", +}: { + atURI: string; + children: React.ReactNode; + className?: string; +}) { + const aturi = new AtUri(atURI); + const isPublication = aturi.collection === "pub.leaflet.publication"; + const isDocument = aturi.collection === "pub.leaflet.document"; + + // Show publication icon if available + const icon = + isPublication || isDocument ? ( + + ) : null; + + return ( + + {icon} + {children} + + ); +} diff --git a/components/Blocks/TextBlock/RenderYJSFragment.tsx b/components/Blocks/TextBlock/RenderYJSFragment.tsx index ba4670d4..127ac405 100644 --- a/components/Blocks/TextBlock/RenderYJSFragment.tsx +++ b/components/Blocks/TextBlock/RenderYJSFragment.tsx @@ -3,6 +3,8 @@ import { nodes, marks } from "prosemirror-schema-basic"; import { CSSProperties, Fragment } from "react"; import { theme } from "tailwind.config"; import * as base64 from "base64-js"; +import { didToBlueskyUrl } from "src/utils/mentionUtils"; +import { AtMentionLink } from "components/AtMentionLink"; type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; export function RenderYJSFragment({ @@ -46,6 +48,30 @@ export function RenderYJSFragment({ {d.insert} ); + if (d.attributes?.didMention) + return ( + + {d.insert} + + ); + if (d.attributes?.atMention) { + return ( + + {d.insert} + + ); + } return ( { if (!direct) return; if (node.nodeSize - 2 <= _pos) return; - let mark = - node - .nodeAt(_pos - 1) - ?.marks.find((f) => f.type === schema.marks.link) || - node - .nodeAt(Math.max(_pos - 2, 0)) - ?.marks.find((f) => f.type === schema.marks.link); - if (mark) { - window.open(mark.attrs.href, "_blank"); + + // Check for marks at the clicked position + const nodeAt1 = node.nodeAt(_pos - 1); + const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); + + // Check for link marks + let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) || + nodeAt2?.marks.find((f) => f.type === schema.marks.link); + if (linkMark) { + window.open(linkMark.attrs.href, "_blank"); + return; + } + + // Check for didMention marks + let didMentionMark = nodeAt1?.marks.find((f) => f.type === schema.marks.didMention) || + nodeAt2?.marks.find((f) => f.type === schema.marks.didMention); + if (didMentionMark) { + window.open(didToBlueskyUrl(didMentionMark.attrs.did), "_blank", "noopener,noreferrer"); + return; + } + + // Check for atMention marks + let atMentionMark = nodeAt1?.marks.find((f) => f.type === schema.marks.atMention) || + nodeAt2?.marks.find((f) => f.type === schema.marks.atMention); + if (atMentionMark) { + const url = atUriToUrl(atMentionMark.attrs.atURI); + window.open(url, "_blank", "noopener,noreferrer"); + return; } }, dispatchTransaction, diff --git a/components/Blocks/TextBlock/schema.ts b/components/Blocks/TextBlock/schema.ts index 90c018b1..4b199def 100644 --- a/components/Blocks/TextBlock/schema.ts +++ b/components/Blocks/TextBlock/schema.ts @@ -120,6 +120,9 @@ let baseSchema = { }, ], toDOM(node) { + // NOTE: This rendering should match the AtMentionLink component in + // components/AtMentionLink.tsx. If you update one, update the other. + // We can't use the React component here because ProseMirror expects DOM specs. let className = "atMention text-accent-contrast"; let aturi = new AtUri(node.attrs.atURI); if (aturi.collection === "pub.leaflet.publication") diff --git a/src/utils/mentionUtils.ts b/src/utils/mentionUtils.ts new file mode 100644 index 00000000..0202ecc6 --- /dev/null +++ b/src/utils/mentionUtils.ts @@ -0,0 +1,59 @@ +import { AtUri } from "@atproto/api"; + +/** + * Converts a DID to a Bluesky profile URL + */ +export function didToBlueskyUrl(did: string): string { + return `https://bsky.app/profile/${did}`; +} + +/** + * Converts an AT URI (publication or document) to the appropriate URL + */ +export function atUriToUrl(atUri: string): string { + try { + const uri = new AtUri(atUri); + + if (uri.collection === "pub.leaflet.publication") { + // Publication URL: /lish/{did}/{rkey} + return `/lish/${uri.host}/${uri.rkey}`; + } else if (uri.collection === "pub.leaflet.document") { + // Document URL - we need to resolve this via the API + // For now, create a redirect route that will handle it + return `/lish/uri/${encodeURIComponent(atUri)}`; + } + + return "#"; + } catch (e) { + console.error("Failed to parse AT URI:", atUri, e); + return "#"; + } +} + +/** + * Opens a mention link in the appropriate way + * - DID mentions open in a new tab (external Bluesky) + * - Publication/document mentions navigate in the same tab + */ +export function handleMentionClick( + e: MouseEvent | React.MouseEvent, + type: "did" | "at-uri", + value: string +) { + e.preventDefault(); + e.stopPropagation(); + + if (type === "did") { + // Open Bluesky profile in new tab + window.open(didToBlueskyUrl(value), "_blank", "noopener,noreferrer"); + } else { + // Navigate to publication/document in same tab + const url = atUriToUrl(value); + if (url.startsWith("/lish/uri/")) { + // Redirect route - navigate to it + window.location.href = url; + } else { + window.location.href = url; + } + } +}