dev vouch dev on at. thats about it atvouch.dev
8
fork

Configure Feed

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

frontend: let users create membership records

authored by

Luna and committed by tangled.org 31afa328 569bf85b

+421
+308
frontend/src/App.tsx
··· 343 343 </main> 344 344 <RemoteVouchesSection agent={agent} myVouches={vouches} /> 345 345 </div> 346 + <BotRepositoriesSection agent={agent} /> 346 347 </div> 347 348 ); 348 349 } ··· 565 566 </> 566 567 )} 567 568 </aside> 569 + ); 570 + } 571 + 572 + function BotRepositoriesSection({ agent }: { agent: OAuthUserAgent }) { 573 + const [repos, setRepos] = useState<TangledRepo[]>([]); 574 + const [memberships, setMemberships] = useState<BotMembership[]>([]); 575 + const [loading, setLoading] = useState(true); 576 + const [error, setError] = useState<string | null>(null); 577 + const [adding, setAdding] = useState<string | null>(null); 578 + const [removing, setRemoving] = useState<string | null>(null); 579 + // maintainer handle map: did -> { handle, avatar } 580 + const [maintainerInfo, setMaintainerInfo] = useState<Record<string, { handle: string; avatar?: string }>>({}); 581 + 582 + const refresh = useCallback(async () => { 583 + setLoading(true); 584 + setError(null); 585 + try { 586 + const [r, m] = await Promise.all([ 587 + listTangledRepos(agent), 588 + listBotMemberships(agent), 589 + ]); 590 + setRepos(r); 591 + setMemberships(m); 592 + 593 + // Resolve all unique maintainer DIDs to handles 594 + const allDids = new Set(m.flatMap((mem) => mem.maintainers)); 595 + const info: Record<string, { handle: string; avatar?: string }> = {}; 596 + await Promise.all( 597 + [...allDids].map(async (did) => { 598 + try { 599 + const handle = await resolveDidToHandle(did); 600 + // Try to get avatar from bsky public API 601 + let avatar: string | undefined; 602 + try { 603 + const resp = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`); 604 + if (resp.ok) { 605 + const profile = await resp.json(); 606 + avatar = profile.avatar; 607 + } 608 + } catch { 609 + // no avatar, that's fine 610 + } 611 + info[did] = { handle, avatar }; 612 + } catch { 613 + info[did] = { handle: did }; 614 + } 615 + }), 616 + ); 617 + setMaintainerInfo(info); 618 + } catch (err) { 619 + setError(String(err)); 620 + } 621 + setLoading(false); 622 + }, [agent]); 623 + 624 + useEffect(() => { 625 + refresh(); 626 + }, [refresh]); 627 + 628 + const linkedRkeys = new Set(memberships.map((m) => m.rkey)); 629 + 630 + const handleAdd = async (repo: TangledRepo) => { 631 + setAdding(repo.rkey); 632 + setError(null); 633 + try { 634 + await createBotMembership(agent, repo.uri, repo.rkey); 635 + await refresh(); 636 + } catch (err) { 637 + setError(String(err)); 638 + } 639 + setAdding(null); 640 + }; 641 + 642 + const handleRemove = async (membership: BotMembership) => { 643 + setRemoving(membership.rkey); 644 + setError(null); 645 + try { 646 + await deleteBotMembership(agent, membership.rkey); 647 + await refresh(); 648 + } catch (err) { 649 + setError(String(err)); 650 + } 651 + setRemoving(null); 652 + }; 653 + 654 + const handleAddMaintainer = async (membership: BotMembership, handle: string) => { 655 + setError(null); 656 + try { 657 + const did = await resolveHandle(handle); 658 + if (membership.maintainers.includes(did)) { 659 + setError(`${handle} is already a maintainer`); 660 + return; 661 + } 662 + await updateBotMembership( 663 + agent, 664 + membership.rkey, 665 + membership.repo, 666 + [...membership.maintainers, did], 667 + ); 668 + await refresh(); 669 + } catch (err) { 670 + setError(String(err)); 671 + } 672 + }; 673 + 674 + const handleRemoveMaintainer = async (membership: BotMembership, did: string) => { 675 + setError(null); 676 + try { 677 + const updated = membership.maintainers.filter((d) => d !== did); 678 + if (updated.length === 0) { 679 + setError("Cannot remove the last maintainer"); 680 + return; 681 + } 682 + await updateBotMembership(agent, membership.rkey, membership.repo, updated); 683 + await refresh(); 684 + } catch (err) { 685 + setError(String(err)); 686 + } 687 + }; 688 + 689 + return ( 690 + <section className="bot-repos-section"> 691 + <h2>Repositories linked to atvouch bot</h2> 692 + <p className="bot-repos-desc"> 693 + the bot scrapes tangled repositories for the repos you add here 694 + </p> 695 + {error && <div className="error">{error}</div>} 696 + {loading ? ( 697 + <p className="muted">Loading...</p> 698 + ) : repos.length === 0 ? ( 699 + <p className="muted">No tangled repositories found in your account.</p> 700 + ) : ( 701 + <ul className="bot-repos-list"> 702 + {repos.map((repo) => { 703 + const isLinked = linkedRkeys.has(repo.rkey); 704 + const membership = memberships.find((m) => m.rkey === repo.rkey); 705 + return ( 706 + <li key={repo.rkey} className="bot-repo-item"> 707 + <div className="bot-repo-header"> 708 + <div className="bot-repo-info"> 709 + <span className="bot-repo-name">{repo.name}</span> 710 + {repo.description && ( 711 + <span className="bot-repo-desc">{repo.description}</span> 712 + )} 713 + </div> 714 + {isLinked ? ( 715 + <button 716 + className="bot-repo-btn bot-repo-btn-remove" 717 + disabled={removing === repo.rkey} 718 + onClick={() => membership && handleRemove(membership)} 719 + > 720 + {removing === repo.rkey ? "..." : "REMOVE"} 721 + </button> 722 + ) : ( 723 + <button 724 + className="bot-repo-btn bot-repo-btn-add" 725 + disabled={adding === repo.rkey} 726 + onClick={() => handleAdd(repo)} 727 + > 728 + {adding === repo.rkey ? "..." : "ADD"} 729 + </button> 730 + )} 731 + </div> 732 + {isLinked && membership && ( 733 + <MaintainersList 734 + membership={membership} 735 + maintainerInfo={maintainerInfo} 736 + onAddMaintainer={(handle) => handleAddMaintainer(membership, handle)} 737 + onRemoveMaintainer={(did) => handleRemoveMaintainer(membership, did)} 738 + /> 739 + )} 740 + </li> 741 + ); 742 + })} 743 + </ul> 744 + )} 745 + </section> 746 + ); 747 + } 748 + 749 + function MaintainersList({ 750 + membership, 751 + maintainerInfo, 752 + onAddMaintainer, 753 + onRemoveMaintainer, 754 + }: { 755 + membership: BotMembership; 756 + maintainerInfo: Record<string, { handle: string; avatar?: string }>; 757 + onAddMaintainer: (handle: string) => void; 758 + onRemoveMaintainer: (did: string) => void; 759 + }) { 760 + const [showInput, setShowInput] = useState(false); 761 + const [inputValue, setInputValue] = useState(""); 762 + const [submitting, setSubmitting] = useState(false); 763 + const [suggestions, setSuggestions] = useState<{ did: string; handle: string; avatar?: string }[]>([]); 764 + const [activeIdx, setActiveIdx] = useState(-1); 765 + 766 + useEffect(() => { 767 + if (inputValue.length < 2) { 768 + setSuggestions([]); 769 + return; 770 + } 771 + const timer = setTimeout(async () => { 772 + const results = await searchActorsTypeahead(inputValue); 773 + setSuggestions(results.map((a) => ({ did: a.did, handle: a.handle, avatar: a.avatar }))); 774 + setActiveIdx(-1); 775 + }, 200); 776 + return () => clearTimeout(timer); 777 + }, [inputValue]); 778 + 779 + const submit = async (handle: string) => { 780 + if (!handle.trim()) return; 781 + setSubmitting(true); 782 + await onAddMaintainer(handle.trim()); 783 + setSubmitting(false); 784 + setInputValue(""); 785 + setShowInput(false); 786 + setSuggestions([]); 787 + }; 788 + 789 + return ( 790 + <div className="maintainers"> 791 + <span className="maintainers-label">Maintainers:</span> 792 + <div className="maintainers-list"> 793 + {membership.maintainers.map((did) => { 794 + const info = maintainerInfo[did]; 795 + return ( 796 + <span key={did} className="maintainer-chip"> 797 + {info?.avatar && ( 798 + <img className="maintainer-avatar" src={info.avatar} alt="" /> 799 + )} 800 + <a 801 + href={`https://bsky.app/profile/${info?.handle ?? did}`} 802 + target="_blank" 803 + rel="noopener noreferrer" 804 + > 805 + {info?.handle ?? did} 806 + </a> 807 + {membership.maintainers.length > 1 && ( 808 + <button 809 + className="maintainer-remove" 810 + title="Remove maintainer" 811 + onClick={() => onRemoveMaintainer(did)} 812 + > 813 + x 814 + </button> 815 + )} 816 + </span> 817 + ); 818 + })} 819 + {showInput ? ( 820 + <span className="maintainer-input-wrapper"> 821 + <input 822 + type="text" 823 + className="maintainer-input" 824 + placeholder="handle" 825 + value={inputValue} 826 + onChange={(e) => setInputValue(e.target.value.replace("@", ""))} 827 + disabled={submitting} 828 + autoFocus 829 + onKeyDown={(e) => { 830 + if (e.key === "Escape") { 831 + setShowInput(false); 832 + setInputValue(""); 833 + setSuggestions([]); 834 + } else if (e.key === "Enter") { 835 + e.preventDefault(); 836 + if (activeIdx >= 0 && suggestions[activeIdx]) { 837 + submit(suggestions[activeIdx].handle); 838 + } else { 839 + submit(inputValue); 840 + } 841 + } else if (e.key === "ArrowDown") { 842 + e.preventDefault(); 843 + setActiveIdx((i) => Math.min(i + 1, suggestions.length - 1)); 844 + } else if (e.key === "ArrowUp") { 845 + e.preventDefault(); 846 + setActiveIdx((i) => Math.max(i - 1, 0)); 847 + } 848 + }} 849 + /> 850 + {suggestions.length > 0 && ( 851 + <ul className="maintainer-dropdown"> 852 + {suggestions.map((s, i) => ( 853 + <li 854 + key={s.did} 855 + className={i === activeIdx ? "maintainer-dd-active" : ""} 856 + onMouseDown={() => submit(s.handle)} 857 + > 858 + {s.avatar && <img className="maintainer-dd-avatar" src={s.avatar} alt="" />} 859 + <span>{s.handle}</span> 860 + </li> 861 + ))} 862 + </ul> 863 + )} 864 + </span> 865 + ) : ( 866 + <button 867 + className="maintainer-add-btn" 868 + title="Add maintainer" 869 + onClick={() => setShowInput(true)} 870 + > 871 + + 872 + </button> 873 + )} 874 + </div> 875 + </div> 568 876 ); 569 877 } 570 878
+113
frontend/src/api.ts
··· 221 221 routes: { path: string[] }[]; 222 222 } 223 223 224 + // --- Tangled repo & bot membership --- 225 + 226 + export interface TangledRepo { 227 + uri: string; 228 + rkey: string; 229 + name: string; 230 + description?: string; 231 + } 232 + 233 + export async function listTangledRepos(agent: OAuthUserAgent): Promise<TangledRepo[]> { 234 + const rpc = new XRPC({ handler: agent }); 235 + const all: TangledRepo[] = []; 236 + let cursor: string | undefined; 237 + do { 238 + const params: Record<string, unknown> = { 239 + repo: agent.sub, 240 + collection: "sh.tangled.repo", 241 + limit: 100, 242 + }; 243 + if (cursor) params.cursor = cursor; 244 + const resp = await rpc.get("com.atproto.repo.listRecords", { params } as any); 245 + const data = resp.data as unknown as { records: { uri: string; value: { name: string; description?: string } }[]; cursor?: string }; 246 + for (const rec of data.records) { 247 + const rkey = rec.uri.split("/").pop()!; 248 + all.push({ uri: rec.uri, rkey, name: rec.value.name, description: rec.value.description }); 249 + } 250 + cursor = data.cursor; 251 + } while (cursor); 252 + return all; 253 + } 254 + 255 + export interface BotMembership { 256 + uri: string; 257 + rkey: string; 258 + repo: string; 259 + maintainers: string[]; 260 + } 261 + 262 + export async function listBotMemberships(agent: OAuthUserAgent): Promise<BotMembership[]> { 263 + const rpc = new XRPC({ handler: agent }); 264 + const all: BotMembership[] = []; 265 + let cursor: string | undefined; 266 + do { 267 + const params: Record<string, unknown> = { 268 + repo: agent.sub, 269 + collection: "dev.atvouch.bot.membership", 270 + limit: 100, 271 + }; 272 + if (cursor) params.cursor = cursor; 273 + const resp = await rpc.get("com.atproto.repo.listRecords", { params } as any); 274 + const data = resp.data as unknown as { records: { uri: string; value: { repo: string; maintainers: string[] } }[]; cursor?: string }; 275 + for (const rec of data.records) { 276 + const rkey = rec.uri.split("/").pop()!; 277 + all.push({ uri: rec.uri, rkey, repo: rec.value.repo, maintainers: rec.value.maintainers }); 278 + } 279 + cursor = data.cursor; 280 + } while (cursor); 281 + return all; 282 + } 283 + 284 + export async function createBotMembership( 285 + agent: OAuthUserAgent, 286 + repoAtUri: string, 287 + rkey: string, 288 + ): Promise<{ uri: string }> { 289 + const rpc = new XRPC({ handler: agent }); 290 + const resp = await rpc.call("com.atproto.repo.createRecord", { 291 + data: { 292 + repo: agent.sub, 293 + collection: "dev.atvouch.bot.membership", 294 + rkey, 295 + record: { 296 + $type: "dev.atvouch.bot.membership", 297 + repo: repoAtUri, 298 + maintainers: [agent.sub], 299 + }, 300 + }, 301 + }); 302 + return { uri: (resp.data as unknown as { uri: string }).uri }; 303 + } 304 + 305 + export async function deleteBotMembership(agent: OAuthUserAgent, rkey: string): Promise<void> { 306 + const rpc = new XRPC({ handler: agent }); 307 + await rpc.call("com.atproto.repo.deleteRecord", { 308 + data: { 309 + repo: agent.sub, 310 + collection: "dev.atvouch.bot.membership", 311 + rkey, 312 + }, 313 + }); 314 + } 315 + 316 + export async function updateBotMembership( 317 + agent: OAuthUserAgent, 318 + rkey: string, 319 + repoAtUri: string, 320 + maintainers: string[], 321 + ): Promise<void> { 322 + const rpc = new XRPC({ handler: agent }); 323 + await rpc.call("com.atproto.repo.putRecord", { 324 + data: { 325 + repo: agent.sub, 326 + collection: "dev.atvouch.bot.membership", 327 + rkey, 328 + record: { 329 + $type: "dev.atvouch.bot.membership", 330 + repo: repoAtUri, 331 + maintainers, 332 + }, 333 + }, 334 + }); 335 + } 336 + 224 337 export async function checkVouchPaths(agent: OAuthUserAgent, handle: string): Promise<CheckResult> { 225 338 const targetDID = await resolveHandle(handle); 226 339