a love letter to tangled (android, iOS, and a search API)
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}