Добавить страницу донатов канала

This commit is contained in:
AidarKC
2026-09-17 22:43:49 +03:00
parent 7b81d140ec
commit 7aa05dc609
10 changed files with 1609 additions and 124 deletions
+2 -2
View File
@@ -11,7 +11,7 @@ import {
markArweaveAttachmentPlaced,
} from '../components/arweave-attachment-manager.js';
import { renderAvatar } from '../components/avatar-image.js';
import { makeShineChannelRoute } from '../services/shine-routes.js';
import { makeShineChannelShortRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
@@ -54,7 +54,7 @@ function renderAvatarPreview(slot, avatar, title) {
}
function buildAbsoluteChannelUrl({ ownerBlockchainName = '', channelName = '' } = {}) {
const route = makeShineChannelRoute({ ownerBlockchainName, channelName });
const route = makeShineChannelShortRoute({ ownerBlockchainName, channelName });
if (!route) return '';
try {
return new URL(`/${route}`, window.location.origin).toString();
+559 -95
View File
@@ -1,11 +1,27 @@
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { renderAvatar } from '../components/avatar-image.js';
import { authService, setChannelsFeed, state } from '../state.js';
import { navigateBack } from '../router.js';
import { makeShineChannelRootRoute, makeShineChannelShortRoute } from '../services/shine-routes.js';
import {
extractLoginFromBlockchainName,
makeProfileRoute,
makeShineChannelShortRoute,
makeShineChannelDonateRoute,
} from '../services/shine-routes.js';
import { loadProfileSnapshot } from '../services/user-profile-params.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-about-view', title: 'О канале' };
export const pageMeta = { id: 'channel-about-view', title: 'Описание канала' };
function escapeHtml(text) {
return String(text || '')
@@ -22,49 +38,384 @@ function normalizeHash(hash) {
}
function toSafeInt(value) {
if (value === '' || value == null) return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function findCachedChannel(ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash) {
const ownerBch = String(ownerBlockchainName || '').trim().toLowerCase();
const rootNo = Number(channelRootBlockNumber);
const rootHash = normalizeHash(channelRootBlockHash);
const rows = Object.values(state.channelsIndex || {});
return rows.find((row) => (
String(row?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerBch
&& Number(row?.channel?.channelRoot?.blockNumber) === rootNo
&& normalizeHash(row?.channel?.channelRoot?.blockHash) === rootHash
)) || null;
}
function buildChannelLink(route) {
if (!route) return '';
const url = new URL(window.location.href);
url.pathname = `/${String(route).replace(/^\/+/, '')}`;
url.hash = '';
return url.toString();
try {
return new URL(`/${String(route).replace(/^\/+/, '')}`, window.location.origin).toString();
} catch {
return `${window.location.origin}/${String(route).replace(/^\/+/, '')}`;
}
}
function statsText(value) {
return Number.isFinite(Number(value)) ? String(Math.max(0, Number(value))) : '0';
}
export function render({ navigate, route, chrome }) {
const ownerBlockchainName = String(route?.params?.ownerBlockchainName || '').trim();
const channelRootBlockNumber = toSafeInt(route?.params?.channelRootBlockNumber);
const channelRootBlockHash = normalizeHash(route?.params?.channelRootBlockHash);
const channelRoute = makeShineChannelRootRoute({
function profileField(snapshot, key) {
const row = Array.isArray(snapshot?.fields)
? snapshot.fields.find((item) => String(item?.key || '') === key)
: null;
return String(row?.value || '').trim();
}
function ownerDisplayName(snapshot, ownerLogin) {
const firstName = profileField(snapshot, 'first_name');
const lastName = profileField(snapshot, 'last_name');
return [firstName, lastName].filter(Boolean).join(' ').trim() || String(ownerLogin || '').trim() || 'Владелец канала';
}
function isSelectorMatch(row, selector) {
return Boolean(
row?.channel
&& String(row.channel.ownerBlockchainName || '') === String(selector?.ownerBlockchainName || '')
&& Number(row.channel.channelRoot?.blockNumber) === Number(selector?.channelRootBlockNumber)
&& normalizeHash(row.channel.channelRoot?.blockHash) === normalizeHash(selector?.channelRootBlockHash)
);
}
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,
channelRootBlockHash,
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;
}
}
function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
const root = document.getElementById('modal-root');
if (!root) return;
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);
root.innerHTML = `
<div class="modal" id="channel-support-modal">
<div class="modal-card stack channel-support-modal-card">
<div class="channel-support-modal-head">
<div>
<h3 class="modal-title">Донат автору</h3>
<p class="meta-muted channel-support-subtitle">${escapeHtml(channelTitle || 'Канал')} · @${escapeHtml(ownerLogin)}</p>
</div>
<button class="icon-btn channel-support-close" id="channel-support-close" type="button" aria-label="Закрыть" title="Закрыть">×</button>
</div>
<label class="field-label" for="channel-support-blockchain">Блокчейн</label>
<select class="select" id="channel-support-blockchain">
<option value="solana">Solana</option>
</select>
<label class="field-label" for="channel-support-recipient-key">Счёт получателя</label>
<select class="select" id="channel-support-recipient-key" ${recipientChoices.length ? '' : 'disabled'}>
${recipientChoices.map((choice) => `<option value="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</option>`).join('')}
</select>
<div class="channel-support-address" id="channel-support-recipient-address">—</div>
<label class="field-label" for="channel-support-sender-key">Перевести с моего счёта</label>
<select class="select" id="channel-support-sender-key">
<option value="client-key">Client key</option>
<option value="root-key">Root key</option>
</select>
<div class="channel-support-address" id="channel-support-sender-address">—</div>
<div class="channel-support-balance-row">
<span class="meta-muted">Баланс</span>
<strong id="channel-support-balance">—</strong>
<button class="secondary-btn channel-support-refresh" id="channel-support-refresh" type="button">Обновить</button>
</div>
<label class="field-label" for="channel-support-amount">Сумма</label>
<div class="channel-support-amount-row">
<input class="input" id="channel-support-amount" type="number" min="0" step="0.000001" inputmode="decimal" placeholder="0.1" />
<span>SOL</span>
</div>
<p class="meta-muted inline-error channel-support-status" id="channel-support-status"></p>
<div class="channel-support-result" id="channel-support-result" hidden></div>
<button class="primary-btn channel-support-submit" id="channel-support-submit" type="button" ${recipientChoices.length ? '' : 'disabled'}>Перевести</button>
</div>
</div>
`;
const modal = root.querySelector('#channel-support-modal');
const recipientSelect = root.querySelector('#channel-support-recipient-key');
const senderSelect = root.querySelector('#channel-support-sender-key');
const recipientAddressEl = root.querySelector('#channel-support-recipient-address');
const senderAddressEl = root.querySelector('#channel-support-sender-address');
const balanceEl = root.querySelector('#channel-support-balance');
const amountEl = root.querySelector('#channel-support-amount');
const statusEl = root.querySelector('#channel-support-status');
const resultEl = root.querySelector('#channel-support-result');
const submitEl = root.querySelector('#channel-support-submit');
const refreshEl = root.querySelector('#channel-support-refresh');
const walletCache = new Map();
let closed = false;
let busy = false;
const cleanupWallets = () => {
for (const wallet of walletCache.values()) {
try {
wallet?.keypair?.secretKey?.fill?.(0);
} catch {
// best effort: секрет существует только в памяти модального окна
}
}
walletCache.clear();
};
const close = () => {
if (closed) return;
closed = true;
cleanupWallets();
if (root.querySelector('#channel-support-modal') === modal) root.innerHTML = '';
};
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 || closed) return;
setBusy(true);
setStatus('Загрузка баланса…');
if (balanceEl) balanceEl.textContent = '—';
try {
const wallet = await resolveSenderWallet();
if (closed) return;
if (senderAddressEl) senderAddressEl.textContent = wallet.address;
const balance = await getBalanceSol({
endpoint: state.entrySettings.solanaServer,
address: wallet.address,
});
if (closed) return;
if (balanceEl) balanceEl.textContent = `${formatSol(balance.sol)} SOL`;
setStatus('');
} catch (error) {
if (closed) return;
if (senderAddressEl) senderAddressEl.textContent = '—';
setStatus(toUserMessage(error, 'Не удалось получить баланс.'), 'error');
} finally {
if (!closed) 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());
root.querySelector('#channel-support-close')?.addEventListener('click', close);
modal?.addEventListener('pointerdown', (event) => {
if (event.target === modal) close();
});
submitEl?.addEventListener('click', async () => {
if (busy || closed) 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 (closed) 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 (!closed && balanceEl) balanceEl.textContent = `${formatSol(balance.sol)} SOL`;
} catch (error) {
if (!closed) setStatus(toUserMessage(error, 'Не удалось выполнить перевод.'), 'error');
} finally {
if (!closed) setBusy(false);
}
});
updateRecipientAddress();
if (!recipientChoices.length) {
setStatus('У владельца канала нет доступных публичных ключей для перевода.', 'error');
} else {
void refreshBalance();
}
return close;
}
function confirmUnsubscribeModal({ channelTitle }) {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(window.confirm('Отписаться от канала?'));
return new Promise((resolve) => {
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
if (root.querySelector('#channel-unsubscribe-confirm-modal')) root.innerHTML = '';
resolve(Boolean(value));
};
root.innerHTML = `
<div class="modal" id="channel-unsubscribe-confirm-modal">
<div class="modal-card stack channel-unsubscribe-confirm-card">
<h3 class="modal-title">Отписаться от канала?</h3>
<p class="meta-muted channel-unsubscribe-confirm-text">
Вы действительно хотите отписаться от «${escapeHtml(channelTitle || 'этого канала')}»?
</p>
<div class="channel-unsubscribe-confirm-actions">
<button class="secondary-btn" id="channel-unsubscribe-no" type="button">Нет</button>
<button class="destructive-btn channel-unsubscribe-confirm-yes" id="channel-unsubscribe-yes" type="button">Да</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('#channel-unsubscribe-confirm-modal');
root.querySelector('#channel-unsubscribe-no')?.addEventListener('click', () => finish(false));
root.querySelector('#channel-unsubscribe-yes')?.addEventListener('click', () => finish(true));
modal?.addEventListener('pointerdown', (event) => {
if (event.target === modal) finish(false);
});
});
}
export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--channel-about';
const topbar = createTopBar({
title: 'О канале',
title: '',
back: {
label: '←',
onClick: () => {
@@ -72,7 +423,7 @@ export function render({ navigate, route, chrome }) {
navigateBack();
return;
}
if (channelRoute) navigate(channelRoute);
navigate('channels-list');
},
ariaLabel: 'Назад',
title: 'Назад',
@@ -82,97 +433,210 @@ export function render({ navigate, route, chrome }) {
const card = document.createElement('div');
card.className = 'card stack channel-about-card';
card.innerHTML = `
<div class="stack" id="channel-about-content">
<div class="meta-muted">Загрузка данных канала…</div>
</div>
`;
const content = document.createElement('div');
content.className = 'stack channel-about-content';
content.innerHTML = '<div class="meta-muted">Загрузка данных канала…</div>';
card.append(content);
screen.append(card);
const renderContent = (channel) => {
let disposed = false;
let refreshSeq = 0;
let closeSupportModal = null;
const requireSigningSession = () => {
const login = String(state.session.login || '').trim();
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
if (!login || !storagePwd) {
state.authReturnHash = window.location.pathname || '/channels';
navigate('login-view');
throw new Error('Для этого действия нужно войти.');
}
return { login, storagePwd };
};
const renderContent = ({ channel, selector, ownerUser, ownerProfile, isOwnChannel, isSubscribed }) => {
const cleanName = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
const ownerName = String(channel?.ownerLogin || channel?.ownerName || 'автор').trim();
const ownerLogin = String(channel?.ownerLogin || extractLoginFromBlockchainName(selector?.ownerBlockchainName) || '').trim();
const channelName = String(channel?.channelName || '').trim();
const description = String(channel?.channelDescription || channel?.description || '').trim();
const subscribersCount = Number(channel?.subscribersCount || 0);
const aboutRoute = makeShineChannelRootRoute({
ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName,
channelRootBlockNumber: channel?.channelRoot?.blockNumber ?? channelRootBlockNumber,
channelRootBlockHash: channel?.channelRoot?.blockHash ?? channelRootBlockHash,
const shortRoute = makeShineChannelShortRoute({
ownerLogin,
ownerBlockchainName: selector?.ownerBlockchainName,
channelName,
});
const channelLinkRoute = makeShineChannelShortRoute({
ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName,
channelName: channel?.channelName || '',
const donateRoute = makeShineChannelDonateRoute({
ownerBlockchainName: selector?.ownerBlockchainName,
channelRootBlockNumber: selector?.channelRootBlockNumber,
channelRootBlockHash: selector?.channelRootBlockHash,
});
const channelLink = buildChannelLink(channelLinkRoute);
const changedAtMs = Number(channel?.metaUpdatedAtMs || 0);
const changedAtLabel = changedAtMs ? new Date(changedAtMs).toLocaleString('ru-RU') : '—';
const avatarState = String(channel?.avaAr || '').trim() ? 'Установлен' : 'Не установлен';
const serverLink = buildChannelLink(shortRoute);
const ownerName = ownerDisplayName(ownerProfile, ownerLogin);
const content = card.querySelector('#channel-about-content');
if (!content) return;
content.innerHTML = `
<div class="channel-profile-modal-head">
<h2 class="modal-title">${escapeHtml(cleanName)}</h2>
</div>
<div class="channel-meta-details-grid">
<span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
<span>Владелец</span><strong>${escapeHtml(ownerName)}</strong>
<span>Подписчиков</span><strong>${escapeHtml(statsText(subscribersCount))}</strong>
<span>Системное имя</span><code>${escapeHtml(String(channel?.channelName || '').trim() || 'channel')}</code>
<span>Название</span><strong>${escapeHtml(cleanName)}</strong>
<span>Описание</span><span style="white-space: pre-wrap;">${escapeHtml(description || 'Описание не задано.')}</span>
<span>Аватар</span><span>${escapeHtml(avatarState)}</span>
<span>Ссылка</span><span><a href="${escapeHtml(channelLink)}">${escapeHtml(channelLink)}</a></span>
</div>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="channel-about-open">Открыть канал</button>
<button class="secondary-btn" type="button" id="channel-about-copy">Скопировать ссылку</button>
<div class="channel-about-hero">
<div class="channel-about-avatar-slot" id="channel-about-avatar-slot"></div>
<h2 class="channel-about-title">${escapeHtml(cleanName)}</h2>
<div class="channel-about-technical">${escapeHtml(ownerLogin)} / ${escapeHtml(channelName)}</div>
<div class="channel-about-subscribers">${escapeHtml(statsText(subscribersCount))} подписчиков</div>
</div>
<section class="channel-about-section">
<h3>О канале</h3>
<p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p>
</section>
<section class="channel-about-section channel-about-owner-section">
<h3>Владелец канала</h3>
<button class="channel-about-owner-link" id="channel-about-owner" type="button">
<strong>${escapeHtml(ownerName)}</strong>
<span>@${escapeHtml(ownerLogin)}</span>
</button>
<button class="secondary-btn channel-about-support-btn" id="channel-about-support" type="button">Донат автору</button>
</section>
<section class="channel-about-section">
<h3>Ссылка на этом сервере</h3>
<div class="channel-about-link-box">
<a href="${escapeHtml(serverLink)}">${escapeHtml(serverLink)}</a>
<button class="icon-btn channel-about-copy-btn" id="channel-about-copy" type="button" aria-label="Скопировать ссылку" title="Скопировать ссылку">⧉</button>
</div>
</section>
<button class="primary-btn channel-about-open-btn" id="channel-about-open" type="button">Открыть канал</button>
${isOwnChannel ? '' : `
<button class="${isSubscribed ? 'destructive-btn is-unsubscribe' : 'secondary-btn'} channel-about-subscription-btn" id="channel-about-subscription" type="button">
${isSubscribed ? 'Отписаться от канала' : 'Подписаться на канал'}
</button>
`}
`;
if (String(channel?.avaAr || '').trim()) {
const avatar = renderAvatar({
initials: cleanName.slice(0, 1).toUpperCase() || 'К',
avatar: { ar: String(channel.avaAr || '').trim() },
size: 'xl',
className: 'channel-about-avatar channel-profile-avatar',
title: cleanName,
alt: 'Аватар канала',
});
avatar.style.setProperty('--channel-avatar-size', '112px');
content.querySelector('#channel-about-avatar-slot')?.append(avatar);
} else {
content.querySelector('#channel-about-avatar-slot')?.remove();
}
content.querySelector('#channel-about-owner')?.addEventListener('click', () => {
const profileRoute = makeProfileRoute(ownerLogin);
if (profileRoute) navigate(profileRoute);
});
content.querySelector('#channel-about-support')?.addEventListener('click', () => {
if (donateRoute) navigate(donateRoute);
});
content.querySelector('#channel-about-open')?.addEventListener('click', () => {
if (!channelLinkRoute) return;
navigate(channelLinkRoute);
if (shortRoute) navigate(shortRoute);
});
content.querySelector('#channel-about-copy')?.addEventListener('click', async () => {
if (!channelLink) return;
if (!serverLink) return;
try {
await navigator.clipboard.writeText(channelLink);
await navigator.clipboard.writeText(serverLink);
showToast('Ссылка скопирована');
} catch (error) {
showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' });
}
});
const subscriptionButton = content.querySelector('#channel-about-subscription');
subscriptionButton?.addEventListener('click', async () => {
if (subscriptionButton.disabled) return;
try {
const { login, storagePwd } = requireSigningSession();
if (isSubscribed) {
const confirmed = await confirmUnsubscribeModal({ channelTitle: cleanName });
if (!confirmed) return;
}
subscriptionButton.disabled = true;
await authService.addBlockFollowChannel({
login,
storagePwd,
targetBlockchainName: selector.ownerBlockchainName,
targetBlockNumber: selector.channelRootBlockNumber,
targetBlockHashHex: selector.channelRootBlockHash,
unfollow: isSubscribed,
});
if (disposed) return;
const feed = await authService.listSubscriptionsFeed(login, 1000);
if (disposed) return;
setChannelsFeed(feed, state.channelsIndex);
showToast(isSubscribed ? 'Вы отписались от канала' : 'Подписка на канал выполнена');
void refresh();
} catch (error) {
if (!disposed) {
showToast(toUserMessage(error, isSubscribed ? 'Не удалось отписаться от канала.' : 'Не удалось подписаться на канал.'), { kind: 'error' });
subscriptionButton.disabled = false;
}
}
});
};
const cached = findCachedChannel(ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash);
if (cached?.channel) {
renderContent({
...cached.channel,
subscribersCount: cached.channel.subscribersCount ?? cached.subscribersCount ?? 0,
});
return screen;
}
void (async () => {
const refresh = async () => {
const seq = ++refreshSeq;
content.innerHTML = '<div class="meta-muted">Загрузка данных канала…</div>';
try {
const payload = await authService.getChannelMessages({
ownerBlockchainName,
channelRootBlockNumber,
channelRootBlockHash,
}, 1, 'asc', String(state.session.login || '').trim());
renderContent(payload?.channel || {});
} catch (error) {
const content = card.querySelector('#channel-about-content');
if (content) {
content.innerHTML = `
<div class="meta-muted">Не удалось загрузить данные канала.</div>
<div class="meta-muted">${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}</div>
`;
}
}
})();
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 currentLogin = String(state.session.login || '').trim();
const isOwnChannel = Boolean(ownerLogin && currentLogin && ownerLogin.toLowerCase() === currentLogin.toLowerCase());
const [ownerUserResult, ownerProfileResult, feedResult] = await Promise.allSettled([
authService.getUser(ownerLogin),
loadProfileSnapshot(ownerLogin),
currentLogin && !isOwnChannel ? authService.listSubscriptionsFeed(currentLogin, 1000) : Promise.resolve(null),
]);
if (disposed || seq !== refreshSeq) return;
const ownerUser = ownerUserResult.status === 'fulfilled' ? ownerUserResult.value : {};
const ownerProfile = ownerProfileResult.status === 'fulfilled' ? ownerProfileResult.value : null;
const feed = feedResult.status === 'fulfilled' ? feedResult.value : null;
if (feed) setChannelsFeed(feed, state.channelsIndex);
const followedRows = Array.isArray(feed?.followedChannels)
? feed.followedChannels
: (Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : []);
const isSubscribed = !isOwnChannel && followedRows.some((row) => isSelectorMatch(row, selector));
renderContent({
channel,
selector,
ownerUser,
ownerProfile,
isOwnChannel,
isSubscribed,
});
} 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-about-retry" type="button">Повторить</button>
`;
content.querySelector('#channel-about-retry')?.addEventListener('click', () => void refresh());
}
};
screen.refresh = refresh;
screen.cleanup = () => {
if (disposed) return;
disposed = true;
refreshSeq += 1;
closeSupportModal?.();
closeSupportModal = null;
};
void refresh();
return screen;
}
+399
View File
@@ -0,0 +1,399 @@
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('Для перевода нужно войти в 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;
}
+38 -9
View File
@@ -31,6 +31,7 @@ import {
makeProfileRoute,
makeShineMessageRoute,
makeShineChannelAboutRoute,
makeShineChannelDonateRoute,
} from '../services/shine-routes.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
@@ -669,7 +670,7 @@ function openChannelMetaDetailsModal({
function openAboutChannelModal(channel, options = {}) {
openChannelMetaDetailsModal({
title: 'О канале',
title: 'Описание канала',
channel,
canEdit: options.canEdit === true,
onEdit: options.onEdit,
@@ -1595,9 +1596,13 @@ async function loadFromApi(route, channelId) {
try {
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginForLookup, 500);
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
const ownerLoginNormalized = ownerLoginForLookup.toLowerCase();
channel = ownerRows.find((item) => (
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
&& (
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
|| String(item?.channel?.ownerLogin || '').trim().toLowerCase() === ownerLoginNormalized
)
));
} catch {
// ignore owner feed lookup failures
@@ -2535,7 +2540,7 @@ export function render({ navigate, route, chrome }) {
actions: [
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
{
label: '',
label: '',
title: 'Действия канала',
ariaLabel: 'Открыть меню канала',
className: 'channel-header-more-btn',
@@ -2544,14 +2549,38 @@ export function render({ navigate, route, chrome }) {
items: () => {
const apiData = activeChannelData;
if (!apiData) return [];
const aboutRoute = makeShineChannelAboutRoute({
const routeArgs = {
ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '',
channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '',
channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '',
});
const items = [
{ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } },
];
};
const aboutRoute = makeShineChannelAboutRoute(routeArgs);
const donateRoute = makeShineChannelDonateRoute(routeArgs);
const items = [];
if (apiData?.isOwnChannel && !apiData?.isDiary) {
items.push({
label: 'Добавить сообщение',
action: () => {
openAddMessageModal({
channelName: apiData?.channel?.name || '',
navigate,
isActive: () => !disposed,
onSubmit: async ({ text: bodyText, msgSubType }) => {
try {
await onAddPost(bodyText, msgSubType);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
}
},
});
},
});
}
items.push({ label: 'Описание канала', action: () => { if (aboutRoute) navigate(aboutRoute); } });
if (!apiData?.isOwnChannel) {
items.push({ label: 'Поддержать автора', action: () => { if (donateRoute) navigate(donateRoute); } });
}
if (!apiData?.isOwnChannel && !isStoriesChannel(apiData?.channel)) {
if (apiData?.isSubscribed) {
items.push({