a tool for shared writing and social publishing

render mentions and handle as links

+281 -9
+27
app/lish/[did]/[publication]/[rkey]/BaseTextBlock.tsx
··· 1 1 import { UnicodeString } from "@atproto/api"; 2 2 import { PubLeafletRichtextFacet } from "lexicons/api"; 3 + import { didToBlueskyUrl } from "src/utils/mentionUtils"; 4 + import { AtMentionLink } from "components/AtMentionLink"; 3 5 4 6 type Facet = PubLeafletRichtextFacet.Main; 5 7 export function BaseTextBlock(props: { ··· 25 27 let isDidMention = segment.facet?.find( 26 28 PubLeafletRichtextFacet.isDidMention, 27 29 ); 30 + let isAtMention = segment.facet?.find( 31 + PubLeafletRichtextFacet.isAtMention, 32 + ); 28 33 let isUnderline = segment.facet?.find(PubLeafletRichtextFacet.isUnderline); 29 34 let isItalic = segment.facet?.find(PubLeafletRichtextFacet.isItalic); 30 35 let isHighlighted = segment.facet?.find( ··· 50 55 <code key={counter} className={className} id={id?.id}> 51 56 {renderedText} 52 57 </code>, 58 + ); 59 + } else if (isDidMention) { 60 + children.push( 61 + <a 62 + key={counter} 63 + href={didToBlueskyUrl(isDidMention.did)} 64 + className={`text-accent-contrast hover:underline cursor-pointer ${className}`} 65 + target="_blank" 66 + rel="noopener noreferrer" 67 + > 68 + {renderedText} 69 + </a>, 70 + ); 71 + } else if (isAtMention) { 72 + children.push( 73 + <AtMentionLink 74 + key={counter} 75 + atURI={isAtMention.atURI} 76 + className={className} 77 + > 78 + {renderedText} 79 + </AtMentionLink>, 53 80 ); 54 81 } else if (link) { 55 82 children.push(
+91
app/lish/uri/[uri]/route.ts
··· 1 + import { NextRequest, NextResponse } from "next/server"; 2 + import { AtUri } from "@atproto/api"; 3 + import { supabaseServerClient } from "supabase/serverClient"; 4 + import { PubLeafletPublication } from "lexicons/api"; 5 + 6 + /** 7 + * Redirect route for AT URIs (publications and documents) 8 + * Redirects to the actual hosted domains from publication records 9 + */ 10 + export async function GET( 11 + request: NextRequest, 12 + { params }: { params: Promise<{ uri: string }> } 13 + ) { 14 + try { 15 + const { uri: uriParam } = await params; 16 + const atUriString = decodeURIComponent(uriParam); 17 + const uri = new AtUri(atUriString); 18 + 19 + if (uri.collection === "pub.leaflet.publication") { 20 + // Get the publication record to retrieve base_path 21 + const { data: publication } = await supabaseServerClient 22 + .from("publications") 23 + .select("record") 24 + .eq("uri", atUriString) 25 + .single(); 26 + 27 + if (!publication?.record) { 28 + return new NextResponse("Publication not found", { status: 404 }); 29 + } 30 + 31 + const record = publication.record as PubLeafletPublication.Record; 32 + const basePath = record.base_path; 33 + 34 + if (!basePath) { 35 + return new NextResponse("Publication has no base_path", { status: 404 }); 36 + } 37 + 38 + // Redirect to the publication's hosted domain (temporary redirect since base_path can change) 39 + return NextResponse.redirect(basePath, 307); 40 + } else if (uri.collection === "pub.leaflet.document") { 41 + // Document link - need to find the publication it belongs to 42 + const { data: docInPub } = await supabaseServerClient 43 + .from("documents_in_publications") 44 + .select("publication, publications!inner(record)") 45 + .eq("document", atUriString) 46 + .single(); 47 + 48 + if (docInPub?.publication && docInPub.publications) { 49 + // Document is in a publication - redirect to domain/rkey 50 + const record = docInPub.publications.record as PubLeafletPublication.Record; 51 + const basePath = record.base_path; 52 + 53 + if (!basePath) { 54 + return new NextResponse("Publication has no base_path", { status: 404 }); 55 + } 56 + 57 + // Ensure basePath ends without trailing slash 58 + const cleanBasePath = basePath.endsWith("/") 59 + ? basePath.slice(0, -1) 60 + : basePath; 61 + 62 + // Redirect to the document on the publication's domain (temporary redirect since base_path can change) 63 + return NextResponse.redirect(`${cleanBasePath}/${uri.rkey}`, 307); 64 + } 65 + 66 + // If not in a publication, check if it's a standalone document 67 + const { data: doc } = await supabaseServerClient 68 + .from("documents") 69 + .select("uri") 70 + .eq("uri", atUriString) 71 + .single(); 72 + 73 + if (doc) { 74 + // Standalone document - redirect to /p/did/rkey (temporary redirect) 75 + return NextResponse.redirect( 76 + new URL(`/p/${uri.host}/${uri.rkey}`, request.url), 77 + 307 78 + ); 79 + } 80 + 81 + // Document not found 82 + return new NextResponse("Document not found", { status: 404 }); 83 + } 84 + 85 + // Unsupported collection type 86 + return new NextResponse("Unsupported URI type", { status: 400 }); 87 + } catch (error) { 88 + console.error("Error resolving AT URI:", error); 89 + return new NextResponse("Invalid URI", { status: 400 }); 90 + } 91 + }
+46
components/AtMentionLink.tsx
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { atUriToUrl } from "src/utils/mentionUtils"; 3 + 4 + /** 5 + * Component for rendering at-uri mentions (publications and documents) as clickable links. 6 + * NOTE: This component's styling and behavior should match the ProseMirror schema rendering 7 + * in components/Blocks/TextBlock/schema.ts (atMention mark). If you update one, update the other. 8 + */ 9 + export function AtMentionLink({ 10 + atURI, 11 + children, 12 + className = "", 13 + }: { 14 + atURI: string; 15 + children: React.ReactNode; 16 + className?: string; 17 + }) { 18 + const aturi = new AtUri(atURI); 19 + const isPublication = aturi.collection === "pub.leaflet.publication"; 20 + const isDocument = aturi.collection === "pub.leaflet.document"; 21 + 22 + // Show publication icon if available 23 + const icon = 24 + isPublication || isDocument ? ( 25 + <img 26 + src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`} 27 + className="inline-block w-4 h-4 rounded-full ml-1 align-text-bottom" 28 + alt="" 29 + width="16" 30 + height="16" 31 + loading="lazy" 32 + /> 33 + ) : null; 34 + 35 + return ( 36 + <a 37 + href={atUriToUrl(atURI)} 38 + target="_blank" 39 + rel="noopener noreferrer" 40 + className={`text-accent-contrast hover:underline cursor-pointer ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`} 41 + > 42 + {icon} 43 + {children} 44 + </a> 45 + ); 46 + }
+26
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 3 3 import { CSSProperties, Fragment } from "react"; 4 4 import { theme } from "tailwind.config"; 5 5 import * as base64 from "base64-js"; 6 + import { didToBlueskyUrl } from "src/utils/mentionUtils"; 7 + import { AtMentionLink } from "components/AtMentionLink"; 6 8 7 9 type BlockElements = "h1" | "h2" | "h3" | null | "blockquote" | "p"; 8 10 export function RenderYJSFragment({ ··· 46 48 {d.insert} 47 49 </a> 48 50 ); 51 + if (d.attributes?.didMention) 52 + return ( 53 + <a 54 + href={didToBlueskyUrl(d.attributes.didMention.did)} 55 + target="_blank" 56 + rel="noopener noreferrer" 57 + key={index} 58 + {...attributesToStyle(d)} 59 + className={`${attributesToStyle(d).className} text-accent-contrast hover:underline cursor-pointer`} 60 + > 61 + {d.insert} 62 + </a> 63 + ); 64 + if (d.attributes?.atMention) { 65 + return ( 66 + <AtMentionLink 67 + key={index} 68 + atURI={d.attributes.atMention.atURI} 69 + className={attributesToStyle(d).className} 70 + > 71 + {d.insert} 72 + </AtMentionLink> 73 + ); 74 + } 49 75 return ( 50 76 <span 51 77 key={index}
+29 -9
components/Blocks/TextBlock/mountProsemirror.ts
··· 23 23 import { useHandlePaste } from "./useHandlePaste"; 24 24 import { BlockProps } from "../Block"; 25 25 import { useEntitySetContext } from "components/EntitySetProvider"; 26 + import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils"; 26 27 27 28 export function useMountProsemirror({ 28 29 props, ··· 80 81 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => { 81 82 if (!direct) return; 82 83 if (node.nodeSize - 2 <= _pos) return; 83 - let mark = 84 - node 85 - .nodeAt(_pos - 1) 86 - ?.marks.find((f) => f.type === schema.marks.link) || 87 - node 88 - .nodeAt(Math.max(_pos - 2, 0)) 89 - ?.marks.find((f) => f.type === schema.marks.link); 90 - if (mark) { 91 - window.open(mark.attrs.href, "_blank"); 84 + 85 + // Check for marks at the clicked position 86 + const nodeAt1 = node.nodeAt(_pos - 1); 87 + const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0)); 88 + 89 + // Check for link marks 90 + let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) || 91 + nodeAt2?.marks.find((f) => f.type === schema.marks.link); 92 + if (linkMark) { 93 + window.open(linkMark.attrs.href, "_blank"); 94 + return; 95 + } 96 + 97 + // Check for didMention marks 98 + let didMentionMark = nodeAt1?.marks.find((f) => f.type === schema.marks.didMention) || 99 + nodeAt2?.marks.find((f) => f.type === schema.marks.didMention); 100 + if (didMentionMark) { 101 + window.open(didToBlueskyUrl(didMentionMark.attrs.did), "_blank", "noopener,noreferrer"); 102 + return; 103 + } 104 + 105 + // Check for atMention marks 106 + let atMentionMark = nodeAt1?.marks.find((f) => f.type === schema.marks.atMention) || 107 + nodeAt2?.marks.find((f) => f.type === schema.marks.atMention); 108 + if (atMentionMark) { 109 + const url = atUriToUrl(atMentionMark.attrs.atURI); 110 + window.open(url, "_blank", "noopener,noreferrer"); 111 + return; 92 112 } 93 113 }, 94 114 dispatchTransaction,
+3
components/Blocks/TextBlock/schema.ts
··· 120 120 }, 121 121 ], 122 122 toDOM(node) { 123 + // NOTE: This rendering should match the AtMentionLink component in 124 + // components/AtMentionLink.tsx. If you update one, update the other. 125 + // We can't use the React component here because ProseMirror expects DOM specs. 123 126 let className = "atMention text-accent-contrast"; 124 127 let aturi = new AtUri(node.attrs.atURI); 125 128 if (aturi.collection === "pub.leaflet.publication")
+59
src/utils/mentionUtils.ts
··· 1 + import { AtUri } from "@atproto/api"; 2 + 3 + /** 4 + * Converts a DID to a Bluesky profile URL 5 + */ 6 + export function didToBlueskyUrl(did: string): string { 7 + return `https://bsky.app/profile/${did}`; 8 + } 9 + 10 + /** 11 + * Converts an AT URI (publication or document) to the appropriate URL 12 + */ 13 + export function atUriToUrl(atUri: string): string { 14 + try { 15 + const uri = new AtUri(atUri); 16 + 17 + if (uri.collection === "pub.leaflet.publication") { 18 + // Publication URL: /lish/{did}/{rkey} 19 + return `/lish/${uri.host}/${uri.rkey}`; 20 + } else if (uri.collection === "pub.leaflet.document") { 21 + // Document URL - we need to resolve this via the API 22 + // For now, create a redirect route that will handle it 23 + return `/lish/uri/${encodeURIComponent(atUri)}`; 24 + } 25 + 26 + return "#"; 27 + } catch (e) { 28 + console.error("Failed to parse AT URI:", atUri, e); 29 + return "#"; 30 + } 31 + } 32 + 33 + /** 34 + * Opens a mention link in the appropriate way 35 + * - DID mentions open in a new tab (external Bluesky) 36 + * - Publication/document mentions navigate in the same tab 37 + */ 38 + export function handleMentionClick( 39 + e: MouseEvent | React.MouseEvent, 40 + type: "did" | "at-uri", 41 + value: string 42 + ) { 43 + e.preventDefault(); 44 + e.stopPropagation(); 45 + 46 + if (type === "did") { 47 + // Open Bluesky profile in new tab 48 + window.open(didToBlueskyUrl(value), "_blank", "noopener,noreferrer"); 49 + } else { 50 + // Navigate to publication/document in same tab 51 + const url = atUriToUrl(value); 52 + if (url.startsWith("/lish/uri/")) { 53 + // Redirect route - navigate to it 54 + window.location.href = url; 55 + } else { 56 + window.location.href = url; 57 + } 58 + } 59 + }