Files
SHiNE-server/shine-UI/js/pages/channel-donate-view.js
T

400 lines
16 KiB
JavaScript

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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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 = '<div class="meta-muted">Загрузка данных канала…</div>';
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 = `
<div class="channel-donate-hero">
<h2>Донат автору</h2>
<strong>${escapeHtml(channelTitle)}</strong>
<span>${escapeHtml(ownerLogin)}${channelName ? ` / ${escapeHtml(channelName)}` : ''}</span>
</div>
<section class="channel-donate-section">
<label class="field-label" for="channel-donate-blockchain">Блокчейн</label>
<select class="select" id="channel-donate-blockchain">
<option value="solana">Solana</option>
</select>
</section>
<section class="channel-donate-section">
<label class="field-label" for="channel-donate-recipient-key">Счёт получателя</label>
<select class="select" id="channel-donate-recipient-key" ${recipientChoices.length ? '' : 'disabled'}>
${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')}
</select>
<div class="channel-donate-address" id="channel-donate-recipient-address">—</div>
</section>
<section class="channel-donate-section">
<label class="field-label" for="channel-donate-sender-key">Перевести с моего счёта</label>
<select class="select" id="channel-donate-sender-key">
<option value="client-key">Client key</option>
<option value="root-key">Root key</option>
</select>
<div class="channel-donate-address" id="channel-donate-sender-address">—</div>
<div class="channel-donate-balance-row">
<span class="meta-muted">Баланс</span>
<strong id="channel-donate-balance">—</strong>
<button class="secondary-btn channel-donate-refresh" id="channel-donate-refresh" type="button">Обновить</button>
</div>
</section>
<section class="channel-donate-section">
<label class="field-label" for="channel-donate-amount">Сумма</label>
<div class="channel-donate-amount-row">
<input class="input" id="channel-donate-amount" type="number" min="0" step="0.000001" inputmode="decimal" placeholder="0.1" />
<span>SOL</span>
</div>
</section>
<p class="meta-muted inline-error channel-donate-status" id="channel-donate-status"></p>
<div class="channel-donate-result" id="channel-donate-result" hidden></div>
<button class="primary-btn channel-donate-submit" id="channel-donate-submit" type="button" ${recipientChoices.length ? '' : 'disabled'}>Перевести</button>
`;
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 = `
<strong>Перевод выполнен ✓</strong>
<span>${escapeHtml(formatSol(amount))} SOL → ${escapeHtml(recipient.label)}</span>
<code>${escapeHtml(String(result.signature || ''))}</code>
`;
}
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 = '<div class="meta-muted">Загрузка данных канала…</div>';
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 = `
<div class="meta-muted">Не удалось открыть донат автору.</div>
<div class="meta-muted">${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}</div>
<button class="secondary-btn" id="channel-donate-retry" type="button">Повторить</button>
`;
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;
}