SHA256
channels ux cleanup and create-flow recovery
This commit is contained in:
+531
-220
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user