SHA256
Compare commits
6
Commits
1be1d56599
...
svyazi
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
23969121e5 | ||
|
|
4da2d7129a | ||
|
|
2be5ad0b7d | ||
|
|
f44ab75c64 | ||
|
|
7aa05dc609 | ||
|
|
7b81d140ec |
+5
@@ -28,6 +28,7 @@ public final class SolanaUserPdaImportService {
|
|||||||
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
||||||
private static final String MAGIC = "SHiNE";
|
private static final String MAGIC = "SHiNE";
|
||||||
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 1;
|
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 1;
|
||||||
|
private static final int ARCHIVE_HEAD_PAYLOAD_BYTES = 64;
|
||||||
|
|
||||||
private SolanaUserPdaImportService() {}
|
private SolanaUserPdaImportService() {}
|
||||||
|
|
||||||
@@ -266,6 +267,8 @@ public final class SolanaUserPdaImportService {
|
|||||||
}
|
}
|
||||||
} else if (blockType == 70) {
|
} else if (blockType == 70) {
|
||||||
c += 1;
|
c += 1;
|
||||||
|
} else if (blockType == 100) {
|
||||||
|
c += ARCHIVE_HEAD_PAYLOAD_BYTES;
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -374,6 +377,8 @@ public final class SolanaUserPdaImportService {
|
|||||||
}
|
}
|
||||||
} else if (blockType == 70) {
|
} else if (blockType == 70) {
|
||||||
c += 1;
|
c += 1;
|
||||||
|
} else if (blockType == 100) {
|
||||||
|
c += ARCHIVE_HEAD_PAYLOAD_BYTES;
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.12.15
|
client.version=1.12.19
|
||||||
server.version=1.10.6
|
server.version=1.10.7
|
||||||
|
|||||||
+2
-1
@@ -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,
|
||||||
@@ -216,7 +218,6 @@ const SCROLL_TO_BOTTOM_PAGE_IDS = new Set([
|
|||||||
'chat-view',
|
'chat-view',
|
||||||
'channel-view',
|
'channel-view',
|
||||||
'channel-thread-view',
|
'channel-thread-view',
|
||||||
'notifications-view',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
|
const FILLED_ACTION_BUTTON_PAGE_IDS = new Set([
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function renderAvatar({
|
|||||||
title = '',
|
title = '',
|
||||||
alt = 'Аватар',
|
alt = 'Аватар',
|
||||||
glow = false,
|
glow = false,
|
||||||
|
official = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
|
const classes = new Set(['avatar', 'avatar-image', 'avatar-framed']);
|
||||||
@@ -52,6 +53,15 @@ export function renderAvatar({
|
|||||||
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
|
fallback.textContent = String(initials || '?').trim().slice(0, 2).toUpperCase() || '?';
|
||||||
wrap.append(fallback);
|
wrap.append(fallback);
|
||||||
|
|
||||||
|
if (official) {
|
||||||
|
const badge = document.createElement('img');
|
||||||
|
badge.className = 'avatar-official-badge';
|
||||||
|
badge.src = '/assets/shine-official-badge.svg?v=2';
|
||||||
|
badge.alt = '';
|
||||||
|
badge.setAttribute('aria-hidden', 'true');
|
||||||
|
wrap.append(badge);
|
||||||
|
}
|
||||||
|
|
||||||
const txId = String(avatar?.ar || '').trim();
|
const txId = String(avatar?.ar || '').trim();
|
||||||
if (!validateArweaveTxId(txId)) {
|
if (!validateArweaveTxId(txId)) {
|
||||||
return wrap;
|
return wrap;
|
||||||
@@ -146,6 +156,7 @@ export function renderUserAvatar({
|
|||||||
className = '',
|
className = '',
|
||||||
title = '',
|
title = '',
|
||||||
glow = false,
|
glow = false,
|
||||||
|
official = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return renderAvatar({
|
return renderAvatar({
|
||||||
initials: buildAvatarInitials({ login, firstName, lastName }),
|
initials: buildAvatarInitials({ login, firstName, lastName }),
|
||||||
@@ -155,5 +166,6 @@ export function renderUserAvatar({
|
|||||||
title,
|
title,
|
||||||
alt: 'Аватар',
|
alt: 'Аватар',
|
||||||
glow,
|
glow,
|
||||||
|
official,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -306,6 +307,7 @@ function createChannelReadTracker({
|
|||||||
let persistedSeenCount = safeInitialSeenCount;
|
let persistedSeenCount = safeInitialSeenCount;
|
||||||
let initialPersistPending = !!initializeIfMissing;
|
let initialPersistPending = !!initializeIfMissing;
|
||||||
let inFlight = false;
|
let inFlight = false;
|
||||||
|
let writeBlocked = false;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let rafId = 0;
|
let rafId = 0;
|
||||||
let timerId = 0;
|
let timerId = 0;
|
||||||
@@ -318,7 +320,7 @@ function createChannelReadTracker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const queueFlush = (delayMs = 180) => {
|
const queueFlush = (delayMs = 180) => {
|
||||||
if (disposed || !canWrite) return;
|
if (disposed || writeBlocked || !canWrite) return;
|
||||||
clearTimer();
|
clearTimer();
|
||||||
timerId = setTimeout(() => {
|
timerId = setTimeout(() => {
|
||||||
timerId = 0;
|
timerId = 0;
|
||||||
@@ -327,7 +329,7 @@ function createChannelReadTracker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const flush = async () => {
|
const flush = async () => {
|
||||||
if (disposed || !canWrite) return;
|
if (disposed || writeBlocked || !canWrite) return;
|
||||||
const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount));
|
const next = Math.max(safeInitialSeenCount, Math.min(desiredSeenCount, safeMessagesCount));
|
||||||
if (next <= persistedSeenCount && !initialPersistPending) return;
|
if (next <= persistedSeenCount && !initialPersistPending) return;
|
||||||
if (inFlight) {
|
if (inFlight) {
|
||||||
@@ -358,6 +360,18 @@ function createChannelReadTracker({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorCode = String(error?.code || '').trim().toUpperCase();
|
||||||
|
if (errorCode === 'CHANNEL_NOT_FOLLOWED') {
|
||||||
|
// This is a persistent state mismatch, not a transient transport failure.
|
||||||
|
// Stop writes for the current channel view instead of hammering the server
|
||||||
|
// after every scroll/resize. A fresh view after subscribe/refresh will create
|
||||||
|
// a new tracker from the latest subscription state.
|
||||||
|
writeBlocked = true;
|
||||||
|
initialPersistPending = false;
|
||||||
|
clearTimer();
|
||||||
|
if (typeof onPersistError === 'function') onPersistError(error, { retrying: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (typeof onPersistError === 'function') onPersistError(error, { retrying: true });
|
if (typeof onPersistError === 'function') onPersistError(error, { retrying: true });
|
||||||
queueFlush(800);
|
queueFlush(800);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -656,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,
|
||||||
@@ -1582,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
|
||||||
@@ -2411,12 +2429,19 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
? window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40)
|
? window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const tracker = createChannelReadTracker({
|
const shouldTrackReadState = !!(
|
||||||
|
channelData.isSubscribed
|
||||||
|
&& !channelData.isOwnChannel
|
||||||
|
&& !channelData.isDiary
|
||||||
|
);
|
||||||
|
|
||||||
|
const tracker = shouldTrackReadState
|
||||||
|
? createChannelReadTracker({
|
||||||
screen,
|
screen,
|
||||||
routeKey,
|
routeKey,
|
||||||
ownerBlockchainName: channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
|
ownerBlockchainName: channelData.channel?.ownerBlockchainName || channelData.selector?.ownerBlockchainName,
|
||||||
channelName: channelData.channel?.name || channelData.channel?.channelName,
|
channelName: channelData.channel?.name || channelData.channel?.channelName,
|
||||||
initializeIfMissing: !!(channelData.isSubscribed && !channelData.readStateInitialized),
|
initializeIfMissing: !!(!channelData.readStateInitialized),
|
||||||
unreadCount,
|
unreadCount,
|
||||||
messagesCount,
|
messagesCount,
|
||||||
initialSeenCount: readCount,
|
initialSeenCount: readCount,
|
||||||
@@ -2450,7 +2475,8 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
}
|
}
|
||||||
showStatus('');
|
showStatus('');
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
: { cleanup() {}, measure() {} };
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (pendingScrollTimer) window.clearTimeout(pendingScrollTimer);
|
if (pendingScrollTimer) window.clearTimeout(pendingScrollTimer);
|
||||||
@@ -2514,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',
|
||||||
@@ -2523,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({
|
||||||
|
|||||||
@@ -1426,7 +1426,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
iconNode: createOverflowDots(),
|
iconNode: createOverflowDots(),
|
||||||
title: 'Действия чата',
|
title: 'Действия чата',
|
||||||
ariaLabel: 'Открыть меню действий чата',
|
ariaLabel: 'Открыть меню действий чата',
|
||||||
className: 'chat-header-icon-btn chat-header-menu-btn',
|
className: 'chat-header-icon-btn chat-header-menu-btn topbar-overflow-action--raised',
|
||||||
menu: {
|
menu: {
|
||||||
minWidth: 230,
|
minWidth: 230,
|
||||||
items: () => [
|
items: () => [
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ export function render({ navigate, chrome }) {
|
|||||||
iconNode: createOverflowDots(),
|
iconNode: createOverflowDots(),
|
||||||
title: 'Меню чатов',
|
title: 'Меню чатов',
|
||||||
ariaLabel: 'Меню чатов',
|
ariaLabel: 'Меню чатов',
|
||||||
className: 'messages-topbar-menu-btn',
|
className: 'messages-topbar-menu-btn topbar-overflow-action--raised',
|
||||||
menu: {
|
menu: {
|
||||||
minWidth: 210,
|
minWidth: 210,
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -709,14 +709,23 @@ function buildStableHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVI
|
|||||||
const centerKey = normKey(snap?.centerLogin);
|
const centerKey = normKey(snap?.centerLogin);
|
||||||
const snapNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : [];
|
const snapNodes = Array.isArray(snap?.engineModel?.nodes) ? snap.engineModel.nodes : [];
|
||||||
const ownCenter = snapNodes.find((node) => normKey(node?.id) === centerKey);
|
const ownCenter = snapNodes.find((node) => normKey(node?.id) === centerKey);
|
||||||
if (ownCenter) centerNodeByKey.set(centerKey, { ...ownCenter, isHistoryCenter: true, keepVisible: true, tier: 1 });
|
if (ownCenter) centerNodeByKey.set(centerKey, {
|
||||||
|
...ownCenter,
|
||||||
|
isHistoryCenter: true,
|
||||||
|
clusterOwnerId: centerKey,
|
||||||
|
keepVisible: true,
|
||||||
|
tier: 1,
|
||||||
|
});
|
||||||
|
|
||||||
snapNodes.forEach((rawNode) => {
|
snapNodes.forEach((rawNode) => {
|
||||||
const key = normKey(rawNode?.id);
|
const key = normKey(rawNode?.id);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
// Исторические центры сохраняют позицию собственного кластера. Все остальные общие узлы
|
// Исторические центры сохраняют позицию собственного кластера. Все остальные общие узлы
|
||||||
// принадлежат самому свежему кластеру, где встретились, и поэтому «переезжают» туда без дубля.
|
// принадлежат самому свежему кластеру, где встретились, и поэтому «переезжают» туда без дубля.
|
||||||
if (!centerKeys.has(key) || key === latestCenterKey || key === centerKey) nodeMap.set(key, { ...rawNode, id: key });
|
if (!centerKeys.has(key) || key === latestCenterKey || key === centerKey) {
|
||||||
|
// Общий узел принадлежит последнему кластеру, возле которого он был перерисован.
|
||||||
|
nodeMap.set(key, { ...rawNode, id: key, clusterOwnerId: centerKey });
|
||||||
|
}
|
||||||
|
|
||||||
if (key === centerKey) return;
|
if (key === centerKey) return;
|
||||||
let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : [];
|
let parents = Array.isArray(rawNode?.edgeParents) ? rawNode.edgeParents : [];
|
||||||
@@ -755,9 +764,11 @@ function buildStableHistoryEngineModel(history, historyDepth = HISTORY_MAX_PREVI
|
|||||||
id: key,
|
id: key,
|
||||||
login: node?.login || node?.id || key,
|
login: node?.login || node?.id || key,
|
||||||
tier: centerKeys.has(key) ? 1 : Math.max(1, Number(node?.tier) || 1),
|
tier: centerKeys.has(key) ? 1 : Math.max(1, Number(node?.tier) || 1),
|
||||||
|
isHistoryCenter: centerKeys.has(key),
|
||||||
fixedLayout: true,
|
fixedLayout: true,
|
||||||
keepVisible: centerKeys.has(key) || Boolean(node?.keepVisible),
|
keepVisible: centerKeys.has(key) || Boolean(node?.keepVisible),
|
||||||
alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible),
|
alwaysVisible: (Number(node?.tier) || 1) >= 2 ? true : Boolean(node?.alwaysVisible),
|
||||||
|
clusterOwnerId: normKey(node?.clusterOwnerId || key),
|
||||||
edgeParents: edgeParentsByChild.get(key) || [],
|
edgeParents: edgeParentsByChild.get(key) || [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -1098,11 +1109,15 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
window.setTimeout(() => inputEl.focus(), 0);
|
window.setTimeout(() => inputEl.focus(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistManualNodePosition(nodeId, point) {
|
function persistManualNodePositions(rows) {
|
||||||
const key = normKey(nodeId);
|
const positions = new Map();
|
||||||
const x = Number(point?.x);
|
(Array.isArray(rows) ? rows : []).forEach((row) => {
|
||||||
const y = Number(point?.y);
|
const key = normKey(row?.id || row?.login);
|
||||||
if (!key || !Number.isFinite(x) || !Number.isFinite(y)) return;
|
const x = Number(row?.x);
|
||||||
|
const y = Number(row?.y);
|
||||||
|
if (key && Number.isFinite(x) && Number.isFinite(y)) positions.set(key, { x, y });
|
||||||
|
});
|
||||||
|
if (!positions.size) return;
|
||||||
|
|
||||||
// Координаты исторических snapshot'ов уже находятся в общей world-системе. Обновляем все
|
// Координаты исторических snapshot'ов уже находятся в общей world-системе. Обновляем все
|
||||||
// упоминания пользователя, чтобы следующий setModel/filter/history render не откатил ручной drag.
|
// упоминания пользователя, чтобы следующий setModel/filter/history render не откатил ручной drag.
|
||||||
@@ -1110,12 +1125,13 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
const model = cloneEngineModel(snapshot?.engineModel || { focusId: '', nodes: [] });
|
const model = cloneEngineModel(snapshot?.engineModel || { focusId: '', nodes: [] });
|
||||||
let changed = false;
|
let changed = false;
|
||||||
model.nodes = model.nodes.map((node) => {
|
model.nodes = model.nodes.map((node) => {
|
||||||
if (normKey(node?.id) !== key) return node;
|
const point = positions.get(normKey(node?.id));
|
||||||
|
if (!point) return node;
|
||||||
changed = true;
|
changed = true;
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
layoutX: x,
|
layoutX: point.x,
|
||||||
layoutY: y,
|
layoutY: point.y,
|
||||||
fixedLayout: true,
|
fixedLayout: true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1149,10 +1165,19 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
}
|
}
|
||||||
void load(node.login, { pushHistory: true, transitionAngle, transitionX, transitionY });
|
void load(node.login, { pushHistory: true, transitionAngle, transitionX, transitionY });
|
||||||
},
|
},
|
||||||
// тап по центру — полноценный профиль
|
// Короткий тап по текущему центру сразу открывает его меню.
|
||||||
onCenterTap: (node) => {
|
onCenterTap: (node) => {
|
||||||
const routeTo = profileInfoRoute(node.login);
|
const rect = node?.el?.getBoundingClientRect?.();
|
||||||
if (routeTo) navigate(routeTo);
|
openNodeMenu({
|
||||||
|
login: normalizeLogin(node?.login),
|
||||||
|
displayName: String(node?.name || '').trim(),
|
||||||
|
relationType: node?.relationType,
|
||||||
|
point: rect ? { x: rect.left + rect.width / 2, y: rect.top, rect } : undefined,
|
||||||
|
actions: [
|
||||||
|
{ label: 'Профиль', onClick: () => { const r = profileInfoRoute(node?.login); if (r) navigate(r); } },
|
||||||
|
{ label: 'Написать', onClick: () => navigate(`chat/${encodeURIComponent(normalizeLogin(node?.login))}`) },
|
||||||
|
],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
// долгое нажатие — контекстное меню (вне масштабируемого холста)
|
// долгое нажатие — контекстное меню (вне масштабируемого холста)
|
||||||
onNodeLongPress: (node, point) => {
|
onNodeLongPress: (node, point) => {
|
||||||
@@ -1171,7 +1196,12 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
// Drag периферийного аватара — ручная правка текущей карты. Движок уже двигает DOM/рёбра
|
// Drag периферийного аватара — ручная правка текущей карты. Движок уже двигает DOM/рёбра
|
||||||
// в реальном времени; здесь только сохраняем итоговую world-позицию в историю/X2 snapshot.
|
// в реальном времени; здесь только сохраняем итоговую world-позицию в историю/X2 snapshot.
|
||||||
onNodeMoveEnd: (node, point) => {
|
onNodeMoveEnd: (node, point) => {
|
||||||
persistManualNodePosition(node?.id || node?.login, point);
|
persistManualNodePositions([{ id: node?.id || node?.login, ...point }]);
|
||||||
|
},
|
||||||
|
// Центр и узлы, которые в последний раз были нарисованы возле него,
|
||||||
|
// сохраняются одной пачкой, чтобы общий узел не вернулся в старый кластер при rebuild.
|
||||||
|
onGroupMoveEnd: (movedNodes) => {
|
||||||
|
persistManualNodePositions(movedNodes);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,13 +219,14 @@ function hash01(str) {
|
|||||||
* @param {Object} opts
|
* @param {Object} opts
|
||||||
* @param {HTMLElement} opts.stage - контейнер сцены (position: relative/absolute, overflow hidden)
|
* @param {HTMLElement} opts.stage - контейнер сцены (position: relative/absolute, overflow hidden)
|
||||||
* @param {Object} opts.model - нормализованная модель { focusId, nodes[] }
|
* @param {Object} opts.model - нормализованная модель { focusId, nodes[] }
|
||||||
* @param {Function} [opts.onCenterTap] - тап по центральному узлу (node) => void
|
* @param {Function} [opts.onCenterTap] - тап по текущему центральному узлу (node) => void
|
||||||
* @param {Function} [opts.onNodeTap] - тап по периферийному узлу (node) => void (вызывается ДО центрирования)
|
* @param {Function} [opts.onNodeTap] - тап по периферийному узлу (node) => void (вызывается ДО центрирования)
|
||||||
* @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void
|
* @param {Function} [opts.onNodeLongPress] - долгое нажатие (node, screenPoint) => void
|
||||||
* @param {Function} [opts.onNodeMoveEnd] - ручное перемещение периферийного узла (node, {x,y}) => void
|
* @param {Function} [opts.onNodeMoveEnd] - ручное перемещение периферийного узла (node, {x,y}) => void
|
||||||
|
* @param {Function} [opts.onGroupMoveEnd] - перемещение исторического кластера (nodes[]) => void
|
||||||
* @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }}
|
* @returns {{ destroy: Function, recenter: Function, setModel: Function, getFocusNode: Function }}
|
||||||
*/
|
*/
|
||||||
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeMoveEnd, onNodeHover, onDiveChange } = {}) {
|
export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeLongPress, onNodeMoveEnd, onGroupMoveEnd, onNodeHover, onDiveChange } = {}) {
|
||||||
// Слои DOM
|
// Слои DOM
|
||||||
const edgesSvg = document.createElementNS(SVGNS, 'svg');
|
const edgesSvg = document.createElementNS(SVGNS, 'svg');
|
||||||
edgesSvg.setAttribute('class', 'fg-edges');
|
edgesSvg.setAttribute('class', 'fg-edges');
|
||||||
@@ -658,6 +659,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
node.relationType = src.relationType;
|
node.relationType = src.relationType;
|
||||||
node.shining = Boolean(src.shining);
|
node.shining = Boolean(src.shining);
|
||||||
node.official = Boolean(src.official);
|
node.official = Boolean(src.official);
|
||||||
|
node.isHistoryCenter = Boolean(src.isHistoryCenter);
|
||||||
|
node.clusterOwnerId = String(src.clusterOwnerId || src.id || '');
|
||||||
node.keepVisible = Boolean(src.keepVisible);
|
node.keepVisible = Boolean(src.keepVisible);
|
||||||
node.alwaysVisible = Boolean(src.alwaysVisible);
|
node.alwaysVisible = Boolean(src.alwaysVisible);
|
||||||
node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [];
|
node.edgeParents = Array.isArray(src.edgeParents) ? src.edgeParents.map((row) => ({ ...row })) : [];
|
||||||
@@ -1472,6 +1475,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
let nodeDragActive = false;
|
let nodeDragActive = false;
|
||||||
let nodeDragStartX = 0;
|
let nodeDragStartX = 0;
|
||||||
let nodeDragStartY = 0;
|
let nodeDragStartY = 0;
|
||||||
|
let groupDragActive = false;
|
||||||
|
let groupDragNodes = [];
|
||||||
|
let groupDragStarts = new Map();
|
||||||
let longTimer = 0;
|
let longTimer = 0;
|
||||||
let longFired = false;
|
let longFired = false;
|
||||||
const activePointers = new Map(); // id → {x, y}: для щипкового зума двумя пальцами
|
const activePointers = new Map(); // id → {x, y}: для щипкового зума двумя пальцами
|
||||||
@@ -1602,8 +1608,16 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания
|
if (downNodeEl) { downNodeEl.classList.add('is-pressed'); haptic(6); } // тактильный «клик» вдавливания
|
||||||
downNode = nodeFromEvent(ev);
|
downNode = nodeFromEvent(ev);
|
||||||
nodeDragActive = false;
|
nodeDragActive = false;
|
||||||
|
groupDragActive = false;
|
||||||
|
groupDragNodes = [];
|
||||||
|
groupDragStarts = new Map();
|
||||||
nodeDragStartX = Number(downNode?.x) || 0;
|
nodeDragStartX = Number(downNode?.x) || 0;
|
||||||
nodeDragStartY = Number(downNode?.y) || 0;
|
nodeDragStartY = Number(downNode?.y) || 0;
|
||||||
|
if (downNode?.isHistoryCenter) {
|
||||||
|
const ownerId = String(downNode.clusterOwnerId || downNode.id);
|
||||||
|
groupDragNodes = nodes.filter((node) => String(node.clusterOwnerId || node.id) === ownerId);
|
||||||
|
groupDragStarts = new Map(groupDragNodes.map((node) => [String(node.id), { x: Number(node.x) || 0, y: Number(node.y) || 0 }]));
|
||||||
|
}
|
||||||
// касание пальцем по узлу = «наведение» (превью ветки), как ховер мышью; мышь обслуживают over/out
|
// касание пальцем по узлу = «наведение» (превью ветки), как ховер мышью; мышь обслуживают over/out
|
||||||
if (downNode && ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(downNode, true);
|
if (downNode && ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(downNode, true);
|
||||||
if (downNode && typeof onNodeLongPress === 'function') {
|
if (downNode && typeof onNodeLongPress === 'function') {
|
||||||
@@ -1655,15 +1669,34 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
moved = true;
|
moved = true;
|
||||||
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
if (longTimer) { window.clearTimeout(longTimer); longTimer = 0; }
|
||||||
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это drag/pan, а не нажатие
|
if (downNodeEl) downNodeEl.classList.remove('is-pressed'); // это drag/pan, а не нажатие
|
||||||
// Не центральный аватар перетаскивается сам. Пустой фон или центральный узел продолжают панорамировать карту.
|
// Исторический центр тащит свой кластер; обычный узел — только себя; пустой фон — всю карту.
|
||||||
nodeDragActive = Boolean(downNode && !downNode.isFocus);
|
groupDragActive = Boolean(downNode?.isHistoryCenter && groupDragNodes.length);
|
||||||
if (nodeDragActive && cssBloom) endCssBloom();
|
nodeDragActive = Boolean(downNode && !groupDragActive);
|
||||||
|
if ((nodeDragActive || groupDragActive) && cssBloom) endCssBloom();
|
||||||
// палец «съехал» с узла — снимаем временный ховер-превью (касанием), если он был
|
// палец «съехал» с узла — снимаем временный ховер-превью (касанием), если он был
|
||||||
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
|
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
|
||||||
camTargetX = null; camTargetY = null; // свайп отменяет доводчик камеры (приоритет жеста)
|
camTargetX = null; camTargetY = null; // свайп отменяет доводчик камеры (приоритет жеста)
|
||||||
cancelTween(); // жест прерывает анимацию центрирования
|
cancelTween(); // жест прерывает анимацию центрирования
|
||||||
dragging = true;
|
dragging = true;
|
||||||
}
|
}
|
||||||
|
if (moved && groupDragActive) {
|
||||||
|
const zx = dx / Math.max(0.001, zoom);
|
||||||
|
const zy = dy / Math.max(0.001, zoom);
|
||||||
|
groupDragNodes.forEach((node) => {
|
||||||
|
const start = groupDragStarts.get(String(node.id));
|
||||||
|
if (!start) return;
|
||||||
|
const nx = start.x + zx;
|
||||||
|
const ny = start.y + zy;
|
||||||
|
node.x = nx; node.y = ny;
|
||||||
|
node.tx = nx; node.ty = ny;
|
||||||
|
node.bfx = nx; node.bfy = ny;
|
||||||
|
node.vx = 0; node.vy = 0;
|
||||||
|
node.fixedLayout = true;
|
||||||
|
});
|
||||||
|
renderNodes();
|
||||||
|
renderEdges();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (moved && nodeDragActive && downNode) {
|
if (moved && nodeDragActive && downNode) {
|
||||||
// dx/dy приходят в экранных пикселях, координаты узла живут в world-space — делим на текущий zoom.
|
// dx/dy приходят в экранных пикселях, координаты узла живут в world-space — делим на текущий zoom.
|
||||||
const nx = nodeDragStartX + dx / Math.max(0.001, zoom);
|
const nx = nodeDragStartX + dx / Math.max(0.001, zoom);
|
||||||
@@ -1708,9 +1741,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
const wasMoved = moved;
|
const wasMoved = moved;
|
||||||
const wasLong = longFired;
|
const wasLong = longFired;
|
||||||
const movedNode = nodeDragActive ? downNode : null;
|
const movedNode = nodeDragActive ? downNode : null;
|
||||||
|
const movedGroup = groupDragActive ? [...groupDragNodes] : [];
|
||||||
pointerId = null;
|
pointerId = null;
|
||||||
dragging = false;
|
dragging = false;
|
||||||
nodeDragActive = false;
|
nodeDragActive = false;
|
||||||
|
groupDragActive = false;
|
||||||
// касание: убрали палец — снимаем временный ховер-превью (фиксацию ниже делает тап через onNodeTap)
|
// касание: убрали палец — снимаем временный ховер-превью (фиксацию ниже делает тап через onNodeTap)
|
||||||
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
|
if (ev.pointerType !== 'mouse' && typeof onNodeHover === 'function') onNodeHover(null, false);
|
||||||
|
|
||||||
@@ -1719,6 +1754,11 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
// Передаём окончательную world-позицию наружу, чтобы ручная правка пережила следующий setModel/history render.
|
// Передаём окончательную world-позицию наружу, чтобы ручная правка пережила следующий setModel/history render.
|
||||||
if (typeof onNodeMoveEnd === 'function') onNodeMoveEnd(movedNode, { x: movedNode.x, y: movedNode.y });
|
if (typeof onNodeMoveEnd === 'function') onNodeMoveEnd(movedNode, { x: movedNode.x, y: movedNode.y });
|
||||||
renderEdges();
|
renderEdges();
|
||||||
|
} else if (wasMoved && movedGroup.length) {
|
||||||
|
if (typeof onGroupMoveEnd === 'function') {
|
||||||
|
onGroupMoveEnd(movedGroup.map((node) => ({ id: node.id, login: node.login, x: node.x, y: node.y })));
|
||||||
|
}
|
||||||
|
renderEdges();
|
||||||
} else if (wasMoved) {
|
} else if (wasMoved) {
|
||||||
// после pan даём физике чуть устаканиться и уснуть
|
// после pan даём физике чуть устаканиться и уснуть
|
||||||
wake();
|
wake();
|
||||||
@@ -1866,8 +1906,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
|||||||
}
|
}
|
||||||
|
|
||||||
// финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty)
|
// финальные координаты разлёта (детерминированная орбита с джиттером — в node.tx/ty)
|
||||||
const finalX = node.isFocus ? 0 : node.tx;
|
// Ручно сдвинутый текущий центр не должен прыгать обратно в (0,0) при setModel.
|
||||||
const finalY = node.isFocus ? 0 : node.ty;
|
const finalX = node.tx;
|
||||||
|
const finalY = node.ty;
|
||||||
const finalScale = node.targetScale; // масштаб уже по уровню (focus / tier-1 / tier-2 0.5 / tier-3 точка)
|
const finalScale = node.targetScale; // масштаб уже по уровню (focus / tier-1 / tier-2 0.5 / tier-3 точка)
|
||||||
const finalOp = node.targetOpacity; // прозрачность по уровню (tier-2 ~0.4, tier-3 ~0.9, иначе 1)
|
const finalOp = node.targetOpacity; // прозрачность по уровню (tier-2 ~0.4, tier-3 ~0.9, иначе 1)
|
||||||
|
|
||||||
|
|||||||
@@ -320,11 +320,15 @@ function renderItem(item, activeTab, navigate) {
|
|||||||
export function render({ navigate, chrome } = {}) {
|
export function render({ navigate, chrome } = {}) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack notifications-screen';
|
screen.className = 'stack notifications-screen';
|
||||||
chrome?.setTopbar(createTopBar({ title: 'Уведомления' }));
|
|
||||||
|
|
||||||
const tabs = document.createElement('div');
|
const tabs = document.createElement('div');
|
||||||
tabs.className = 'notification-feed-tabs app-top-tabs';
|
tabs.className = 'notification-feed-tabs app-top-tabs';
|
||||||
const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']];
|
const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']];
|
||||||
|
chrome?.setTopbar(createTopBar({
|
||||||
|
center: tabs,
|
||||||
|
className: 'notifications-topbar',
|
||||||
|
}));
|
||||||
|
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
list.className = 'stack notifications-list';
|
list.className = 'stack notifications-list';
|
||||||
let payloadCache = null;
|
let payloadCache = null;
|
||||||
@@ -415,7 +419,7 @@ export function render({ navigate, chrome } = {}) {
|
|||||||
observer?.disconnect();
|
observer?.disconnect();
|
||||||
Object.values(pendingSeenTimers).forEach((timer) => clearTimeout(timer));
|
Object.values(pendingSeenTimers).forEach((timer) => clearTimeout(timer));
|
||||||
};
|
};
|
||||||
screen.append(tabs,list);
|
screen.append(list);
|
||||||
void load();
|
void load();
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function render({ navigate, chrome }) {
|
|||||||
iconNode: createOverflowDots(),
|
iconNode: createOverflowDots(),
|
||||||
title: 'Меню профиля',
|
title: 'Меню профиля',
|
||||||
ariaLabel: 'Меню профиля',
|
ariaLabel: 'Меню профиля',
|
||||||
className: 'profile-head-menu-btn',
|
className: 'profile-head-menu-btn topbar-overflow-action--raised',
|
||||||
menu: {
|
menu: {
|
||||||
className: 'profile-head-menu',
|
className: 'profile-head-menu',
|
||||||
minWidth: 250,
|
minWidth: 250,
|
||||||
@@ -176,6 +176,7 @@ export function render({ navigate, chrome }) {
|
|||||||
size: 'xl',
|
size: 'xl',
|
||||||
className: 'user-profile-hero-avatar',
|
className: 'user-profile-hero-avatar',
|
||||||
glow: shining,
|
glow: shining,
|
||||||
|
official,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
status.textContent = '';
|
status.textContent = '';
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ export function render({ navigate, route, chrome }) {
|
|||||||
size: 'xl',
|
size: 'xl',
|
||||||
className: 'user-profile-hero-avatar',
|
className: 'user-profile-hero-avatar',
|
||||||
glow: shining,
|
glow: shining,
|
||||||
|
official,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
addMenu = body.querySelector('.user-profile-add-menu');
|
addMenu = body.querySelector('.user-profile-add-menu');
|
||||||
|
|||||||
+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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
|
const WEB_CRYPTO_REQUIRED_MESSAGE = 'Регистрация и подпись блоков требуют WebCrypto (crypto.subtle). Откройте приложение через HTTPS или localhost в современном браузере и повторите попытку.';
|
||||||
import { argon2idAsync } from 'https://esm.sh/@noble/hashes@1.8.0/argon2.js';
|
import { argon2idAsync } from '../vendor/noble/hashes-argon2-1.8.0.bundle.mjs';
|
||||||
import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from 'https://esm.sh/@noble/curves@1.8.1/ed25519';
|
import { edwardsToMontgomeryPriv, edwardsToMontgomeryPub, x25519 } from '../vendor/noble/curves-ed25519-1.8.1.bundle.mjs';
|
||||||
const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key';
|
const SHINE_KEY_DERIVATION_PREFIX = 'SHiNE-key';
|
||||||
|
|
||||||
function getCryptoApi() {
|
function getCryptoApi() {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
edwardsToMontgomeryPriv,
|
edwardsToMontgomeryPriv,
|
||||||
edwardsToMontgomeryPub,
|
edwardsToMontgomeryPub,
|
||||||
x25519,
|
x25519,
|
||||||
} from 'https://esm.sh/@noble/curves@1.5.0/ed25519';
|
} from '../vendor/noble/curves-ed25519-1.5.0.bundle.mjs';
|
||||||
|
|
||||||
const PAIRING_ENVELOPE_PREFIX = 'shine-esp-pairing-v1:';
|
const PAIRING_ENVELOPE_PREFIX = 'shine-esp-pairing-v1:';
|
||||||
const PAIRING_HASH_PREFIX = 'sha256$';
|
const PAIRING_HASH_PREFIX = 'sha256$';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -269,3 +269,23 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Единый знак официального пользователя. Левый и нижний края зафиксированы,
|
||||||
|
а сам знак растёт вправо и вверх вместе с аватаром. */
|
||||||
|
.avatar.avatar-image.avatar-framed > .avatar-official-badge {
|
||||||
|
position: absolute;
|
||||||
|
left: -2%;
|
||||||
|
bottom: -1%;
|
||||||
|
width: 32%;
|
||||||
|
height: 32%;
|
||||||
|
min-width: 18px;
|
||||||
|
min-height: 18px;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
display: block;
|
||||||
|
opacity: 1;
|
||||||
|
border-radius: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 4;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42));
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,3 +26,12 @@
|
|||||||
0 0 5px rgba(92, 190, 255, 0.72),
|
0 0 5px rgba(92, 190, 255, 0.72),
|
||||||
0 0 10px rgba(72, 145, 255, 0.34);
|
0 0 10px rgba(72, 145, 255, 0.34);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Явный вариант для шапок, где overflow-кнопку нужно выровнять по эталонной
|
||||||
|
* позиции экрана «Связи». Поднимаем кнопку вместе с зоной нажатия и якорем
|
||||||
|
* выпадающего меню, не меняя базовый TopBar для остальных экранов. */
|
||||||
|
.topbar-overflow-action--raised {
|
||||||
|
position: relative;
|
||||||
|
top: -2px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,14 @@
|
|||||||
text-shadow: 0 0 5px var(--app-topbar-blue-glow), 0 0 12px var(--app-topbar-blue-glow-soft);
|
text-shadow: 0 0 5px var(--app-topbar-blue-glow), 0 0 12px var(--app-topbar-blue-glow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-slot .topbar__action {
|
||||||
|
min-width: 40px;
|
||||||
|
padding-block: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-slot .topbar__action svg,
|
.topbar-slot .topbar__action svg,
|
||||||
.topbar-slot .topbar__back svg {
|
.topbar-slot .topbar__back svg {
|
||||||
filter: drop-shadow(0 0 3px var(--app-topbar-blue-glow)) drop-shadow(0 0 7px var(--app-topbar-blue-glow-soft));
|
filter: drop-shadow(0 0 3px var(--app-topbar-blue-glow)) drop-shadow(0 0 7px var(--app-topbar-blue-glow-soft));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -186,14 +186,32 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* Уведомления: переключатели лент повторяют стеклянные чипы экрана «Связи». */
|
/* Уведомления: переключатели лент занимают место заголовка в закреплённом
|
||||||
.notifications-screen .notification-feed-tabs {
|
* TopBar и повторяют стеклянные чипы экрана «Связи». */
|
||||||
|
.notifications-topbar {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.notifications-topbar .topbar__left,
|
||||||
|
.notifications-topbar .topbar__right {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.notifications-topbar .topbar__center {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.notifications-topbar .notification-feed-tabs {
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 12px 4px;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -201,25 +219,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.notifications-screen .notification-feed-tabs .notification-tab-btn {
|
.notifications-topbar .notification-feed-tabs .notification-tab-btn {
|
||||||
min-width: 92px;
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
padding-inline: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* На обычных экранах отступ создаёт стандартный padding screen-content (14px). */
|
/* Высота панели остаётся стандартной: перенос фильтров не увеличивает TopBar. */
|
||||||
.notifications-screen .notification-feed-tabs.app-top-tabs {
|
.notifications-topbar .notification-feed-tabs.app-top-tabs {
|
||||||
min-height: var(--app-primary-tab-min-height);
|
min-height: var(--app-primary-tab-min-height);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.notifications-screen .notification-feed-tabs .notification-tab-btn {
|
|
||||||
min-width: 92px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Уведомления: информационные блоки без рамок. Hover/focus не возвращает
|
/* Уведомления: информационные блоки без рамок. Hover/focus не возвращает
|
||||||
* обводку — различение сохраняется фоном и лёгкой реакцией на нажатие. */
|
* обводку — различение сохраняется фоном и лёгкой реакцией на нажатие. */
|
||||||
.notifications-screen .notifications-list > .notification-card,
|
.notifications-screen .notifications-list > .notification-card,
|
||||||
|
|||||||
@@ -799,8 +799,26 @@
|
|||||||
|
|
||||||
|
|
||||||
.user-profile-screen .user-profile-hero-avatar.avatar-glow::before {
|
.user-profile-screen .user-profile-hero-avatar.avatar-glow::before {
|
||||||
inset: -12%;
|
inset: -7%;
|
||||||
opacity: 0.5;
|
border: 2px solid rgba(145, 239, 255, 0.94);
|
||||||
|
background: transparent;
|
||||||
|
box-shadow:
|
||||||
|
0 0 3px rgba(155, 244, 255, 0.96),
|
||||||
|
0 0 9px rgba(112, 222, 255, 0.74),
|
||||||
|
0 0 17px rgba(86, 190, 255, 0.38),
|
||||||
|
inset 0 0 4px rgba(176, 248, 255, 0.72);
|
||||||
|
filter: none;
|
||||||
|
transform: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* На крупном аватаре профиля официальный знак на 20% компактнее базового.
|
||||||
|
* Левый и нижний края остаются на месте, поэтому сокращение приходится на
|
||||||
|
* верхнюю и правую стороны знака. */
|
||||||
|
.user-profile-screen .user-profile-hero-avatar.avatar-framed > .avatar-official-badge {
|
||||||
|
width: 25.6%;
|
||||||
|
height: 25.6%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -210,10 +210,10 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: -2%;
|
left: -2%;
|
||||||
bottom: -1%;
|
bottom: -1%;
|
||||||
width: 16%;
|
width: 32%;
|
||||||
height: 16%;
|
height: 32%;
|
||||||
min-width: 9px;
|
min-width: 18px;
|
||||||
min-height: 9px;
|
min-height: 18px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
object-position: center;
|
object-position: center;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
Reference in New Issue
Block a user