import { createTopBar } from '../components/topbar.js'; import { authService, state } from '../state.js'; import { navigateBack } from '../router.js'; import { extractLoginFromBlockchainName } from '../services/shine-routes.js'; import { formatSol, getBalanceSol, getSolanaWalletFromStoredSecret, getWalletFromStoredClientKey, getWalletFromStoredRootKey, solanaAddressFromPublicKeyBase64, transferSol, } from '../services/solana-wallet-service.js'; import { showToast } from '../services/channels-ux.js'; import { toUserMessage } from '../services/ui-error-texts.js'; export const pageMeta = { id: 'channel-donate-view', title: 'Донат автору' }; function escapeHtml(text) { return String(text || '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function normalizeHash(hash) { const normalized = String(hash || '').trim().toLowerCase(); return normalized || '0'; } function toSafeInt(value) { if (value === '' || value == null) return null; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } async function resolveChannelSelector(route) { const ownerRef = String(route?.params?.ownerBlockchainName || '').trim(); const rootNo = toSafeInt(route?.params?.channelRootBlockNumber); const rootHash = normalizeHash(route?.params?.channelRootBlockHash); const channelName = String(route?.params?.channelName || '').trim(); if (ownerRef && rootNo != null) { return { ownerBlockchainName: ownerRef, channelRootBlockNumber: rootNo, channelRootBlockHash: rootHash, channelName, }; } if (!ownerRef || !channelName) { throw new Error('Не удалось определить канал из адреса страницы.'); } const ownerLogin = extractLoginFromBlockchainName(ownerRef); const ownerUser = await authService.getUser(ownerLogin); if (!ownerUser?.exists) throw new Error('Владелец канала не найден.'); const ownerBlockchainName = String(ownerUser.blockchainName || ownerRef).trim(); const ownerFeed = await authService.listSubscriptionsFeed(ownerLogin, 500); const ownedRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : []; const row = ownedRows.find((item) => ( String(item?.channel?.channelName || '').trim().toLowerCase() === channelName.toLowerCase() && String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBlockchainName.toLowerCase() )); if (!row?.channel?.channelRoot) throw new Error('Канал не найден.'); return { ownerBlockchainName, channelRootBlockNumber: Number(row.channel.channelRoot.blockNumber), channelRootBlockHash: normalizeHash(row.channel.channelRoot.blockHash), channelName, }; } function publicKeyChoice(ownerUser, field, id, label) { const publicKeyB64 = String(ownerUser?.[field] || '').trim(); if (!publicKeyB64) return null; try { return { id, label, address: solanaAddressFromPublicKeyBase64(publicKeyB64), }; } catch { return null; } } export function render({ navigate, route, chrome }) { const screen = document.createElement('section'); screen.className = 'stack channels-screen channels-screen--channel-donate'; const topbar = createTopBar({ title: 'Донат автору', back: { label: '←', onClick: () => { if (window.history.length > 1) { navigateBack(); return; } navigate('channels-list'); }, ariaLabel: 'Назад', title: 'Назад', }, }); chrome?.setTopbar(topbar); const card = document.createElement('div'); card.className = 'card stack channel-donate-card'; const content = document.createElement('div'); content.className = 'stack channel-donate-content'; content.innerHTML = '
Загрузка данных канала…
'; card.append(content); screen.append(card); let disposed = false; let refreshSeq = 0; let busy = false; const walletCache = new Map(); const cleanupWallets = () => { for (const wallet of walletCache.values()) { try { wallet?.keypair?.secretKey?.fill?.(0); } catch { // Секретные ключи живут только в памяти этой страницы. } } walletCache.clear(); }; const renderDonationForm = ({ channel, selector, ownerUser }) => { const ownerLogin = String(channel?.ownerLogin || extractLoginFromBlockchainName(selector?.ownerBlockchainName) || '').trim(); const channelTitle = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim(); const channelName = String(channel?.channelName || selector?.channelName || '').trim(); const recipientChoices = [ publicKeyChoice(ownerUser, 'solanaKey', 'root-key', 'Root key'), publicKeyChoice(ownerUser, 'blockchainKey', 'blockchain-key', 'Blockchain key'), publicKeyChoice(ownerUser, 'clientKey', 'client-key', 'Client key'), ].filter(Boolean); content.innerHTML = `

Донат автору

${escapeHtml(channelTitle)} ${escapeHtml(ownerLogin)}${channelName ? ` / ${escapeHtml(channelName)}` : ''}
Баланс
SOL

`; const recipientSelect = content.querySelector('#channel-donate-recipient-key'); const senderSelect = content.querySelector('#channel-donate-sender-key'); const recipientAddressEl = content.querySelector('#channel-donate-recipient-address'); const senderAddressEl = content.querySelector('#channel-donate-sender-address'); const balanceEl = content.querySelector('#channel-donate-balance'); const amountEl = content.querySelector('#channel-donate-amount'); const statusEl = content.querySelector('#channel-donate-status'); const resultEl = content.querySelector('#channel-donate-result'); const submitEl = content.querySelector('#channel-donate-submit'); const refreshEl = content.querySelector('#channel-donate-refresh'); const setBusy = (nextBusy) => { busy = Boolean(nextBusy); if (submitEl) submitEl.disabled = busy || recipientChoices.length === 0; if (refreshEl) refreshEl.disabled = busy; if (recipientSelect) recipientSelect.disabled = busy || recipientChoices.length === 0; if (senderSelect) senderSelect.disabled = busy; if (amountEl) amountEl.disabled = busy; }; const setStatus = (message, kind = '') => { if (!statusEl) return; statusEl.textContent = String(message || ''); statusEl.classList.toggle('is-error', kind === 'error'); }; const selectedRecipient = () => ( recipientChoices.find((choice) => choice.id === String(recipientSelect?.value || '')) || recipientChoices[0] || null ); const updateRecipientAddress = () => { const choice = selectedRecipient(); if (recipientAddressEl) recipientAddressEl.textContent = choice?.address || 'Публичный ключ получателя недоступен'; }; const resolveSenderWallet = async () => { const keyId = String(senderSelect?.value || 'client-key'); if (walletCache.has(keyId)) return walletCache.get(keyId); const login = String(state.session.login || '').trim(); const storagePwd = String(state.session.storagePwdInMemory || '').trim(); if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.'); let wallet; if (keyId === 'root-key') { try { wallet = await getWalletFromStoredRootKey({ login, storagePwd }); } catch { const password = window.prompt( 'Root key не сохранён на этом устройстве.\nВведите пароль аккаунта для временного восстановления Root key:', '', ); if (password == null) throw new Error('Операция отменена.'); const keyBundle = await authService.derivePasswordKeyBundle(login, password); const rootPrivate = String(keyBundle?.rootPair?.privatePkcs8B64 || '').trim(); if (!rootPrivate) throw new Error('Не удалось временно восстановить Root key.'); wallet = await getSolanaWalletFromStoredSecret(rootPrivate); } } else { wallet = await getWalletFromStoredClientKey({ login, storagePwd }); } walletCache.set(keyId, wallet); return wallet; }; const refreshBalance = async () => { if (busy || disposed) return; setBusy(true); setStatus('Загрузка баланса…'); if (balanceEl) balanceEl.textContent = '—'; try { const wallet = await resolveSenderWallet(); if (disposed) return; if (senderAddressEl) senderAddressEl.textContent = wallet.address; const balance = await getBalanceSol({ endpoint: state.entrySettings.solanaServer, address: wallet.address, }); if (disposed) return; if (balanceEl) balanceEl.textContent = `${formatSol(balance.sol)} SOL`; setStatus(''); } catch (error) { if (disposed) return; if (senderAddressEl) senderAddressEl.textContent = '—'; setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error'); } finally { if (!disposed) setBusy(false); } }; recipientSelect?.addEventListener('change', updateRecipientAddress); senderSelect?.addEventListener('change', () => { if (senderAddressEl) senderAddressEl.textContent = '—'; if (balanceEl) balanceEl.textContent = '—'; setStatus(''); void refreshBalance(); }); refreshEl?.addEventListener('click', () => void refreshBalance()); submitEl?.addEventListener('click', async () => { if (busy || disposed) return; const recipient = selectedRecipient(); if (!recipient?.address) { setStatus('Не удалось определить счёт получателя.', 'error'); return; } const amount = Number(String(amountEl?.value || '').replace(',', '.')); if (!Number.isFinite(amount) || amount <= 0) { setStatus('Введите сумму перевода больше 0.', 'error'); return; } setBusy(true); setStatus('Отправляем перевод…'); if (resultEl) { resultEl.hidden = true; resultEl.innerHTML = ''; } try { const wallet = await resolveSenderWallet(); const result = await transferSol({ endpoint: state.entrySettings.solanaServer, fromKeypair: wallet.keypair, toAddress: recipient.address, amountSol: amount, }); if (disposed) return; setStatus(''); if (resultEl) { resultEl.hidden = false; resultEl.innerHTML = ` Перевод выполнен ✓ ${escapeHtml(formatSol(amount))} SOL → ${escapeHtml(recipient.label)} ${escapeHtml(String(result.signature || ''))} `; } showToast('Перевод отправлен'); const balance = await getBalanceSol({ endpoint: state.entrySettings.solanaServer, address: wallet.address, }); if (!disposed && balanceEl) balanceEl.textContent = `${formatSol(balance.sol)} SOL`; } catch (error) { if (!disposed) setStatus(toUserMessage(error, 'Не удалось выполнить перевод.'), 'error'); } finally { if (!disposed) setBusy(false); } }); updateRecipientAddress(); if (!recipientChoices.length) { setStatus('У владельца канала нет доступных публичных ключей для перевода.', 'error'); } else { void refreshBalance(); } }; const refresh = async () => { const seq = ++refreshSeq; cleanupWallets(); content.innerHTML = '
Загрузка данных канала…
'; try { const selector = await resolveChannelSelector(route); const payload = await authService.getChannelMessages(selector, 1, 'asc', String(state.session.login || '').trim()); if (disposed || seq !== refreshSeq) return; const channel = payload?.channel || {}; const ownerLogin = String(channel?.ownerLogin || extractLoginFromBlockchainName(selector.ownerBlockchainName) || '').trim(); const ownerUser = await authService.getUser(ownerLogin); if (disposed || seq !== refreshSeq) return; if (!ownerUser?.exists) throw new Error('Владелец канала не найден.'); renderDonationForm({ channel, selector, ownerUser }); } catch (error) { if (disposed || seq !== refreshSeq) return; content.innerHTML = `
Не удалось открыть донат автору.
${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}
`; content.querySelector('#channel-donate-retry')?.addEventListener('click', () => void refresh()); } }; screen.refresh = refresh; screen.cleanup = () => { if (disposed) return; disposed = true; refreshSeq += 1; cleanupWallets(); }; void refresh(); return screen; }