channels ux cleanup and create-flow recovery

This commit is contained in:
DrygMira
2026-04-14 02:08:44 +03:00
parent 07e57b8563
commit 126b4ba3a1
22 changed files with 2322 additions and 664 deletions
+58 -28
View File
@@ -3,6 +3,7 @@ import { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
channelNameErrorText,
normalizeChannelDescription,
normalizeChannelDisplayName,
validateChannelDisplayName,
} from '../services/channel-name-rules.js';
@@ -19,6 +20,15 @@ function persistCreateSuccessFlash(message) {
}
}
function validateDescription(value) {
const normalized = normalizeChannelDescription(value);
const bytes = new TextEncoder().encode(normalized).length;
if (bytes > 200) {
return { ok: false, normalized, bytes, error: 'Описание слишком длинное: максимум 200 байт UTF-8.' };
}
return { ok: true, normalized, bytes, error: '' };
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--add';
@@ -27,7 +37,7 @@ export function render({ navigate }) {
renderHeader({
title: 'Создать канал',
leftAction: { label: '<', onClick: () => navigate('channels-list') },
})
}),
);
const form = document.createElement('form');
@@ -35,9 +45,17 @@ export function render({ navigate }) {
form.innerHTML = `
<strong class="channel-head-title">Создание канала</strong>
<p class="channel-head-meta">Можно использовать кириллицу, латиницу, цифры, пробел, _ и -.</p>
<p class="channel-head-meta">Длина: от 3 до 32 символов. Название уникально во всей системе.</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-name-error" class="meta-muted inline-error"></div>
<label for="channel-description">Описание канала (необязательно)</label>
<textarea id="channel-description" class="input" rows="4" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea>
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div>
<div id="channel-description-error" class="meta-muted inline-error"></div>
<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>
@@ -45,7 +63,11 @@ export function render({ navigate }) {
</div>
`;
const inputEl = form.querySelector('#channel-name');
const nameEl = form.querySelector('#channel-name');
const descriptionEl = form.querySelector('#channel-description');
const nameErrorEl = form.querySelector('#channel-name-error');
const descriptionErrorEl = form.querySelector('#channel-description-error');
const descriptionCounterEl = form.querySelector('#channel-description-counter');
const errorEl = form.querySelector('#channel-create-error');
const submitEl = form.querySelector('#submit-create-channel');
const cancelEl = form.querySelector('#cancel-create-channel');
@@ -56,24 +78,33 @@ export function render({ navigate }) {
submitInFlight = !!busy;
submitEl.disabled = submitInFlight;
cancelEl.disabled = submitInFlight;
inputEl.disabled = submitInFlight;
nameEl.disabled = submitInFlight;
descriptionEl.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;
const nameCheck = validateChannelDisplayName(nameEl.value);
const descriptionCheck = validateDescription(descriptionEl.value);
nameErrorEl.textContent = nameCheck.ok ? '' : channelNameErrorText(nameCheck.code);
descriptionErrorEl.textContent = descriptionCheck.error;
const descLength = Number(descriptionCheck.bytes || 0);
descriptionCounterEl.textContent = `${descLength} / 200 байт`;
const ok = nameCheck.ok && descriptionCheck.ok;
submitEl.disabled = submitInFlight || !ok;
return {
ok,
name: nameCheck.normalized,
description: descriptionCheck.normalized,
};
};
inputEl.addEventListener('input', () => {
updateValidation();
});
nameEl.addEventListener('input', updateValidation);
descriptionEl.addEventListener('input', updateValidation);
form.addEventListener('submit', async (event) => {
event.preventDefault();
@@ -93,31 +124,30 @@ export function render({ navigate }) {
errorEl.textContent = '';
try {
const channelName = normalizeChannelDisplayName(check.normalized);
await authService.addBlockCreateChannel({
const created = await authService.addBlockCreateChannel({
login,
storagePwd,
channelName,
channelName: normalizeChannelDisplayName(check.name),
channelDescription: normalizeChannelDescription(check.description),
});
persistCreateSuccessFlash(`Канал "${channelName}" создан.`);
const baseMessage = `Канал "${normalizeChannelDisplayName(check.name)}" создан.`;
const successMessage = created?.usedLegacyDescriptionFallback
? `${baseMessage} Описание не сохранено: на текущем сервере включен legacy-формат create-channel.`
: baseMessage;
persistCreateSuccessFlash(successMessage);
navigate('channels-list');
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось создать канал.');
setBusy(false);
const checkAfterError = validateChannelDisplayName(inputEl.value);
submitEl.disabled = submitInFlight || !checkAfterError.ok;
updateValidation();
}
});
cancelEl.addEventListener('click', () => {
navigate('channels-list');
});
cancelEl.addEventListener('click', () => navigate('channels-list'));
screen.append(form);
if (inputEl) {
inputEl.focus();
updateValidation();
}
nameEl.focus();
updateValidation();
return screen;
}
+129 -48
View File
@@ -2,10 +2,17 @@
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
import { captureClientError } from '../services/client-error-reporter.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
animatePress,
createSkeletonCard,
showToast,
softHaptic,
} from '../services/channels-ux.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
const pendingReactionActions = new Set();
const pendingThreadScroll = new Map();
function logThreadRuntimeError(stage, error, context = {}) {
const message = String(error?.message || error || 'thread runtime error');
@@ -14,10 +21,7 @@ function logThreadRuntimeError(stage, error, context = {}) {
kind: 'channels_thread_runtime',
message,
stack: error?.stack || '',
context: {
stage,
...context,
},
context: { stage, ...context },
});
}
@@ -51,6 +55,14 @@ function makeReactionActionKey(messageRef) {
return `${login}|${blockchainName}|${blockNumber}|${blockHash}`;
}
function messageRefKey(messageRef) {
const blockchainName = String(messageRef?.blockchainName || '').trim();
const blockNumber = Number(messageRef?.blockNumber);
const blockHash = normalizeMessageHash(messageRef?.blockHash);
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
return `${blockchainName}:${blockNumber}:${blockHash}`;
}
function parseThreadSelector(route) {
const params = route?.params || {};
const blockNumber = toSafeInt(params.messageBlockNumber);
@@ -161,22 +173,38 @@ function openReplyModal({ onSubmit }) {
const textEl = root.querySelector('#thread-reply-text');
const errorEl = root.querySelector('#thread-reply-error');
const submitEl = root.querySelector('#thread-reply-submit');
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
const close = () => {
root.innerHTML = '';
};
root.querySelector('#thread-reply-cancel').addEventListener('click', close);
root.querySelector('#thread-reply-submit').addEventListener('click', async () => {
root.querySelector('#thread-reply-cancel')?.addEventListener('click', close);
root.querySelector('#thread-reply-submit')?.addEventListener('click', async () => {
if (inFlight) return;
const text = String(textEl?.value || '').trim();
if (!text) {
errorEl.textContent = 'Введите текст ответа.';
return;
}
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit(text);
close();
} catch (error) {
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
}
});
@@ -184,32 +212,48 @@ function openReplyModal({ onSubmit }) {
if (textEl) textEl.focus();
}
function renderNodeCard(node, heading, handlers) {
function renderNodeCard(node, heading, handlers, localNumber) {
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 headingEl = document.createElement('strong');
headingEl.className = 'thread-node-heading';
headingEl.textContent = heading;
const meta = document.createElement('p');
meta.className = 'thread-node-meta';
meta.innerHTML = `
<span class="author-line-login">${author}</span>
<span class="author-line-num">· #${localNumber}</span>
`;
const body = document.createElement('p');
body.className = 'thread-node-body';
body.textContent = text;
const stats = document.createElement('p');
stats.className = 'thread-node-stats';
stats.textContent = `Лайки: ${likes}, ответы: ${replies}, версий: ${versions}`;
card.append(headingEl, meta, body, stats);
const target = buildTargetFromNode(node);
if (!target || !handlers) return card;
const refKey = messageRefKey(target);
if (refKey) card.dataset.messageKey = refKey;
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');
@@ -221,8 +265,11 @@ function renderNodeCard(node, heading, handlers) {
if (isLiked) likeButton.classList.add('is-liked');
likeButton.textContent = isPending ? 'Выполняется...' : (isLiked ? 'Убрать лайк' : 'Лайк');
likeButton.disabled = isPending;
likeButton.addEventListener('click', async () => {
likeButton.addEventListener('click', async (event) => {
animatePress(event.currentTarget);
if (isPending) return;
likeButton.disabled = true;
likeButton.textContent = 'Выполняется...';
try {
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
} catch (error) {
@@ -239,7 +286,8 @@ function renderNodeCard(node, heading, handlers) {
replyButton.type = 'button';
replyButton.className = 'secondary-btn thread-reply-btn';
replyButton.textContent = 'Ответить';
replyButton.addEventListener('click', () => {
replyButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
openReplyModal({
onSubmit: async (textValue) => handlers.onReply(target, textValue),
});
@@ -250,20 +298,21 @@ function renderNodeCard(node, heading, handlers) {
return card;
}
function renderDescendants(items, handlers, depth = 0) {
function renderDescendants(items, handlers, nextNumber, 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);
const nodeNumber = nextNumber();
const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, handlers, nodeNumber);
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));
wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1));
}
} catch (error) {
logThreadRuntimeError('render_descendants_branch', error, { depth, index });
@@ -273,10 +322,44 @@ function renderDescendants(items, handlers, depth = 0) {
return wrap;
}
function applyPendingScroll(screen, routeKey) {
const target = pendingThreadScroll.get(routeKey);
if (!target) return;
const doScroll = () => {
if (target === '__LAST_REPLY__') {
const cards = screen.querySelectorAll('.thread-block--replies [data-message-key]');
const last = cards[cards.length - 1];
if (last) {
last.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
pendingThreadScroll.delete(routeKey);
return;
}
const node = screen.querySelector(`[data-message-key="${target}"]`);
if (node) {
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
pendingThreadScroll.delete(routeKey);
}
};
setTimeout(doScroll, 20);
}
function renderSkeleton(screen) {
const wrap = document.createElement('div');
wrap.className = 'stack';
wrap.append(createSkeletonCard(), createSkeletonCard(), createSkeletonCard());
screen.append(wrap);
return wrap;
}
export function render({ navigate, route }) {
const selector = parseThreadSelector(route);
const backRoute = buildBackRoute(selector);
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--thread';
@@ -300,9 +383,7 @@ export function render({ navigate, route }) {
const next = render({ navigate, route });
current.replaceWith(next);
} catch (error) {
logThreadRuntimeError('rerender', error, {
routeHash: window.location.hash,
});
logThreadRuntimeError('rerender', error, { routeHash: window.location.hash });
}
};
@@ -323,19 +404,16 @@ export function render({ navigate, route }) {
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;
const previousReaction = getMessageReactionState(target);
const nextReaction = action === 'unlike' ? 'unliked' : 'liked';
pendingReactionActions.add(actionKey);
rerender();
try {
const { login, storagePwd } = requireSigningSession();
if (action === 'unlike') {
@@ -343,24 +421,24 @@ export function render({ navigate, route }) {
} else {
await authService.addBlockLike({ login, storagePwd, message: target });
}
await rereadThread();
showStatus('');
setMessageReactionState(target, nextReaction);
softHaptic(10);
rerender();
} catch (error) {
logThreadRuntimeError('toggle_like', error, {
action,
targetBlockchainName: target?.blockchainName || '',
targetBlockNumber: target?.blockNumber,
});
setMessageReactionState(target, previousReaction || 'unliked');
rerender();
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();
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
softHaptic(15);
showToast('Ответ отправлен');
showStatus('');
rerender();
},
@@ -376,7 +454,7 @@ export function render({ navigate, route }) {
renderHeader({
title: 'Тред',
leftAction: { label: '<', onClick: () => navigate(backRoute) },
})
}),
);
screen.append(userIndicator, channelIndicator, statusBox);
@@ -388,15 +466,12 @@ export function render({ navigate, route }) {
return screen;
}
const loading = document.createElement('div');
loading.className = 'card meta-muted';
loading.textContent = 'Загрузка треда...';
screen.append(loading);
const skeleton = renderSkeleton(screen);
(async () => {
try {
const payload = await authService.getMessageThread(selector.message, 20, 2, 50, state.session.login);
loading.remove();
skeleton.remove();
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
const focus = payload?.focus || null;
@@ -407,6 +482,12 @@ export function render({ navigate, route }) {
summary.textContent = `Предки: ${ancestors.length}, ответы: ${descendants.length}`;
screen.append(summary);
let seq = 0;
const nextNumber = () => {
seq += 1;
return seq;
};
if (ancestors.length) {
const ancestorsWrap = document.createElement('div');
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
@@ -415,7 +496,7 @@ export function render({ navigate, route }) {
title.textContent = 'Предыдущие сообщения';
ancestorsWrap.append(title);
ancestors.forEach((node, index) => {
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers));
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber()));
});
screen.append(ancestorsWrap);
}
@@ -426,7 +507,7 @@ export function render({ navigate, route }) {
const title = document.createElement('h3');
title.className = 'section-title';
title.textContent = 'Текущее сообщение';
focusWrap.append(title, renderNodeCard(focus, 'Выбранное сообщение', handlers));
focusWrap.append(title, renderNodeCard(focus, 'Выбранное сообщение', handlers, nextNumber()));
screen.append(focusWrap);
}
@@ -438,7 +519,7 @@ export function render({ navigate, route }) {
descendantsWrap.append(descendantsTitle);
if (descendants.length) {
descendantsWrap.append(renderDescendants(descendants, handlers));
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
} else {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
@@ -447,8 +528,9 @@ export function render({ navigate, route }) {
}
screen.append(descendantsWrap);
applyPendingScroll(screen, routeKey);
} catch (error) {
loading.remove();
skeleton.remove();
const failed = document.createElement('div');
failed.className = 'card meta-muted';
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
@@ -458,4 +540,3 @@ export function render({ navigate, route }) {
return screen;
}
+531 -220
View File
@@ -1,19 +1,22 @@
import { renderHeader } from '../components/header.js';
import { channelPosts, channels } from '../mock-data.js';
import { renderHeader } from '../components/header.js';
import {
addLocalChannelPost,
authService,
getLocalChannelPosts,
getMessageReactionState,
setMessageReactionState,
state,
} from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
animatePress,
createSkeletonCard,
showToast,
softHaptic,
} from '../services/channels-ux.js';
export const pageMeta = { id: 'channel-view', title: 'Канал' };
const ZERO64 = '0'.repeat(64);
const pendingReactionActions = new Set();
const pendingScrollByRoute = new Map();
function isChannelsDemoMode() {
try {
@@ -55,6 +58,49 @@ function makeReactionActionKey(messageRef) {
return `${login}|${blockchainName}|${blockNumber}|${blockHash}`;
}
function messageRefKey(messageRef) {
const blockchainName = String(messageRef?.blockchainName || '').trim();
const blockNumber = Number(messageRef?.blockNumber);
const blockHash = normalizeMessageHash(messageRef?.blockHash);
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
return `${blockchainName}:${blockNumber}:${blockHash}`;
}
function channelDescriptionParamKey(selector) {
const owner = String(selector?.ownerBlockchainName || '').trim();
const rootNo = Number(selector?.channelRootBlockNumber);
const rootHash = normalizeRouteHash(selector?.channelRootBlockHash);
if (!owner || !Number.isFinite(rootNo)) return '';
return `channel_desc:${owner}:${rootNo}:${rootHash}`;
}
function parseDescriptionOverride(payload) {
if (!payload || typeof payload !== 'object') {
return { hasOverride: false, description: '' };
}
const rawValue = String(payload?.value ?? payload?.param_value ?? '').trim();
if (!rawValue && !Number(payload?.time_ms || payload?.timeMs || 0)) {
return { hasOverride: false, description: '' };
}
if (!rawValue) {
return { hasOverride: true, description: '' };
}
try {
const parsed = JSON.parse(rawValue);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const value = typeof parsed.v === 'string' ? parsed.v : '';
return { hasOverride: true, description: value.trim() };
}
} catch {
// legacy raw string value
}
return { hasOverride: true, description: rawValue };
}
function buildSelectorFromRoute(route, channelId) {
const params = route?.params || {};
@@ -78,13 +124,6 @@ function buildSelectorFromRoute(route, channelId) {
};
}
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 [
@@ -126,54 +165,22 @@ function resolveMessageText(message) {
);
}
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;
function openAboutChannelModal(channel) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="about-channel-modal">
<div class="modal-card stack">
<h3 class="modal-title">О канале</h3>
<p><strong>${channel.displayName || channel.name}</strong></p>
<p class="meta-muted">${channel.description || 'Описание не задано.'}</p>
<button class="secondary-btn" id="about-channel-close" type="button">Закрыть</button>
</div>
</div>
`;
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 fallback = channels[0] || {
id: 'ch0',
name: 'Неизвестный канал',
description: 'Описание отсутствует',
ownerName: 'неизвестно',
ownerLogin: '',
displayName: 'неизвестно/Неизвестный канал',
};
const channel = channels.find((c) => c.id === channelId) || fallback;
return {
channel,
posts: [
...(channelPosts[channel.id] || []).map((post) => ({ title: post.title, body: post.body })),
...getLocalChannelPosts(channelId),
],
isOwnChannel: channel.ownerLogin === '@shine.alex',
selector: null,
localKey: channelId,
};
root.querySelector('#about-channel-close')?.addEventListener('click', () => {
root.innerHTML = '';
});
}
function openReplyModal({ onSubmit }) {
@@ -194,22 +201,38 @@ function openReplyModal({ onSubmit }) {
const textEl = root.querySelector('#reply-text');
const errorEl = root.querySelector('#reply-error');
const submitEl = root.querySelector('#reply-submit');
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
const close = () => {
root.innerHTML = '';
};
root.querySelector('#reply-cancel').addEventListener('click', close);
root.querySelector('#reply-submit').addEventListener('click', async () => {
root.querySelector('#reply-cancel')?.addEventListener('click', close);
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
const text = String(textEl?.value || '').trim();
if (!text) {
errorEl.textContent = 'Введите текст ответа.';
return;
}
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit(text);
close();
} catch (error) {
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
}
});
@@ -223,7 +246,7 @@ function openAddMessageModal({ channelName, onSubmit }) {
<div class="modal" id="channel-message-modal">
<div class="modal-card stack">
<h3 class="modal-title">Новое сообщение в канале</h3>
<p class="meta-muted"># ${channelName}</p>
<p class="meta-muted">${channelName}</p>
<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">
@@ -236,25 +259,38 @@ function openAddMessageModal({ channelName, onSubmit }) {
const textEl = root.querySelector('#channel-message-text');
const errorEl = root.querySelector('#channel-message-error');
const submitEl = root.querySelector('#channel-message-submit');
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
const close = () => {
root.innerHTML = '';
};
root.querySelector('#channel-message-cancel').addEventListener('click', close);
root.querySelector('#channel-message-submit').addEventListener('click', async () => {
root.querySelector('#channel-message-cancel')?.addEventListener('click', close);
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
const body = String(textEl?.value || '').trim();
if (!body) {
errorEl.textContent = 'Введите текст сообщения.';
return;
}
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit({
title: `${state.session.login || 'вы'} - сейчас`,
body,
});
await onSubmit(body);
close();
} catch (error) {
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.');
}
});
@@ -262,114 +298,116 @@ function openAddMessageModal({ channelName, onSubmit }) {
if (textEl) textEl.focus();
}
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 channel-head-card';
head.innerHTML = `
<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>
function openEditDescriptionModal({ initialValue = '', onSubmit }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-edit-description-modal">
<div class="modal-card stack">
<h3 class="modal-title">Описание канала</h3>
<textarea id="channel-description-text" class="input" rows="5" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea>
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div>
<div class="meta-muted inline-error" id="channel-description-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-description-cancel" type="button">Отмена</button>
<button class="primary-btn" id="channel-description-submit" type="button">Сохранить</button>
</div>
</div>
</div>
`;
const actionButton = document.createElement('button');
actionButton.className = channelData.isOwnChannel
? 'primary-btn channel-main-action'
: 'destructive-btn channel-main-action';
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Отписаться от канала';
let followLimit = null;
const textEl = root.querySelector('#channel-description-text');
const counterEl = root.querySelector('#channel-description-counter');
const errorEl = root.querySelector('#channel-description-error');
const submitEl = root.querySelector('#channel-description-submit');
const cancelEl = root.querySelector('#channel-description-cancel');
const feed = document.createElement('div');
feed.className = 'stack channel-feed';
let inFlight = false;
channelData.posts.forEach((post) => {
feed.append(renderPostCard(post, {
navigate,
selector: channelData.selector,
onToggleLike: handlers.onToggleLike,
onReply: handlers.onReply,
}));
const compute = () => {
const value = String(textEl?.value || '').replace(/\s+/g, ' ').trim();
const bytes = new TextEncoder().encode(value).length;
const ok = bytes <= 200;
return {
value,
bytes,
ok,
error: ok ? '' : 'Описание слишком длинное: максимум 200 байт UTF-8.',
};
};
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
cancelEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
submitEl.textContent = inFlight ? 'Сохраняем...' : 'Сохранить';
};
const close = () => {
root.innerHTML = '';
};
const updateValidation = () => {
const check = compute();
counterEl.textContent = `${check.bytes} / 200 байт`;
errorEl.textContent = check.error;
submitEl.disabled = inFlight || !check.ok;
return check;
};
cancelEl?.addEventListener('click', close);
textEl?.addEventListener('input', updateValidation);
submitEl?.addEventListener('click', async () => {
if (inFlight) return;
const check = updateValidation();
if (!check.ok) return;
setBusy(true);
errorEl.textContent = '';
try {
await onSubmit(check.value);
close();
} catch (error) {
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось сохранить описание.');
}
});
if (channelData.isOwnChannel) {
actionButton.addEventListener('click', () => {
openAddMessageModal({
channelName: channelData.channel.name,
onSubmit: async (post) => handlers.onAddPost(post),
});
});
} else {
followLimit = document.createElement('p');
followLimit.className = 'channel-note';
followLimit.textContent = 'Отписка удаляет только эту подписку на канал.';
actionButton.addEventListener('click', handlers.onUnfollowChannel);
if (textEl) {
textEl.value = String(initialValue || '');
textEl.focus();
}
updateValidation();
}
function mapApiMessageToPost(message, selector, localNumber) {
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');
}
const backButton = document.createElement('button');
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);
return {
localNumber,
authorLogin: message?.authorLogin || 'автор',
body: resolvedText || '(пусто)',
likesCount: Number(message?.likesCount || 0),
repliesCount: Number(message?.repliesCount || 0),
messageRef,
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
};
}
async function loadFromApi(route, channelId) {
@@ -379,23 +417,36 @@ async function loadFromApi(route, channelId) {
}
const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login);
const localKey = localPostsKey(selector, channelId);
const posts = [
...(payload.messages || []).map((message) => mapApiMessageToPost(message, selector)),
...getLocalChannelPosts(localKey),
];
const messages = Array.isArray(payload.messages) ? payload.messages : [];
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
const readDescription = async () => {
const sourceDescription = String(payload.channel?.channelDescription || '').trim();
const paramKey = channelDescriptionParamKey(selector);
if (!ownerLogin || !paramKey) return sourceDescription;
try {
const paramPayload = await authService.getUserParam(ownerLogin, paramKey);
const override = parseDescriptionOverride(paramPayload);
return override.hasOverride ? override.description : sourceDescription;
} catch {
return sourceDescription;
}
};
const resolvedDescription = await readDescription();
return {
channel: {
name: payload.channel?.channelName || 'неизвестный канал',
displayName: `${payload.channel?.ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
description: `bch=${payload.channel?.ownerBlockchainName || selector.ownerBlockchainName}`,
ownerName: payload.channel?.ownerLogin || 'неизвестно',
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
description: resolvedDescription,
ownerName: ownerLogin || 'неизвестно',
},
posts,
isOwnChannel: (payload.channel?.ownerLogin || '').toLowerCase() === (state.session.login || '').toLowerCase(),
isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(),
selector,
localKey,
};
}
@@ -423,39 +474,261 @@ function renderLoadError(screen, navigate, message, onRetry) {
screen.append(card);
}
function renderDemoFallback(screen, navigate, channelId, error) {
function renderDemoFallback(screen, navigate, error) {
const info = document.createElement('div');
info.className = 'card stack';
info.innerHTML = `
<strong>Включен демо-режим</strong>
<p class="meta-muted">Данные канала с сервера недоступны. Показан моканал, потому что включен channelsDemo.</p>
<p class="meta-muted">Данные канала с сервера недоступны. Показан демо-контент.</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: () => {},
const back = document.createElement('button');
back.className = 'secondary-btn';
back.textContent = 'Назад к каналам';
back.addEventListener('click', () => navigate('channels-list'));
screen.append(back);
}
function applyPendingScroll(screen, routeKey) {
const target = pendingScrollByRoute.get(routeKey);
if (!target) return;
const doScroll = () => {
if (target === '__LAST__') {
const cards = screen.querySelectorAll('[data-message-key]');
const last = cards[cards.length - 1];
if (last) {
last.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
pendingScrollByRoute.delete(routeKey);
return;
}
const element = screen.querySelector(`[data-message-key="${target}"]`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
pendingScrollByRoute.delete(routeKey);
}
};
setTimeout(doScroll, 20);
}
function renderPostCard(post, { navigate, selector, onToggleLike, onReply }) {
const card = document.createElement('article');
card.className = 'card stack channel-message-card';
const title = document.createElement('div');
title.className = 'channel-message-title author-line';
const loginEl = document.createElement('span');
loginEl.className = 'author-line-login';
loginEl.textContent = post.authorLogin;
const numberEl = document.createElement('span');
numberEl.className = 'author-line-num';
numberEl.textContent = `· #${post.localNumber}`;
title.append(loginEl, numberEl);
const body = document.createElement('p');
body.className = 'channel-message-body';
body.textContent = post.body;
const stats = document.createElement('p');
stats.className = 'channel-message-stats';
stats.textContent = `Лайки: ${post.likesCount || 0}, ответы: ${post.repliesCount || 0}`;
card.append(title, body, stats);
const refKey = messageRefKey(post.messageRef);
if (refKey) {
card.dataset.messageKey = refKey;
}
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 (event) => {
animatePress(event.currentTarget);
if (isPending) return;
likeButton.disabled = true;
likeButton.textContent = 'Выполняется...';
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
});
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'secondary-btn channel-action-reply';
replyButton.textContent = 'Ответить';
replyButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
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', (event) => {
animatePress(event.currentTarget);
const route = buildThreadRoute(post.messageRef, selector);
if (route) navigate(route);
});
actions.append(likeButton, replyButton, openThreadButton);
card.append(actions);
return card;
}
function renderBody(screen, navigate, routeKey, channelData, handlers) {
const head = document.createElement('div');
head.className = 'card channel-head-card';
const title = document.createElement('strong');
title.className = 'channel-head-title';
title.textContent = channelData.channel.displayName || channelData.channel.name;
const description = document.createElement('p');
description.className = 'channel-head-description';
const hasDescription = !!String(channelData.channel.description || '').trim();
if (hasDescription) {
description.textContent = channelData.channel.description;
} else if (channelData.isOwnChannel) {
description.textContent = 'Описание пока не задано.';
}
const owner = document.createElement('p');
owner.className = 'channel-head-meta';
owner.textContent = `Владелец: ${channelData.channel.ownerName}`;
const headActions = document.createElement('div');
headActions.className = 'row';
const aboutButton = document.createElement('button');
aboutButton.type = 'button';
aboutButton.className = 'secondary-btn small-btn';
aboutButton.textContent = 'О канале';
aboutButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
openAboutChannelModal(channelData.channel);
});
headActions.append(aboutButton);
if (channelData.isOwnChannel) {
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.className = 'secondary-btn small-btn';
editButton.textContent = '✎';
editButton.title = 'Редактировать описание';
editButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
openEditDescriptionModal({
initialValue: channelData.channel.description || '',
onSubmit: async (nextValue) => handlers.onEditDescription(nextValue),
});
});
headActions.append(editButton);
}
if (hasDescription) {
const moreButton = document.createElement('button');
moreButton.type = 'button';
moreButton.className = 'channel-head-more';
moreButton.textContent = 'ещё';
moreButton.addEventListener('click', () => {
description.classList.toggle('is-expanded');
moreButton.textContent = description.classList.contains('is-expanded') ? 'скрыть' : 'ещё';
});
headActions.append(moreButton);
}
head.append(title);
if (hasDescription || channelData.isOwnChannel) head.append(description);
head.append(owner, headActions);
const actionButton = document.createElement('button');
actionButton.className = channelData.isOwnChannel
? 'primary-btn channel-main-action'
: 'destructive-btn channel-main-action';
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Отписаться от канала';
const feed = document.createElement('div');
feed.className = 'stack channel-feed';
if (channelData.posts.length) {
channelData.posts.forEach((post) => {
feed.append(renderPostCard(post, {
navigate,
selector: channelData.selector,
onToggleLike: handlers.onToggleLike,
onReply: handlers.onReply,
}));
});
} else {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Ждем ваших начинаний';
feed.append(empty);
}
if (channelData.isOwnChannel) {
actionButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
openAddMessageModal({
channelName: channelData.channel.name,
onSubmit: async (bodyText) => handlers.onAddPost(bodyText),
});
});
} else {
actionButton.addEventListener('click', handlers.onUnfollowChannel);
}
const backButton = document.createElement('button');
backButton.className = 'secondary-btn channel-back-btn';
backButton.textContent = 'Назад к каналам';
backButton.addEventListener('click', () => navigate('channels-list'));
screen.append(head, actionButton, feed, backButton);
applyPendingScroll(screen, routeKey);
}
function renderSkeleton(screen) {
const wrap = document.createElement('div');
wrap.className = 'stack';
wrap.append(createSkeletonCard(), createSkeletonCard(), createSkeletonCard());
screen.append(wrap);
return wrap;
}
export function render({ navigate, route }) {
const channelId = route.params.channelId || '';
const routeSelector = buildSelectorFromRoute(route, channelId);
const routeKey = `${routeSelector?.ownerBlockchainName || ''}:${routeSelector?.channelRootBlockNumber || ''}:${routeSelector?.channelRootBlockHash || ''}`;
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--channel';
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 headerTitle = `Канал: ${titleFromIndexDisplay || titleFromRoute || 'Канал'}`;
const userIndicator = document.createElement('div');
userIndicator.className = 'card channels-user-chip';
@@ -465,13 +738,6 @@ export function render({ navigate, route }) {
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';
@@ -482,6 +748,13 @@ export function render({ navigate, route }) {
statusBox.style.display = '';
};
const rerender = () => {
const current = document.querySelector('section.channels-screen--channel');
if (!current) return;
const next = render({ navigate, route });
current.replaceWith(next);
};
const requireSigningSession = () => {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
@@ -491,10 +764,6 @@ export function render({ navigate, route }) {
return { login, storagePwd };
};
const rereadChannel = async () => {
await loadFromApi(route, channelId);
};
const onToggleLike = async (messageRef, action) => {
const actionKey = makeReactionActionKey(messageRef);
if (!actionKey) {
@@ -502,8 +771,10 @@ export function render({ navigate, route }) {
}
if (pendingReactionActions.has(actionKey)) return;
const previousReaction = getMessageReactionState(messageRef);
const nextReaction = action === 'unlike' ? 'unliked' : 'liked';
pendingReactionActions.add(actionKey);
rerender();
try {
const { login, storagePwd } = requireSigningSession();
if (action === 'unlike') {
@@ -511,22 +782,31 @@ export function render({ navigate, route }) {
} else {
await authService.addBlockLike({ login, storagePwd, message: messageRef });
}
await rereadChannel();
showStatus('');
setMessageReactionState(messageRef, nextReaction);
softHaptic(10);
rerender();
} catch (error) {
setMessageReactionState(messageRef, previousReaction || 'unliked');
rerender();
throw error;
} finally {
pendingReactionActions.delete(actionKey);
rerender();
}
};
const onReply = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockReply({ login, storagePwd, message: messageRef, text });
await rereadChannel();
const scrollTarget = messageRefKey(messageRef);
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
softHaptic(15);
showToast('Ответ отправлен');
rerender();
};
const onAddPost = async (post) => {
const onAddPost = async (bodyText) => {
const { login, storagePwd } = requireSigningSession();
if (!routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) {
throw new Error('Идентификатор канала не готов.');
@@ -536,9 +816,31 @@ export function render({ navigate, route }) {
login,
storagePwd,
channel: routeSelector,
text: post?.body || '',
text: bodyText,
});
await rereadChannel();
pendingScrollByRoute.set(routeKey, '__LAST__');
softHaptic(15);
showToast('Сообщение отправлено');
rerender();
};
const onEditDescription = async (descriptionText) => {
const { login, storagePwd } = requireSigningSession();
const selector = routeSelector;
const param = channelDescriptionParamKey(selector);
if (!param) throw new Error('Идентификатор канала не готов для обновления описания.');
const value = JSON.stringify({ v: String(descriptionText || '').trim() });
await authService.addBlockUserParam({
login,
storagePwd,
param,
value,
});
softHaptic(10);
showToast('Описание канала сохранено');
rerender();
};
@@ -546,23 +848,21 @@ export function render({ navigate, route }) {
renderHeader({
title: headerTitle,
leftAction: { label: '<', onClick: () => navigate('channels-list') },
})
}),
);
screen.append(userIndicator, statusBox);
const loading = document.createElement('div');
loading.className = 'card meta-muted';
loading.textContent = 'Загрузка канала...';
screen.append(loading);
const skeleton = renderSkeleton(screen);
(async () => {
try {
const apiData = await loadFromApi(route, channelId);
loading.remove();
renderBody(screen, navigate, apiData, {
skeleton.remove();
renderBody(screen, navigate, routeKey, apiData, {
onToggleLike: async (messageRef, action) => {
try {
await onToggleLike(messageRef, action);
showStatus('');
} catch (error) {
showStatus(toUserMessage(error, action === 'unlike' ? 'Не удалось убрать лайк.' : 'Не удалось поставить лайк.'));
}
@@ -575,15 +875,24 @@ export function render({ navigate, route }) {
throw new Error(toUserMessage(error, 'Не удалось отправить ответ.'));
}
},
onAddPost: async (post) => {
onAddPost: async (bodyText) => {
try {
await onAddPost(post);
await onAddPost(bodyText);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
}
},
onUnfollowChannel: async () => {
onEditDescription: async (descriptionText) => {
try {
await onEditDescription(descriptionText);
showStatus('');
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось сохранить описание.'));
}
},
onUnfollowChannel: async (event) => {
animatePress(event?.currentTarget);
try {
const { login, storagePwd } = requireSigningSession();
if (!apiData.selector) throw new Error('Не удалось определить канал для отписки.');
@@ -597,6 +906,8 @@ export function render({ navigate, route }) {
unfollow: true,
});
softHaptic(15);
showToast('Отписка от канала выполнена');
navigate('channels-list');
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
@@ -604,9 +915,9 @@ export function render({ navigate, route }) {
},
});
} catch (error) {
loading.remove();
skeleton.remove();
if (isChannelsDemoMode()) {
renderDemoFallback(screen, navigate, channelId, error);
renderDemoFallback(screen, navigate, error);
return;
}
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), rerender);
File diff suppressed because it is too large Load Diff
+4 -26
View File
@@ -1,8 +1,10 @@
import { clearStartHint, state } from '../state.js';
import { clearStartHint } from '../state.js';
export const pageMeta = { id: 'start-view', title: 'Старт', showAppChrome: false };
export function render({ navigate }) {
clearStartHint();
const screen = document.createElement('section');
screen.className = 'auth-screen stack';
@@ -37,30 +39,6 @@ export function render({ navigate }) {
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
actions.append(loginButton, registerButton, settingsButton);
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';
notice.textContent = state.startHint;
screen.append(notice);
clearStartHint();
}
screen.append(actions);
screen.append(logo, title, actions);
return screen;
}
+94 -16
View File
@@ -40,6 +40,7 @@ const MSG_SUBTYPE_REACTION_LIKE = 1;
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
const CREATE_CHANNEL_BODY_VERSION = 2;
function normalizeServerUrl(url) {
const value = (url || '').trim();
@@ -77,6 +78,17 @@ function opError(op, response) {
return error;
}
function isLegacyCreateChannelFormatError(error) {
const code = String(error?.code || '').trim().toUpperCase();
const text = String(error?.message || '').toLowerCase();
if (code === 'BAD_BLOCK_FORMAT') return true;
return (
text.includes('unknown body type/version') ||
text.includes('unknown tech body type/version/subtype') ||
text.includes('bad_block_format')
);
}
function makeClientInfo() {
const ua = navigator.userAgent || 'unknown';
return ua.slice(0, 50);
@@ -267,6 +279,50 @@ function makeCreateChannelBodyBytes({ lineCode, prevLineNumber, prevLineHashHex,
);
}
function normalizeChannelDescription(value) {
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
const bytes = utf8Bytes(text);
if (bytes.length > 200) {
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
}
return text;
}
function makeCreateChannelBodyV2Bytes({
lineCode,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName,
channelDescription = '',
}) {
const check = validateChannelDisplayName(channelName);
if (!check.ok) throw new Error(channelNameErrorText(check.code));
const cleanName = check.normalized;
const cleanDescription = normalizeChannelDescription(channelDescription);
const nameBytes = utf8Bytes(cleanName);
if (nameBytes.length < 1 || nameBytes.length > 255) {
throw new Error('Channel name must be 1..255 bytes');
}
const descriptionBytes = utf8Bytes(cleanDescription);
if (descriptionBytes.length > 200) {
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
}
return concatBytes(
int32Bytes(lineCode),
int32Bytes(prevLineNumber),
hexToBytes(normalizeHex32(prevLineHashHex)),
int32Bytes(thisLineNumber),
int8Byte(nameBytes.length),
nameBytes,
int16Bytes(descriptionBytes.length),
descriptionBytes,
);
}
function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
const message = String(text || '').trim();
if (!message) throw new Error('Message text is required');
@@ -828,7 +884,7 @@ export class AuthService {
.filter((item) => Number.isFinite(item.rootBlockNumber) && item.rootBlockNumber >= 0);
}
async addBlockCreateChannel({ login, channelName, storagePwd }) {
async addBlockCreateChannel({ login, channelName, channelDescription = '', storagePwd }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
@@ -866,25 +922,47 @@ export class AuthService {
thisLineNumber = createdChannels.length + 1;
}
const bodyBytes = makeCreateChannelBodyBytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
});
const submitCreate = async (useV2) => {
const bodyBytes = useV2
? makeCreateChannelBodyV2Bytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
channelDescription,
})
: makeCreateChannelBodyBytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
});
const payload = await this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: 1,
bodyBytes,
});
return this.addBlockSigned({
login: cleanLogin,
storagePwd,
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: useV2 ? CREATE_CHANNEL_BODY_VERSION : 1,
bodyBytes,
});
};
let payload;
let usedLegacyDescriptionFallback = false;
try {
payload = await submitCreate(true);
} catch (error) {
if (!isLegacyCreateChannelFormatError(error)) throw error;
payload = await submitCreate(false);
usedLegacyDescriptionFallback = true;
}
return {
...payload,
usedLegacyDescriptionFallback,
channel: {
ownerBlockchainName: blockchainName,
channelRootBlockNumber: Number(payload?.serverLastGlobalNumber),
+12 -1
View File
@@ -7,6 +7,11 @@ export function normalizeChannelDisplayName(value) {
return String(value).trim().replace(/\s+/g, ' ');
}
export function normalizeChannelDescription(value) {
if (value == null) return '';
return String(value).trim().replace(/\s+/g, ' ');
}
export function toCanonicalChannelSlug(value) {
const normalized = normalizeChannelDisplayName(value);
if (!normalized) return '';
@@ -76,4 +81,10 @@ export function channelNameErrorText(code) {
}
}
export function channelDescriptionErrorText(value) {
const normalized = normalizeChannelDescription(value);
if (new TextEncoder().encode(normalized).length > 200) {
return 'Описание слишком длинное: максимум 200 байт UTF-8.';
}
return '';
}
+170
View File
@@ -0,0 +1,170 @@
const TOAST_HOST_ID = 'shine-toast-host';
const rtf = (() => {
try {
return new Intl.RelativeTimeFormat('ru', { numeric: 'auto' });
} catch {
return null;
}
})();
function toNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function pickUnit(seconds) {
const abs = Math.abs(seconds);
if (abs < 60) return ['second', Math.round(seconds)];
const minutes = seconds / 60;
if (Math.abs(minutes) < 60) return ['minute', Math.round(minutes)];
const hours = minutes / 60;
if (Math.abs(hours) < 24) return ['hour', Math.round(hours)];
const days = hours / 24;
if (Math.abs(days) < 30) return ['day', Math.round(days)];
const months = days / 30;
if (Math.abs(months) < 12) return ['month', Math.round(months)];
const years = months / 12;
return ['year', Math.round(years)];
}
export function formatRelativeTime(timestampMs) {
const ts = toNumber(timestampMs);
if (!ts) return '—';
const now = Date.now();
const diffSeconds = (ts - now) / 1000;
const ageSeconds = now >= ts ? (now - ts) / 1000 : 0;
const ageHours = ageSeconds / 3600;
if (ageHours <= 10) {
const [unit, value] = pickUnit(diffSeconds);
if (rtf) return rtf.format(value, unit);
const absValue = Math.abs(value);
const suffix = value <= 0 ? 'назад' : 'через';
const labels = {
second: 'сек',
minute: 'мин',
hour: 'ч',
day: 'д',
month: 'мес',
year: 'г',
};
return `${suffix} ${absValue} ${labels[unit] || ''}`.trim();
}
try {
const dt = new Date(ts);
const nowDt = new Date(now);
const formatter = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
...(dt.getFullYear() !== nowDt.getFullYear() ? { year: 'numeric' } : {}),
hour: '2-digit',
minute: '2-digit',
});
return formatter.format(dt);
} catch {
return new Date(ts).toLocaleString();
}
}
function ensureToastHost() {
let host = document.getElementById(TOAST_HOST_ID);
if (host) return host;
host = document.createElement('div');
host.id = TOAST_HOST_ID;
host.className = 'toast-host';
document.body.append(host);
return host;
}
export function showToast(message, { kind = 'success', timeoutMs = 2500 } = {}) {
const text = String(message || '').trim();
if (!text) return;
const host = ensureToastHost();
const toast = document.createElement('div');
toast.className = `toast toast--${kind}`;
toast.textContent = text;
host.append(toast);
requestAnimationFrame(() => {
toast.classList.add('is-visible');
});
const hide = () => {
toast.classList.remove('is-visible');
toast.classList.add('is-hiding');
setTimeout(() => toast.remove(), 220);
};
setTimeout(hide, Math.max(1200, Number(timeoutMs) || 2500));
}
export function softHaptic(duration = 15) {
try {
if (navigator?.vibrate) navigator.vibrate(Math.max(5, Math.min(30, Number(duration) || 15)));
} catch {
// ignore
}
}
export function animatePress(el) {
if (!el) return;
el.classList.remove('is-springing');
// force reflow
// eslint-disable-next-line no-unused-expressions
el.offsetWidth;
el.classList.add('is-springing');
}
const CHANNEL_NOTIF_KEY = 'shine-channels-notify-v1';
export function readChannelNotificationsState() {
try {
const raw = localStorage.getItem(CHANNEL_NOTIF_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
} catch {
// ignore
}
return {};
}
export function writeChannelNotificationsState(nextState) {
try {
localStorage.setItem(CHANNEL_NOTIF_KEY, JSON.stringify(nextState || {}));
} catch {
// ignore
}
}
export function makeAuthorLabel(login, localNumber) {
const cleanLogin = String(login || 'автор');
const n = Number(localNumber);
if (!Number.isFinite(n) || n < 1) return cleanLogin;
return `${cleanLogin} · #${n}`;
}
export function createSkeletonCard(className = '') {
const card = document.createElement('div');
card.className = `card skeleton-card ${className}`.trim();
card.innerHTML = `
<div class="skeleton-line w-40"></div>
<div class="skeleton-line w-90"></div>
<div class="skeleton-line w-70"></div>
`;
return card;
}
export function normalizeChannelDescription(value) {
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
if (!text) return '';
const chars = Array.from(text);
if (chars.length <= 200) return text;
return chars.slice(0, 200).join('');
}