a love letter to tangled (android, iOS, and a search API)
19
fork

Configure Feed

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

feat: enqueue lists of records

fix: issue links

+327 -46
+4 -1
packages/api/internal/api/readthrough.go
··· 202 202 s.enqueueRecordForIndexing(ctx, store.IndexSourceReadThrough, uri, cid, value) 203 203 } 204 204 205 - func (s *Server) enqueueXRPCList(context.Context, []xrpc.ListRecordEntry) { 205 + func (s *Server) enqueueXRPCList(ctx context.Context, entries []xrpc.ListRecordEntry) { 206 + for _, entry := range entries { 207 + s.enqueueRecordForIndexing(ctx, store.IndexSourceReadThrough, entry.URI, entry.CID, entry.Value) 208 + } 206 209 } 207 210 208 211 func (s *Server) enqueueRecordForIndexing(
+65 -3
packages/api/internal/api/readthrough_test.go
··· 67 67 } 68 68 } 69 69 70 - func TestHandleActorFollowingDoesNotEnqueueBulkList(t *testing.T) { 70 + func TestHandleActorFollowingEnqueuesRecords(t *testing.T) { 71 71 var upstream *httptest.Server 72 72 upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 73 73 switch { ··· 112 112 if rec.Code != http.StatusOK { 113 113 t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) 114 114 } 115 - if len(st.jobs) != 0 { 116 - t.Fatalf("expected no queued jobs from list handler, got %#v", st.jobs) 115 + if len(st.jobs) != 1 { 116 + t.Fatalf("expected one queued follow job, got %#v", st.jobs) 117 + } 118 + job := st.jobs["did:plc:alice|sh.tangled.graph.follow|1"] 119 + if job == nil { 120 + t.Fatalf("expected follow indexing job, got %#v", st.jobs) 121 + } 122 + } 123 + 124 + func TestHandleActorReposEnqueuesRecords(t *testing.T) { 125 + var upstream *httptest.Server 126 + upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 127 + switch { 128 + case r.URL.Path == "/xrpc/com.atproto.identity.resolveHandle": 129 + _ = json.NewEncoder(w).Encode(map[string]string{"did": "did:plc:alice"}) 130 + case r.URL.Path == "/did%3Aplc%3Aalice" || r.URL.Path == "/did:plc:alice": 131 + _ = json.NewEncoder(w).Encode(map[string]any{ 132 + "id": "did:plc:alice", 133 + "alsoKnownAs": []string{"at://alice.tangled.org"}, 134 + "service": []map[string]string{{ 135 + "type": "AtprotoPersonalDataServer", "serviceEndpoint": upstream.URL, 136 + }}, 137 + }) 138 + case r.URL.Path == "/xrpc/com.atproto.repo.listRecords": 139 + _ = json.NewEncoder(w).Encode(map[string]any{ 140 + "records": []map[string]any{{ 141 + "uri": "at://did:plc:alice/sh.tangled.repo/repo1", 142 + "cid": "cid-1", 143 + "value": map[string]any{ 144 + "$type": "sh.tangled.repo", 145 + "name": "repo1", 146 + "knot": "knot.tangled.org", 147 + }, 148 + }}, 149 + }) 150 + default: 151 + http.NotFound(w, r) 152 + } 153 + })) 154 + defer upstream.Close() 155 + 156 + client := xrpc.NewClient( 157 + xrpc.WithHTTPClient(upstream.Client()), 158 + xrpc.WithIdentityService(upstream.URL), 159 + xrpc.WithPLCDirectory(upstream.URL), 160 + ) 161 + st := newAPITestStore() 162 + srv := newAPITestServer(st, client) 163 + mux := http.NewServeMux() 164 + mux.HandleFunc("GET /actors/{handle}/repos", srv.handleListActorRepos) 165 + 166 + req := httptest.NewRequest(http.MethodGet, "/actors/alice.tangled.org/repos", nil) 167 + rec := httptest.NewRecorder() 168 + mux.ServeHTTP(rec, req) 169 + 170 + if rec.Code != http.StatusOK { 171 + t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) 172 + } 173 + if len(st.jobs) != 1 { 174 + t.Fatalf("expected one queued repo job, got %#v", st.jobs) 175 + } 176 + job := st.jobs["did:plc:alice|sh.tangled.repo|repo1"] 177 + if job == nil { 178 + t.Fatalf("expected repo indexing job, got %#v", st.jobs) 117 179 } 118 180 } 119 181
+7
packages/api/internal/enrich/enrich.go
··· 182 182 } 183 183 } 184 184 } 185 + if ownerHandle == "" { 186 + if doc.RepoDID != "" && doc.RepoDID != doc.DID { 187 + ownerHandle = doc.RepoDID 188 + } else { 189 + ownerHandle = doc.DID 190 + } 191 + } 185 192 186 193 if doc.WebURL == "" { 187 194 webURL := xrpc.BuildWebURL(ownerHandle, doc.RepoName, doc.RecordType, doc.RKey)
+7
packages/api/internal/index/enrich.go
··· 51 51 ownerHandle = info.Handle 52 52 } 53 53 } 54 + if ownerHandle == "" { 55 + if doc.RepoDID != "" && doc.RepoDID != doc.DID { 56 + ownerHandle = doc.RepoDID 57 + } else { 58 + ownerHandle = doc.DID 59 + } 60 + } 54 61 doc.WebURL = xrpc.BuildWebURL(ownerHandle, doc.RepoName, doc.RecordType, doc.RKey) 55 62 } 56 63 return nil
+104 -19
packages/api/internal/view/static/search.js
··· 11 11 loading: false, 12 12 searched: false, 13 13 error: null, 14 + toastMessage: "", 15 + toastVisible: false, 16 + toastTimer: null, 14 17 15 18 get hasMore() { 16 19 return this.searched && this.offset + this.limit < this.total; ··· 86 89 this.doSearch(false); 87 90 }, 88 91 89 - canonicalURL(r) { 90 - if (r.web_url) return r.web_url; 92 + resultMode(r) { 93 + return this.resolveResult(r).mode; 94 + }, 95 + 96 + resultURL(r) { 97 + return this.resolveResult(r).url; 98 + }, 91 99 92 - const explicitURL = this.extractTangledURL(r.body_snippet) || this.extractTangledURL(r.summary); 93 - if (explicitURL) return explicitURL; 100 + warningMessage(r) { 101 + return this.resolveResult(r).warning; 102 + }, 94 103 95 - const author = this.normalizeOwner(r.author_handle); 104 + resolveResult(r) { 105 + const parsed = this.parseATURI(r.at_uri); 106 + const author = this.normalizeOwner(r.author_handle) || this.normalizeSegment(r.did) || parsed.did; 96 107 const repoOwner = this.normalizeOwner(r.repo_owner_handle) || author; 97 108 const repoName = this.normalizeSegment(r.repo_name); 98 109 110 + if (r.record_type === "issue") { 111 + if (!r.at_uri) { 112 + return { 113 + mode: "none", 114 + url: "", 115 + warning: "This issue is missing its AT URI, so Twister cannot copy or link it yet.", 116 + }; 117 + } 118 + return { mode: "copy", url: "", warning: "" }; 119 + } 120 + 121 + if (r.record_type === "string") { 122 + const owner = author || parsed.did; 123 + const rkey = parsed.rkey; 124 + const url = r.web_url || (owner && rkey ? this.buildTangledURL("strings", owner, rkey) : ""); 125 + const warning = url ? "" : "This string is indexed from AT Protocol, but Tangled no longer has a page for it."; 126 + return { mode: url ? "link" : "none", url, warning }; 127 + } 128 + 129 + if (r.web_url) { 130 + return { mode: "link", url: r.web_url, warning: "" }; 131 + } 132 + 133 + let url = ""; 99 134 switch (r.record_type) { 100 135 case "profile": 101 - return author ? this.buildTangledURL(author) : "#"; 136 + url = author ? this.buildTangledURL(author) : ""; 137 + break; 102 138 case "repo": 103 - return repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName) : "#"; 104 - case "issue": 139 + url = repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName) : ""; 140 + break; 105 141 case "issue_comment": 106 - return repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName, "issues") : "#"; 142 + url = repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName, "issues") : ""; 143 + break; 107 144 case "pull": 108 145 case "pull_comment": 109 - return repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName, "pulls") : "#"; 110 - case "string": 111 - return author ? this.buildTangledURL(author) : "#"; 112 - default: 113 - return "#"; 146 + url = repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName, "pulls") : ""; 147 + break; 148 + } 149 + 150 + return url 151 + ? { mode: "link", url, warning: "" } 152 + : { 153 + mode: "none", 154 + url: "", 155 + warning: "This record is indexed from AT Protocol, but Tangled does not currently expose a page for it.", 156 + }; 157 + }, 158 + 159 + async copyIssueATURI(r) { 160 + if (!r.at_uri) { 161 + this.showToast("Issue AT URI is unavailable."); 162 + return; 163 + } 164 + 165 + try { 166 + await this.writeClipboard(r.at_uri); 167 + this.showToast("Issue AT URI copied."); 168 + } catch (_) { 169 + this.showToast("Could not copy the issue AT URI."); 114 170 } 115 171 }, 116 172 173 + async writeClipboard(text) { 174 + if (navigator.clipboard && window.isSecureContext) { 175 + await navigator.clipboard.writeText(text); 176 + return; 177 + } 178 + 179 + const input = document.createElement("textarea"); 180 + input.value = text; 181 + input.setAttribute("readonly", ""); 182 + input.style.position = "absolute"; 183 + input.style.left = "-9999px"; 184 + document.body.appendChild(input); 185 + input.select(); 186 + const copied = document.execCommand("copy"); 187 + document.body.removeChild(input); 188 + if (!copied) throw new Error("copy failed"); 189 + }, 190 + 191 + showToast(message) { 192 + this.toastMessage = message; 193 + this.toastVisible = true; 194 + if (this.toastTimer) window.clearTimeout(this.toastTimer); 195 + this.toastTimer = window.setTimeout(() => { 196 + this.toastVisible = false; 197 + }, 1800); 198 + }, 199 + 117 200 buildTangledURL() { 118 201 const segments = Array.from(arguments) 119 202 .filter(Boolean) ··· 129 212 return segment ? segment.trim() : ""; 130 213 }, 131 214 132 - extractTangledURL(text) { 133 - if (!text) return ""; 134 - const match = text.match(/https:\/\/tangled\.org\/[^\s<>"']+/i); 135 - if (!match) return ""; 136 - return match[0].replace(/[),.;:>]+$/, ""); 215 + parseATURI(uri) { 216 + if (!uri || !uri.startsWith("at://")) return { did: "", collection: "", rkey: "" }; 217 + const parts = uri.slice("at://".length).split("/"); 218 + const did = parts[0] || ""; 219 + const collection = parts[1] || ""; 220 + const rkey = parts.slice(2).join("/"); 221 + return { did, collection, rkey }; 137 222 }, 138 223 139 224 relTime(iso) {
+60 -1
packages/api/internal/view/static/style.css
··· 64 64 font-size: .8rem; 65 65 color: var(--text-dim); 66 66 } 67 - .footer-inner { max-width: 720px; margin: 0 auto; } 67 + 68 + .footer-inner { 69 + max-width: 720px; 70 + margin: 0 auto; 71 + display: flex; 72 + align-items: center; 73 + justify-content: space-between; 74 + flex-wrap: wrap; 75 + gap: .5rem; 76 + } 77 + 78 + .footer-right { 79 + display: flex; 80 + gap: 1rem; 81 + align-items: center; 82 + } 68 83 69 84 /* Search hero */ 70 85 .search-hero { margin-bottom: 1.5rem; } ··· 136 151 min-width: 0; 137 152 } 138 153 .card:hover { border-color: var(--accent); text-decoration: none; } 154 + .card-button { 155 + width: 100%; 156 + text-align: left; 157 + font: inherit; 158 + cursor: pointer; 159 + } 160 + .card-disabled:hover { border-color: var(--border); } 139 161 .card-head { 140 162 display: flex; 141 163 align-items: flex-start; ··· 189 211 word-break: break-word; 190 212 } 191 213 .meta-sep::before { content: "\00b7"; margin-right: .5rem; } 214 + .card-warning { 215 + margin-top: .6rem; 216 + padding: .55rem .65rem; 217 + border-radius: var(--radius); 218 + border: 1px solid #5c4b1f; 219 + background: #2a2416; 220 + } 221 + .warning-title { 222 + display: block; 223 + color: #f2c879; 224 + font-size: .78rem; 225 + line-height: 1.5; 226 + } 227 + .warning-uri { 228 + display: block; 229 + margin-top: .35rem; 230 + color: var(--text); 231 + overflow-wrap: anywhere; 232 + word-break: break-word; 233 + } 234 + .toast { 235 + position: fixed; 236 + left: 50%; 237 + bottom: 1.25rem; 238 + transform: translateX(-50%); 239 + padding: .65rem .85rem; 240 + border-radius: var(--radius); 241 + border: 1px solid var(--border); 242 + background: #111827; 243 + color: var(--text); 244 + box-shadow: 0 10px 30px rgba(0, 0, 0, .35); 245 + opacity: 0; 246 + pointer-events: none; 247 + } 248 + .toast-visible { 249 + opacity: 1; 250 + } 192 251 193 252 /* Docs */ 194 253 .main h1 { font-size: 1.4rem; margin-bottom: 1rem; font-weight: 500; }
+59 -12
packages/api/internal/view/templates/index.html
··· 39 39 <div class="msg">No results found.</div> 40 40 </template> 41 41 <template x-for="r in results" :key="r.id"> 42 - <a :href="canonicalURL(r)" target="_blank" rel="noopener" class="card"> 43 - <div class="card-head"> 44 - <span class="badge" x-text="r.record_type"></span> 45 - <span class="card-title" x-text="r.title || r.id"></span> 46 - </div> 47 - <div class="card-snippet" x-show="r.body_snippet" x-html="r.body_snippet"></div> 48 - <div class="card-meta"> 49 - <span x-show="r.author_handle" x-text="r.author_handle"></span> 50 - <span x-show="r.repo_name" class="meta-sep" x-text="r.repo_name"></span> 51 - <span x-show="r.updated_at" class="meta-sep" x-text="relTime(r.updated_at)"></span> 52 - </div> 53 - </a> 42 + <div class="result-shell"> 43 + <template x-if="resultMode(r) === 'link'"> 44 + <a :href="resultURL(r)" target="_blank" rel="noopener" class="card"> 45 + <div class="card-head"> 46 + <span class="badge" x-text="r.record_type"></span> 47 + <span class="card-title" x-text="r.title || r.id"></span> 48 + </div> 49 + <div class="card-snippet" x-show="r.body_snippet" x-html="r.body_snippet"></div> 50 + <div class="card-meta"> 51 + <span x-show="r.author_handle" x-text="r.author_handle"></span> 52 + <span x-show="r.repo_name" class="meta-sep" x-text="r.repo_name"></span> 53 + <span x-show="r.updated_at" class="meta-sep" x-text="relTime(r.updated_at)"></span> 54 + </div> 55 + <div x-show="warningMessage(r)" class="card-warning" role="note"> 56 + <strong class="warning-title" x-text="warningMessage(r)"></strong> 57 + <code x-show="r.at_uri" class="warning-uri" x-text="r.at_uri"></code> 58 + </div> 59 + </a> 60 + </template> 61 + <template x-if="resultMode(r) === 'copy'"> 62 + <button type="button" class="card card-button" @click="copyIssueATURI(r)"> 63 + <div class="card-head"> 64 + <span class="badge" x-text="r.record_type"></span> 65 + <span class="card-title" x-text="r.title || r.id"></span> 66 + </div> 67 + <div class="card-snippet" x-show="r.body_snippet" x-html="r.body_snippet"></div> 68 + <div class="card-meta"> 69 + <span x-show="r.author_handle" x-text="r.author_handle"></span> 70 + <span x-show="r.repo_name" class="meta-sep" x-text="r.repo_name"></span> 71 + <span x-show="r.updated_at" class="meta-sep" x-text="relTime(r.updated_at)"></span> 72 + </div> 73 + <div class="card-warning" role="note"> 74 + <strong class="warning-title">Click to copy the issue AT URI.</strong> 75 + <code x-show="r.at_uri" class="warning-uri" x-text="r.at_uri"></code> 76 + </div> 77 + </button> 78 + </template> 79 + <template x-if="resultMode(r) === 'none'"> 80 + <article class="card card-disabled"> 81 + <div class="card-head"> 82 + <span class="badge" x-text="r.record_type"></span> 83 + <span class="card-title" x-text="r.title || r.id"></span> 84 + </div> 85 + <div class="card-snippet" x-show="r.body_snippet" x-html="r.body_snippet"></div> 86 + <div class="card-meta"> 87 + <span x-show="r.author_handle" x-text="r.author_handle"></span> 88 + <span x-show="r.repo_name" class="meta-sep" x-text="r.repo_name"></span> 89 + <span x-show="r.updated_at" class="meta-sep" x-text="relTime(r.updated_at)"></span> 90 + </div> 91 + <div x-show="warningMessage(r)" class="card-warning" role="note"> 92 + <strong class="warning-title" x-text="warningMessage(r)"></strong> 93 + <code x-show="r.at_uri" class="warning-uri" x-text="r.at_uri"></code> 94 + </div> 95 + </article> 96 + </template> 97 + </div> 54 98 </template> 55 99 <template x-if="hasMore"> 56 100 <button class="btn btn-more" @click="loadMore()" x-text="loading ? 'Loading\u2026' : 'Load more'" :disabled="loading"></button> 57 101 </template> 58 102 </section> 103 + <div class="toast" :class="{ 'toast-visible': toastVisible }" x-show="toastVisible" x-transition.opacity.duration.150ms> 104 + <span x-text="toastMessage"></span> 105 + </div> 59 106 </div> 60 107 {{end}} 61 108 {{define "scripts"}}
+4
packages/api/internal/view/templates/layout.html
··· 26 26 <footer class="footer"> 27 27 <div class="footer-inner"> 28 28 <span>Twister &mdash; search for <a href="https://tangled.org" target="_blank" rel="noopener">Tangled</a></span> 29 + <div class="footer-right"> 30 + <span>Made with ⚡️ by <a href="https://bsky.app/profile/desertthunder.dev" target="_blank" rel="noopener">Owais</a></span> 31 + <a href="https://tangled.org/desertthunder.dev/twisted" target="_blank" rel="noopener">Source</a> 32 + </div> 29 33 </div> 30 34 </footer> 31 35 {{block "scripts" .}}{{end}}
+14 -9
packages/api/internal/xrpc/repo.go
··· 34 34 // BuildWebURL builds a canonical tangled.org URL for a record. 35 35 // recordType should be one of: "repo", "issue", "pull", "issue_comment", "pull_comment", "profile". 36 36 func BuildWebURL(ownerHandle, repoName, recordType, rkey string) string { 37 - if ownerHandle == "" { 38 - return "" 39 - } 40 37 owner := strings.TrimPrefix(ownerHandle, "@") 41 38 42 39 switch recordType { 43 40 case "profile": 41 + if owner == "" { 42 + return "" 43 + } 44 44 return fmt.Sprintf("https://tangled.org/%s", owner) 45 45 case "repo": 46 - if repoName == "" { 46 + if owner == "" || repoName == "" { 47 47 return "" 48 48 } 49 49 return fmt.Sprintf("https://tangled.org/%s/%s", owner, repoName) 50 50 case "issue": 51 - if repoName == "" || rkey == "" { 51 + if owner == "" || repoName == "" { 52 52 return "" 53 53 } 54 - return fmt.Sprintf("https://tangled.org/%s/%s/issues/%s", owner, repoName, rkey) 54 + return fmt.Sprintf("https://tangled.org/%s/%s/issues", owner, repoName) 55 55 case "pull": 56 - if repoName == "" || rkey == "" { 56 + if owner == "" || repoName == "" || rkey == "" { 57 57 return "" 58 58 } 59 59 return fmt.Sprintf("https://tangled.org/%s/%s/pulls/%s", owner, repoName, rkey) 60 60 case "issue_comment": 61 - if repoName == "" { 61 + if owner == "" || repoName == "" { 62 62 return "" 63 63 } 64 64 return fmt.Sprintf("https://tangled.org/%s/%s/issues", owner, repoName) 65 65 case "pull_comment": 66 - if repoName == "" { 66 + if owner == "" || repoName == "" { 67 67 return "" 68 68 } 69 69 return fmt.Sprintf("https://tangled.org/%s/%s/pulls", owner, repoName) 70 + case "string": 71 + if owner == "" || rkey == "" { 72 + return "" 73 + } 74 + return fmt.Sprintf("https://tangled.org/strings/%s/%s", owner, rkey) 70 75 default: 71 76 return "" 72 77 }
+3 -1
packages/api/internal/xrpc/repo_test.go
··· 8 8 want string 9 9 }{ 10 10 {"alice.test", "myrepo", "repo", "", "https://tangled.org/alice.test/myrepo"}, 11 - {"alice.test", "myrepo", "issue", "123", "https://tangled.org/alice.test/myrepo/issues/123"}, 11 + {"alice.test", "myrepo", "issue", "123", "https://tangled.org/alice.test/myrepo/issues"}, 12 12 {"alice.test", "myrepo", "pull", "456", "https://tangled.org/alice.test/myrepo/pulls/456"}, 13 13 {"alice.test", "myrepo", "issue_comment", "789", "https://tangled.org/alice.test/myrepo/issues"}, 14 14 {"alice.test", "myrepo", "pull_comment", "789", "https://tangled.org/alice.test/myrepo/pulls"}, 15 15 {"alice.test", "", "profile", "", "https://tangled.org/alice.test"}, 16 + {"alice.test", "", "string", "3jqfcqzm2lc2g", "https://tangled.org/strings/alice.test/3jqfcqzm2lc2g"}, 17 + {"did:plc:alice", "", "string", "3jqfcqzm2lc2g", "https://tangled.org/strings/did:plc:alice/3jqfcqzm2lc2g"}, 16 18 {"@alice.test", "myrepo", "repo", "", "https://tangled.org/alice.test/myrepo"}, 17 19 {"", "myrepo", "repo", "", ""}, 18 20 {"alice.test", "", "repo", "", ""},