27 changed files with 903 additions and 144 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
/** |
||||||
|
* 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