13 changed files with 461 additions and 51 deletions
@ -0,0 +1,102 @@ |
|||||||
|
import { |
||||||
|
Dialog, |
||||||
|
DialogContent, |
||||||
|
DialogDescription, |
||||||
|
DialogHeader, |
||||||
|
DialogTitle |
||||||
|
} from '@/components/ui/dialog' |
||||||
|
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' |
||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { WrapText, Copy, Check } from 'lucide-react' |
||||||
|
import { useMemo, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import logger from '@/lib/logger' |
||||||
|
|
||||||
|
export default function JsonViewDialog({ |
||||||
|
title, |
||||||
|
value, |
||||||
|
isOpen, |
||||||
|
onClose |
||||||
|
}: { |
||||||
|
title?: string |
||||||
|
value: unknown |
||||||
|
isOpen: boolean |
||||||
|
onClose: () => void |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const [wordWrapEnabled, setWordWrapEnabled] = useState(true) |
||||||
|
const [copied, setCopied] = useState(false) |
||||||
|
|
||||||
|
const text = useMemo(() => { |
||||||
|
try { |
||||||
|
return JSON.stringify(value, null, 2) |
||||||
|
} catch (e) { |
||||||
|
return String(e) |
||||||
|
} |
||||||
|
}, [value]) |
||||||
|
|
||||||
|
const handleCopy = async () => { |
||||||
|
try { |
||||||
|
await navigator.clipboard.writeText(text) |
||||||
|
setCopied(true) |
||||||
|
setTimeout(() => setCopied(false), 2000) |
||||||
|
} catch (err) { |
||||||
|
logger.error('Failed to copy JSON view', { error: err }) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> |
||||||
|
<DialogContent className="h-[60vh] w-[95vw] max-w-[400px] sm:w-[90vw] sm:max-w-[600px] md:w-[85vw] md:max-w-[800px] lg:w-[80vw] lg:max-w-[1000px] xl:w-[75vw] xl:max-w-[1200px] 2xl:w-[70vw] 2xl:max-w-[1400px] flex flex-col overflow-hidden"> |
||||||
|
<DialogHeader className="shrink-0 pr-8"> |
||||||
|
<div className="flex items-center justify-between gap-2"> |
||||||
|
<div className="flex-1 min-w-0"> |
||||||
|
<DialogTitle>{title ?? t('View JSON')}</DialogTitle> |
||||||
|
<DialogDescription className="sr-only">{t('View JSON')}</DialogDescription> |
||||||
|
</div> |
||||||
|
<div className="flex items-center gap-1 shrink-0"> |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
size="sm" |
||||||
|
onClick={handleCopy} |
||||||
|
title={copied ? t('Copied!') : t('Copy to clipboard')} |
||||||
|
> |
||||||
|
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />} |
||||||
|
</Button> |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
size="sm" |
||||||
|
onClick={() => setWordWrapEnabled(!wordWrapEnabled)} |
||||||
|
title={wordWrapEnabled ? t('Disable word wrap') : t('Enable word wrap')} |
||||||
|
> |
||||||
|
<WrapText className={`h-4 w-4 ${wordWrapEnabled ? '' : 'opacity-50'}`} /> |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</DialogHeader> |
||||||
|
<div className="flex-1 min-h-0 min-w-0 overflow-hidden"> |
||||||
|
<ScrollArea className="h-full w-full"> |
||||||
|
<div className="w-full min-w-0 max-w-full pr-4"> |
||||||
|
<pre |
||||||
|
className={`text-sm text-muted-foreground select-text min-w-0 ${wordWrapEnabled ? 'whitespace-pre-wrap overflow-x-hidden' : 'whitespace-pre overflow-x-auto'}`} |
||||||
|
style={{ |
||||||
|
wordBreak: wordWrapEnabled ? 'break-all' : 'normal', |
||||||
|
overflowWrap: wordWrapEnabled ? 'anywhere' : 'normal', |
||||||
|
maxWidth: '100%', |
||||||
|
width: '100%', |
||||||
|
boxSizing: 'border-box' |
||||||
|
}} |
||||||
|
> |
||||||
|
{text} |
||||||
|
</pre> |
||||||
|
</div> |
||||||
|
<ScrollBar |
||||||
|
orientation="horizontal" |
||||||
|
className={wordWrapEnabled ? 'opacity-0 pointer-events-none' : ''} |
||||||
|
/> |
||||||
|
</ScrollArea> |
||||||
|
</div> |
||||||
|
</DialogContent> |
||||||
|
</Dialog> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,52 @@ |
|||||||
|
import i18n from '@/i18n' |
||||||
|
import { normalizeUrl, simplifyUrl } from '@/lib/url' |
||||||
|
import logger from '@/lib/logger' |
||||||
|
import { toast } from 'sonner' |
||||||
|
|
||||||
|
/** Many subs / resubscribes call `auth()` on the same relay; one success/reject per URL per tab session is enough. */ |
||||||
|
const nip42NotifiedAccept = new Set<string>() |
||||||
|
const nip42NotifiedReject = new Set<string>() |
||||||
|
|
||||||
|
function sessionKeyForRelay(url: string): string { |
||||||
|
return normalizeUrl(url) || url.trim() |
||||||
|
} |
||||||
|
|
||||||
|
function relayLabel(url: string): string { |
||||||
|
const n = normalizeUrl(url) || url |
||||||
|
try { |
||||||
|
return simplifyUrl(n) |
||||||
|
} catch { |
||||||
|
return n |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** User-visible result after the relay responds to NIP-42 AUTH (`OK` / failure). */ |
||||||
|
export function notifyRelayNip42Accepted(url: string, okReason?: string): void { |
||||||
|
const relay = relayLabel(url) |
||||||
|
const detailSuffix = okReason?.trim() ? ` (${okReason.trim()})` : '' |
||||||
|
toast.success( |
||||||
|
i18n.t('Relay auth accepted (NIP-42)', { |
||||||
|
relay, |
||||||
|
detailSuffix, |
||||||
|
defaultValue: `The relay accepted authentication (NIP-42): ${relay}${detailSuffix}` |
||||||
|
}) |
||||||
|
) |
||||||
|
logger.info('[NIP-42] Auth accepted by relay', { url, okReason }) |
||||||
|
} |
||||||
|
|
||||||
|
export function notifyRelayNip42Rejected(url: string, message: string): void { |
||||||
|
const key = sessionKeyForRelay(url) |
||||||
|
if (!key || nip42NotifiedAccept.has(key) || nip42NotifiedReject.has(key)) return |
||||||
|
nip42NotifiedReject.add(key) |
||||||
|
|
||||||
|
const relay = relayLabel(url) |
||||||
|
const msg = message.trim() || i18n.t('Relay auth error unknown', { defaultValue: 'Unknown error' }) |
||||||
|
toast.error( |
||||||
|
i18n.t('Relay auth rejected (NIP-42)', { |
||||||
|
relay, |
||||||
|
message: msg, |
||||||
|
defaultValue: `The relay rejected authentication (NIP-42): ${relay} — ${msg}` |
||||||
|
}) |
||||||
|
) |
||||||
|
logger.warn('[NIP-42] Auth rejected by relay', { url, message: msg }) |
||||||
|
} |
||||||
@ -0,0 +1,38 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { normalizeUrl } from '@/lib/url' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
/** |
||||||
|
* `d` tag values on kind 31987 vary by client (trailing slash, scheme, etc.). REQ `#d` is OR-matched; |
||||||
|
* include every variant we care about for the relay being viewed. |
||||||
|
*/ |
||||||
|
export function relayReviewDTagsForRelayUrl(url: string): string[] { |
||||||
|
const raw = url?.trim() |
||||||
|
if (!raw) return [] |
||||||
|
const norm = normalizeUrl(raw) || raw |
||||||
|
const uniq: string[] = [] |
||||||
|
const add = (s: string | undefined) => { |
||||||
|
const t = s?.trim() |
||||||
|
if (t && !uniq.includes(t)) uniq.push(t) |
||||||
|
} |
||||||
|
add(raw) |
||||||
|
add(norm) |
||||||
|
return uniq |
||||||
|
} |
||||||
|
|
||||||
|
/** Same key as {@link RelayReviewsPage} / NoteList session snapshot. */ |
||||||
|
export function relayReviewsFeedSnapshotKey(normalizedRelayUrl: string): string { |
||||||
|
return `relay-reviews:v1|${normalizedRelayUrl}|k=${ExtendedKind.RELAY_REVIEW}` |
||||||
|
} |
||||||
|
|
||||||
|
/** Whether a cached or live event is a review for this relay (handles `d` vs URL normalization drift). */ |
||||||
|
export function relayReviewEventTargetsRelay(event: Event, relayUrl: string): boolean { |
||||||
|
if (event.kind !== ExtendedKind.RELAY_REVIEW) return false |
||||||
|
const d = event.tags.find((t) => t[0] === 'd')?.[1]?.trim() |
||||||
|
if (!d) return false |
||||||
|
const candidates = relayReviewDTagsForRelayUrl(relayUrl) |
||||||
|
if (candidates.includes(d)) return true |
||||||
|
const dNorm = normalizeUrl(d) || d |
||||||
|
const targetNorm = normalizeUrl(relayUrl) || relayUrl |
||||||
|
return dNorm === targetNorm |
||||||
|
} |
||||||
Loading…
Reference in new issue