a tool for shared writing and social publishing

get basic mentions working

+83 -44
+4 -3
app/[leaflet_id]/publish/BskyPostEditorProsemirror.tsx
··· 435 435 const atMentionNode = schema.nodes.atMention.create({ 436 436 atURI: mention.uri, 437 437 text, 438 - ...(mention.type === "service_result" && mention.href 439 - ? { href: mention.href } 440 - : {}), 438 + ...(mention.type === "service_result" && { 439 + href: mention.href, 440 + icon: mention.icon, 441 + }), 441 442 }); 442 443 tr.insert(from, atMentionNode); 443 444 }
+1
app/api/rpc/[command]/proxy_mention_search.ts
··· 67 67 uri: String(r.uri || ""), 68 68 name: String(r.name || ""), 69 69 href: r.href ? String(r.href) : undefined, 70 + icon: r.icon ? String(r.icon) : undefined, 70 71 })), 71 72 }, 72 73 };
+18 -12
components/AtMentionLink.tsx
··· 8 8 export function AtMentionLink({ 9 9 atURI, 10 10 href, 11 + icon: iconUrl, 11 12 children, 12 13 className = "", 13 14 }: { 14 15 atURI: string; 15 16 href?: string; 17 + icon?: string; 16 18 children: React.ReactNode; 17 19 className?: string; 18 20 }) { 19 21 const { isPublication, isDocument } = classifyAtUri(atURI); 20 22 21 - // Show publication icon if available 22 - const icon = 23 - isPublication || isDocument ? ( 24 - <img 25 - src={`/api/pub_icon?at_uri=${encodeURIComponent(atURI)}`} 26 - className="inline-block w-4 h-4 rounded-full mr-1 mt-[3px] align-text-top" 27 - alt="" 28 - width="20" 29 - height="20" 30 - loading="lazy" 31 - /> 32 - ) : null; 23 + // Show publication icon, or service-provided icon 24 + const iconSrc = 25 + isPublication || isDocument 26 + ? `/api/pub_icon?at_uri=${encodeURIComponent(atURI)}` 27 + : iconUrl ?? null; 28 + 29 + const icon = iconSrc ? ( 30 + <img 31 + src={iconSrc} 32 + className="inline-block w-4 h-4 rounded-full mr-1 mt-[3px] align-text-top" 33 + alt="" 34 + width="20" 35 + height="20" 36 + loading="lazy" 37 + /> 38 + ) : null; 33 39 34 40 const linkHref = href || atUriToUrl(atURI); 35 41
+2 -1
components/Blocks/TextBlock/RenderYJSFragment.tsx
··· 114 114 const atURI = node.getAttribute("atURI") || ""; 115 115 const text = node.getAttribute("text") || ""; 116 116 const href = node.getAttribute("href") || undefined; 117 + const icon = node.getAttribute("icon") || undefined; 117 118 return ( 118 - <AtMentionLink key={index} atURI={atURI} href={href}> 119 + <AtMentionLink key={index} atURI={atURI} href={href} icon={icon}> 119 120 {text} 120 121 </AtMentionLink> 121 122 );
+13 -3
components/Blocks/TextBlock/schema.ts
··· 128 128 atURI: {}, 129 129 text: { default: "" }, 130 130 href: { default: undefined }, 131 + icon: { default: undefined }, 131 132 }, 132 133 group: "inline", 133 134 inline: true, ··· 142 143 atURI: dom.getAttribute("data-at-uri"), 143 144 text: dom.textContent || "", 144 145 href: dom.getAttribute("data-href") || undefined, 146 + icon: dom.getAttribute("data-icon") || undefined, 145 147 }; 146 148 }, 147 149 }, ··· 161 163 if (node.attrs.href) { 162 164 attrs["data-href"] = node.attrs.href; 163 165 } 166 + if (node.attrs.icon) { 167 + attrs["data-icon"] = node.attrs.icon; 168 + } 164 169 165 - // For publications and documents, show icon 166 - if (isPublication || isDocument) { 170 + // Show icon for publications/documents or service results 171 + const iconSrc = 172 + isPublication || isDocument 173 + ? `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}` 174 + : node.attrs.icon ?? null; 175 + 176 + if (iconSrc) { 167 177 return [ 168 178 "span", 169 179 attrs, 170 180 [ 171 181 "img", 172 182 { 173 - src: `/api/pub_icon?at_uri=${encodeURIComponent(node.attrs.atURI)}`, 183 + src: iconSrc, 174 184 class: 175 185 "inline-block w-4 h-4 rounded-full mt-[3px] mr-1 align-text-top", 176 186 alt: "",
+32 -24
components/Mention.tsx
··· 323 323 }} 324 324 onMouseDown={(e) => e.preventDefault()} 325 325 name={result.name} 326 + icon={result.icon} 326 327 selected={index === suggestionIndex} 327 328 /> 328 329 ) : ( ··· 507 508 508 509 const ServiceSearchResult = (props: { 509 510 name: string; 511 + icon?: string; 510 512 onClick: () => void; 511 513 onMouseDown: (e: React.MouseEvent) => void; 512 514 selected?: boolean; 513 515 }) => { 514 516 return ( 515 517 <Result 518 + icon={ 519 + props.icon ? ( 520 + <img 521 + src={props.icon} 522 + alt="" 523 + className="w-5 h-5 rounded-full shrink-0" 524 + /> 525 + ) : undefined 526 + } 516 527 result={<div className="truncate w-full">{props.name}</div>} 517 528 onClick={props.onClick} 518 529 onMouseDown={props.onMouseDown} ··· 569 580 uri: string; 570 581 name: string; 571 582 href?: string; 583 + icon?: string; 572 584 }; 573 585 574 586 export type MentionScope = ··· 690 702 691 703 useEffect(() => { 692 704 let stale = false; 693 - // Only skip debounce for purely local operations (showing service list) 694 - const delay = 695 - !query && hasServices && scope.type === "default" ? 0 : 300; 705 + // Default scope with services: show local filter instantly, debounce network fallback 706 + if (hasServices && scope.type === "default") { 707 + const filtered = allServices.filter((s) => 708 + s.type === "service" 709 + ? s.name.toLowerCase().includes((query || "").toLowerCase()) 710 + : true, 711 + ); 712 + setSuggestions(filtered); 713 + 714 + // If local filter found matches, no need for network search 715 + if (!query || filtered.length > 0) return; 716 + } 696 717 697 718 const handler = setTimeout(async () => { 698 719 if (stale || !open) return; 699 720 700 - if (!query && scope.type === "default") { 701 - // No query: show services if available, otherwise clear (sync, no race) 702 - setSuggestions(hasServices ? allServices : []); 703 - return; 704 - } 705 - 706 721 let results: Array<Mention>; 707 722 708 723 if (scope.type === "identities") { ··· 716 731 query: query || "", 717 732 limit: 10, 718 733 }); 719 - results = documents.result.documents.map((d) => ({ 734 + results = (documents?.result?.documents ?? []).map((d) => ({ 720 735 type: "post" as const, 721 736 uri: d.uri, 722 737 title: d.title, ··· 728 743 service_uri: scope.serviceUri, 729 744 search: query || "", 730 745 }); 731 - results = res.result.results.map( 732 - (r: { uri: string; name: string; href?: string }) => ({ 746 + const items = res?.result?.results ?? []; 747 + results = items.map( 748 + (r: { uri: string; name: string; href?: string; icon?: string }) => ({ 733 749 type: "service_result" as const, 734 750 uri: r.uri, 735 751 name: r.name, 736 752 href: r.href, 753 + icon: r.icon, 737 754 }), 738 755 ); 739 756 } else if (hasServices) { 740 - // Default scope with services: filter locally, fall back to identity search 741 - const filtered = allServices.filter((s) => 742 - s.type === "service" 743 - ? s.name.toLowerCase().includes((query || "").toLowerCase()) 744 - : true, 745 - ); 746 - if (filtered.length > 0) { 747 - results = filtered; 748 - } else { 749 - results = await searchIdentities(query || "", 10); 750 - } 757 + // Default scope with services: local filter showed no matches, fall back to identity search 758 + results = await searchIdentities(query || "", 10); 751 759 } else { 752 760 // Default scope, no services: search people & publications together 753 761 const [identities, publications] = await Promise.all([ ··· 760 768 if (!stale) { 761 769 setSuggestions(results); 762 770 } 763 - }, delay); 771 + }, 300); 764 772 765 773 return () => { 766 774 stale = true;
+5
lexicons/parts/page/mention/searchService.json
··· 69 69 "type": "string", 70 70 "format": "uri", 71 71 "description": "Optional web URL for the mentioned entity" 72 + }, 73 + "icon": { 74 + "type": "string", 75 + "format": "uri", 76 + "description": "Optional icon URL for the mentioned entity, displayed next to the mention" 72 77 } 73 78 } 74 79 }
+6
lexicons/src/mentionService.ts
··· 90 90 format: "uri", 91 91 description: "Optional web URL for the mentioned entity", 92 92 }, 93 + icon: { 94 + type: "string", 95 + format: "uri", 96 + description: 97 + "Optional icon URL for the mentioned entity, displayed next to the mention", 98 + }, 93 99 }, 94 100 }, 95 101 },
+2 -1
mentions/services/wikipedia.ts
··· 1 - type Result = { uri: string; name: string; href?: string }; 1 + type Result = { uri: string; name: string; href?: string; icon?: string }; 2 2 3 3 export async function wikipedia(search: string, limit: number): Promise<Result[]> { 4 4 if (!search.trim()) return []; ··· 21 21 uri: urls[i] || `https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`, 22 22 name: title, 23 23 href: urls[i] || `https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`, 24 + icon: "https://en.wikipedia.org/static/apple-touch/wikipedia.png", 24 25 })); 25 26 }