SHA256
Добавить страницу донатов канала
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.12.16
|
client.version=1.12.17
|
||||||
server.version=1.10.7
|
server.version=1.10.7
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ import * as userRelationManageView from './pages/user-relation-manage-view.js';
|
|||||||
import * as channelsList from './pages/channels-list.js?v=202608221218';
|
import * as channelsList from './pages/channels-list.js?v=202608221218';
|
||||||
import * as channelView from './pages/channel-view.js';
|
import * as channelView from './pages/channel-view.js';
|
||||||
import * as channelAboutView from './pages/channel-about-view.js';
|
import * as channelAboutView from './pages/channel-about-view.js';
|
||||||
|
import * as channelDonateView from './pages/channel-donate-view.js';
|
||||||
import * as channelThreadView from './pages/channel-thread-view.js';
|
import * as channelThreadView from './pages/channel-thread-view.js';
|
||||||
import * as addChannelView from './pages/add-channel-view.js';
|
import * as addChannelView from './pages/add-channel-view.js';
|
||||||
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
||||||
@@ -170,6 +171,7 @@ const routes = {
|
|||||||
'channels-list': channelsList,
|
'channels-list': channelsList,
|
||||||
'channel-view': channelView,
|
'channel-view': channelView,
|
||||||
'channel-about-view': channelAboutView,
|
'channel-about-view': channelAboutView,
|
||||||
|
'channel-donate-view': channelDonateView,
|
||||||
'channel-thread-view': channelThreadView,
|
'channel-thread-view': channelThreadView,
|
||||||
'add-channel-view': addChannelView,
|
'add-channel-view': addChannelView,
|
||||||
'add-personal-public-chat-view': addPersonalPublicChatView,
|
'add-personal-public-chat-view': addPersonalPublicChatView,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
markArweaveAttachmentPlaced,
|
markArweaveAttachmentPlaced,
|
||||||
} from '../components/arweave-attachment-manager.js';
|
} from '../components/arweave-attachment-manager.js';
|
||||||
import { renderAvatar } from '../components/avatar-image.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: 'Создание канала' };
|
export const pageMeta = { id: 'add-channel-view', title: 'Создание канала' };
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ function renderAvatarPreview(slot, avatar, title) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildAbsoluteChannelUrl({ ownerBlockchainName = '', channelName = '' } = {}) {
|
function buildAbsoluteChannelUrl({ ownerBlockchainName = '', channelName = '' } = {}) {
|
||||||
const route = makeShineChannelRoute({ ownerBlockchainName, channelName });
|
const route = makeShineChannelShortRoute({ ownerBlockchainName, channelName });
|
||||||
if (!route) return '';
|
if (!route) return '';
|
||||||
try {
|
try {
|
||||||
return new URL(`/${route}`, window.location.origin).toString();
|
return new URL(`/${route}`, window.location.origin).toString();
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
import { createTopBar } from '../components/topbar.js';
|
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 { 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 { showToast } from '../services/channels-ux.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.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) {
|
function escapeHtml(text) {
|
||||||
return String(text || '')
|
return String(text || '')
|
||||||
@@ -22,49 +38,384 @@ function normalizeHash(hash) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toSafeInt(value) {
|
function toSafeInt(value) {
|
||||||
|
if (value === '' || value == null) return null;
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
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) {
|
function buildChannelLink(route) {
|
||||||
if (!route) return '';
|
if (!route) return '';
|
||||||
const url = new URL(window.location.href);
|
try {
|
||||||
url.pathname = `/${String(route).replace(/^\/+/, '')}`;
|
return new URL(`/${String(route).replace(/^\/+/, '')}`, window.location.origin).toString();
|
||||||
url.hash = '';
|
} catch {
|
||||||
return url.toString();
|
return `${window.location.origin}/${String(route).replace(/^\/+/, '')}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statsText(value) {
|
function statsText(value) {
|
||||||
return Number.isFinite(Number(value)) ? String(Math.max(0, Number(value))) : '0';
|
return Number.isFinite(Number(value)) ? String(Math.max(0, Number(value))) : '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function render({ navigate, route, chrome }) {
|
function profileField(snapshot, key) {
|
||||||
const ownerBlockchainName = String(route?.params?.ownerBlockchainName || '').trim();
|
const row = Array.isArray(snapshot?.fields)
|
||||||
const channelRootBlockNumber = toSafeInt(route?.params?.channelRootBlockNumber);
|
? snapshot.fields.find((item) => String(item?.key || '') === key)
|
||||||
const channelRootBlockHash = normalizeHash(route?.params?.channelRootBlockHash);
|
: null;
|
||||||
const channelRoute = makeShineChannelRootRoute({
|
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,
|
ownerBlockchainName,
|
||||||
channelRootBlockNumber,
|
channelRootBlockNumber: Number(row.channel.channelRoot.blockNumber),
|
||||||
channelRootBlockHash,
|
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');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack channels-screen channels-screen--channel-about';
|
screen.className = 'stack channels-screen channels-screen--channel-about';
|
||||||
|
|
||||||
const topbar = createTopBar({
|
const topbar = createTopBar({
|
||||||
title: 'О канале',
|
title: '',
|
||||||
back: {
|
back: {
|
||||||
label: '←',
|
label: '←',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@@ -72,7 +423,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
navigateBack();
|
navigateBack();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (channelRoute) navigate(channelRoute);
|
navigate('channels-list');
|
||||||
},
|
},
|
||||||
ariaLabel: 'Назад',
|
ariaLabel: 'Назад',
|
||||||
title: 'Назад',
|
title: 'Назад',
|
||||||
@@ -82,97 +433,210 @@ export function render({ navigate, route, chrome }) {
|
|||||||
|
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'card stack channel-about-card';
|
card.className = 'card stack channel-about-card';
|
||||||
card.innerHTML = `
|
const content = document.createElement('div');
|
||||||
<div class="stack" id="channel-about-content">
|
content.className = 'stack channel-about-content';
|
||||||
<div class="meta-muted">Загрузка данных канала…</div>
|
content.innerHTML = '<div class="meta-muted">Загрузка данных канала…</div>';
|
||||||
</div>
|
card.append(content);
|
||||||
`;
|
|
||||||
|
|
||||||
screen.append(card);
|
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 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 description = String(channel?.channelDescription || channel?.description || '').trim();
|
||||||
const subscribersCount = Number(channel?.subscribersCount || 0);
|
const subscribersCount = Number(channel?.subscribersCount || 0);
|
||||||
const aboutRoute = makeShineChannelRootRoute({
|
const shortRoute = makeShineChannelShortRoute({
|
||||||
ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName,
|
ownerLogin,
|
||||||
channelRootBlockNumber: channel?.channelRoot?.blockNumber ?? channelRootBlockNumber,
|
ownerBlockchainName: selector?.ownerBlockchainName,
|
||||||
channelRootBlockHash: channel?.channelRoot?.blockHash ?? channelRootBlockHash,
|
channelName,
|
||||||
});
|
});
|
||||||
const channelLinkRoute = makeShineChannelShortRoute({
|
const donateRoute = makeShineChannelDonateRoute({
|
||||||
ownerBlockchainName: channel?.ownerBlockchainName || ownerBlockchainName,
|
ownerBlockchainName: selector?.ownerBlockchainName,
|
||||||
channelName: channel?.channelName || '',
|
channelRootBlockNumber: selector?.channelRootBlockNumber,
|
||||||
|
channelRootBlockHash: selector?.channelRootBlockHash,
|
||||||
});
|
});
|
||||||
const channelLink = buildChannelLink(channelLinkRoute);
|
const serverLink = buildChannelLink(shortRoute);
|
||||||
const changedAtMs = Number(channel?.metaUpdatedAtMs || 0);
|
const ownerName = ownerDisplayName(ownerProfile, ownerLogin);
|
||||||
const changedAtLabel = changedAtMs ? new Date(changedAtMs).toLocaleString('ru-RU') : '—';
|
|
||||||
const avatarState = String(channel?.avaAr || '').trim() ? 'Установлен' : 'Не установлен';
|
|
||||||
|
|
||||||
const content = card.querySelector('#channel-about-content');
|
|
||||||
if (!content) return;
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="channel-profile-modal-head">
|
<div class="channel-about-hero">
|
||||||
<h2 class="modal-title">${escapeHtml(cleanName)}</h2>
|
<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>
|
</div>
|
||||||
<div class="channel-meta-details-grid">
|
|
||||||
<span>Дата</span><strong>${escapeHtml(changedAtLabel)}</strong>
|
<section class="channel-about-section">
|
||||||
<span>Владелец</span><strong>${escapeHtml(ownerName)}</strong>
|
<h3>О канале</h3>
|
||||||
<span>Подписчиков</span><strong>${escapeHtml(statsText(subscribersCount))}</strong>
|
<p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p>
|
||||||
<span>Системное имя</span><code>${escapeHtml(String(channel?.channelName || '').trim() || 'channel')}</code>
|
</section>
|
||||||
<span>Название</span><strong>${escapeHtml(cleanName)}</strong>
|
|
||||||
<span>Описание</span><span style="white-space: pre-wrap;">${escapeHtml(description || 'Описание не задано.')}</span>
|
<section class="channel-about-section channel-about-owner-section">
|
||||||
<span>Аватар</span><span>${escapeHtml(avatarState)}</span>
|
<h3>Владелец канала</h3>
|
||||||
<span>Ссылка</span><span><a href="${escapeHtml(channelLink)}">${escapeHtml(channelLink)}</a></span>
|
<button class="channel-about-owner-link" id="channel-about-owner" type="button">
|
||||||
</div>
|
<strong>${escapeHtml(ownerName)}</strong>
|
||||||
<div class="form-actions-grid">
|
<span>@${escapeHtml(ownerLogin)}</span>
|
||||||
<button class="secondary-btn" type="button" id="channel-about-open">Открыть канал</button>
|
</button>
|
||||||
<button class="secondary-btn" type="button" id="channel-about-copy">Скопировать ссылку</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>
|
</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', () => {
|
content.querySelector('#channel-about-open')?.addEventListener('click', () => {
|
||||||
if (!channelLinkRoute) return;
|
if (shortRoute) navigate(shortRoute);
|
||||||
navigate(channelLinkRoute);
|
|
||||||
});
|
});
|
||||||
content.querySelector('#channel-about-copy')?.addEventListener('click', async () => {
|
content.querySelector('#channel-about-copy')?.addEventListener('click', async () => {
|
||||||
if (!channelLink) return;
|
if (!serverLink) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(channelLink);
|
await navigator.clipboard.writeText(serverLink);
|
||||||
showToast('Ссылка скопирована');
|
showToast('Ссылка скопирована');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: '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);
|
const refresh = async () => {
|
||||||
if (cached?.channel) {
|
const seq = ++refreshSeq;
|
||||||
renderContent({
|
content.innerHTML = '<div class="meta-muted">Загрузка данных канала…</div>';
|
||||||
...cached.channel,
|
|
||||||
subscribersCount: cached.channel.subscribersCount ?? cached.subscribersCount ?? 0,
|
|
||||||
});
|
|
||||||
return screen;
|
|
||||||
}
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
try {
|
||||||
const payload = await authService.getChannelMessages({
|
const selector = await resolveChannelSelector(route);
|
||||||
ownerBlockchainName,
|
const payload = await authService.getChannelMessages(selector, 1, 'asc', String(state.session.login || '').trim());
|
||||||
channelRootBlockNumber,
|
if (disposed || seq !== refreshSeq) return;
|
||||||
channelRootBlockHash,
|
|
||||||
}, 1, 'asc', String(state.session.login || '').trim());
|
const channel = payload?.channel || {};
|
||||||
renderContent(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) {
|
} catch (error) {
|
||||||
const content = card.querySelector('#channel-about-content');
|
if (disposed || seq !== refreshSeq) return;
|
||||||
if (content) {
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="meta-muted">Не удалось загрузить данные канала.</div>
|
<div class="meta-muted">Не удалось загрузить данные канала.</div>
|
||||||
<div class="meta-muted">${escapeHtml(toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'))}</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;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('&', '&')
|
||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
makeProfileRoute,
|
makeProfileRoute,
|
||||||
makeShineMessageRoute,
|
makeShineMessageRoute,
|
||||||
makeShineChannelAboutRoute,
|
makeShineChannelAboutRoute,
|
||||||
|
makeShineChannelDonateRoute,
|
||||||
} from '../services/shine-routes.js';
|
} from '../services/shine-routes.js';
|
||||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||||
|
|
||||||
@@ -669,7 +670,7 @@ function openChannelMetaDetailsModal({
|
|||||||
|
|
||||||
function openAboutChannelModal(channel, options = {}) {
|
function openAboutChannelModal(channel, options = {}) {
|
||||||
openChannelMetaDetailsModal({
|
openChannelMetaDetailsModal({
|
||||||
title: 'О канале',
|
title: 'Описание канала',
|
||||||
channel,
|
channel,
|
||||||
canEdit: options.canEdit === true,
|
canEdit: options.canEdit === true,
|
||||||
onEdit: options.onEdit,
|
onEdit: options.onEdit,
|
||||||
@@ -1595,9 +1596,13 @@ async function loadFromApi(route, channelId) {
|
|||||||
try {
|
try {
|
||||||
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginForLookup, 500);
|
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginForLookup, 500);
|
||||||
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
|
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
|
||||||
|
const ownerLoginNormalized = ownerLoginForLookup.toLowerCase();
|
||||||
channel = ownerRows.find((item) => (
|
channel = ownerRows.find((item) => (
|
||||||
|
String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
||||||
|
&& (
|
||||||
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
|
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === routeOwnerNormalized
|
||||||
&& String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
|
|| String(item?.channel?.ownerLogin || '').trim().toLowerCase() === ownerLoginNormalized
|
||||||
|
)
|
||||||
));
|
));
|
||||||
} catch {
|
} catch {
|
||||||
// ignore owner feed lookup failures
|
// ignore owner feed lookup failures
|
||||||
@@ -2535,7 +2540,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
actions: [
|
actions: [
|
||||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||||
{
|
{
|
||||||
label: '⋯',
|
label: '⋮',
|
||||||
title: 'Действия канала',
|
title: 'Действия канала',
|
||||||
ariaLabel: 'Открыть меню канала',
|
ariaLabel: 'Открыть меню канала',
|
||||||
className: 'channel-header-more-btn',
|
className: 'channel-header-more-btn',
|
||||||
@@ -2544,14 +2549,38 @@ export function render({ navigate, route, chrome }) {
|
|||||||
items: () => {
|
items: () => {
|
||||||
const apiData = activeChannelData;
|
const apiData = activeChannelData;
|
||||||
if (!apiData) return [];
|
if (!apiData) return [];
|
||||||
const aboutRoute = makeShineChannelAboutRoute({
|
const routeArgs = {
|
||||||
ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '',
|
ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '',
|
||||||
channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '',
|
channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '',
|
||||||
channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '',
|
channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '',
|
||||||
|
};
|
||||||
|
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, 'Не удалось добавить сообщение.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const items = [
|
},
|
||||||
{ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } },
|
});
|
||||||
];
|
}
|
||||||
|
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?.isOwnChannel && !isStoriesChannel(apiData?.channel)) {
|
||||||
if (apiData?.isSubscribed) {
|
if (apiData?.isSubscribed) {
|
||||||
items.push({
|
items.push({
|
||||||
|
|||||||
+47
-5
@@ -28,6 +28,7 @@ const PRETTY_PATHS = new Map([
|
|||||||
['add-personal-public-chat-view', 'channels/new-public-chat'],
|
['add-personal-public-chat-view', 'channels/new-public-chat'],
|
||||||
['channel-view', 'channel'],
|
['channel-view', 'channel'],
|
||||||
['channel-about-view', 'channel/about'],
|
['channel-about-view', 'channel/about'],
|
||||||
|
['channel-donate-view', 'channel/donate'],
|
||||||
['channel-thread-view', 'thread'],
|
['channel-thread-view', 'thread'],
|
||||||
['network-view', 'network'],
|
['network-view', 'network'],
|
||||||
['notifications-view', 'notifications'],
|
['notifications-view', 'notifications'],
|
||||||
@@ -180,9 +181,9 @@ export function parseRouteFromPath(pathname = '') {
|
|||||||
const channelName = decodePart(segments[1] || '');
|
const channelName = decodePart(segments[1] || '');
|
||||||
const sub = decodePart(segments[2] || '').toLowerCase();
|
const sub = decodePart(segments[2] || '').toLowerCase();
|
||||||
if (ownerBlockchainName && channelName) {
|
if (ownerBlockchainName && channelName) {
|
||||||
if (sub === 'about') {
|
if (sub === 'about' || sub === 'donate') {
|
||||||
return {
|
return {
|
||||||
pageId: 'channel-about-view',
|
pageId: sub === 'donate' ? 'channel-donate-view' : 'channel-about-view',
|
||||||
params: {
|
params: {
|
||||||
ownerBlockchainName,
|
ownerBlockchainName,
|
||||||
channelRootBlockNumber: '',
|
channelRootBlockNumber: '',
|
||||||
@@ -270,9 +271,11 @@ export function parseRouteFromPath(pathname = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pageId === 'channel') {
|
if (pageId === 'channel') {
|
||||||
if (segments.length >= 5 && decodePart(segments[4] || '').toLowerCase() === 'about') {
|
if (segments.length >= 5) {
|
||||||
|
const sub = decodePart(segments[4] || '').toLowerCase();
|
||||||
|
if (sub === 'about' || sub === 'donate') {
|
||||||
return {
|
return {
|
||||||
pageId: 'channel-about-view',
|
pageId: sub === 'donate' ? 'channel-donate-view' : 'channel-about-view',
|
||||||
params: {
|
params: {
|
||||||
ownerBlockchainName: decodePart(segments[1]),
|
ownerBlockchainName: decodePart(segments[1]),
|
||||||
channelRootBlockNumber: segments[2] || '',
|
channelRootBlockNumber: segments[2] || '',
|
||||||
@@ -281,6 +284,7 @@ export function parseRouteFromPath(pathname = '') {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (segments.length >= 4) {
|
if (segments.length >= 4) {
|
||||||
return {
|
return {
|
||||||
pageId: 'channel-view',
|
pageId: 'channel-view',
|
||||||
@@ -358,6 +362,18 @@ export function parseRouteFromPath(pathname = '') {
|
|||||||
return { pageId: 'remote-addblock-session-view', params: {} };
|
return { pageId: 'remote-addblock-session-view', params: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pageId === 'channel-donate-view' || pageId === 'channel-about-view') {
|
||||||
|
return {
|
||||||
|
pageId,
|
||||||
|
params: {
|
||||||
|
ownerBlockchainName: decodePart(segments[1]),
|
||||||
|
channelRootBlockNumber: segments[2] || '',
|
||||||
|
channelRootBlockHash: segments[3] || '',
|
||||||
|
channelId: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (pageId === 'channel-view') {
|
if (pageId === 'channel-view') {
|
||||||
if (segments.length >= 4) {
|
if (segments.length >= 4) {
|
||||||
return {
|
return {
|
||||||
@@ -399,6 +415,32 @@ export function parseRouteFromPath(pathname = '') {
|
|||||||
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
|
return { pageId, params: { mode: segments[1] ? decodePart(segments[1]) : '' } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Публичная короткая ссылка канала не содержит внутренний номер блокчейна:
|
||||||
|
// /<login>/<channel>. Старый /<login>-001/<channel> обрабатывается выше.
|
||||||
|
if (segments.length === 2 || (segments.length === 3 && ['about', 'donate'].includes(decodePart(segments[2] || '').toLowerCase()))) {
|
||||||
|
const ownerBlockchainName = decodePart(segments[0] || '');
|
||||||
|
const channelName = decodePart(segments[1] || '');
|
||||||
|
if (ownerBlockchainName && channelName) {
|
||||||
|
if (segments.length === 3) {
|
||||||
|
const sub = decodePart(segments[2] || '').toLowerCase();
|
||||||
|
return {
|
||||||
|
pageId: sub === 'donate' ? 'channel-donate-view' : 'channel-about-view',
|
||||||
|
params: {
|
||||||
|
ownerBlockchainName,
|
||||||
|
channelRootBlockNumber: '',
|
||||||
|
channelRootBlockHash: '',
|
||||||
|
channelId: '',
|
||||||
|
channelName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pageId: 'channel-view',
|
||||||
|
params: { ownerBlockchainName, channelName, channelId: '' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { pageId, params: {} };
|
return { pageId, params: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +509,7 @@ export function resolveToolbarActive(pageId) {
|
|||||||
pageId === 'solana-users-init-view'
|
pageId === 'solana-users-init-view'
|
||||||
) return 'profile-view';
|
) return 'profile-view';
|
||||||
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
||||||
if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
|
if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-donate-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
|
||||||
if (pageId === 'user') return 'messages-list';
|
if (pageId === 'user') return 'messages-list';
|
||||||
return 'profile-view';
|
return 'profile-view';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,11 +64,16 @@ export function makeShineChannelAboutRoute({ ownerBlockchainName = '', channelRo
|
|||||||
return base ? `${base}/about` : '';
|
return base ? `${base}/about` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeShineChannelShortRoute({ ownerBlockchainName = '', channelName = '' }) {
|
export function makeShineChannelDonateRoute({ ownerBlockchainName = '', channelRootBlockNumber = '', channelRootBlockHash = '' }) {
|
||||||
const ownerBch = String(ownerBlockchainName || '').trim();
|
const base = makeShineChannelRootRoute({ ownerBlockchainName, channelRootBlockNumber, channelRootBlockHash });
|
||||||
|
return base ? `${base}/donate` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeShineChannelShortRoute({ ownerLogin = '', ownerBlockchainName = '', channelName = '' }) {
|
||||||
|
const cleanOwnerLogin = normalizeLogin(ownerLogin) || extractLoginFromBlockchainName(ownerBlockchainName);
|
||||||
const chName = String(channelName || '').trim();
|
const chName = String(channelName || '').trim();
|
||||||
if (!ownerBch || !chName) return '';
|
if (!cleanOwnerLogin || !chName) return '';
|
||||||
return `${encodeRoutePart(ownerBch)}/${encodeRoutePart(chName)}`;
|
return `${encodeRoutePart(cleanOwnerLogin)}/${encodeRoutePart(chName)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
|
export function makeShineMessageRoute({ ownerLogin = '', messageBlockchainName = '', messageBlockNumber = '' }) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { extractClientKey32FromStoredValue } from './client-key-utils.js';
|
import { extractClientKey32FromStoredValue } from './client-key-utils.js';
|
||||||
|
import { base64ToBytes } from './crypto-utils.js';
|
||||||
import { loadEncryptedUserSecrets } from './key-vault.js';
|
import { loadEncryptedUserSecrets } from './key-vault.js';
|
||||||
import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js';
|
import { SOLANA_ENDPOINT_DEFAULT } from '../solana-programs.js';
|
||||||
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
|
||||||
@@ -45,6 +46,17 @@ function encodeBase58(bytesLike) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function solanaAddressFromPublicKeyBase64(publicKeyB64) {
|
||||||
|
const clean = String(publicKeyB64 || '').trim();
|
||||||
|
if (!clean) return '';
|
||||||
|
const bytes = base64ToBytes(clean);
|
||||||
|
if (bytes.length !== 32) {
|
||||||
|
throw new Error('Публичный ключ Solana должен содержать 32 байта');
|
||||||
|
}
|
||||||
|
return encodeBase58(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeEndpoint(url) {
|
function normalizeEndpoint(url) {
|
||||||
const raw = String(url || '').trim();
|
const raw = String(url || '').trim();
|
||||||
if (!raw) return DEFAULT_SOLANA_ENDPOINT;
|
if (!raw) return DEFAULT_SOLANA_ENDPOINT;
|
||||||
@@ -110,6 +122,17 @@ async function keypairFromStoredSecret(storedSecret) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function getSolanaWalletFromStoredSecret(storedSecret) {
|
||||||
|
const clean = String(storedSecret || '').trim();
|
||||||
|
if (!clean) throw new Error('Не передан приватный ключ');
|
||||||
|
const keypair = await keypairFromStoredSecret(clean);
|
||||||
|
return {
|
||||||
|
address: keypair.publicKey.toBase58(),
|
||||||
|
keypair,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function createRandomSolanaWallet() {
|
export async function createRandomSolanaWallet() {
|
||||||
const solana = await loadSolanaLib();
|
const solana = await loadSolanaLib();
|
||||||
const keypair = solana.Keypair.generate();
|
const keypair = solana.Keypair.generate();
|
||||||
|
|||||||
@@ -826,3 +826,524 @@
|
|||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Channel description ===== */
|
||||||
|
.channels-screen--channel-about {
|
||||||
|
padding-bottom: calc(22px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-card {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-content {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-hero {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 26px 20px 22px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-avatar-slot {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-avatar.channel-profile-avatar {
|
||||||
|
box-shadow: 0 12px 32px rgba(3, 8, 18, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-title {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.97);
|
||||||
|
font-size: clamp(24px, 7vw, 32px);
|
||||||
|
line-height: 1.12;
|
||||||
|
font-weight: 760;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-technical {
|
||||||
|
color: rgba(196, 210, 238, 0.72);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-subscribers {
|
||||||
|
color: rgba(170, 190, 226, 0.68);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.065);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-section h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(255, 218, 135, 0.92);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-description {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(235, 241, 255, 0.92);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.58;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-owner-link {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
justify-items: start;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-owner-link strong {
|
||||||
|
color: rgba(255, 255, 255, 0.96);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-owner-link span {
|
||||||
|
color: rgba(176, 198, 234, 0.72);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-owner-link:hover strong,
|
||||||
|
.channel-about-owner-link:focus-visible strong {
|
||||||
|
color: #f4dca6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-owner-link:focus-visible {
|
||||||
|
outline: 2px solid rgba(244, 220, 166, 0.52);
|
||||||
|
outline-offset: 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-support-btn {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-link-box {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 10px 10px 10px 12px;
|
||||||
|
border: 1px solid rgba(137, 168, 220, 0.14);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(7, 15, 29, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.channel-about-link-box a,
|
||||||
|
.channel-about-link-box span {
|
||||||
|
color: rgba(169, 215, 255, 0.95);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.channel-about-copy-btn {
|
||||||
|
width: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-open-btn,
|
||||||
|
.channel-about-subscription-btn {
|
||||||
|
margin: 18px 20px 0;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-subscription-btn {
|
||||||
|
margin-top: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-subscription-btn.is-unsubscribe {
|
||||||
|
border: 1px solid rgba(255, 91, 105, 0.68);
|
||||||
|
background: rgba(181, 43, 56, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-subscription-btn.is-unsubscribe:hover,
|
||||||
|
.channel-about-subscription-btn.is-unsubscribe:focus-visible {
|
||||||
|
background: rgba(205, 49, 63, 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-open-btn:last-child {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Channel unsubscribe confirmation ===== */
|
||||||
|
#channel-unsubscribe-confirm-modal {
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#channel-unsubscribe-confirm-modal .channel-unsubscribe-confirm-card {
|
||||||
|
width: min(100%, 390px);
|
||||||
|
gap: 14px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-unsubscribe-confirm-card .modal-title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-unsubscribe-confirm-text {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-unsubscribe-confirm-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-unsubscribe-confirm-actions button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-unsubscribe-confirm-yes {
|
||||||
|
border: 1px solid rgba(255, 91, 105, 0.68);
|
||||||
|
background: rgba(181, 43, 56, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-unsubscribe-confirm-yes:hover,
|
||||||
|
.channel-unsubscribe-confirm-yes:focus-visible {
|
||||||
|
background: rgba(205, 49, 63, 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Channel support transfer ===== */
|
||||||
|
#channel-support-modal {
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#channel-support-modal .channel-support-modal-card {
|
||||||
|
width: min(100%, 430px);
|
||||||
|
max-height: min(88vh, 720px);
|
||||||
|
overflow-y: auto;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-modal-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-modal-head .modal-title {
|
||||||
|
margin: 0 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-close {
|
||||||
|
width: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-address {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(5, 11, 23, 0.44);
|
||||||
|
color: rgba(186, 215, 255, 0.8);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-balance-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-balance-row strong {
|
||||||
|
color: rgba(255, 255, 255, 0.94);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-refresh {
|
||||||
|
min-height: 36px;
|
||||||
|
padding-block: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-amount-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-amount-row span {
|
||||||
|
color: rgba(232, 239, 255, 0.82);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-status {
|
||||||
|
min-height: 18px;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: rgba(192, 207, 236, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-status.is-error {
|
||||||
|
color: #ff9fa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-result {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(130, 227, 164, 0.18);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(44, 105, 67, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-result[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-result strong {
|
||||||
|
color: #c9f2d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-result span,
|
||||||
|
.channel-support-result code {
|
||||||
|
color: rgba(220, 236, 255, 0.82);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-submit {
|
||||||
|
min-height: 48px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.channel-about-section {
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-about-open-btn,
|
||||||
|
.channel-about-subscription-btn {
|
||||||
|
margin-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-balance-row {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-support-balance-row .meta-muted {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Channel donation view ===== */
|
||||||
|
.channels-screen--channel-donate {
|
||||||
|
padding-bottom: calc(24px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-card {
|
||||||
|
width: min(100%, 680px);
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-content {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-hero {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 28px 20px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-hero h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: rgba(255, 255, 255, 0.97);
|
||||||
|
font-size: clamp(24px, 7vw, 31px);
|
||||||
|
line-height: 1.12;
|
||||||
|
font-weight: 760;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-hero strong {
|
||||||
|
color: rgba(255, 230, 169, 0.94);
|
||||||
|
font-size: 17px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-hero span {
|
||||||
|
color: rgba(176, 198, 234, 0.72);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.065);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-address {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(5, 11, 23, 0.44);
|
||||||
|
color: rgba(186, 215, 255, 0.82);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-balance-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-balance-row strong {
|
||||||
|
color: rgba(255, 255, 255, 0.94);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-refresh {
|
||||||
|
min-height: 36px;
|
||||||
|
padding-block: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-amount-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-amount-row span {
|
||||||
|
color: rgba(232, 239, 255, 0.82);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-status {
|
||||||
|
min-height: 18px;
|
||||||
|
margin: 14px 20px 0;
|
||||||
|
color: rgba(192, 207, 236, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-status.is-error {
|
||||||
|
color: #ff9fa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-result {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 12px 20px 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(130, 227, 164, 0.18);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(44, 105, 67, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-result[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-result strong {
|
||||||
|
color: #c9f2d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-result span,
|
||||||
|
.channel-donate-result code {
|
||||||
|
color: rgba(220, 236, 255, 0.82);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-submit {
|
||||||
|
min-height: 50px;
|
||||||
|
margin: 16px 20px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.channel-donate-section {
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-status,
|
||||||
|
.channel-donate-result,
|
||||||
|
.channel-donate-submit {
|
||||||
|
margin-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-balance-row {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-donate-balance-row .meta-muted {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user