a love letter to tangled (android, iOS, and a search API)
at main 249 lines 7.7 kB view raw
1function searchApp() { 2 const TANGLED_BASE = "https://tangled.org"; 3 const PDS_BASE = "https://pds.ls"; 4 const DOCUMENTS_BASE = "/documents"; 5 6 return { 7 query: "", 8 filters: { type: "", author: "", language: "", state: "" }, 9 results: [], 10 offset: 0, 11 limit: 20, 12 total: 0, 13 loading: false, 14 searched: false, 15 error: null, 16 toastMessage: "", 17 toastVisible: false, 18 toastTimer: null, 19 20 get hasMore() { 21 return this.searched && this.offset + this.limit < this.total; 22 }, 23 24 initFromURL() { 25 const p = new URLSearchParams(window.location.search); 26 this.query = p.get("q") || ""; 27 this.filters.type = p.get("type") || ""; 28 this.filters.author = p.get("author") || ""; 29 this.filters.language = p.get("language") || ""; 30 this.filters.state = p.get("state") || ""; 31 if (this.query) this.doSearch(true); 32 }, 33 34 buildParams(reset) { 35 const p = new URLSearchParams(); 36 p.set("q", this.query); 37 p.set("limit", String(this.limit)); 38 p.set("offset", String(reset ? 0 : this.offset)); 39 if (this.filters.type) p.set("type", this.filters.type); 40 if (this.filters.author) p.set("author", this.filters.author); 41 if (this.filters.language) p.set("language", this.filters.language); 42 if (this.filters.state) p.set("state", this.filters.state); 43 return p; 44 }, 45 46 syncURL() { 47 const p = new URLSearchParams(); 48 if (this.query) p.set("q", this.query); 49 if (this.filters.type) p.set("type", this.filters.type); 50 if (this.filters.author) p.set("author", this.filters.author); 51 if (this.filters.language) p.set("language", this.filters.language); 52 if (this.filters.state) p.set("state", this.filters.state); 53 const qs = p.toString(); 54 history.replaceState(null, "", qs ? "?" + qs : "/"); 55 }, 56 57 async doSearch(reset) { 58 if (!this.query.trim()) return; 59 if (reset) { 60 this.offset = 0; 61 this.results = []; 62 } 63 this.loading = true; 64 this.error = null; 65 this.syncURL(); 66 67 try { 68 const resp = await fetch("/search?" + this.buildParams(reset)); 69 if (!resp.ok) { 70 const body = await resp.json().catch(() => null); 71 this.error = (body && body.message) || "Search request failed (" + resp.status + ")"; 72 return; 73 } 74 const data = await resp.json(); 75 if (reset) { 76 this.results = data.results || []; 77 } else { 78 this.results = this.results.concat(data.results || []); 79 } 80 this.total = data.total || 0; 81 this.searched = true; 82 } catch (e) { 83 this.error = "Could not reach the API. Is the server running?"; 84 } finally { 85 this.loading = false; 86 } 87 }, 88 89 loadMore() { 90 this.offset += this.limit; 91 this.doSearch(false); 92 }, 93 94 resultURL(r) { 95 return this.resolveResult(r).url; 96 }, 97 98 warningMessage(r) { 99 return this.resolveResult(r).warning; 100 }, 101 102 jsonURL(r) { 103 return DOCUMENTS_BASE + "/" + encodeURIComponent(r.id); 104 }, 105 106 pdsURL(r) { 107 return r.at_uri ? PDS_BASE + "/" + r.at_uri : ""; 108 }, 109 110 resolveResult(r) { 111 const parsed = this.parseATURI(r.at_uri); 112 const author = this.normalizeOwner(r.author_handle) || this.normalizeSegment(r.did) || parsed.did; 113 const repoOwner = this.normalizeOwner(r.repo_owner_handle) || author; 114 const repoName = this.normalizeSegment(r.repo_name); 115 116 if (r.record_type === "string") { 117 const owner = author || parsed.did; 118 const rkey = parsed.rkey; 119 const url = r.web_url || (owner && rkey ? this.buildTangledURL("strings", owner, rkey) : ""); 120 const warning = url ? "" : "This string is indexed from AT Protocol, but Tangled no longer has a page for it."; 121 return { url, warning }; 122 } 123 124 let url = ""; 125 switch (r.record_type) { 126 case "profile": 127 url = r.web_url || (author ? this.buildTangledURL(author) : ""); 128 break; 129 case "repo": 130 url = r.web_url || (repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName) : ""); 131 break; 132 case "issue": 133 case "issue_comment": 134 url = repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName, "issues") : ""; 135 break; 136 case "pull": 137 case "pull_comment": 138 url = repoOwner && repoName ? this.buildTangledURL(repoOwner, repoName, "pulls") : ""; 139 break; 140 default: 141 url = r.web_url || ""; 142 } 143 144 return url 145 ? { url, warning: "" } 146 : { 147 url: "", 148 warning: "This record is indexed from AT Protocol, but Tangled does not currently expose a page for it.", 149 }; 150 }, 151 152 async copyATURI(r) { 153 const label = this.recordLabel(r); 154 if (!r.at_uri) { 155 this.showToast(label + " AT URI is unavailable."); 156 return; 157 } 158 159 try { 160 await this.writeClipboard(r.at_uri); 161 this.showToast(label + " AT URI copied."); 162 } catch (_) { 163 this.showToast("Could not copy the " + label.toLowerCase() + " AT URI."); 164 } 165 }, 166 167 recordLabel(r) { 168 switch (r.record_type) { 169 case "profile": 170 return "User"; 171 case "repo": 172 return "Repo"; 173 case "issue": 174 return "Issue"; 175 case "pull": 176 return "Pull"; 177 default: { 178 const label = (r.record_type || "record").replace(/_/g, " "); 179 return label.charAt(0).toUpperCase() + label.slice(1); 180 } 181 } 182 }, 183 184 async writeClipboard(text) { 185 if (navigator.clipboard && window.isSecureContext) { 186 await navigator.clipboard.writeText(text); 187 return; 188 } 189 190 const input = document.createElement("textarea"); 191 input.value = text; 192 input.setAttribute("readonly", ""); 193 input.style.position = "absolute"; 194 input.style.left = "-9999px"; 195 document.body.appendChild(input); 196 input.select(); 197 const copied = document.execCommand("copy"); 198 document.body.removeChild(input); 199 if (!copied) throw new Error("copy failed"); 200 }, 201 202 showToast(message) { 203 this.toastMessage = message; 204 this.toastVisible = true; 205 if (this.toastTimer) window.clearTimeout(this.toastTimer); 206 this.toastTimer = window.setTimeout(() => { 207 this.toastVisible = false; 208 }, 1800); 209 }, 210 211 buildTangledURL() { 212 const segments = Array.from(arguments) 213 .filter(Boolean) 214 .map((segment) => encodeURIComponent(segment)); 215 return TANGLED_BASE + "/" + segments.join("/"); 216 }, 217 218 normalizeOwner(owner) { 219 return owner ? owner.replace(/^@+/, "").trim() : ""; 220 }, 221 222 normalizeSegment(segment) { 223 return segment ? segment.trim() : ""; 224 }, 225 226 parseATURI(uri) { 227 if (!uri || !uri.startsWith("at://")) return { did: "", collection: "", rkey: "" }; 228 const parts = uri.slice("at://".length).split("/"); 229 const did = parts[0] || ""; 230 const collection = parts[1] || ""; 231 const rkey = parts.slice(2).join("/"); 232 return { did, collection, rkey }; 233 }, 234 235 relTime(iso) { 236 if (!iso) return ""; 237 const diff = Date.now() - new Date(iso).getTime(); 238 const mins = Math.floor(diff / 60000); 239 if (mins < 1) return "just now"; 240 if (mins < 60) return mins + "m ago"; 241 const hrs = Math.floor(mins / 60); 242 if (hrs < 24) return hrs + "h ago"; 243 const days = Math.floor(hrs / 24); 244 if (days < 30) return days + "d ago"; 245 const months = Math.floor(days / 30); 246 return months + "mo ago"; 247 }, 248 }; 249}