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

643 lines
26 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 { renderAvatar } from '../components/avatar-image.js';
import { authService, setChannelsFeed, state } from '../state.js';
import { navigateBack } from '../router.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: 'Описание канала' };
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;
}
function buildChannelLink(route) {
if (!route) return '';
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';
}
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: 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: '',
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-about-card';
const content = document.createElement('div');
content.className = 'stack channel-about-content';
content.innerHTML = '<div class="meta-muted">Загрузка данных канала…</div>';
card.append(content);
screen.append(card);
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 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 shortRoute = makeShineChannelShortRoute({
ownerLogin,
ownerBlockchainName: selector?.ownerBlockchainName,
channelName,
});
const donateRoute = makeShineChannelDonateRoute({
ownerBlockchainName: selector?.ownerBlockchainName,
channelRootBlockNumber: selector?.channelRootBlockNumber,
channelRootBlockHash: selector?.channelRootBlockHash,
});
const serverLink = buildChannelLink(shortRoute);
const ownerName = ownerDisplayName(ownerProfile, ownerLogin);
content.innerHTML = `
<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 (shortRoute) navigate(shortRoute);
});
content.querySelector('#channel-about-copy')?.addEventListener('click', async () => {
if (!serverLink) return;
try {
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 refresh = async () => {
const seq = ++refreshSeq;
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 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;
}