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;
return {
...item,
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)}`;
}
export function openArweaveAttachmentManager({
login,
storagePwd,
gateway,
onSelect,
selectedTxIds = [],
historyOnly = false,
} = {}) {
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 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 = `
${historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'}
${historyOnly ? '' : '
Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.
'}
${historyOnly ? '' : ''}
${historyOnly ? '' : ''}
`;
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-кошелёк.');
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 = `
Имя: ${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: 'attachment',
tags: [
{ name: 'SHiNE-Login', value: cleanLogin },
{ name: 'SHiNE-Attachment-Name', value: selectedFile.name || 'file' },
],
});
finish(resolve, {
name: selectedFile.name || 'file',
size: selectedFile.size,
sha256: selectedSha256,
ar: uploaded.id,
uploadedAtMs: Date.now(),
}, { pendingPlacement: historyOnly });
} 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).slice().reverse();
root.innerHTML = `
Журнал загруженных файлов
Журнал хранится только в этой браузерной сессии.
${rows.length ? rows.map((item, index) => {
const isSelected = selectedTxIdSet.has(item.ar);
return `
${escapeHtml(item.name)}
${escapeHtml(formatBytes(item.size))}
${escapeHtml(formatArweaveHistoryTime(item.uploadedAtMs))}
Проверяем...
${item.pendingPlacement ? 'Не добавлен в SHiNE' : ''}
${escapeHtml(item.ar)}
`;
}).join('') : '
В этой сессии ещё нет загруженных файлов.
'}
`;
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 = `
Добавить Arweave-кошелёк
Вставьте JSON JWK или короткую строку base64url(JSON JWK). Ключ будет сохранён в зашифрованном контейнере ключей этого пользователя на устройстве.
`;
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();
});
}