diff --git a/actions/publishToPublication.ts b/actions/publishToPublication.ts index d125d0be..935e2fd9 100644 --- a/actions/publishToPublication.ts +++ b/actions/publishToPublication.ts @@ -923,6 +923,9 @@ function YJSFragmentToFacets( { $type: "pub.leaflet.richtext.facet#atMention", atURI: node.getAttribute("atURI"), + ...(node.getAttribute("href") + ? { href: node.getAttribute("href") } + : {}), }, ], }; diff --git a/app/(home-pages)/reader/idResolver.ts b/app/(home-pages)/reader/idResolver.ts index f4c1af72..37d57480 100644 --- a/app/(home-pages)/reader/idResolver.ts +++ b/app/(home-pages)/reader/idResolver.ts @@ -3,7 +3,7 @@ import type { DidCache, CacheResult, DidDocument } from "@atproto/identity"; import Client from "ioredis"; // Create Redis client for DID caching let redisClient: Client | null = null; -if (process.env.REDIS_URL) { +if (process.env.REDIS_URL && process.env.NODE_ENV === "production") { redisClient = new Client(process.env.REDIS_URL); } diff --git a/app/[leaflet_id]/page.tsx b/app/[leaflet_id]/page.tsx index 851ac894..e23a817d 100644 --- a/app/[leaflet_id]/page.tsx +++ b/app/[leaflet_id]/page.tsx @@ -41,18 +41,23 @@ export default async function LeafletPage(props: Props) { ); - let [{ data }, rsvp_data, poll_data] = await Promise.all([ + let [{ data, error }, rsvp_data, poll_data] = await Promise.all([ supabaseServerClient.rpc("get_facts", { root: rootEntity, }), getRSVPData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), getPollData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), ]); + console.log("ERROR:", error); let initialFacts = (data as unknown as Fact[]) || []; // Extract font settings from facts for server-side font loading - const { headingFontId, bodyFontId } = extractFontsFromFacts(initialFacts as any, rootEntity); + const { headingFontId, bodyFontId } = extractFontsFromFacts( + initialFacts as any, + rootEntity, + ); + console.log(res); return ( <> {/* Server-side font loading with preload and @font-face */} diff --git a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx index 8e029198..204e2dea 100644 --- a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx +++ b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx @@ -425,14 +425,19 @@ export const addMentionToEditor = ( }); tr.insert(from, didMentionNode); } - if (mention.type === "publication" || mention.type === "post") { - // Delete the @ and any query text + if ( + mention.type === "publication" || + mention.type === "post" || + mention.type === "service_result" + ) { tr.delete(from, to); - let name = mention.type == "post" ? mention.title : mention.name; - // Insert atMention inline node + const text = mention.type === "post" ? mention.title : mention.name; const atMentionNode = schema.nodes.atMention.create({ atURI: mention.uri, - text: name, + text, + ...(mention.type === "service_result" && mention.href + ? { href: mention.href } + : {}), }); tr.insert(from, atMentionNode); } diff --git a/app/api/rpc/[command]/get_user_mention_services.ts b/app/api/rpc/[command]/get_user_mention_services.ts new file mode 100644 index 00000000..5003c33d --- /dev/null +++ b/app/api/rpc/[command]/get_user_mention_services.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { makeRoute } from "../lib"; +import type { Env } from "./route"; +import { getIdentityData } from "actions/getIdentityData"; + +export type GetUserMentionServicesReturnType = Awaited< + ReturnType<(typeof get_user_mention_services)["handler"]> +>; + +export const get_user_mention_services = makeRoute({ + route: "get_user_mention_services", + input: z.object({}), + handler: async (_input, { supabase }: Pick) => { + let user = await getIdentityData(); + if (!user?.atp_did) return { result: { services: [] } }; + const { data: config } = await supabase + .from("mention_service_configs") + .select("record") + .eq("identity_did", user?.atp_did) + .single(); + + const services = (config?.record as any)?.services as string[] | undefined; + if (!services?.length) return { result: { services: [] } }; + + const { data: serviceRows, error } = await supabase + .from("mention_services") + .select("uri, record") + .in("uri", services); + + if (error) { + throw new Error(`Failed to fetch mention services: ${error.message}`); + } + + return { + result: { + services: (serviceRows || []).map((s) => { + const record = s.record as any; + return { + uri: s.uri, + name: record?.name as string, + description: record?.description as string | undefined, + endpoint_url: record?.endpoint as string, + }; + }), + }, + }; + }, +}); diff --git a/app/api/rpc/[command]/proxy_mention_search.ts b/app/api/rpc/[command]/proxy_mention_search.ts new file mode 100644 index 00000000..94deba53 --- /dev/null +++ b/app/api/rpc/[command]/proxy_mention_search.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; +import { makeRoute } from "../lib"; +import type { Env } from "./route"; +import { idResolver } from "app/(home-pages)/reader/idResolver"; + +export type ProxyMentionSearchReturnType = Awaited< + ReturnType<(typeof proxy_mention_search)["handler"]> +>; + +async function resolveDidToServiceEndpoint(did: string): Promise { + const doc = await idResolver.did.resolve(did); + if (!doc) throw new Error(`Could not resolve DID: ${did}`); + const service = doc.service?.find( + (s: any) => s.id === "#mention_search" || s.type === "MentionSearchService", + ); + if (!service) + throw new Error(`No mention search service in DID document for ${did}`); + return service.serviceEndpoint as string; +} + +export const proxy_mention_search = makeRoute({ + route: "proxy_mention_search", + input: z.object({ + service_uri: z.string(), + search: z.string(), + }), + handler: async ( + { service_uri, search }, + { supabase }: Pick, + ) => { + const { data: service } = await supabase + .from("mention_services") + .select("record") + .eq("uri", service_uri) + .single(); + + if (!service) throw new Error("Mention service not found"); + + const did = (service.record as any)?.did as string; + if (!did) throw new Error("Service has no DID"); + + const serviceEndpoint = await resolveDidToServiceEndpoint(did); + + const url = new URL( + "/xrpc/parts.page.mention.searchService", + serviceEndpoint, + ); + url.searchParams.set("service", service_uri); + url.searchParams.set("search", search); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + try { + const response = await fetch(url.toString(), { + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Service returned ${response.status}`); + } + const data = (await response.json()) as { results?: unknown[] }; + const results = Array.isArray(data.results) ? data.results : []; + + return { + result: { + results: results.slice(0, 50).map((r: any) => ({ + uri: String(r.uri || ""), + name: String(r.name || ""), + href: r.href ? String(r.href) : undefined, + })), + }, + }; + } finally { + clearTimeout(timeout); + } + }, +}); diff --git a/app/api/rpc/[command]/route.ts b/app/api/rpc/[command]/route.ts index 05912055..91f4bb74 100644 --- a/app/api/rpc/[command]/route.ts +++ b/app/api/rpc/[command]/route.ts @@ -19,6 +19,8 @@ import { get_hot_feed } from "./get_hot_feed"; import { get_document_interactions } from "./get_document_interactions"; import { get_publication_analytics } from "./get_publication_analytics"; import { get_publication_subscribers_timeseries } from "./get_publication_subscribers_timeseries"; +import { get_user_mention_services } from "./get_user_mention_services"; +import { proxy_mention_search } from "./proxy_mention_search"; let supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, @@ -51,6 +53,8 @@ let Routes = [ get_document_interactions, get_publication_analytics, get_publication_subscribers_timeseries, + get_user_mention_services, + proxy_mention_search, ]; export async function POST( req: Request, diff --git a/app/api/rpc/[command]/search_loose_leafs.ts b/app/api/rpc/[command]/search_loose_leafs.ts new file mode 100644 index 00000000..b8800105 --- /dev/null +++ b/app/api/rpc/[command]/search_loose_leafs.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import { makeRoute } from "../lib"; +import type { Env } from "./route"; +import { deduplicateByUri } from "src/utils/deduplicateRecords"; + +export type SearchLooseLeafsReturnType = Awaited< + ReturnType<(typeof search_loose_leafs)["handler"]> +>; + +export const search_loose_leafs = makeRoute({ + route: "search_loose_leafs", + input: z.object({ + query: z.string(), + limit: z.number().optional().default(10), + }), + handler: async ( + { query, limit }, + { supabase }: Pick, + ) => { + // Search for documents that are NOT in any publication (loose leafs) + // A loose leaf is a document with no entry in documents_in_publications + const { data: rawLooseLeafs, error } = await supabase + .from("documents") + .select("uri, data") + .ilike("data->>title", `%${query}%`) + .not("uri", "in", supabase.from("documents_in_publications").select("document")) + .limit(limit); + + if (error) { + throw new Error(`Failed to search loose leafs: ${error.message}`); + } + + // Deduplicate records that may exist under both pub.leaflet and site.standard namespaces + const looseLeafs = deduplicateByUri(rawLooseLeafs || []); + + const result = looseLeafs.map((d) => ({ + uri: d.uri, + title: (d.data as { title?: string })?.title || "Untitled", + })); + + return { result: { documents: result } }; + }, +}); diff --git a/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx b/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx index 292d44c7..a2a92486 100644 --- a/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx +++ b/app/lish/[did]/[publication]/[rkey]/Blocks/TextBlockCore.tsx @@ -114,6 +114,7 @@ export function TextBlockCore(props: TextBlockCoreProps) { {renderedText} diff --git a/components/AtMentionLink.tsx b/components/AtMentionLink.tsx index 29580785..cf3028fe 100644 --- a/components/AtMentionLink.tsx +++ b/components/AtMentionLink.tsx @@ -1,9 +1,4 @@ -import { AtUri } from "@atproto/api"; -import { atUriToUrl } from "src/utils/mentionUtils"; -import { - isDocumentCollection, - isPublicationCollection, -} from "src/utils/collectionHelpers"; +import { atUriToUrl, classifyAtUri } from "src/utils/mentionUtils"; /** * Component for rendering at-uri mentions (publications and documents) as clickable links. @@ -12,16 +7,16 @@ import { */ export function AtMentionLink({ atURI, + href, children, className = "", }: { atURI: string; + href?: string; children: React.ReactNode; className?: string; }) { - const aturi = new AtUri(atURI); - const isPublication = isPublicationCollection(aturi.collection); - const isDocument = isDocumentCollection(aturi.collection); + const { isPublication, isDocument } = classifyAtUri(atURI); // Show publication icon if available const icon = @@ -36,9 +31,11 @@ export function AtMentionLink({ /> ) : null; + const linkHref = href || atUriToUrl(atURI); + return ( + {text} ); diff --git a/components/Blocks/TextBlock/schema.ts b/components/Blocks/TextBlock/schema.ts index 88b2ed91..a9ade1b8 100644 --- a/components/Blocks/TextBlock/schema.ts +++ b/components/Blocks/TextBlock/schema.ts @@ -1,11 +1,7 @@ -import { AtUri } from "@atproto/api"; import { Schema, Node, MarkSpec, NodeSpec } from "prosemirror-model"; import { marks } from "prosemirror-schema-basic"; import { theme } from "tailwind.config"; -import { - isDocumentCollection, - isPublicationCollection, -} from "src/utils/collectionHelpers"; +import { classifyAtUri } from "src/utils/mentionUtils"; let baseSchema = { marks: { @@ -131,6 +127,7 @@ let baseSchema = { attrs: { atURI: {}, text: { default: "" }, + href: { default: undefined }, }, group: "inline", inline: true, @@ -144,6 +141,7 @@ let baseSchema = { return { atURI: dom.getAttribute("data-at-uri"), text: dom.textContent || "", + href: dom.getAttribute("data-href") || undefined, }; }, }, @@ -152,22 +150,23 @@ let baseSchema = { // NOTE: This rendering should match the AtMentionLink component in // components/AtMentionLink.tsx. If you update one, update the other. let className = "atMention mention"; - let aturi = new AtUri(node.attrs.atURI); - if (isPublicationCollection(aturi.collection)) - className += " font-bold"; - if (isDocumentCollection(aturi.collection)) className += " italic"; + const { isPublication, isDocument } = classifyAtUri(node.attrs.atURI); + if (isPublication) className += " font-bold"; + if (isDocument) className += " italic"; + + const attrs: Record = { + class: className, + "data-at-uri": node.attrs.atURI, + }; + if (node.attrs.href) { + attrs["data-href"] = node.attrs.href; + } // For publications and documents, show icon - if ( - isPublicationCollection(aturi.collection) || - isDocumentCollection(aturi.collection) - ) { + if (isPublication || isDocument) { return [ "span", - { - class: className, - "data-at-uri": node.attrs.atURI, - }, + attrs, [ "img", { @@ -184,14 +183,7 @@ let baseSchema = { ]; } - return [ - "span", - { - class: className, - "data-at-uri": node.attrs.atURI, - }, - node.attrs.text, - ]; + return ["span", attrs, node.attrs.text]; }, } as NodeSpec, footnote: { diff --git a/components/Mention.tsx b/components/Mention.tsx index 32e0075a..4f2724c5 100644 --- a/components/Mention.tsx +++ b/components/Mention.tsx @@ -1,6 +1,14 @@ "use client"; import { Agent } from "@atproto/api"; -import { useState, useEffect, Fragment, useRef, useCallback } from "react"; +import { + useState, + useEffect, + useMemo, + Fragment, + useRef, + useCallback, +} from "react"; +import useSWR from "swr"; import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; import * as Popover from "@radix-ui/react-popover"; import { EditorView } from "prosemirror-view"; @@ -26,7 +34,7 @@ export function MentionAutocomplete(props: { const contentRef = useRef(null); const { suggestionIndex, setSuggestionIndex, suggestions, scope, setScope } = - useMentionSuggestions(searchQuery); + useMentionSuggestions(searchQuery, props.open); // Clear search when scope changes const handleScopeChange = useCallback( @@ -110,11 +118,16 @@ export function MentionAutocomplete(props: { uri: selectedSuggestion.uri, name: selectedSuggestion.name, }); + } else if (selectedSuggestion?.type === "service") { + e.preventDefault(); + handleScopeChange(serviceScopeFromMention(selectedSuggestion)); } } else if (e.key === "Enter") { e.preventDefault(); const selectedSuggestion = suggestions[suggestionIndex]; - if (selectedSuggestion) { + if (selectedSuggestion?.type === "service") { + handleScopeChange(serviceScopeFromMention(selectedSuggestion)); + } else if (selectedSuggestion) { props.onSelect(selectedSuggestion); props.onOpenChange(false); } @@ -139,27 +152,44 @@ export function MentionAutocomplete(props: { if (!props.open || !props.coords) return null; const getHeader = (type: Mention["type"], scope?: MentionScope) => { + // When in a built-in scope, show a back header + if ( + scope?.type === "identities" || + scope?.type === "publications" || + scope?.type === "publication" || + scope?.type === "service" + ) { + return ( + { + handleScopeChange({ type: "default" }); + }} + /> + ); + } switch (type) { case "did": return "People"; case "publication": return "Publications"; case "post": - if (scope) { - return ( - { - handleScopeChange({ type: "default" }); - }} - /> - ); - } else return "Posts"; + return "Posts"; + case "service": + return "Services"; + case "service_result": + return null; } }; const sortedSuggestions = [...suggestions].sort((a, b) => { - const order: Mention["type"][] = ["did", "publication", "post"]; + const order: Mention["type"][] = [ + "did", + "publication", + "post", + "service", + "service_result", + ]; return order.indexOf(a.type) - order.indexOf(b.type); }); @@ -206,9 +236,7 @@ export function MentionAutocomplete(props: { onKeyDown={handleKeyDown} autoFocus placeholder={ - scope.type === "publication" - ? "Search posts..." - : props.placeholder ?? "Search people & publications..." + scopePlaceholder(scope, props.placeholder) } className="flex-1 w-full min-w-0 bg-transparent border-none outline-none text-sm placeholder:text-tertiary" /> @@ -227,10 +255,15 @@ export function MentionAutocomplete(props: { index === 0 || (prevResult && prevResult.type !== result.type); + const key = + result.type === "did" + ? result.did + : result.type === "service" + ? result.serviceUri + : result.uri; + return ( - + {showHeader && ( <> {index > 0 && ( @@ -271,6 +304,26 @@ export function MentionAutocomplete(props: { }); }} /> + ) : result.type === "service" ? ( + { + handleScopeChange(serviceScopeFromMention(result)); + }} + onMouseDown={(e) => e.preventDefault()} + name={result.name} + description={result.description} + selected={index === suggestionIndex} + /> + ) : result.type === "service_result" ? ( + { + props.onSelect(result); + props.onOpenChange(false); + }} + onMouseDown={(e) => e.preventDefault()} + name={result.name} + selected={index === suggestionIndex} + /> ) : ( { @@ -428,24 +481,69 @@ const PostResult = (props: { ); }; +const ServiceEntry = (props: { + name: string; + description?: string; + onClick: () => void; + onMouseDown: (e: React.MouseEvent) => void; + selected?: boolean; +}) => { + return ( + +
{props.name}
+ Search + + } + subtext={props.description} + onClick={props.onClick} + onMouseDown={props.onMouseDown} + selected={props.selected} + /> + ); +}; + +const ServiceSearchResult = (props: { + name: string; + onClick: () => void; + onMouseDown: (e: React.MouseEvent) => void; + selected?: boolean; +}) => { + return ( + {props.name}} + onClick={props.onClick} + onMouseDown={props.onMouseDown} + selected={props.selected} + /> + ); +}; + const ScopeHeader = (props: { scope: MentionScope; handleScopeChange: () => void; }) => { if (props.scope.type === "default") return; - if (props.scope.type === "publication") - return ( - + + const label = + props.scope.type === "identities" + ? "People" + : props.scope.type === "publications" + ? "Publications" + : props.scope.type === "publication" + ? `Posts from ${props.scope.name}` + : `Results from ${props.scope.name}`; + + return ( + ); }; @@ -458,15 +556,130 @@ export type Mention = avatar?: string; } | { type: "publication"; uri: string; name: string; url: string } - | { type: "post"; uri: string; title: string; url: string }; + | { type: "post"; uri: string; title: string; url: string } + | { + type: "service"; + serviceUri: string; + name: string; + description?: string; + } + | { + type: "service_result"; + uri: string; + name: string; + href?: string; + }; export type MentionScope = | { type: "default" } - | { type: "publication"; uri: string; name: string }; -function useMentionSuggestions(query: string | null) { + | { type: "identities" } + | { type: "publications" } + | { type: "publication"; uri: string; name: string } + | { type: "service"; serviceUri: string; name: string }; + +function scopePlaceholder(scope: MentionScope, fallback?: string): string { + switch (scope.type) { + case "identities": return "Search people..."; + case "publications": return "Search publications..."; + case "publication": return "Search posts..."; + case "service": return `Search ${scope.name}...`; + default: return fallback ?? "Search people & publications..."; + } +} + +function serviceScopeFromMention(service: Mention & { type: "service" }): MentionScope { + if (service.serviceUri === BUILTIN_IDENTITIES.serviceUri) return { type: "identities" }; + if (service.serviceUri === BUILTIN_PUBLICATIONS.serviceUri) return { type: "publications" }; + return { type: "service", serviceUri: service.serviceUri, name: service.name }; +} + +const BUILTIN_IDENTITIES: Mention & { type: "service" } = { + type: "service", + serviceUri: "builtin:identities", + name: "Identities", + description: "Search people on Bluesky", +}; +const BUILTIN_PUBLICATIONS: Mention & { type: "service" } = { + type: "service", + serviceUri: "builtin:publications", + name: "Publications", + description: "Search publications on Leaflet", +}; + +const bskyAgent = new Agent("https://public.api.bsky.app"); + +async function searchIdentities( + query: string, + limit: number, +): Promise { + const result = await bskyAgent.searchActorsTypeahead({ q: query, limit }); + return result.data.actors.map((actor) => ({ + type: "did" as const, + handle: actor.handle, + did: actor.did, + displayName: actor.displayName, + avatar: actor.avatar, + })); +} + +async function searchPublications( + query: string, + limit: number, +): Promise { + const publications = await callRPC(`search_publication_names`, { + query, + limit, + }); + return publications.result.publications.map((p) => ({ + type: "publication" as const, + uri: p.uri, + name: p.name, + url: p.url, + })); +} + +const EMPTY_SERVICES: Array = []; +function useMentionServices(enabled: boolean): Array { + const { data } = useSWR( + enabled ? "mention_services" : null, + async () => { + try { + const result = await callRPC(`get_user_mention_services`, {}); + return result.result.services.map( + (s: { + uri: string; + name: string; + description?: string; + endpoint_url: string; + }) => ({ + type: "service" as const, + serviceUri: s.uri, + name: s.name, + description: s.description, + }), + ); + } catch { + return EMPTY_SERVICES; + } + }, + { revalidateOnFocus: false, revalidateOnReconnect: false }, + ); + return data || EMPTY_SERVICES; +} + +function useMentionSuggestions(query: string | null, open: boolean) { const [suggestionIndex, setSuggestionIndex] = useState(0); const [suggestions, setSuggestions] = useState>([]); const [scope, setScope] = useState({ type: "default" }); + const externalServices = useMentionServices(open); + const allServices = useMemo( + () => + externalServices.length > 0 + ? [BUILTIN_IDENTITIES, BUILTIN_PUBLICATIONS, ...externalServices] + : EMPTY_SERVICES, + [externalServices], + ); + const hasServices = allServices.length > 0; // Clear suggestions immediately when scope changes const setScopeAndClear = useCallback((newScope: MentionScope) => { @@ -476,13 +689,20 @@ function useMentionSuggestions(query: string | null) { useDebouncedEffect( async () => { + if (!open) return; + if (!query && scope.type === "default") { - setSuggestions([]); + // No query: show services if available, otherwise clear + setSuggestions(hasServices ? allServices : []); return; } - if (scope.type === "publication") { - // Search within the publication's documents + if (scope.type === "identities") { + setSuggestions(await searchIdentities(query || "", 10)); + } else if (scope.type === "publications") { + setSuggestions(await searchPublications(query || "", 10)); + } else if (scope.type === "publication") { + // Search within a specific publication's documents const documents = await callRPC(`search_publication_documents`, { publication_uri: scope.uri, query: query || "", @@ -496,35 +716,45 @@ function useMentionSuggestions(query: string | null) { url: d.url, })), ); + } else if (scope.type === "service") { + // Search within a mention service + const results = await callRPC(`proxy_mention_search`, { + service_uri: scope.serviceUri, + search: query || "", + }); + setSuggestions( + results.result.results.map( + (r: { uri: string; name: string; href?: string }) => ({ + type: "service_result" as const, + uri: r.uri, + name: r.name, + href: r.href, + }), + ), + ); + } else if (hasServices) { + // Default scope with services: filter locally, fall back to identity search + const filtered = allServices.filter((s) => + s.type === "service" + ? s.name.toLowerCase().includes((query || "").toLowerCase()) + : true, + ); + if (filtered.length > 0) { + setSuggestions(filtered); + } else { + setSuggestions(await searchIdentities(query || "", 10)); + } } else { - // Default scope: search people and publications - const agent = new Agent("https://public.api.bsky.app"); - const [result, publications] = await Promise.all([ - agent.searchActorsTypeahead({ - q: query || "", - limit: 8, - }), - callRPC(`search_publication_names`, { query: query || "", limit: 8 }), - ]); - setSuggestions([ - ...result.data.actors.map((actor) => ({ - type: "did" as const, - handle: actor.handle, - did: actor.did, - displayName: actor.displayName, - avatar: actor.avatar, - })), - ...publications.result.publications.map((p) => ({ - type: "publication" as const, - uri: p.uri, - name: p.name, - url: p.url, - })), + // Default scope, no services: search people & publications together + const [identities, publications] = await Promise.all([ + searchIdentities(query || "", 8), + searchPublications(query || "", 8), ]); + setSuggestions([...identities, ...publications]); } }, 300, - [query, scope], + [query, scope, open, hasServices, allServices], ); useEffect(() => { diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 2f44c62c..237c22bc 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -281,12 +281,14 @@ export const documents = pgTable("documents", { bsky_like_count: integer("bsky_like_count").default(0).notNull(), recommend_count: integer("recommend_count").default(0).notNull(), indexed: boolean("indexed").default(false).notNull(), + identity_did: text("identity_did"), }, (table) => { return { sort_date_idx: index("documents_sort_date_idx").on(table.uri, table.sort_date), indexed_at_idx: index("documents_indexed_at_idx").on(table.indexed_at), idx_documents_ranking: index("idx_documents_ranking").on(table.uri, table.sort_date, table.bsky_like_count, table.recommend_count), + identity_did_sort_idx: index("documents_identity_did_sort_idx").on(table.uri, table.sort_date, table.identity_did), } }); diff --git a/feeds/index.ts b/feeds/index.ts index a30e229f..f7d6f497 100644 --- a/feeds/index.ts +++ b/feeds/index.ts @@ -9,6 +9,8 @@ import { } from "src/utils/normalizeRecords"; import { inngest } from "app/api/inngest/client"; import { AtUri } from "@atproto/api"; +import { wikipedia } from "../mentions/services/wikipedia"; +import { pokemon } from "../mentions/services/pokemon"; const app = new Hono(); @@ -25,9 +27,53 @@ app.get("/.well-known/did.json", (c) => { type: "BskyFeedGenerator", serviceEndpoint: `https://${domain}`, }, + { + id: "#mention_search", + type: "MentionSearchService", + serviceEndpoint: `https://${domain}`, + }, ], }); }); + +// Mention search services, keyed by rkey +const mentionServices: Record< + string, + (search: string, limit: number) => Promise<{ uri: string; name: string; href?: string }[]> +> = { + wikipedia, + pokemon, +}; + +app.get("/xrpc/parts.page.mention.searchService", async (c) => { + const serviceUri = c.req.query("service"); + const search = c.req.query("search"); + const limit = Math.min( + Math.max(parseInt(c.req.query("limit") || "20"), 1), + 50, + ); + + if (!serviceUri || !search) { + return c.json({ error: "missing required parameters: service, search" }, 400); + } + + let rkey: string; + try { + const parsed = new AtUri(serviceUri); + rkey = parsed.rkey; + } catch { + return c.json({ error: "invalid service AT URI" }, 400); + } + + const handler = mentionServices[rkey]; + if (!handler) { + return c.json({ error: `unknown service: ${rkey}` }, 404); + } + + const results = await handler(search, limit); + return c.json({ results }); +}); + //Cursor format ts::uri app.get("/xrpc/app.bsky.feed.getFeedSkeleton", async (c) => { diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index 1a8a7de0..16452077 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -2088,6 +2088,10 @@ export const schemaDict = { type: 'string', format: 'uri', }, + href: { + type: 'string', + format: 'uri', + }, }, }, code: { @@ -2152,7 +2156,7 @@ export const schemaDict = { type: 'array', items: { type: 'ref', - ref: 'lex:pub.leaflet.richtext.facet', + ref: 'lex:pub.leaflet.richtext.facet#main', }, }, }, diff --git a/lexicons/api/types/pub/leaflet/richtext/facet.ts b/lexicons/api/types/pub/leaflet/richtext/facet.ts index dc8062c1..576c574d 100644 --- a/lexicons/api/types/pub/leaflet/richtext/facet.ts +++ b/lexicons/api/types/pub/leaflet/richtext/facet.ts @@ -97,6 +97,7 @@ export function validateDidMention(v: V) { export interface AtMention { $type?: 'pub.leaflet.richtext.facet#atMention' atURI: string + href?: string } const hashAtMention = 'atMention' diff --git a/lexicons/build.ts b/lexicons/build.ts index 3f38f99e..b005bfa4 100644 --- a/lexicons/build.ts +++ b/lexicons/build.ts @@ -12,6 +12,7 @@ import { PubLeafletRichTextFacet } from "./src/facet"; import { PubLeafletComment } from "./src/comment"; import { PubLeafletAuthFullPermissions } from "./src/authFullPermissions"; import { PubLeafletContent } from "./src/content"; +import * as MentionServiceLexicons from "./src/mentionService"; const outdir = path.join("lexicons", "pub", "leaflet"); @@ -36,9 +37,23 @@ const lexicons = [ ]; // Write each lexicon to a file -lexicons.forEach((lexicon) => { +const allLexicons = [ + ...lexicons, + ...Object.values(MentionServiceLexicons), +]; +allLexicons.forEach((lexicon) => { let id = lexicon.id.split("."); - let folder = path.join(outdir, ...id.slice(2, -1)); + // Determine output base and path segments based on namespace + let baseDir: string; + let segments: string[]; + if (id[0] === "parts" && id[1] === "page") { + baseDir = path.join("lexicons", "parts", "page"); + segments = id.slice(2, -1); + } else { + baseDir = outdir; + segments = id.slice(2, -1); + } + let folder = path.join(baseDir, ...segments); if (!fs.existsSync(folder)) fs.mkdirSync(folder, { recursive: true }); const filename = path.join(folder, id[id.length - 1] + ".json"); fs.writeFileSync(filename, JSON.stringify(lexicon, null, 2)); diff --git a/lexicons/parts/page/mention/config.json b/lexicons/parts/page/mention/config.json new file mode 100644 index 00000000..1b9080fe --- /dev/null +++ b/lexicons/parts/page/mention/config.json @@ -0,0 +1,28 @@ +{ + "lexicon": 1, + "id": "parts.page.mention.config", + "defs": { + "main": { + "type": "record", + "key": "literal:self", + "description": "User's configured mention services. Singleton record per user.", + "record": { + "type": "object", + "required": [ + "services" + ], + "properties": { + "services": { + "type": "array", + "items": { + "type": "string", + "format": "at-uri" + }, + "maxLength": 50, + "description": "AT URIs of parts.page.mention.service records the user has enabled" + } + } + } + } + } +} \ No newline at end of file diff --git a/lexicons/parts/page/mention/searchService.json b/lexicons/parts/page/mention/searchService.json new file mode 100644 index 00000000..f62e562f --- /dev/null +++ b/lexicons/parts/page/mention/searchService.json @@ -0,0 +1,76 @@ +{ + "lexicon": 1, + "id": "parts.page.mention.searchService", + "defs": { + "main": { + "type": "query", + "description": "Search a mention service for matching results. A single XRPC host can serve multiple mention services, distinguished by the service AT URI.", + "parameters": { + "type": "params", + "required": [ + "service", + "search" + ], + "properties": { + "service": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the parts.page.mention.service record identifying which service to query" + }, + "search": { + "type": "string", + "description": "Search query string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 20, + "description": "Maximum number of results to return" + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "results" + ], + "properties": { + "results": { + "type": "array", + "items": { + "type": "ref", + "ref": "#result" + }, + "maxLength": 50 + } + } + } + } + }, + "result": { + "type": "object", + "required": [ + "uri", + "name" + ], + "properties": { + "uri": { + "type": "string", + "description": "Identifier for the mentioned entity" + }, + "name": { + "type": "string", + "description": "Display name for the mentioned entity" + }, + "href": { + "type": "string", + "format": "uri", + "description": "Optional web URL for the mentioned entity" + } + } + } + } +} \ No newline at end of file diff --git a/lexicons/parts/page/mention/service.json b/lexicons/parts/page/mention/service.json new file mode 100644 index 00000000..c89e28bf --- /dev/null +++ b/lexicons/parts/page/mention/service.json @@ -0,0 +1,33 @@ +{ + "lexicon": 1, + "id": "parts.page.mention.service", + "defs": { + "main": { + "type": "record", + "key": "any", + "description": "Declares a mention service. The did is an XRPC service URL that implements parts.page.mention.searchService.", + "record": { + "type": "object", + "required": [ + "name", + "did" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string", + "maxLength": 2000 + }, + "did": { + "type": "string", + "format": "did", + "description": "DID of the service that implements parts.page.mention.searchService" + } + } + } + } + } +} \ No newline at end of file diff --git a/lexicons/pub/leaflet/richtext/facet.json b/lexicons/pub/leaflet/richtext/facet.json index 4822cc67..9520868a 100644 --- a/lexicons/pub/leaflet/richtext/facet.json +++ b/lexicons/pub/leaflet/richtext/facet.json @@ -88,6 +88,10 @@ "atURI": { "type": "string", "format": "uri" + }, + "href": { + "type": "string", + "format": "uri" } } }, @@ -140,7 +144,10 @@ "footnote": { "type": "object", "description": "Facet feature for a footnote reference", - "required": ["footnoteId", "contentPlaintext"], + "required": [ + "footnoteId", + "contentPlaintext" + ], "properties": { "footnoteId": { "type": "string" diff --git a/lexicons/src/facet.ts b/lexicons/src/facet.ts index b942df88..00e19d57 100644 --- a/lexicons/src/facet.ts +++ b/lexicons/src/facet.ts @@ -19,7 +19,10 @@ const FacetItems: LexiconDoc["defs"] = { type: "object", description: "Facet feature for mentioning an AT URI.", required: ["atURI"], - properties: { atURI: { type: "string", format: "uri" } }, + properties: { + atURI: { type: "string", format: "uri" }, + href: { type: "string", format: "uri" }, + }, }, code: { type: "object", diff --git a/lexicons/src/mentionService.ts b/lexicons/src/mentionService.ts new file mode 100644 index 00000000..46466598 --- /dev/null +++ b/lexicons/src/mentionService.ts @@ -0,0 +1,122 @@ +import { LexiconDoc } from "@atproto/lexicon"; + +export const PagePartsMentionService: LexiconDoc = { + lexicon: 1, + id: "parts.page.mention.service", + defs: { + main: { + type: "record", + key: "any", + description: + "Declares a mention service. The did is an XRPC service URL that implements parts.page.mention.searchService.", + record: { + type: "object", + required: ["name", "did"], + properties: { + name: { type: "string", maxLength: 200 }, + description: { type: "string", maxLength: 2000 }, + did: { + type: "string", + format: "did", + description: + "DID of the service that implements parts.page.mention.searchService", + }, + }, + }, + }, + }, +}; + +export const PagePartsMentionSearchService: LexiconDoc = { + lexicon: 1, + id: "parts.page.mention.searchService", + defs: { + main: { + type: "query", + description: + "Search a mention service for matching results. A single XRPC host can serve multiple mention services, distinguished by the service AT URI.", + parameters: { + type: "params", + required: ["service", "search"], + properties: { + service: { + type: "string", + format: "at-uri", + description: + "AT URI of the parts.page.mention.service record identifying which service to query", + }, + search: { + type: "string", + description: "Search query string", + }, + limit: { + type: "integer", + minimum: 1, + maximum: 50, + default: 20, + description: "Maximum number of results to return", + }, + }, + }, + output: { + encoding: "application/json", + schema: { + type: "object", + required: ["results"], + properties: { + results: { + type: "array", + items: { type: "ref", ref: "#result" }, + maxLength: 50, + }, + }, + }, + }, + }, + result: { + type: "object", + required: ["uri", "name"], + properties: { + uri: { + type: "string", + description: "Identifier for the mentioned entity", + }, + name: { + type: "string", + description: "Display name for the mentioned entity", + }, + href: { + type: "string", + format: "uri", + description: "Optional web URL for the mentioned entity", + }, + }, + }, + }, +}; + +export const PagePartsMentionConfig: LexiconDoc = { + lexicon: 1, + id: "parts.page.mention.config", + defs: { + main: { + type: "record", + key: "literal:self", + description: + "User's configured mention services. Singleton record per user.", + record: { + type: "object", + required: ["services"], + properties: { + services: { + type: "array", + items: { type: "string", format: "at-uri" }, + maxLength: 50, + description: + "AT URIs of parts.page.mention.service records the user has enabled", + }, + }, + }, + }, + }, +}; diff --git a/mentions/services/pokemon.ts b/mentions/services/pokemon.ts new file mode 100644 index 00000000..960eeb67 --- /dev/null +++ b/mentions/services/pokemon.ts @@ -0,0 +1,37 @@ +type Result = { uri: string; name: string; href?: string }; + +let cachedPokemon: { name: string; url: string }[] | null = null; + +async function getAllPokemon() { + if (cachedPokemon) return cachedPokemon; + const res = await fetch("https://pokeapi.co/api/v2/pokemon?limit=1302"); + if (!res.ok) return []; + const data = (await res.json()) as { + results: { name: string; url: string }[]; + }; + cachedPokemon = data.results; + return cachedPokemon; +} + +function formatName(name: string) { + return name + .split("-") + .map((w) => w[0].toUpperCase() + w.slice(1)) + .join(" "); +} + +export async function pokemon(search: string, limit: number): Promise { + if (!search.trim()) return []; + + const allPokemon = await getAllPokemon(); + const query = search.toLowerCase(); + + return allPokemon + .filter((p) => p.name.includes(query)) + .slice(0, limit) + .map((p) => ({ + uri: `pokemon:${p.name}`, + name: formatName(p.name), + href: `https://pokemondb.net/pokedex/${p.name}`, + })); +} diff --git a/mentions/services/wikipedia.ts b/mentions/services/wikipedia.ts new file mode 100644 index 00000000..0f64e3e2 --- /dev/null +++ b/mentions/services/wikipedia.ts @@ -0,0 +1,25 @@ +type Result = { uri: string; name: string; href?: string }; + +export async function wikipedia(search: string, limit: number): Promise { + if (!search.trim()) return []; + + const url = new URL("https://en.wikipedia.org/w/api.php"); + url.searchParams.set("action", "opensearch"); + url.searchParams.set("search", search); + url.searchParams.set("limit", String(limit)); + url.searchParams.set("format", "json"); + + const res = await fetch(url.toString()); + if (!res.ok) return []; + + // OpenSearch returns [query, [titles], [descriptions], [urls]] + const data = (await res.json()) as [string, string[], string[], string[]]; + const titles = data[1] || []; + const urls = data[3] || []; + + return titles.map((title, i) => ({ + uri: urls[i] || `https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`, + name: title, + href: urls[i] || `https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`, + })); +} diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/utils/mentionUtils.ts b/src/utils/mentionUtils.ts index 8e97482f..dc72c766 100644 --- a/src/utils/mentionUtils.ts +++ b/src/utils/mentionUtils.ts @@ -4,6 +4,25 @@ import { isPublicationCollection, } from "src/utils/collectionHelpers"; +/** + * Safely classifies an AT URI string as publication, document, or unknown. + * Returns { isPublication: false, isDocument: false } for invalid/external URIs. + */ +export function classifyAtUri(atURI: string): { + isPublication: boolean; + isDocument: boolean; +} { + try { + const uri = new AtUri(atURI); + return { + isPublication: isPublicationCollection(uri.collection), + isDocument: isDocumentCollection(uri.collection), + }; + } catch { + return { isPublication: false, isDocument: false }; + } +} + /** * Converts a DID to a Bluesky profile URL */ @@ -11,6 +30,16 @@ export function didToBlueskyUrl(did: string): string { return `https://bsky.app/profile/${did}`; } +function tryAsHttpUrl(str: string): string | null { + try { + const url = new URL(str); + if (url.protocol === "http:" || url.protocol === "https:") return str; + } catch { + // Not a valid URL + } + return null; +} + /** * Converts an AT URI (publication or document) to the appropriate URL */ @@ -18,14 +47,14 @@ export function atUriToUrl(atUri: string): string { try { const uri = new AtUri(atUri); - if (isPublicationCollection(uri.collection)) { - return `/lish/uri/${encodeURIComponent(atUri)}`; - } else if (isDocumentCollection(uri.collection)) { + if (isPublicationCollection(uri.collection) || isDocumentCollection(uri.collection)) { return `/lish/uri/${encodeURIComponent(atUri)}`; } - return "#"; + return tryAsHttpUrl(atUri) ?? "#"; } catch (e) { + const httpUrl = tryAsHttpUrl(atUri); + if (httpUrl) return httpUrl; console.error("Failed to parse AT URI:", atUri, e); return "#"; } @@ -49,12 +78,6 @@ export function handleMentionClick( 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; - } + window.location.href = atUriToUrl(value); } }