Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

webhook styling work, fix firehose reconnection logic, subscribe to ws while backfilling

+524 -279
+7 -7
apps/firehose-service/src/index.ts
··· 246 246 247 247 logger.info(`Health endpoint: http://localhost:${config.healthPort}/health`) 248 248 249 + // Always start firehose and revalidate worker 250 + startFirehose() 251 + await startRevalidateWorker() 252 + 249 253 if (config.isBackfill) { 250 - // Run backfill and exit 254 + // Run backfill while firehose is already consuming events 255 + logger.info('Running backfill with firehose active') 251 256 await runBackfill() 252 - await closeDatabase() 253 - process.exit(0) 254 - } else { 255 - // Start firehose 256 - startFirehose() 257 - await startRevalidateWorker() 257 + logger.info('Backfill complete, continuing firehose consumption') 258 258 } 259 259 } 260 260
+9 -2
apps/firehose-service/src/lib/firehose.ts
··· 159 159 logger.info(`Service: ${config.firehoseService}`) 160 160 logger.info(`Max concurrency: ${config.firehoseMaxConcurrency}`) 161 161 162 - isConnected = true 163 - 164 162 if (isBun) { 165 163 // Use BunFirehose for Bun runtime 166 164 const bunFirehose = new BunFirehose({ ··· 169 167 filterCollections: ['place.wisp.fs', 'place.wisp.settings'], 170 168 handleEvent, 171 169 onError: handleError, 170 + onConnect: () => { 171 + isConnected = true 172 + logger.info('Firehose connected') 173 + }, 174 + onDisconnect: () => { 175 + isConnected = false 176 + logger.warn('Firehose disconnected, will reconnect') 177 + }, 172 178 }) 173 179 bunFirehose.start() 174 180 firehoseHandle = { destroy: () => bunFirehose.destroy() } 175 181 } else { 176 182 // Use @atproto/sync Firehose for Node.js 183 + isConnected = true 177 184 const nodeFirehose = new Firehose({ 178 185 idResolver, 179 186 service: config.firehoseService,
+458 -262
apps/main-app/public/editor/tabs/WebhooksTab.tsx
··· 4 4 import { Input } from '@public/components/ui/input' 5 5 import { Label } from '@public/components/ui/label' 6 6 import { SkeletonShimmer } from '@public/components/ui/skeleton' 7 - import { Loader2, RefreshCw, Trash2 } from 'lucide-react' 8 - import { useEffect, useState } from 'react' 7 + import { CheckCircle2, ChevronDown, ChevronUp, ExternalLink, Loader2, Plus, RefreshCw, Trash2, Webhook } from 'lucide-react' 8 + import { useEffect, useRef, useState } from 'react' 9 9 import type { WebhookEventLog, WebhookRecord } from '../hooks/useWebhookData' 10 10 11 11 const APPS = [ 12 12 { id: 'bluesky', label: 'Bluesky', path: 'app.bsky.*' }, 13 - { id: 'tangled', label: 'Tangled', path: 'chat.tangled.*' }, 13 + { id: 'tangled', label: 'Tangled', path: 'sh.tangled.*' }, 14 14 { id: 'leaflet', label: 'Leaflet', path: 'pub.leaflet.*' }, 15 15 { id: 'wisp', label: 'wisp', path: 'place.wisp.*' }, 16 16 { id: 'blento', label: 'Blento', path: 'blue.blento.*' }, ··· 56 56 return scopePath ? `at://${userDid}/${scopePath}` : `at://${userDid}` 57 57 } 58 58 59 + function formatTimeAgo(dateStr: string): string { 60 + const diff = Date.now() - new Date(dateStr).getTime() 61 + const seconds = Math.floor(diff / 1000) 62 + if (seconds < 60) return `${seconds}s ago` 63 + const minutes = Math.floor(seconds / 60) 64 + if (minutes < 60) return `${minutes}m ago` 65 + const hours = Math.floor(minutes / 60) 66 + if (hours < 24) return `${hours}h ago` 67 + return new Date(dateStr).toLocaleDateString() 68 + } 69 + 59 70 export function WebhooksTab({ 60 71 webhooks, 61 72 webhooksLoading, ··· 80 91 const [error, setError] = useState<string | null>(null) 81 92 const [success, setSuccess] = useState<string | null>(null) 82 93 const [deletingRkey, setDeletingRkey] = useState<string | null>(null) 94 + const [showCreateForm, setShowCreateForm] = useState(false) 95 + const [focusedWebhook, setFocusedWebhook] = useState(0) 96 + const containerRef = useRef<HTMLDivElement>(null) 97 + const itemRefs = useRef<(HTMLDivElement | null)[]>([]) 83 98 84 99 useEffect(() => { 85 100 const id = setInterval(onRefreshEvents, 60_000) 86 101 return () => clearInterval(id) 87 102 }, [onRefreshEvents]) 88 103 104 + // Auto-focus container when webhooks are loaded (fires on mount if data ready) 105 + useEffect(() => { 106 + if (!webhooksLoading && containerRef.current) { 107 + const timer = setTimeout(() => containerRef.current?.focus(), 100) 108 + return () => clearTimeout(timer) 109 + } 110 + }, [webhooksLoading]) 111 + 112 + // Clamp focused index 113 + useEffect(() => { 114 + if (webhooks.length > 0 && focusedWebhook >= webhooks.length) { 115 + setFocusedWebhook(webhooks.length - 1) 116 + } 117 + }, [webhooks.length, focusedWebhook]) 118 + 119 + // Keyboard navigation 120 + useEffect(() => { 121 + const handleKeyDown = (e: KeyboardEvent) => { 122 + const target = e.target as HTMLElement 123 + const isTyping = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' 124 + const hasFocus = containerRef.current?.contains(document.activeElement) 125 + 126 + if (isTyping || !hasFocus || webhooks.length === 0 || showCreateForm) return 127 + 128 + switch (e.key) { 129 + case 'ArrowUp': 130 + e.preventDefault() 131 + setFocusedWebhook((prev) => Math.max(0, prev - 1)) 132 + break 133 + case 'ArrowDown': 134 + e.preventDefault() 135 + setFocusedWebhook((prev) => Math.min(webhooks.length - 1, prev + 1)) 136 + break 137 + case 'd': 138 + e.preventDefault() 139 + handleDelete(webhooks[focusedWebhook].rkey) 140 + break 141 + case 'n': 142 + e.preventDefault() 143 + setShowCreateForm(true) 144 + break 145 + } 146 + } 147 + 148 + window.addEventListener('keydown', handleKeyDown) 149 + return () => window.removeEventListener('keydown', handleKeyDown) 150 + }, [webhooks, focusedWebhook, showCreateForm]) 151 + 152 + // Scroll focused item into view 153 + useEffect(() => { 154 + const element = itemRefs.current[focusedWebhook] 155 + if (element) { 156 + element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) 157 + } 158 + }, [focusedWebhook]) 159 + 89 160 const selectApp = (id: AppId) => { 90 161 setSelectedApp(id) 91 162 if (id !== 'other') { ··· 125 196 secret: '', 126 197 enabled: true, 127 198 }) 128 - setSuccess('Webhook created.') 199 + setSuccess('Webhook created successfully') 129 200 setUrl('') 130 201 setSelectedApp(null) 131 202 setScopePath('') ··· 136 207 setEventCreate(true) 137 208 setEventUpdate(true) 138 209 setEventDelete(true) 210 + setTimeout(() => { 211 + setSuccess(null) 212 + setShowCreateForm(false) 213 + }, 1500) 139 214 } catch (err) { 140 215 setError(err instanceof Error ? err.message : 'Failed to create webhook') 141 216 } ··· 152 227 } 153 228 } 154 229 155 - return ( 156 - <div className="h-full flex flex-col border border-border/30 bg-card/50 font-mono"> 157 - {/* Header */} 158 - <div className="p-4 pb-3 border-b border-border/30 flex-shrink-0"> 159 - <p className="text-sm font-semibold">Webhooks</p> 160 - <p className="text-xs text-muted-foreground mt-0.5">Receive HTTP callbacks when AT Protocol records change</p> 161 - </div> 162 - 163 - <div className="flex-1 min-h-0 overflow-y-auto p-4 space-y-6"> 164 - {/* Create form */} 165 - <form onSubmit={handleCreate} className="space-y-4"> 166 - <p className="text-xs uppercase tracking-wider text-muted-foreground">Create Webhook</p> 167 - 168 - {/* URL */} 169 - <div className="space-y-1"> 170 - <Label htmlFor="wh-url" className="text-xs text-muted-foreground"> 171 - URL 172 - </Label> 173 - <Input 174 - id="wh-url" 175 - value={url} 176 - onChange={(e) => setUrl(e.target.value)} 177 - placeholder="https://example.com/webhook" 178 - required 179 - className="h-8 text-sm" 180 - /> 181 - </div> 230 + const Kbd = ({ children }: { children: React.ReactNode }) => ( 231 + <kbd className="px-2 py-1 bg-muted/50 rounded border border-border/50">{children}</kbd> 232 + ) 182 233 183 - {/* App picker */} 184 - <div className="space-y-2"> 185 - <Label className="text-xs text-muted-foreground">App</Label> 186 - <div className="flex flex-wrap gap-1.5"> 187 - {APPS.map((app) => ( 188 - <button 189 - key={app.id} 190 - type="button" 191 - onClick={() => selectApp(app.id)} 192 - className={`px-3 py-1 text-xs border transition-colors ${ 193 - selectedApp === app.id 194 - ? 'border-accent bg-accent/20 text-foreground' 195 - : 'border-border/40 text-muted-foreground hover:border-border hover:text-foreground' 196 - }`} 197 - > 198 - {app.label} 199 - </button> 200 - ))} 201 - <button 202 - type="button" 203 - onClick={() => selectApp('other')} 204 - className={`px-3 py-1 text-xs border transition-colors ${ 205 - selectedApp === 'other' 206 - ? 'border-accent bg-accent/20 text-foreground' 207 - : 'border-border/40 text-muted-foreground hover:border-border hover:text-foreground' 208 - }`} 209 - > 210 - Other 211 - </button> 234 + return ( 235 + <div 236 + ref={containerRef} 237 + className="h-full flex flex-col border border-border/30 bg-card/50 font-mono outline-none" 238 + tabIndex={-1} 239 + onClick={(e) => { 240 + const t = e.target as HTMLElement 241 + if (!t.closest('input, textarea, button, select, a, label')) { 242 + containerRef.current?.focus() 243 + } 244 + }} 245 + > 246 + {/* Header with keyboard hints */} 247 + <div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground p-4 pb-3 border-b border-border/30 flex-shrink-0"> 248 + {webhooks.length > 0 && !showCreateForm ? ( 249 + <> 250 + <div className="flex items-center gap-2"> 251 + <Kbd>↑</Kbd> 252 + <Kbd>↓</Kbd> 253 + <span>navigate</span> 212 254 </div> 213 - </div> 214 - 215 - {/* Scope detail — known app */} 216 - {selectedApp && selectedApp !== 'other' && ( 217 - <div className="space-y-1"> 218 - <Label htmlFor="wh-path" className="text-xs text-muted-foreground"> 219 - Collection / glob 220 - </Label> 221 - <div className="flex items-center gap-0 border border-border/40 focus-within:border-border"> 222 - <span className="px-2 py-1.5 text-xs text-muted-foreground bg-muted/40 border-r border-border/40 whitespace-nowrap select-none"> 223 - at://{userDid || 'did'}/ 224 - </span> 225 - <input 226 - id="wh-path" 227 - value={scopePath} 228 - onChange={(e) => setScopePath(e.target.value)} 229 - placeholder="app.bsky.*" 230 - className="flex-1 px-2 py-1.5 text-xs bg-transparent outline-none font-mono" 231 - /> 232 - </div> 255 + <span>•</span> 256 + <div className="flex items-center gap-2"> 257 + <Kbd>d</Kbd> 258 + <span className="text-red-400">delete</span> 233 259 </div> 234 - )} 260 + <span>•</span> 261 + <div className="flex items-center gap-2"> 262 + <Kbd>n</Kbd> 263 + <span>new</span> 264 + </div> 265 + </> 266 + ) : ( 267 + <> 268 + <Webhook className="w-3.5 h-3.5" /> 269 + <span>Receive HTTP callbacks for changes to your ATProto collections as well as references</span> 270 + </> 271 + )} 272 + </div> 235 273 236 - {/* Scope detail — other */} 237 - {selectedApp === 'other' && ( 238 - <div className="space-y-2"> 239 - <Label className="text-xs text-muted-foreground">Scope</Label> 240 - <div className="space-y-1.5"> 241 - {( 242 - [ 243 - ['all', 'All my records', `at://${userDid || 'did'}`], 244 - ['collection', 'Specific collection', ''], 245 - ['rkey', 'Specific record', ''], 246 - ] as const 247 - ).map(([mode, label, hint]) => ( 248 - <label key={mode} className="flex items-start gap-2 cursor-pointer group"> 249 - <input 250 - type="radio" 251 - name="other-mode" 252 - checked={otherMode === mode} 253 - onChange={() => setOtherMode(mode)} 254 - className="mt-0.5 accent-accent" 255 - /> 256 - <div className="flex-1"> 257 - <span className="text-xs">{label}</span> 258 - {hint && <span className="text-xs text-muted-foreground ml-2">{hint}</span>} 259 - </div> 260 - </label> 261 - ))} 262 - </div> 263 - {otherMode === 'collection' && ( 264 - <Input 265 - value={otherCollection} 266 - onChange={(e) => setOtherCollection(e.target.value)} 267 - placeholder="app.bsky.feed.post" 268 - className="h-8 text-xs" 269 - /> 274 + <div className="flex-1 min-h-0 overflow-y-auto"> 275 + {/* Your Webhooks */} 276 + <div className="p-4 space-y-2"> 277 + <div className="flex items-center justify-between mb-3"> 278 + <p className="text-xs uppercase tracking-wider text-muted-foreground">Your Webhooks</p> 279 + <Button 280 + variant="outline" 281 + size="sm" 282 + className="h-7 text-xs px-3" 283 + onClick={() => setShowCreateForm(!showCreateForm)} 284 + > 285 + {showCreateForm ? ( 286 + <> 287 + <ChevronUp className="w-3 h-3 mr-1.5" /> 288 + Cancel 289 + </> 290 + ) : ( 291 + <> 292 + <Plus className="w-3 h-3 mr-1.5" /> 293 + New Webhook 294 + </> 270 295 )} 271 - {otherMode === 'rkey' && ( 272 - <div className="flex gap-2"> 273 - <Input 274 - value={otherCollection} 275 - onChange={(e) => setOtherCollection(e.target.value)} 276 - placeholder="collection" 277 - className="h-8 text-xs flex-1" 278 - /> 296 + </Button> 297 + </div> 298 + 299 + {/* Create form (collapsible) */} 300 + {showCreateForm && ( 301 + <div className="border border-dashed border-border/50 p-4 space-y-4 mb-4"> 302 + <form onSubmit={handleCreate} className="space-y-4"> 303 + {/* URL */} 304 + <div className="space-y-1.5"> 305 + <Label htmlFor="wh-url" className="text-xs text-muted-foreground"> 306 + Endpoint URL 307 + </Label> 279 308 <Input 280 - value={otherRkey} 281 - onChange={(e) => setOtherRkey(e.target.value)} 282 - placeholder="rkey" 283 - className="h-8 text-xs flex-1" 309 + id="wh-url" 310 + value={url} 311 + onChange={(e) => setUrl(e.target.value)} 312 + placeholder="https://example.com/webhook" 313 + required 314 + className="h-8 text-sm font-mono" 315 + autoFocus 284 316 /> 285 317 </div> 286 - )} 287 - </div> 288 - )} 289 318 290 - {/* Wildcard hint */} 291 - {scopeAturi.includes('*') && ( 292 - <p className="text-xs text-muted-foreground"> 293 - <code className="bg-muted px-1">*</code> is a wildcard — matches any collection name at that level. 294 - </p> 295 - )} 296 - 297 - {/* Backlinks */} 298 - {selectedApp && ( 299 - <div className="flex items-center gap-2"> 300 - <Checkbox id="wh-backlinks" checked={backlinks} onCheckedChange={(v) => setBacklinks(!!v)} /> 301 - <Label htmlFor="wh-backlinks" className="cursor-pointer text-xs"> 302 - Backlinks{' '} 303 - <span className="text-muted-foreground">— also fire when other records reference this scope</span> 304 - </Label> 305 - </div> 306 - )} 319 + {/* App picker */} 320 + <div className="space-y-2"> 321 + <Label className="text-xs text-muted-foreground">Scope</Label> 322 + <div className="flex flex-wrap gap-1.5"> 323 + {APPS.map((app) => ( 324 + <button 325 + key={app.id} 326 + type="button" 327 + onClick={() => selectApp(app.id)} 328 + className={`px-3 py-1.5 text-xs border rounded-sm transition-all ${ 329 + selectedApp === app.id 330 + ? 'border-accent bg-accent/15 text-foreground shadow-sm' 331 + : 'border-border text-muted-foreground hover:border-foreground/40 hover:text-foreground hover:bg-muted/30' 332 + }`} 333 + > 334 + {app.label} 335 + </button> 336 + ))} 337 + <button 338 + type="button" 339 + onClick={() => selectApp('other')} 340 + className={`px-3 py-1.5 text-xs border rounded-sm transition-all ${ 341 + selectedApp === 'other' 342 + ? 'border-accent bg-accent/15 text-foreground shadow-sm' 343 + : 'border-border text-muted-foreground hover:border-foreground/40 hover:text-foreground hover:bg-muted/30' 344 + }`} 345 + > 346 + Other 347 + </button> 348 + </div> 349 + </div> 307 350 308 - {/* Events */} 309 - {selectedApp && ( 310 - <div className="space-y-1.5"> 311 - <Label className="text-xs text-muted-foreground">Events</Label> 312 - <div className="flex gap-4"> 313 - {( 314 - [ 315 - ['create', eventCreate, setEventCreate], 316 - ['update', eventUpdate, setEventUpdate], 317 - ['delete', eventDelete, setEventDelete], 318 - ] as const 319 - ).map(([name, val, set]) => ( 320 - <div key={name} className="flex items-center gap-1.5"> 321 - <Checkbox id={`wh-event-${name}`} checked={val} onCheckedChange={(v) => set(!!v)} /> 322 - <Label htmlFor={`wh-event-${name}`} className="cursor-pointer text-xs capitalize"> 323 - {name} 351 + {/* Scope detail — known app */} 352 + {selectedApp && selectedApp !== 'other' && ( 353 + <div className="space-y-1.5"> 354 + <Label htmlFor="wh-path" className="text-xs text-muted-foreground"> 355 + Collection / glob 324 356 </Label> 357 + <div className="flex items-center gap-0 border border-border rounded-sm focus-within:border-accent transition-colors"> 358 + <span className="px-2.5 py-1.5 text-xs text-muted-foreground bg-muted/40 border-r border-border whitespace-nowrap select-none"> 359 + at://{userDid ? `${userDid.slice(0, 12)}...` : 'did'}/ 360 + </span> 361 + <input 362 + id="wh-path" 363 + value={scopePath} 364 + onChange={(e) => setScopePath(e.target.value)} 365 + placeholder="app.bsky.*" 366 + className="flex-1 px-2.5 py-1.5 text-xs bg-transparent outline-none font-mono" 367 + /> 368 + </div> 325 369 </div> 326 - ))} 327 - </div> 328 - <p className="text-xs text-muted-foreground">All checked = no filter</p> 329 - </div> 330 - )} 370 + )} 331 371 332 - {error && <p className="text-xs text-destructive">{error}</p>} 333 - {success && <p className="text-xs text-green-500">{success}</p>} 372 + {/* Scope detail — other */} 373 + {selectedApp === 'other' && ( 374 + <div className="space-y-2"> 375 + <Label className="text-xs text-muted-foreground">Scope Level</Label> 376 + <div className="space-y-1.5"> 377 + {( 378 + [ 379 + ['all', 'All my records'], 380 + ['collection', 'Specific collection'], 381 + ['rkey', 'Specific record'], 382 + ] as const 383 + ).map(([mode, label]) => ( 384 + <label key={mode} className="flex items-center gap-2 cursor-pointer group"> 385 + <input 386 + type="radio" 387 + name="other-mode" 388 + checked={otherMode === mode} 389 + onChange={() => setOtherMode(mode)} 390 + className="accent-accent" 391 + /> 392 + <span className="text-xs group-hover:text-foreground transition-colors">{label}</span> 393 + </label> 394 + ))} 395 + </div> 396 + {otherMode === 'collection' && ( 397 + <Input 398 + value={otherCollection} 399 + onChange={(e) => setOtherCollection(e.target.value)} 400 + placeholder="app.bsky.feed.post" 401 + className="h-8 text-xs font-mono" 402 + /> 403 + )} 404 + {otherMode === 'rkey' && ( 405 + <div className="flex gap-2"> 406 + <Input 407 + value={otherCollection} 408 + onChange={(e) => setOtherCollection(e.target.value)} 409 + placeholder="collection" 410 + className="h-8 text-xs flex-1 font-mono" 411 + /> 412 + <Input 413 + value={otherRkey} 414 + onChange={(e) => setOtherRkey(e.target.value)} 415 + placeholder="rkey" 416 + className="h-8 text-xs flex-1 font-mono" 417 + /> 418 + </div> 419 + )} 420 + </div> 421 + )} 334 422 335 - {selectedApp && ( 336 - <Button type="submit" disabled={isCreating || !scopeAturi} size="sm"> 337 - {isCreating ? ( 338 - <> 339 - <Loader2 className="w-3 h-3 mr-2 animate-spin" /> 340 - Creating... 341 - </> 342 - ) : ( 343 - 'Create Webhook' 344 - )} 345 - </Button> 423 + {/* Scope preview */} 424 + {scopeAturi && ( 425 + <div className="p-2.5 bg-muted/20 border border-border/20 rounded-sm"> 426 + <p className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Scope Preview</p> 427 + <p className="text-xs font-mono break-all">{scopeAturi}</p> 428 + </div> 429 + )} 430 + 431 + {/* Wildcard hint */} 432 + {scopeAturi.includes('*') && ( 433 + <p className="text-xs text-muted-foreground"> 434 + <code className="bg-muted/50 px-1.5 py-0.5 rounded-sm border border-border/20">*</code> matches any collection name at that level 435 + </p> 436 + )} 437 + 438 + {/* Options row */} 439 + {selectedApp && ( 440 + <div className="flex flex-col sm:flex-row sm:items-start gap-4 pt-1"> 441 + {/* Backlinks */} 442 + <div className="flex items-center gap-2"> 443 + <Checkbox id="wh-backlinks" checked={backlinks} onCheckedChange={(v) => setBacklinks(!!v)} /> 444 + <Label htmlFor="wh-backlinks" className="cursor-pointer text-xs"> 445 + Backlinks 446 + </Label> 447 + </div> 448 + 449 + {/* Events */} 450 + <div className="flex items-center gap-3"> 451 + <span className="text-xs text-muted-foreground">Events:</span> 452 + {( 453 + [ 454 + ['create', eventCreate, setEventCreate], 455 + ['update', eventUpdate, setEventUpdate], 456 + ['delete', eventDelete, setEventDelete], 457 + ] as const 458 + ).map(([name, val, set]) => ( 459 + <div key={name} className="flex items-center gap-1.5"> 460 + <Checkbox id={`wh-event-${name}`} checked={val} onCheckedChange={(v) => set(!!v)} /> 461 + <Label htmlFor={`wh-event-${name}`} className="cursor-pointer text-xs capitalize"> 462 + {name} 463 + </Label> 464 + </div> 465 + ))} 466 + </div> 467 + </div> 468 + )} 469 + 470 + {error && ( 471 + <div className="p-2.5 bg-destructive/10 border border-destructive/20 rounded-sm"> 472 + <p className="text-xs text-destructive">{error}</p> 473 + </div> 474 + )} 475 + {success && ( 476 + <div className="p-2.5 bg-green-500/10 border border-green-500/20 rounded-sm flex items-center gap-2"> 477 + <CheckCircle2 className="w-3 h-3 text-green-500" /> 478 + <p className="text-xs text-green-500">{success}</p> 479 + </div> 480 + )} 481 + 482 + {selectedApp && ( 483 + <Button type="submit" disabled={isCreating || !scopeAturi} size="sm" className="w-full sm:w-auto"> 484 + {isCreating ? ( 485 + <> 486 + <Loader2 className="w-3 h-3 mr-2 animate-spin" /> 487 + Creating... 488 + </> 489 + ) : ( 490 + 'Create Webhook' 491 + )} 492 + </Button> 493 + )} 494 + </form> 495 + </div> 346 496 )} 347 - </form> 348 497 349 - {/* Existing webhooks */} 350 - <div className="space-y-2"> 351 - <p className="text-xs uppercase tracking-wider text-muted-foreground">Your Webhooks</p> 498 + {/* Webhook list */} 352 499 {webhooksLoading ? ( 353 500 <div className="space-y-2"> 354 501 {['a', 'b'].map((id) => ( 355 - <SkeletonShimmer key={id} className="h-12 w-full" /> 502 + <SkeletonShimmer key={id} className="h-16 w-full" /> 356 503 ))} 357 504 </div> 358 505 ) : webhooks.length === 0 ? ( 359 - <p className="text-xs text-muted-foreground py-1">No webhooks configured.</p> 506 + <div className="py-8 text-center space-y-3"> 507 + <Webhook className="w-6 h-6 text-muted-foreground/50 mx-auto" /> 508 + <div> 509 + <p className="text-sm text-muted-foreground">No webhooks configured</p> 510 + <p className="text-xs text-muted-foreground/70 mt-1 max-w-xs mx-auto"> 511 + Create one to receive HTTP callbacks for changes to your ATProto collections 512 + </p> 513 + </div> 514 + {!showCreateForm && ( 515 + <Button 516 + variant="outline" 517 + size="sm" 518 + className="text-xs" 519 + onClick={() => setShowCreateForm(true)} 520 + > 521 + <Plus className="w-3 h-3 mr-1.5" /> 522 + Create your first webhook 523 + </Button> 524 + )} 525 + </div> 360 526 ) : ( 361 527 <div className="space-y-1.5"> 362 - {webhooks.map((wh) => ( 363 - <div key={wh.rkey} className="flex items-start justify-between p-3 border border-border/30 gap-4"> 364 - <div className="space-y-0.5 min-w-0"> 365 - <p className="text-xs truncate">{wh.url}</p> 366 - <p className="text-xs text-muted-foreground truncate">{wh.scopeAturi}</p> 367 - <div className="flex gap-1 flex-wrap mt-1"> 368 - {!wh.enabled && ( 369 - <Badge variant="secondary" className="text-[10px]"> 370 - disabled 371 - </Badge> 372 - )} 373 - {wh.backlinks && ( 374 - <Badge variant="outline" className="text-[10px]"> 375 - backlinks 376 - </Badge> 377 - )} 378 - {wh.events.length > 0 && 379 - wh.events.map((e) => ( 380 - <Badge key={e} variant="outline" className="text-[10px]"> 381 - {e} 528 + {webhooks.map((wh, idx) => { 529 + const isFocused = idx === focusedWebhook 530 + // Extract the collection/path from the AT-URI for display 531 + const scopeParts = wh.scopeAturi.replace('at://', '').split('/') 532 + const scopeDisplay = scopeParts.slice(1).join('/') || 'all records' 533 + 534 + return ( 535 + <div 536 + key={wh.rkey} 537 + ref={(el) => { 538 + itemRefs.current[idx] = el 539 + }} 540 + className={`flex items-start justify-between p-3 border transition-colors ${ 541 + isFocused ? 'border-accent bg-accent/10' : 'border-border/30 hover:bg-muted/10' 542 + }`} 543 + > 544 + <div className="space-y-1.5 min-w-0 flex-1"> 545 + <div className="flex items-center gap-2"> 546 + <div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${wh.enabled ? 'bg-green-500' : 'bg-muted-foreground/30'}`} /> 547 + <p className="text-xs font-medium truncate">{wh.url}</p> 548 + <a 549 + href={wh.url} 550 + target="_blank" 551 + rel="noopener noreferrer" 552 + className="text-muted-foreground hover:text-foreground transition-colors flex-shrink-0" 553 + onClick={(e) => e.stopPropagation()} 554 + > 555 + <ExternalLink className="w-2.5 h-2.5" /> 556 + </a> 557 + </div> 558 + <p className="text-xs text-muted-foreground truncate ml-3.5 font-mono">{scopeDisplay}</p> 559 + <div className="flex gap-1 flex-wrap ml-3.5"> 560 + {!wh.enabled && ( 561 + <Badge variant="secondary" className="text-[10px]"> 562 + disabled 382 563 </Badge> 383 - ))} 564 + )} 565 + {wh.backlinks && ( 566 + <Badge variant="outline" className="text-[10px]"> 567 + backlinks 568 + </Badge> 569 + )} 570 + {wh.events.length > 0 571 + ? wh.events.map((e) => ( 572 + <Badge key={e} variant="outline" className="text-[10px]"> 573 + {e} 574 + </Badge> 575 + )) 576 + : ( 577 + <Badge variant="outline" className="text-[10px]"> 578 + all events 579 + </Badge> 580 + )} 581 + </div> 384 582 </div> 583 + <Button 584 + variant="ghost" 585 + size="sm" 586 + onClick={() => handleDelete(wh.rkey)} 587 + disabled={deletingRkey === wh.rkey} 588 + className="flex-shrink-0 h-7 w-7 p-0 text-muted-foreground hover:text-destructive" 589 + > 590 + {deletingRkey === wh.rkey ? ( 591 + <Loader2 className="w-3 h-3 animate-spin" /> 592 + ) : ( 593 + <Trash2 className="w-3 h-3" /> 594 + )} 595 + </Button> 385 596 </div> 386 - <Button 387 - variant="ghost" 388 - size="sm" 389 - onClick={() => handleDelete(wh.rkey)} 390 - disabled={deletingRkey === wh.rkey} 391 - className="flex-shrink-0 h-7 w-7 p-0 text-muted-foreground hover:text-destructive" 392 - > 393 - {deletingRkey === wh.rkey ? ( 394 - <Loader2 className="w-3 h-3 animate-spin" /> 395 - ) : ( 396 - <Trash2 className="w-3 h-3" /> 397 - )} 398 - </Button> 399 - </div> 400 - ))} 597 + ) 598 + })} 401 599 </div> 402 600 )} 403 601 </div> 404 602 405 - {/* Event logs */} 406 - <div className="space-y-2"> 407 - <div className="flex items-center justify-between"> 603 + {/* Event Logs */} 604 + <div className="p-4 border-t border-border/30 space-y-2"> 605 + <div className="flex items-center justify-between mb-3"> 408 606 <p className="text-xs uppercase tracking-wider text-muted-foreground"> 409 - Event Logs <span className="font-normal normal-case">(60s refresh)</span> 607 + Recent Deliveries 410 608 </p> 411 609 <Button 412 610 variant="outline" ··· 423 621 {eventLogsLoading ? ( 424 622 <div className="space-y-1.5"> 425 623 {['a', 'b', 'c'].map((id) => ( 426 - <SkeletonShimmer key={id} className="h-7 w-full" /> 624 + <SkeletonShimmer key={id} className="h-10 w-full" /> 427 625 ))} 428 626 </div> 429 627 ) : eventLogs.length === 0 ? ( 430 - <p className="text-xs text-muted-foreground py-1">No events yet.</p> 628 + <p className="text-xs text-muted-foreground py-4 text-center">No delivery events yet</p> 431 629 ) : ( 432 - <div className="overflow-x-auto border border-border/30"> 433 - <table className="w-full text-xs font-mono border-collapse"> 434 - <thead> 435 - <tr className="border-b border-border/30 text-muted-foreground bg-muted/20"> 436 - <th className="text-left py-1.5 px-3">Time</th> 437 - <th className="text-left py-1.5 px-3">Status</th> 438 - <th className="text-left py-1.5 px-3">Event</th> 439 - <th className="text-left py-1.5 px-3">Source</th> 440 - <th className="text-left py-1.5 px-3">Collection</th> 441 - <th className="text-left py-1.5 px-3">Rkey</th> 442 - <th className="text-left py-1.5 px-3">Delivered To</th> 443 - </tr> 444 - </thead> 445 - <tbody> 446 - {eventLogs.map((log) => ( 447 - <tr key={`${log.rkey}-${log.deliveredAt}`} className="border-b border-border/20 hover:bg-muted/20"> 448 - <td className="py-1.5 px-3 text-muted-foreground whitespace-nowrap"> 449 - {new Date(log.deliveredAt).toLocaleTimeString()} 450 - </td> 451 - <td className="py-1.5 px-3"> 452 - <Badge variant={log.status === 'ok' ? 'default' : 'destructive'} className="text-[10px]"> 453 - {log.status} 454 - </Badge> 455 - </td> 456 - <td className="py-1.5 px-3">{log.eventKind}</td> 457 - <td className="py-1.5 px-3 truncate max-w-[8rem]">{log.eventDid}</td> 458 - <td className="py-1.5 px-3">{log.eventCollection}</td> 459 - <td className="py-1.5 px-3">{log.eventRkey}</td> 460 - <td className="py-1.5 px-3 truncate max-w-[8rem]">{log.url}</td> 461 - </tr> 462 - ))} 463 - </tbody> 464 - </table> 630 + <div className="space-y-1"> 631 + {eventLogs.map((log) => ( 632 + <div 633 + key={`${log.rkey}-${log.deliveredAt}`} 634 + className="flex items-center gap-3 p-2.5 border border-border/20 hover:bg-muted/10 transition-colors" 635 + > 636 + {/* Status indicator */} 637 + <div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${ 638 + log.status === 'ok' ? 'bg-green-500' : 'bg-red-500' 639 + }`} /> 640 + 641 + {/* Event info */} 642 + <div className="flex-1 min-w-0"> 643 + <div className="flex items-center gap-2"> 644 + <Badge 645 + variant={log.status === 'ok' ? 'default' : 'destructive'} 646 + className="text-[10px]" 647 + > 648 + {log.status === 'ok' ? '200' : 'ERR'} 649 + </Badge> 650 + <span className="text-xs font-medium capitalize">{log.eventKind}</span> 651 + <span className="text-xs text-muted-foreground truncate">{log.eventCollection}</span> 652 + </div> 653 + <div className="flex items-center gap-2 mt-0.5"> 654 + <span className="text-[10px] text-muted-foreground truncate max-w-[12rem]">{log.url}</span> 655 + <span className="text-[10px] text-muted-foreground/50">•</span> 656 + <span className="text-[10px] text-muted-foreground whitespace-nowrap">{formatTimeAgo(log.deliveredAt)}</span> 657 + </div> 658 + </div> 659 + </div> 660 + ))} 465 661 </div> 466 662 )} 467 663 </div>
+6 -5
apps/main-app/public/styles/global.css
··· 36 36 37 37 /* Significantly darker border for visibility */ 38 38 --border: oklch(0.65 0.02 30); 39 - /* Input backgrounds lighter than cards */ 40 - --input: oklch(0.97 0.005 35); 39 + /* Input backgrounds - distinct from card background */ 40 + --input: oklch(0.82 0.01 35); 41 41 --ring: #ff5c8a; 42 42 43 43 --destructive: oklch(0.50 0.20 25); ··· 91 91 --muted: oklch(0.33 0.015 285); 92 92 --muted-foreground: oklch(0.72 0.01 285); 93 93 94 - /* Subtle borders */ 95 - --border: oklch(0.38 0.02 285); 96 - --input: oklch(0.30 0.015 285); 94 + /* Borders with more contrast */ 95 + --border: oklch(0.42 0.02 285); 96 + /* Input borders - more visible against card backgrounds */ 97 + --input: oklch(0.42 0.02 285); 97 98 --ring: oklch(0.70 0.10 295); 98 99 99 100 /* Warm destructive color */
+1 -1
apps/main-app/src/lib/oauth-client.ts
··· 6 6 7 7 // OAuth scope for all client types 8 8 const OAUTH_SCOPE = 9 - 'atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs repo:place.wisp.settings blob:*/*' 9 + 'atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs repo:place.wisp.settings repo:place.wisp.v2.wh blob:*/*' 10 10 // Session timeout configuration (30 days in seconds) 11 11 const SESSION_TIMEOUT = 30 * 24 * 60 * 60 // 2592000 seconds 12 12 // OAuth state timeout (1 hour in seconds)
+1 -1
apps/webhook-service/src/config.ts
··· 1 1 export const config = { 2 - firehoseService: process.env.FIREHOSE_SERVICE || 'wss://bsky.network', 2 + firehoseService: process.env.FIREHOSE_SERVICE || 'wss://relay.fire.hose.cam', 3 3 healthPort: parseInt(process.env.HEALTH_PORT || '3003', 10), 4 4 deliveryTimeoutMs: parseInt(process.env.DELIVERY_TIMEOUT_MS || '10000', 10), 5 5 deliveryMaxRetries: parseInt(process.env.DELIVERY_MAX_RETRIES || '3', 10),
+9 -1
apps/webhook-service/src/lib/firehose.ts
··· 109 109 110 110 export function startFirehose(): void { 111 111 logger.info(`Starting firehose (runtime: ${isBun ? 'Bun' : 'Node.js'})`) 112 - isConnected = true 113 112 114 113 if (isBun) { 115 114 const f = new BunFirehose({ ··· 118 117 unauthenticatedCommits: true, 119 118 handleEvent, 120 119 onError: handleError, 120 + onConnect: () => { 121 + isConnected = true 122 + logger.info('Firehose connected') 123 + }, 124 + onDisconnect: () => { 125 + isConnected = false 126 + logger.warn('Firehose disconnected, will reconnect') 127 + }, 121 128 }) 122 129 f.start() 123 130 firehoseHandle = { destroy: () => f.destroy() } 124 131 } else { 132 + isConnected = true 125 133 const f = new Firehose({ 126 134 idResolver, 127 135 service: config.firehoseService,
+7
packages/@wispplace/bun-firehose/src/firehose.ts
··· 118 118 service: string 119 119 handleEvent: (evt: Event) => Promise<void> | void 120 120 onError: (err: Error) => void 121 + onConnect?: () => void 122 + onDisconnect?: () => void 121 123 filterCollections?: string[] 122 124 unauthenticatedCommits?: boolean 123 125 getCursor?: () => number | undefined | Promise<number | undefined> 126 + /** Force reconnect if no messages received within this many ms (default: 15000) */ 127 + maxSilenceMs?: number 124 128 } 125 129 126 130 export class BunFirehose { ··· 166 170 onReconnectError: (err, n) => { 167 171 this.opts.onError(new Error(`Reconnect attempt ${n}: ${err}`)) 168 172 }, 173 + onConnect: this.opts.onConnect, 174 + onDisconnect: this.opts.onDisconnect, 175 + maxSilenceMs: this.opts.maxSilenceMs, 169 176 }) 170 177 171 178 try {
+26
packages/@wispplace/bun-firehose/src/subscription.ts
··· 38 38 validate: (obj: unknown) => T | undefined 39 39 getParams?: () => Record<string, unknown> | Promise<Record<string, unknown> | undefined> | undefined 40 40 onReconnectError?: (error: unknown, n: number, initialSetup: boolean) => void 41 + onConnect?: () => void 42 + onDisconnect?: () => void 41 43 maxReconnectSeconds?: number 44 + /** Force reconnect if no messages received within this many ms (default: 15000 = 15s) */ 45 + maxSilenceMs?: number 42 46 } 43 47 44 48 export class BunSubscription<T = unknown> { ··· 69 73 } 70 74 71 75 async *[Symbol.asyncIterator](): AsyncGenerator<T> { 76 + const maxSilenceMs = this.opts.maxSilenceMs ?? 15_000 77 + 72 78 while (!this.aborted) { 79 + let silenceTimer: ReturnType<typeof setTimeout> | null = null 80 + 73 81 try { 74 82 const url = await this.getUrl() 75 83 ··· 80 88 let wsOpen = false 81 89 let wsClosed = false 82 90 91 + const resetSilenceTimer = () => { 92 + if (silenceTimer) clearTimeout(silenceTimer) 93 + silenceTimer = setTimeout(() => { 94 + if (!wsClosed && !this.aborted) { 95 + console.warn(`[BunSubscription] No messages for ${maxSilenceMs / 1000}s, forcing reconnect`) 96 + wsClosed = true 97 + this.ws?.close() 98 + resolveMessage?.() 99 + } 100 + }, maxSilenceMs) 101 + } 102 + 83 103 this.ws = new WebSocket(url) 84 104 this.ws.binaryType = 'arraybuffer' 85 105 86 106 this.ws.addEventListener('open', () => { 87 107 wsOpen = true 88 108 this.reconnectAttempts = 0 109 + this.opts.onConnect?.() 110 + resetSilenceTimer() 89 111 }) 90 112 91 113 this.ws.addEventListener('message', (event) => { 92 114 const data = event.data 93 115 if (data instanceof ArrayBuffer) { 94 116 messageQueue.push(new Uint8Array(data)) 117 + resetSilenceTimer() 95 118 resolveMessage?.() 96 119 } 97 120 }) ··· 102 125 103 126 this.ws.addEventListener('close', () => { 104 127 wsClosed = true 128 + this.opts.onDisconnect?.() 105 129 resolveMessage?.() 106 130 }) 107 131 ··· 159 183 } 160 184 161 185 // Clean up 186 + if (silenceTimer) clearTimeout(silenceTimer) 162 187 this.ws?.close() 163 188 this.ws = null 164 189 ··· 170 195 this.opts.onReconnectError?.(new Error('Connection closed'), this.reconnectAttempts, false) 171 196 await new Promise((resolve) => setTimeout(resolve, delay)) 172 197 } catch (err) { 198 + if (silenceTimer) clearTimeout(silenceTimer) 173 199 this.ws?.close() 174 200 this.ws = null 175 201