27 changed files with 903 additions and 144 deletions
@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
import { createHttpRelayListDraftEvent } from '@/lib/draft-event' |
||||
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { TMailboxRelay } from '@/types' |
||||
import { CloudUpload } from 'lucide-react' |
||||
import { useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import logger from '@/lib/logger' |
||||
|
||||
export default function SaveButton({ |
||||
mailboxRelays, |
||||
hasChange, |
||||
setHasChange |
||||
}: { |
||||
mailboxRelays: TMailboxRelay[] |
||||
hasChange: boolean |
||||
setHasChange: (hasChange: boolean) => void |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { pubkey, publish, updateHttpRelayListEvent } = useNostr() |
||||
const [pushing, setPushing] = useState(false) |
||||
|
||||
const save = async () => { |
||||
if (!pubkey) return |
||||
|
||||
setPushing(true) |
||||
try { |
||||
const event = createHttpRelayListDraftEvent(mailboxRelays) |
||||
const result = await publish(event) |
||||
|
||||
const relayStatuses = (result as any).relayStatuses |
||||
|
||||
await updateHttpRelayListEvent(result) |
||||
setHasChange(false) |
||||
|
||||
if (relayStatuses && relayStatuses.length > 0) { |
||||
showPublishingFeedback( |
||||
{ |
||||
success: true, |
||||
relayStatuses: relayStatuses, |
||||
successCount: relayStatuses.filter((s: any) => s.success).length, |
||||
totalCount: relayStatuses.length |
||||
}, |
||||
{ |
||||
message: t('HTTP relays saved'), |
||||
duration: 6000 |
||||
} |
||||
) |
||||
} else { |
||||
showSimplePublishSuccess(t('HTTP relays saved')) |
||||
} |
||||
} catch (error) { |
||||
logger.error('Failed to save HTTP relay list', { error }) |
||||
if (error instanceof Error && (error as any).relayStatuses) { |
||||
const errorRelayStatuses = (error as any).relayStatuses |
||||
showPublishingFeedback( |
||||
{ |
||||
success: false, |
||||
relayStatuses: errorRelayStatuses, |
||||
successCount: errorRelayStatuses.filter((s: any) => s.success).length, |
||||
totalCount: errorRelayStatuses.length |
||||
}, |
||||
{ |
||||
message: error.message || t('Failed to save HTTP relay list'), |
||||
duration: 6000 |
||||
} |
||||
) |
||||
} else { |
||||
showPublishingError(error instanceof Error ? error : new Error(t('Failed to save HTTP relay list'))) |
||||
} |
||||
} finally { |
||||
setPushing(false) |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<Button className="w-full" disabled={!pubkey || pushing || !hasChange} onClick={save}> |
||||
{pushing ? <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden /> : <CloudUpload />} |
||||
{t('Save')} |
||||
</Button> |
||||
) |
||||
} |
||||
@ -0,0 +1,165 @@
@@ -0,0 +1,165 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { isHttpRelayUrl, normalizeHttpRelayUrl } from '@/lib/url' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { TMailboxRelay, TMailboxRelayScope } from '@/types' |
||||
import { useEffect, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { |
||||
DndContext, |
||||
closestCenter, |
||||
KeyboardSensor, |
||||
PointerSensor, |
||||
TouchSensor, |
||||
useSensor, |
||||
useSensors, |
||||
DragEndEvent |
||||
} from '@dnd-kit/core' |
||||
import { |
||||
arrayMove, |
||||
SortableContext, |
||||
sortableKeyboardCoordinates, |
||||
verticalListSortingStrategy |
||||
} from '@dnd-kit/sortable' |
||||
import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers' |
||||
import MailboxRelay from '../MailboxSetting/MailboxRelay' |
||||
import NewMailboxRelayInput from '../MailboxSetting/NewMailboxRelayInput' |
||||
import RelayCountWarning from '../MailboxSetting/RelayCountWarning' |
||||
import SaveButton from './SaveButton' |
||||
import DiscoveredRelays from '../MailboxSetting/DiscoveredRelays' |
||||
|
||||
export default function HttpRelaysSetting() { |
||||
const { t } = useTranslation() |
||||
const { pubkey, httpRelayListEvent, checkLogin } = useNostr() |
||||
const [relays, setRelays] = useState<TMailboxRelay[]>([]) |
||||
const [hasChange, setHasChange] = useState(false) |
||||
|
||||
const sensors = useSensors( |
||||
useSensor(PointerSensor, { |
||||
activationConstraint: { distance: 8 } |
||||
}), |
||||
useSensor(TouchSensor, { |
||||
activationConstraint: { delay: 200, tolerance: 8 } |
||||
}), |
||||
useSensor(KeyboardSensor, { |
||||
coordinateGetter: sortableKeyboardCoordinates |
||||
}) |
||||
) |
||||
|
||||
function handleDragEnd(event: DragEndEvent) { |
||||
const { active, over } = event |
||||
if (active.id !== over?.id) { |
||||
const oldIndex = relays.findIndex((relay) => relay.url === active.id) |
||||
const newIndex = relays.findIndex((relay) => relay.url === over?.id) |
||||
if (oldIndex !== -1 && newIndex !== -1) { |
||||
setRelays((relays) => arrayMove(relays, oldIndex, newIndex)) |
||||
setHasChange(true) |
||||
} |
||||
} |
||||
} |
||||
|
||||
useEffect(() => { |
||||
if (!httpRelayListEvent) { |
||||
setRelays([]) |
||||
setHasChange(false) |
||||
return |
||||
} |
||||
const fromTags: TMailboxRelay[] = [] |
||||
httpRelayListEvent.tags.forEach((tag) => { |
||||
if (tag[0] !== 'r' || !tag[1]) return |
||||
const url = tag[1].trim() |
||||
if (!isHttpRelayUrl(url)) return |
||||
const n = normalizeHttpRelayUrl(url) |
||||
if (!n) return |
||||
const type = tag[2] |
||||
const scope: TMailboxRelayScope = |
||||
type === 'read' ? 'read' : type === 'write' ? 'write' : 'both' |
||||
fromTags.push({ url: n, scope }) |
||||
}) |
||||
setRelays(fromTags) |
||||
setHasChange(false) |
||||
}, [httpRelayListEvent]) |
||||
|
||||
if (!pubkey) { |
||||
return ( |
||||
<div className="flex flex-col w-full items-center"> |
||||
<Button size="lg" onClick={() => checkLogin()}> |
||||
{t('Login to set')} |
||||
</Button> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (httpRelayListEvent === undefined) { |
||||
return <div className="text-center text-sm text-muted-foreground">{t('loading...')}</div> |
||||
} |
||||
|
||||
const changeScope = (url: string, scope: TMailboxRelayScope) => { |
||||
setRelays((prev) => prev.map((r) => (r.url === url ? { ...r, scope } : r))) |
||||
setHasChange(true) |
||||
} |
||||
|
||||
const removeRelay = (url: string) => { |
||||
setRelays((prev) => prev.filter((r) => r.url !== url)) |
||||
setHasChange(true) |
||||
} |
||||
|
||||
const saveNewRelay = (url: string) => { |
||||
if (url === '') return null |
||||
const normalizedUrl = normalizeHttpRelayUrl(url) |
||||
if (!normalizedUrl) { |
||||
return t('Invalid relay URL') |
||||
} |
||||
if (!isHttpRelayUrl(normalizedUrl)) { |
||||
return t('HTTP relays must start with https:// or http://') |
||||
} |
||||
if (relays.some((r) => r.url === normalizedUrl)) { |
||||
return t('Relay already exists') |
||||
} |
||||
setRelays([...relays, { url: normalizedUrl, scope: 'both' }]) |
||||
setHasChange(true) |
||||
return null |
||||
} |
||||
|
||||
const handleAddDiscovered = (newRelays: TMailboxRelay[]) => { |
||||
const httpOnly = newRelays.filter((r) => isHttpRelayUrl(r.url)) |
||||
const toAdd = httpOnly.filter((nr) => !relays.some((r) => r.url === nr.url)) |
||||
if (toAdd.length > 0) { |
||||
setRelays([...relays, ...toAdd]) |
||||
setHasChange(true) |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<div className="space-y-4"> |
||||
<div className="text-xs text-muted-foreground space-y-1"> |
||||
<div>{t('httpRelaysDescription')}</div> |
||||
<div>{t('read relays description')}</div> |
||||
<div>{t('write relays description')}</div> |
||||
<div>{t('read & write relays notice')}</div> |
||||
</div> |
||||
<DiscoveredRelays onAdd={handleAddDiscovered} /> |
||||
<RelayCountWarning relays={relays} /> |
||||
<SaveButton mailboxRelays={relays} hasChange={hasChange} setHasChange={setHasChange} /> |
||||
<DndContext |
||||
sensors={sensors} |
||||
collisionDetection={closestCenter} |
||||
onDragEnd={handleDragEnd} |
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]} |
||||
> |
||||
<SortableContext items={relays.map((r) => r.url)} strategy={verticalListSortingStrategy}> |
||||
<div className="space-y-2"> |
||||
{relays.map((relay) => ( |
||||
<MailboxRelay |
||||
key={relay.url} |
||||
mailboxRelay={relay} |
||||
changeMailboxRelayScope={changeScope} |
||||
removeMailboxRelay={removeRelay} |
||||
/> |
||||
))} |
||||
</div> |
||||
</SortableContext> |
||||
</DndContext> |
||||
<NewMailboxRelayInput saveNewMailboxRelay={saveNewRelay} /> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,152 @@
@@ -0,0 +1,152 @@
|
||||
/** |
||||
* HTTP JSON API for index-style relays (e.g. gc_index_relay: POST /api/events/filter, POST /api/events). |
||||
* @see gc_index_relay lib/gc_index_relay_web/router.ex |
||||
*/ |
||||
import logger from '@/lib/logger' |
||||
import { normalizeHttpRelayUrl } from '@/lib/url' |
||||
import type { Filter, Event as NEvent } from 'nostr-tools' |
||||
import { verifyEvent } from 'nostr-tools' |
||||
|
||||
function trimSlash(base: string): string { |
||||
return base.replace(/\/+$/, '') |
||||
} |
||||
|
||||
export function indexRelayFilterUrl(baseUrl: string): string { |
||||
return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/events/filter` |
||||
} |
||||
|
||||
export function indexRelayPublishUrl(baseUrl: string): string { |
||||
return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/events` |
||||
} |
||||
|
||||
/** Map a Nostr filter to gc_index_relay POST body (requires `limit` 1–100; strips unsupported keys). */ |
||||
export function nostrFilterToIndexRelayBody(f: Filter): Record<string, unknown> { |
||||
const body: Record<string, unknown> = {} |
||||
const lim = f.limit |
||||
const capped = lim == null || lim < 1 ? 100 : Math.min(100, lim) |
||||
body.limit = capped |
||||
if (f.ids?.length) body.ids = f.ids |
||||
if (f.authors?.length) body.authors = f.authors |
||||
if (f.kinds?.length) body.kinds = f.kinds |
||||
if (f.since != null) body.since = f.since |
||||
if (f.until != null) body.until = f.until |
||||
for (const key of Object.keys(f)) { |
||||
if (key.startsWith('#') && key.length === 2) { |
||||
const v = (f as Record<string, unknown>)[key] |
||||
if (Array.isArray(v) && v.length > 0) body[key] = v |
||||
} |
||||
} |
||||
return body |
||||
} |
||||
|
||||
function rawToVerifiedEvent(raw: Record<string, unknown>): NEvent | null { |
||||
try { |
||||
const id = raw.id |
||||
const pubkey = raw.pubkey |
||||
const created_at = raw.created_at |
||||
const kind = raw.kind |
||||
const tags = raw.tags |
||||
const content = raw.content |
||||
const sig = raw.sig |
||||
if ( |
||||
typeof id !== 'string' || |
||||
typeof pubkey !== 'string' || |
||||
typeof created_at !== 'number' || |
||||
typeof kind !== 'number' || |
||||
!Array.isArray(tags) || |
||||
typeof content !== 'string' || |
||||
typeof sig !== 'string' |
||||
) { |
||||
return null |
||||
} |
||||
const ev = { id, pubkey, created_at, kind, tags, content, sig } as NEvent |
||||
return verifyEvent(ev) ? ev : null |
||||
} catch { |
||||
return null |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Query one HTTP index relay. Runs one POST per filter when given an array. |
||||
*/ |
||||
export async function queryIndexRelay( |
||||
baseUrl: string, |
||||
filter: Filter | Filter[], |
||||
options?: { signal?: AbortSignal } |
||||
): Promise<NEvent[]> { |
||||
const base = normalizeHttpRelayUrl(baseUrl) || baseUrl |
||||
const endpoint = indexRelayFilterUrl(base) |
||||
const filters = Array.isArray(filter) ? filter : [filter] |
||||
const out: NEvent[] = [] |
||||
const seen = new Set<string>() |
||||
for (const f of filters) { |
||||
const body = nostrFilterToIndexRelayBody(filterForIndexRelay(f)) |
||||
try { |
||||
const res = await fetch(endpoint, { |
||||
method: 'POST', |
||||
headers: { |
||||
Accept: 'application/json', |
||||
'Content-Type': 'application/json' |
||||
}, |
||||
body: JSON.stringify(body), |
||||
signal: options?.signal |
||||
}) |
||||
if (!res.ok) { |
||||
logger.warn('[IndexRelayHttp] filter request failed', { endpoint, status: res.status }) |
||||
continue |
||||
} |
||||
const json = (await res.json()) as { data?: unknown } |
||||
const data = json.data |
||||
if (!Array.isArray(data)) continue |
||||
for (const item of data) { |
||||
if (!item || typeof item !== 'object') continue |
||||
const ev = rawToVerifiedEvent(item as Record<string, unknown>) |
||||
if (ev && !seen.has(ev.id)) { |
||||
seen.add(ev.id) |
||||
out.push(ev) |
||||
} |
||||
} |
||||
} catch (e) { |
||||
if ((e as Error).name === 'AbortError') throw e |
||||
logger.warn('[IndexRelayHttp] filter request error', { endpoint, error: e }) |
||||
} |
||||
} |
||||
return out |
||||
} |
||||
|
||||
function filterForIndexRelay(f: Filter): Filter { |
||||
const { search: _s, ...rest } = f |
||||
return rest as Filter |
||||
} |
||||
|
||||
export async function publishEventToIndexRelay( |
||||
baseUrl: string, |
||||
event: NEvent, |
||||
options?: { signal?: AbortSignal } |
||||
): Promise<void> { |
||||
const base = normalizeHttpRelayUrl(baseUrl) || baseUrl |
||||
const endpoint = indexRelayPublishUrl(base) |
||||
const res = await fetch(endpoint, { |
||||
method: 'POST', |
||||
headers: { |
||||
Accept: 'application/json', |
||||
'Content-Type': 'application/json' |
||||
}, |
||||
body: JSON.stringify({ |
||||
event: { |
||||
id: event.id, |
||||
pubkey: event.pubkey, |
||||
created_at: event.created_at, |
||||
kind: event.kind, |
||||
tags: event.tags, |
||||
content: event.content, |
||||
sig: event.sig |
||||
} |
||||
}), |
||||
signal: options?.signal |
||||
}) |
||||
if (!res.ok) { |
||||
const text = await res.text().catch(() => '') |
||||
throw new Error(`HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue