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
+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);