···11+import { AtUri } from "@atproto/api";
22+import { atUriToUrl } from "src/utils/mentionUtils";
33+44+/**
55+ * Component for rendering at-uri mentions (publications and documents) as clickable links.
66+ * NOTE: This component's styling and behavior should match the ProseMirror schema rendering
77+ * in components/Blocks/TextBlock/schema.ts (atMention mark). If you update one, update the other.
88+ */
99+export function AtMentionLink({
1010+ atURI,
1111+ children,
1212+ className = "",
1313+}: {
1414+ atURI: string;
1515+ children: React.ReactNode;
1616+ className?: string;
1717+}) {
1818+ const aturi = new AtUri(atURI);
1919+ const isPublication = aturi.collection === "pub.leaflet.publication";
2020+ const isDocument = aturi.collection === "pub.leaflet.document";
2121+2222+ // Show publication icon if available
2323+ const icon =
2424+ isPublication || isDocument ? (
2525+ <img
2626+ src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`}
2727+ className="inline-block w-4 h-4 rounded-full ml-1 align-text-bottom"
2828+ alt=""
2929+ width="16"
3030+ height="16"
3131+ loading="lazy"
3232+ />
3333+ ) : null;
3434+3535+ return (
3636+ <a
3737+ href={atUriToUrl(atURI)}
3838+ target="_blank"
3939+ rel="noopener noreferrer"
4040+ className={`text-accent-contrast hover:underline cursor-pointer ${isPublication ? "font-bold" : ""} ${isDocument ? "italic" : ""} ${className}`}
4141+ >
4242+ {icon}
4343+ {children}
4444+ </a>
4545+ );
4646+}
+59
src/utils/mentionUtils.ts
···11+import { AtUri } from "@atproto/api";
22+33+/**
44+ * Converts a DID to a Bluesky profile URL
55+ */
66+export function didToBlueskyUrl(did: string): string {
77+ return `https://bsky.app/profile/${did}`;
88+}
99+1010+/**
1111+ * Converts an AT URI (publication or document) to the appropriate URL
1212+ */
1313+export function atUriToUrl(atUri: string): string {
1414+ try {
1515+ const uri = new AtUri(atUri);
1616+1717+ if (uri.collection === "pub.leaflet.publication") {
1818+ // Publication URL: /lish/{did}/{rkey}
1919+ return `/lish/${uri.host}/${uri.rkey}`;
2020+ } else if (uri.collection === "pub.leaflet.document") {
2121+ // Document URL - we need to resolve this via the API
2222+ // For now, create a redirect route that will handle it
2323+ return `/lish/uri/${encodeURIComponent(atUri)}`;
2424+ }
2525+2626+ return "#";
2727+ } catch (e) {
2828+ console.error("Failed to parse AT URI:", atUri, e);
2929+ return "#";
3030+ }
3131+}
3232+3333+/**
3434+ * Opens a mention link in the appropriate way
3535+ * - DID mentions open in a new tab (external Bluesky)
3636+ * - Publication/document mentions navigate in the same tab
3737+ */
3838+export function handleMentionClick(
3939+ e: MouseEvent | React.MouseEvent,
4040+ type: "did" | "at-uri",
4141+ value: string
4242+) {
4343+ e.preventDefault();
4444+ e.stopPropagation();
4545+4646+ if (type === "did") {
4747+ // Open Bluesky profile in new tab
4848+ window.open(didToBlueskyUrl(value), "_blank", "noopener,noreferrer");
4949+ } else {
5050+ // Navigate to publication/document in same tab
5151+ const url = atUriToUrl(value);
5252+ if (url.startsWith("/lish/uri/")) {
5353+ // Redirect route - navigate to it
5454+ window.location.href = url;
5555+ } else {
5656+ window.location.href = url;
5757+ }
5858+ }
5959+}
···2323import { useHandlePaste } from "./useHandlePaste";
2424import { BlockProps } from "../Block";
2525import { useEntitySetContext } from "components/EntitySetProvider";
2626+import { didToBlueskyUrl, atUriToUrl } from "src/utils/mentionUtils";
26272728export function useMountProsemirror({
2829 props,
···8081 handleClickOn: (_view, _pos, node, _nodePos, _event, direct) => {
8182 if (!direct) return;
8283 if (node.nodeSize - 2 <= _pos) return;
8383- let mark =
8484- node
8585- .nodeAt(_pos - 1)
8686- ?.marks.find((f) => f.type === schema.marks.link) ||
8787- node
8888- .nodeAt(Math.max(_pos - 2, 0))
8989- ?.marks.find((f) => f.type === schema.marks.link);
9090- if (mark) {
9191- window.open(mark.attrs.href, "_blank");
8484+8585+ // Check for marks at the clicked position
8686+ const nodeAt1 = node.nodeAt(_pos - 1);
8787+ const nodeAt2 = node.nodeAt(Math.max(_pos - 2, 0));
8888+8989+ // Check for link marks
9090+ let linkMark = nodeAt1?.marks.find((f) => f.type === schema.marks.link) ||
9191+ nodeAt2?.marks.find((f) => f.type === schema.marks.link);
9292+ if (linkMark) {
9393+ window.open(linkMark.attrs.href, "_blank");
9494+ return;
9595+ }
9696+9797+ // Check for didMention marks
9898+ let didMentionMark = nodeAt1?.marks.find((f) => f.type === schema.marks.didMention) ||
9999+ nodeAt2?.marks.find((f) => f.type === schema.marks.didMention);
100100+ if (didMentionMark) {
101101+ window.open(didToBlueskyUrl(didMentionMark.attrs.did), "_blank", "noopener,noreferrer");
102102+ return;
103103+ }
104104+105105+ // Check for atMention marks
106106+ let atMentionMark = nodeAt1?.marks.find((f) => f.type === schema.marks.atMention) ||
107107+ nodeAt2?.marks.find((f) => f.type === schema.marks.atMention);
108108+ if (atMentionMark) {
109109+ const url = atUriToUrl(atMentionMark.attrs.atURI);
110110+ window.open(url, "_blank", "noopener,noreferrer");
111111+ return;
92112 }
93113 },
94114 dispatchTransaction,
+3
components/Blocks/TextBlock/schema.ts
···120120 },
121121 ],
122122 toDOM(node) {
123123+ // NOTE: This rendering should match the AtMentionLink component in
124124+ // components/AtMentionLink.tsx. If you update one, update the other.
125125+ // We can't use the React component here because ProseMirror expects DOM specs.
123126 let className = "atMention text-accent-contrast";
124127 let aturi = new AtUri(node.attrs.atURI);
125128 if (aturi.collection === "pub.leaflet.publication")
+91
app/lish/uri/[uri]/route.ts
···11+import { NextRequest, NextResponse } from "next/server";
22+import { AtUri } from "@atproto/api";
33+import { supabaseServerClient } from "supabase/serverClient";
44+import { PubLeafletPublication } from "lexicons/api";
55+66+/**
77+ * Redirect route for AT URIs (publications and documents)
88+ * Redirects to the actual hosted domains from publication records
99+ */
1010+export async function GET(
1111+ request: NextRequest,
1212+ { params }: { params: Promise<{ uri: string }> }
1313+) {
1414+ try {
1515+ const { uri: uriParam } = await params;
1616+ const atUriString = decodeURIComponent(uriParam);
1717+ const uri = new AtUri(atUriString);
1818+1919+ if (uri.collection === "pub.leaflet.publication") {
2020+ // Get the publication record to retrieve base_path
2121+ const { data: publication } = await supabaseServerClient
2222+ .from("publications")
2323+ .select("record")
2424+ .eq("uri", atUriString)
2525+ .single();
2626+2727+ if (!publication?.record) {
2828+ return new NextResponse("Publication not found", { status: 404 });
2929+ }
3030+3131+ const record = publication.record as PubLeafletPublication.Record;
3232+ const basePath = record.base_path;
3333+3434+ if (!basePath) {
3535+ return new NextResponse("Publication has no base_path", { status: 404 });
3636+ }
3737+3838+ // Redirect to the publication's hosted domain (temporary redirect since base_path can change)
3939+ return NextResponse.redirect(basePath, 307);
4040+ } else if (uri.collection === "pub.leaflet.document") {
4141+ // Document link - need to find the publication it belongs to
4242+ const { data: docInPub } = await supabaseServerClient
4343+ .from("documents_in_publications")
4444+ .select("publication, publications!inner(record)")
4545+ .eq("document", atUriString)
4646+ .single();
4747+4848+ if (docInPub?.publication && docInPub.publications) {
4949+ // Document is in a publication - redirect to domain/rkey
5050+ const record = docInPub.publications.record as PubLeafletPublication.Record;
5151+ const basePath = record.base_path;
5252+5353+ if (!basePath) {
5454+ return new NextResponse("Publication has no base_path", { status: 404 });
5555+ }
5656+5757+ // Ensure basePath ends without trailing slash
5858+ const cleanBasePath = basePath.endsWith("/")
5959+ ? basePath.slice(0, -1)
6060+ : basePath;
6161+6262+ // Redirect to the document on the publication's domain (temporary redirect since base_path can change)
6363+ return NextResponse.redirect(`${cleanBasePath}/${uri.rkey}`, 307);
6464+ }
6565+6666+ // If not in a publication, check if it's a standalone document
6767+ const { data: doc } = await supabaseServerClient
6868+ .from("documents")
6969+ .select("uri")
7070+ .eq("uri", atUriString)
7171+ .single();
7272+7373+ if (doc) {
7474+ // Standalone document - redirect to /p/did/rkey (temporary redirect)
7575+ return NextResponse.redirect(
7676+ new URL(`/p/${uri.host}/${uri.rkey}`, request.url),
7777+ 307
7878+ );
7979+ }
8080+8181+ // Document not found
8282+ return new NextResponse("Document not found", { status: 404 });
8383+ }
8484+8585+ // Unsupported collection type
8686+ return new NextResponse("Unsupported URI type", { status: 400 });
8787+ } catch (error) {
8888+ console.error("Error resolving AT URI:", error);
8989+ return new NextResponse("Invalid URI", { status: 400 });
9090+ }
9191+}