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

376 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
getWalletFromStoredBlockchainKey,
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, 'blockchainKey', 'blockchain-key', 'Blockchain 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>
<div class="channel-donate-address" id="channel-donate-sender-key">Blockchain key</div>
<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 = 'blockchain-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('Для перевода нужно войти в Сиянии.');
const wallet = await getWalletFromStoredBlockchainKey({ 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;
}