an appview-less Bluesky client using Constellation and PDS Queries
reddwarf.app
frontend
spa
bluesky
reddwarf
microcosm
1import { createFileRoute } from "@tanstack/react-router";
2import { useAtom } from "jotai";
3import React, { useEffect, useRef,useState } from "react";
4
5import { useAuth } from "~/providers/UnifiedAuthProvider";
6import { constellationURLAtom } from "~/utils/atoms";
7
8const HANDLE_DID_CACHE_TIMEOUT = 60 * 60 * 1000; // 1 hour
9
10export const Route = createFileRoute("/notifications")({
11 component: NotificationsComponent,
12});
13
14function NotificationsComponent() {
15 // /*mass comment*/ console.log("NotificationsComponent render");
16 const { agent, status } = useAuth();
17 const authed = !!agent?.did;
18 const authLoading = status === "loading";
19 const [did, setDid] = useState<string | null>(null);
20 const [resolving, setResolving] = useState(false);
21 const [error, setError] = useState<string | null>(null);
22 const [responses, setResponses] = useState<any[]>([null, null, null]);
23 const [loading, setLoading] = useState(false);
24 const inputRef = useRef<HTMLInputElement>(null);
25
26 useEffect(() => {
27 if (authLoading) return;
28 if (authed && agent && agent.assertDid) {
29 setDid(agent.assertDid);
30 }
31 }, [authed, agent, authLoading]);
32
33 async function handleSubmit() {
34 // /*mass comment*/ console.log("handleSubmit called");
35 setError(null);
36 setResponses([null, null, null]);
37 const value = inputRef.current?.value?.trim() || "";
38 if (!value) return;
39 if (value.startsWith("did:")) {
40 setDid(value);
41 setError(null);
42 return;
43 }
44 setResolving(true);
45 const cacheKey = `handleDid:${value}`;
46 const now = Date.now();
47 const cached = undefined // await get(cacheKey);
48 // if (
49 // cached &&
50 // cached.value &&
51 // cached.time &&
52 // now - cached.time < HANDLE_DID_CACHE_TIMEOUT
53 // ) {
54 // try {
55 // const data = JSON.parse(cached.value);
56 // setDid(data.did);
57 // setResolving(false);
58 // return;
59 // } catch {}
60 // }
61 try {
62 const url = `https://free-fly-24.deno.dev/?handle=${encodeURIComponent(value)}`;
63 const res = await fetch(url);
64 if (!res.ok) throw new Error("Failed to resolve handle");
65 const data = await res.json();
66 //set(cacheKey, JSON.stringify(data));
67 setDid(data.did);
68 } catch (e: any) {
69 setError("Failed to resolve handle: " + (e?.message || e));
70 } finally {
71 setResolving(false);
72 }
73 }
74
75 const [constellationURL] = useAtom(constellationURLAtom)
76
77 useEffect(() => {
78 if (!did) return;
79 setLoading(true);
80 setError(null);
81 const urls = [
82 `https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[app.bsky.richtext.facet].features[app.bsky.richtext.facet%23mention].did`,
83 `https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.feed.post&path=.facets[].features[app.bsky.richtext.facet%23mention].did`,
84 `https://${constellationURL}/links?target=${encodeURIComponent(did)}&collection=app.bsky.graph.follow&path=.subject`,
85 ];
86 let ignore = false;
87 Promise.all(
88 urls.map(async (url) => {
89 try {
90 const r = await fetch(url);
91 if (!r.ok) throw new Error("Failed to fetch");
92 const text = await r.text();
93 if (!text) return null;
94 try {
95 return JSON.parse(text);
96 } catch {
97 return null;
98 }
99 } catch (e: any) {
100 return { error: e?.message || String(e) };
101 }
102 })
103 )
104 .then((results) => {
105 if (!ignore) setResponses(results);
106 })
107 .catch((e) => {
108 if (!ignore)
109 setError("Failed to fetch notifications: " + (e?.message || e));
110 })
111 .finally(() => {
112 if (!ignore) setLoading(false);
113 });
114 return () => {
115 ignore = true;
116 };
117 }, [did]);
118
119 return (
120 <div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
121 <div className="flex items-center gap-2 px-4 py-2 h-[52px] sticky top-0 bg-white dark:bg-gray-950 z-10 border-b border-gray-200 dark:border-gray-800">
122 <span className="text-xl font-bold ml-2">Notifications</span>
123 {!authed && (
124 <div className="flex items-center gap-2">
125 <input
126 type="text"
127 placeholder="Enter handle or DID"
128 ref={inputRef}
129 className="ml-4 px-2 py-1 rounded border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100"
130 style={{ minWidth: 220 }}
131 disabled={resolving}
132 />
133 <button
134 type="button"
135 className="px-3 py-1 rounded bg-blue-600 text-white font-semibold disabled:opacity-50"
136 disabled={resolving}
137 onClick={handleSubmit}
138 >
139 {resolving ? "Resolving..." : "Submit"}
140 </button>
141 </div>
142 )}
143 </div>
144 {error && <div className="p-4 text-red-500">{error}</div>}
145 {loading && (
146 <div className="p-4 text-gray-500">Loading notifications...</div>
147 )}
148 {!loading &&
149 !error &&
150 responses.map((resp, i) => (
151 <div key={i} className="p-4">
152 <div className="font-bold mb-2">Query {i + 1}</div>
153 {!resp ||
154 (typeof resp === "object" && Object.keys(resp).length === 0) ||
155 (Array.isArray(resp) && resp.length === 0) ? (
156 <div className="text-gray-500">No notifications found.</div>
157 ) : (
158 <pre
159 style={{
160 background: "#222",
161 color: "#eee",
162 borderRadius: 8,
163 padding: 12,
164 fontSize: 13,
165 overflowX: "auto",
166 }}
167 >
168 {JSON.stringify(resp, null, 2)}
169 </pre>
170 )}
171 </div>
172 ))}
173 {/* <div className="p-4"> yo this project sucks, ill remake it some other time, like cmon inputting anything into the textbox makes it break. ive warned you</div> */}
174 </div>
175 );
176}