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;
+ }
+ }
+}