diff --git a/src/components/CacheEventImportSettings/index.tsx b/src/components/CacheEventImportSettings/index.tsx new file mode 100644 index 00000000..4a5f1f9f --- /dev/null +++ b/src/components/CacheEventImportSettings/index.tsx @@ -0,0 +1,218 @@ +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Textarea } from '@/components/ui/textarea' +import { + CACHE_IMPORT_MAX_JSONL_FILE_BYTES, + formatCacheImportLimits, + parseJsonlCacheImportText, + parsePastedCacheImportJson +} from '@/lib/cache-event-import' +import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback' +import { useNostr } from '@/providers/NostrProvider' +import client from '@/services/client.service' +import type { Event } from 'nostr-tools' +import { Upload } from 'lucide-react' +import { useCallback, useId, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +async function ingestImportedEvents( + events: Event[], + broadcast: boolean, + accountPubkey: string | null, + t: (key: string) => string +): Promise<{ imported: number; broadcastOk: number; broadcastFailed: number }> { + const unique: Event[] = [] + const seen = new Set() + for (const ev of events) { + const id = ev.id.toLowerCase() + if (seen.has(id)) continue + seen.add(id) + unique.push(ev) + client.addEventToCache(ev, { explicitNoteLookupHexId: id }) + } + + let broadcastOk = 0 + let broadcastFailed = 0 + if (broadcast && accountPubkey && unique.length > 0) { + const urls = await client.getMailboxStackWriteUrlsForRepublish(accountPubkey) + if (!urls.length) { + throw new Error(t('No mailbox cache or HTTP write relays configured')) + } + for (const ev of unique) { + try { + const result = await client.publishEvent(urls, ev, { skipOutboxRetry: true }) + if (result.successCount >= 1) broadcastOk++ + else broadcastFailed++ + } catch { + broadcastFailed++ + } + } + } + + return { imported: unique.length, broadcastOk, broadcastFailed } +} + +export default function CacheEventImportSettings() { + const { t } = useTranslation() + const { pubkey: accountPubkey } = useNostr() + const broadcastId = useId() + const fileInputRef = useRef(null) + const [pasteText, setPasteText] = useState('') + const [broadcastAfterImport, setBroadcastAfterImport] = useState(false) + const [busy, setBusy] = useState(false) + + const runImport = useCallback( + async (events: Event[], issueCount: number) => { + if (events.length === 0) { + toast.error(t('cacheImport.noValidEvents')) + return + } + if (broadcastAfterImport && !accountPubkey) { + toast.error(t('Log in to broadcast')) + return + } + setBusy(true) + const loadingId = toast.loading(t('cacheImport.importing')) + try { + const stats = await ingestImportedEvents(events, broadcastAfterImport, accountPubkey ?? null, t) + if (issueCount > 0) { + toast.warning( + t('cacheImport.partialWithIssues', { + imported: stats.imported, + skipped: issueCount + }) + ) + } + const successMessage = + broadcastAfterImport && accountPubkey + ? t('cacheImport.doneWithBroadcast', { + imported: stats.imported, + broadcastOk: stats.broadcastOk, + broadcastFailed: stats.broadcastFailed + }) + : t('cacheImport.done', { count: stats.imported }) + toast.dismiss(loadingId) + showSimplePublishSuccess(successMessage) + setPasteText('') + } catch (err) { + toast.dismiss(loadingId) + showPublishingError( + t('cacheImport.failed', { + error: err instanceof Error ? err.message : String(err) + }) + ) + } finally { + setBusy(false) + } + }, + [accountPubkey, broadcastAfterImport, t] + ) + + const handleImportPaste = () => { + const { events, issues } = parsePastedCacheImportJson(pasteText) + void runImport(events, issues.length) + } + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + e.target.value = '' + if (!file) return + if (!file.name.toLowerCase().endsWith('.jsonl')) { + toast.error(t('cacheImport.jsonlOnly')) + return + } + if (file.size > CACHE_IMPORT_MAX_JSONL_FILE_BYTES) { + toast.error( + t('cacheImport.fileTooLarge', { + mb: Math.round(CACHE_IMPORT_MAX_JSONL_FILE_BYTES / (1024 * 1024)) + }) + ) + return + } + void (async () => { + setBusy(true) + try { + const text = await file.text() + const { events, issues } = parseJsonlCacheImportText(text) + await runImport(events, issues.length) + } catch (err) { + toast.error( + t('cacheImport.failed', { + error: err instanceof Error ? err.message : String(err) + }) + ) + } finally { + setBusy(false) + } + })() + } + + return ( +
+

{t('cacheImport.sectionTitle')}

+

{t('cacheImport.sectionBlurb')}

+

{formatCacheImportLimits()}

+ +
+ setBroadcastAfterImport(v === true)} + disabled={!accountPubkey || busy} + /> + +
+ + + + + {t('cacheImport.tabPaste')} + + + {t('cacheImport.tabFile')} + + + +