From c0b0c99f53ec9b59c0027de9587b060138ed02249192b77c2d529a5e18cc1806 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Thu, 14 May 2026 12:46:22 +0300 Subject: [PATCH] =?UTF-8?q?UI:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20thread/counters,=20=D0=B2=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D0=B4=D0=BA=D1=83=20=D0=9A=D0=B0=D0=BD=D0=B0=D0=BB=D1=8B=20?= =?UTF-8?q?=D0=B8=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0=D1=80=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=D0=B0+=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-14_1236_thread-actions-counters-layout.md | 25 ++++++ ...nnels-tabs-find-and-view-subscribe-flow.md | 24 ++++++ VERSION.properties | 4 +- shine-UI/js/pages/channel-thread-view.js | 80 +++++-------------- shine-UI/js/pages/channel-view.js | 22 ++++- shine-UI/js/pages/channels-list.js | 65 +++++++++------ 6 files changed, 129 insertions(+), 91 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-05-14_1236_thread-actions-counters-layout.md create mode 100644 Dev_Docs/Pending_Features/2026-05-14_1243_channels-tabs-find-and-view-subscribe-flow.md diff --git a/Dev_Docs/Pending_Features/2026-05-14_1236_thread-actions-counters-layout.md b/Dev_Docs/Pending_Features/2026-05-14_1236_thread-actions-counters-layout.md new file mode 100644 index 0000000..2b36076 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-14_1236_thread-actions-counters-layout.md @@ -0,0 +1,25 @@ +# Thread: стабильная нижняя панель действий со счётчиками + +- краткое описание фичи: + - На карточках сообщений во вкладке thread нижняя панель действий теперь всегда стабильная и содержит: + - сердечко + количество лайков; + - иконка ответа + количество ответов; + - иконка изменений + количество изменений (только если изменений больше 0); + - справа кнопку отправки (`↗ Отправить`). + - Логика изменений: `изменения = versionsTotal - 1`. + - Если `versionsTotal = 1`, поле изменений не показывается. + - Убрано поведение с появлением дополнительной верхней надписи/статистики после первого взаимодействия. + +- что именно проверять: + - Открыть любой thread и убедиться, что у каждой карточки внизу всегда видны кнопки/счётчики. + - Проверить, что лайк и ответ отображают корректные числа сразу, без дополнительного клика. + - Для сообщения с `versionsTotal = 1` убедиться, что поле изменений отсутствует. + - Для сообщения с `versionsTotal > 1` убедиться, что показывается `✏️ N`, где `N = versionsTotal - 1`. + - Проверить, что справа всегда есть `↗ Отправить`. + +- ожидаемый результат: + - Нижняя панель действий во thread ведёт себя одинаково и не меняет структуру после кликов/ответов. + - Счётчики соответствуют данным API. + +- статус: + - pending diff --git a/Dev_Docs/Pending_Features/2026-05-14_1243_channels-tabs-find-and-view-subscribe-flow.md b/Dev_Docs/Pending_Features/2026-05-14_1243_channels-tabs-find-and-view-subscribe-flow.md new file mode 100644 index 0000000..a81a49e --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-14_1243_channels-tabs-find-and-view-subscribe-flow.md @@ -0,0 +1,24 @@ +# Каналы: новые табы + поиск/просмотр + подписка в канале + +- краткое описание фичи: + - На вкладке «Каналы» верхние табы переставлены в порядок: `Чаты`, `Каналы`, `Мои`. + - По умолчанию открывается вкладка `Каналы` (центральная). + - Нижняя кнопка на вкладке `Каналы` переименована: `Найти канал` (вместо `Подписаться на канал`). + - В модальном поиске канала оставлен сценарий выбора по `user/channel` (и по имени канала через существующие подсказки), без использования формата `blockchain:number:hash`. + - В результатах поиска канала добавлена явная кнопка `Просмотреть` для перехода в канал. + - На экране канала кнопка `Подписаться на канал` показывается только если пользователь ещё не подписан. + - После подтверждённой подписки кнопка исчезает (повторный ререндер с обновлённым feed). + +- что именно проверять: + - Открыть `Каналы`: убедиться, что порядок табов `Чаты | Каналы | Мои`, активна по умолчанию `Каналы`. + - На `Каналы` проверить нижнюю кнопку `Найти канал`. + - В `Найти канал` выбрать канал и нажать `Просмотреть`: должен открыться экран канала. + - На экране чужого канала (без подписки) нажать `Подписаться на канал`, подтвердить `Ок`. + - Убедиться, что после успешной подписки кнопка `Подписаться на канал` исчезает. + +- ожидаемый результат: + - Пользователь находит и открывает канал через `Найти канал` → `Просмотреть`. + - Подписка выполняется на экране канала и не предлагается повторно сразу после успеха. + +- статус: + - pending diff --git a/VERSION.properties b/VERSION.properties index 7af4e47..ee401c8 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.49 -server.version=1.2.43 +client.version=1.2.50 +server.version=1.2.44 diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index 5aa0a4c..0b49194 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -16,7 +16,6 @@ export const pageMeta = { id: 'channel-thread-view', title: 'Тред' }; const pendingReactionActions = new Set(); const pendingThreadScroll = new Map(); -const revealedCountersByRoute = new Map(); function logThreadRuntimeError(stage, error, context = {}) { const message = String(error?.message || error || 'thread runtime error'); @@ -67,29 +66,6 @@ function messageRefKey(messageRef) { return `${blockchainName}:${blockNumber}:${blockHash}`; } -function getRevealedCounterSet(routeKey) { - const key = String(routeKey || '').trim(); - if (!key) return new Set(); - let bucket = revealedCountersByRoute.get(key); - if (!bucket) { - bucket = new Set(); - revealedCountersByRoute.set(key, bucket); - } - return bucket; -} - -function isCounterVisible(routeKey, counterKey) { - const key = String(counterKey || '').trim(); - if (!key) return false; - return getRevealedCounterSet(routeKey).has(key); -} - -function revealCounter(routeKey, counterKey) { - const key = String(counterKey || '').trim(); - if (!key) return; - getRevealedCounterSet(routeKey).add(key); -} - function buildAbsoluteRouteUrl(routePath = '') { const cleanRoute = String(routePath || '').replace(/^#?\/?/, ''); const url = new URL(window.location.href); @@ -299,7 +275,7 @@ function openReplyModal({ onSubmit, navigate }) { if (textEl) textEl.focus(); } -function renderNodeCard(node, heading, handlers, localNumber, routeKey, options = {}) { +function renderNodeCard(node, heading, handlers, localNumber) { const card = document.createElement('article'); card.className = 'card stack thread-node-card'; @@ -308,6 +284,7 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options const likes = Number(node?.likesCount || 0); const replies = Number(node?.repliesCount || 0); const versions = Number(node?.versionsTotal || 1); + const changes = Math.max(0, versions - 1); const headingText = String(heading || '').trim(); if (headingText) { @@ -328,31 +305,10 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options 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(meta, body, stats); + card.append(meta, body); const target = buildTargetFromNode(node); const refKey = messageRefKey(target); - const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true; - if (!countersVisible) { - card.classList.remove('is-counters-visible'); - stats.classList.add('is-hidden'); - } else { - card.classList.add('is-counters-visible'); - stats.classList.remove('is-hidden'); - } - - const revealCounters = () => { - if (!refKey) return; - revealCounter(routeKey, refKey); - card.classList.add('is-counters-visible'); - stats.classList.remove('is-hidden'); - }; - card.addEventListener('click', revealCounters); - if (!target || !handlers) return card; if (refKey) card.dataset.messageKey = refKey; @@ -371,7 +327,7 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options likeButton.type = 'button'; likeButton.className = 'secondary-btn thread-like-btn'; if (isLiked) likeButton.classList.add('is-liked'); - likeButton.textContent = isPending ? '❤️ Лайк...' : (isLiked ? '❤️ Лайк' : '🤍 Лайк'); + likeButton.textContent = isPending ? `❤️ ${likes}...` : `${isLiked ? '❤️' : '🤍'} ${likes}`; likeButton.disabled = isPending; likeButton.addEventListener('click', async (event) => { animatePress(event.currentTarget); @@ -380,10 +336,9 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options const ok = window.confirm('Поставить лайк?'); if (!ok) return; } - revealCounters(); await longPressFeel(event.currentTarget, 130); likeButton.disabled = true; - likeButton.textContent = 'Лайк...'; + likeButton.textContent = `❤️ ${likes}...`; try { await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like'); } catch (error) { @@ -399,16 +354,22 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options const replyButton = document.createElement('button'); replyButton.type = 'button'; replyButton.className = 'secondary-btn thread-reply-btn'; - replyButton.textContent = '💬 Ответить'; + replyButton.textContent = `💬 ${replies}`; replyButton.addEventListener('click', (event) => { animatePress(event.currentTarget); - revealCounters(); openReplyModal({ navigate: handlers.navigate, onSubmit: async (textValue) => handlers.onReply(target, textValue), }); }); + const changedButton = document.createElement('button'); + changedButton.type = 'button'; + changedButton.className = 'secondary-btn thread-version-btn'; + changedButton.textContent = `✏️ ${changes}`; + changedButton.disabled = true; + changedButton.style.display = changes > 0 ? '' : 'none'; + const shareButton = document.createElement('button'); shareButton.type = 'button'; shareButton.className = 'secondary-btn thread-share-btn'; @@ -416,16 +377,15 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options shareButton.addEventListener('click', async (event) => { event.stopPropagation(); animatePress(event.currentTarget); - revealCounters(); await handlers.onShare(target); }); - actions.append(likeButton, replyButton, shareButton); + actions.append(likeButton, replyButton, changedButton, shareButton); card.append(actions); return card; } -function renderDescendants(items, handlers, nextNumber, routeKey, depth = 0) { +function renderDescendants(items, handlers, nextNumber, depth = 0) { const wrap = document.createElement('div'); wrap.className = 'stack'; @@ -433,13 +393,13 @@ function renderDescendants(items, handlers, nextNumber, routeKey, depth = 0) { normalized.forEach((branch, index) => { try { const nodeNumber = nextNumber(); - const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, handlers, nodeNumber, routeKey); + 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, nextNumber, routeKey, depth + 1)); + wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1)); } } catch (error) { logThreadRuntimeError('render_descendants_branch', error, { depth, index }); @@ -655,7 +615,7 @@ export function render({ navigate, route }) { title.textContent = 'Предыдущие сообщения'; ancestorsWrap.append(title); ancestors.forEach((node, index) => { - ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber(), routeKey)); + ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber())); }); screen.append(ancestorsWrap); } @@ -663,7 +623,7 @@ export function render({ navigate, route }) { if (focus) { const focusWrap = document.createElement('div'); focusWrap.className = 'stack thread-block thread-block--focus'; - focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber(), routeKey, { showViews: false })); + focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber())); screen.append(focusWrap); } @@ -675,7 +635,7 @@ export function render({ navigate, route }) { descendantsWrap.append(descendantsTitle); if (descendants.length) { - descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber, routeKey)); + descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber)); } else { const empty = document.createElement('div'); empty.className = 'card meta-muted'; diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 84cd7ea..0d8cbb3 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -2,6 +2,7 @@ import { authService, getMessageReactionState, + setChannelsFeed, setMessageReactionState, state, } from '../state.js'; @@ -459,6 +460,13 @@ async function loadFromApi(route, channelId) { 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 isOwnChannel = ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(); + const followedRows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : []; + const isSubscribed = followedRows.some((row) => ( + String(row?.channel?.ownerBlockchainName || '') === String(selector.ownerBlockchainName || '') + && Number(row?.channel?.channelRoot?.blockNumber) === Number(selector.channelRootBlockNumber) + && normalizeRouteHash(row?.channel?.channelRoot?.blockHash) === normalizeRouteHash(selector.channelRootBlockHash) + )); return { channel: { @@ -468,7 +476,8 @@ async function loadFromApi(route, channelId) { ownerName: ownerLogin || 'неизвестно', }, posts, - isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(), + isOwnChannel, + isSubscribed, selector, }; } @@ -762,7 +771,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { onSubmit: async (bodyText) => handlers.onAddPost(bodyText), }); }); - } else { + } else if (!channelData.isSubscribed) { actionButton.addEventListener('click', handlers.onSubscribeChannel); } @@ -771,7 +780,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { backButton.textContent = 'Назад к каналам'; backButton.addEventListener('click', () => navigate('channels-list')); - screen.append(head, actionButton, feed, backButton); + if (channelData.isOwnChannel || !channelData.isSubscribed) { + screen.append(head, actionButton, feed, backButton); + } else { + screen.append(head, feed, backButton); + } applyPendingScroll(screen, routeKey); return () => { @@ -967,8 +980,11 @@ export function render({ navigate, route }) { unfollow: false, }); + const feed = await authService.listSubscriptionsFeed(login, 200); + setChannelsFeed(feed, state.channelsIndex); softHaptic(15); showToast('Подписка на канал выполнена'); + rerender(); } catch (error) { showStatus(toUserMessage(error, 'Не удалось подписаться на канал.')); } diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index b5e4391..198acc5 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -17,7 +17,7 @@ const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; const MENU_OVERLAY_ID = 'channels-context-menu-overlay'; const CHANNEL_TYPE_STORIES = 0; const CHANNEL_TYPE_PERSONAL = 100; -const TAB_ORDER = ['feed', 'dialogs', 'my']; +const TAB_ORDER = ['dialogs', 'feed', 'my']; function isChannelsDemoMode() { try { @@ -98,15 +98,6 @@ async function resolveChannelTargetFromInput(rawInput) { const input = String(rawInput || '').trim(); if (!input) throw new Error('Введите канал.'); - const bySelector = input.match(/^([A-Za-z0-9._-]+-\d+)\s*[:/]\s*(\d+)\s*[:/]\s*([A-Fa-f0-9]{1,64})$/); - if (bySelector) { - return { - ownerBlockchainName: bySelector[1], - rootBlockNumber: Number(bySelector[2]), - rootBlockHash: normalizeHash(bySelector[3]), - }; - } - const byOwnerAndName = input.match(/^@?([^/#\s]+)\s*\/\s*#?(.+)$/); if (byOwnerAndName) { const ownerLogin = normalizeLoginInput(byOwnerAndName[1]); @@ -242,7 +233,7 @@ function isFollowedChannelVisible(target) { function openSimpleSubscribeModal({ kind, kindLabel, submitLabel, unfollow = false, onSuccess }) { const targetHint = kind === 'channel' - ? '

Канал: user/channel, имя канала или bch:number:hash.

' + ? '

Канал: user/channel или имя канала.

' : '

Автор: @login или login.

'; const submitText = submitLabel || (unfollow ? 'Отписаться' : 'Подписаться'); const placeholder = kind === 'channel' ? '@owner/channel' : '@login'; @@ -457,6 +448,38 @@ function openChannelFinderModal({ navigate }) { }); }; + const renderChannelRows = (values) => { + channelsEl.innerHTML = ''; + if (!values.length) { + channelsEl.style.display = 'none'; + return; + } + channelsEl.style.display = ''; + values.forEach((item) => { + const row = document.createElement('div'); + row.className = 'channel-search-item'; + + const label = document.createElement('span'); + label.textContent = item.label; + + const openBtn = document.createElement('button'); + openBtn.type = 'button'; + openBtn.className = 'secondary-btn small-btn'; + openBtn.textContent = 'Просмотреть'; + openBtn.addEventListener('click', () => { + close(); + navigate(`channel/${encodeRoutePart(item.ownerLogin)}/${encodeRoutePart(item.channelName)}`); + }); + + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.justifyContent = 'space-between'; + row.style.gap = '8px'; + row.append(label, openBtn); + channelsEl.append(row); + }); + }; + const loadChannelsForLogin = async (login, filterChannel = '') => { const ownerLogin = normalizeLoginInput(login); if (!ownerLogin) return; @@ -469,10 +492,7 @@ function openChannelFinderModal({ navigate }) { .filter((name) => !needle || name.toLowerCase().includes(needle)) .slice(0, 200) .map((name) => ({ label: `${ownerLogin}/${name}`, ownerLogin, channelName: name })); - renderButtons(channelsEl, channels, (item) => { - close(); - navigate(`channel/${encodeRoutePart(item.ownerLogin)}/${encodeRoutePart(item.channelName)}`); - }); + renderChannelRows(channels); }; const refresh = createDebounced(async () => { @@ -999,19 +1019,14 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed container.append(list); } -function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = false }) { +function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) { const tab = listState.activeTab; const baseClass = `primary-btn channels-bottom-action${isTabEmpty && tab !== 'my' ? ' is-empty-lift' : ''}`; if (tab === 'feed') { - button.textContent = 'Подписаться на канал'; + button.textContent = 'Найти канал'; button.className = baseClass; - button.onclick = () => openSimpleSubscribeModal({ - kind: 'channel', - kindLabel: 'Подписка на канал', - submitLabel: 'Подписаться', - onSuccess: onReload, - }); + button.onclick = () => openChannelFinderModal({ navigate }); return; } @@ -1078,7 +1093,7 @@ export function render({ navigate, route }) { const listState = { activeTab: TAB_ORDER.includes(String(route?.params?.mode || '').trim()) ? String(route?.params?.mode).trim() - : 'dialogs', + : 'feed', openMenuId: null, notificationsState, revealedCounters: new Set(), @@ -1140,7 +1155,6 @@ export function render({ navigate, route }) { button: bottomCta, listState, navigate, - onReload: reloadFeed, isTabEmpty, }); tabsEl.querySelectorAll('.channels-tab-btn').forEach((btn, idx) => { @@ -1180,7 +1194,6 @@ export function render({ navigate, route }) { button: bottomCta, listState, navigate, - onReload: reloadFeed, isTabEmpty: true, });