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, normalizeAttachment } from '../services/attachment-format.js'; const HISTORY_KEY = 'shine-ui-arweave-attachment-history-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, }; } 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)}`; } 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() }); } export function openArweaveAttachmentManager({ login, storagePwd, gateway, onSelect, selectedTxIds = [], historyOnly = false, mode = 'attachment', historyPurpose = '', } = {}) { 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 selectedWalletId = 'derived-client-key'; let selectedFile = null; let selectedSha256 = ''; 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 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'); } } catch (error) { setText(errorEl, error?.message || 'Не удалось загрузить Arweave-кошельки.'); } }; const showUpload = async () => { root.innerHTML = ` `; bindBackdrop(); const walletEl = root.querySelector('#ar-attach-wallet'); const fileEl = root.querySelector('#ar-attach-file'); const metaEl = root.querySelector('[data-meta="true"]'); const errorEl = root.querySelector('[data-error="true"]'); const uploadBtn = root.querySelector('[data-action="upload"]'); await loadWallets(errorEl); if (walletEl) { walletEl.innerHTML = renderWalletOptions(); walletEl.value = selectedWalletId; walletEl.addEventListener('change', () => { selectedWalletId = String(walletEl.value || ''); uploadBtn.disabled = true; selectedFile = null; selectedSha256 = ''; priceInfo = null; balanceInfo = null; if (fileEl) fileEl.value = ''; setText(metaEl, ''); }); } root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(resolve, null)); root.querySelector('[data-action="existing"]')?.addEventListener('click', showExisting); root.querySelector('[data-action="history"]')?.addEventListener('click', showHistory); root.querySelector('[data-action="add-wallet"]')?.addEventListener('click', showAddWallet); fileEl?.addEventListener('change', async () => { selectedFile = fileEl.files?.[0] || null; selectedSha256 = ''; priceInfo = null; balanceInfo = null; uploadBtn.disabled = true; setText(errorEl, ''); setText(metaEl, ''); if (!selectedFile) return; try { const wallet = selectedWallet(); if (!wallet?.address) throw new Error('Выберите Arweave-кошелёк.'); 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); priceInfo = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedFile.size }); balanceInfo = await getArweaveBalance({ gateway: cleanGateway, address: wallet.address }); const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston); metaEl.innerHTML = ` ${isAvatarMode ? '' : `
Имя: ${escapeHtml(selectedFile.name || 'file')}
`}
Размер: ${escapeHtml(formatBytes(selectedFile.size))}
SHA-256: ${escapeHtml(selectedSha256)}
Цена: ${escapeHtml(Number(priceInfo.ar).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR
Баланс кошелька: ${escapeHtml(Number(balanceInfo.ar).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR
`; if (!hasFunds) { setText(errorEl, 'Недостаточно AR на выбранном кошельке.'); return; } uploadBtn.disabled = false; } catch (error) { setText(errorEl, error?.message || 'Не удалось подготовить файл.'); } }); uploadBtn?.addEventListener('click', async () => { if (!selectedFile || !selectedSha256) { setText(errorEl, 'Выберите файл.'); return; } const wallet = selectedWallet(); if (!wallet?.jwk) { setText(errorEl, 'Выберите Arweave-кошелёк.'); return; } uploadBtn.disabled = true; setText(errorEl, 'Загружаем файл в Arweave...'); try { const uploaded = await uploadArweaveFile({ gateway: cleanGateway, jwk: wallet.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, uploadedAtMs: Date.now(), purpose: isAvatarMode ? 'avatar' : 'attachment', }, { pendingPlacement: historyOnly && !isAvatarMode }); } catch (error) { uploadBtn.disabled = false; setText(errorEl, error?.message || 'Не удалось загрузить файл в 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 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); checkBtn?.addEventListener('click', async () => { const txId = String(txEl?.value || '').trim(); const name = String(nameEl?.value || '').trim() || 'arweave-file'; if (!validateArweaveTxId(txId)) { setText(errorEl, 'Некорректный Transaction ID Arweave.'); return; } checkBtn.disabled = true; setText(errorEl, 'Скачиваем файл и считаем SHA-256...'); setText(metaEl, ''); try { const meta = await loadArweaveFileMetadata({ gateway: cleanGateway, txId }); const item = normalizeAttachment({ name, size: meta.sizeBytes, sha256: meta.sha256Hex, ar: txId, uploadedAtMs: Date.now(), }); metaEl.innerHTML = `
Размер: ${escapeHtml(formatBytes(item.size))}
SHA-256: ${escapeHtml(item.sha256)}
`; setText(errorEl, ''); const addBtn = document.createElement('button'); addBtn.className = 'primary-btn'; addBtn.type = 'button'; addBtn.textContent = 'Добавить в сообщение'; addBtn.addEventListener('click', () => finish(resolve, item)); 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; await showUpload(); } catch (error) { saveBtn.disabled = false; setText(errorEl, error?.message || 'Не удалось добавить Arweave-кошелёк.'); } }); }; void showUpload(); }); }