an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
99
fork

Configure Feed

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

add url import bar in lieu of full-text search

rimar1337 08b6118f 96fdf44f

+230 -30
+149
src/components/Import.tsx
··· 1 + import { AtUri } from "@atproto/api"; 2 + import { useNavigate, type UseNavigateResult } from "@tanstack/react-router"; 3 + import { useState } from "react"; 4 + 5 + /** 6 + * Basically the best equivalent to Search that i can do 7 + */ 8 + export function Import() { 9 + const [textInput, setTextInput] = useState<string | undefined>(); 10 + const navigate = useNavigate(); 11 + 12 + const handleEnter = () => { 13 + if (!textInput) return; 14 + handleImport({ 15 + text: textInput, 16 + navigate, 17 + }); 18 + }; 19 + 20 + return ( 21 + <div className="w-full relative"> 22 + <IconMaterialSymbolsSearch className="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500" /> 23 + 24 + <input 25 + type="text" 26 + placeholder="Import..." 27 + value={textInput} 28 + onChange={(e) => setTextInput(e.target.value)} 29 + onKeyDown={(e) => { 30 + if (e.key === "Enter") handleEnter(); 31 + }} 32 + className="w-full h-12 pl-12 pr-4 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-gray-500 box-border transition" 33 + /> 34 + </div> 35 + ); 36 + } 37 + 38 + function handleImport({ 39 + text, 40 + navigate, 41 + }: { 42 + text: string; 43 + navigate: UseNavigateResult<string>; 44 + }) { 45 + const trimmed = text.trim(); 46 + // parse text 47 + /** 48 + * text might be 49 + * 1. bsky dot app url (deer.social, reddwarf.whey.party, main.bsky.dev, catskys.social) (reddwarf link segments might be uri encoded,) 50 + * 2. aturi 51 + * 3. plain handle 52 + * 4. plain did 53 + */ 54 + 55 + // 1. Check if it’s a URL 56 + try { 57 + const url = new URL(text); 58 + const knownHosts = [ 59 + "bsky.app", 60 + "social.daniela.lol", 61 + "deer.social", 62 + "reddwarf.whey.party", 63 + "main.bsky.dev", 64 + "catsky.social", 65 + "blacksky.community", 66 + "red-dwarf-social-app.whey.party", 67 + "zeppelin.social", 68 + ]; 69 + if (knownHosts.includes(url.hostname)) { 70 + // parse path to get URI or handle 71 + const path = decodeURIComponent(url.pathname.slice(1)); // remove leading / 72 + console.log("BSky URL path:", path); 73 + navigate({ 74 + to: `/${path}`, 75 + }); 76 + return; 77 + } 78 + } catch { 79 + // not a URL, continue 80 + } 81 + 82 + // 2. Check if text looks like an at-uri 83 + try { 84 + if (text.startsWith("at://")) { 85 + console.log("AT URI detected:", text); 86 + const aturi = new AtUri(text); 87 + switch (aturi.collection) { 88 + case "app.bsky.feed.post": { 89 + navigate({ 90 + to: "/profile/$did/post/$rkey", 91 + params: { 92 + did: aturi.host, 93 + rkey: aturi.rkey, 94 + }, 95 + }); 96 + return; 97 + } 98 + case "app.bsky.actor.profile": { 99 + navigate({ 100 + to: "/profile/$did", 101 + params: { 102 + did: aturi.host, 103 + }, 104 + }); 105 + return; 106 + } 107 + // todo add more handlers as more routes are added. like feeds, lists, etc etc thanks! 108 + default: { 109 + // continue 110 + } 111 + } 112 + } 113 + } catch { 114 + // continue 115 + } 116 + 117 + // 3. Plain handle (starts with @) 118 + try { 119 + if (text.startsWith("@")) { 120 + const handle = text.slice(1); 121 + console.log("Handle detected:", handle); 122 + navigate({ to: "/profile/$did", params: { did: handle } }); 123 + return; 124 + } 125 + } catch { 126 + // continue 127 + } 128 + 129 + // 4. Plain DID (starts with did:) 130 + try { 131 + if (text.startsWith("did:")) { 132 + console.log("did detected:", text); 133 + navigate({ to: "/profile/$did", params: { did: text } }); 134 + return; 135 + } 136 + } catch { 137 + // continue 138 + } 139 + 140 + // if all else fails 141 + 142 + // try { 143 + // // probably a user? 144 + // navigate({ to: "/profile/$did", params: { did: text } }); 145 + // return; 146 + // } catch { 147 + // // continue 148 + // } 149 + }
+3 -3
src/components/Login.tsx
··· 24 24 className={ 25 25 compact 26 26 ? "flex items-center justify-center p-1" 27 - : "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-6 mx-4 flex justify-center items-center h-[280px]" 27 + : "p-6 bg-gray-100 dark:bg-gray-900 rounded-xl shadow border border-gray-200 dark:border-gray-800 mt-4 mx-4 flex justify-center items-center h-[280px]" 28 28 } 29 29 > 30 30 <span ··· 43 43 // Large view 44 44 if (!compact) { 45 45 return ( 46 - <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4"> 46 + <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4"> 47 47 <div className="flex flex-col items-center justify-center text-center"> 48 48 <p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100"> 49 49 You are logged in! ··· 77 77 if (!compact) { 78 78 // Large view renders the form directly in the card 79 79 return ( 80 - <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-6 mx-4"> 80 + <div className="p-4 bg-gray-100 dark:bg-gray-900 rounded-xl border-gray-200 dark:border-gray-800 mt-4 mx-4"> 81 81 <UnifiedLoginForm /> 82 82 </div> 83 83 );
+28 -26
src/routes/__root.tsx
··· 18 18 19 19 import { Composer } from "~/components/Composer"; 20 20 import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary"; 21 + import { Import } from "~/components/Import"; 21 22 import Login from "~/components/Login"; 22 23 import { NotFound } from "~/components/NotFound"; 23 24 import { FluentEmojiHighContrastGlowingStar } from "~/components/Star"; ··· 154 155 /> 155 156 156 157 <MaterialNavItem 158 + InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 159 + ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 160 + active={locationEnum === "search"} 161 + onClickCallbback={() => 162 + navigate({ 163 + to: "/search", 164 + //params: { did: agent.assertDid }, 165 + }) 166 + } 167 + text="Explore" 168 + /> 169 + <MaterialNavItem 157 170 InactiveIcon={ 158 171 <IconMaterialSymbolsNotificationsOutline className="w-6 h-6" /> 159 172 } ··· 180 193 }) 181 194 } 182 195 text="Feeds" 183 - /> 184 - <MaterialNavItem 185 - InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 186 - ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 187 - active={locationEnum === "search"} 188 - onClickCallbback={() => 189 - navigate({ 190 - to: "/search", 191 - //params: { did: agent.assertDid }, 192 - }) 193 - } 194 - text="Search" 195 196 /> 196 197 <MaterialNavItem 197 198 InactiveIcon={ ··· 389 390 390 391 <MaterialNavItem 391 392 small 393 + InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 394 + ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 395 + active={locationEnum === "search"} 396 + onClickCallbback={() => 397 + navigate({ 398 + to: "/search", 399 + //params: { did: agent.assertDid }, 400 + }) 401 + } 402 + text="Explore" 403 + /> 404 + <MaterialNavItem 405 + small 392 406 InactiveIcon={ 393 407 <IconMaterialSymbolsNotificationsOutline className="w-6 h-6" /> 394 408 } ··· 419 433 /> 420 434 <MaterialNavItem 421 435 small 422 - InactiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 423 - ActiveIcon={<IconMaterialSymbolsSearch className="w-6 h-6" />} 424 - active={locationEnum === "search"} 425 - onClickCallbback={() => 426 - navigate({ 427 - to: "/search", 428 - //params: { did: agent.assertDid }, 429 - }) 430 - } 431 - text="Search" 432 - /> 433 - <MaterialNavItem 434 - small 435 436 InactiveIcon={ 436 437 <IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" /> 437 438 } ··· 498 499 </main> 499 500 500 501 <aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col"> 502 + <div className="px-4 pt-4"><Import /></div> 501 503 <Login /> 502 504 503 505 <div className="flex-1"></div> ··· 551 553 //params: { did: agent.assertDid }, 552 554 }) 553 555 } 554 - text="Search" 556 + text="Explore" 555 557 /> 556 558 {/* <Link 557 559 to="/search"
+50 -1
src/routes/search.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 2 3 + import { Header } from "~/components/Header"; 4 + import { Import } from "~/components/Import"; 5 + 3 6 export const Route = createFileRoute("/search")({ 4 7 component: Search, 5 8 }); 6 9 7 10 export function Search() { 8 - return <div className="p-6">Search page (coming soon)</div>; 11 + return ( 12 + <> 13 + <Header 14 + title="Explore" 15 + backButtonCallback={() => { 16 + if (window.history.length > 1) { 17 + window.history.back(); 18 + } else { 19 + window.location.assign("/"); 20 + } 21 + }} 22 + /> 23 + <div className=" flex flex-col items-center mt-4 mx-4 gap-4"> 24 + <Import /> 25 + <div className="flex flex-col"> 26 + <p className="text-gray-600 dark:text-gray-400"> 27 + Sorry we dont have search. But instead, you can load some of these 28 + types of content into Red Dwarf: 29 + </p> 30 + <ul className="list-disc list-inside mt-2 text-gray-600 dark:text-gray-400"> 31 + <li> 32 + Bluesky URLs from supported clients (like{" "} 33 + <code className="text-sm">bsky.app</code> or{" "} 34 + <code className="text-sm">deer.social</code>). 35 + </li> 36 + <li> 37 + AT-URIs (e.g.,{" "} 38 + <code className="text-sm">at://did:example/collection/item</code> 39 + ). 40 + </li> 41 + <li> 42 + Plain handles (like{" "} 43 + <code className="text-sm">@username.bsky.social</code>). 44 + </li> 45 + <li> 46 + Direct DIDs (Decentralized Identifiers, starting with{" "} 47 + <code className="text-sm">did:</code>). 48 + </li> 49 + </ul> 50 + <p className="mt-2 text-gray-600 dark:text-gray-400"> 51 + Simply paste one of these into the import field above and press 52 + Enter to load the content. 53 + </p> 54 + </div> 55 + </div> 56 + </> 57 + ); 9 58 }