tangled
alpha
login
or
join now
nekomimi.pet
/
wisp.place-monorepo
87
fork
atom
Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
87
fork
atom
overview
issues
9
pulls
pipelines
styling work
nekomimi.pet
2 weeks ago
95ddd1dd
f1f32112
1/2
deploy-wisp.yml
success
38s
test.yml
failed
40s
+203
-82
2 changed files
expand all
collapse all
unified
split
apps
main-app
public
editor
editor.tsx
tabs
WebhooksTab.tsx
+2
-1
apps/main-app/public/editor/editor.tsx
reviewed
···
532
532
eventLogs={eventLogs}
533
533
eventLogsLoading={eventLogsLoading}
534
534
isCreating={isCreating}
535
535
-
onCreateWebhook={createWebhook}
535
535
+
userDid={userInfo?.did}
536
536
+
onCreateWebhook={createWebhook}
536
537
onDeleteWebhook={deleteWebhook}
537
538
onRefreshEvents={fetchEventLogs}
538
539
/>
+201
-81
apps/main-app/public/editor/tabs/WebhooksTab.tsx
reviewed
···
8
8
import { Loader2, RefreshCw, Trash2 } from 'lucide-react'
9
9
import type { WebhookRecord, WebhookEventLog } from '../hooks/useWebhookData'
10
10
11
11
+
const APPS = [
12
12
+
{ id: 'bluesky', label: 'Bluesky', path: 'app.bsky.*' },
13
13
+
{ id: 'tangled', label: 'Tangled', path: 'chat.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.*' },
17
17
+
] as const
18
18
+
19
19
+
type AppId = typeof APPS[number]['id'] | 'other'
20
20
+
type OtherMode = 'all' | 'collection' | 'rkey'
21
21
+
11
22
interface WebhooksTabProps {
12
23
webhooks: WebhookRecord[]
13
24
webhooksLoading: boolean
14
25
eventLogs: WebhookEventLog[]
15
26
eventLogsLoading: boolean
16
27
isCreating: boolean
28
28
+
userDid?: string
17
29
onCreateWebhook: (data: {
18
30
scopeAturi: string
19
31
url: string
···
26
38
onRefreshEvents: () => Promise<void>
27
39
}
28
40
41
41
+
function buildScope(userDid: string, selectedApp: AppId | null, scopePath: string, otherMode: OtherMode, otherCollection: string, otherRkey: string): string {
42
42
+
if (!userDid) return ''
43
43
+
if (!selectedApp) return ''
44
44
+
if (selectedApp === 'other') {
45
45
+
if (otherMode === 'all') return `at://${userDid}`
46
46
+
if (otherMode === 'collection') return otherCollection ? `at://${userDid}/${otherCollection}` : ''
47
47
+
return (otherCollection && otherRkey) ? `at://${userDid}/${otherCollection}/${otherRkey}` : ''
48
48
+
}
49
49
+
return scopePath ? `at://${userDid}/${scopePath}` : `at://${userDid}`
50
50
+
}
51
51
+
29
52
export function WebhooksTab({
30
53
webhooks,
31
54
webhooksLoading,
32
55
eventLogs,
33
56
eventLogsLoading,
34
57
isCreating,
58
58
+
userDid = '',
35
59
onCreateWebhook,
36
60
onDeleteWebhook,
37
61
onRefreshEvents,
38
62
}: WebhooksTabProps) {
39
63
const [url, setUrl] = useState('')
40
40
-
const [scopeAturi, setScopeAturi] = useState('')
64
64
+
const [selectedApp, setSelectedApp] = useState<AppId | null>(null)
65
65
+
const [scopePath, setScopePath] = useState('')
66
66
+
const [otherMode, setOtherMode] = useState<OtherMode>('all')
67
67
+
const [otherCollection, setOtherCollection] = useState('')
68
68
+
const [otherRkey, setOtherRkey] = useState('')
41
69
const [backlinks, setBacklinks] = useState(false)
42
70
const [eventCreate, setEventCreate] = useState(true)
43
71
const [eventUpdate, setEventUpdate] = useState(true)
44
72
const [eventDelete, setEventDelete] = useState(true)
45
45
-
const [secret, setSecret] = useState('')
46
46
-
const [enabled, setEnabled] = useState(true)
47
73
const [error, setError] = useState<string | null>(null)
48
74
const [success, setSuccess] = useState<string | null>(null)
49
75
const [deletingRkey, setDeletingRkey] = useState<string | null>(null)
50
76
51
51
-
// Auto-refresh event logs every 60 seconds
52
77
useEffect(() => {
53
78
const id = setInterval(onRefreshEvents, 60_000)
54
79
return () => clearInterval(id)
55
80
}, [onRefreshEvents])
56
81
82
82
+
const selectApp = (id: AppId) => {
83
83
+
setSelectedApp(id)
84
84
+
if (id !== 'other') {
85
85
+
const app = APPS.find(a => a.id === id)
86
86
+
setScopePath(app?.path ?? '')
87
87
+
}
88
88
+
setError(null)
89
89
+
}
90
90
+
91
91
+
const scopeAturi = buildScope(userDid, selectedApp, scopePath, otherMode, otherCollection, otherRkey)
92
92
+
57
93
const handleCreate = async (e: React.FormEvent) => {
58
94
e.preventDefault()
59
95
setError(null)
···
63
99
setError('URL must start with http:// or https://')
64
100
return
65
101
}
66
66
-
if (!scopeAturi.startsWith('at://')) {
67
67
-
setError('Scope must be a valid AT-URI (at://...)')
102
102
+
if (!scopeAturi || !scopeAturi.startsWith('at://')) {
103
103
+
setError('Please select an app scope above')
68
104
return
69
105
}
70
106
···
74
110
if (eventDelete) events.push('delete')
75
111
76
112
try {
77
77
-
await onCreateWebhook({ url, scopeAturi, backlinks, events: events.length === 3 ? [] : events, secret, enabled })
78
78
-
setSuccess('Webhook created. It will become active once the service picks it up from the firehose.')
113
113
+
await onCreateWebhook({ url, scopeAturi, backlinks, events: events.length === 3 ? [] : events, secret: '', enabled: true })
114
114
+
setSuccess('Webhook created.')
79
115
setUrl('')
80
80
-
setScopeAturi('')
116
116
+
setSelectedApp(null)
117
117
+
setScopePath('')
118
118
+
setOtherMode('all')
119
119
+
setOtherCollection('')
120
120
+
setOtherRkey('')
81
121
setBacklinks(false)
82
122
setEventCreate(true)
83
123
setEventUpdate(true)
84
124
setEventDelete(true)
85
85
-
setSecret('')
86
86
-
setEnabled(true)
87
125
} catch (err) {
88
126
setError(err instanceof Error ? err.message : 'Failed to create webhook')
89
127
}
···
108
146
<p className="text-xs text-muted-foreground mt-0.5">Receive HTTP callbacks when AT Protocol records change</p>
109
147
</div>
110
148
111
111
-
{/* Content */}
112
149
<div className="flex-1 min-h-0 overflow-y-auto p-4 space-y-6">
113
150
114
114
-
{/* Create webhook form */}
115
115
-
<div className="space-y-3">
151
151
+
{/* Create form */}
152
152
+
<form onSubmit={handleCreate} className="space-y-4">
116
153
<p className="text-xs uppercase tracking-wider text-muted-foreground">Create Webhook</p>
117
117
-
<form onSubmit={handleCreate} className="space-y-3 p-3 border border-border/30">
118
118
-
<div className="space-y-1">
119
119
-
<Label htmlFor="wh-url" className="text-xs">URL <span className="text-muted-foreground">(required)</span></Label>
120
120
-
<Input
121
121
-
id="wh-url"
122
122
-
value={url}
123
123
-
onChange={e => setUrl(e.target.value)}
124
124
-
placeholder="https://example.com/webhook"
125
125
-
required
126
126
-
className="h-8 text-sm"
127
127
-
/>
154
154
+
155
155
+
{/* URL */}
156
156
+
<div className="space-y-1">
157
157
+
<Label htmlFor="wh-url" className="text-xs text-muted-foreground">URL</Label>
158
158
+
<Input
159
159
+
id="wh-url"
160
160
+
value={url}
161
161
+
onChange={e => setUrl(e.target.value)}
162
162
+
placeholder="https://example.com/webhook"
163
163
+
required
164
164
+
className="h-8 text-sm"
165
165
+
/>
166
166
+
</div>
167
167
+
168
168
+
{/* App picker */}
169
169
+
<div className="space-y-2">
170
170
+
<Label className="text-xs text-muted-foreground">App</Label>
171
171
+
<div className="flex flex-wrap gap-1.5">
172
172
+
{APPS.map(app => (
173
173
+
<button
174
174
+
key={app.id}
175
175
+
type="button"
176
176
+
onClick={() => selectApp(app.id)}
177
177
+
className={`px-3 py-1 text-xs border transition-colors ${
178
178
+
selectedApp === app.id
179
179
+
? 'border-accent bg-accent/20 text-foreground'
180
180
+
: 'border-border/40 text-muted-foreground hover:border-border hover:text-foreground'
181
181
+
}`}
182
182
+
>
183
183
+
{app.label}
184
184
+
</button>
185
185
+
))}
186
186
+
<button
187
187
+
type="button"
188
188
+
onClick={() => selectApp('other')}
189
189
+
className={`px-3 py-1 text-xs border transition-colors ${
190
190
+
selectedApp === 'other'
191
191
+
? 'border-accent bg-accent/20 text-foreground'
192
192
+
: 'border-border/40 text-muted-foreground hover:border-border hover:text-foreground'
193
193
+
}`}
194
194
+
>
195
195
+
Other
196
196
+
</button>
128
197
</div>
198
198
+
</div>
129
199
200
200
+
{/* Scope detail — known app */}
201
201
+
{selectedApp && selectedApp !== 'other' && (
130
202
<div className="space-y-1">
131
131
-
<Label htmlFor="wh-scope" className="text-xs">Scope <span className="text-muted-foreground">(required)</span></Label>
132
132
-
<Input
133
133
-
id="wh-scope"
134
134
-
value={scopeAturi}
135
135
-
onChange={e => setScopeAturi(e.target.value)}
136
136
-
placeholder="at://did:plc:... or at://did:plc:.../app.bsky.*"
137
137
-
required
138
138
-
className="h-8 text-sm"
139
139
-
/>
140
140
-
<p className="text-xs text-muted-foreground">
141
141
-
<code className="bg-muted px-1">at://did</code> watches all records,{' '}
142
142
-
<code className="bg-muted px-1">at://did/collection</code> for a specific one,{' '}
143
143
-
<code className="bg-muted px-1">at://did/app.bsky.*</code> for a glob.
144
144
-
</p>
203
203
+
<Label htmlFor="wh-path" className="text-xs text-muted-foreground">Collection / glob</Label>
204
204
+
<div className="flex items-center gap-0 border border-border/40 focus-within:border-border">
205
205
+
<span className="px-2 py-1.5 text-xs text-muted-foreground bg-muted/40 border-r border-border/40 whitespace-nowrap select-none">
206
206
+
at://{userDid || 'did'}/
207
207
+
</span>
208
208
+
<input
209
209
+
id="wh-path"
210
210
+
value={scopePath}
211
211
+
onChange={e => setScopePath(e.target.value)}
212
212
+
placeholder="app.bsky.*"
213
213
+
className="flex-1 px-2 py-1.5 text-xs bg-transparent outline-none font-mono"
214
214
+
/>
215
215
+
</div>
145
216
</div>
217
217
+
)}
146
218
219
219
+
{/* Scope detail — other */}
220
220
+
{selectedApp === 'other' && (
221
221
+
<div className="space-y-2">
222
222
+
<Label className="text-xs text-muted-foreground">Scope</Label>
223
223
+
<div className="space-y-1.5">
224
224
+
{([
225
225
+
['all', 'All my records', `at://${userDid || 'did'}`],
226
226
+
['collection', 'Specific collection', ''],
227
227
+
['rkey', 'Specific record', ''],
228
228
+
] as const).map(([mode, label, hint]) => (
229
229
+
<label key={mode} className="flex items-start gap-2 cursor-pointer group">
230
230
+
<input
231
231
+
type="radio"
232
232
+
name="other-mode"
233
233
+
checked={otherMode === mode}
234
234
+
onChange={() => setOtherMode(mode)}
235
235
+
className="mt-0.5 accent-accent"
236
236
+
/>
237
237
+
<div className="flex-1">
238
238
+
<span className="text-xs">{label}</span>
239
239
+
{hint && <span className="text-xs text-muted-foreground ml-2">{hint}</span>}
240
240
+
</div>
241
241
+
</label>
242
242
+
))}
243
243
+
</div>
244
244
+
{otherMode === 'collection' && (
245
245
+
<Input
246
246
+
value={otherCollection}
247
247
+
onChange={e => setOtherCollection(e.target.value)}
248
248
+
placeholder="app.bsky.feed.post"
249
249
+
className="h-8 text-xs"
250
250
+
/>
251
251
+
)}
252
252
+
{otherMode === 'rkey' && (
253
253
+
<div className="flex gap-2">
254
254
+
<Input
255
255
+
value={otherCollection}
256
256
+
onChange={e => setOtherCollection(e.target.value)}
257
257
+
placeholder="collection"
258
258
+
className="h-8 text-xs flex-1"
259
259
+
/>
260
260
+
<Input
261
261
+
value={otherRkey}
262
262
+
onChange={e => setOtherRkey(e.target.value)}
263
263
+
placeholder="rkey"
264
264
+
className="h-8 text-xs flex-1"
265
265
+
/>
266
266
+
</div>
267
267
+
)}
268
268
+
</div>
269
269
+
)}
270
270
+
271
271
+
{/* Wildcard hint */}
272
272
+
{scopeAturi.includes('*') && (
273
273
+
<p className="text-xs text-muted-foreground">
274
274
+
<code className="bg-muted px-1">*</code> is a wildcard — matches any collection name at that level.
275
275
+
</p>
276
276
+
)}
277
277
+
278
278
+
{/* Backlinks */}
279
279
+
{selectedApp && (
147
280
<div className="flex items-center gap-2">
148
281
<Checkbox id="wh-backlinks" checked={backlinks} onCheckedChange={v => setBacklinks(!!v)} />
149
282
<Label htmlFor="wh-backlinks" className="cursor-pointer text-xs">
150
283
Backlinks <span className="text-muted-foreground">— also fire when other records reference this scope</span>
151
284
</Label>
152
285
</div>
286
286
+
)}
153
287
288
288
+
{/* Events */}
289
289
+
{selectedApp && (
154
290
<div className="space-y-1.5">
155
155
-
<Label className="text-xs">Events</Label>
291
291
+
<Label className="text-xs text-muted-foreground">Events</Label>
156
292
<div className="flex gap-4">
157
293
{([['create', eventCreate, setEventCreate], ['update', eventUpdate, setEventUpdate], ['delete', eventDelete, setEventDelete]] as const).map(([name, val, set]) => (
158
294
<div key={name} className="flex items-center gap-1.5">
···
161
297
</div>
162
298
))}
163
299
</div>
164
164
-
<p className="text-xs text-muted-foreground">All checked = no filter (fires on all events)</p>
300
300
+
<p className="text-xs text-muted-foreground">All checked = no filter</p>
165
301
</div>
302
302
+
)}
166
303
167
167
-
<div className="space-y-1">
168
168
-
<Label htmlFor="wh-secret" className="text-xs">Secret <span className="text-muted-foreground">(optional)</span></Label>
169
169
-
<Input
170
170
-
id="wh-secret"
171
171
-
type="password"
172
172
-
value={secret}
173
173
-
onChange={e => setSecret(e.target.value)}
174
174
-
placeholder="HMAC-SHA256 signing secret"
175
175
-
className="h-8 text-sm"
176
176
-
/>
177
177
-
<p className="text-xs text-muted-foreground">Stored publicly in your PDS record — transport integrity, not authentication.</p>
178
178
-
</div>
304
304
+
{error && <p className="text-xs text-destructive">{error}</p>}
305
305
+
{success && <p className="text-xs text-green-500">{success}</p>}
179
306
180
180
-
<div className="flex items-center gap-2">
181
181
-
<Checkbox id="wh-enabled" checked={enabled} onCheckedChange={v => setEnabled(!!v)} />
182
182
-
<Label htmlFor="wh-enabled" className="cursor-pointer text-xs">Enabled</Label>
183
183
-
</div>
184
184
-
185
185
-
{error && <p className="text-xs text-destructive">{error}</p>}
186
186
-
{success && <p className="text-xs text-green-500">{success}</p>}
187
187
-
188
188
-
<Button type="submit" disabled={isCreating} size="sm" className="w-full sm:w-auto">
307
307
+
{selectedApp && (
308
308
+
<Button type="submit" disabled={isCreating || !scopeAturi} size="sm">
189
309
{isCreating ? <><Loader2 className="w-3 h-3 mr-2 animate-spin" />Creating...</> : 'Create Webhook'}
190
310
</Button>
191
191
-
</form>
192
192
-
</div>
311
311
+
)}
312
312
+
</form>
193
313
194
314
{/* Existing webhooks */}
195
315
<div className="space-y-2">
196
316
<p className="text-xs uppercase tracking-wider text-muted-foreground">Your Webhooks</p>
197
317
{webhooksLoading ? (
198
318
<div className="space-y-2">
199
199
-
{[...Array(2)].map((_, i) => <SkeletonShimmer key={i} className="h-14 w-full" />)}
319
319
+
{[...Array(2)].map((_, i) => <SkeletonShimmer key={i} className="h-12 w-full" />)}
200
320
</div>
201
321
) : webhooks.length === 0 ? (
202
202
-
<p className="text-xs text-muted-foreground py-2">No webhooks configured.</p>
322
322
+
<p className="text-xs text-muted-foreground py-1">No webhooks configured.</p>
203
323
) : (
204
204
-
<div className="space-y-2">
324
324
+
<div className="space-y-1.5">
205
325
{webhooks.map(wh => (
206
326
<div key={wh.rkey} className="flex items-start justify-between p-3 border border-border/30 gap-4">
207
207
-
<div className="space-y-1 min-w-0">
208
208
-
<p className="text-sm truncate">{wh.url}</p>
327
327
+
<div className="space-y-0.5 min-w-0">
328
328
+
<p className="text-xs truncate">{wh.url}</p>
209
329
<p className="text-xs text-muted-foreground truncate">{wh.scopeAturi}</p>
210
210
-
<div className="flex gap-1.5 flex-wrap">
330
330
+
<div className="flex gap-1 flex-wrap mt-1">
211
331
{!wh.enabled && <Badge variant="secondary" className="text-[10px]">disabled</Badge>}
212
332
{wh.backlinks && <Badge variant="outline" className="text-[10px]">backlinks</Badge>}
213
333
{wh.events.length > 0 && wh.events.map(e => <Badge key={e} variant="outline" className="text-[10px]">{e}</Badge>)}
···
232
352
<div className="space-y-2">
233
353
<div className="flex items-center justify-between">
234
354
<p className="text-xs uppercase tracking-wider text-muted-foreground">
235
235
-
Event Logs <span className="font-normal normal-case">(auto-refreshes every 60s)</span>
355
355
+
Event Logs <span className="font-normal normal-case">(60s refresh)</span>
236
356
</p>
237
357
<Button variant="outline" size="sm" onClick={onRefreshEvents} disabled={eventLogsLoading} className="h-7 px-2 gap-1.5 text-xs">
238
358
<RefreshCw className={`w-3 h-3 ${eventLogsLoading ? 'animate-spin' : ''}`} />
···
245
365
{[...Array(3)].map((_, i) => <SkeletonShimmer key={i} className="h-7 w-full" />)}
246
366
</div>
247
367
) : eventLogs.length === 0 ? (
248
248
-
<p className="text-xs text-muted-foreground py-2">No events yet.</p>
368
368
+
<p className="text-xs text-muted-foreground py-1">No events yet.</p>
249
369
) : (
250
370
<div className="overflow-x-auto border border-border/30">
251
371
<table className="w-full text-xs font-mono border-collapse">
252
372
<thead>
253
373
<tr className="border-b border-border/30 text-muted-foreground bg-muted/20">
254
254
-
<th className="text-left py-2 px-3">Time</th>
255
255
-
<th className="text-left py-2 px-3">Status</th>
256
256
-
<th className="text-left py-2 px-3">Event</th>
257
257
-
<th className="text-left py-2 px-3">Source DID</th>
258
258
-
<th className="text-left py-2 px-3">Collection</th>
259
259
-
<th className="text-left py-2 px-3">Rkey</th>
260
260
-
<th className="text-left py-2 px-3">Delivered To</th>
374
374
+
<th className="text-left py-1.5 px-3">Time</th>
375
375
+
<th className="text-left py-1.5 px-3">Status</th>
376
376
+
<th className="text-left py-1.5 px-3">Event</th>
377
377
+
<th className="text-left py-1.5 px-3">Source</th>
378
378
+
<th className="text-left py-1.5 px-3">Collection</th>
379
379
+
<th className="text-left py-1.5 px-3">Rkey</th>
380
380
+
<th className="text-left py-1.5 px-3">Delivered To</th>
261
381
</tr>
262
382
</thead>
263
383
<tbody>
···
268
388
<Badge variant={log.status === 'ok' ? 'default' : 'destructive'} className="text-[10px]">{log.status}</Badge>
269
389
</td>
270
390
<td className="py-1.5 px-3">{log.eventKind}</td>
271
271
-
<td className="py-1.5 px-3 truncate max-w-[10rem]">{log.eventDid}</td>
391
391
+
<td className="py-1.5 px-3 truncate max-w-[8rem]">{log.eventDid}</td>
272
392
<td className="py-1.5 px-3">{log.eventCollection}</td>
273
393
<td className="py-1.5 px-3">{log.eventRkey}</td>
274
274
-
<td className="py-1.5 px-3 truncate max-w-[10rem]">{log.url}</td>
394
394
+
<td className="py-1.5 px-3 truncate max-w-[8rem]">{log.url}</td>
275
395
</tr>
276
396
))}
277
397
</tbody>