|
|
|
@ -2,10 +2,10 @@ import { Button } from '@/components/ui/button' |
|
|
|
import logger from '@/lib/logger' |
|
|
|
import logger from '@/lib/logger' |
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
|
|
|
import { useTranslation } from 'react-i18next' |
|
|
|
import { useTranslation } from 'react-i18next' |
|
|
|
import { Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Copy } from 'lucide-react' |
|
|
|
import { Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Copy, Radio } from 'lucide-react' |
|
|
|
import { Input } from '@/components/ui/input' |
|
|
|
import { Input } from '@/components/ui/input' |
|
|
|
import { Skeleton } from '@/components/ui/skeleton' |
|
|
|
import { Skeleton } from '@/components/ui/skeleton' |
|
|
|
import indexedDb, { StoreNames, type TCachedEventSearchHit } from '@/services/indexed-db.service' |
|
|
|
import indexedDb, { isLikelyCachedNostrEvent, StoreNames, type TCachedEventSearchHit } from '@/services/indexed-db.service' |
|
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' |
|
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' |
|
|
|
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer' |
|
|
|
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer' |
|
|
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' |
|
|
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' |
|
|
|
@ -13,6 +13,9 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' |
|
|
|
import { toast } from 'sonner' |
|
|
|
import { toast } from 'sonner' |
|
|
|
import { Event } from 'nostr-tools' |
|
|
|
import { Event } from 'nostr-tools' |
|
|
|
import { cn } from '@/lib/utils' |
|
|
|
import { cn } from '@/lib/utils' |
|
|
|
|
|
|
|
import { useNostr } from '@/providers/NostrProvider' |
|
|
|
|
|
|
|
import client from '@/services/client.service' |
|
|
|
|
|
|
|
import { toastPublishPromise } from '@/lib/publishing-feedback' |
|
|
|
|
|
|
|
|
|
|
|
const GLOBAL_CACHED_EVENTS_SEARCH_LIMIT = 400 |
|
|
|
const GLOBAL_CACHED_EVENTS_SEARCH_LIMIT = 400 |
|
|
|
|
|
|
|
|
|
|
|
@ -25,6 +28,8 @@ export default function CacheBrowserDialog({ |
|
|
|
}) { |
|
|
|
}) { |
|
|
|
const { t } = useTranslation() |
|
|
|
const { t } = useTranslation() |
|
|
|
const { isSmallScreen } = useScreenSize() |
|
|
|
const { isSmallScreen } = useScreenSize() |
|
|
|
|
|
|
|
const { pubkey: accountPubkey } = useNostr() |
|
|
|
|
|
|
|
const [broadcastingKey, setBroadcastingKey] = useState<string | null>(null) |
|
|
|
const [cacheInfo, setCacheInfo] = useState<Record<string, number>>({}) |
|
|
|
const [cacheInfo, setCacheInfo] = useState<Record<string, number>>({}) |
|
|
|
const [selectedStore, setSelectedStore] = useState<string | null>(null) |
|
|
|
const [selectedStore, setSelectedStore] = useState<string | null>(null) |
|
|
|
const [storeItems, setStoreItems] = useState<any[]>([]) |
|
|
|
const [storeItems, setStoreItems] = useState<any[]>([]) |
|
|
|
@ -228,6 +233,54 @@ export default function CacheBrowserDialog({ |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const handleBroadcastToMailboxStack = useCallback( |
|
|
|
|
|
|
|
(event: Event, itemKey: string) => { |
|
|
|
|
|
|
|
if (!accountPubkey) { |
|
|
|
|
|
|
|
toast.error(t('Log in to broadcast')) |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
if (event.pubkey?.toLowerCase() !== accountPubkey.toLowerCase()) { |
|
|
|
|
|
|
|
toast.error(t('Only the author can broadcast this event')) |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
setBroadcastingKey(itemKey) |
|
|
|
|
|
|
|
const promise = (async () => { |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
const urls = await client.getMailboxStackWriteUrlsForRepublish(accountPubkey) |
|
|
|
|
|
|
|
if (!urls.length) { |
|
|
|
|
|
|
|
throw new Error(t('No mailbox cache or HTTP write relays configured')) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
const result = await client.publishEvent(urls, event, { skipOutboxRetry: true }) |
|
|
|
|
|
|
|
if (result.successCount < 1) { |
|
|
|
|
|
|
|
throw new Error(t('No relay accepted the event')) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
return result |
|
|
|
|
|
|
|
} finally { |
|
|
|
|
|
|
|
setBroadcastingKey(null) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
})() |
|
|
|
|
|
|
|
toastPublishPromise(promise, { |
|
|
|
|
|
|
|
loading: t('Broadcasting to outbox HTTP and cache relays…'), |
|
|
|
|
|
|
|
success: () => t('Broadcast to mailbox stack finished'), |
|
|
|
|
|
|
|
error: (err) => |
|
|
|
|
|
|
|
t('Failed to broadcast: {{error}}', { |
|
|
|
|
|
|
|
error: err instanceof Error ? err.message : String(err) |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
}, |
|
|
|
|
|
|
|
[accountPubkey, t] |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Same predicate as full-text cache search — avoids hiding broadcast on valid rows that fail stricter checks. */ |
|
|
|
|
|
|
|
const rowLooksLikeNostrEventForBroadcast = useCallback((v: unknown, storeName?: string | null): v is Event => { |
|
|
|
|
|
|
|
if (storeName === 'rssFeedItems' || storeName === StoreNames.PIPER_TTS_CACHE) return false |
|
|
|
|
|
|
|
return isLikelyCachedNostrEvent(v) |
|
|
|
|
|
|
|
}, []) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const nostrEventHasPublishableSig = useCallback((e: Event): boolean => { |
|
|
|
|
|
|
|
return typeof e.sig === 'string' && e.sig.trim().length > 0 |
|
|
|
|
|
|
|
}, []) |
|
|
|
|
|
|
|
|
|
|
|
const isInvalidEvent = useCallback( |
|
|
|
const isInvalidEvent = useCallback( |
|
|
|
(item: { key: string; value: any; addedAt: number }, storeName?: string | null): boolean => { |
|
|
|
(item: { key: string; value: any; addedAt: number }, storeName?: string | null): boolean => { |
|
|
|
if (!item) return true |
|
|
|
if (!item) return true |
|
|
|
@ -240,9 +293,9 @@ export default function CacheBrowserDialog({ |
|
|
|
} |
|
|
|
} |
|
|
|
if (!item.value) return true |
|
|
|
if (!item.value) return true |
|
|
|
const event = item.value as Event |
|
|
|
const event = item.value as Event |
|
|
|
if (!event.pubkey || !event.kind || typeof event.created_at !== 'number') return true |
|
|
|
if (!event.pubkey || typeof event.kind !== 'number' || typeof event.created_at !== 'number') return true |
|
|
|
if (!event.tags || !Array.isArray(event.tags)) return true |
|
|
|
if (!event.tags || !Array.isArray(event.tags)) return true |
|
|
|
if (!event.id || !event.sig) return true |
|
|
|
if (!event.id || typeof event.sig !== 'string' || !event.sig.trim()) return true |
|
|
|
return false |
|
|
|
return false |
|
|
|
}, |
|
|
|
}, |
|
|
|
[] |
|
|
|
[] |
|
|
|
@ -254,11 +307,11 @@ export default function CacheBrowserDialog({ |
|
|
|
const event = item.value as Event |
|
|
|
const event = item.value as Event |
|
|
|
const missing: string[] = [] |
|
|
|
const missing: string[] = [] |
|
|
|
if (!event.pubkey) missing.push(t('pubkey')) |
|
|
|
if (!event.pubkey) missing.push(t('pubkey')) |
|
|
|
if (!event.kind) missing.push(t('kind')) |
|
|
|
if (typeof event.kind !== 'number') missing.push(t('kind')) |
|
|
|
if (typeof event.created_at !== 'number') missing.push(t('created_at')) |
|
|
|
if (typeof event.created_at !== 'number') missing.push(t('created_at')) |
|
|
|
if (!event.tags || !Array.isArray(event.tags)) missing.push(t('tags')) |
|
|
|
if (!event.tags || !Array.isArray(event.tags)) missing.push(t('tags')) |
|
|
|
if (!event.id) missing.push(t('id')) |
|
|
|
if (!event.id) missing.push(t('id')) |
|
|
|
if (!event.sig) missing.push(t('sig')) |
|
|
|
if (typeof event.sig !== 'string' || !event.sig.trim()) missing.push(t('sig')) |
|
|
|
if (missing.length > 0) return t('Event is missing required fields: {{fields}}', { fields: missing.join(', ') }) |
|
|
|
if (missing.length > 0) return t('Event is missing required fields: {{fields}}', { fields: missing.join(', ') }) |
|
|
|
return t('Event appears to be invalid or corrupted') |
|
|
|
return t('Event appears to be invalid or corrupted') |
|
|
|
}, |
|
|
|
}, |
|
|
|
@ -292,9 +345,25 @@ export default function CacheBrowserDialog({ |
|
|
|
{t('Showing first {{count}} cached event matches.', { count: GLOBAL_CACHED_EVENTS_SEARCH_LIMIT })} |
|
|
|
{t('Showing first {{count}} cached event matches.', { count: GLOBAL_CACHED_EVENTS_SEARCH_LIMIT })} |
|
|
|
</p> |
|
|
|
</p> |
|
|
|
)} |
|
|
|
)} |
|
|
|
{globalSearchHits.map((hit) => ( |
|
|
|
{globalSearchHits.map((hit) => { |
|
|
|
|
|
|
|
const hitRowKey = `${hit.storeName}:${hit.key}:${hit.value.id}` |
|
|
|
|
|
|
|
const hitEvent = hit.value as Event |
|
|
|
|
|
|
|
const showBroadcast = rowLooksLikeNostrEventForBroadcast(hit.value, hit.storeName) |
|
|
|
|
|
|
|
const hasSig = nostrEventHasPublishableSig(hitEvent) |
|
|
|
|
|
|
|
const authorOk = |
|
|
|
|
|
|
|
!!accountPubkey && hitEvent.pubkey?.toLowerCase() === accountPubkey.toLowerCase() |
|
|
|
|
|
|
|
const broadcastDisabled = |
|
|
|
|
|
|
|
!accountPubkey || !authorOk || broadcastingKey !== null || !hasSig |
|
|
|
|
|
|
|
const broadcastTitle = !accountPubkey |
|
|
|
|
|
|
|
? t('Log in to broadcast') |
|
|
|
|
|
|
|
: !hasSig |
|
|
|
|
|
|
|
? t('Event must be signed to broadcast') |
|
|
|
|
|
|
|
: !authorOk |
|
|
|
|
|
|
|
? t('Only the author can broadcast this event') |
|
|
|
|
|
|
|
: t('Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays') |
|
|
|
|
|
|
|
return ( |
|
|
|
<div |
|
|
|
<div |
|
|
|
key={`${hit.storeName}:${hit.key}:${hit.value.id}`} |
|
|
|
key={hitRowKey} |
|
|
|
className="space-y-2 rounded-lg border p-3 transition-colors hover:bg-muted/50" |
|
|
|
className="space-y-2 rounded-lg border p-3 transition-colors hover:bg-muted/50" |
|
|
|
> |
|
|
|
> |
|
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> |
|
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> |
|
|
|
@ -324,9 +393,24 @@ export default function CacheBrowserDialog({ |
|
|
|
<Copy className="mr-1 h-3 w-3" /> |
|
|
|
<Copy className="mr-1 h-3 w-3" /> |
|
|
|
{t('Copy event JSON')} |
|
|
|
{t('Copy event JSON')} |
|
|
|
</Button> |
|
|
|
</Button> |
|
|
|
|
|
|
|
{showBroadcast && ( |
|
|
|
|
|
|
|
<Button |
|
|
|
|
|
|
|
variant="outline" |
|
|
|
|
|
|
|
size="sm" |
|
|
|
|
|
|
|
className="h-7 text-xs" |
|
|
|
|
|
|
|
type="button" |
|
|
|
|
|
|
|
disabled={broadcastDisabled} |
|
|
|
|
|
|
|
title={broadcastTitle} |
|
|
|
|
|
|
|
onClick={() => handleBroadcastToMailboxStack(hitEvent, hitRowKey)} |
|
|
|
|
|
|
|
> |
|
|
|
|
|
|
|
<Radio className="mr-1 h-3 w-3" /> |
|
|
|
|
|
|
|
{t('Broadcast to mailbox stack')} |
|
|
|
|
|
|
|
</Button> |
|
|
|
|
|
|
|
)} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
))} |
|
|
|
) |
|
|
|
|
|
|
|
})} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
@ -396,6 +480,20 @@ export default function CacheBrowserDialog({ |
|
|
|
const nestedCount = (item as any).nestedCount |
|
|
|
const nestedCount = (item as any).nestedCount |
|
|
|
const invalid = isInvalidEvent(item, selectedStore) |
|
|
|
const invalid = isInvalidEvent(item, selectedStore) |
|
|
|
const invalidExplanation = invalid ? getInvalidEventExplanation(item) : '' |
|
|
|
const invalidExplanation = invalid ? getInvalidEventExplanation(item) : '' |
|
|
|
|
|
|
|
const showBroadcast = rowLooksLikeNostrEventForBroadcast(item.value, selectedStore) |
|
|
|
|
|
|
|
const rowEvent = item.value as Event |
|
|
|
|
|
|
|
const hasSig = nostrEventHasPublishableSig(rowEvent) |
|
|
|
|
|
|
|
const authorMatches = |
|
|
|
|
|
|
|
!!accountPubkey && rowEvent.pubkey?.toLowerCase() === accountPubkey.toLowerCase() |
|
|
|
|
|
|
|
const broadcastDisabled = |
|
|
|
|
|
|
|
!accountPubkey || !authorMatches || broadcastingKey !== null || !hasSig |
|
|
|
|
|
|
|
const broadcastTitle = !accountPubkey |
|
|
|
|
|
|
|
? t('Log in to broadcast') |
|
|
|
|
|
|
|
: !hasSig |
|
|
|
|
|
|
|
? t('Event must be signed to broadcast') |
|
|
|
|
|
|
|
: !authorMatches |
|
|
|
|
|
|
|
? t('Only the author can broadcast this event') |
|
|
|
|
|
|
|
: t('Publish this event to your NIP-65 outbox, HTTP write relays, and cache relays') |
|
|
|
return ( |
|
|
|
return ( |
|
|
|
<div |
|
|
|
<div |
|
|
|
key={item.key || index} |
|
|
|
key={item.key || index} |
|
|
|
@ -425,6 +523,19 @@ export default function CacheBrowserDialog({ |
|
|
|
</HoverCardContent> |
|
|
|
</HoverCardContent> |
|
|
|
</HoverCard> |
|
|
|
</HoverCard> |
|
|
|
)} |
|
|
|
)} |
|
|
|
|
|
|
|
{showBroadcast && ( |
|
|
|
|
|
|
|
<Button |
|
|
|
|
|
|
|
variant="ghost" |
|
|
|
|
|
|
|
size="sm" |
|
|
|
|
|
|
|
type="button" |
|
|
|
|
|
|
|
onClick={() => handleBroadcastToMailboxStack(rowEvent, item.key)} |
|
|
|
|
|
|
|
disabled={broadcastDisabled} |
|
|
|
|
|
|
|
className="h-6 w-6 p-0" |
|
|
|
|
|
|
|
title={broadcastTitle} |
|
|
|
|
|
|
|
> |
|
|
|
|
|
|
|
<Radio className="h-3 w-3" /> |
|
|
|
|
|
|
|
</Button> |
|
|
|
|
|
|
|
)} |
|
|
|
<Button |
|
|
|
<Button |
|
|
|
variant="ghost" |
|
|
|
variant="ghost" |
|
|
|
size="sm" |
|
|
|
size="sm" |
|
|
|
@ -445,7 +556,9 @@ export default function CacheBrowserDialog({ |
|
|
|
<X className="h-3 w-3" /> |
|
|
|
<X className="h-3 w-3" /> |
|
|
|
</Button> |
|
|
|
</Button> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
<div className={`font-semibold text-xs mb-2 break-all ${invalid ? 'pr-24' : 'pr-20'}`}> |
|
|
|
<div |
|
|
|
|
|
|
|
className={`font-semibold text-xs mb-2 break-all ${invalid || showBroadcast ? 'pr-28' : 'pr-20'}`} |
|
|
|
|
|
|
|
> |
|
|
|
{item.key} |
|
|
|
{item.key} |
|
|
|
{typeof nestedCount === 'number' && nestedCount > 0 && ( |
|
|
|
{typeof nestedCount === 'number' && nestedCount > 0 && ( |
|
|
|
<span className="ml-2 text-muted-foreground"> |
|
|
|
<span className="ml-2 text-muted-foreground"> |
|
|
|
|