SHA256
400 lines
16 KiB
JavaScript
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('<', '<')
|
|
.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 = '<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;
|
|
}
|