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: relax field handling

* polish UI

+281 -44
+56
README.md
··· 30 30 31 31 To enable indexed search in the client, set `VITE_TWISTER_API_BASE_URL` in `apps/twisted/.env`. 32 32 33 + ## Run Locally 34 + 35 + Install dependencies once from the repo root: 36 + 37 + ```bash 38 + pnpm install 39 + ``` 40 + 41 + Start the Ionic/Vite app: 42 + 43 + ```bash 44 + pnpm dev 45 + # or: just dev 46 + ``` 47 + 48 + That serves the client from `apps/twisted` with Vite. 49 + 50 + To run the Go API locally, make sure `packages/api/.env` has at least: 51 + 52 + - `TURSO_DATABASE_URL` 53 + - `TURSO_AUTH_TOKEN` 54 + 55 + Then start the API: 56 + 57 + ```bash 58 + pnpm api:run:api 59 + # or: just api-dev 60 + ``` 61 + 62 + This serves the API and search site on `http://localhost:8080`. 63 + 64 + To run the indexer as well, `packages/api/.env` also needs: 65 + 66 + - `TAP_URL` 67 + - `TAP_AUTH_PASSWORD` 68 + - `INDEXED_COLLECTIONS` 69 + 70 + Then start the indexer in a separate terminal: 71 + 72 + ```bash 73 + pnpm api:run:indexer 74 + # or: just api-run-indexer 75 + ``` 76 + 77 + Typical local setup is three terminals: 78 + 79 + 1. `pnpm dev` 80 + 2. `pnpm api:run:api` 81 + 3. `pnpm api:run:indexer` 82 + 83 + If you want the app to call the local API, set this in `apps/twisted/.env`: 84 + 85 + ```bash 86 + VITE_TWISTER_API_BASE_URL=http://localhost:8080 87 + ``` 88 + 33 89 ## Infrastructure Setup 34 90 35 91 ### Turso
+9
packages/api/internal/normalize/normalize.go
··· 108 108 return "" 109 109 } 110 110 111 + func firstString(m map[string]any, keys ...string) string { 112 + for _, key := range keys { 113 + if v := str(m, key); v != "" { 114 + return v 115 + } 116 + } 117 + return "" 118 + } 119 + 111 120 // nestedMap safely extracts a nested map[string]any from a map. 112 121 func nestedMap(m map[string]any, key string) map[string]any { 113 122 if v, ok := m[key]; ok {
+19 -1
packages/api/internal/normalize/normalize_test.go
··· 394 394 t.Run("missing status", func(t *testing.T) { 395 395 event := normalize.TapRecordEvent{ 396 396 Record: &normalize.TapRecord{ 397 - Record: map[string]any{"subject": "at://did:plc:x/col/rkey"}, 397 + Record: map[string]any{"issue": "at://did:plc:x/col/rkey"}, 398 398 }, 399 399 } 400 400 _, err := handler.HandleState(event) 401 401 if err == nil { 402 402 t.Error("expected error for missing status") 403 + } 404 + }) 405 + 406 + t.Run("legacy field names still work", func(t *testing.T) { 407 + event := normalize.TapRecordEvent{ 408 + Record: &normalize.TapRecord{ 409 + Record: map[string]any{ 410 + "subject": "at://did:plc:x/col/rkey", 411 + "status": "closed", 412 + }, 413 + }, 414 + } 415 + update, err := handler.HandleState(event) 416 + if err != nil { 417 + t.Fatalf("HandleState legacy: %v", err) 418 + } 419 + if update.SubjectURI != "at://did:plc:x/col/rkey" || update.State != "closed" { 420 + t.Fatalf("legacy update = %#v", update) 403 421 } 404 422 }) 405 423 }
+7 -7
packages/api/internal/normalize/state.go
··· 16 16 r := event.Record 17 17 rec := r.Record 18 18 19 - subject := str(rec, "subject") 19 + subject := firstString(rec, "issue", "subject") 20 20 if subject == "" { 21 - return nil, fmt.Errorf("issue state record missing subject field") 21 + return nil, fmt.Errorf("issue state record missing issue field") 22 22 } 23 - state := str(rec, "status") 23 + state := firstString(rec, "state", "status") 24 24 if state == "" { 25 - return nil, fmt.Errorf("issue state record missing status field") 25 + return nil, fmt.Errorf("issue state record missing state field") 26 26 } 27 27 28 28 return &StateUpdate{ ··· 40 40 r := event.Record 41 41 rec := r.Record 42 42 43 - subject := str(rec, "subject") 43 + subject := firstString(rec, "pull", "subject") 44 44 if subject == "" { 45 - return nil, fmt.Errorf("pull status record missing subject field") 45 + return nil, fmt.Errorf("pull status record missing pull field") 46 46 } 47 - status := str(rec, "status") 47 + status := firstString(rec, "status", "state") 48 48 if status == "" { 49 49 return nil, fmt.Errorf("pull status record missing status field") 50 50 }
+2 -2
packages/api/internal/normalize/testdata/issue_state.json
··· 11 11 "cid": "bafyreigabc128", 12 12 "record": { 13 13 "$type": "sh.tangled.repo.issue.state", 14 - "subject": "at://did:plc:abc123/sh.tangled.repo.issue/3kb3fge5lm32y", 15 - "status": "closed" 14 + "issue": "at://did:plc:abc123/sh.tangled.repo.issue/3kb3fge5lm32y", 15 + "state": "closed" 16 16 } 17 17 } 18 18 }
+1 -1
packages/api/internal/normalize/testdata/pull_status.json
··· 11 11 "cid": "bafyreigabc129", 12 12 "record": { 13 13 "$type": "sh.tangled.repo.pull.status", 14 - "subject": "at://did:plc:abc123/sh.tangled.repo.pull/3kb3fge5lm32z", 14 + "pull": "at://did:plc:abc123/sh.tangled.repo.pull/3kb3fge5lm32z", 15 15 "status": "merged" 16 16 } 17 17 }
+6 -3
packages/api/internal/search/search.go
··· 31 31 BodySnippet string `json:"body_snippet,omitempty"` 32 32 Summary string `json:"summary,omitempty"` 33 33 RepoName string `json:"repo_name,omitempty"` 34 + RepoOwnerHandle string `json:"repo_owner_handle,omitempty"` 34 35 AuthorHandle string `json:"author_handle,omitempty"` 35 36 DID string `json:"did"` 36 37 ATURI string `json:"at_uri"` ··· 126 127 127 128 // Fetch results with score and snippet. 128 129 resultsSQL := fmt.Sprintf(` 129 - SELECT d.id, d.title, d.summary, d.repo_name, d.author_handle, 130 + SELECT d.id, d.title, d.summary, d.repo_name, repo_owner.handle, d.author_handle, 130 131 d.did, d.at_uri, d.collection, d.record_type, d.created_at, d.updated_at, 131 132 -bm25(documents_fts, 0.0, 3.0, 1.0, 1.5, 2.5, 2.0, 1.2) AS score, 132 133 snippet(documents_fts, 2, '<mark>', '</mark>', '...', 20) AS body_snippet 133 134 FROM documents_fts 134 135 JOIN documents d ON d.id = documents_fts.id 136 + LEFT JOIN identity_handles repo_owner ON repo_owner.did = d.repo_did AND repo_owner.is_active = 1 135 137 %s 136 138 WHERE %s 137 139 ORDER BY score DESC ··· 151 153 results := make([]Result, 0) 152 154 for rows.Next() { 153 155 var res Result 154 - var title, summary, repoName, authorHandle sql.NullString 156 + var title, summary, repoName, repoOwnerHandle, authorHandle sql.NullString 155 157 var createdAt, updatedAt sql.NullString 156 158 var bodySnippet sql.NullString 157 159 158 160 if err := rows.Scan( 159 - &res.ID, &title, &summary, &repoName, &authorHandle, 161 + &res.ID, &title, &summary, &repoName, &repoOwnerHandle, &authorHandle, 160 162 &res.DID, &res.ATURI, &res.Collection, &res.RecordType, 161 163 &createdAt, &updatedAt, &res.Score, &bodySnippet, 162 164 ); err != nil { ··· 165 167 res.Title = title.String 166 168 res.Summary = summary.String 167 169 res.RepoName = repoName.String 170 + res.RepoOwnerHandle = repoOwnerHandle.String 168 171 res.AuthorHandle = authorHandle.String 169 172 res.BodySnippet = bodySnippet.String 170 173 res.CreatedAt = createdAt.String
+31 -11
packages/api/internal/tapclient/tapclient.go
··· 21 21 const ( 22 22 minReconnectBackoff = 500 * time.Millisecond 23 23 maxReconnectBackoff = 10 * time.Second 24 - keepAliveInterval = 20 * time.Second 25 - keepAliveTimeout = 5 * time.Second 24 + keepAliveInterval = 2 * time.Minute 25 + keepAliveTimeout = 20 * time.Second 26 + maxReadMessageBytes = 8 << 20 26 27 ) 27 28 28 29 // Client receives Tap events over WebSocket and sends acks after processing. ··· 31 32 password string 32 33 log *slog.Logger 33 34 34 - mu sync.Mutex 35 - conn *websocket.Conn 36 - ackAsJSON bool 37 - disableAcks bool 35 + mu sync.Mutex 36 + conn *websocket.Conn 37 + ackAsJSON bool 38 + disableAcks bool 39 + lastActivity time.Time 38 40 } 39 41 40 42 func New(url, password string, log *slog.Logger) *Client { ··· 43 45 } 44 46 disableAcks, _ := strconv.ParseBool(strings.TrimSpace(os.Getenv("TAP_DISABLE_ACKS"))) 45 47 return &Client{ 46 - url: url, 47 - password: password, 48 - log: log, 49 - ackAsJSON: true, 50 - disableAcks: disableAcks, 48 + url: url, 49 + password: password, 50 + log: log, 51 + ackAsJSON: true, 52 + disableAcks: disableAcks, 53 + lastActivity: time.Now(), 51 54 } 52 55 } 53 56 ··· 67 70 } 68 71 continue 69 72 } 73 + 74 + c.markActivity() 70 75 71 76 var event normalize.TapRecordEvent 72 77 if err := json.Unmarshal(data, &event); err != nil { ··· 95 100 if ackAsJSON { 96 101 payload, _ := json.Marshal(map[string]int64{"id": id}) 97 102 if err := conn.Write(ctx, websocket.MessageText, payload); err == nil { 103 + c.markActivity() 98 104 return nil 99 105 } else if isConnectionWriteError(err) { 100 106 c.resetConn(websocket.StatusInternalError, "ack json write failed") ··· 109 115 c.resetConn(websocket.StatusInternalError, "ack failed") 110 116 return fmt.Errorf("ack event %d: %w", id, err) 111 117 } 118 + c.markActivity() 112 119 113 120 c.mu.Lock() 114 121 c.ackAsJSON = false ··· 120 127 c.resetConn(websocket.StatusInternalError, "ack failed") 121 128 return fmt.Errorf("ack event %d: %w", id, err) 122 129 } 130 + c.markActivity() 123 131 return nil 124 132 } 125 133 ··· 157 165 158 166 conn, _, err := websocket.Dial(ctx, c.url, &websocket.DialOptions{HTTPHeader: h}) 159 167 if err == nil { 168 + conn.SetReadLimit(maxReadMessageBytes) 160 169 c.mu.Lock() 161 170 if c.conn == nil { 162 171 c.conn = conn 172 + c.lastActivity = time.Now() 163 173 c.startKeepAlive(conn) 164 174 } else { 165 175 _ = conn.Close(websocket.StatusNormalClosure, "duplicate") ··· 187 197 } 188 198 } 189 199 200 + func (c *Client) markActivity() { 201 + c.mu.Lock() 202 + c.lastActivity = time.Now() 203 + c.mu.Unlock() 204 + } 205 + 190 206 func (c *Client) resetConn(status websocket.StatusCode, reason string) { 191 207 c.mu.Lock() 192 208 defer c.mu.Unlock() ··· 207 223 if c.conn != conn { 208 224 c.mu.Unlock() 209 225 return 226 + } 227 + if time.Since(c.lastActivity) < keepAliveInterval { 228 + c.mu.Unlock() 229 + continue 210 230 } 211 231 c.mu.Unlock() 212 232
+40 -6
packages/api/internal/view/static/search.js
··· 1 1 function searchApp() { 2 + const TANGLED_BASE = "https://tangled.org"; 3 + 2 4 return { 3 5 query: "", 4 6 filters: { type: "", author: "", language: "", state: "" }, ··· 85 87 }, 86 88 87 89 canonicalURL(r) { 88 - const h = r.author_handle || ""; 90 + const explicitURL = this.extractTangledURL(r.body_snippet) || this.extractTangledURL(r.summary); 91 + if (explicitURL) return explicitURL; 92 + 93 + const author = this.normalizeOwner(r.author_handle); 94 + const repoOwner = this.normalizeOwner(r.repo_owner_handle) || author; 95 + const repoName = this.normalizeSegment(r.repo_name); 96 + 89 97 switch (r.record_type) { 98 + case "profile": 99 + return author ? this.buildTangledURL(author) : "#"; 90 100 case "repo": 91 - return h && r.repo_name ? "https://tangled.org/" + h + "/" + r.repo_name : "#"; 101 + return repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName) : "#"; 92 102 case "issue": 93 - return h && r.repo_name ? "https://tangled.org/" + h + "/" + r.repo_name + "/issues" : "#"; 103 + case "issue_comment": 104 + return repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName, "issues") : "#"; 94 105 case "pull": 95 - return h && r.repo_name ? "https://tangled.org/" + h + "/" + r.repo_name + "/pulls" : "#"; 96 - case "profile": 97 - return h ? "https://tangled.org/" + h : "#"; 106 + case "pull_comment": 107 + return repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName, "pulls") : "#"; 108 + case "string": 109 + return author ? this.buildTangledURL(author) : "#"; 98 110 default: 99 111 return "#"; 100 112 } 113 + }, 114 + 115 + buildTangledURL() { 116 + const segments = Array.from(arguments) 117 + .filter(Boolean) 118 + .map((segment) => encodeURIComponent(segment)); 119 + return TANGLED_BASE + "/" + segments.join("/"); 120 + }, 121 + 122 + normalizeOwner(owner) { 123 + return owner ? owner.replace(/^@+/, "").trim() : ""; 124 + }, 125 + 126 + normalizeSegment(segment) { 127 + return segment ? segment.trim() : ""; 128 + }, 129 + 130 + extractTangledURL(text) { 131 + if (!text) return ""; 132 + const match = text.match(/https:\/\/tangled\.org\/[^\s<>"']+/i); 133 + if (!match) return ""; 134 + return match[0].replace(/[),.;:>]+$/, ""); 101 135 }, 102 136 103 137 relTime(iso) {
+43 -3
packages/api/internal/view/static/style.css
··· 54 54 margin: 0 auto; 55 55 padding: 2rem 1rem; 56 56 flex: 1; 57 + min-width: 0; 57 58 } 58 59 59 60 /* Footer */ ··· 132 133 margin-bottom: .6rem; 133 134 color: var(--text); 134 135 transition: border-color .15s; 136 + min-width: 0; 135 137 } 136 138 .card:hover { border-color: var(--accent); text-decoration: none; } 137 - .card-head { display: flex; align-items: center; gap: .5rem; margin-bottom: .35rem; } 139 + .card-head { 140 + display: flex; 141 + align-items: flex-start; 142 + gap: .5rem; 143 + margin-bottom: .35rem; 144 + flex-wrap: wrap; 145 + min-width: 0; 146 + } 138 147 .badge { 139 148 font-family: var(--mono); 140 149 font-size: .7rem; ··· 145 154 color: var(--text-dim); 146 155 white-space: nowrap; 147 156 } 148 - .card-title { font-weight: 500; font-size: .95rem; } 157 + .card-title { 158 + font-weight: 500; 159 + font-size: .95rem; 160 + min-width: 0; 161 + overflow-wrap: anywhere; 162 + word-break: break-word; 163 + } 149 164 .card-snippet { 150 165 font-size: .85rem; 151 166 color: var(--text-dim); 152 167 margin-bottom: .35rem; 153 168 line-height: 1.5; 169 + overflow-wrap: anywhere; 170 + word-break: break-word; 154 171 } 155 172 .card-snippet mark { 156 173 background: var(--mark-bg); ··· 158 175 padding: 0 .1rem; 159 176 border-radius: 2px; 160 177 } 161 - .card-meta { font-size: .78rem; color: var(--text-dim); display: flex; gap: .5rem; } 178 + .card-meta { 179 + font-size: .78rem; 180 + color: var(--text-dim); 181 + display: flex; 182 + gap: .5rem; 183 + flex-wrap: wrap; 184 + min-width: 0; 185 + } 186 + .card-meta span { 187 + min-width: 0; 188 + overflow-wrap: anywhere; 189 + word-break: break-word; 190 + } 162 191 .meta-sep::before { content: "\00b7"; margin-right: .5rem; } 163 192 164 193 /* Docs */ ··· 171 200 border-collapse: collapse; 172 201 margin-bottom: 1rem; 173 202 font-size: .85rem; 203 + display: block; 204 + overflow-x: auto; 174 205 } 175 206 .main th, .main td { 176 207 text-align: left; ··· 196 227 line-height: 1.5; 197 228 } 198 229 .main pre code { background: none; padding: 0; } 230 + .main p, 231 + .main li, 232 + .main td, 233 + .main th, 234 + .main code { 235 + overflow-wrap: anywhere; 236 + word-break: break-word; 237 + } 199 238 200 239 /* Mobile */ 201 240 @media (max-width: 640px) { 202 241 .search-form { flex-direction: column; } 242 + .btn-primary { width: 100%; } 203 243 .filter-bar { flex-direction: column; } 204 244 .filter-input { width: 100%; } 205 245 .card-meta { flex-wrap: wrap; }
+21 -10
packages/api/internal/view/view.go
··· 10 10 //go:embed templates static 11 11 var content embed.FS 12 12 13 - var templates *template.Template 13 + var pageTemplates map[string]*template.Template 14 14 15 15 func init() { 16 - templates = template.Must(template.ParseFS(content, 17 - "templates/layout.html", 18 - "templates/index.html", 19 - "templates/docs/index.html", 20 - "templates/docs/search.html", 21 - "templates/docs/documents.html", 22 - "templates/docs/health.html", 23 - )) 16 + pageTemplates = make(map[string]*template.Template) 17 + for _, name := range []string{ 18 + "index.html", 19 + "docs/index.html", 20 + "docs/search.html", 21 + "docs/documents.html", 22 + "docs/health.html", 23 + } { 24 + pageTemplates[name] = template.Must(template.New("layout").ParseFS(content, 25 + "templates/layout.html", 26 + "templates/"+name, 27 + )) 28 + } 24 29 } 25 30 26 31 // Handler returns an http.Handler that serves the site pages and static assets. ··· 41 46 42 47 func renderPage(name string) http.HandlerFunc { 43 48 return func(w http.ResponseWriter, r *http.Request) { 49 + tmpl, ok := pageTemplates[name] 50 + if !ok { 51 + http.NotFound(w, r) 52 + return 53 + } 54 + 44 55 w.Header().Set("Content-Type", "text/html; charset=utf-8") 45 - if err := templates.ExecuteTemplate(w, name, nil); err != nil { 56 + if err := tmpl.ExecuteTemplate(w, "layout", nil); err != nil { 46 57 http.Error(w, "template error", http.StatusInternalServerError) 47 58 } 48 59 }
+46
packages/api/internal/view/view_test.go
··· 1 + package view 2 + 3 + import ( 4 + "net/http" 5 + "net/http/httptest" 6 + "strings" 7 + "testing" 8 + ) 9 + 10 + func TestHandlerRendersSearchHome(t *testing.T) { 11 + req := httptest.NewRequest(http.MethodGet, "/", nil) 12 + rec := httptest.NewRecorder() 13 + 14 + Handler().ServeHTTP(rec, req) 15 + 16 + if rec.Code != http.StatusOK { 17 + t.Fatalf("expected status 200, got %d", rec.Code) 18 + } 19 + 20 + body := rec.Body.String() 21 + if !strings.Contains(body, "Search Tangled") { 22 + t.Fatalf("expected search home content, got body %q", body) 23 + } 24 + if strings.Contains(body, "Health Endpoints") { 25 + t.Fatalf("expected search home page, got health page content") 26 + } 27 + } 28 + 29 + func TestHandlerRendersDocsIndex(t *testing.T) { 30 + req := httptest.NewRequest(http.MethodGet, "/docs", nil) 31 + rec := httptest.NewRecorder() 32 + 33 + Handler().ServeHTTP(rec, req) 34 + 35 + if rec.Code != http.StatusOK { 36 + t.Fatalf("expected status 200, got %d", rec.Code) 37 + } 38 + 39 + body := rec.Body.String() 40 + if !strings.Contains(body, "API Documentation") { 41 + t.Fatalf("expected docs page content, got body %q", body) 42 + } 43 + if strings.Contains(body, "template error") { 44 + t.Fatalf("expected docs page render, got template error") 45 + } 46 + }