13 changed files with 461 additions and 51 deletions
@ -0,0 +1,102 @@
@@ -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 @@
@@ -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 @@
@@ -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