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