a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

more scoping stuff

Jared Pereira (Apr 1, 2026, 3:12 PM -0700) eb5ee5a9 14d5617b

+158 -13
+158 -13
components/Mention.tsx
··· 33 33 const inputRef = useRef<HTMLInputElement>(null); 34 34 const contentRef = useRef<HTMLDivElement>(null); 35 35 36 - const { suggestionIndex, setSuggestionIndex, suggestions, scope, setScope, searchComplete } = 36 + const { suggestionIndex, setSuggestionIndex, suggestions, scope, setScope, searchComplete, hasDidScopableServices } = 37 37 useMentionSuggestions(searchQuery, props.open); 38 38 39 39 const noResults = searchComplete && searchQuery !== "" && suggestions.length === 0; ··· 119 119 uri: selectedSuggestion.uri, 120 120 name: selectedSuggestion.name, 121 121 }); 122 + } else if ( 123 + selectedSuggestion?.type === "did" && 124 + hasDidScopableServices 125 + ) { 126 + // Tab on a DID result → show services that can be scoped to this DID 127 + e.preventDefault(); 128 + handleScopeChange({ 129 + type: "did_services", 130 + did: selectedSuggestion.did, 131 + name: selectedSuggestion.displayName || selectedSuggestion.handle, 132 + }); 133 + } else if ( 134 + selectedSuggestion?.type === "service" && 135 + scope.type === "did_services" 136 + ) { 137 + // Tab on a service in the DID services scope → search that service scoped to the DID 138 + e.preventDefault(); 139 + handleScopeChange({ 140 + type: "service", 141 + serviceUri: selectedSuggestion.serviceUri, 142 + name: selectedSuggestion.name, 143 + scope: scope.did, 144 + serviceName: selectedSuggestion.name, 145 + scopedDid: scope.did, 146 + scopedDidName: scope.name, 147 + }); 122 148 } else if (selectedSuggestion?.type === "service") { 123 149 e.preventDefault(); 124 150 handleScopeChange(serviceScopeFromMention(selectedSuggestion)); 151 + } else if ( 152 + selectedSuggestion?.type === "service_result" && 153 + selectedSuggestion.subscope && 154 + scope.type === "service" 155 + ) { 156 + e.preventDefault(); 157 + handleScopeChange({ 158 + type: "service", 159 + serviceUri: scope.serviceUri, 160 + name: selectedSuggestion.name, 161 + scope: selectedSuggestion.subscope.scope, 162 + serviceName: scope.serviceName ?? scope.name, 163 + }); 125 164 } 126 165 } else if (e.key === "Enter") { 127 166 e.preventDefault(); 128 167 const selectedSuggestion = sortedSuggestions[suggestionIndex]; 129 - if (selectedSuggestion?.type === "service") { 168 + if ( 169 + selectedSuggestion?.type === "service" && 170 + scope.type === "did_services" 171 + ) { 172 + handleScopeChange({ 173 + type: "service", 174 + serviceUri: selectedSuggestion.serviceUri, 175 + name: selectedSuggestion.name, 176 + scope: scope.did, 177 + serviceName: selectedSuggestion.name, 178 + scopedDid: scope.did, 179 + scopedDidName: scope.name, 180 + }); 181 + } else if (selectedSuggestion?.type === "service") { 130 182 handleScopeChange(serviceScopeFromMention(selectedSuggestion)); 131 183 } else if ( 132 184 (e.ctrlKey || e.metaKey) && ··· 166 218 scope?.type === "identities" || 167 219 scope?.type === "publications" || 168 220 scope?.type === "publication" || 169 - scope?.type === "service" 221 + scope?.type === "service" || 222 + scope?.type === "did_services" 170 223 ) { 171 224 return ( 172 225 <ScopeHeader 173 226 scope={scope} 174 227 handleScopeChange={() => { 175 - handleScopeChange({ type: "default" }); 228 + // When in a service entered from did_services, go back to did_services 229 + if (scope.type === "service" && scope.scopedDid) { 230 + handleScopeChange({ 231 + type: "did_services", 232 + did: scope.scopedDid, 233 + name: scope.scopedDidName ?? scope.scopedDid, 234 + }); 235 + // When in a subscoped service, go back to the parent service scope 236 + } else if (scope.type === "service" && scope.scope) { 237 + handleScopeChange({ 238 + type: "service", 239 + serviceUri: scope.serviceUri, 240 + name: scope.serviceName ?? scope.name, 241 + }); 242 + } else { 243 + handleScopeChange({ type: "default" }); 244 + } 176 245 }} 177 246 /> 178 247 ); ··· 283 352 handle={result.handle} 284 353 avatar={result.avatar} 285 354 selected={index === suggestionIndex} 355 + onServicesClick={ 356 + hasDidScopableServices 357 + ? () => { 358 + handleScopeChange({ 359 + type: "did_services", 360 + did: result.did, 361 + name: result.displayName || result.handle, 362 + }); 363 + } 364 + : undefined 365 + } 286 366 /> 287 367 ) : result.type === "publication" ? ( 288 368 <PublicationResult ··· 327 407 ? () => { 328 408 props.onEmbed!(result); 329 409 props.onOpenChange(false); 410 + } 411 + : undefined 412 + } 413 + subscope={result.subscope} 414 + onSubscopeClick={ 415 + result.subscope && scope.type === "service" 416 + ? () => { 417 + handleScopeChange({ 418 + type: "service", 419 + serviceUri: scope.serviceUri, 420 + name: result.name, 421 + scope: result.subscope!.scope, 422 + serviceName: scope.serviceName ?? scope.name, 423 + }); 330 424 } 331 425 : undefined 332 426 } ··· 420 514 onClick: () => void; 421 515 onMouseDown: (e: React.MouseEvent) => void; 422 516 selected?: boolean; 517 + onServicesClick?: () => void; 423 518 }) => { 519 + const nameContent = props.displayName ? props.displayName : props.handle; 424 520 return ( 425 521 <Result 426 522 icon={ ··· 434 530 <div className="w-5 h-5 rounded-full bg-border shrink-0" /> 435 531 ) 436 532 } 437 - result={props.displayName ? props.displayName : props.handle} 533 + result={ 534 + props.onServicesClick ? ( 535 + <> 536 + <div className="truncate w-full grow min-w-0">{nameContent}</div> 537 + <ScopeButton onClick={props.onServicesClick}>Search</ScopeButton> 538 + </> 539 + ) : ( 540 + nameContent 541 + ) 542 + } 438 543 subtext={props.displayName && `@${props.handle}`} 439 544 onClick={props.onClick} 440 545 onMouseDown={props.onMouseDown} ··· 517 622 icon?: string; 518 623 hasEmbed?: boolean; 519 624 onEmbedClick?: () => void; 625 + subscope?: SearchService.SubscopeInfo; 626 + onSubscopeClick?: () => void; 520 627 onClick: () => void; 521 628 onMouseDown: (e: React.MouseEvent) => void; 522 629 selected?: boolean; 523 630 }) => { 631 + const hasActions = 632 + (props.hasEmbed && props.onEmbedClick) || 633 + (props.subscope && props.onSubscopeClick); 524 634 return ( 525 635 <Result 526 636 icon={ ··· 533 643 ) : undefined 534 644 } 535 645 result={ 536 - props.hasEmbed && props.onEmbedClick ? ( 646 + hasActions ? ( 537 647 <> 538 648 <div className="truncate w-full grow min-w-0">{props.name}</div> 539 - <ScopeButton onClick={props.onEmbedClick}>Embed</ScopeButton> 649 + {props.subscope && props.onSubscopeClick && ( 650 + <ScopeButton onClick={props.onSubscopeClick}> 651 + {props.subscope.label} 652 + </ScopeButton> 653 + )} 654 + {props.hasEmbed && props.onEmbedClick && ( 655 + <ScopeButton onClick={props.onEmbedClick}>Embed</ScopeButton> 656 + )} 540 657 </> 541 658 ) : ( 542 659 <div className="truncate w-full">{props.name}</div> ··· 562 679 ? "Publications" 563 680 : props.scope.type === "publication" 564 681 ? `Posts from ${props.scope.name}` 565 - : `Results from ${props.scope.name}`; 682 + : props.scope.type === "did_services" 683 + ? `Services for ${props.scope.name}` 684 + : `Results from ${props.scope.name}`; 566 685 567 686 return ( 568 687 <button ··· 591 710 serviceUri: string; 592 711 name: string; 593 712 description?: string; 713 + canBeScopedToDid?: boolean; 594 714 } 595 715 | { 596 716 type: "service_result"; ··· 599 719 href?: string; 600 720 icon?: string; 601 721 embed?: SearchService.EmbedInfo; 722 + subscope?: SearchService.SubscopeInfo; 602 723 }; 603 724 604 725 export type MentionScope = ··· 606 727 | { type: "identities" } 607 728 | { type: "publications" } 608 729 | { type: "publication"; uri: string; name: string } 609 - | { type: "service"; serviceUri: string; name: string }; 730 + | { type: "service"; serviceUri: string; name: string; scope?: string; serviceName?: string; scopedDid?: string; scopedDidName?: string } 731 + | { type: "did_services"; did: string; name: string }; 610 732 611 733 function scopePlaceholder(scope: MentionScope, fallback?: string): string { 612 734 switch (scope.type) { ··· 614 736 case "publications": return "Search publications..."; 615 737 case "publication": return "Search posts..."; 616 738 case "service": return `Search ${scope.name}...`; 739 + case "did_services": return `Search ${scope.name} with...`; 617 740 default: return fallback ?? "Search people & publications..."; 618 741 } 619 742 } ··· 677 800 try { 678 801 const result = await callRPC(`get_user_mention_services`, {}); 679 802 return result.result.services.map( 680 - (s: { uri: string; name: string; description?: string }) => ({ 803 + (s: { uri: string; name: string; description?: string; canBeScopedToDid?: boolean }) => ({ 681 804 type: "service" as const, 682 805 serviceUri: s.uri, 683 806 name: s.name, 684 807 description: s.description, 808 + canBeScopedToDid: s.canBeScopedToDid, 685 809 }), 686 810 ); 687 811 } catch { ··· 707 831 [externalServices], 708 832 ); 709 833 const hasServices = allServices.length > 0; 834 + const didScopableServices = useMemo( 835 + () => externalServices.filter((s) => s.type === "service" && s.canBeScopedToDid), 836 + [externalServices], 837 + ); 838 + const hasDidScopableServices = didScopableServices.length > 0; 710 839 711 840 // Clear suggestions immediately when scope changes 712 841 const setScopeAndClear = useCallback((newScope: MentionScope) => { ··· 717 846 useEffect(() => { 718 847 let stale = false; 719 848 setSearchComplete(false); 849 + // DID services scope: show DID-scopable services filtered locally 850 + if (scope.type === "did_services") { 851 + const filtered = didScopableServices.filter((s) => 852 + s.type === "service" 853 + ? s.name.toLowerCase().includes((query || "").toLowerCase()) 854 + : true, 855 + ); 856 + setSuggestions(filtered); 857 + setSearchComplete(true); 858 + return; 859 + } 860 + 720 861 // Default scope with services: show local filter instantly, debounce network fallback 721 862 if (hasServices && scope.type === "default") { 722 863 const filtered = allServices.filter((s) => ··· 756 897 })); 757 898 } else if (scope.type === "service") { 758 899 // Search within a mention service 759 - if (!query) { 900 + // When not in a subscope, require a query 901 + if (!query && !scope.scope) { 760 902 if (!stale) { 761 903 setSuggestions([]); 762 904 setSearchComplete(true); ··· 765 907 } 766 908 const res = await callRPC(`proxy_mention_search`, { 767 909 service_uri: scope.serviceUri, 768 - search: query, 910 + search: query || "", 911 + ...(scope.scope ? { scope: scope.scope } : {}), 769 912 }); 770 913 const items: SearchService.Result[] = res?.result?.results ?? []; 771 914 results = items.map((r) => ({ ··· 775 918 href: r.href, 776 919 icon: r.icon, 777 920 embed: r.embed, 921 + subscope: r.subscope, 778 922 })); 779 923 } else if (hasServices) { 780 924 // Default scope with services: local filter showed no matches, fall back to identity search ··· 799 943 clearTimeout(handler); 800 944 }; 801 945 // eslint-disable-next-line react-hooks/exhaustive-deps 802 - }, [query, scope, open, hasServices, allServices]); 946 + }, [query, scope, open, hasServices, allServices, didScopableServices]); 803 947 804 948 useEffect(() => { 805 949 if (suggestionIndex > suggestions.length - 1) { ··· 814 958 scope, 815 959 setScope: setScopeAndClear, 816 960 searchComplete, 961 + hasDidScopableServices, 817 962 }; 818 963 }