import { buildArweaveDataUrl, getArweaveUploadPrice, loadArweaveFileMetadata, sha256HexFromArrayBuffer, uploadArweaveFile, validateArweaveTxId, } from '../services/arweave-file-service.js'; import { addArweaveWalletSecret, getArweaveBalance, getArweaveWalletChoices } from '../services/arweave-wallet-service.js'; import { escapeHtml, formatBytes, getAttachmentKind, normalizeAttachment } from '../services/attachment-format.js'; import { getStoredSolanaWalletChoices } from '../services/solana-wallet-service.js'; import { estimateTurboUploadPrice, formatTurboCredits, getFreeTurboUploadBytesLimit, getTurboBalanceForStoredSolanaKey, topUpTurboWithSolana, uploadFileWithTurbo, } from '../services/turbo-upload-service.js'; const HISTORY_KEY = 'shine-ui-arweave-attachment-history-v1'; const LAST_WALLET_KEY_PREFIX = 'shine-ui-arweave-last-wallet-v1:'; const LAST_TURBO_KEY_SOURCE_PREFIX = 'shine-ui-turbo-last-key-source-v1:'; const MAX_HISTORY_ITEMS = 100; const RECENT_UPLOAD_MS = 20 * 60 * 1000; function normalizeHistoryItem(input = {}) { const item = normalizeAttachment(input); const placedInShineAtMs = Number(input.placedInShineAtMs || 0) || 0; const purpose = String(input.purpose || '').trim() === 'avatar' ? 'avatar' : 'attachment'; return { ...item, purpose, pendingPlacement: input.pendingPlacement === true && !placedInShineAtMs, placedInShineAtMs, }; } function hasAttachmentPreview(item) { return !!String(item?.preview?.ar || '').trim(); } function isVideoAttachmentName(name) { return getAttachmentKind({ name }) === 'video'; } function buildHistoryPreviewMarkup(item, gateway) { if (!hasAttachmentPreview(item)) return ''; const previewTxId = String(item.preview.ar || '').trim(); const previewUrl = buildArweaveDataUrl({ gateway, txId: previewTxId }); return `
Превью ${escapeHtml(item.name)}
Есть превью
`; } export function readArweaveAttachmentHistory(login) { try { const raw = sessionStorage.getItem(HISTORY_KEY); const parsed = raw ? JSON.parse(raw) : {}; const key = String(login || '').trim().toLowerCase(); const rows = Array.isArray(parsed?.[key]) ? parsed[key] : []; return rows.map((item) => normalizeHistoryItem(item)).filter(Boolean); } catch { return []; } } export function saveArweaveAttachmentHistory(login, rows) { try { const key = String(login || '').trim().toLowerCase(); const raw = sessionStorage.getItem(HISTORY_KEY); const parsed = raw ? JSON.parse(raw) : {}; parsed[key] = (Array.isArray(rows) ? rows : []) .map((item) => normalizeHistoryItem(item)) .slice(0, MAX_HISTORY_ITEMS); sessionStorage.setItem(HISTORY_KEY, JSON.stringify(parsed)); } catch { // ignore sessionStorage errors } } export function clearArweaveAttachmentHistory(login) { saveArweaveAttachmentHistory(login, []); } export function addArweaveAttachmentToHistory(login, attachment, { pendingPlacement = undefined, markPlaced = false } = {}) { const item = normalizeHistoryItem(attachment); const rows = readArweaveAttachmentHistory(login); const existing = rows.find((row) => row.ar === item.ar) || {}; const placedInShineAtMs = markPlaced ? Date.now() : Number(existing.placedInShineAtMs || item.placedInShineAtMs || 0) || 0; const nextPendingPlacement = pendingPlacement === undefined ? (existing.pendingPlacement === true && !placedInShineAtMs) : (!!pendingPlacement && !placedInShineAtMs); const nextItem = { ...item, pendingPlacement: nextPendingPlacement, placedInShineAtMs, }; const nextRows = rows.filter((row) => row.ar !== item.ar); nextRows.unshift(nextItem); saveArweaveAttachmentHistory(login, nextRows); return nextItem; } export function markArweaveAttachmentPlaced(login, attachment) { const item = normalizeHistoryItem(attachment); const rows = readArweaveAttachmentHistory(login); const existing = rows.find((row) => row.ar === item.ar) || {}; const nextItem = { ...item, uploadedAtMs: item.uploadedAtMs || existing.uploadedAtMs || Date.now(), pendingPlacement: false, placedInShineAtMs: Number(existing.placedInShineAtMs || 0) || Date.now(), }; const nextRows = rows.filter((row) => row.ar !== item.ar); nextRows.unshift(nextItem); saveArweaveAttachmentHistory(login, nextRows); return nextItem; } export function formatArweaveHistoryTime(ms) { const value = Number(ms || 0); if (!Number.isFinite(value) || value <= 0) return '—'; const diffMs = Date.now() - value; if (diffMs >= 0 && diffMs < RECENT_UPLOAD_MS) { const minutes = Math.max(1, Math.floor(diffMs / 60000)); return `${minutes} мин назад`; } return new Date(value).toLocaleString('ru-RU'); } export function formatArweaveHistoryDateTime(ms) { const value = Number(ms || 0); if (!Number.isFinite(value) || value <= 0) return '—'; return new Date(value).toLocaleString('ru-RU'); } export async function getArweaveAttachmentAvailability({ gateway, attachment } = {}) { const item = normalizeAttachment(attachment); const url = buildArweaveDataUrl({ gateway, txId: item.ar }); try { const response = await fetch(url, { method: 'HEAD', cache: 'no-store' }); if (response.ok) return { status: 'available', label: '✓ Доступен', url }; } catch { // gateway может временно не отвечать или блокировать HEAD; для UI это всё равно недоступно. } const ageMs = Date.now() - Number(item.uploadedAtMs || 0); if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs < RECENT_UPLOAD_MS) { return { status: 'pending', label: '… Ещё обновляется', url }; } return { status: 'unavailable', label: '✕ Недоступен', url }; } function setText(node, text) { if (node) node.textContent = String(text || ''); } function shortAddress(value) { const raw = String(value || '').trim(); if (raw.length <= 16) return raw; return `${raw.slice(0, 8)}...${raw.slice(-6)}`; } function lastWalletStorageKey(login) { return `${LAST_WALLET_KEY_PREFIX}${String(login || '').trim().toLowerCase() || 'anonymous'}`; } function lastTurboKeySourceStorageKey(login) { return `${LAST_TURBO_KEY_SOURCE_PREFIX}${String(login || '').trim().toLowerCase() || 'anonymous'}`; } function readLastWalletId(login) { try { return sessionStorage.getItem(lastWalletStorageKey(login)) || ''; } catch { return ''; } } function writeLastWalletId(login, walletId) { try { const clean = String(walletId || '').trim(); if (clean) sessionStorage.setItem(lastWalletStorageKey(login), clean); } catch { // Если sessionStorage недоступен, выбор сохраняется хотя бы в текущем окне менеджера. } } function readLastTurboKeySource(login, fallback = 'client') { try { return sessionStorage.getItem(lastTurboKeySourceStorageKey(login)) || String(fallback || 'client'); } catch { return String(fallback || 'client'); } } function writeLastTurboKeySource(login, keySource) { try { const clean = String(keySource || '').trim() || 'client'; sessionStorage.setItem(lastTurboKeySourceStorageKey(login), clean); } catch { // ignore sessionStorage errors } } async function resizeImageFileToAvatar(file) { const source = file instanceof File ? file : null; if (!source) throw new Error('Выберите изображение.'); const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || '')); reader.onerror = () => reject(new Error('Не удалось прочитать изображение.')); reader.readAsDataURL(source); }); const image = await new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error('Не удалось открыть изображение.')); img.src = dataUrl; }); const side = Math.min(image.naturalWidth || image.width, image.naturalHeight || image.height); if (!side || side <= 0) throw new Error('Некорректный размер изображения.'); const canvas = document.createElement('canvas'); canvas.width = 512; canvas.height = 512; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas недоступен.'); const sx = Math.max(0, ((image.naturalWidth || image.width) - side) / 2); const sy = Math.max(0, ((image.naturalHeight || image.height) - side) / 2); ctx.drawImage(image, sx, sy, side, side, 0, 0, 512, 512); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.9)); if (!blob) throw new Error('Не удалось сжать аватар.'); return new File([blob], 'avatar.jpg', { type: 'image/jpeg', lastModified: Date.now() }); } async function createVideoPreviewFile(file) { const source = file instanceof File ? file : null; if (!source) throw new Error('Выберите видеофайл.'); const blobUrl = URL.createObjectURL(source); try { const video = await new Promise((resolve, reject) => { const node = document.createElement('video'); node.preload = 'metadata'; node.muted = true; node.playsInline = true; node.onloadeddata = () => resolve(node); node.onerror = () => reject(new Error('Не удалось открыть видео для создания превью.')); node.src = blobUrl; }); const width = Number(video.videoWidth || 0); const height = Number(video.videoHeight || 0); if (!width || !height) { throw new Error('Не удалось определить размер видео для превью.'); } const maxSide = 640; const scale = Math.min(1, maxSide / Math.max(width, height)); const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(width * scale)); canvas.height = Math.max(1, Math.round(height * scale)); const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas недоступен для создания превью.'); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.86)); if (!blob) throw new Error('Не удалось собрать preview image.'); const baseName = String(source.name || 'video').replace(/\.[^.]+$/u, '') || 'video'; return new File([blob], `${baseName}-preview.jpg`, { type: 'image/jpeg', lastModified: Date.now(), }); } finally { URL.revokeObjectURL(blobUrl); } } export function openArweaveAttachmentManager({ login, storagePwd, gateway, onSelect, selectedTxIds = [], historyOnly = false, mode = 'attachment', historyPurpose = '', uploadTransport = 'arweave', turboKeySource = 'client', } = {}) { const cleanLogin = String(login || '').trim(); const cleanStoragePwd = String(storagePwd || '').trim(); const cleanGateway = String(gateway || 'https://arweave.net').trim(); if (!cleanLogin || !cleanStoragePwd) return Promise.reject(new Error('Нет активной сессии.')); const root = document.createElement('div'); root.className = 'ar-attachment-manager-root'; document.body.append(root); let closed = false; let wallets = []; let turboKeyChoices = []; let selectedWalletId = readLastWalletId(cleanLogin) || 'derived-client-key'; let selectedTurboKeySource = readLastTurboKeySource(cleanLogin, turboKeySource); let selectedUploadTransport = String(uploadTransport || '').trim().toLowerCase() === 'turbo' ? 'turbo' : 'arweave'; let selectedFile = null; let selectedSha256 = ''; let selectedPreviewEnabled = false; let selectedPreviewFile = null; let selectedPreviewSha256 = ''; let selectedPreviewPriceInfo = null; let priceInfo = null; let balanceInfo = null; let autoOpenedFileDialog = false; const isAvatarMode = String(mode || '') === 'avatar'; const historyPurposeMode = String(historyPurpose || '').trim(); const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment'; const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean)); function isTurboUpload() { return selectedUploadTransport === 'turbo'; } function selectedWallet() { return wallets.find((item) => String(item.id) === String(selectedWalletId)) || wallets[0] || null; } function close(resolve, result = null) { if (closed) return; closed = true; root.remove(); resolve(result); } function finish(resolve, attachment, { pendingPlacement = undefined } = {}) { const item = addArweaveAttachmentToHistory(cleanLogin, attachment, { pendingPlacement, markPlaced: false, }); if (!pendingPlacement && typeof onSelect === 'function') onSelect(item); close(resolve, item); } return new Promise((resolve) => { const bindBackdrop = () => { const modal = root.querySelector('[data-ar-attach-modal="true"]'); modal?.addEventListener('click', (event) => { if (event.target === modal) close(resolve, null); }); }; const renderWalletOptions = () => wallets.map((wallet) => ( `` )).join(''); const loadWallets = async (errorEl = null) => { try { wallets = await getArweaveWalletChoices({ login: cleanLogin, storagePwd: cleanStoragePwd }); if (!wallets.some((item) => String(item.id) === String(selectedWalletId))) { selectedWalletId = String(wallets[0]?.id || 'derived-client-key'); } writeLastWalletId(cleanLogin, selectedWalletId); } catch (error) { setText(errorEl, error?.message || 'Не удалось загрузить Arweave-кошельки.'); } }; const loadTurboKeyChoices = async (errorEl = null) => { try { turboKeyChoices = await getStoredSolanaWalletChoices({ login: cleanLogin, storagePwd: cleanStoragePwd, includeRoot: true, }); turboKeyChoices.sort((a, b) => { if (a?.keySource === b?.keySource) return 0; if (a?.keySource === 'client') return -1; if (b?.keySource === 'client') return 1; return 0; }); if (!turboKeyChoices.some((item) => String(item.keySource) === String(selectedTurboKeySource))) { selectedTurboKeySource = String( turboKeyChoices.find((item) => item.keySource === 'client')?.keySource || turboKeyChoices[0]?.keySource || 'client' ); } writeLastTurboKeySource(cleanLogin, selectedTurboKeySource); } catch (error) { setText(errorEl, error?.message || 'Не удалось загрузить Solana-ключи для Turbo.'); } }; const renderTurboMeta = (mainMetaEl, previewMetaEl) => { if (!mainMetaEl || !previewMetaEl) return; const turboAddress = String(balanceInfo?.address || priceInfo?.address || '').trim(); const turboCredits = balanceInfo ? formatTurboCredits(balanceInfo.effectiveCredits, 6) : '—'; const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '—'; const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '—'; const fileSize = selectedFile ? formatBytes(selectedFile.size) : '—'; const freeHint = selectedFile ? (combinedUploadSize() <= getFreeTurboUploadBytesLimit() ? 'Да, бесплатно через Turbo' : 'Нет, нужен Turbo balance') : `До ${formatBytes(getFreeTurboUploadBytesLimit())} бесплатно`; const freeBadge = priceInfo?.isFree ? ' (пока файлы до 100 KB бесплатно)' : ''; mainMetaEl.innerHTML = `
Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}
Turbo balance: ${escapeHtml(turboCredits)}
SOL на этом ключе: ${solBalance}
Размер файла: ${escapeHtml(fileSize)}
SHA-256: ${escapeHtml(selectedSha256 || '—')}
Цена Turbo: ${escapeHtml(uploadPrice)}${freeBadge}
До ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))} бесплатно: ${escapeHtml(freeHint)}
`; previewMetaEl.innerHTML = ''; if (selectedPreviewEnabled && selectedPreviewFile) { previewMetaEl.innerHTML = `
Файл превью
Имя: ${escapeHtml(selectedPreviewFile.name)}
Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}
SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}
Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${formatTurboCredits(selectedPreviewPriceInfo.credits, 6)} credits` : '—')}
`; } }; const refreshTurboBalance = async (mainMetaEl, previewMetaEl, errorEl) => { try { balanceInfo = await getTurboBalanceForStoredSolanaKey({ login: cleanLogin, storagePwd: cleanStoragePwd, keySource: selectedTurboKeySource, }); renderTurboMeta(mainMetaEl, previewMetaEl); if (errorEl && String(errorEl.textContent || '').includes('Turbo balance')) { setText(errorEl, ''); } } catch (error) { setText(errorEl, error?.message || 'Не удалось получить Turbo balance.'); } }; const promptTurboTopUp = async (mainMetaEl, previewMetaEl, errorEl) => { const value = window.prompt('Сколько SOL перевести в Turbo?', '0.01'); if (value == null) return; setText(errorEl, 'Пополняем Turbo...'); try { const result = await topUpTurboWithSolana({ login: cleanLogin, storagePwd: cleanStoragePwd, keySource: selectedTurboKeySource, amountSol: value, }); await refreshTurboBalance(mainMetaEl, previewMetaEl, errorEl); setText(errorEl, `Turbo пополнен. Tx: ${result.txId || 'отправлено'}`); } catch (error) { setText(errorEl, error?.message || 'Не удалось пополнить Turbo.'); } }; const resetPreviewState = () => { selectedPreviewEnabled = false; selectedPreviewFile = null; selectedPreviewSha256 = ''; selectedPreviewPriceInfo = null; }; const isPreviewEligibleForCurrentFile = () => ( !isAvatarMode && selectedFile instanceof File && isVideoAttachmentName(selectedFile.name) ); const combinedUploadSize = () => { return Number(selectedFile?.size || 0) + Number(selectedPreviewEnabled ? (selectedPreviewFile?.size || 0) : 0); }; const renderArweaveMeta = (mainMetaEl, previewMetaEl) => { if (!mainMetaEl || !previewMetaEl) return; mainMetaEl.innerHTML = ` ${isAvatarMode ? '' : `
Имя: ${escapeHtml(selectedFile?.name || 'file')}
`}
Размер: ${escapeHtml(formatBytes(selectedFile?.size || 0))}
SHA-256: ${escapeHtml(selectedSha256 || '—')}
Цена: ${escapeHtml(Number(priceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR
Баланс кошелька: ${escapeHtml(Number(balanceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR
`; previewMetaEl.innerHTML = ''; if (selectedPreviewEnabled && selectedPreviewFile) { previewMetaEl.innerHTML = `
Файл превью
Имя: ${escapeHtml(selectedPreviewFile.name)}
Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}
SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}
Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${Number(selectedPreviewPriceInfo.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 })} AR` : '—')}
`; } }; const recalculateArweaveState = async (mainMetaEl, previewMetaEl, errorEl, uploadBtn) => { uploadBtn.disabled = true; priceInfo = null; balanceInfo = null; selectedPreviewPriceInfo = null; setText(errorEl, ''); if (!selectedFile || !selectedSha256) { setText(mainMetaEl, ''); setText(previewMetaEl, ''); return; } const wallet = selectedWallet(); if (!wallet?.address) throw new Error('Выберите Arweave-кошелёк.'); const mainPrice = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedFile.size }); let totalWinston = BigInt(mainPrice.winston); if (selectedPreviewEnabled && selectedPreviewFile) { const previewPrice = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedPreviewFile.size }); selectedPreviewPriceInfo = previewPrice; totalWinston += BigInt(previewPrice.winston); } balanceInfo = await getArweaveBalance({ gateway: cleanGateway, address: wallet.address }); priceInfo = { ar: Number(totalWinston) / 1e12, winston: totalWinston.toString(), }; renderArweaveMeta(mainMetaEl, previewMetaEl); const hasFunds = BigInt(balanceInfo.winston) >= totalWinston; if (!hasFunds) { setText(errorEl, 'Недостаточно AR на выбранном кошельке.'); return; } uploadBtn.disabled = false; }; const refreshTurboStateForCurrentFile = async (mainMetaEl, previewMetaEl, errorEl, uploadBtn) => { uploadBtn.disabled = true; priceInfo = null; balanceInfo = null; selectedPreviewPriceInfo = null; setText(errorEl, ''); if (!selectedFile || !selectedSha256) { renderTurboMeta(mainMetaEl, previewMetaEl); return; } try { const mainPrice = await estimateTurboUploadPrice({ login: cleanLogin, storagePwd: cleanStoragePwd, keySource: selectedTurboKeySource, byteLength: selectedFile.size, }); let totalWinc = BigInt(mainPrice.winc); if (selectedPreviewEnabled && selectedPreviewFile) { const previewPrice = await estimateTurboUploadPrice({ login: cleanLogin, storagePwd: cleanStoragePwd, keySource: selectedTurboKeySource, byteLength: selectedPreviewFile.size, }); selectedPreviewPriceInfo = previewPrice; totalWinc += BigInt(previewPrice.winc); } priceInfo = { ...mainPrice, byteLength: combinedUploadSize(), isFree: combinedUploadSize() <= getFreeTurboUploadBytesLimit(), winc: totalWinc.toString(), credits: Number(totalWinc) / 1_000_000_000_000, }; balanceInfo = await getTurboBalanceForStoredSolanaKey({ login: cleanLogin, storagePwd: cleanStoragePwd, keySource: selectedTurboKeySource, }); renderTurboMeta(mainMetaEl, previewMetaEl); if (!priceInfo.isFree && BigInt(balanceInfo.effectiveWinc) < BigInt(priceInfo.winc)) { setText(errorEl, 'Turbo balance недостаточен. Нажмите «Пополнить Turbo».'); return; } uploadBtn.disabled = false; } catch (error) { setText(errorEl, error?.message || 'Не удалось обновить цену Turbo.'); } }; const showUpload = async () => { const turboMode = isTurboUpload(); root.innerHTML = ` `; bindBackdrop(); const walletEl = root.querySelector('#ar-attach-wallet'); const turboKeyEl = root.querySelector('#turbo-key-source'); const fileEl = root.querySelector('#ar-attach-file'); const mainMetaEl = root.querySelector('[data-meta-main="true"]'); const previewMetaEl = root.querySelector('[data-meta-preview="true"]'); const errorEl = root.querySelector('[data-error="true"]'); const uploadBtn = root.querySelector('[data-action="upload"]'); const previewOptionEl = root.querySelector('[data-preview-option="true"]'); const previewToggleEl = root.querySelector('[data-preview-toggle="true"]'); root.querySelector('[data-action="switch-arweave"]')?.addEventListener('click', async () => { if (selectedUploadTransport === 'arweave') return; selectedUploadTransport = 'arweave'; selectedFile = null; selectedSha256 = ''; resetPreviewState(); priceInfo = null; balanceInfo = null; await showUpload(); }); root.querySelector('[data-action="switch-turbo"]')?.addEventListener('click', async () => { if (selectedUploadTransport === 'turbo') return; selectedUploadTransport = 'turbo'; selectedFile = null; selectedSha256 = ''; resetPreviewState(); priceInfo = null; balanceInfo = null; await showUpload(); }); root.querySelector('[data-action="history-top"]')?.addEventListener('click', showHistory); root.querySelector('[data-action="existing-top"]')?.addEventListener('click', showExisting); if (turboMode) { await loadTurboKeyChoices(errorEl); if (turboKeyEl) { turboKeyEl.innerHTML = turboKeyChoices.map((item) => ( `` )).join(''); turboKeyEl.value = selectedTurboKeySource; turboKeyEl.addEventListener('change', async () => { selectedTurboKeySource = String(turboKeyEl.value || 'client'); writeLastTurboKeySource(cleanLogin, selectedTurboKeySource); await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn); }); } await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn); } else { await loadWallets(errorEl); if (walletEl) { walletEl.innerHTML = renderWalletOptions(); walletEl.value = selectedWalletId; walletEl.addEventListener('change', () => { selectedWalletId = String(walletEl.value || ''); writeLastWalletId(cleanLogin, selectedWalletId); uploadBtn.disabled = true; selectedFile = null; selectedSha256 = ''; resetPreviewState(); priceInfo = null; balanceInfo = null; if (fileEl) fileEl.value = ''; setText(mainMetaEl, ''); setText(previewMetaEl, ''); }); } } root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(resolve, null)); root.querySelector('[data-action="topup"]')?.addEventListener('click', async () => { await promptTurboTopUp(mainMetaEl, previewMetaEl, errorEl); if (turboMode && selectedFile && selectedSha256) { await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn); } }); if (!turboMode) { root.querySelector('[data-action="add-wallet"]')?.addEventListener('click', showAddWallet); } fileEl?.addEventListener('change', async () => { selectedFile = fileEl.files?.[0] || null; selectedSha256 = ''; resetPreviewState(); priceInfo = null; balanceInfo = null; uploadBtn.disabled = true; setText(errorEl, ''); setText(mainMetaEl, ''); setText(previewMetaEl, ''); if (previewToggleEl instanceof HTMLInputElement) previewToggleEl.checked = false; if (previewOptionEl) previewOptionEl.hidden = true; if (!selectedFile) return; try { if (isAvatarMode && !String(selectedFile.type || '').startsWith('image/')) { throw new Error('Для аватара можно выбрать только изображение.'); } if (isAvatarMode) { selectedFile = await resizeImageFileToAvatar(selectedFile); } const buffer = await selectedFile.arrayBuffer(); selectedSha256 = await sha256HexFromArrayBuffer(buffer); if (previewOptionEl && isPreviewEligibleForCurrentFile()) { previewOptionEl.hidden = false; } if (turboMode) { await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn); } else { await recalculateArweaveState(mainMetaEl, previewMetaEl, errorEl, uploadBtn); } } catch (error) { setText(errorEl, error?.message || 'Не удалось подготовить файл.'); } }); previewToggleEl?.addEventListener('change', async () => { if (!(previewToggleEl instanceof HTMLInputElement)) return; if (!selectedFile || !isPreviewEligibleForCurrentFile()) { previewToggleEl.checked = false; return; } uploadBtn.disabled = true; selectedPreviewEnabled = previewToggleEl.checked; selectedPreviewFile = null; selectedPreviewSha256 = ''; setText(errorEl, ''); try { if (selectedPreviewEnabled) { selectedPreviewFile = await createVideoPreviewFile(selectedFile); selectedPreviewSha256 = await sha256HexFromArrayBuffer(await selectedPreviewFile.arrayBuffer()); } if (turboMode) { await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn); } else { await recalculateArweaveState(mainMetaEl, previewMetaEl, errorEl, uploadBtn); } } catch (error) { resetPreviewState(); previewToggleEl.checked = false; setText(errorEl, error?.message || 'Не удалось подготовить превью.'); } }); uploadBtn?.addEventListener('click', async () => { if (!selectedFile || !selectedSha256) { setText(errorEl, 'Выберите файл.'); return; } if (!turboMode) { const wallet = selectedWallet(); if (!wallet?.jwk) { setText(errorEl, 'Выберите Arweave-кошелёк.'); return; } } uploadBtn.disabled = true; setText(errorEl, turboMode ? 'Загружаем файл через Turbo...' : 'Загружаем файл в Arweave...'); try { let previewUpload = null; if (selectedPreviewEnabled && selectedPreviewFile && selectedPreviewSha256) { previewUpload = turboMode ? await uploadFileWithTurbo({ login: cleanLogin, storagePwd: cleanStoragePwd, keySource: selectedTurboKeySource, file: selectedPreviewFile, shineType: 'attachment-preview', tags: [ { name: 'SHiNE-Login', value: cleanLogin }, { name: 'SHiNE-Attachment-Preview-For', value: selectedFile.name || 'file' }, ], }) : await uploadArweaveFile({ gateway: cleanGateway, jwk: selectedWallet()?.jwk, file: selectedPreviewFile, shineType: 'attachment-preview', tags: [ { name: 'SHiNE-Login', value: cleanLogin }, { name: 'SHiNE-Attachment-Preview-For', value: selectedFile.name || 'file' }, ], }); } const uploaded = turboMode ? await uploadFileWithTurbo({ login: cleanLogin, storagePwd: cleanStoragePwd, keySource: selectedTurboKeySource, file: selectedFile, shineType: isAvatarMode ? 'avatar' : 'attachment', tags: [ { name: 'SHiNE-Login', value: cleanLogin }, { name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') }, ], }) : await uploadArweaveFile({ gateway: cleanGateway, jwk: selectedWallet()?.jwk, file: selectedFile, shineType: isAvatarMode ? 'avatar' : 'attachment', tags: [ { name: 'SHiNE-Login', value: cleanLogin }, { name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') }, ], }); finish(resolve, { name: isAvatarMode ? 'Аватар' : (selectedFile.name || 'file'), size: selectedFile.size, sha256: selectedSha256, ar: uploaded.id, preview: previewUpload?.id && selectedPreviewSha256 ? { ar: previewUpload.id, sha256: selectedPreviewSha256, } : undefined, uploadedAtMs: Date.now(), purpose: isAvatarMode ? 'avatar' : 'attachment', }, { pendingPlacement: historyOnly && !isAvatarMode }); } catch (error) { uploadBtn.disabled = false; setText(errorEl, error?.message || (turboMode ? 'Не удалось загрузить файл через Turbo.' : 'Не удалось загрузить файл в Arweave.')); } }); if (!autoOpenedFileDialog) { autoOpenedFileDialog = true; window.setTimeout(() => fileEl?.click(), 0); } }; const showExisting = () => { root.innerHTML = ` `; bindBackdrop(); const txEl = root.querySelector('#ar-existing-txid'); const nameEl = root.querySelector('#ar-existing-name'); const previewWrapEl = root.querySelector('[data-existing-preview-wrap="true"]'); const previewToggleEl = root.querySelector('[data-existing-preview-toggle="true"]'); const previewFieldsEl = root.querySelector('[data-existing-preview-fields="true"]'); const previewTxEl = root.querySelector('#ar-existing-preview-txid'); const metaEl = root.querySelector('[data-meta="true"]'); const errorEl = root.querySelector('[data-error="true"]'); const checkBtn = root.querySelector('[data-action="check"]'); root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload); nameEl?.addEventListener('input', () => { const isVideo = isVideoAttachmentName(String(nameEl.value || '').trim()); if (previewWrapEl) previewWrapEl.hidden = !isVideo; if (!isVideo) { if (previewToggleEl instanceof HTMLInputElement) previewToggleEl.checked = false; if (previewFieldsEl) previewFieldsEl.hidden = true; if (previewTxEl) previewTxEl.value = ''; } }); previewToggleEl?.addEventListener('change', () => { if (previewFieldsEl && previewToggleEl instanceof HTMLInputElement) { previewFieldsEl.hidden = !previewToggleEl.checked; } }); checkBtn?.addEventListener('click', async () => { const txId = String(txEl?.value || '').trim(); const name = String(nameEl?.value || '').trim() || 'arweave-file'; const wantsPreview = previewToggleEl instanceof HTMLInputElement && previewToggleEl.checked; const previewTxId = String(previewTxEl?.value || '').trim(); if (!validateArweaveTxId(txId)) { setText(errorEl, 'Некорректный Transaction ID Arweave.'); return; } if (wantsPreview && !validateArweaveTxId(previewTxId)) { setText(errorEl, 'Некорректный Transaction ID превью.'); return; } checkBtn.disabled = true; setText(errorEl, 'Скачиваем файл и считаем SHA-256...'); setText(metaEl, ''); try { const meta = await loadArweaveFileMetadata({ gateway: cleanGateway, txId }); let preview = null; if (wantsPreview) { const previewMeta = await loadArweaveFileMetadata({ gateway: cleanGateway, txId: previewTxId }); preview = { ar: previewTxId, sha256: previewMeta.sha256Hex, }; } const item = normalizeAttachment({ name, size: meta.sizeBytes, sha256: meta.sha256Hex, ar: txId, preview, uploadedAtMs: Date.now(), }); metaEl.innerHTML = `
Размер: ${escapeHtml(formatBytes(item.size))}
SHA-256: ${escapeHtml(item.sha256)}
${item.preview ? '
Превью будет добавлено вместе с файлом.
' : ''} `; setText(errorEl, ''); const addBtn = document.createElement('button'); addBtn.className = 'primary-btn'; addBtn.type = 'button'; addBtn.textContent = historyOnly ? 'Добавить в журнал' : 'Добавить в сообщение'; addBtn.addEventListener('click', () => finish(resolve, item, { pendingPlacement: historyOnly && !isAvatarMode })); metaEl.append(addBtn); } catch (error) { setText(errorEl, error?.message || 'Не удалось проверить файл Arweave.'); checkBtn.disabled = false; } }); window.setTimeout(() => txEl?.focus(), 0); }; const showHistory = () => { const rows = readArweaveAttachmentHistory(cleanLogin) .filter((item) => purposeFilter === 'avatar' ? item.purpose === 'avatar' : item.purpose !== 'avatar') .slice() .reverse(); root.innerHTML = ` `; bindBackdrop(); const scrollEl = root.querySelector('[data-history-scroll="true"]'); window.setTimeout(() => { if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight; }, 0); root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload); root.querySelectorAll('[data-pick]').forEach((button) => { button.addEventListener('click', () => { const index = Number(button.getAttribute('data-pick')); const item = rows[index]; if (item) finish(resolve, item); }); }); rows.forEach((item, index) => { const statusEl = root.querySelector(`[data-status="${index}"]`); void getArweaveAttachmentAvailability({ gateway: cleanGateway, attachment: item }) .then((result) => { if (!statusEl) return; statusEl.className = `ar-attachment-status ar-attachment-status--${result.status}`; statusEl.textContent = result.label; }) .catch(() => { if (!statusEl) return; statusEl.className = 'ar-attachment-status ar-attachment-status--unavailable'; statusEl.textContent = 'Недоступен'; }); }); }; const showAddWallet = () => { root.innerHTML = ` `; bindBackdrop(); const labelEl = root.querySelector('#ar-wallet-label'); const secretEl = root.querySelector('#ar-wallet-secret'); const errorEl = root.querySelector('[data-error="true"]'); const saveBtn = root.querySelector('[data-action="save"]'); root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload); saveBtn?.addEventListener('click', async () => { saveBtn.disabled = true; setText(errorEl, ''); try { const wallet = await addArweaveWalletSecret({ login: cleanLogin, storagePwd: cleanStoragePwd, label: String(labelEl?.value || '').trim(), secret: String(secretEl?.value || '').trim(), }); selectedWalletId = wallet.id; writeLastWalletId(cleanLogin, selectedWalletId); await showUpload(); } catch (error) { saveBtn.disabled = false; setText(errorEl, error?.message || 'Не удалось добавить Arweave-кошелёк.'); } }); }; void showUpload(); }); }