From 126b4ba3a121a6c2213aa5cf6776f501e3d84ccc49f21dbc0e3e887265658eab Mon Sep 17 00:00:00 2001 From: DrygMira Date: Tue, 14 Apr 2026 02:08:44 +0300 Subject: [PATCH] channels ux cleanup and create-flow recovery --- shine-UI/js/pages/add-channel-view.js | 86 +- shine-UI/js/pages/channel-thread-view.js | 177 +++- shine-UI/js/pages/channel-view.js | 751 ++++++++++---- shine-UI/js/pages/channels-list.js | 945 +++++++++++++----- shine-UI/js/pages/start-view.js | 30 +- shine-UI/js/services/auth-service.js | 110 +- shine-UI/js/services/channel-name-rules.js | 13 +- shine-UI/js/services/channels-ux.js | 170 ++++ shine-UI/styles/components.css | 376 +++++++ .../java/blockchain/BodyRecordParser.java | 40 +- .../blockchain/body/CreateChannelBody.java | 186 +++- .../java/shine/db/DatabaseInitializer.java | 1 + .../java/shine/db/SqliteDbController.java | 20 + .../shine/db/dao/ChannelNameStateDAO.java | 14 +- .../db/entities/ChannelNameStateEntry.java | 9 + .../blockchain/Net_AddBlock_Handler.java | 5 + .../ChannelNamesStateBootstrapper.java | 3 + .../channels/ChannelsReadSupport.java | 40 + .../Net_GetChannelMessages_Handler.java | 1 + .../Net_ListSubscriptionsFeed_Handler.java | 1 + .../Net_GetChannelMessages_Response.java | 4 + .../Net_ListSubscriptionsFeed_Response.java | 4 + 22 files changed, 2322 insertions(+), 664 deletions(-) create mode 100644 shine-UI/js/services/channels-ux.js diff --git a/shine-UI/js/pages/add-channel-view.js b/shine-UI/js/pages/add-channel-view.js index f63a509..0023b12 100644 --- a/shine-UI/js/pages/add-channel-view.js +++ b/shine-UI/js/pages/add-channel-view.js @@ -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 = ` Создание канала

Можно использовать кириллицу, латиницу, цифры, пробел, _ и -.

-

Длина: от 3 до 32 символов. Название уникально во всей системе.

+

Длина названия: от 3 до 32 символов. Название уникально во всей системе.

+ +
+ + + +
0 / 200 байт
+
+
@@ -45,7 +63,11 @@ export function render({ navigate }) {
`; - 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; } diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index 1807429..638083a 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -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 = ` - ${heading} -

${author} (${bch}) - #${blockNo}

-

${text}

-

Лайки: ${likes}, ответы: ${replies}, версий: ${versions}

+ 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 = ` + ${author} + · #${localNumber} `; + 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; } - diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 3c34a52..b3c4613 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -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 = ` + + `; - 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 }) {