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 { ATProtoActivity } 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<ATProtoActivity[]>([]);
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 const url = `wss://${host}/subscribe/${did}`;
66
67 try {
68 ws = new WebSocket(url);
69
70 ws.onopen = () => {
71 setIsConnected(true);
72 setConnectionStatus("connected");
73 setError(null);
74 console.log("WebSocket connected to:", url);
75 };
76
77 ws.onmessage = (event: MessageEvent) => {
78 try {
79 const data: ATProtoActivity = JSON.parse(event.data);
80 setItems((prev) => [data, ...prev]); // add new items to the top
81 } catch (error) {
82 console.error("Error parsing JSON:", error);
83 }
84 };
85
86 ws.onclose = () => {
87 setIsConnected(false);
88 setConnectionStatus("disconnected");
89 console.log("WebSocket disconnected");
90 };
91
92 ws.onerror = (error: Event) => {
93 setConnectionStatus("error");
94 setError(`connection failed: ${error}`);
95 console.error("WebSocket error:", error);
96 };
97 } catch (error) {
98 setConnectionStatus("error");
99 setError(`failed to create connection: ${error}`);
100 console.error("Failed to create WebSocket:", error);
101 }
102 };
103
104 const disconnect = (): void => {
105 if (ws) {
106 ws.close();
107 ws = null;
108 }
109 };
110
111 const clearItems = (): void => {
112 setItems([]);
113 };
114
115 onCleanup(() => {
116 if (ws) {
117 ws.close();
118 }
119 });
120
121 return (
122 <div max-w-4xl mx-auto p-6 bg-gray-50 min-h-screen>
123 <h1 border="l-16 blue" font-bold text="3xl gray-800" pl-2 mb-6>
124 monitor bluesky repost likes
125 </h1>
126
127 {/* connection */}
128 <div mb-6>
129 <div flex gap-2 mb-2>
130 <input
131 type="text"
132 value={serviceDomain()}
133 onInput={(e) => setWsUrl((e.target as HTMLInputElement).value)}
134 placeholder="enter service host (e.g., likes.gaze.systems)"
135 class="flex-1 px-4 py-2 border border-gray-300 rounded-none focus:(outline-none ring-2 ring-purple-500) bg-white"
136 disabled={isConnected() || connectionStatus() == "connecting..."}
137 />
138 </div>
139 <div flex gap-2 mb-2>
140 <input
141 type="text"
142 value={actorId()}
143 onInput={(e) => setActorId((e.target as HTMLInputElement).value)}
144 onKeyPress={(e) => {
145 if (!isConnected() && e.key == "Enter") {
146 connectWebSocket();
147 e.preventDefault();
148 }
149 }}
150 placeholder="enter handle or DID"
151 class="flex-1 px-4 py-2 border border-gray-300 rounded-none focus:(outline-none ring-2 ring-blue-500) bg-white"
152 disabled={isConnected() || connectionStatus() == "connecting..."}
153 />
154 <button
155 onClick={() => (isConnected() ? disconnect() : connectWebSocket())}
156 class={`px-6 py-2 rounded-none font-medium transition-colors ${
157 isConnected()
158 ? "bg-red-500 hover:bg-red-600 text-white"
159 : "bg-blue-500 hover:bg-blue-600 text-white"
160 }`}
161 >
162 {isConnected() ? "Disconnect" : "Connect"}
163 </button>
164 </div>
165
166 {/* Status indicator */}
167 <div flex gap-2 items-center>
168 <div w-fit border border-gray-300 bg-gray-80 px-1 py="0.5">
169 <div
170 inline-block
171 w-3
172 h-3
173 rounded-full
174 class={
175 connectionStatus() === "connected"
176 ? "bg-green-500"
177 : connectionStatus() === "connecting..."
178 ? "bg-yellow-500"
179 : connectionStatus() === "error"
180 ? "bg-red-500"
181 : "bg-gray-400"
182 }
183 />
184 <span ml-2 align="10%" text="sm gray-600">
185 status: {connectionStatus()}
186 </span>
187 </div>
188 {error() && (
189 <div w-fit border border-gray-300 bg-gray-80 p-1>
190 <div text="sm red-500">{error()}</div>
191 </div>
192 )}
193 </div>
194 </div>
195
196 {/* feed */}
197 <div class="mb-4">
198 <div class="flex justify-between items-center mb-4">
199 <h2 border="l-8 blue" pl-2 text="xl gray-700" font-semibold>
200 activity feed ({items().length})
201 </h2>
202 <button
203 onClick={clearItems}
204 text="white sm"
205 class="px-4 py-2 bg-gray-500 hover:bg-gray-600 rounded-none transition-colors disabled:opacity-50"
206 disabled={items().length === 0}
207 >
208 clear feed
209 </button>
210 </div>
211
212 <div class="h-[60vh] max-h-[60vh] overflow-y-auto border border-gray-200 rounded-none p-4 bg-white">
213 {items().length === 0 ? (
214 <div flex items-center w-full h-full>
215 <div mx-auto text="center gray-500">
216 <div text-lg mb-2>
217 👀
218 </div>
219 <div>
220 nothing yet. connect and wait for someone to like a repost of
221 yours!
222 </div>
223 </div>
224 </div>
225 ) : (
226 <For each={items()}>
227 {(item, index) => (
228 <div mb={index() == items().length - 1 ? "0" : "2"}>
229 <ActivityItem data={item} />
230 </div>
231 )}
232 </For>
233 )}
234 </div>
235 </div>
236
237 {/* Instructions */}
238 <div border bg-blue-50 border-blue-200 rounded-none pl="1.5" p-1>
239 <span text="xs blue-800" align="10%">
240 <span text-pink-400>source</span> <span text-gray>=</span>{" "}
241 <a
242 href="https://tangled.sh/@poor.dog/bsky-repost-likes"
243 text-orange-700
244 hover:text-orange-400
245 >
246 "https://tangled.sh/@poor.dog/bsky-repost-likes"
247 </a>{" "}
248 // made by{" "}
249 <a text-purple-700 hover:text-purple href="https://gaze.systems">
250 dusk
251 </a>
252 </span>
253 </div>
254 </div>
255 );
256};
257
258export default App;