From 71b1daae9e5dd4b69af2927bb56fd9be54383285 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Wed, 25 Mar 2026 17:16:54 -0700 Subject: [PATCH] get basic mentions working --- .../publish/BskyPostEditorProsemirror.tsx | 7 ++- app/api/rpc/[command]/proxy_mention_search.ts | 1 + components/AtMentionLink.tsx | 30 ++++++---- .../Blocks/TextBlock/RenderYJSFragment.tsx | 3 +- components/Blocks/TextBlock/schema.ts | 16 +++++- components/Mention.tsx | 56 +++++++++++-------- .../parts/page/mention/searchService.json | 5 ++ lexicons/src/mentionService.ts | 6 ++ mentions/services/wikipedia.ts | 3 +- 9 files changed, 83 insertions(+), 44 deletions(-) diff --git a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx index 204e2dea..26a74252 100644 --- a/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx +++ b/app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx @@ -435,9 +435,10 @@ export const addMentionToEditor = ( const atMentionNode = schema.nodes.atMention.create({ atURI: mention.uri, text, - ...(mention.type === "service_result" && mention.href - ? { href: mention.href } - : {}), + ...(mention.type === "service_result" && { + href: mention.href, + icon: mention.icon, + }), }); tr.insert(from, atMentionNode); } diff --git a/app/api/rpc/[command]/proxy_mention_search.ts b/app/api/rpc/[command]/proxy_mention_search.ts index 94deba53..a0d7538b 100644 --- a/app/api/rpc/[command]/proxy_mention_search.ts +++ b/app/api/rpc/[command]/proxy_mention_search.ts @@ -67,6 +67,7 @@ export const proxy_mention_search = makeRoute({ uri: String(r.uri || ""), name: String(r.name || ""), href: r.href ? String(r.href) : undefined, + icon: r.icon ? String(r.icon) : undefined, })), }, }; diff --git a/components/AtMentionLink.tsx b/components/AtMentionLink.tsx index cf3028fe..fb23f93c 100644 --- a/components/AtMentionLink.tsx +++ b/components/AtMentionLink.tsx @@ -8,28 +8,34 @@ import { atUriToUrl, classifyAtUri } from "src/utils/mentionUtils"; export function AtMentionLink({ atURI, href, + icon: iconUrl, children, className = "", }: { atURI: string; href?: string; + icon?: string; children: React.ReactNode; className?: string; }) { const { isPublication, isDocument } = classifyAtUri(atURI); - // Show publication icon if available - const icon = - isPublication || isDocument ? ( - - ) : null; + // Show publication icon, or service-provided icon + const iconSrc = + isPublication || isDocument + ? `/api/pub_icon?at_uri=${encodeURIComponent(atURI)}` + : iconUrl ?? null; + + const icon = iconSrc ? ( + + ) : null; const linkHref = href || atUriToUrl(atURI); diff --git a/components/Blocks/TextBlock/RenderYJSFragment.tsx b/components/Blocks/TextBlock/RenderYJSFragment.tsx index 57578255..d277d17e 100644 --- a/components/Blocks/TextBlock/RenderYJSFragment.tsx +++ b/components/Blocks/TextBlock/RenderYJSFragment.tsx @@ -114,8 +114,9 @@ export function RenderYJSFragment({ const atURI = node.getAttribute("atURI") || ""; const text = node.getAttribute("text") || ""; const href = node.getAttribute("href") || undefined; + const icon = node.getAttribute("icon") || undefined; return ( - + {text} ); diff --git a/components/Blocks/TextBlock/schema.ts b/components/Blocks/TextBlock/schema.ts index a9ade1b8..853b0518 100644 --- a/components/Blocks/TextBlock/schema.ts +++ b/components/Blocks/TextBlock/schema.ts @@ -128,6 +128,7 @@ let baseSchema = { atURI: {}, text: { default: "" }, href: { default: undefined }, + icon: { default: undefined }, }, group: "inline", inline: true, @@ -142,6 +143,7 @@ let baseSchema = { atURI: dom.getAttribute("data-at-uri"), text: dom.textContent || "", href: dom.getAttribute("data-href") || undefined, + icon: dom.getAttribute("data-icon") || undefined, }; }, }, @@ -161,16 +163,24 @@ let baseSchema = { if (node.attrs.href) { attrs["data-href"] = node.attrs.href; } + if (node.attrs.icon) { + attrs["data-icon"] = node.attrs.icon; + } + + // Show icon for publications/documents or service results + const iconSrc = + isPublication || isDocument + ? `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}` + : node.attrs.icon ?? null; - // For publications and documents, show icon - if (isPublication || isDocument) { + if (iconSrc) { return [ "span", attrs, [ "img", { - src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`, + src: iconSrc, class: "inline-block w-4 h-4 rounded-full mt-[3px] mr-1 align-text-top", alt: "", diff --git a/components/Mention.tsx b/components/Mention.tsx index c22e29e3..5d97e3ff 100644 --- a/components/Mention.tsx +++ b/components/Mention.tsx @@ -323,6 +323,7 @@ export function MentionAutocomplete(props: { }} onMouseDown={(e) => e.preventDefault()} name={result.name} + icon={result.icon} selected={index === suggestionIndex} /> ) : ( @@ -507,12 +508,22 @@ const ServiceEntry = (props: { const ServiceSearchResult = (props: { name: string; + icon?: string; onClick: () => void; onMouseDown: (e: React.MouseEvent) => void; selected?: boolean; }) => { return ( + ) : undefined + } result={
{props.name}
} onClick={props.onClick} onMouseDown={props.onMouseDown} @@ -569,6 +580,7 @@ export type Mention = uri: string; name: string; href?: string; + icon?: string; }; export type MentionScope = @@ -690,19 +702,22 @@ function useMentionSuggestions(query: string | null, open: boolean) { useEffect(() => { let stale = false; - // Only skip debounce for purely local operations (showing service list) - const delay = - !query && hasServices && scope.type === "default" ? 0 : 300; + // Default scope with services: show local filter instantly, debounce network fallback + if (hasServices && scope.type === "default") { + const filtered = allServices.filter((s) => + s.type === "service" + ? s.name.toLowerCase().includes((query || "").toLowerCase()) + : true, + ); + setSuggestions(filtered); + + // If local filter found matches, no need for network search + if (!query || filtered.length > 0) return; + } const handler = setTimeout(async () => { if (stale || !open) return; - if (!query && scope.type === "default") { - // No query: show services if available, otherwise clear (sync, no race) - setSuggestions(hasServices ? allServices : []); - return; - } - let results: Array; if (scope.type === "identities") { @@ -716,7 +731,7 @@ function useMentionSuggestions(query: string | null, open: boolean) { query: query || "", limit: 10, }); - results = documents.result.documents.map((d) => ({ + results = (documents?.result?.documents ?? []).map((d) => ({ type: "post" as const, uri: d.uri, title: d.title, @@ -728,26 +743,19 @@ function useMentionSuggestions(query: string | null, open: boolean) { service_uri: scope.serviceUri, search: query || "", }); - results = res.result.results.map( - (r: { uri: string; name: string; href?: string }) => ({ + const items = res?.result?.results ?? []; + results = items.map( + (r: { uri: string; name: string; href?: string; icon?: string }) => ({ type: "service_result" as const, uri: r.uri, name: r.name, href: r.href, + icon: r.icon, }), ); } 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) { - results = filtered; - } else { - results = await searchIdentities(query || "", 10); - } + // Default scope with services: local filter showed no matches, fall back to identity search + results = await searchIdentities(query || "", 10); } else { // Default scope, no services: search people & publications together const [identities, publications] = await Promise.all([ @@ -760,7 +768,7 @@ function useMentionSuggestions(query: string | null, open: boolean) { if (!stale) { setSuggestions(results); } - }, delay); + }, 300); return () => { stale = true; diff --git a/lexicons/parts/page/mention/searchService.json b/lexicons/parts/page/mention/searchService.json index f62e562f..2779701b 100644 --- a/lexicons/parts/page/mention/searchService.json +++ b/lexicons/parts/page/mention/searchService.json @@ -69,6 +69,11 @@ "type": "string", "format": "uri", "description": "Optional web URL for the mentioned entity" + }, + "icon": { + "type": "string", + "format": "uri", + "description": "Optional icon URL for the mentioned entity, displayed next to the mention" } } } diff --git a/lexicons/src/mentionService.ts b/lexicons/src/mentionService.ts index 46466598..6069280d 100644 --- a/lexicons/src/mentionService.ts +++ b/lexicons/src/mentionService.ts @@ -90,6 +90,12 @@ export const PagePartsMentionSearchService: LexiconDoc = { format: "uri", description: "Optional web URL for the mentioned entity", }, + icon: { + type: "string", + format: "uri", + description: + "Optional icon URL for the mentioned entity, displayed next to the mention", + }, }, }, }, diff --git a/mentions/services/wikipedia.ts b/mentions/services/wikipedia.ts index 0f64e3e2..92f1e177 100644 --- a/mentions/services/wikipedia.ts +++ b/mentions/services/wikipedia.ts @@ -1,4 +1,4 @@ -type Result = { uri: string; name: string; href?: string }; +type Result = { uri: string; name: string; href?: string; icon?: string }; export async function wikipedia(search: string, limit: number): Promise { if (!search.trim()) return []; @@ -21,5 +21,6 @@ export async function wikipedia(search: string, limit: number): Promise