tangled vouch map with historical data
7
fork

Configure Feed

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

feat: add better backfill and layout

+318 -17
+46 -13
cmd/backfill/main.go
··· 128 128 pdsCache := &sync.Map{} 129 129 130 130 visited := sync.Map{} 131 - didCh := make(chan string, *workers*4) 131 + didCh := make(chan string, 10000) 132 + var inFlight sync.WaitGroup 132 133 134 + // seed initial DIDs 133 135 for _, did := range cleanSeeds { 134 136 visited.Store(did, true) 137 + inFlight.Add(1) 135 138 didCh <- did 136 139 } 137 140 ··· 139 142 var totalVouches atomic.Int64 140 143 var totalFollows atomic.Int64 141 144 var totalErrors atomic.Int64 145 + var startTime = time.Now() 146 + 147 + // periodic progress ticker 148 + go func() { 149 + ticker := time.NewTicker(5 * time.Second) 150 + defer ticker.Stop() 151 + for { 152 + select { 153 + case <-ctx.Done(): 154 + return 155 + case <-ticker.C: 156 + visited := totalVisited.Load() 157 + elapsed := time.Since(startTime).Round(time.Second) 158 + rate := float64(visited) / elapsed.Seconds() 159 + slog.Info("progress", 160 + "visited", visited, 161 + "vouches", totalVouches.Load(), 162 + "follows", totalFollows.Load(), 163 + "errors", totalErrors.Load(), 164 + "queue", len(didCh), 165 + "rate", fmt.Sprintf("%.1f/s", rate), 166 + "elapsed", elapsed.String(), 167 + ) 168 + } 169 + } 170 + }() 171 + 172 + // closer: wait for all in-flight work to finish, then close the channel 173 + go func() { 174 + inFlight.Wait() 175 + close(didCh) 176 + }() 142 177 143 178 var wg sync.WaitGroup 144 179 for i := 0; i < *workers; i++ { ··· 148 183 for did := range didCh { 149 184 select { 150 185 case <-ctx.Done(): 186 + inFlight.Done() 151 187 return 152 188 default: 153 189 } 154 190 155 - n := totalVisited.Add(1) 156 - if n%20 == 0 { 157 - slog.Info("progress", 158 - "visited", n, 159 - "vouches", totalVouches.Load(), 160 - "follows", totalFollows.Load(), 161 - "errors", totalErrors.Load(), 162 - "queue", len(didCh), 163 - ) 164 - } 191 + totalVisited.Add(1) 165 192 166 193 newDIDs, err := backfillDID(ctx, store, pdsCache, did, &totalVouches, &totalFollows) 167 194 if err != nil { 168 195 totalErrors.Add(1) 169 196 slog.Warn("backfill failed", "did", did, "error", err) 197 + inFlight.Done() 170 198 continue 171 199 } 172 200 ··· 175 203 continue 176 204 } 177 205 if _, loaded := visited.LoadOrStore(newDID, true); !loaded { 206 + inFlight.Add(1) 178 207 select { 179 208 case didCh <- newDID: 180 209 case <-ctx.Done(): 181 - return 210 + inFlight.Done() 182 211 } 183 212 } 184 213 } 214 + inFlight.Done() 185 215 } 186 216 }(i) 187 217 } 188 218 189 - close(didCh) 190 219 wg.Wait() 191 220 221 + elapsed := time.Since(startTime).Round(time.Second) 222 + rate := float64(totalVisited.Load()) / elapsed.Seconds() 192 223 slog.Info("backfill complete", 193 224 "visited", totalVisited.Load(), 194 225 "vouches", totalVouches.Load(), 195 226 "follows", totalFollows.Load(), 196 227 "errors", totalErrors.Load(), 228 + "elapsed", elapsed.String(), 229 + "rate", fmt.Sprintf("%.1f/s", rate), 197 230 ) 198 231 199 232 slog.Info("enriching profiles...")
+3
internal/db/db.go
··· 255 255 AND d.did NOT IN ( 256 256 SELECT did FROM profiles WHERE handle != '' AND avatar_url != '' 257 257 ) 258 + AND d.did NOT IN ( 259 + SELECT did FROM profiles WHERE handle = '!' 260 + ) 258 261 LIMIT ? 259 262 `, limit) 260 263 if err != nil {
+51
internal/resolve/resolve.go
··· 141 141 // PLC fallback for handle only 142 142 h, err := ResolveHandle(ctx, did) 143 143 if err != nil { 144 + // tombstone: mark as unresolvable so we don't retry 145 + store.UpsertProfile(db.Profile{DID: did, Handle: "!", UpdatedAt: time.Now()}) 144 146 return fmt.Errorf("resolve handle: %w", err) 145 147 } 146 148 handle = h ··· 199 201 handle, err := ResolveHandle(ctx, did) 200 202 if err != nil { 201 203 slog.Warn("handle resolution failed", "did", did, "error", err) 204 + // tombstone: mark as unresolvable so we don't retry 205 + store.UpsertProfile(db.Profile{DID: did, Handle: "!", UpdatedAt: time.Now()}) 202 206 } else { 203 207 p.Handle = handle 204 208 } ··· 265 269 return total, nil 266 270 } 267 271 } 272 + } 273 + 274 + func ResolveDIDFromHandle(ctx context.Context, handle string) (string, error) { 275 + // 1. Try DNS TXT _atproto.<handle> 276 + req, err := http.NewRequestWithContext(ctx, "GET", 277 + fmt.Sprintf("https://dns.google/resolve?name=_atproto.%s&type=TXT", url.QueryEscape(handle)), nil) 278 + if err == nil { 279 + resp, err := httpClient.Do(req) 280 + if err == nil { 281 + defer resp.Body.Close() 282 + if resp.StatusCode == 200 { 283 + var dnsResult struct { 284 + Answer []struct { 285 + Data string `json:"data"` 286 + } `json:"Answer"` 287 + } 288 + if json.NewDecoder(resp.Body).Decode(&dnsResult) == nil { 289 + for _, a := range dnsResult.Answer { 290 + did := strings.TrimSpace(strings.Trim(a.Data, `"`)) 291 + if isValidDID(did) { 292 + return did, nil 293 + } 294 + } 295 + } 296 + } 297 + } 298 + } 299 + 300 + // 2. Try HTTPS well-known 301 + wkReq, err := http.NewRequestWithContext(ctx, "GET", 302 + fmt.Sprintf("https://%s/.well-known/atproto.json", handle), nil) 303 + if err == nil { 304 + resp, err := httpClient.Do(wkReq) 305 + if err == nil { 306 + defer resp.Body.Close() 307 + if resp.StatusCode == 200 { 308 + var wkDoc struct { 309 + DID string `json:"did"` 310 + } 311 + if json.NewDecoder(resp.Body).Decode(&wkDoc) == nil && isValidDID(wkDoc.DID) { 312 + return wkDoc.DID, nil 313 + } 314 + } 315 + } 316 + } 317 + 318 + return "", fmt.Errorf("could not resolve handle %q to DID", handle) 268 319 } 269 320 270 321 func truncate(s string, n int) string {
+59
internal/web/server.go
··· 7 7 "io" 8 8 "log/slog" 9 9 "net/http" 10 + "net/url" 10 11 "os" 12 + "strings" 11 13 "time" 12 14 13 15 "dunkirk.sh/tangle-of-trust/internal/db" ··· 62 64 63 65 func (s *Server) routes() { 64 66 s.mux.HandleFunc("/api/proxy/avatar", s.handleAvatarProxy) 67 + s.mux.HandleFunc("/api/search", s.handleSearch) 65 68 s.mux.HandleFunc("/api/graph", s.handleGraph) 66 69 s.mux.HandleFunc("/api/stats", s.handleStats) 67 70 s.mux.HandleFunc("/api/resolve", s.handleResolve) ··· 110 113 w.Header().Set("Cache-Control", "public, max-age=86400") 111 114 w.Header().Set("Access-Control-Allow-Origin", "*") 112 115 io.Copy(w, resp.Body) 116 + } 117 + 118 + func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { 119 + q := r.URL.Query().Get("q") 120 + if q == "" { 121 + writeJSON(w, map[string]interface{}{"actors": nil}) 122 + return 123 + } 124 + 125 + ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) 126 + defer cancel() 127 + 128 + // Always try Bluesky search first 129 + type SearchActor struct { 130 + DID string `json:"did"` 131 + Handle string `json:"handle"` 132 + Avatar string `json:"avatar"` 133 + } 134 + var actors []SearchActor 135 + 136 + u := fmt.Sprintf("%s/xrpc/app.bsky.actor.searchActors?q=%s&limit=6", resolve.BskyPublicAPI, url.QueryEscape(q)) 137 + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) 138 + if err == nil { 139 + resp, err := http.DefaultClient.Do(req) 140 + if err == nil && resp.StatusCode == 200 { 141 + defer resp.Body.Close() 142 + var result struct { 143 + Actors []SearchActor `json:"actors"` 144 + } 145 + if json.NewDecoder(resp.Body).Decode(&result) == nil { 146 + actors = result.Actors 147 + } 148 + } else if resp != nil { 149 + resp.Body.Close() 150 + } 151 + } 152 + 153 + // If Bluesky found nothing and query looks like a handle (contains a dot), resolve via ATProto 154 + if len(actors) == 0 && strings.Contains(q, ".") { 155 + did, err := resolve.ResolveDIDFromHandle(ctx, q) 156 + if err == nil && did != "" { 157 + // try to get avatar from Bluesky 158 + avatar := "" 159 + handle := q 160 + profiles, perr := resolve.BatchProfiles(ctx, []string{did}) 161 + if perr == nil && len(profiles) > 0 { 162 + if profiles[0].Handle != "" { 163 + handle = profiles[0].Handle 164 + } 165 + avatar = profiles[0].Avatar 166 + } 167 + actors = append(actors, SearchActor{DID: did, Handle: handle, Avatar: avatar}) 168 + } 169 + } 170 + 171 + writeJSON(w, map[string]interface{}{"actors": actors}) 113 172 } 114 173 115 174 func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) {
+159 -4
web/index.html
··· 95 95 color: var(--text-muted); 96 96 } 97 97 98 + #search-wrap { 99 + position: relative; 100 + } 101 + #search-input { 102 + font-family: 'IBM Plex Mono', ui-monospace, monospace; 103 + font-size: 0.8125rem; 104 + padding: 0.25rem 0.5rem; 105 + border: 1px solid var(--border-default); 106 + border-radius: 0.25rem; 107 + background: var(--bg-input); 108 + color: var(--text-primary); 109 + outline: none; 110 + width: 200px; 111 + box-shadow: inset 0 -2px 0 0 rgba(0,0,0,0.05); 112 + transition: border-color 0.15s, box-shadow 0.15s; 113 + } 114 + #search-input:focus { 115 + border-color: var(--border-strong); 116 + box-shadow: inset 0 -2px 0 0 rgba(0,0,0,0.1), 0 0 0 2px rgba(136,57,239,0.15); 117 + } 118 + #search-dropdown { 119 + position: absolute; 120 + top: 100%; 121 + left: 0; 122 + margin-top: 4px; 123 + background: var(--bg-card); 124 + border: 1px solid var(--border-default); 125 + border-radius: 0.25rem; 126 + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 127 + z-index: 30; 128 + display: none; 129 + min-width: 240px; 130 + max-height: 260px; 131 + overflow-y: auto; 132 + } 133 + .search-item { 134 + display: flex; 135 + align-items: center; 136 + gap: 0.5rem; 137 + padding: 0.375rem 0.5rem; 138 + cursor: pointer; 139 + transition: background 0.1s; 140 + } 141 + .search-item:hover { background: var(--bg-input); } 142 + .search-item img { 143 + width: 24px; height: 24px; border-radius: 50%; 144 + object-fit: cover; flex-shrink: 0; 145 + border: 1px solid var(--border-default); 146 + } 147 + .search-item .search-fallback { 148 + width: 24px; height: 24px; border-radius: 50%; 149 + background: var(--bg-input); border: 1px solid var(--border-default); 150 + display: flex; align-items: center; justify-content: center; 151 + font-size: 0.625rem; color: var(--text-muted); flex-shrink: 0; 152 + } 153 + .search-item .search-handle { 154 + font-size: 0.8125rem; color: var(--text-primary); font-weight: 500; 155 + } 156 + .search-item .search-did { 157 + font-size: 0.6875rem; color: var(--text-muted); 158 + } 159 + .search-item .search-info { overflow: hidden; } 160 + 98 161 #graph-container { flex: 1; min-height: 0; overflow: hidden; } 99 162 100 163 #controls { ··· 200 263 .kv { color: var(--vouch); } 201 264 .kd { color: var(--denounce); } 202 265 .kf { color: var(--follow); } 266 + .ks { color: #df8e1d; } 203 267 .action-line { display: flex; align-items: baseline; gap: 0; flex-wrap: wrap; } 204 268 .action-line .action-word { font-weight: 600; } 205 269 #tooltip .tooltip-profile { ··· 234 298 <span class="logo-text">tangled</span> 235 299 <span class="logo-badge">trust</span> 236 300 </a> 237 - <nav><span id="header-stats"></span></nav> 301 + <nav> 302 + <div id="search-wrap"> 303 + <input id="search-input" type="text" placeholder="Search handle..." autocomplete="off" spellcheck="false"> 304 + <div id="search-dropdown"></div> 305 + </div> 306 + <span id="header-stats"></span> 307 + </nav> 238 308 </header> 239 309 240 310 <div id="graph-container"></div> ··· 249 319 <button class="filter-btn active" data-kind="vouch/denounce"> 250 320 <span class="filter-dot" style="background:var(--denounce)"></span>denounce 251 321 </button> 252 - <button class="filter-btn active" data-kind="follow"> 322 + <button class="filter-btn" data-kind="follow"> 253 323 <span class="filter-dot" style="background:var(--follow)"></span>follow 254 324 </button> 255 325 <div class="spacer"></div> ··· 278 348 const edgeFilters = { 279 349 'vouch/vouch': true, 280 350 'vouch/denounce': true, 281 - 'follow': true, 351 + 'follow': false, 282 352 }; 283 353 284 354 document.querySelectorAll('.filter-btn').forEach(btn => { ··· 532 602 .onNodeClick(node => { 533 603 if (node) window.open(`https://tangled.org/${node.id}`, '_blank'); 534 604 }) 535 - .cooldownTime(2000); 605 + .cooldownTime(4000); 606 + 607 + // tune forces: moderate repulsion, longer links to spread center, distance cap prevents outliers 608 + fg.d3Force('charge').strength(-100).distanceMax(400); 609 + fg.d3Force('link').distance(80).strength(0.5); 536 610 537 611 container.addEventListener('mousemove', e => { 538 612 if (tooltip.style.display === 'block') { ··· 561 635 const avatarHtml = avatar 562 636 ? `<img class="tooltip-avatar" src="${avatar}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><div class="tooltip-avatar-fallback" style="display:none">${(handle || node.id).charAt(0).toUpperCase()}</div>` 563 637 : `<div class="tooltip-avatar-fallback">${(handle || node.id).charAt(0).toUpperCase()}</div>`; 638 + 639 + // count incoming vouches vs denounces 640 + let vouches = 0, denounces = 0; 641 + for (const link of (fg ? fg.graphData().links : [])) { 642 + const tgt = link.target.id || link.target; 643 + if (tgt === node.id) { 644 + if (link.kind === 'vouch/vouch') vouches++; 645 + else if (link.kind === 'vouch/denounce') denounces++; 646 + } 647 + } 648 + const total = vouches + denounces; 649 + let trustLine = ''; 650 + if (total > 0) { 651 + const pct = Math.round((vouches / total) * 100); 652 + const cls = pct > 90 ? 'kv' : pct > 50 ? 'ks' : 'kd'; 653 + trustLine = `<div style="margin-top:4px;font-size:0.6875rem"><span class="${cls}">${pct}%</span> trusted <span style="color:var(--text-muted)">(${vouches}v / ${denounces}d)</span></div>`; 654 + } 655 + 564 656 tooltip.innerHTML = ` 565 657 <div class="tooltip-profile"> 566 658 ${avatarHtml} 567 659 <div> 568 660 <div class="tooltip-name">${handle || shortenDID(node.id)}</div> 569 661 ${handle ? `<div class="tooltip-handle">${shortenDID(node.id)}</div>` : ''} 662 + ${trustLine} 570 663 </div> 571 664 </div> 572 665 `; ··· 611 704 document.getElementById('stat-follows').textContent = follows; 612 705 headerStats.textContent = `${data.nodes.length} nodes / ${data.links.length} edges`; 613 706 } 707 + 708 + // --- search --- 709 + const searchInput = document.getElementById('search-input'); 710 + const searchDropdown = document.getElementById('search-dropdown'); 711 + let searchTimeout = null; 712 + 713 + searchInput.addEventListener('input', () => { 714 + clearTimeout(searchTimeout); 715 + const q = searchInput.value.trim(); 716 + if (q.length < 2) { searchDropdown.style.display = 'none'; return; } 717 + searchTimeout = setTimeout(async () => { 718 + try { 719 + const resp = await fetch(`/api/search?q=${encodeURIComponent(q)}`); 720 + if (!resp.ok) return; 721 + const data = await resp.json(); 722 + const actors = data.actors || []; 723 + if (actors.length === 0) { searchDropdown.style.display = 'none'; return; } 724 + searchDropdown.innerHTML = actors.map(a => { 725 + const avatar = a.avatar 726 + ? `<img src="/api/proxy/avatar?url=${encodeURIComponent(a.avatar)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><div class="search-fallback" style="display:none">${a.handle.charAt(0).toUpperCase()}</div>` 727 + : `<div class="search-fallback">${a.handle.charAt(0).toUpperCase()}</div>`; 728 + return `<div class="search-item" data-did="${a.did}"> 729 + ${avatar} 730 + <div class="search-info"> 731 + <div class="search-handle">${a.handle}</div> 732 + <div class="search-did">${shortenDID(a.did)}</div> 733 + </div> 734 + </div>`; 735 + }).join(''); 736 + searchDropdown.style.display = 'block'; 737 + } catch {} 738 + }, 250); 739 + }); 740 + 741 + searchDropdown.addEventListener('click', e => { 742 + const item = e.target.closest('.search-item'); 743 + if (!item) return; 744 + const did = item.dataset.did; 745 + searchInput.value = ''; 746 + searchDropdown.style.display = 'none'; 747 + // center on the node if it exists in the graph 748 + if (fg) { 749 + const node = fg.graphData().nodes.find(n => n.id === did); 750 + if (node && node.x != null) { 751 + fg.centerAt(node.x, node.y, 800); 752 + fg.zoom(3, 800); 753 + } 754 + } 755 + }); 756 + 757 + document.addEventListener('click', e => { 758 + if (!document.getElementById('search-wrap').contains(e.target)) { 759 + searchDropdown.style.display = 'none'; 760 + } 761 + }); 762 + 763 + searchInput.addEventListener('keydown', e => { 764 + if (e.key === 'Escape') { 765 + searchDropdown.style.display = 'none'; 766 + searchInput.blur(); 767 + } 768 + }); 614 769 615 770 loadData(); 616 771 </script>