+ diff --git a/packages/api/internal/api/readthrough.go b/packages/api/internal/api/readthrough.go --- a/packages/api/internal/api/readthrough.go +++ b/packages/api/internal/api/readthrough.go @@ -202,7 +202,10 @@ s.enqueueRecordForIndexing(ctx, store.IndexSourceReadThrough, uri, cid, value) } -func (s *Server) enqueueXRPCList(context.Context, []xrpc.ListRecordEntry) { +func (s *Server) enqueueXRPCList(ctx context.Context, entries []xrpc.ListRecordEntry) { + for _, entry := range entries { + s.enqueueRecordForIndexing(ctx, store.IndexSourceReadThrough, entry.URI, entry.CID, entry.Value) + } } func (s *Server) enqueueRecordForIndexing( diff --git a/packages/api/internal/api/readthrough_test.go b/packages/api/internal/api/readthrough_test.go --- a/packages/api/internal/api/readthrough_test.go +++ b/packages/api/internal/api/readthrough_test.go @@ -67,7 +67,7 @@ } } -func TestHandleActorFollowingDoesNotEnqueueBulkList(t *testing.T) { +func TestHandleActorFollowingEnqueuesRecords(t *testing.T) { var upstream *httptest.Server upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -112,8 +112,70 @@ if rec.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) } - if len(st.jobs) != 0 { - t.Fatalf("expected no queued jobs from list handler, got %#v", st.jobs) + if len(st.jobs) != 1 { + t.Fatalf("expected one queued follow job, got %#v", st.jobs) + } + job := st.jobs["did:plc:alice|sh.tangled.graph.follow|1"] + if job == nil { + t.Fatalf("expected follow indexing job, got %#v", st.jobs) + } +} + +func TestHandleActorReposEnqueuesRecords(t *testing.T) { + var upstream *httptest.Server + upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/xrpc/com.atproto.identity.resolveHandle": + _ = json.NewEncoder(w).Encode(map[string]string{"did": "did:plc:alice"}) + case r.URL.Path == "/did%3Aplc%3Aalice" || r.URL.Path == "/did:plc:alice": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "did:plc:alice", + "alsoKnownAs": []string{"at://alice.tangled.org"}, + "service": []map[string]string{{ + "type": "AtprotoPersonalDataServer", "serviceEndpoint": upstream.URL, + }}, + }) + case r.URL.Path == "/xrpc/com.atproto.repo.listRecords": + _ = json.NewEncoder(w).Encode(map[string]any{ + "records": []map[string]any{{ + "uri": "at://did:plc:alice/sh.tangled.repo/repo1", + "cid": "cid-1", + "value": map[string]any{ + "$type": "sh.tangled.repo", + "name": "repo1", + "knot": "knot.tangled.org", + }, + }}, + }) + default: + http.NotFound(w, r) + } + })) + defer upstream.Close() + + client := xrpc.NewClient( + xrpc.WithHTTPClient(upstream.Client()), + xrpc.WithIdentityService(upstream.URL), + xrpc.WithPLCDirectory(upstream.URL), + ) + st := newAPITestStore() + srv := newAPITestServer(st, client) + mux := http.NewServeMux() + mux.HandleFunc("GET /actors/{handle}/repos", srv.handleListActorRepos) + + req := httptest.NewRequest(http.MethodGet, "/actors/alice.tangled.org/repos", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) + } + if len(st.jobs) != 1 { + t.Fatalf("expected one queued repo job, got %#v", st.jobs) + } + job := st.jobs["did:plc:alice|sh.tangled.repo|repo1"] + if job == nil { + t.Fatalf("expected repo indexing job, got %#v", st.jobs) } } diff --git a/packages/api/internal/enrich/enrich.go b/packages/api/internal/enrich/enrich.go --- a/packages/api/internal/enrich/enrich.go +++ b/packages/api/internal/enrich/enrich.go @@ -182,6 +182,13 @@ } } } + if ownerHandle == "" { + if doc.RepoDID != "" && doc.RepoDID != doc.DID { + ownerHandle = doc.RepoDID + } else { + ownerHandle = doc.DID + } + } if doc.WebURL == "" { webURL := xrpc.BuildWebURL(ownerHandle, doc.RepoName, doc.RecordType, doc.RKey) diff --git a/packages/api/internal/index/enrich.go b/packages/api/internal/index/enrich.go --- a/packages/api/internal/index/enrich.go +++ b/packages/api/internal/index/enrich.go @@ -51,6 +51,13 @@ ownerHandle = info.Handle } } + if ownerHandle == "" { + if doc.RepoDID != "" && doc.RepoDID != doc.DID { + ownerHandle = doc.RepoDID + } else { + ownerHandle = doc.DID + } + } doc.WebURL = xrpc.BuildWebURL(ownerHandle, doc.RepoName, doc.RecordType, doc.RKey) } return nil diff --git a/packages/api/internal/xrpc/repo.go b/packages/api/internal/xrpc/repo.go --- a/packages/api/internal/xrpc/repo.go +++ b/packages/api/internal/xrpc/repo.go @@ -34,39 +34,44 @@ // BuildWebURL builds a canonical tangled.org URL for a record. // recordType should be one of: "repo", "issue", "pull", "issue_comment", "pull_comment", "profile". func BuildWebURL(ownerHandle, repoName, recordType, rkey string) string { - if ownerHandle == "" { - return "" - } owner := strings.TrimPrefix(ownerHandle, "@") switch recordType { case "profile": + if owner == "" { + return "" + } return fmt.Sprintf("https://tangled.org/%s", owner) case "repo": - if repoName == "" { + if owner == "" || repoName == "" { return "" } return fmt.Sprintf("https://tangled.org/%s/%s", owner, repoName) case "issue": - if repoName == "" || rkey == "" { + if owner == "" || repoName == "" { return "" } - return fmt.Sprintf("https://tangled.org/%s/%s/issues/%s", owner, repoName, rkey) + return fmt.Sprintf("https://tangled.org/%s/%s/issues", owner, repoName) case "pull": - if repoName == "" || rkey == "" { + if owner == "" || repoName == "" || rkey == "" { return "" } return fmt.Sprintf("https://tangled.org/%s/%s/pulls/%s", owner, repoName, rkey) case "issue_comment": - if repoName == "" { + if owner == "" || repoName == "" { return "" } return fmt.Sprintf("https://tangled.org/%s/%s/issues", owner, repoName) case "pull_comment": - if repoName == "" { + if owner == "" || repoName == "" { return "" } return fmt.Sprintf("https://tangled.org/%s/%s/pulls", owner, repoName) + case "string": + if owner == "" || rkey == "" { + return "" + } + return fmt.Sprintf("https://tangled.org/strings/%s/%s", owner, rkey) default: return "" } diff --git a/packages/api/internal/xrpc/repo_test.go b/packages/api/internal/xrpc/repo_test.go --- a/packages/api/internal/xrpc/repo_test.go +++ b/packages/api/internal/xrpc/repo_test.go @@ -8,11 +8,13 @@ want string }{ {"alice.test", "myrepo", "repo", "", "https://tangled.org/alice.test/myrepo"}, - {"alice.test", "myrepo", "issue", "123", "https://tangled.org/alice.test/myrepo/issues/123"}, + {"alice.test", "myrepo", "issue", "123", "https://tangled.org/alice.test/myrepo/issues"}, {"alice.test", "myrepo", "pull", "456", "https://tangled.org/alice.test/myrepo/pulls/456"}, {"alice.test", "myrepo", "issue_comment", "789", "https://tangled.org/alice.test/myrepo/issues"}, {"alice.test", "myrepo", "pull_comment", "789", "https://tangled.org/alice.test/myrepo/pulls"}, {"alice.test", "", "profile", "", "https://tangled.org/alice.test"}, + {"alice.test", "", "string", "3jqfcqzm2lc2g", "https://tangled.org/strings/alice.test/3jqfcqzm2lc2g"}, + {"did:plc:alice", "", "string", "3jqfcqzm2lc2g", "https://tangled.org/strings/did:plc:alice/3jqfcqzm2lc2g"}, {"@alice.test", "myrepo", "repo", "", "https://tangled.org/alice.test/myrepo"}, {"", "myrepo", "repo", "", ""}, {"alice.test", "", "repo", "", ""}, diff --git a/packages/api/internal/view/static/search.js b/packages/api/internal/view/static/search.js --- a/packages/api/internal/view/static/search.js +++ b/packages/api/internal/view/static/search.js @@ -11,6 +11,9 @@ loading: false, searched: false, error: null, + toastMessage: "", + toastVisible: false, + toastTimer: null, get hasMore() { return this.searched && this.offset + this.limit < this.total; @@ -86,32 +89,112 @@ this.doSearch(false); }, - canonicalURL(r) { - if (r.web_url) return r.web_url; + resultMode(r) { + return this.resolveResult(r).mode; + }, - const explicitURL = this.extractTangledURL(r.body_snippet) || this.extractTangledURL(r.summary); - if (explicitURL) return explicitURL; + resultURL(r) { + return this.resolveResult(r).url; + }, - const author = this.normalizeOwner(r.author_handle); + warningMessage(r) { + return this.resolveResult(r).warning; + }, + + resolveResult(r) { + const parsed = this.parseATURI(r.at_uri); + const author = this.normalizeOwner(r.author_handle) || this.normalizeSegment(r.did) || parsed.did; const repoOwner = this.normalizeOwner(r.repo_owner_handle) || author; const repoName = this.normalizeSegment(r.repo_name); + if (r.record_type === "issue") { + if (!r.at_uri) { + return { + mode: "none", + url: "", + warning: "This issue is missing its AT URI, so Twister cannot copy or link it yet.", + }; + } + return { mode: "copy", url: "", warning: "" }; + } + + if (r.record_type === "string") { + const owner = author || parsed.did; + const rkey = parsed.rkey; + const url = r.web_url || (owner && rkey ? this.buildTangledURL("strings", owner, rkey) : ""); + const warning = url ? "" : "This string is indexed from AT Protocol, but Tangled no longer has a page for it."; + return { mode: url ? "link" : "none", url, warning }; + } + + if (r.web_url) { + return { mode: "link", url: r.web_url, warning: "" }; + } + + let url = ""; switch (r.record_type) { case "profile": - return author ? this.buildTangledURL(author) : "#"; + url = author ? this.buildTangledURL(author) : ""; + break; case "repo": - return repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName) : "#"; - case "issue": + url = repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName) : ""; + break; case "issue_comment": - return repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName, "issues") : "#"; + url = repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName, "issues") : ""; + break; case "pull": case "pull_comment": - return repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName, "pulls") : "#"; - case "string": - return author ? this.buildTangledURL(author) : "#"; - default: - return "#"; + url = repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName, "pulls") : ""; + break; } + + return url + ? { mode: "link", url, warning: "" } + : { + mode: "none", + url: "", + warning: "This record is indexed from AT Protocol, but Tangled does not currently expose a page for it.", + }; + }, + + async copyIssueATURI(r) { + if (!r.at_uri) { + this.showToast("Issue AT URI is unavailable."); + return; + } + + try { + await this.writeClipboard(r.at_uri); + this.showToast("Issue AT URI copied."); + } catch (_) { + this.showToast("Could not copy the issue AT URI."); + } + }, + + async writeClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return; + } + + const input = document.createElement("textarea"); + input.value = text; + input.setAttribute("readonly", ""); + input.style.position = "absolute"; + input.style.left = "-9999px"; + document.body.appendChild(input); + input.select(); + const copied = document.execCommand("copy"); + document.body.removeChild(input); + if (!copied) throw new Error("copy failed"); + }, + + showToast(message) { + this.toastMessage = message; + this.toastVisible = true; + if (this.toastTimer) window.clearTimeout(this.toastTimer); + this.toastTimer = window.setTimeout(() => { + this.toastVisible = false; + }, 1800); }, buildTangledURL() { @@ -129,11 +212,13 @@ return segment ? segment.trim() : ""; }, - extractTangledURL(text) { - if (!text) return ""; - const match = text.match(/https:\/\/tangled\.org\/[^\s<>"']+/i); - if (!match) return ""; - return match[0].replace(/[),.;:>]+$/, ""); + parseATURI(uri) { + if (!uri || !uri.startsWith("at://")) return { did: "", collection: "", rkey: "" }; + const parts = uri.slice("at://".length).split("/"); + const did = parts[0] || ""; + const collection = parts[1] || ""; + const rkey = parts.slice(2).join("/"); + return { did, collection, rkey }; }, relTime(iso) { diff --git a/packages/api/internal/view/static/style.css b/packages/api/internal/view/static/style.css --- a/packages/api/internal/view/static/style.css +++ b/packages/api/internal/view/static/style.css @@ -64,7 +64,22 @@ font-size: .8rem; color: var(--text-dim); } -.footer-inner { max-width: 720px; margin: 0 auto; } + +.footer-inner { + max-width: 720px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: .5rem; +} + +.footer-right { + display: flex; + gap: 1rem; + align-items: center; +} /* Search hero */ .search-hero { margin-bottom: 1.5rem; } @@ -136,6 +151,13 @@ min-width: 0; } .card:hover { border-color: var(--accent); text-decoration: none; } +.card-button { + width: 100%; + text-align: left; + font: inherit; + cursor: pointer; +} +.card-disabled:hover { border-color: var(--border); } .card-head { display: flex; align-items: flex-start; @@ -189,6 +211,43 @@ word-break: break-word; } .meta-sep::before { content: "\00b7"; margin-right: .5rem; } +.card-warning { + margin-top: .6rem; + padding: .55rem .65rem; + border-radius: var(--radius); + border: 1px solid #5c4b1f; + background: #2a2416; +} +.warning-title { + display: block; + color: #f2c879; + font-size: .78rem; + line-height: 1.5; +} +.warning-uri { + display: block; + margin-top: .35rem; + color: var(--text); + overflow-wrap: anywhere; + word-break: break-word; +} +.toast { + position: fixed; + left: 50%; + bottom: 1.25rem; + transform: translateX(-50%); + padding: .65rem .85rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: #111827; + color: var(--text); + box-shadow: 0 10px 30px rgba(0, 0, 0, .35); + opacity: 0; + pointer-events: none; +} +.toast-visible { + opacity: 1; +} /* Docs */ .main h1 { font-size: 1.4rem; margin-bottom: 1rem; font-weight: 500; } diff --git a/packages/api/internal/view/templates/index.html b/packages/api/internal/view/templates/index.html --- a/packages/api/internal/view/templates/index.html +++ b/packages/api/internal/view/templates/index.html @@ -39,23 +39,70 @@
+
+