its for when you want to get like notifications for your reposts
1import { createSignal, onCleanup, For, type Component } from "solid-js";
2
3import type {} from "@atcute/bluesky";
4import type {} from "@atcute/atproto";
5import { isDid, isHandle } from "@atcute/lexicons/syntax";
6import { XrpcHandleResolver } from "@atcute/identity-resolver";
7import { Notification } from "./types.js";
8import { ActivityItem } from "./ActivityItem.jsx";
9
10const handleResolver = new XrpcHandleResolver({
11 serviceUrl: "https://public.api.bsky.app",
12});
13
14const App: Component = () => {
15 const [actorId, setActorId] = createSignal<string>("");
16 const [serviceDomain, setWsUrl] = createSignal<string>("likes.gaze.systems");
17 const [isConnected, setIsConnected] = createSignal<boolean>(false);
18 const [items, setItems] = createSignal<Notification[]>([]);
19 const [connectionStatus, setConnectionStatus] = createSignal<
20 "disconnected" | "connecting..." | "connected" | "error"
21 >("disconnected");
22 const [error, setError] = createSignal<string | null>(null);
23
24 let ws: WebSocket | null = null;
25
26 const connectWebSocket = async () => {
27 const didOrHandle = actorId().trim();
28 const host = serviceDomain().trim();
29
30 setError(null);
31 setConnectionStatus("connecting...");
32
33 let did: string;
34 if (!didOrHandle) {
35 setConnectionStatus("error");
36 setError("please enter a DID or a handle");
37 return;
38 } else if (isHandle(didOrHandle)) {
39 try {
40 did = await handleResolver.resolve(didOrHandle);
41 } catch (error) {
42 setConnectionStatus("error");
43 setError(`can't resolve handle: ${error}`);
44 return;
45 }
46 } else if (isDid(didOrHandle)) {
47 did = didOrHandle;
48 } else {
49 setConnectionStatus("error");
50 setError("inputted DID / handle is not valid");
51 return;
52 }
53
54 if (!host) {
55 setError("please enter service host");
56 setConnectionStatus("error");
57 return;
58 }
59
60 // Close existing connection if any
61 if (ws) {
62 ws.close();
63 }
64
65 let proto = "wss";
66 const domain = host.split(":").at(0) ?? "";
67 if (["localhost", "0.0.0.0", "127.0.0.1"].some((v) => v === domain)) {
68 proto = "ws";
69 }
70
71 const url = `${proto}://${host}/subscribe/${did}`;
72
73 try {
74 ws = new WebSocket(url);
75
76 ws.onopen = () => {
77 setIsConnected(true);
78 setConnectionStatus("connected");
79 setError(null);
80 console.log("WebSocket connected to:", url);
81 };
82
83 ws.onmessage = (event: MessageEvent) => {
84 try {
85 const data: Notification = JSON.parse(event.data);
86 setItems((prev) => [data, ...prev]); // add new items to the top
87 } catch (error) {
88 console.error("Error parsing JSON:", error);
89 }
90 };
91
92 ws.onclose = () => {
93 setIsConnected(false);
94 setConnectionStatus("disconnected");
95 console.log("WebSocket disconnected");
96 };
97
98 ws.onerror = (error: Event) => {
99 setConnectionStatus("error");
100 setError(`connection failed: ${error}`);
101 console.error("WebSocket error:", error);
102 };
103 } catch (error) {
104 setConnectionStatus("error");
105 setError(`failed to create connection: ${error}`);
106 console.error("Failed to create WebSocket:", error);
107 }
108 };
109
110 const disconnect = (): void => {
111 if (ws) {
112 ws.close();
113 ws = null;
114 }
115 };
116
117 const clearItems = (): void => {
118 setItems([]);
119 };
120
121 onCleanup(() => {
122 if (ws) {
123 ws.close();
124 }
125 });
126
127 return (
128 <div max-w-4xl mx-auto p-6 bg-gray-50 min-h-screen>
129 <h1 border="l-16 blue" font-bold text="3xl gray-800" pl-2 mb-6>
130 monitor bluesky repost likes
131 </h1>
132
133 {/* connection */}
134 <div mb-6>
135 <div flex gap-2 mb-2>
136 <input
137 type="text"
138 value={serviceDomain()}
139 onInput={(e) => setWsUrl((e.target as HTMLInputElement).value)}
140 placeholder="enter service host (e.g., likes.gaze.systems)"
141 class="flex-1 px-4 py-2 border border-gray-300 rounded-none focus:(outline-none ring-2 ring-purple-500) bg-white"
142 disabled={isConnected() || connectionStatus() == "connecting..."}
143 />
144 </div>
145 <div flex gap-2 mb-2>
146 <input
147 type="text"
148 value={actorId()}
149 onInput={(e) => setActorId((e.target as HTMLInputElement).value)}
150 onKeyPress={(e) => {
151 if (!isConnected() && e.key == "Enter") {
152 connectWebSocket();
153 e.preventDefault();
154 }
155 }}
156 placeholder="enter handle or DID"
157 class="flex-1 px-4 py-2 border border-gray-300 rounded-none focus:(outline-none ring-2 ring-blue-500) bg-white"
158 disabled={isConnected() || connectionStatus() == "connecting..."}
159 />
160 <button
161 onClick={() => (isConnected() ? disconnect() : connectWebSocket())}
162 class={`px-6 py-2 rounded-none font-medium transition-colors ${
163 isConnected()
164 ? "bg-red-500 hover:bg-red-600 text-white"
165 : "bg-blue-500 hover:bg-blue-600 text-white"
166 }`}
167 >
168 {isConnected() ? "Disconnect" : "Connect"}
169 </button>
170 </div>
171
172 {/* Status indicator */}
173 <div flex gap-2 items-center>
174 <div w-fit border border-gray-300 bg-gray-80 px-1 py="0.5">
175 <div
176 inline-block
177 w-3
178 h-3
179 rounded-full
180 class={
181 connectionStatus() === "connected"
182 ? "bg-green-500"
183 : connectionStatus() === "connecting..."
184 ? "bg-yellow-500"
185 : connectionStatus() === "error"
186 ? "bg-red-500"
187 : "bg-gray-400"
188 }
189 />
190 <span ml-2 align="10%" text="sm gray-600">
191 status: {connectionStatus()}
192 </span>
193 </div>
194 {error() && (
195 <div w-fit border border-gray-300 bg-gray-80 p-1>
196 <div text="sm red-500">{error()}</div>
197 </div>
198 )}
199 </div>
200 </div>
201
202 {/* feed */}
203 <div class="mb-4">
204 <div class="flex justify-between items-center mb-4">
205 <h2 border="l-8 blue" pl-2 text="xl gray-700" font-semibold>
206 activity feed ({items().length})
207 </h2>
208 <button
209 onClick={clearItems}
210 text="white sm"
211 class="px-4 py-2 bg-gray-500 hover:bg-gray-600 rounded-none transition-colors disabled:opacity-50"
212 disabled={items().length === 0}
213 >
214 clear feed
215 </button>
216 </div>
217
218 <div class="h-[60vh] max-h-[60vh] overflow-y-auto border border-gray-200 rounded-none p-4 bg-white">
219 {items().length === 0 ? (
220 <div flex items-center w-full h-full>
221 <div mx-auto text="center gray-500">
222 <div text-lg mb-2>
223 👀
224 </div>
225 <div>
226 nothing yet. connect and wait for someone to like a repost of
227 yours!
228 </div>
229 </div>
230 </div>
231 ) : (
232 <For each={items()}>
233 {(item, index) => (
234 <div mb={index() == items().length - 1 ? "0" : "2"}>
235 <ActivityItem data={item} />
236 </div>
237 )}
238 </For>
239 )}
240 </div>
241 </div>
242
243 {/* Instructions */}
244 <div border bg-blue-50 border-blue-200 rounded-none pl="1.5" p-1>
245 <span text="xs blue-800" align="10%">
246 <span text-pink-400>source</span> <span text-gray>=</span>{" "}
247 <a
248 href="https://tangled.sh/@poor.dog/bsky-repost-likes"
249 text-orange-700
250 hover:text-orange-400
251 >
252 "https://tangled.sh/@poor.dog/bsky-repost-likes"
253 </a>{" "}
254 // made by{" "}
255 <a text-purple-700 hover:text-purple href="https://gaze.systems">
256 dusk
257 </a>
258 </span>
259 </div>
260 </div>
261 );
262};
263
264export default App;