diff --git a/apps/main-app/public/editor/editor.tsx b/apps/main-app/public/editor/editor.tsx index 6d5d017..c0832e8 100644 --- a/apps/main-app/public/editor/editor.tsx +++ b/apps/main-app/public/editor/editor.tsx @@ -532,7 +532,8 @@ function Dashboard() { eventLogs={eventLogs} eventLogsLoading={eventLogsLoading} isCreating={isCreating} - onCreateWebhook={createWebhook} + userDid={userInfo?.did} + onCreateWebhook={createWebhook} onDeleteWebhook={deleteWebhook} onRefreshEvents={fetchEventLogs} /> diff --git a/apps/main-app/public/editor/tabs/WebhooksTab.tsx b/apps/main-app/public/editor/tabs/WebhooksTab.tsx index 986da8e..95156d6 100644 --- a/apps/main-app/public/editor/tabs/WebhooksTab.tsx +++ b/apps/main-app/public/editor/tabs/WebhooksTab.tsx @@ -8,12 +8,24 @@ import { SkeletonShimmer } from '@public/components/ui/skeleton' import { Loader2, RefreshCw, Trash2 } from 'lucide-react' import type { WebhookRecord, WebhookEventLog } from '../hooks/useWebhookData' +const APPS = [ + { id: 'bluesky', label: 'Bluesky', path: 'app.bsky.*' }, + { id: 'tangled', label: 'Tangled', path: 'chat.tangled.*' }, + { id: 'leaflet', label: 'Leaflet', path: 'pub.leaflet.*' }, + { id: 'wisp', label: 'wisp', path: 'place.wisp.*' }, + { id: 'blento', label: 'Blento', path: 'blue.blento.*' }, +] as const + +type AppId = typeof APPS[number]['id'] | 'other' +type OtherMode = 'all' | 'collection' | 'rkey' + interface WebhooksTabProps { webhooks: WebhookRecord[] webhooksLoading: boolean eventLogs: WebhookEventLog[] eventLogsLoading: boolean isCreating: boolean + userDid?: string onCreateWebhook: (data: { scopeAturi: string url: string @@ -26,34 +38,58 @@ interface WebhooksTabProps { onRefreshEvents: () => Promise } +function buildScope(userDid: string, selectedApp: AppId | null, scopePath: string, otherMode: OtherMode, otherCollection: string, otherRkey: string): string { + if (!userDid) return '' + if (!selectedApp) return '' + if (selectedApp === 'other') { + if (otherMode === 'all') return `at://${userDid}` + if (otherMode === 'collection') return otherCollection ? `at://${userDid}/${otherCollection}` : '' + return (otherCollection && otherRkey) ? `at://${userDid}/${otherCollection}/${otherRkey}` : '' + } + return scopePath ? `at://${userDid}/${scopePath}` : `at://${userDid}` +} + export function WebhooksTab({ webhooks, webhooksLoading, eventLogs, eventLogsLoading, isCreating, + userDid = '', onCreateWebhook, onDeleteWebhook, onRefreshEvents, }: WebhooksTabProps) { const [url, setUrl] = useState('') - const [scopeAturi, setScopeAturi] = useState('') + const [selectedApp, setSelectedApp] = useState(null) + const [scopePath, setScopePath] = useState('') + const [otherMode, setOtherMode] = useState('all') + const [otherCollection, setOtherCollection] = useState('') + const [otherRkey, setOtherRkey] = useState('') const [backlinks, setBacklinks] = useState(false) const [eventCreate, setEventCreate] = useState(true) const [eventUpdate, setEventUpdate] = useState(true) const [eventDelete, setEventDelete] = useState(true) - const [secret, setSecret] = useState('') - const [enabled, setEnabled] = useState(true) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) const [deletingRkey, setDeletingRkey] = useState(null) - // Auto-refresh event logs every 60 seconds useEffect(() => { const id = setInterval(onRefreshEvents, 60_000) return () => clearInterval(id) }, [onRefreshEvents]) + const selectApp = (id: AppId) => { + setSelectedApp(id) + if (id !== 'other') { + const app = APPS.find(a => a.id === id) + setScopePath(app?.path ?? '') + } + setError(null) + } + + const scopeAturi = buildScope(userDid, selectedApp, scopePath, otherMode, otherCollection, otherRkey) + const handleCreate = async (e: React.FormEvent) => { e.preventDefault() setError(null) @@ -63,8 +99,8 @@ export function WebhooksTab({ setError('URL must start with http:// or https://') return } - if (!scopeAturi.startsWith('at://')) { - setError('Scope must be a valid AT-URI (at://...)') + if (!scopeAturi || !scopeAturi.startsWith('at://')) { + setError('Please select an app scope above') return } @@ -74,16 +110,18 @@ export function WebhooksTab({ if (eventDelete) events.push('delete') try { - await onCreateWebhook({ url, scopeAturi, backlinks, events: events.length === 3 ? [] : events, secret, enabled }) - setSuccess('Webhook created. It will become active once the service picks it up from the firehose.') + await onCreateWebhook({ url, scopeAturi, backlinks, events: events.length === 3 ? [] : events, secret: '', enabled: true }) + setSuccess('Webhook created.') setUrl('') - setScopeAturi('') + setSelectedApp(null) + setScopePath('') + setOtherMode('all') + setOtherCollection('') + setOtherRkey('') setBacklinks(false) setEventCreate(true) setEventUpdate(true) setEventDelete(true) - setSecret('') - setEnabled(true) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create webhook') } @@ -108,51 +146,149 @@ export function WebhooksTab({

Receive HTTP callbacks when AT Protocol records change

- {/* Content */}
- {/* Create webhook form */} -
+ {/* Create form */} +

Create Webhook

- -
- - setUrl(e.target.value)} - placeholder="https://example.com/webhook" - required - className="h-8 text-sm" - /> + + {/* URL */} +
+ + setUrl(e.target.value)} + placeholder="https://example.com/webhook" + required + className="h-8 text-sm" + /> +
+ + {/* App picker */} +
+ +
+ {APPS.map(app => ( + + ))} +
+
+ {/* Scope detail — known app */} + {selectedApp && selectedApp !== 'other' && (
- - setScopeAturi(e.target.value)} - placeholder="at://did:plc:... or at://did:plc:.../app.bsky.*" - required - className="h-8 text-sm" - /> -

- at://did watches all records,{' '} - at://did/collection for a specific one,{' '} - at://did/app.bsky.* for a glob. -

+ +
+ + at://{userDid || 'did'}/ + + setScopePath(e.target.value)} + placeholder="app.bsky.*" + className="flex-1 px-2 py-1.5 text-xs bg-transparent outline-none font-mono" + /> +
+ )} + {/* Scope detail — other */} + {selectedApp === 'other' && ( +
+ +
+ {([ + ['all', 'All my records', `at://${userDid || 'did'}`], + ['collection', 'Specific collection', ''], + ['rkey', 'Specific record', ''], + ] as const).map(([mode, label, hint]) => ( + + ))} +
+ {otherMode === 'collection' && ( + setOtherCollection(e.target.value)} + placeholder="app.bsky.feed.post" + className="h-8 text-xs" + /> + )} + {otherMode === 'rkey' && ( +
+ setOtherCollection(e.target.value)} + placeholder="collection" + className="h-8 text-xs flex-1" + /> + setOtherRkey(e.target.value)} + placeholder="rkey" + className="h-8 text-xs flex-1" + /> +
+ )} +
+ )} + + {/* Wildcard hint */} + {scopeAturi.includes('*') && ( +

+ * is a wildcard — matches any collection name at that level. +

+ )} + + {/* Backlinks */} + {selectedApp && (
setBacklinks(!!v)} />
+ )} + {/* Events */} + {selectedApp && (
- +
{([['create', eventCreate, setEventCreate], ['update', eventUpdate, setEventUpdate], ['delete', eventDelete, setEventDelete]] as const).map(([name, val, set]) => (
@@ -161,53 +297,37 @@ export function WebhooksTab({
))}
-

All checked = no filter (fires on all events)

-
- -
- - setSecret(e.target.value)} - placeholder="HMAC-SHA256 signing secret" - className="h-8 text-sm" - /> -

Stored publicly in your PDS record — transport integrity, not authentication.

-
- -
- setEnabled(!!v)} /> - +

All checked = no filter

+ )} - {error &&

{error}

} - {success &&

{success}

} + {error &&

{error}

} + {success &&

{success}

} - - -
+ )} + {/* Existing webhooks */}

Your Webhooks

{webhooksLoading ? (
- {[...Array(2)].map((_, i) => )} + {[...Array(2)].map((_, i) => )}
) : webhooks.length === 0 ? ( -

No webhooks configured.

+

No webhooks configured.

) : ( -
+
{webhooks.map(wh => (
-
-

{wh.url}

+
+

{wh.url}

{wh.scopeAturi}

-
+
{!wh.enabled && disabled} {wh.backlinks && backlinks} {wh.events.length > 0 && wh.events.map(e => {e})} @@ -232,7 +352,7 @@ export function WebhooksTab({

- Event Logs (auto-refreshes every 60s) + Event Logs (60s refresh)

) : eventLogs.length === 0 ? ( -

No events yet.

+

No events yet.

) : (
- - - - - - - + + + + + + + @@ -268,10 +388,10 @@ export function WebhooksTab({ {log.status} - + - + ))}
TimeStatusEventSource DIDCollectionRkeyDelivered ToTimeStatusEventSourceCollectionRkeyDelivered To
{log.eventKind}{log.eventDid}{log.eventDid} {log.eventCollection} {log.eventRkey}{log.url}{log.url}