Files
SHiNE-server/shine-UI/js/pages/channel-donate-view.js
T
qandClaude Opus 5.5 2e62616b46 UI v4: новый стиль по макету V3/V4 и доводка экранов
Палитра «Сияние» по умолчанию, новые роли цветов. Лента канала: плоские записи, ряд действий
с равным шагом иконок, дата у записи. Обсуждение со строкой ввода, личный чат, шторки.
Вход и регистрация: стартовый экран (надпись «СИЯНИЕ», сияние мандалы), настройки входа
со статусами серверов, статус логина в одну строку, вход с другого устройства (код, таймер, шаги),
сохранение ключей с переключателями. Корневые разделы – заголовок по центру; фильтр чатов –
переключатель; уведомления – список как в чатах; фото – просмотр на весь экран.
Общее: один зазор под шапкой (--page-top-gap), переключатели высотой с поле ввода,
второстепенные кнопки по одной роли (в том числе на экранах устройств и серверов),
иконки только из ui-icon.js, полоса прокрутки скрыта, среднее тире в текстах интерфейса.

Что нужно от сервера и разработчика – docs/UI-Design/UI-v4-for-dev.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:36:52 +03:00

400 lines
16 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,
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('&', '&amp;')
.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('Для перевода нужно войти в Сиянии.');
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;
}