atmosphere explorer
0
fork

Configure Feed

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

at main 512 lines 18 kB view raw
1import { Client, simpleFetchHandler } from "@atcute/client"; 2import { Nsid } from "@atcute/lexicons"; 3import { A, useNavigate } from "@solidjs/router"; 4import { 5 createEffect, 6 createResource, 7 createSignal, 8 For, 9 onCleanup, 10 onMount, 11 Show, 12} from "solid-js"; 13import { canHover } from "../layout"; 14import { resolveLexiconAuthority, resolveLexiconAuthorityDirect } from "../lib/api"; 15import { appHandleLink, appList, AppUrl } from "../lib/app-urls"; 16import { createDebouncedValue } from "../lib/debounced"; 17import { Button } from "./button"; 18import { Modal } from "./modal"; 19 20type RecentSearch = { 21 path: string; 22 label: string; 23 type: "handle" | "did" | "at-uri" | "lexicon" | "pds" | "url"; 24}; 25 26const RECENT_SEARCHES_KEY = "recent-searches"; 27const MAX_RECENT_SEARCHES = 5; 28 29const getRecentSearches = (): RecentSearch[] => { 30 try { 31 const stored = localStorage.getItem(RECENT_SEARCHES_KEY); 32 return stored ? JSON.parse(stored) : []; 33 } catch { 34 return []; 35 } 36}; 37 38const addRecentSearch = (search: RecentSearch) => { 39 const searches = getRecentSearches(); 40 const filtered = searches.filter((s) => s.path !== search.path); 41 const updated = [search, ...filtered].slice(0, MAX_RECENT_SEARCHES); 42 localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); 43}; 44 45const removeRecentSearch = (path: string) => { 46 const searches = getRecentSearches(); 47 const updated = searches.filter((s) => s.path !== path); 48 localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); 49}; 50 51export const [showSearch, setShowSearch] = createSignal(false); 52 53const EXAMPLES: (RecentSearch & { prefix: string })[] = [ 54 { 55 path: "/at://did:plc:vwzwgnygau7ed7b7wt5ux7y2", 56 label: "retr0.id", 57 type: "handle", 58 prefix: "@", 59 }, 60 { 61 path: "/at://did:plc:uu5axsmbm2or2dngy4gwchec/sh.tangled.repo/3m2skfgqpvn22", 62 label: "futur.blue/sh.tangled.repo/3m2skfgqpvn22", 63 type: "at-uri", 64 prefix: "at://", 65 }, 66 { path: "/npmx.social", label: "npmx.social", type: "pds", prefix: "pds:" }, 67 { 68 path: "/at://did:web:iame.li/com.atproto.lexicon.schema/place.stream.chat.message#schema", 69 label: "place.stream.chat.message", 70 type: "lexicon", 71 prefix: "lex:", 72 }, 73 { 74 path: "/at://did:plc:oisofpd7lj26yvgiivf3lxsi/app.bsky.feed.post/3mfflamxxvk2t", 75 label: "bsky.app/profile/hailey.at/post/3mfflamxxvk2t", 76 type: "at-uri", 77 prefix: "https://", 78 }, 79]; 80 81const SEARCH_PREFIXES: { prefix: string; description: string }[] = [ 82 { prefix: "@", description: "example.com" }, 83 { prefix: "did:", description: "web:example.com" }, 84 { prefix: "at:", description: "//example.com/com.example.test/self" }, 85 { prefix: "lex:", description: "com.example.test" }, 86 { prefix: "pds:", description: "host.example.com" }, 87]; 88 89const parsePrefix = (input: string): { prefix: string | null; query: string } => { 90 const matchedPrefix = SEARCH_PREFIXES.find((p) => input.toLowerCase().startsWith(p.prefix)); 91 if (matchedPrefix) { 92 return { 93 prefix: matchedPrefix.prefix, 94 query: input.slice(matchedPrefix.prefix.length), 95 }; 96 } 97 return { prefix: null, query: input }; 98}; 99 100export const SearchButton = () => { 101 onMount(() => window.addEventListener("keydown", keyEvent)); 102 onCleanup(() => window.removeEventListener("keydown", keyEvent)); 103 104 const keyEvent = (ev: KeyboardEvent) => { 105 if (document.querySelector("[data-modal]")) return; 106 107 if ((ev.ctrlKey || ev.metaKey) && ev.key == "k") { 108 ev.preventDefault(); 109 110 if (showSearch()) { 111 const searchInput = document.querySelector("#input") as HTMLInputElement; 112 if (searchInput && document.activeElement !== searchInput) { 113 searchInput.focus(); 114 } else { 115 setShowSearch(false); 116 } 117 } else { 118 setShowSearch(true); 119 } 120 } else if (ev.key == "Escape") { 121 ev.preventDefault(); 122 setShowSearch(false); 123 } 124 }; 125 126 return ( 127 <Button onClick={() => setShowSearch(!showSearch())}> 128 <span class="iconify lucide--search"></span> 129 <span>Search</span> 130 <Show when={canHover}> 131 <kbd class="font-sans text-neutral-400 dark:text-neutral-500"> 132 {/Mac/i.test(navigator.platform) ? "⌘" : "⌃"}K 133 </kbd> 134 </Show> 135 </Button> 136 ); 137}; 138 139export const Search = () => { 140 const navigate = useNavigate(); 141 let searchInput!: HTMLInputElement; 142 const rpc = new Client({ 143 handler: simpleFetchHandler({ service: "https://public.api.bsky.app" }), 144 }); 145 const [recentSearches, setRecentSearches] = createSignal<RecentSearch[]>(getRecentSearches()); 146 147 onMount(() => { 148 const handlePaste = (e: ClipboardEvent) => { 149 if (e.target === searchInput) return; 150 if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; 151 if (document.querySelector("[data-modal]")) return; 152 153 const pastedText = e.clipboardData?.getData("text"); 154 if (pastedText) processInput(pastedText); 155 }; 156 157 window.addEventListener("paste", handlePaste); 158 onCleanup(() => window.removeEventListener("paste", handlePaste)); 159 160 const requestUrl = new URL(location.href); 161 const requestQuery = requestUrl.searchParams.get("q"); 162 if (requestQuery !== null) { 163 requestUrl.searchParams.delete("q"); 164 history.replaceState(null, "", requestUrl.toString()); 165 processInput(requestQuery); 166 } 167 }); 168 169 createEffect(() => { 170 if (showSearch()) setTimeout(() => searchInput?.focus()); 171 }); 172 173 const resetSearch = () => { 174 setInput(undefined); 175 setSelectedIndex(-1); 176 setSearch(undefined); 177 }; 178 179 const fetchTypeahead = async (input: string | undefined) => { 180 if (!input) return []; 181 182 const { prefix, query } = parsePrefix(input); 183 184 if (prefix === "@") { 185 if (!query.length) return []; 186 187 const res = await rpc.get("app.bsky.actor.searchActorsTypeahead", { 188 params: { q: query, limit: 5 }, 189 }); 190 if (res.ok) { 191 return res.data.actors; 192 } 193 } 194 195 return []; 196 }; 197 198 const [input, setInput] = createSignal<string>(); 199 const [selectedIndex, setSelectedIndex] = createSignal(-1); 200 const [search, { mutate: setSearch }] = createResource( 201 createDebouncedValue(input, 200), 202 fetchTypeahead, 203 ); 204 205 const getRecentSuggestions = () => { 206 const currentInput = input()?.toLowerCase(); 207 if (!currentInput) return recentSearches(); 208 return recentSearches().filter((r) => r.label.toLowerCase().includes(currentInput)); 209 }; 210 211 const saveRecentSearch = (path: string, label: string, type: RecentSearch["type"]) => { 212 addRecentSearch({ path, label, type }); 213 setRecentSearches(getRecentSearches()); 214 }; 215 216 const processInput = async (input: string) => { 217 input = input.trim().replace(/^@/, ""); 218 if (!input.length) return; 219 220 if (input.includes("%")) { 221 try { 222 input = decodeURIComponent(input); 223 } catch {} 224 } 225 226 setShowSearch(false); 227 228 const { prefix, query } = parsePrefix(input); 229 230 if (prefix === "@") { 231 const path = `/at://${query}`; 232 saveRecentSearch(path, query, "handle"); 233 navigate(path); 234 } else if (prefix === "did:") { 235 const path = `/at://did:${query}`; 236 saveRecentSearch(path, `did:${query}`, "did"); 237 navigate(path); 238 } else if (prefix === "at:") { 239 const path = `/${input}`; 240 saveRecentSearch(path, input, "at-uri"); 241 navigate(path); 242 } else if (prefix === "lex:") { 243 if (query.split(".").length >= 3) { 244 const nsid = query as Nsid; 245 const res = await resolveLexiconAuthority(nsid); 246 const path = `/at://${res}/com.atproto.lexicon.schema/${nsid}`; 247 saveRecentSearch(path, query, "lexicon"); 248 navigate(path); 249 } else { 250 const did = await resolveLexiconAuthorityDirect(query); 251 const path = `/at://${did}/com.atproto.lexicon.schema`; 252 saveRecentSearch(path, query, "lexicon"); 253 navigate(path); 254 } 255 } else if (prefix === "pds:") { 256 const path = `/${query}`; 257 saveRecentSearch(path, query, "pds"); 258 navigate(path); 259 } else if (input.startsWith("https://") || input.startsWith("http://")) { 260 const hostLength = input.indexOf("/", 8); 261 const host = input.slice(0, hostLength).replace("https://", "").replace("http://", ""); 262 263 if (!(host in appList)) { 264 const path = `/${input.replace("https://", "").replace("http://", "").replace("/", "")}`; 265 saveRecentSearch(path, input, "url"); 266 navigate(path); 267 } else { 268 const app = appList[host as AppUrl]; 269 const pathParts = input.slice(hostLength + 1).split("/"); 270 const uri = appHandleLink[app](pathParts); 271 const path = `/${uri}`; 272 saveRecentSearch(path, input, "url"); 273 navigate(path); 274 } 275 } else { 276 const path = `/at://${input.replace("at://", "")}`; 277 const type = input.split("/").length > 1 ? "at-uri" : "handle"; 278 saveRecentSearch(path, input, type); 279 navigate(path); 280 } 281 }; 282 283 return ( 284 <Modal 285 open={showSearch()} 286 onClose={() => setShowSearch(false)} 287 onClosed={resetSearch} 288 alignTop 289 contentClass="dark:bg-dark-200 dark:shadow-dark-700 pointer-events-auto mx-3 w-full max-w-lg rounded-lg border-[0.5px] min-w-0 border-neutral-300 bg-white shadow-md dark:border-neutral-700" 290 > 291 <form 292 class="w-full" 293 onsubmit={(e) => { 294 e.preventDefault(); 295 processInput(searchInput.value); 296 }} 297 > 298 <label for="input" class="hidden"> 299 Search or paste a link 300 </label> 301 <div 302 class={`flex items-center gap-2 px-3 ${ 303 getRecentSuggestions().length > 0 || search()?.length ? "rounded-t-lg" : "rounded-lg" 304 }`} 305 > 306 <label 307 for="input" 308 class="iconify lucide--search text-neutral-500 dark:text-neutral-400" 309 ></label> 310 <input 311 type="text" 312 spellcheck={false} 313 autocapitalize="off" 314 autocomplete="off" 315 placeholder="Search or paste a link..." 316 ref={searchInput} 317 id="input" 318 class="grow py-2.5 select-none placeholder:text-sm focus:outline-none" 319 value={input() ?? ""} 320 onInput={(e) => { 321 setInput(e.currentTarget.value); 322 setSelectedIndex(-1); 323 }} 324 onBlur={() => setSelectedIndex(-1)} 325 onKeyDown={(e) => { 326 const results = search(); 327 const recent = getRecentSuggestions(); 328 const totalSuggestions = recent.length + (results?.length || 0); 329 330 if (!totalSuggestions) return; 331 332 if (e.key === "ArrowDown") { 333 e.preventDefault(); 334 setSelectedIndex((prev) => (prev === -1 ? 0 : (prev + 1) % totalSuggestions)); 335 } else if (e.key === "ArrowUp") { 336 e.preventDefault(); 337 setSelectedIndex((prev) => 338 prev === -1 ? 339 totalSuggestions - 1 340 : (prev - 1 + totalSuggestions) % totalSuggestions, 341 ); 342 } else if (e.key === "Enter") { 343 const index = selectedIndex(); 344 if (index >= 0) { 345 e.preventDefault(); 346 if (index < recent.length) { 347 const item = recent[index]; 348 addRecentSearch(item); 349 setRecentSearches(getRecentSearches()); 350 setShowSearch(false); 351 navigate(item.path); 352 } else { 353 const adjustedIndex = index - recent.length; 354 if (results && results[adjustedIndex]) { 355 const actor = results[adjustedIndex]; 356 const path = `/at://${actor.did}`; 357 saveRecentSearch(path, actor.handle, "handle"); 358 setShowSearch(false); 359 navigate(path); 360 } 361 } 362 } else if (results?.length && recent.length === 0) { 363 e.preventDefault(); 364 const actor = results[0]; 365 const path = `/at://${actor.did}`; 366 saveRecentSearch(path, actor.handle, "handle"); 367 setShowSearch(false); 368 navigate(path); 369 } 370 } 371 }} 372 /> 373 </div> 374 375 <Show 376 when={ 377 getRecentSuggestions().length > 0 || 378 search()?.length || 379 (!input() && recentSearches().length === 0) 380 } 381 > 382 <div 383 class="flex w-full flex-col overflow-hidden rounded-b-md border-t border-neutral-200 dark:border-neutral-700" 384 onMouseDown={(e) => e.preventDefault()} 385 > 386 {/* Suggestions (shown when no recents and no input) */} 387 <Show when={!input() && recentSearches().length === 0}> 388 <div class="mt-2 mb-1 flex px-3"> 389 <span class="text-xs font-medium text-neutral-500 dark:text-neutral-400"> 390 Examples 391 </span> 392 </div> 393 <For each={EXAMPLES}> 394 {(example) => ( 395 <A 396 href={example.path} 397 class="dark:hover:bg-dark-100 flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-100" 398 onClick={() => setShowSearch(false)} 399 > 400 <span class="truncate"> 401 <span class="text-neutral-500 dark:text-neutral-400">{example.prefix}</span> 402 {example.label} 403 </span> 404 </A> 405 )} 406 </For> 407 </Show> 408 409 {/* Recent searches */} 410 <Show when={getRecentSuggestions().length > 0}> 411 <div class="mt-2 mb-1 flex items-center justify-between px-3"> 412 <span class="text-xs font-medium text-neutral-500 dark:text-neutral-400"> 413 Recent 414 </span> 415 <button 416 type="button" 417 class="text-xs not-hover:text-neutral-500 dark:not-hover:text-neutral-400" 418 onClick={() => { 419 localStorage.removeItem(RECENT_SEARCHES_KEY); 420 setRecentSearches([]); 421 }} 422 > 423 Clear all 424 </button> 425 </div> 426 <For each={getRecentSuggestions()}> 427 {(recent, index) => { 428 const icon = 429 recent.type === "handle" ? "lucide--at-sign" 430 : recent.type === "did" ? "lucide--user-round" 431 : recent.type === "at-uri" ? "lucide--link" 432 : recent.type === "lexicon" ? "lucide--book-open" 433 : recent.type === "pds" ? "lucide--hard-drive" 434 : "lucide--globe"; 435 return ( 436 <div 437 class={`group flex items-center ${ 438 index() === selectedIndex() ? 439 "bg-neutral-200 dark:bg-neutral-700" 440 : "dark:hover:bg-dark-100 hover:bg-neutral-100" 441 }`} 442 > 443 <A 444 href={recent.path} 445 class="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-sm" 446 onClick={() => { 447 addRecentSearch(recent); 448 setRecentSearches(getRecentSearches()); 449 setShowSearch(false); 450 }} 451 > 452 <span 453 class={`iconify ${icon} shrink-0 text-neutral-500 dark:text-neutral-400`} 454 ></span> 455 <span class="truncate">{recent.label}</span> 456 </A> 457 <button 458 type="button" 459 class="flex items-center p-2.5 opacity-0 not-hover:text-neutral-500 group-hover:opacity-100 dark:not-hover:text-neutral-400" 460 onClick={() => { 461 removeRecentSearch(recent.path); 462 setRecentSearches(getRecentSearches()); 463 }} 464 > 465 <span class="iconify lucide--x text-base"></span> 466 </button> 467 </div> 468 ); 469 }} 470 </For> 471 </Show> 472 473 {/* Typeahead results */} 474 <For each={search()}> 475 {(actor, index) => { 476 const adjustedIndex = getRecentSuggestions().length + index(); 477 const path = `/at://${actor.did}`; 478 return ( 479 <A 480 class={`flex items-center gap-2 px-3 py-1.5 ${ 481 adjustedIndex === selectedIndex() ? 482 "bg-neutral-200 dark:bg-neutral-700" 483 : "dark:hover:bg-dark-100 hover:bg-neutral-100 active:bg-neutral-200 dark:active:bg-neutral-700" 484 }`} 485 href={path} 486 onClick={() => { 487 saveRecentSearch(path, actor.handle, "handle"); 488 setShowSearch(false); 489 }} 490 > 491 <img 492 src={actor.avatar?.replace("img/avatar/", "img/avatar_thumbnail/")} 493 class="size-8 rounded-full" 494 /> 495 <div class="flex min-w-0 flex-col"> 496 <Show when={actor.displayName}> 497 <span class="truncate text-sm font-medium">{actor.displayName}</span> 498 </Show> 499 <span class="truncate text-xs text-neutral-600 dark:text-neutral-400"> 500 @{actor.handle} 501 </span> 502 </div> 503 </A> 504 ); 505 }} 506 </For> 507 </div> 508 </Show> 509 </form> 510 </Modal> 511 ); 512};