SHA256
feat: finalize channels fixes and runtime stability
This commit is contained in:
@@ -1,38 +1,123 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
channelNameErrorText,
|
||||
normalizeChannelDisplayName,
|
||||
validateChannelDisplayName,
|
||||
} from '../services/channel-name-rules.js';
|
||||
|
||||
export const pageMeta = { id: 'add-channel-view', title: 'Добавить канал' };
|
||||
export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' };
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
|
||||
function persistCreateSuccessFlash(message) {
|
||||
try {
|
||||
sessionStorage.setItem(CREATE_CHANNEL_FLASH_KEY, String(message || '').trim());
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
screen.className = 'stack channels-screen channels-screen--add';
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Добавить канал',
|
||||
leftAction: { label: '←', onClick: () => navigate('channels-list') },
|
||||
title: 'Создать канал',
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||
})
|
||||
);
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.className = 'card stack';
|
||||
form.innerHTML = `
|
||||
<label for="channel-name">Имя канала</label>
|
||||
<input id="channel-name" class="input" maxlength="64" placeholder="Например: Новости команды" required />
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||||
<strong class="channel-head-title">Создание канала</strong>
|
||||
<p class="channel-head-meta">Можно использовать кириллицу, латиницу, цифры, пробел, _ и -.</p>
|
||||
<p class="channel-head-meta">Длина: от 3 до 32 символов. Название уникально во всей системе.</p>
|
||||
<label for="channel-name">Название канала</label>
|
||||
<input id="channel-name" class="input" maxlength="64" placeholder="Например: Поток силы" required />
|
||||
<div id="channel-create-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button type="button" class="secondary-btn" id="cancel-create-channel">Отмена</button>
|
||||
<button type="submit" class="primary-btn">Создать</button>
|
||||
<button type="submit" class="primary-btn" id="submit-create-channel">Создать</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
navigate('channels-list');
|
||||
const inputEl = form.querySelector('#channel-name');
|
||||
const errorEl = form.querySelector('#channel-create-error');
|
||||
const submitEl = form.querySelector('#submit-create-channel');
|
||||
const cancelEl = form.querySelector('#cancel-create-channel');
|
||||
|
||||
let submitInFlight = false;
|
||||
|
||||
const setBusy = (busy) => {
|
||||
submitInFlight = !!busy;
|
||||
submitEl.disabled = submitInFlight;
|
||||
cancelEl.disabled = submitInFlight;
|
||||
inputEl.disabled = submitInFlight;
|
||||
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
|
||||
};
|
||||
|
||||
const updateValidation = () => {
|
||||
const check = validateChannelDisplayName(inputEl.value);
|
||||
if (!check.ok) {
|
||||
errorEl.textContent = channelNameErrorText(check.code);
|
||||
} else {
|
||||
errorEl.textContent = '';
|
||||
}
|
||||
submitEl.disabled = submitInFlight || !check.ok;
|
||||
return check;
|
||||
};
|
||||
|
||||
inputEl.addEventListener('input', () => {
|
||||
updateValidation();
|
||||
});
|
||||
|
||||
form.querySelector('#cancel-create-channel').addEventListener('click', () => {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
if (submitInFlight) return;
|
||||
|
||||
const login = state.session.login;
|
||||
const storagePwd = state.session.storagePwdInMemory;
|
||||
if (!login || !storagePwd) {
|
||||
errorEl.textContent = 'Сессия недействительна. Выполните вход заново.';
|
||||
return;
|
||||
}
|
||||
|
||||
const check = updateValidation();
|
||||
if (!check.ok) return;
|
||||
|
||||
setBusy(true);
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
const channelName = normalizeChannelDisplayName(check.normalized);
|
||||
await authService.addBlockCreateChannel({
|
||||
login,
|
||||
storagePwd,
|
||||
channelName,
|
||||
});
|
||||
|
||||
persistCreateSuccessFlash(`Канал "${channelName}" создан.`);
|
||||
navigate('channels-list');
|
||||
} catch (error) {
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось создать канал.');
|
||||
setBusy(false);
|
||||
const checkAfterError = validateChannelDisplayName(inputEl.value);
|
||||
submitEl.disabled = submitInFlight || !checkAfterError.ok;
|
||||
}
|
||||
});
|
||||
|
||||
cancelEl.addEventListener('click', () => {
|
||||
navigate('channels-list');
|
||||
});
|
||||
|
||||
screen.append(form);
|
||||
if (inputEl) {
|
||||
inputEl.focus();
|
||||
updateValidation();
|
||||
}
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
||||
import { captureClientError } from '../services/client-error-reporter.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
||||
|
||||
const pendingReactionActions = new Set();
|
||||
|
||||
function logThreadRuntimeError(stage, error, context = {}) {
|
||||
const message = String(error?.message || error || 'thread runtime error');
|
||||
console.error(`[channel-thread-view:${stage}]`, error, context);
|
||||
captureClientError({
|
||||
kind: 'channels_thread_runtime',
|
||||
message,
|
||||
stack: error?.stack || '',
|
||||
context: {
|
||||
stage,
|
||||
...context,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function encodeRoutePart(value = '') {
|
||||
return encodeURIComponent(String(value));
|
||||
}
|
||||
|
||||
function normalizeRouteHash(hash) {
|
||||
const normalized = String(hash || '').trim().toLowerCase();
|
||||
return normalized || '0';
|
||||
}
|
||||
|
||||
function normalizeMessageHash(hash) {
|
||||
const normalized = String(hash || '').trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(normalized)) return '';
|
||||
if (/^0+$/.test(normalized)) return '';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function toSafeInt(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function makeReactionActionKey(messageRef) {
|
||||
const login = String(state.session.login || '').trim().toLowerCase();
|
||||
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
||||
const blockNumber = Number(messageRef?.blockNumber);
|
||||
const blockHash = normalizeMessageHash(messageRef?.blockHash);
|
||||
if (!login || !blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
|
||||
return `${login}|${blockchainName}|${blockNumber}|${blockHash}`;
|
||||
}
|
||||
|
||||
function parseThreadSelector(route) {
|
||||
const params = route?.params || {};
|
||||
const blockNumber = toSafeInt(params.messageBlockNumber);
|
||||
if (!params.messageBlockchainName || blockNumber == null) return null;
|
||||
|
||||
return {
|
||||
message: {
|
||||
blockchainName: String(params.messageBlockchainName),
|
||||
blockNumber,
|
||||
blockHash: normalizeRouteHash(params.messageBlockHash),
|
||||
},
|
||||
channel: {
|
||||
ownerBlockchainName: String(params.channelOwnerBlockchainName || ''),
|
||||
rootBlockNumber: toSafeInt(params.channelRootBlockNumber),
|
||||
rootBlockHash: normalizeRouteHash(params.channelRootBlockHash),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function allFeedSummaries() {
|
||||
const feed = state.channelsFeed || {};
|
||||
return [
|
||||
...(feed.ownedChannels || []),
|
||||
...(feed.followedUsersChannels || []),
|
||||
...(feed.followedChannels || []),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveChannelDisplayName(channelSelector) {
|
||||
if (!channelSelector?.ownerBlockchainName || channelSelector?.rootBlockNumber == null) return '';
|
||||
const ownerBch = String(channelSelector.ownerBlockchainName);
|
||||
const rootNo = Number(channelSelector.rootBlockNumber);
|
||||
const rootHash = normalizeRouteHash(channelSelector.rootBlockHash);
|
||||
|
||||
const found = allFeedSummaries().find((summary) => (
|
||||
String(summary?.channel?.ownerBlockchainName || '') === ownerBch
|
||||
&& Number(summary?.channel?.channelRoot?.blockNumber) === rootNo
|
||||
&& normalizeRouteHash(summary?.channel?.channelRoot?.blockHash) === rootHash
|
||||
));
|
||||
if (!found) return '';
|
||||
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
|
||||
}
|
||||
|
||||
function buildBackRoute(selector) {
|
||||
const channel = selector?.channel;
|
||||
if (channel?.ownerBlockchainName && channel.rootBlockNumber != null) {
|
||||
return [
|
||||
'channel-view',
|
||||
encodeRoutePart(channel.ownerBlockchainName),
|
||||
channel.rootBlockNumber,
|
||||
channel.rootBlockHash,
|
||||
].join('/');
|
||||
}
|
||||
return 'channels-list';
|
||||
}
|
||||
|
||||
function buildTargetFromNode(node) {
|
||||
const blockchainName = String(node?.authorBlockchainName || '').trim();
|
||||
const blockNumber = Number(node?.messageRef?.blockNumber);
|
||||
const blockHash = normalizeMessageHash(node?.messageRef?.blockHash);
|
||||
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
|
||||
return { blockchainName, blockNumber, blockHash };
|
||||
}
|
||||
|
||||
function firstNonEmptyText(...candidates) {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (trimmed.length > 0) return candidate;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function latestVersionText(versions) {
|
||||
if (!Array.isArray(versions)) return '';
|
||||
for (let i = versions.length - 1; i >= 0; i -= 1) {
|
||||
const version = versions[i];
|
||||
const value = firstNonEmptyText(version?.text, version?.message, version?.body);
|
||||
if (value) return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveNodeText(node) {
|
||||
return firstNonEmptyText(
|
||||
node?.text,
|
||||
node?.message,
|
||||
node?.body,
|
||||
latestVersionText(node?.versions),
|
||||
);
|
||||
}
|
||||
|
||||
function openReplyModal({ onSubmit }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="thread-reply-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Ответ</h3>
|
||||
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
|
||||
<div class="meta-muted inline-error" id="thread-reply-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="thread-reply-submit" type="button">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textEl = root.querySelector('#thread-reply-text');
|
||||
const errorEl = root.querySelector('#thread-reply-error');
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#thread-reply-cancel').addEventListener('click', close);
|
||||
root.querySelector('#thread-reply-submit').addEventListener('click', async () => {
|
||||
const text = String(textEl?.value || '').trim();
|
||||
if (!text) {
|
||||
errorEl.textContent = 'Введите текст ответа.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onSubmit(text);
|
||||
close();
|
||||
} catch (error) {
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
|
||||
}
|
||||
});
|
||||
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function renderNodeCard(node, heading, handlers) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack thread-node-card';
|
||||
|
||||
const author = node?.authorLogin || 'автор';
|
||||
const bch = node?.authorBlockchainName || '-';
|
||||
const blockNo = node?.messageRef?.blockNumber ?? '?';
|
||||
const text = resolveNodeText(node) || '(пусто)';
|
||||
const likes = Number(node?.likesCount || 0);
|
||||
const replies = Number(node?.repliesCount || 0);
|
||||
const versions = Number(node?.versionsTotal || 1);
|
||||
|
||||
card.innerHTML = `
|
||||
<strong class="thread-node-heading">${heading}</strong>
|
||||
<p class="thread-node-meta">${author} (${bch}) - #${blockNo}</p>
|
||||
<p class="thread-node-body">${text}</p>
|
||||
<p class="thread-node-stats">Лайки: ${likes}, ответы: ${replies}, версий: ${versions}</p>
|
||||
`;
|
||||
|
||||
const target = buildTargetFromNode(node);
|
||||
if (!target || !handlers) return card;
|
||||
|
||||
setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked');
|
||||
|
||||
const actionKey = makeReactionActionKey(target);
|
||||
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
|
||||
const isLiked = getMessageReactionState(target) === 'liked';
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'thread-node-actions';
|
||||
|
||||
const likeButton = document.createElement('button');
|
||||
likeButton.type = 'button';
|
||||
likeButton.className = 'secondary-btn thread-like-btn';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.textContent = isPending ? 'Выполняется...' : (isLiked ? 'Убрать лайк' : 'Лайк');
|
||||
likeButton.disabled = isPending;
|
||||
likeButton.addEventListener('click', async () => {
|
||||
if (isPending) return;
|
||||
try {
|
||||
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('like_click', error, {
|
||||
action: isLiked ? 'unlike' : 'like',
|
||||
targetBlockchainName: target?.blockchainName || '',
|
||||
targetBlockNumber: target?.blockNumber,
|
||||
});
|
||||
handlers?.onActionError?.(error, isLiked ? 'unlike' : 'like');
|
||||
}
|
||||
});
|
||||
|
||||
const replyButton = document.createElement('button');
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'secondary-btn thread-reply-btn';
|
||||
replyButton.textContent = 'Ответить';
|
||||
replyButton.addEventListener('click', () => {
|
||||
openReplyModal({
|
||||
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
||||
});
|
||||
});
|
||||
|
||||
actions.append(likeButton, replyButton);
|
||||
card.append(actions);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderDescendants(items, handlers, depth = 0) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'stack';
|
||||
|
||||
const normalized = Array.isArray(items) ? items : [];
|
||||
normalized.forEach((branch, index) => {
|
||||
try {
|
||||
const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, handlers);
|
||||
row.classList.add('thread-node-level');
|
||||
row.style.setProperty('--depth', String(Math.min(depth, 4)));
|
||||
wrap.append(row);
|
||||
|
||||
if (Array.isArray(branch?.children) && branch.children.length) {
|
||||
wrap.append(renderDescendants(branch.children, handlers, depth + 1));
|
||||
}
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('render_descendants_branch', error, { depth, index });
|
||||
}
|
||||
});
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
const selector = parseThreadSelector(route);
|
||||
const backRoute = buildBackRoute(selector);
|
||||
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--thread';
|
||||
|
||||
const userIndicator = document.createElement('div');
|
||||
userIndicator.className = 'card channels-user-chip';
|
||||
userIndicator.textContent = `Вы вошли как @${state.session.login || 'неизвестно'}`;
|
||||
|
||||
const channelIndicator = document.createElement('div');
|
||||
channelIndicator.className = 'card channels-user-chip';
|
||||
channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`;
|
||||
|
||||
const statusBox = document.createElement('div');
|
||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||
statusBox.style.display = 'none';
|
||||
|
||||
const rerender = () => {
|
||||
try {
|
||||
const current = document.querySelector('section.channels-screen--thread');
|
||||
if (!current) return;
|
||||
const next = render({ navigate, route });
|
||||
current.replaceWith(next);
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('rerender', error, {
|
||||
routeHash: window.location.hash,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const showStatus = (message) => {
|
||||
if (!message) {
|
||||
statusBox.style.display = 'none';
|
||||
statusBox.textContent = '';
|
||||
return;
|
||||
}
|
||||
statusBox.textContent = message;
|
||||
statusBox.style.display = '';
|
||||
};
|
||||
|
||||
const requireSigningSession = () => {
|
||||
const login = state.session.login;
|
||||
const storagePwd = state.session.storagePwdInMemory;
|
||||
if (!login || !storagePwd) throw new Error('Сессия недействительна. Выполните вход заново.');
|
||||
return { login, storagePwd };
|
||||
};
|
||||
|
||||
const rereadThread = async () => {
|
||||
if (!selector) return;
|
||||
await authService.getMessageThread(selector.message, 20, 2, 50, state.session.login);
|
||||
};
|
||||
|
||||
const handlers = {
|
||||
onToggleLike: async (target, action) => {
|
||||
const actionKey = makeReactionActionKey(target);
|
||||
if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.');
|
||||
if (pendingReactionActions.has(actionKey)) return;
|
||||
|
||||
pendingReactionActions.add(actionKey);
|
||||
rerender();
|
||||
try {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
if (action === 'unlike') {
|
||||
await authService.addBlockUnlike({ login, storagePwd, message: target });
|
||||
} else {
|
||||
await authService.addBlockLike({ login, storagePwd, message: target });
|
||||
}
|
||||
await rereadThread();
|
||||
showStatus('');
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('toggle_like', error, {
|
||||
action,
|
||||
targetBlockchainName: target?.blockchainName || '',
|
||||
targetBlockNumber: target?.blockNumber,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
pendingReactionActions.delete(actionKey);
|
||||
rerender();
|
||||
}
|
||||
},
|
||||
onReply: async (target, textValue) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockReply({ login, storagePwd, message: target, text: textValue });
|
||||
await rereadThread();
|
||||
showStatus('');
|
||||
rerender();
|
||||
},
|
||||
onActionError: (error, action) => {
|
||||
const fallback = action === 'unlike'
|
||||
? 'Не удалось убрать лайк.'
|
||||
: 'Не удалось поставить лайк.';
|
||||
showStatus(toUserMessage(error, fallback));
|
||||
},
|
||||
};
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Тред',
|
||||
leftAction: { label: '<', onClick: () => navigate(backRoute) },
|
||||
})
|
||||
);
|
||||
screen.append(userIndicator, channelIndicator, statusBox);
|
||||
|
||||
if (!selector) {
|
||||
const invalid = document.createElement('div');
|
||||
invalid.className = 'card meta-muted';
|
||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||
screen.append(invalid);
|
||||
return screen;
|
||||
}
|
||||
|
||||
const loading = document.createElement('div');
|
||||
loading.className = 'card meta-muted';
|
||||
loading.textContent = 'Загрузка треда...';
|
||||
screen.append(loading);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const payload = await authService.getMessageThread(selector.message, 20, 2, 50, state.session.login);
|
||||
loading.remove();
|
||||
|
||||
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
||||
const focus = payload?.focus || null;
|
||||
const descendants = Array.isArray(payload?.descendants) ? payload.descendants : [];
|
||||
|
||||
const summary = document.createElement('div');
|
||||
summary.className = 'card thread-summary';
|
||||
summary.textContent = `Предки: ${ancestors.length}, ответы: ${descendants.length}`;
|
||||
screen.append(summary);
|
||||
|
||||
if (ancestors.length) {
|
||||
const ancestorsWrap = document.createElement('div');
|
||||
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'section-title';
|
||||
title.textContent = 'Предыдущие сообщения';
|
||||
ancestorsWrap.append(title);
|
||||
ancestors.forEach((node, index) => {
|
||||
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers));
|
||||
});
|
||||
screen.append(ancestorsWrap);
|
||||
}
|
||||
|
||||
if (focus) {
|
||||
const focusWrap = document.createElement('div');
|
||||
focusWrap.className = 'stack thread-block thread-block--focus';
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'section-title';
|
||||
title.textContent = 'Текущее сообщение';
|
||||
focusWrap.append(title, renderNodeCard(focus, 'Выбранное сообщение', handlers));
|
||||
screen.append(focusWrap);
|
||||
}
|
||||
|
||||
const descendantsWrap = document.createElement('div');
|
||||
descendantsWrap.className = 'stack thread-block thread-block--replies';
|
||||
const descendantsTitle = document.createElement('h3');
|
||||
descendantsTitle.className = 'section-title';
|
||||
descendantsTitle.textContent = 'Ответы';
|
||||
descendantsWrap.append(descendantsTitle);
|
||||
|
||||
if (descendants.length) {
|
||||
descendantsWrap.append(renderDescendants(descendants, handlers));
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Ответов пока нет.';
|
||||
descendantsWrap.append(empty);
|
||||
}
|
||||
|
||||
screen.append(descendantsWrap);
|
||||
} catch (error) {
|
||||
loading.remove();
|
||||
const failed = document.createElement('div');
|
||||
failed.className = 'card meta-muted';
|
||||
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
|
||||
screen.append(failed);
|
||||
}
|
||||
})();
|
||||
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,169 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { channelPosts, channels } from '../mock-data.js';
|
||||
import { addLocalChannelPost, authService, getLocalChannelPosts, state } from '../state.js';
|
||||
import {
|
||||
addLocalChannelPost,
|
||||
authService,
|
||||
getLocalChannelPosts,
|
||||
getMessageReactionState,
|
||||
setMessageReactionState,
|
||||
state,
|
||||
} from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||
|
||||
const ZERO64 = '0'.repeat(64);
|
||||
const pendingReactionActions = new Set();
|
||||
|
||||
function isChannelsDemoMode() {
|
||||
try {
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
if (qs.get('channelsDemo') === '1') return true;
|
||||
return localStorage.getItem('shine-channels-demo') === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeRoutePart(value = '') {
|
||||
return encodeURIComponent(String(value));
|
||||
}
|
||||
|
||||
function normalizeRouteHash(hash) {
|
||||
const normalized = String(hash || '').trim().toLowerCase();
|
||||
return normalized || '0';
|
||||
}
|
||||
|
||||
function normalizeMessageHash(hash) {
|
||||
const normalized = String(hash || '').trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(normalized)) return '';
|
||||
if (/^0+$/.test(normalized)) return '';
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function toSafeInt(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function makeReactionActionKey(messageRef) {
|
||||
const login = String(state.session.login || '').trim().toLowerCase();
|
||||
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
||||
const blockNumber = Number(messageRef?.blockNumber);
|
||||
const blockHash = normalizeMessageHash(messageRef?.blockHash);
|
||||
if (!login || !blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
|
||||
return `${login}|${blockchainName}|${blockNumber}|${blockHash}`;
|
||||
}
|
||||
|
||||
function buildSelectorFromRoute(route, channelId) {
|
||||
const params = route?.params || {};
|
||||
|
||||
if (params.ownerBlockchainName) {
|
||||
const rootBlockNumber = toSafeInt(params.channelRootBlockNumber);
|
||||
if (rootBlockNumber != null) {
|
||||
return {
|
||||
ownerBlockchainName: String(params.ownerBlockchainName),
|
||||
channelRootBlockNumber: rootBlockNumber,
|
||||
channelRootBlockHash: normalizeRouteHash(params.channelRootBlockHash),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const summary = channelId ? state.channelsIndex[channelId] : null;
|
||||
if (!summary) return null;
|
||||
return {
|
||||
ownerBlockchainName: summary.channel?.ownerBlockchainName,
|
||||
channelRootBlockNumber: summary.channel?.channelRoot?.blockNumber,
|
||||
channelRootBlockHash: normalizeRouteHash(summary.channel?.channelRoot?.blockHash),
|
||||
};
|
||||
}
|
||||
|
||||
function localPostsKey(selector, channelId) {
|
||||
if (selector?.ownerBlockchainName && selector?.channelRootBlockNumber != null) {
|
||||
return `${selector.ownerBlockchainName}:${selector.channelRootBlockNumber}`;
|
||||
}
|
||||
return channelId || '';
|
||||
}
|
||||
|
||||
function buildThreadRoute(messageRef, selector) {
|
||||
if (!messageRef || !selector) return '';
|
||||
return [
|
||||
'channel-thread-view',
|
||||
encodeRoutePart(messageRef.blockchainName),
|
||||
messageRef.blockNumber,
|
||||
normalizeRouteHash(messageRef.blockHash),
|
||||
encodeRoutePart(selector.ownerBlockchainName),
|
||||
selector.channelRootBlockNumber,
|
||||
normalizeRouteHash(selector.channelRootBlockHash),
|
||||
].join('/');
|
||||
}
|
||||
|
||||
function firstNonEmptyText(...candidates) {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (trimmed.length > 0) return candidate;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function latestVersionText(versions) {
|
||||
if (!Array.isArray(versions)) return '';
|
||||
for (let i = versions.length - 1; i >= 0; i -= 1) {
|
||||
const version = versions[i];
|
||||
const value = firstNonEmptyText(version?.text, version?.message, version?.body);
|
||||
if (value) return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveMessageText(message) {
|
||||
return firstNonEmptyText(
|
||||
message?.text,
|
||||
message?.message,
|
||||
message?.body,
|
||||
latestVersionText(message?.versions),
|
||||
);
|
||||
}
|
||||
|
||||
function mapApiMessageToPost(message, selector) {
|
||||
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
|
||||
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
|
||||
const messageBch = String(message?.authorBlockchainName || selector?.ownerBlockchainName || '').trim();
|
||||
const hasRef = !!(messageBch && blockNumber != null && blockHash);
|
||||
const resolvedText = resolveMessageText(message);
|
||||
const messageRef = hasRef
|
||||
? {
|
||||
blockchainName: messageBch,
|
||||
blockNumber,
|
||||
blockHash,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (messageRef) {
|
||||
setMessageReactionState(messageRef, message?.likedByMe === true ? 'liked' : 'unliked');
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${message?.authorLogin || 'автор'} - #${blockNumber ?? '?'}`,
|
||||
body: resolvedText || '(пусто)',
|
||||
likesCount: Number(message?.likesCount || 0),
|
||||
repliesCount: Number(message?.repliesCount || 0),
|
||||
messageRef,
|
||||
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
||||
};
|
||||
}
|
||||
|
||||
function findMockChannel(channelId) {
|
||||
const channel = channels.find((c) => c.id === channelId) || channels[0];
|
||||
const fallback = channels[0] || {
|
||||
id: 'ch0',
|
||||
name: 'Неизвестный канал',
|
||||
description: 'Описание отсутствует',
|
||||
ownerName: 'неизвестно',
|
||||
ownerLogin: '',
|
||||
displayName: 'неизвестно/Неизвестный канал',
|
||||
};
|
||||
const channel = channels.find((c) => c.id === channelId) || fallback;
|
||||
return {
|
||||
channel,
|
||||
posts: [
|
||||
@@ -13,33 +171,62 @@ function findMockChannel(channelId) {
|
||||
...getLocalChannelPosts(channelId),
|
||||
],
|
||||
isOwnChannel: channel.ownerLogin === '@shine.alex',
|
||||
selector: null,
|
||||
localKey: channelId,
|
||||
};
|
||||
}
|
||||
|
||||
function mapApiMessageToPost(message) {
|
||||
return {
|
||||
title: `${message.authorLogin || 'author'} • #${message.messageRef?.blockNumber ?? '?'}`,
|
||||
body: message.text || '(пусто)',
|
||||
function openReplyModal({ onSubmit }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="reply-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Ответ</h3>
|
||||
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
|
||||
<div class="meta-muted inline-error" id="reply-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="reply-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="reply-submit" type="button">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const textEl = root.querySelector('#reply-text');
|
||||
const errorEl = root.querySelector('#reply-error');
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#reply-cancel').addEventListener('click', close);
|
||||
root.querySelector('#reply-submit').addEventListener('click', async () => {
|
||||
const text = String(textEl?.value || '').trim();
|
||||
if (!text) {
|
||||
errorEl.textContent = 'Введите текст ответа.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onSubmit(text);
|
||||
close();
|
||||
} catch (error) {
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
|
||||
}
|
||||
});
|
||||
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function renderPostCard(post) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `<strong>${post.title}</strong><p class="meta-muted">${post.body}</p>`;
|
||||
return card;
|
||||
}
|
||||
|
||||
function openAddMessageModal({ channelId, channelName, onSubmit }) {
|
||||
function openAddMessageModal({ channelName, onSubmit }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="channel-message-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 style="font-size:18px;">Новое сообщение в канал</h3>
|
||||
<h3 class="modal-title">Новое сообщение в канале</h3>
|
||||
<p class="meta-muted"># ${channelName}</p>
|
||||
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Введите текст сообщения"></textarea>
|
||||
<div class="meta-muted" id="channel-message-error" style="min-height:18px;"></div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||||
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
|
||||
<div class="meta-muted inline-error" id="channel-message-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="channel-message-submit" type="button">Отправить</button>
|
||||
</div>
|
||||
@@ -54,110 +241,314 @@ function openAddMessageModal({ channelId, channelName, onSubmit }) {
|
||||
};
|
||||
|
||||
root.querySelector('#channel-message-cancel').addEventListener('click', close);
|
||||
root.querySelector('#channel-message-submit').addEventListener('click', () => {
|
||||
const body = textEl.value.trim();
|
||||
root.querySelector('#channel-message-submit').addEventListener('click', async () => {
|
||||
const body = String(textEl?.value || '').trim();
|
||||
if (!body) {
|
||||
errorEl.textContent = 'Введите текст сообщения.';
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
title: `${state.session.login || 'Вы'} • сейчас`,
|
||||
body,
|
||||
});
|
||||
close();
|
||||
try {
|
||||
await onSubmit({
|
||||
title: `${state.session.login || 'вы'} - сейчас`,
|
||||
body,
|
||||
});
|
||||
close();
|
||||
} catch (error) {
|
||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.');
|
||||
}
|
||||
});
|
||||
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function renderBody(screen, navigate, channelId, channelData) {
|
||||
function renderPostCard(post, { navigate, selector, onToggleLike, onReply }) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack channel-message-card';
|
||||
|
||||
const stats = document.createElement('p');
|
||||
stats.className = 'channel-message-stats';
|
||||
stats.textContent = `Лайки: ${post.likesCount || 0}, ответы: ${post.repliesCount || 0}`;
|
||||
|
||||
card.innerHTML = `<strong class="channel-message-title">${post.title}</strong><p class="channel-message-body">${post.body}</p>`;
|
||||
card.append(stats);
|
||||
|
||||
if (!post.messageRef || !selector) return card;
|
||||
|
||||
const actionKey = makeReactionActionKey(post.messageRef);
|
||||
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'channel-message-actions';
|
||||
|
||||
const likeButton = document.createElement('button');
|
||||
likeButton.type = 'button';
|
||||
likeButton.className = 'secondary-btn channel-action-like';
|
||||
const isLiked = post.reactionState === 'liked';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.textContent = isPending ? 'Выполняется...' : (isLiked ? 'Убрать лайк' : 'Лайк');
|
||||
likeButton.disabled = isPending;
|
||||
likeButton.addEventListener('click', async () => {
|
||||
if (isPending) return;
|
||||
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like');
|
||||
});
|
||||
|
||||
const replyButton = document.createElement('button');
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'secondary-btn channel-action-reply';
|
||||
replyButton.textContent = 'Ответить';
|
||||
replyButton.addEventListener('click', () => {
|
||||
openReplyModal({
|
||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||
});
|
||||
});
|
||||
|
||||
const openThreadButton = document.createElement('button');
|
||||
openThreadButton.type = 'button';
|
||||
openThreadButton.className = 'secondary-btn channel-action-thread';
|
||||
openThreadButton.textContent = 'Открыть тред';
|
||||
openThreadButton.addEventListener('click', () => {
|
||||
const route = buildThreadRoute(post.messageRef, selector);
|
||||
if (route) navigate(route);
|
||||
});
|
||||
|
||||
actions.append(likeButton, replyButton, openThreadButton);
|
||||
card.append(actions);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderBody(screen, navigate, channelData, handlers) {
|
||||
const head = document.createElement('div');
|
||||
head.className = 'card';
|
||||
head.className = 'card channel-head-card';
|
||||
head.innerHTML = `
|
||||
<strong># ${channelData.channel.name}</strong>
|
||||
<p class="meta-muted" style="margin-top:4px;">${channelData.channel.description}</p>
|
||||
<p class="meta-muted" style="margin-top:8px;">Владелец: ${channelData.channel.ownerName}</p>
|
||||
<strong class="channel-head-title">${channelData.channel.displayName || channelData.channel.name}</strong>
|
||||
<p class="channel-head-meta">${channelData.channel.description}</p>
|
||||
<p class="channel-head-meta">Владелец: ${channelData.channel.ownerName}</p>
|
||||
<p class="channel-note">Состояние лайка обновляется после подтверждённого reread с сервера.</p>
|
||||
`;
|
||||
|
||||
const actionButton = document.createElement('button');
|
||||
actionButton.className = channelData.isOwnChannel ? 'primary-btn' : 'secondary-btn';
|
||||
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение в канал' : 'Отписаться от канала';
|
||||
actionButton.className = channelData.isOwnChannel
|
||||
? 'primary-btn channel-main-action'
|
||||
: 'destructive-btn channel-main-action';
|
||||
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Отписаться от канала';
|
||||
let followLimit = null;
|
||||
|
||||
const feed = document.createElement('div');
|
||||
feed.className = 'stack';
|
||||
feed.className = 'stack channel-feed';
|
||||
|
||||
channelData.posts.forEach((post) => {
|
||||
feed.append(renderPostCard(post));
|
||||
feed.append(renderPostCard(post, {
|
||||
navigate,
|
||||
selector: channelData.selector,
|
||||
onToggleLike: handlers.onToggleLike,
|
||||
onReply: handlers.onReply,
|
||||
}));
|
||||
});
|
||||
|
||||
if (channelData.isOwnChannel) {
|
||||
actionButton.addEventListener('click', () => {
|
||||
openAddMessageModal({
|
||||
channelId,
|
||||
channelName: channelData.channel.name,
|
||||
onSubmit: (post) => {
|
||||
addLocalChannelPost(channelId, post);
|
||||
channelData.posts.push(post);
|
||||
feed.append(renderPostCard(post));
|
||||
},
|
||||
onSubmit: async (post) => handlers.onAddPost(post),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
followLimit = document.createElement('p');
|
||||
followLimit.className = 'channel-note';
|
||||
followLimit.textContent = 'Отписка удаляет только эту подписку на канал.';
|
||||
actionButton.addEventListener('click', handlers.onUnfollowChannel);
|
||||
}
|
||||
|
||||
const backButton = document.createElement('button');
|
||||
backButton.className = 'secondary-btn';
|
||||
backButton.textContent = 'Назад к списку';
|
||||
backButton.className = 'secondary-btn channel-back-btn';
|
||||
backButton.textContent = 'Назад к каналам';
|
||||
backButton.addEventListener('click', () => navigate('channels-list'));
|
||||
|
||||
if (followLimit) {
|
||||
screen.append(head, followLimit, actionButton, feed, backButton);
|
||||
return;
|
||||
}
|
||||
screen.append(head, actionButton, feed, backButton);
|
||||
}
|
||||
|
||||
async function loadFromApi(channelId) {
|
||||
const summary = state.channelsIndex[channelId];
|
||||
if (!summary) return null;
|
||||
async function loadFromApi(route, channelId) {
|
||||
const selector = buildSelectorFromRoute(route, channelId);
|
||||
if (!selector?.ownerBlockchainName || selector.channelRootBlockNumber == null) {
|
||||
throw new Error('Не удалось определить канал из адреса страницы.');
|
||||
}
|
||||
|
||||
const selector = {
|
||||
ownerBlockchainName: summary.channel?.ownerBlockchainName,
|
||||
channelRootBlockNumber: summary.channel?.channelRoot?.blockNumber,
|
||||
channelRootBlockHash: summary.channel?.channelRoot?.blockHash,
|
||||
};
|
||||
|
||||
if (!selector.ownerBlockchainName || selector.channelRootBlockNumber == null) return null;
|
||||
|
||||
const payload = await authService.getChannelMessages(selector, 200, 'asc');
|
||||
const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login);
|
||||
const localKey = localPostsKey(selector, channelId);
|
||||
const posts = [
|
||||
...(payload.messages || []).map(mapApiMessageToPost),
|
||||
...getLocalChannelPosts(channelId),
|
||||
...(payload.messages || []).map((message) => mapApiMessageToPost(message, selector)),
|
||||
...getLocalChannelPosts(localKey),
|
||||
];
|
||||
|
||||
return {
|
||||
channel: {
|
||||
name: payload.channel?.channelName || summary.channel?.channelName || 'unknown',
|
||||
name: payload.channel?.channelName || 'неизвестный канал',
|
||||
displayName: `${payload.channel?.ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
||||
description: `bch=${payload.channel?.ownerBlockchainName || selector.ownerBlockchainName}`,
|
||||
ownerName: payload.channel?.ownerLogin || summary.channel?.ownerLogin || 'unknown',
|
||||
ownerName: payload.channel?.ownerLogin || 'неизвестно',
|
||||
},
|
||||
posts,
|
||||
isOwnChannel: (payload.channel?.ownerLogin || '').toLowerCase() === (state.session.login || '').toLowerCase(),
|
||||
selector,
|
||||
localKey,
|
||||
};
|
||||
}
|
||||
|
||||
function renderLoadError(screen, navigate, message, onRetry) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack channels-status';
|
||||
card.innerHTML = `
|
||||
<strong>Не удалось загрузить канал</strong>
|
||||
<p class="meta-muted">${message || 'Проверьте подключение к серверу и повторите попытку.'}</p>
|
||||
`;
|
||||
|
||||
const retry = document.createElement('button');
|
||||
retry.type = 'button';
|
||||
retry.className = 'primary-btn';
|
||||
retry.textContent = 'Повторить';
|
||||
retry.addEventListener('click', onRetry);
|
||||
|
||||
const back = document.createElement('button');
|
||||
back.type = 'button';
|
||||
back.className = 'secondary-btn';
|
||||
back.textContent = 'Назад к каналам';
|
||||
back.addEventListener('click', () => navigate('channels-list'));
|
||||
|
||||
card.append(retry, back);
|
||||
screen.append(card);
|
||||
}
|
||||
|
||||
function renderDemoFallback(screen, navigate, channelId, error) {
|
||||
const info = document.createElement('div');
|
||||
info.className = 'card stack';
|
||||
info.innerHTML = `
|
||||
<strong>Включен демо-режим</strong>
|
||||
<p class="meta-muted">Данные канала с сервера недоступны. Показан мок-канал, потому что включен channelsDemo.</p>
|
||||
<p class="meta-muted">${toUserMessage(error, 'Ошибка API/WS')}</p>
|
||||
`;
|
||||
screen.append(info);
|
||||
|
||||
renderBody(screen, navigate, findMockChannel(channelId || 'ch1'), {
|
||||
onToggleLike: async () => {},
|
||||
onReply: async () => {},
|
||||
onAddPost: async (post) => {
|
||||
addLocalChannelPost(channelId || 'ch1', post);
|
||||
},
|
||||
onUnfollowChannel: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
const channelId = route.params.channelId || 'ch1';
|
||||
const channelId = route.params.channelId || '';
|
||||
const routeSelector = buildSelectorFromRoute(route, channelId);
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
screen.className = 'stack channels-screen channels-screen--channel';
|
||||
|
||||
const headerTitle = state.channelsIndex[channelId]?.channel?.channelName
|
||||
? `Канал: ${state.channelsIndex[channelId].channel.channelName}`
|
||||
: `Канал: ${(channels.find((c) => c.id === channelId) || channels[0]).name}`;
|
||||
const fallbackName = channels.find((c) => c.id === channelId)?.name || 'Канал';
|
||||
const titleFromIndex = state.channelsIndex[channelId]?.channel?.channelName;
|
||||
const ownerFromIndex = state.channelsIndex[channelId]?.channel?.ownerLogin;
|
||||
const titleFromIndexDisplay = (ownerFromIndex && titleFromIndex) ? `${ownerFromIndex}/${titleFromIndex}` : titleFromIndex;
|
||||
const titleFromRoute = route.params.ownerBlockchainName ? String(route.params.ownerBlockchainName) : '';
|
||||
const headerTitle = `Канал: ${titleFromIndexDisplay || titleFromRoute || fallbackName}`;
|
||||
|
||||
const userIndicator = document.createElement('div');
|
||||
userIndicator.className = 'card channels-user-chip';
|
||||
userIndicator.textContent = `Вы вошли как @${state.session.login || 'неизвестно'}`;
|
||||
|
||||
const statusBox = document.createElement('div');
|
||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||
statusBox.style.display = 'none';
|
||||
|
||||
const rerender = () => {
|
||||
const current = document.querySelector('section.channels-screen--channel');
|
||||
if (!current) return;
|
||||
const next = render({ navigate, route });
|
||||
current.replaceWith(next);
|
||||
};
|
||||
|
||||
const showStatus = (message) => {
|
||||
if (!message) {
|
||||
statusBox.style.display = 'none';
|
||||
statusBox.textContent = '';
|
||||
return;
|
||||
}
|
||||
statusBox.textContent = message;
|
||||
statusBox.style.display = '';
|
||||
};
|
||||
|
||||
const requireSigningSession = () => {
|
||||
const login = state.session.login;
|
||||
const storagePwd = state.session.storagePwdInMemory;
|
||||
if (!login || !storagePwd) {
|
||||
throw new Error('Сессия недействительна. Выполните вход заново.');
|
||||
}
|
||||
return { login, storagePwd };
|
||||
};
|
||||
|
||||
const rereadChannel = async () => {
|
||||
await loadFromApi(route, channelId);
|
||||
};
|
||||
|
||||
const onToggleLike = async (messageRef, action) => {
|
||||
const actionKey = makeReactionActionKey(messageRef);
|
||||
if (!actionKey) {
|
||||
throw new Error('Некорректная ссылка на сообщение для реакции.');
|
||||
}
|
||||
if (pendingReactionActions.has(actionKey)) return;
|
||||
|
||||
pendingReactionActions.add(actionKey);
|
||||
rerender();
|
||||
try {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
if (action === 'unlike') {
|
||||
await authService.addBlockUnlike({ login, storagePwd, message: messageRef });
|
||||
} else {
|
||||
await authService.addBlockLike({ login, storagePwd, message: messageRef });
|
||||
}
|
||||
await rereadChannel();
|
||||
showStatus('');
|
||||
} finally {
|
||||
pendingReactionActions.delete(actionKey);
|
||||
rerender();
|
||||
}
|
||||
};
|
||||
|
||||
const onReply = async (messageRef, text) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockReply({ login, storagePwd, message: messageRef, text });
|
||||
await rereadChannel();
|
||||
rerender();
|
||||
};
|
||||
|
||||
const onAddPost = async (post) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
if (!routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) {
|
||||
throw new Error('Идентификатор канала не готов.');
|
||||
}
|
||||
|
||||
await authService.addBlockTextPost({
|
||||
login,
|
||||
storagePwd,
|
||||
channel: routeSelector,
|
||||
text: post?.body || '',
|
||||
});
|
||||
await rereadChannel();
|
||||
rerender();
|
||||
};
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: headerTitle,
|
||||
leftAction: { label: '←', onClick: () => navigate('channels-list') },
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||
})
|
||||
);
|
||||
screen.append(userIndicator, statusBox);
|
||||
|
||||
const loading = document.createElement('div');
|
||||
loading.className = 'card meta-muted';
|
||||
@@ -166,18 +557,60 @@ export function render({ navigate, route }) {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const apiData = await loadFromApi(channelId);
|
||||
const apiData = await loadFromApi(route, channelId);
|
||||
loading.remove();
|
||||
if (apiData) {
|
||||
renderBody(screen, navigate, channelId, apiData);
|
||||
renderBody(screen, navigate, apiData, {
|
||||
onToggleLike: async (messageRef, action) => {
|
||||
try {
|
||||
await onToggleLike(messageRef, action);
|
||||
} catch (error) {
|
||||
showStatus(toUserMessage(error, action === 'unlike' ? 'Не удалось убрать лайк.' : 'Не удалось поставить лайк.'));
|
||||
}
|
||||
},
|
||||
onReply: async (messageRef, text) => {
|
||||
try {
|
||||
await onReply(messageRef, text);
|
||||
showStatus('');
|
||||
} catch (error) {
|
||||
throw new Error(toUserMessage(error, 'Не удалось отправить ответ.'));
|
||||
}
|
||||
},
|
||||
onAddPost: async (post) => {
|
||||
try {
|
||||
await onAddPost(post);
|
||||
showStatus('');
|
||||
} catch (error) {
|
||||
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
||||
}
|
||||
},
|
||||
onUnfollowChannel: async () => {
|
||||
try {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
if (!apiData.selector) throw new Error('Не удалось определить канал для отписки.');
|
||||
|
||||
await authService.addBlockFollowChannel({
|
||||
login,
|
||||
storagePwd,
|
||||
targetBlockchainName: apiData.selector.ownerBlockchainName,
|
||||
targetBlockNumber: apiData.selector.channelRootBlockNumber,
|
||||
targetBlockHashHex: apiData.selector.channelRootBlockHash,
|
||||
unfollow: true,
|
||||
});
|
||||
|
||||
navigate('channels-list');
|
||||
} catch (error) {
|
||||
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
loading.remove();
|
||||
if (isChannelsDemoMode()) {
|
||||
renderDemoFallback(screen, navigate, channelId, error);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fallback to mock below
|
||||
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), rerender);
|
||||
}
|
||||
|
||||
loading.remove();
|
||||
renderBody(screen, navigate, channelId, findMockChannel(channelId));
|
||||
})();
|
||||
|
||||
return screen;
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { channels as mockChannels } from '../mock-data.js';
|
||||
import { authService, setChannelsFeed, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
|
||||
function openSimpleSubscribeModal(kindLabel) {
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="channels-subscribe-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 style="font-size:18px;">${kindLabel}</h3>
|
||||
<label class="meta-muted" for="subscribe-input">Введите идентификатор</label>
|
||||
<input id="subscribe-input" class="input" placeholder="@login или #канал" />
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
|
||||
<button class="secondary-btn" id="sub-cancel">Отмена</button>
|
||||
<button class="primary-btn" id="sub-submit">Подписаться</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
function isChannelsDemoMode() {
|
||||
try {
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
if (qs.get('channelsDemo') === '1') return true;
|
||||
return localStorage.getItem('shine-channels-demo') === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
function normalizeHash(hash) {
|
||||
const normalized = String(hash || '').trim().toLowerCase();
|
||||
return normalized || '0';
|
||||
}
|
||||
|
||||
root.querySelector('#sub-cancel').addEventListener('click', close);
|
||||
root.querySelector('#sub-submit').addEventListener('click', close);
|
||||
function encodeRoutePart(value = '') {
|
||||
return encodeURIComponent(String(value));
|
||||
}
|
||||
|
||||
function buildChannelRouteFromSummary(summary, fallbackId) {
|
||||
const ownerBch = summary?.channel?.ownerBlockchainName;
|
||||
const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber;
|
||||
const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash);
|
||||
if (!ownerBch || rootBlockNumber == null) return `channel-view/${fallbackId}`;
|
||||
return `channel-view/${encodeRoutePart(ownerBch)}/${Number(rootBlockNumber)}/${rootBlockHash}`;
|
||||
}
|
||||
|
||||
function initialsFromName(name = '') {
|
||||
@@ -33,30 +38,202 @@ function initialsFromName(name = '') {
|
||||
return (parts[0]?.[0] || '#') + (parts[1]?.[0] || '');
|
||||
}
|
||||
|
||||
function allFeedSummaries() {
|
||||
const feed = state.channelsFeed || {};
|
||||
return [
|
||||
...(feed.ownedChannels || []),
|
||||
...(feed.followedUsersChannels || []),
|
||||
...(feed.followedChannels || []),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveChannelTargetFromInput(rawInput) {
|
||||
const input = String(rawInput || '').trim();
|
||||
if (!input) return null;
|
||||
|
||||
const bySelector = input.match(/^([A-Za-z0-9._-]+-\d+)\s*[:/]\s*(\d+)\s*[:/]\s*([A-Fa-f0-9]{1,64})$/);
|
||||
if (bySelector) {
|
||||
return {
|
||||
ownerBlockchainName: bySelector[1],
|
||||
rootBlockNumber: Number(bySelector[2]),
|
||||
rootBlockHash: normalizeHash(bySelector[3]),
|
||||
};
|
||||
}
|
||||
|
||||
const summaries = allFeedSummaries();
|
||||
|
||||
const byOwnerAndName = input.match(/^@?([^/#\s]+)\s*\/\s*#?(.+)$/);
|
||||
if (byOwnerAndName) {
|
||||
const owner = byOwnerAndName[1].trim().toLowerCase();
|
||||
const channelName = byOwnerAndName[2].trim().toLowerCase();
|
||||
const match = summaries.find((summary) => (
|
||||
String(summary?.channel?.ownerLogin || '').toLowerCase() === owner
|
||||
&& String(summary?.channel?.channelName || '').toLowerCase() === channelName
|
||||
));
|
||||
if (!match) return null;
|
||||
return {
|
||||
ownerBlockchainName: match.channel?.ownerBlockchainName,
|
||||
rootBlockNumber: Number(match.channel?.channelRoot?.blockNumber),
|
||||
rootBlockHash: normalizeHash(match.channel?.channelRoot?.blockHash),
|
||||
};
|
||||
}
|
||||
|
||||
const byNameOnly = input.replace(/^#/, '').trim().toLowerCase();
|
||||
if (!byNameOnly) return null;
|
||||
|
||||
const matches = summaries.filter((summary) => (
|
||||
String(summary?.channel?.channelName || '').toLowerCase() === byNameOnly
|
||||
));
|
||||
|
||||
if (matches.length !== 1) return null;
|
||||
|
||||
return {
|
||||
ownerBlockchainName: matches[0].channel?.ownerBlockchainName,
|
||||
rootBlockNumber: Number(matches[0].channel?.channelRoot?.blockNumber),
|
||||
rootBlockHash: normalizeHash(matches[0].channel?.channelRoot?.blockHash),
|
||||
};
|
||||
}
|
||||
|
||||
function openSimpleSubscribeModal({ kind, kindLabel, submitLabel, unfollow = false, onSuccess }) {
|
||||
const targetHint = kind === 'channel'
|
||||
? '<p class="meta-muted">Цель канала: owner/channel или bch:number:hash.</p>'
|
||||
: '<p class="meta-muted">Цель пользователя: @login.</p>';
|
||||
const submitText = submitLabel || (unfollow ? 'Отписаться' : 'Подписаться');
|
||||
const placeholder = kind === 'channel' ? '@owner/#channel или bch:number:hash' : '@login';
|
||||
|
||||
const root = document.getElementById('modal-root');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="channels-subscribe-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">${kindLabel}</h3>
|
||||
${targetHint}
|
||||
<label class="meta-muted" for="subscribe-input">Идентификатор</label>
|
||||
<input id="subscribe-input" class="input" placeholder="${placeholder}" />
|
||||
<div id="subscribe-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="sub-cancel" type="button">Отмена</button>
|
||||
<button class="primary-btn" id="sub-submit" type="button">${submitText}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const inputEl = root.querySelector('#subscribe-input');
|
||||
const errorEl = root.querySelector('#subscribe-error');
|
||||
const submitEl = root.querySelector('#sub-submit');
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#sub-cancel').addEventListener('click', close);
|
||||
|
||||
submitEl.addEventListener('click', async () => {
|
||||
const login = state.session.login;
|
||||
const storagePwd = state.session.storagePwdInMemory;
|
||||
const value = String(inputEl?.value || '').trim();
|
||||
|
||||
if (!login || !storagePwd) {
|
||||
errorEl.textContent = 'Сессия недействительна. Выполните вход заново.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
errorEl.textContent = 'Введите идентификатор.';
|
||||
return;
|
||||
}
|
||||
|
||||
submitEl.disabled = true;
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
if (kind === 'user') {
|
||||
await authService.addBlockFollowUser({
|
||||
login,
|
||||
targetLogin: value.replace(/^@+/, ''),
|
||||
storagePwd,
|
||||
unfollow,
|
||||
});
|
||||
} else if (kind === 'channel') {
|
||||
const target = resolveChannelTargetFromInput(value);
|
||||
if (!target?.ownerBlockchainName || !Number.isFinite(target.rootBlockNumber)) {
|
||||
throw new Error('Канал не найден. Используйте owner/channel или bch:number:hash.');
|
||||
}
|
||||
|
||||
await authService.addBlockFollowChannel({
|
||||
login,
|
||||
storagePwd,
|
||||
targetBlockchainName: target.ownerBlockchainName,
|
||||
targetBlockNumber: target.rootBlockNumber,
|
||||
targetBlockHashHex: target.rootBlockHash,
|
||||
unfollow,
|
||||
});
|
||||
} else {
|
||||
throw new Error('Неподдерживаемый тип подписки');
|
||||
}
|
||||
|
||||
close();
|
||||
if (typeof onSuccess === 'function') onSuccess();
|
||||
} catch (error) {
|
||||
errorEl.textContent = toUserMessage(error, `${submitText} не удалось.`);
|
||||
submitEl.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (inputEl) inputEl.focus();
|
||||
}
|
||||
|
||||
function mapMockGroups() {
|
||||
const ownChannels = mockChannels.filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own');
|
||||
const followedUserChannels = mockChannels.filter((channel) => channel.kind === 'followed-user-channel');
|
||||
const subscribedChannels = mockChannels.filter((channel) => channel.kind === 'subscribed');
|
||||
const mapRow = (channel) => ({
|
||||
...channel,
|
||||
route: `channel-view/${channel.id}`,
|
||||
});
|
||||
|
||||
const ownChannels = mockChannels
|
||||
.filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own')
|
||||
.map(mapRow);
|
||||
const followedUserChannels = mockChannels
|
||||
.filter((channel) => channel.kind === 'followed-user-channel')
|
||||
.map(mapRow);
|
||||
const subscribedChannels = mockChannels
|
||||
.filter((channel) => channel.kind === 'subscribed')
|
||||
.map(mapRow);
|
||||
|
||||
return { ownChannels, followedUserChannels, subscribedChannels, index: {} };
|
||||
}
|
||||
|
||||
function mapApiChannelRow(summary, bucketKey, idx, index) {
|
||||
const rowId = `${bucketKey}-${idx}`;
|
||||
index[rowId] = summary;
|
||||
const ownerLogin = summary.channel?.ownerLogin || 'неизвестно';
|
||||
const channelName = summary.channel?.channelName || '(без названия)';
|
||||
const displayName = `${ownerLogin}/${channelName}`;
|
||||
|
||||
return {
|
||||
id: rowId,
|
||||
source: 'api',
|
||||
ownerName: summary.channel?.ownerLogin || 'unknown',
|
||||
initials: initialsFromName(summary.channel?.channelName || summary.channel?.ownerLogin || '?'),
|
||||
name: summary.channel?.channelName || '(без имени)',
|
||||
description: `owner=${summary.channel?.ownerLogin || '-'} / bch=${summary.channel?.ownerBlockchainName || '-'}`,
|
||||
route: buildChannelRouteFromSummary(summary, rowId),
|
||||
ownerName: ownerLogin,
|
||||
initials: initialsFromName(channelName || ownerLogin || '?'),
|
||||
name: channelName,
|
||||
displayName,
|
||||
description: `bch=${summary.channel?.ownerBlockchainName || '-'}`,
|
||||
lastMessage: summary.lastMessage?.text || 'Сообщений пока нет',
|
||||
time: summary.lastMessage?.createdAtMs ? new Date(summary.lastMessage.createdAtMs).toLocaleString('ru-RU') : '—',
|
||||
time: summary.lastMessage?.createdAtMs ? new Date(summary.lastMessage.createdAtMs).toLocaleString('ru-RU') : '-',
|
||||
messagesCount: summary.messagesCount || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function pullCreateSuccessFlash() {
|
||||
try {
|
||||
const value = String(sessionStorage.getItem(CREATE_CHANNEL_FLASH_KEY) || '').trim();
|
||||
if (value) sessionStorage.removeItem(CREATE_CHANNEL_FLASH_KEY);
|
||||
return value;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function mapApiFeed(feed) {
|
||||
const index = {};
|
||||
|
||||
@@ -77,40 +254,48 @@ function mapApiFeed(feed) {
|
||||
|
||||
function renderChannelRow(channel, navigate) {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'list-item';
|
||||
row.className = 'channel-row';
|
||||
row.innerHTML = `
|
||||
<div class="avatar">${channel.initials}</div>
|
||||
<div>
|
||||
<strong># ${channel.name}</strong>
|
||||
<p class="meta-muted" style="margin-top:4px;">${channel.description}</p>
|
||||
<p class="meta-muted" style="margin-top:6px; color:#d8e3ff;">${channel.lastMessage}</p>
|
||||
<p class="meta-muted" style="margin-top:6px;">Владелец: ${channel.ownerName}</p>
|
||||
<div class="channel-row-main">
|
||||
<strong class="channel-row-title">${channel.displayName || channel.name}</strong>
|
||||
<p class="channel-row-description">${channel.description}</p>
|
||||
<p class="channel-row-message">${channel.lastMessage}</p>
|
||||
<p class="channel-row-owner">Владелец: ${channel.ownerName}</p>
|
||||
</div>
|
||||
<div style="display:grid; justify-items:end; gap:6px;">
|
||||
<span class="badge alt" style="padding:4px 8px; font-size:10px;">Канал</span>
|
||||
<span class="meta-muted">${channel.time}</span>
|
||||
<span class="unread">${channel.messagesCount}</span>
|
||||
<div class="channel-row-meta">
|
||||
<span class="channel-row-kind">Канал</span>
|
||||
<span class="channel-row-time">${channel.time}</span>
|
||||
<span class="unread channel-row-count">${channel.messagesCount}</span>
|
||||
</div>
|
||||
`;
|
||||
row.addEventListener('click', () => navigate(`channel-view/${channel.id}`));
|
||||
row.addEventListener('click', () => navigate(channel.route || `channel-view/${channel.id}`));
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderSection(title, items, navigate) {
|
||||
const wrap = document.createElement('section');
|
||||
wrap.className = 'stack';
|
||||
wrap.className = 'stack channels-section';
|
||||
|
||||
const header = document.createElement('h3');
|
||||
header.className = 'section-title';
|
||||
header.textContent = title;
|
||||
|
||||
wrap.append(header);
|
||||
if (!items.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'channels-list-empty';
|
||||
empty.textContent = 'Пока пусто.';
|
||||
wrap.append(empty);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
items.forEach((channel) => wrap.append(renderChannelRow(channel, navigate)));
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderGroupedList(screen, navigate, groups) {
|
||||
function renderGroupedList(container, navigate, groups) {
|
||||
const listWrap = document.createElement('div');
|
||||
listWrap.className = 'channels-scroll-wrap';
|
||||
|
||||
@@ -123,17 +308,17 @@ function renderGroupedList(screen, navigate, groups) {
|
||||
dividerOne.className = 'channels-divider';
|
||||
list.append(dividerOne);
|
||||
|
||||
list.append(renderSection('Каналы пользователей, на кого вы подписаны', groups.followedUserChannels, navigate));
|
||||
list.append(renderSection('Каналы пользователей, на которых я подписан', groups.followedUserChannels, navigate));
|
||||
|
||||
const dividerTwo = document.createElement('hr');
|
||||
dividerTwo.className = 'channels-divider';
|
||||
list.append(dividerTwo);
|
||||
|
||||
list.append(renderSection('Каналы, на которые вы подписаны', groups.subscribedChannels, navigate));
|
||||
list.append(renderSection('Каналы, на которые я подписан', groups.subscribedChannels, navigate));
|
||||
|
||||
const addChannelButton = document.createElement('button');
|
||||
addChannelButton.className = 'primary-btn';
|
||||
addChannelButton.textContent = 'Добавить канал';
|
||||
addChannelButton.className = 'primary-btn channels-bottom-action';
|
||||
addChannelButton.textContent = 'Создать канал';
|
||||
addChannelButton.addEventListener('click', () => navigate('add-channel-view'));
|
||||
|
||||
list.append(addChannelButton);
|
||||
@@ -142,43 +327,195 @@ function renderGroupedList(screen, navigate, groups) {
|
||||
scrollHint.className = 'channels-scroll-hint';
|
||||
|
||||
listWrap.append(list, scrollHint);
|
||||
screen.append(listWrap);
|
||||
container.append(listWrap);
|
||||
}
|
||||
|
||||
async function loadFeedAndRender(screen, navigate) {
|
||||
function renderErrorState(container, error, onRetry) {
|
||||
const errCard = document.createElement('div');
|
||||
errCard.className = 'card stack channels-status';
|
||||
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = 'Не удалось загрузить каналы';
|
||||
|
||||
const details = document.createElement('p');
|
||||
details.className = 'meta-muted';
|
||||
details.textContent = toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.');
|
||||
|
||||
const retry = document.createElement('button');
|
||||
retry.className = 'primary-btn';
|
||||
retry.type = 'button';
|
||||
retry.textContent = 'Повторить';
|
||||
retry.addEventListener('click', onRetry);
|
||||
|
||||
errCard.append(title, details, retry);
|
||||
container.append(errCard);
|
||||
}
|
||||
|
||||
function renderDemoFallback(container, navigate, error, onRetry) {
|
||||
const info = document.createElement('div');
|
||||
info.className = 'card stack';
|
||||
info.innerHTML = `
|
||||
<strong>Включен демо-режим</strong>
|
||||
<p class="meta-muted">Данные сервера недоступны. Показаны мок-каналы, потому что включен channelsDemo.</p>
|
||||
<p class="meta-muted">${toUserMessage(error, 'Ошибка API/WS')}</p>
|
||||
`;
|
||||
|
||||
const retry = document.createElement('button');
|
||||
retry.className = 'secondary-btn';
|
||||
retry.type = 'button';
|
||||
retry.textContent = 'Повторить запрос к серверу';
|
||||
retry.addEventListener('click', onRetry);
|
||||
|
||||
info.append(retry);
|
||||
container.append(info);
|
||||
renderGroupedList(container, navigate, mapMockGroups());
|
||||
}
|
||||
|
||||
async function loadFeedAndRender(container, navigate) {
|
||||
container.innerHTML = '';
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.className = 'card meta-muted';
|
||||
status.textContent = 'Загрузка каналов с сервера...';
|
||||
screen.append(status);
|
||||
status.textContent = 'Загрузка каналов...';
|
||||
container.append(status);
|
||||
|
||||
try {
|
||||
if (!state.session.login) throw new Error('not_authorized');
|
||||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||||
const groups = mapApiFeed(feed);
|
||||
setChannelsFeed(feed, groups.index);
|
||||
status.remove();
|
||||
renderGroupedList(screen, navigate, groups);
|
||||
} catch {
|
||||
|
||||
container.innerHTML = '';
|
||||
renderGroupedList(container, navigate, groups);
|
||||
} catch (error) {
|
||||
setChannelsFeed(null, {});
|
||||
status.textContent = 'Сервер недоступен или нет данных. Показаны демо-каналы.';
|
||||
renderGroupedList(screen, navigate, mapMockGroups());
|
||||
container.innerHTML = '';
|
||||
if (isChannelsDemoMode()) {
|
||||
renderDemoFallback(container, navigate, error, () => loadFeedAndRender(container, navigate));
|
||||
return;
|
||||
}
|
||||
renderErrorState(container, error, () => loadFeedAndRender(container, navigate));
|
||||
}
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
screen.className = 'stack channels-screen channels-screen--list';
|
||||
const createSuccessFlash = pullCreateSuccessFlash();
|
||||
const hero = document.createElement('div');
|
||||
hero.className = 'card channels-hero';
|
||||
hero.innerHTML = `
|
||||
<div class="channels-hero-emblem" aria-hidden="true"></div>
|
||||
<div class="channels-hero-copy">
|
||||
<p class="channels-hero-kicker">SHiNE</p>
|
||||
<p class="channels-hero-title">Каналы</p>
|
||||
<p class="channels-hero-subtitle">Ленты, треды и подписки в одном экране.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const currentUser = document.createElement('div');
|
||||
currentUser.className = 'card channels-user-chip';
|
||||
currentUser.textContent = `Вы вошли как @${state.session.login || 'неизвестно'}`;
|
||||
|
||||
let flashCard = null;
|
||||
if (createSuccessFlash) {
|
||||
flashCard = document.createElement('div');
|
||||
flashCard.className = 'card status-line is-available';
|
||||
flashCard.textContent = createSuccessFlash;
|
||||
}
|
||||
|
||||
const help = document.createElement('div');
|
||||
help.className = 'card stack channels-help-card';
|
||||
help.innerHTML = `
|
||||
<strong>Быстрый ручной тест</strong>
|
||||
<p class="meta-muted">
|
||||
1) Создайте пользователей A и B.<br />
|
||||
2) Под A создайте 2 канала и сообщения.<br />
|
||||
3) Под B проверьте follow/unfollow user и channel.<br />
|
||||
4) Откройте канал: проверьте like/unlike, reply и thread.
|
||||
</p>
|
||||
`;
|
||||
|
||||
const content = document.createElement('div');
|
||||
const refresh = () => {
|
||||
loadFeedAndRender(content, navigate);
|
||||
};
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: 'Каналы',
|
||||
rightActions: [
|
||||
{ label: 'Подписаться на человека', onClick: () => openSimpleSubscribeModal('Подписка на человека') },
|
||||
{ label: 'Подписаться на канал', onClick: () => openSimpleSubscribeModal('Подписка на канал') },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
loadFeedAndRender(screen, navigate);
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'channels-action-grid';
|
||||
|
||||
const actionButtons = [
|
||||
{
|
||||
label: 'Подписаться на пользователя',
|
||||
className: 'primary-btn channels-action-btn',
|
||||
onClick: () => openSimpleSubscribeModal({
|
||||
kind: 'user',
|
||||
kindLabel: 'Подписка на пользователя',
|
||||
submitLabel: 'Подписаться',
|
||||
onSuccess: refresh,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Отписаться от пользователя',
|
||||
className: 'destructive-btn channels-action-btn',
|
||||
onClick: () => openSimpleSubscribeModal({
|
||||
kind: 'user',
|
||||
kindLabel: 'Отписка от пользователя',
|
||||
submitLabel: 'Отписаться',
|
||||
unfollow: true,
|
||||
onSuccess: refresh,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Подписаться на канал',
|
||||
className: 'primary-btn channels-action-btn',
|
||||
onClick: () => openSimpleSubscribeModal({
|
||||
kind: 'channel',
|
||||
kindLabel: 'Подписка на канал',
|
||||
submitLabel: 'Подписаться',
|
||||
onSuccess: refresh,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Отписаться от канала',
|
||||
className: 'destructive-btn channels-action-btn',
|
||||
onClick: () => openSimpleSubscribeModal({
|
||||
kind: 'channel',
|
||||
kindLabel: 'Отписка от канала',
|
||||
submitLabel: 'Отписаться',
|
||||
unfollow: true,
|
||||
onSuccess: refresh,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
actionButtons.forEach((config) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = config.className;
|
||||
btn.textContent = config.label;
|
||||
btn.addEventListener('click', config.onClick);
|
||||
actions.append(btn);
|
||||
});
|
||||
|
||||
const limitations = document.createElement('div');
|
||||
limitations.className = 'channels-info-strip';
|
||||
limitations.textContent = 'Подписка на пользователя и подписка на конкретный канал работают независимо.';
|
||||
|
||||
screen.append(hero);
|
||||
screen.append(actions);
|
||||
screen.append(currentUser);
|
||||
if (flashCard) screen.append(flashCard);
|
||||
screen.append(help);
|
||||
screen.append(limitations);
|
||||
screen.append(content);
|
||||
refresh();
|
||||
return screen;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
setAuthError,
|
||||
state,
|
||||
} from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'login-password-view', title: 'Войти по логину', showAppChrome: false };
|
||||
|
||||
@@ -32,7 +33,11 @@ export function render({ navigate }) {
|
||||
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'meta-muted';
|
||||
hint.textContent = 'Root/dev/bch ключи вычисляются из пароля через SHA-256, storagePwd каждый вход приходит с сервера.';
|
||||
hint.textContent = 'Введите логин и пароль. На следующем шаге сохраните ключи на устройстве.';
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.style.display = 'none';
|
||||
|
||||
form.innerHTML = `
|
||||
<label class="stack"><span class="field-label">Логин</span></label>
|
||||
@@ -40,7 +45,7 @@ export function render({ navigate }) {
|
||||
`;
|
||||
form.children[0].append(loginInput);
|
||||
form.children[1].append(passwordInput);
|
||||
form.append(hint);
|
||||
form.append(hint, status);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
@@ -56,11 +61,13 @@ export function render({ navigate }) {
|
||||
enterButton.type = 'button';
|
||||
enterButton.textContent = 'Войти';
|
||||
enterButton.addEventListener('click', async () => {
|
||||
status.style.display = 'none';
|
||||
state.loginDraft.login = loginInput.value.trim();
|
||||
state.loginDraft.password = passwordInput.value;
|
||||
|
||||
if (!state.loginDraft.login || !state.loginDraft.password) {
|
||||
window.alert('Введите логин и пароль');
|
||||
status.textContent = 'Введите логин и пароль.';
|
||||
status.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,8 +88,10 @@ export function render({ navigate }) {
|
||||
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
||||
navigate('registration-keys-view');
|
||||
} catch (error) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
const message = toUserMessage(error, 'Не удалось выполнить вход.');
|
||||
setAuthError(message);
|
||||
status.textContent = message;
|
||||
status.style.display = '';
|
||||
} finally {
|
||||
setAuthBusy(false);
|
||||
enterButton.disabled = false;
|
||||
|
||||
@@ -39,6 +39,20 @@ export function render({ navigate }) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
|
||||
const nextStepCard = document.createElement('div');
|
||||
nextStepCard.className = 'card stack';
|
||||
nextStepCard.innerHTML = `
|
||||
<strong>Вы вошли как @${login}</strong>
|
||||
<p class="meta-muted">Следующий шаг для ручной проверки: откройте вкладку «Каналы» в нижнем меню.</p>
|
||||
`;
|
||||
|
||||
const openChannelsButton = document.createElement('button');
|
||||
openChannelsButton.className = 'primary-btn';
|
||||
openChannelsButton.type = 'button';
|
||||
openChannelsButton.textContent = 'Открыть каналы';
|
||||
openChannelsButton.addEventListener('click', () => navigate('channels-list'));
|
||||
nextStepCard.append(openChannelsButton);
|
||||
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'row';
|
||||
topRow.innerHTML = `
|
||||
@@ -201,7 +215,7 @@ export function render({ navigate }) {
|
||||
shineBtn.addEventListener('click', () => onToggleClick('shine'));
|
||||
|
||||
card.append(topRow, badgesRow, status, listWrap);
|
||||
screen.append(card);
|
||||
screen.append(nextStepCard, card);
|
||||
|
||||
refreshProfileSnapshot();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { authService, clearAuthMessages, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
||||
|
||||
@@ -28,6 +29,10 @@ export function render({ navigate }) {
|
||||
statusText.className = 'meta-muted';
|
||||
statusText.textContent = 'Проверка логина: не выполнена';
|
||||
|
||||
const formError = document.createElement('p');
|
||||
formError.className = 'status-line is-unavailable';
|
||||
formError.style.display = 'none';
|
||||
|
||||
const checkButton = document.createElement('button');
|
||||
checkButton.className = 'ghost-btn';
|
||||
checkButton.type = 'button';
|
||||
@@ -37,6 +42,7 @@ export function render({ navigate }) {
|
||||
const login = loginInput.value.trim();
|
||||
if (!login) {
|
||||
statusText.textContent = 'Введите логин';
|
||||
formError.style.display = 'none';
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -47,9 +53,10 @@ export function render({ navigate }) {
|
||||
const isFree = await authService.ensureLoginFree(login);
|
||||
statusText.textContent = isFree ? 'Логин свободен ✅' : 'Логин уже занят ❌';
|
||||
statusText.className = isFree ? 'is-available' : 'is-unavailable';
|
||||
formError.style.display = 'none';
|
||||
return isFree;
|
||||
} catch (error) {
|
||||
statusText.textContent = error.message;
|
||||
statusText.textContent = toUserMessage(error, 'Не удалось проверить логин');
|
||||
statusText.className = 'is-unavailable';
|
||||
return false;
|
||||
} finally {
|
||||
@@ -66,7 +73,7 @@ export function render({ navigate }) {
|
||||
`;
|
||||
form.children[0].append(loginInput);
|
||||
form.children[1].append(passwordInput);
|
||||
form.append(checkButton, statusText);
|
||||
form.append(checkButton, statusText, formError);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
@@ -82,9 +89,9 @@ export function render({ navigate }) {
|
||||
nextButton.type = 'button';
|
||||
nextButton.textContent = 'Далее';
|
||||
nextButton.addEventListener('click', async () => {
|
||||
formError.style.display = 'none';
|
||||
const isFree = await runAvailabilityCheck();
|
||||
if (!isFree) {
|
||||
window.alert('Выберите свободный логин');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,7 +99,8 @@ export function render({ navigate }) {
|
||||
state.registrationDraft.password = passwordInput.value;
|
||||
|
||||
if (!state.registrationDraft.password) {
|
||||
window.alert('Введите пароль');
|
||||
formError.textContent = 'Введите пароль.';
|
||||
formError.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
setAuthInfo,
|
||||
state,
|
||||
} from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
|
||||
|
||||
@@ -31,6 +32,14 @@ export function render({ navigate }) {
|
||||
question.className = 'auth-copy';
|
||||
question.textContent = 'Какие ключи сохранить в зашифрованном контейнере IndexedDB?';
|
||||
|
||||
const nextStep = document.createElement('p');
|
||||
nextStep.className = 'meta-muted';
|
||||
nextStep.textContent = 'После сохранения откроется профиль. Для проверки откройте вкладку «Каналы».';
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.style.display = 'none';
|
||||
|
||||
const rootToggle = document.createElement('input');
|
||||
rootToggle.type = 'checkbox';
|
||||
rootToggle.checked = state.keyStorage.saveRoot;
|
||||
@@ -46,17 +55,17 @@ export function render({ navigate }) {
|
||||
|
||||
const rootRow = document.createElement('label');
|
||||
rootRow.className = 'checkbox-row';
|
||||
rootRow.append(rootToggle, document.createTextNode('root key'));
|
||||
rootRow.append(rootToggle, document.createTextNode('Ключ root'));
|
||||
|
||||
const blockchainRow = document.createElement('label');
|
||||
blockchainRow.className = 'checkbox-row';
|
||||
blockchainRow.append(blockchainToggle, document.createTextNode('blockchain.key'));
|
||||
blockchainRow.append(blockchainToggle, document.createTextNode('Ключ blockchain'));
|
||||
|
||||
const deviceRow = document.createElement('label');
|
||||
deviceRow.className = 'checkbox-row';
|
||||
deviceRow.append(deviceToggle, document.createTextNode('device key (всегда)'));
|
||||
deviceRow.append(deviceToggle, document.createTextNode('Ключ device (всегда)'));
|
||||
|
||||
card.append(title, question, rootRow, blockchainRow, deviceRow);
|
||||
card.append(title, question, nextStep, rootRow, blockchainRow, deviceRow, status);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
@@ -72,6 +81,7 @@ export function render({ navigate }) {
|
||||
okButton.type = 'button';
|
||||
okButton.textContent = 'OK';
|
||||
okButton.addEventListener('click', async () => {
|
||||
status.style.display = 'none';
|
||||
try {
|
||||
if (!state.registrationDraft.pendingKeyBundle || !state.registrationDraft.pendingSessionMaterial) {
|
||||
throw new Error('Сначала завершите шаг регистрации на предыдущем экране');
|
||||
@@ -117,11 +127,15 @@ export function render({ navigate }) {
|
||||
state.registrationDraft.pendingSessionMaterial = null;
|
||||
|
||||
await refreshSessions();
|
||||
setAuthInfo(isLoginFlow ? 'Ключи сохранены, вход завершён.' : 'Ключи сохранены, регистрация завершена.');
|
||||
setAuthInfo(isLoginFlow
|
||||
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
|
||||
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
|
||||
navigate('profile-view');
|
||||
} catch (error) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
|
||||
setAuthError(message);
|
||||
status.textContent = message;
|
||||
status.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import {
|
||||
authService,
|
||||
refreshRegistrationBalance,
|
||||
@@ -6,9 +6,25 @@ import {
|
||||
setAuthInfo,
|
||||
state,
|
||||
} from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'registration-payment-view', title: 'Оплата регистрации', showAppChrome: false };
|
||||
|
||||
const MIN_REGISTER_BALANCE_SOL = 0.01;
|
||||
|
||||
function parseBalanceSol(value) {
|
||||
const parsed = Number.parseFloat(String(value || '').replace(',', '.'));
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function getCryptoRuntimeState() {
|
||||
const hasCrypto = Boolean(globalThis.crypto);
|
||||
const hasGetRandomValues = Boolean(globalThis.crypto && typeof globalThis.crypto.getRandomValues === 'function');
|
||||
const hasSubtle = Boolean(globalThis.crypto && (globalThis.crypto.subtle || globalThis.crypto.webkitSubtle));
|
||||
const secureContext = window.isSecureContext === true;
|
||||
return { hasCrypto, hasGetRandomValues, hasSubtle, secureContext };
|
||||
}
|
||||
|
||||
export function render({ navigate }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
@@ -16,6 +32,10 @@ export function render({ navigate }) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.style.display = 'none';
|
||||
|
||||
const walletValue = document.createElement('input');
|
||||
walletValue.className = 'input';
|
||||
walletValue.type = 'text';
|
||||
@@ -39,7 +59,9 @@ export function render({ navigate }) {
|
||||
copyButton.textContent = 'Скопировать номер';
|
||||
}, 1500);
|
||||
} catch {
|
||||
window.alert('Не удалось скопировать номер кошелька.');
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = 'Не удалось скопировать номер кошелька.';
|
||||
status.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,6 +95,24 @@ export function render({ navigate }) {
|
||||
submitButton.type = 'button';
|
||||
submitButton.textContent = 'Зарегистрироваться';
|
||||
submitButton.addEventListener('click', async () => {
|
||||
status.style.display = 'none';
|
||||
|
||||
const balanceSol = parseBalanceSol(state.registrationPayment.balanceSOL);
|
||||
if (balanceSol < MIN_REGISTER_BALANCE_SOL) {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = `Недостаточный баланс для регистрации: ${state.registrationPayment.balanceSOL} SOL. Нужно минимум ${MIN_REGISTER_BALANCE_SOL.toFixed(2)} SOL.`;
|
||||
status.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const cryptoState = getCryptoRuntimeState();
|
||||
if (!cryptoState.hasCrypto || !cryptoState.hasGetRandomValues || !cryptoState.hasSubtle) {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = 'Криптография браузера недоступна. Откройте приложение через HTTPS tunnel или localhost и повторите регистрацию.';
|
||||
status.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Регистрация...';
|
||||
@@ -85,12 +125,14 @@ export function render({ navigate }) {
|
||||
state.registrationDraft.pendingKeyBundle = result.keyBundle;
|
||||
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
|
||||
|
||||
setAuthInfo(`Отлично, вы зарегистрировались: ${result.login}`);
|
||||
window.alert('Отлично, вы зарегистрировались');
|
||||
setAuthInfo(`Регистрация завершена. Вы вошли как @${result.login}. Далее откройте вкладку «Каналы».`);
|
||||
navigate('registration-keys-view');
|
||||
} catch (error) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
const message = toUserMessage(error, 'Не удалось завершить регистрацию.');
|
||||
setAuthError(message);
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = message;
|
||||
status.style.display = '';
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Зарегистрироваться';
|
||||
@@ -106,7 +148,7 @@ export function render({ navigate }) {
|
||||
`;
|
||||
card.children[1].append(walletRow);
|
||||
card.children[2].append(balanceRow);
|
||||
card.append(topupButton, submitButton);
|
||||
card.append(topupButton, submitButton, status);
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { clearStartHint, state } from '../state.js';
|
||||
import { clearStartHint, state } from '../state.js';
|
||||
|
||||
export const pageMeta = { id: 'start-view', title: 'Старт', showAppChrome: false };
|
||||
|
||||
@@ -40,6 +40,19 @@ export function render({ navigate }) {
|
||||
|
||||
screen.append(logo, title);
|
||||
|
||||
const help = document.createElement('div');
|
||||
help.className = 'card auth-status-card';
|
||||
help.innerHTML = `
|
||||
<strong>Локальный тест SHiNE</strong>
|
||||
<p class="meta-muted" style="margin-top:6px;">
|
||||
1) Локально: <code>?localWsPort=7071</code>; через tunnel: <code>?wsUrl=wss://.../ws</code>.<br />
|
||||
2) Зарегистрируйте пользователя A, затем пользователя B.<br />
|
||||
3) Войдите под A, создайте 2 канала и сообщения.<br />
|
||||
4) Войдите под B и проверьте каналы, лайк/анлайк и подписки/отписки.
|
||||
</p>
|
||||
`;
|
||||
screen.append(help);
|
||||
|
||||
if (state.startHint) {
|
||||
const notice = document.createElement('div');
|
||||
notice.className = 'card auth-status-card';
|
||||
|
||||
Reference in New Issue
Block a user