tangled
alpha
login
or
join now
whey.party
/
red-dwarf
82
fork
atom
an independent Bluesky client using Constellation, PDS Queries, and other services
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
client
app
82
fork
atom
overview
issues
25
pulls
pipelines
add url import bar in lieu of full-text search
rimar1337
4 months ago
08b6118f
96fdf44f
+230
-30
4 changed files
expand all
collapse all
unified
split
src
components
Import.tsx
Login.tsx
routes
__root.tsx
search.tsx
+149
src/components/Import.tsx
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
className={
25
compact
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]"
28
}
29
>
30
<span
···
43
// Large view
44
if (!compact) {
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">
47
<div className="flex flex-col items-center justify-center text-center">
48
<p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100">
49
You are logged in!
···
77
if (!compact) {
78
// Large view renders the form directly in the card
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">
81
<UnifiedLoginForm />
82
</div>
83
);
···
24
className={
25
compact
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-4 mx-4 flex justify-center items-center h-[280px]"
28
}
29
>
30
<span
···
43
// Large view
44
if (!compact) {
45
return (
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
<div className="flex flex-col items-center justify-center text-center">
48
<p className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-100">
49
You are logged in!
···
77
if (!compact) {
78
// Large view renders the form directly in the card
79
return (
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
<UnifiedLoginForm />
82
</div>
83
);
+28
-26
src/routes/__root.tsx
···
18
19
import { Composer } from "~/components/Composer";
20
import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary";
0
21
import Login from "~/components/Login";
22
import { NotFound } from "~/components/NotFound";
23
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
···
154
/>
155
156
<MaterialNavItem
0
0
0
0
0
0
0
0
0
0
0
0
157
InactiveIcon={
158
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
159
}
···
180
})
181
}
182
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
<MaterialNavItem
197
InactiveIcon={
···
389
390
<MaterialNavItem
391
small
0
0
0
0
0
0
0
0
0
0
0
0
0
392
InactiveIcon={
393
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
394
}
···
419
/>
420
<MaterialNavItem
421
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
InactiveIcon={
436
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
437
}
···
498
</main>
499
500
<aside className="hidden lg:flex h-screen w-[250px] sticky top-0 self-start flex-col">
0
501
<Login />
502
503
<div className="flex-1"></div>
···
551
//params: { did: agent.assertDid },
552
})
553
}
554
-
text="Search"
555
/>
556
{/* <Link
557
to="/search"
···
18
19
import { Composer } from "~/components/Composer";
20
import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary";
21
+
import { Import } from "~/components/Import";
22
import Login from "~/components/Login";
23
import { NotFound } from "~/components/NotFound";
24
import { FluentEmojiHighContrastGlowingStar } from "~/components/Star";
···
155
/>
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
170
InactiveIcon={
171
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
172
}
···
193
})
194
}
195
text="Feeds"
0
0
0
0
0
0
0
0
0
0
0
0
196
/>
197
<MaterialNavItem
198
InactiveIcon={
···
390
391
<MaterialNavItem
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
406
InactiveIcon={
407
<IconMaterialSymbolsNotificationsOutline className="w-6 h-6" />
408
}
···
433
/>
434
<MaterialNavItem
435
small
0
0
0
0
0
0
0
0
0
0
0
0
0
436
InactiveIcon={
437
<IconMaterialSymbolsAccountCircleOutline className="w-6 h-6" />
438
}
···
499
</main>
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>
503
<Login />
504
505
<div className="flex-1"></div>
···
553
//params: { did: agent.assertDid },
554
})
555
}
556
+
text="Explore"
557
/>
558
{/* <Link
559
to="/search"
+50
-1
src/routes/search.tsx
···
1
import { createFileRoute } from "@tanstack/react-router";
2
0
0
0
3
export const Route = createFileRoute("/search")({
4
component: Search,
5
});
6
7
export function Search() {
8
-
return <div className="p-6">Search page (coming soon)</div>;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
9
}
···
1
import { createFileRoute } from "@tanstack/react-router";
2
3
+
import { Header } from "~/components/Header";
4
+
import { Import } from "~/components/Import";
5
+
6
export const Route = createFileRoute("/search")({
7
component: Search,
8
});
9
10
export function Search() {
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
+
);
58
}