import { channels as mockChannels } from '../mock-data.js'; import { authService, setChannelsFeed, state } from '../state.js'; import { toUserMessage } from '../services/ui-error-texts.js'; import { animatePress, createSkeletonCard, formatRelativeTime, readChannelNotificationsState, showToast, softHaptic, writeChannelNotificationsState, } from '../services/channels-ux.js'; export const pageMeta = { id: 'channels-list', title: 'Каналы' }; 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 = ['dialogs', 'feed', 'my']; function isChannelsDemoMode() { try { const qs = new URLSearchParams(window.location.search); if (qs.get('channelsDemo') === '1') return true; return localStorage.getItem('shine-channels-demo') === '1'; } catch { return false; } } function normalizeHash(hash) { const normalized = String(hash || '').trim().toLowerCase(); return normalized || '0'; } function encodeRoutePart(value = '') { return encodeURIComponent(String(value)); } function normalizeLoginInput(value) { return String(value || '').trim().replace(/^@+/, ''); } function buildChannelRouteFromSummary(summary, fallbackId) { const ownerBch = summary?.channel?.ownerBlockchainName; const channelName = String(summary?.channel?.channelName || '').trim(); if (ownerBch && channelName) { return `channel/${encodeRoutePart(ownerBch)}/${encodeRoutePart(channelName)}`; } return `channel/${encodeRoutePart(String(summary?.channel?.ownerLogin || '').trim())}/${encodeRoutePart(channelName || fallbackId)}`; } function avatarLetterFromName(name = '') { const first = Array.from(String(name || '').trim())[0] || '#'; return first.toUpperCase(); } function allFeedSummaries() { const feed = state.channelsFeed || {}; return [ ...(feed.ownedChannels || []), ...(feed.followedUsersChannels || []), ...(feed.followedChannels || []), ]; } function uniqueBy(items, keySelector) { const seen = new Set(); const out = []; for (const item of items) { const key = keySelector(item); if (!key || seen.has(key)) continue; seen.add(key); out.push(item); } return out; } function createDebounced(fn, delayMs = 250) { let timer = null; return (...args) => { if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; fn(...args); }, delayMs); }; } async function resolveChannelTargetFromInput(rawInput) { const input = String(rawInput || '').trim(); if (!input) throw new Error('Введите канал.'); const byOwnerAndName = input.match(/^@?([^/#\s]+)\s*\/\s*#?(.+)$/); if (byOwnerAndName) { const ownerLogin = normalizeLoginInput(byOwnerAndName[1]); const channelName = String(byOwnerAndName[2] || '').trim().toLowerCase(); if (!ownerLogin || !channelName) { throw new Error('Укажите канал в формате user/channel.'); } const user = await authService.getUser(ownerLogin); if (!user?.exists || !user?.blockchainName) { throw new Error('Пользователь не найден.'); } const ownerFeed = await authService.listSubscriptionsFeed(ownerLogin, 500); const ownChannels = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : []; const matches = ownChannels.filter((item) => ( String(item?.channel?.channelName || '').trim().toLowerCase() === channelName )); if (!matches.length) { throw new Error('Канал не найден у указанного автора.'); } const primaryMatches = matches.filter((item) => ( String(item?.channel?.ownerBlockchainName || '') === String(user.blockchainName || '') )); const pool = primaryMatches.length ? primaryMatches : matches; const match = [...pool].sort((a, b) => ( Number(b?.channel?.channelRoot?.blockNumber || -1) - Number(a?.channel?.channelRoot?.blockNumber || -1) ))[0]; return { ownerBlockchainName: String(match?.channel?.ownerBlockchainName || user.blockchainName), rootBlockNumber: Number(match?.channel?.channelRoot?.blockNumber), rootBlockHash: normalizeHash(match?.channel?.channelRoot?.blockHash), }; } const byNameOnly = input.replace(/^#/, '').trim().toLowerCase(); const summaries = allFeedSummaries(); const matches = summaries.filter((summary) => ( String(summary?.channel?.channelName || '').trim().toLowerCase() === byNameOnly )); if (!matches.length) { throw new Error('Канал не найден. Укажите user/channel или выберите из списка.'); } if (matches.length > 1) { throw new Error('Найдено несколько каналов с таким именем. Уточните в формате user/channel.'); } const one = matches[0]; return { ownerBlockchainName: String(one?.channel?.ownerBlockchainName || ''), rootBlockNumber: Number(one?.channel?.channelRoot?.blockNumber), rootBlockHash: normalizeHash(one?.channel?.channelRoot?.blockHash), }; } function channelSuggestionsByInput(rawInput) { const q = String(rawInput || '').trim().toLowerCase(); if (q.length < 1) return []; const rows = allFeedSummaries().map((summary) => { const owner = String(summary?.channel?.ownerLogin || '').trim(); const channel = String(summary?.channel?.channelName || '').trim(); if (!owner || !channel) return null; return { key: `${owner.toLowerCase()}/${channel.toLowerCase()}`, label: `@${owner}/${channel}`, owner, channel, }; }).filter(Boolean); return uniqueBy(rows, (it) => it.key) .filter((it) => ( it.channel.toLowerCase().includes(q) || `${it.owner.toLowerCase()}/${it.channel.toLowerCase()}`.includes(q) || `@${it.owner.toLowerCase()}/${it.channel.toLowerCase()}`.includes(q) )) .slice(0, 7) .map((it) => it.label); } function renderSuggestions(container, values, onPick) { container.innerHTML = ''; if (!values.length) { container.style.display = 'none'; return; } container.style.display = ''; values.forEach((value) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'channel-search-item'; btn.textContent = value; btn.addEventListener('click', () => onPick(value)); container.append(btn); }); } function normalizeComparableLogin(value) { return normalizeLoginInput(value).toLowerCase(); } function isFollowedUserVisible(targetLogin) { const expected = normalizeComparableLogin(targetLogin); if (!expected) return false; const rows = Array.isArray(state.channelsFeed?.followedUsersChannels) ? state.channelsFeed.followedUsersChannels : []; return rows.some((row) => normalizeComparableLogin(row?.channel?.ownerLogin) === expected); } function isFollowedChannelVisible(target) { const rows = Array.isArray(state.channelsFeed?.followedChannels) ? state.channelsFeed.followedChannels : []; const expectedBch = String(target?.ownerBlockchainName || ''); const expectedNo = Number(target?.rootBlockNumber); const expectedHash = normalizeHash(target?.rootBlockHash); if (!expectedBch || !Number.isFinite(expectedNo)) return false; return rows.some((row) => { const rowBch = String(row?.channel?.ownerBlockchainName || ''); const rowNo = Number(row?.channel?.channelRoot?.blockNumber); const rowHash = normalizeHash(row?.channel?.channelRoot?.blockHash); return rowBch === expectedBch && rowNo === expectedNo && rowHash === expectedHash; }); } function openSimpleSubscribeModal({ kind, kindLabel, submitLabel, unfollow = false, onSuccess }) { const targetHint = kind === 'channel' ? '

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

' : '

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

'; const submitText = submitLabel || (unfollow ? 'Отписаться' : 'Подписаться'); const placeholder = kind === 'channel' ? '@owner/channel' : '@login'; const root = document.getElementById('modal-root'); root.innerHTML = ` `; const inputEl = root.querySelector('#subscribe-input'); const suggestEl = root.querySelector('#subscribe-suggest'); const errorEl = root.querySelector('#subscribe-error'); const submitEl = root.querySelector('#sub-submit'); let inFlight = false; const setBusy = (busy) => { inFlight = !!busy; submitEl.disabled = inFlight; if (inputEl) inputEl.disabled = inFlight; submitEl.textContent = inFlight ? 'Выполняем...' : submitText; }; const close = () => { root.innerHTML = ''; }; const applySuggestion = (value) => { if (!inputEl) return; inputEl.value = value; if (suggestEl) suggestEl.style.display = 'none'; inputEl.focus(); }; const refreshSuggestions = createDebounced(async () => { if (!inputEl || !suggestEl || inFlight) return; const raw = String(inputEl.value || '').trim(); if (!raw) { suggestEl.style.display = 'none'; suggestEl.innerHTML = ''; return; } try { if (kind === 'user') { const prefix = normalizeLoginInput(raw); if (prefix.length < 2) { suggestEl.style.display = 'none'; suggestEl.innerHTML = ''; return; } const logins = await authService.searchUsers(prefix); const suggestions = (Array.isArray(logins) ? logins : []).slice(0, 8).map((login) => `@${login}`); renderSuggestions(suggestEl, suggestions, applySuggestion); return; } const suggestions = channelSuggestionsByInput(raw); renderSuggestions(suggestEl, suggestions, applySuggestion); } catch { suggestEl.style.display = 'none'; suggestEl.innerHTML = ''; } }, 240); root.querySelector('#sub-cancel')?.addEventListener('click', close); inputEl?.addEventListener('input', () => { if (!errorEl) return; errorEl.textContent = ''; refreshSuggestions(); }); submitEl?.addEventListener('click', async () => { if (inFlight) return; const login = state.session.login; const storagePwd = state.session.storagePwdInMemory; const value = String(inputEl?.value || '').trim(); if (!login || !storagePwd) { errorEl.textContent = 'Сессия недействительна. Выполните вход заново.'; return; } if (!value) { errorEl.textContent = 'Введите идентификатор.'; return; } setBusy(true); errorEl.textContent = ''; try { let channelTarget = null; let userTargetLogin = ''; if (kind === 'user') { userTargetLogin = normalizeLoginInput(value); await authService.addBlockFollowUser({ login, targetLogin: userTargetLogin, storagePwd, unfollow, }); } else if (kind === 'channel') { channelTarget = await resolveChannelTargetFromInput(value); if (!channelTarget?.ownerBlockchainName || !Number.isFinite(channelTarget.rootBlockNumber)) { throw new Error('Канал не найден.'); } await authService.addBlockFollowChannel({ login, storagePwd, targetBlockchainName: channelTarget.ownerBlockchainName, targetBlockNumber: channelTarget.rootBlockNumber, targetBlockHashHex: channelTarget.rootBlockHash, unfollow, }); } else { throw new Error('Неподдерживаемый тип подписки'); } if (typeof onSuccess === 'function') { await onSuccess(); } if (kind === 'user') { const visible = isFollowedUserVisible(userTargetLogin); if (!unfollow && !visible) { throw new Error('Подписка не подтвердилась после обновления списка.'); } if (unfollow && visible) { throw new Error('Отписка не подтвердилась после обновления списка.'); } } if (kind === 'channel') { const visible = isFollowedChannelVisible(channelTarget); if (!unfollow && !visible) { throw new Error('Подписка на канал не подтвердилась после обновления списка.'); } if (unfollow && visible) { throw new Error('Отписка от канала не подтвердилась после обновления списка.'); } } softHaptic(15); showToast(unfollow ? 'Отписка выполнена' : 'Подписка выполнена'); close(); } catch (error) { errorEl.textContent = toUserMessage(error, `${submitText} не удалось.`); setBusy(false); } }); if (inputEl) inputEl.focus(); } function openChannelFinderModal({ navigate }) { const root = document.getElementById('modal-root'); root.innerHTML = ` `; const inputEl = root.querySelector('#channels-find-input'); const suggestEl = root.querySelector('#channels-find-suggest'); const channelsEl = root.querySelector('#channels-find-list'); const errorEl = root.querySelector('#channels-find-error'); const close = () => { root.innerHTML = ''; }; const renderButtons = (container, values, onPick) => { container.innerHTML = ''; if (!values.length) { container.style.display = 'none'; return; } container.style.display = ''; values.forEach((value) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'channel-search-item'; btn.textContent = value.label; btn.addEventListener('click', () => onPick(value)); container.append(btn); }); }; 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(); const ownerPart = String(item.ownerBlockchainName || '').trim() || String(item.ownerLogin || '').trim(); navigate(`channel/${encodeRoutePart(ownerPart)}/${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; const feed = await authService.listSubscriptionsFeed(ownerLogin, 1000); const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []; const needle = String(filterChannel || '').trim().toLowerCase(); const channels = rows .map((item) => ({ ownerBlockchainName: String(item?.channel?.ownerBlockchainName || '').trim(), channelName: String(item?.channel?.channelName || '').trim(), })) .filter((item) => !!item.channelName) .filter((item) => !needle || item.channelName.toLowerCase().includes(needle)) .slice(0, 200) .map((item) => ({ label: `${ownerLogin}/${item.channelName}`, ownerLogin, ownerBlockchainName: item.ownerBlockchainName, channelName: item.channelName, })); renderChannelRows(channels); if (!channels.length) { errorEl.textContent = filterChannel ? 'Каналы с таким фильтром не найдены.' : `У пользователя "${ownerLogin}" пока нет доступных каналов.`; } else { errorEl.textContent = ''; } }; const runSearch = async () => { const raw = String(inputEl?.value || '').trim(); errorEl.textContent = ''; if (!raw) { suggestEl.style.display = 'none'; suggestEl.innerHTML = ''; channelsEl.style.display = 'none'; channelsEl.innerHTML = ''; errorEl.textContent = 'Введите логин или начало логина.'; return; } const parts = raw.split('/'); const loginPrefix = normalizeLoginInput(parts[0] || ''); const channelFilter = String(parts[1] || '').trim(); try { if (raw.includes('/')) { suggestEl.style.display = 'none'; suggestEl.innerHTML = ''; await loadChannelsForLogin(loginPrefix, channelFilter); return; } if (loginPrefix.length < 1) { suggestEl.style.display = 'none'; suggestEl.innerHTML = ''; channelsEl.style.display = 'none'; channelsEl.innerHTML = ''; return; } const logins = await authService.searchUsers(loginPrefix); const rows = Array.isArray(logins) ? logins : []; if (!rows.length) { suggestEl.style.display = 'none'; suggestEl.innerHTML = ''; channelsEl.style.display = 'none'; channelsEl.innerHTML = ''; errorEl.textContent = `Логины, начинающиеся на "${loginPrefix}", не найдены.`; return; } const items = (Array.isArray(logins) ? logins : []).slice(0, 15).map((login) => ({ label: login, login, })); errorEl.textContent = ''; renderButtons(suggestEl, items, async (item) => { inputEl.value = `${item.login}/`; suggestEl.style.display = 'none'; suggestEl.innerHTML = ''; await loadChannelsForLogin(item.login, ''); }); } catch (error) { errorEl.textContent = toUserMessage(error, 'Не удалось выполнить поиск.'); } }; const refresh = createDebounced(runSearch, 220); root.querySelector('#channels-find-close')?.addEventListener('click', close); root.querySelector('#channels-find-run')?.addEventListener('click', () => { void runSearch(); }); inputEl?.addEventListener('keydown', (event) => { if (event.key !== 'Enter') return; event.preventDefault(); void runSearch(); }); inputEl?.addEventListener('input', refresh); if (inputEl) inputEl.focus(); } function mapMockGroups() { const mapRow = (channel) => ({ ...channel, route: `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.title || channel.id))}`, tabCategory: channel.kind === 'own' ? 'my' : channel.kind === 'own-personal' ? 'dialogs' : 'feed', messagePreview: channel.lastMessage || 'Ждем ваших начинаний', isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal', isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal', notificationsEnabled: false, messagesCount: Number(channel.messagesCount || 0), unreadCount: 0, lastMessageAt: 0, ownerName: String(channel.ownerName || 'неизвестно'), channelName: String(channel.channelName || channel.title || ''), title: String(channel.title || channel.channelName || ''), }); const ownChannels = mockChannels .filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own') .map((item) => ({ ...mapRow(item), tabCategory: 'my' })); const followedUserChannels = mockChannels .filter((channel) => channel.kind === 'followed-user-channel') .map((item) => ({ ...mapRow(item), tabCategory: 'feed' })); const subscribedChannels = mockChannels .filter((channel) => channel.kind === 'subscribed') .map((item) => ({ ...mapRow(item), tabCategory: 'feed' })); return { ownChannels, followedUserChannels, subscribedChannels, index: {} }; } function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) { const rowId = `${bucketKey}-${idx}`; index[rowId] = summary; const ownerLogin = summary?.channel?.ownerLogin || 'неизвестно'; const channelName = summary?.channel?.channelName || '(без названия)'; const channelDescription = String(summary?.channel?.channelDescription || '').trim(); const channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1); const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1); const isOwn = bucketKey === 'own'; const tabCategory = isOwn ? (channelTypeCode === CHANNEL_TYPE_PERSONAL ? 'dialogs' : 'my') : 'feed'; const title = isOwn ? channelName : `${ownerLogin}/${channelName}`; return { id: rowId, route: buildChannelRouteFromSummary(summary, rowId), ownerName: ownerLogin, ownerBlockchainName: summary?.channel?.ownerBlockchainName || '', channelRootBlockNumber: Number(summary?.channel?.channelRoot?.blockNumber), channelRootBlockHash: normalizeHash(summary?.channel?.channelRoot?.blockHash), avatar: avatarLetterFromName(channelName), title, channelName, channelDescription, channelTypeCode, channelTypeVersion, messagePreview: summary?.lastMessage?.text || 'Ждем ваших начинаний', messagesCount: Number(summary?.messagesCount || 0), unreadCount: Number(summary?.unreadCount || 0), lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 0), tabCategory, isOwnChannel: isOwn, isSubscribed: !isOwn, notificationsEnabled: notificationsState[rowId] === true, pending: false, }; } function pullCreateSuccessFlash() { try { const value = String(sessionStorage.getItem(CREATE_CHANNEL_FLASH_KEY) || '').trim(); if (value) sessionStorage.removeItem(CREATE_CHANNEL_FLASH_KEY); return value; } catch { return ''; } } function mapApiFeed(feed, notificationsState) { const index = {}; const ownChannels = (feed?.ownedChannels || []) .map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState)); const followedUserChannels = (feed?.followedUsersChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState)); const subscribedChannels = (feed?.followedChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedChannels', idx, index, notificationsState)); return { ownChannels, followedUserChannels, subscribedChannels, index }; } function toListModel(groups) { return [ ...(groups.ownChannels || []), ...(groups.followedUserChannels || []), ...(groups.subscribedChannels || []), ]; } function renderEmptyState(activeTab, navigate) { const wrap = document.createElement('div'); wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent'; const text = document.createElement('p'); text.className = 'meta-muted'; if (activeTab === 'feed') { text.textContent = 'Нет подписок и найденных каналов.'; } else if (activeTab === 'dialogs') { text.textContent = 'Чаты пока не работают.'; } else if (activeTab === 'my') { text.textContent = 'У вас пока нет каналов.'; } else { text.textContent = 'Пусто.'; } wrap.append(text); return wrap; } function renderSkeletonList(container, count = 4) { container.innerHTML = ''; const list = document.createElement('div'); list.className = 'stack'; for (let i = 0; i < count; i += 1) { list.append(createSkeletonCard()); } container.append(list); } function renderErrorState(container, error, onRetry) { const errCard = document.createElement('div'); errCard.className = 'card stack channels-status'; const title = document.createElement('strong'); title.textContent = 'Не удалось загрузить каналы'; const details = document.createElement('p'); details.className = 'meta-muted'; details.textContent = toUserMessage(error, 'Проверьте подключение к серверу и повторите попытку.'); const retry = document.createElement('button'); retry.className = 'primary-btn'; retry.type = 'button'; retry.textContent = 'Повторить'; retry.addEventListener('click', onRetry); errCard.append(title, details, retry); container.append(errCard); } function renderDemoFallback(container, navigate, error, onRetry) { const info = document.createElement('div'); info.className = 'card stack'; info.innerHTML = ` Включен демо-режим

Данные сервера недоступны. Показаны мок-каналы, потому что включен channelsDemo.

${toUserMessage(error, 'Ошибка API/WS')}

`; const retry = document.createElement('button'); retry.className = 'secondary-btn'; retry.type = 'button'; retry.textContent = 'Повторить запрос к серверу'; retry.addEventListener('click', onRetry); info.append(retry); container.append(info); const groups = mapMockGroups(); const list = document.createElement('div'); list.className = 'stack'; toListModel(groups).forEach((channel) => { const row = document.createElement('article'); row.className = 'channel-row'; row.innerHTML = `
${channel.avatar || channel.initials || '#'}
${channel.title || channel.displayName || channel.name}

${channel.messagePreview || 'Ждем ваших начинаний'}

`; row.addEventListener('click', () => navigate(channel.route || `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.id))}`)); list.append(row); }); container.append(list); } function closeChannelMenu(listState, clearOpenMenuId = true) { if (typeof listState.menuCleanup === 'function') { listState.menuCleanup(); } listState.menuCleanup = null; const root = document.getElementById('modal-root'); if (root) { const overlay = root.querySelector(`#${MENU_OVERLAY_ID}`); if (overlay) overlay.remove(); } if (clearOpenMenuId) { listState.openMenuId = null; } } function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderList }) { closeChannelMenu(listState, false); const root = document.getElementById('modal-root'); if (!root || !anchorEl) return; const rect = anchorEl.getBoundingClientRect(); const menuWidth = Math.min(250, Math.max(220, window.innerWidth - 28)); let left = rect.right - menuWidth; left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12)); const estimatedHeight = 210; let top = rect.bottom + 8; if (top + estimatedHeight > window.innerHeight - 10) { top = Math.max(12, rect.top - estimatedHeight - 8); } const overlay = document.createElement('div'); overlay.id = MENU_OVERLAY_ID; overlay.className = 'channels-menu-overlay'; const menu = document.createElement('div'); menu.className = 'channel-menu-wrap channel-menu-wrap--portal'; menu.style.left = `${Math.round(left)}px`; menu.style.top = `${Math.round(top)}px`; menu.style.width = `${Math.round(menuWidth)}px`; const canToggleSubscription = !channel.isOwnChannel; const actionBtn = document.createElement('button'); actionBtn.type = 'button'; actionBtn.className = `channel-menu-item ${channel.isSubscribed ? 'destructive' : ''}`.trim(); if (canToggleSubscription) { actionBtn.textContent = channel.pending ? 'Выполняется...' : channel.isSubscribed ? 'Отписаться' : 'Подписаться'; actionBtn.disabled = !!channel.pending; actionBtn.addEventListener('click', async (event) => { event.stopPropagation(); if (channel.pending) return; const login = state.session.login; const storagePwd = state.session.storagePwdInMemory; if (!login || !storagePwd) { showToast('Сессия недействительна. Выполните вход заново.', { kind: 'error' }); return; } channel.pending = true; actionBtn.disabled = true; actionBtn.textContent = 'Выполняется...'; const nextSubscribed = !channel.isSubscribed; try { await authService.addBlockFollowChannel({ login, storagePwd, targetBlockchainName: channel.ownerBlockchainName, targetBlockNumber: channel.channelRootBlockNumber, targetBlockHashHex: channel.channelRootBlockHash, unfollow: !nextSubscribed, }); channel.isSubscribed = nextSubscribed; channel.pending = false; softHaptic(15); showToast(nextSubscribed ? 'Подписка на канал включена' : 'Подписка на канал отключена'); closeChannelMenu(listState); await refreshFeed(); } catch (error) { channel.pending = false; actionBtn.disabled = false; actionBtn.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться'; showToast(toUserMessage(error, 'Не удалось изменить подписку.'), { kind: 'error' }); rerenderList(); } }); } else { actionBtn.textContent = 'Собственный канал'; actionBtn.disabled = true; } const toggleWrap = document.createElement('div'); toggleWrap.className = 'channel-menu-toggle'; const toggleLabel = document.createElement('span'); toggleLabel.textContent = 'Уведомления'; const toggleBtn = document.createElement('button'); toggleBtn.type = 'button'; toggleBtn.className = `channel-toggle-btn ${channel.notificationsEnabled ? 'is-on' : ''}`.trim(); toggleBtn.addEventListener('click', (event) => { event.stopPropagation(); animatePress(toggleBtn); channel.notificationsEnabled = !channel.notificationsEnabled; const next = { ...listState.notificationsState, [channel.id]: channel.notificationsEnabled }; listState.notificationsState = next; writeChannelNotificationsState(next); toggleBtn.classList.toggle('is-on', channel.notificationsEnabled); softHaptic(10); }); toggleWrap.append(toggleLabel, toggleBtn); menu.append(actionBtn, toggleWrap); overlay.append(menu); root.append(overlay); const onOverlayClick = (event) => { if (event.target === overlay) { closeChannelMenu(listState); rerenderList(); } }; const onWindowResize = () => { closeChannelMenu(listState); rerenderList(); }; overlay.addEventListener('click', onOverlayClick); window.addEventListener('resize', onWindowResize); listState.menuCleanup = () => { overlay.removeEventListener('click', onOverlayClick); window.removeEventListener('resize', onWindowResize); }; } function renderChannelMain(channel, activeTab) { const main = document.createElement('div'); main.className = 'channel-row-main'; if (activeTab === 'feed') { const author = document.createElement('p'); author.className = 'channel-row-author'; author.textContent = `@${channel.ownerName}`; const title = document.createElement('strong'); title.className = 'channel-row-title'; title.textContent = channel.channelName ? `#${channel.channelName}` : channel.title; const preview = document.createElement('p'); preview.className = 'channel-row-message'; preview.textContent = channel.messagePreview || 'Ждем ваших начинаний'; const meta = document.createElement('p'); meta.className = 'channel-row-owner channel-counter-meta'; meta.textContent = `Сообщений: ${channel.messagesCount || 0}`; main.append(author, title, preview, meta); return main; } const title = document.createElement('strong'); title.className = 'channel-row-title'; title.textContent = activeTab === 'my' ? channel.channelName : channel.title; if ((activeTab === 'my' || activeTab === 'dialogs') && channel.channelDescription) { const desc = document.createElement('p'); desc.className = 'channel-row-description'; desc.textContent = channel.channelDescription; main.append(desc); } const preview = document.createElement('p'); preview.className = 'channel-row-message'; preview.textContent = channel.messagePreview || 'Ждем ваших начинаний'; const meta = document.createElement('p'); meta.className = 'channel-row-owner channel-counter-meta'; meta.textContent = `Сообщений: ${channel.messagesCount || 0}`; main.prepend(title); main.append(preview, meta); return main; } function renderListContent({ screen, container, listState, navigate, refreshFeed }) { container.innerHTML = ''; const allChannels = listState.channels || []; const activeTab = listState.activeTab; const filtered = allChannels.filter((channel) => channel.tabCategory === activeTab); if (!filtered.length) { container.append(renderEmptyState(activeTab, navigate)); return; } const list = document.createElement('div'); list.className = 'stack channels-groups channels-list-body-fade'; const rerenderList = () => renderListContent({ screen, container, listState, navigate, refreshFeed }); filtered.forEach((channel) => { const row = document.createElement('article'); row.className = 'channel-row'; const countersVisible = listState.revealedCounters.has(channel.id); row.classList.toggle('is-counters-visible', countersVisible); const avatar = document.createElement('div'); avatar.className = 'avatar'; avatar.textContent = channel.avatar; const main = renderChannelMain(channel, activeTab); const controls = document.createElement('div'); controls.className = 'channel-row-controls'; const menuButton = document.createElement('button'); menuButton.type = 'button'; menuButton.className = 'channel-menu-trigger'; menuButton.textContent = '…'; menuButton.addEventListener('click', (event) => { event.stopPropagation(); animatePress(menuButton); listState.revealedCounters.add(channel.id); if (listState.openMenuId === channel.id) { closeChannelMenu(listState); rerenderList(); return; } listState.openMenuId = channel.id; openChannelMenu({ listState, channel, anchorEl: menuButton, refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl: container, navigate }), rerenderList, }); rerenderList(); }); const time = document.createElement('span'); time.className = 'channel-row-time'; time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '—'; const count = document.createElement('span'); count.className = 'unread channel-row-count'; const unreadCount = Number(channel.unreadCount || 0); count.textContent = unreadCount > 0 ? String(unreadCount) : ''; count.classList.toggle('is-empty', unreadCount <= 0); controls.append(menuButton, time, count); row.append(avatar, main, controls); row.addEventListener('click', () => navigate(channel.route || `channel/${encodeRoutePart(String(channel.ownerName || 'channel'))}/${encodeRoutePart(String(channel.channelName || channel.id))}`)); list.append(row); }); container.append(list); } 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.className = baseClass; button.onclick = () => openChannelFinderModal({ navigate }); return; } if (tab === 'dialogs') { button.textContent = 'Новый персональный публичный чат'; button.className = baseClass; button.onclick = () => navigate('add-personal-public-chat-view'); return; } if (tab === 'my') { button.textContent = 'Создать канал'; button.className = baseClass; button.onclick = () => navigate('add-channel-view'); return; } button.textContent = 'Поиск каналов'; button.className = baseClass; button.onclick = () => openChannelFinderModal({ navigate }); } async function loadFeedAndRender({ screen, listState, contentEl, navigate }) { closeChannelMenu(listState); renderSkeletonList(contentEl, 5); try { if (!state.session.login) throw new Error('not_authorized'); const feed = await authService.listSubscriptionsFeed(state.session.login, 200); const groups = mapApiFeed(feed, listState.notificationsState); listState.channels = toListModel(groups); setChannelsFeed(feed, groups.index); renderListContent({ screen, container: contentEl, listState, navigate, refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }), }); } catch (error) { setChannelsFeed(null, {}); contentEl.innerHTML = ''; if (isChannelsDemoMode()) { renderDemoFallback(contentEl, navigate, error, () => loadFeedAndRender({ screen, listState, contentEl, navigate })); return; } renderErrorState(contentEl, error, () => loadFeedAndRender({ screen, listState, contentEl, navigate })); } } export function render({ navigate, route }) { const screen = document.createElement('section'); screen.className = 'stack channels-screen channels-screen--list'; const appScreen = document.getElementById('app-screen'); appScreen?.classList.add('channels-scroll-clean'); const createSuccessFlash = pullCreateSuccessFlash(); const notificationsState = readChannelNotificationsState(); const listState = { activeTab: TAB_ORDER.includes(String(route?.params?.mode || '').trim()) ? String(route?.params?.mode).trim() : 'feed', openMenuId: null, notificationsState, revealedCounters: new Set(), channels: [], menuCleanup: null, }; const contentEl = document.createElement('div'); contentEl.className = 'channels-list-content'; const tabsEl = document.createElement('div'); tabsEl.className = 'channels-tabs'; const tabLabels = { feed: 'Каналы', dialogs: 'Чаты', my: 'Мои', }; TAB_ORDER.forEach((tabKey) => { const tabBtn = document.createElement('button'); tabBtn.type = 'button'; tabBtn.className = `channels-tab-btn${listState.activeTab === tabKey ? ' is-active' : ''}`; tabBtn.textContent = tabLabels[tabKey] || tabKey; if (tabKey === 'dialogs') { tabBtn.classList.add('is-disabled'); tabBtn.title = 'Чаты пока не работают'; } tabBtn.addEventListener('click', () => { if (listState.activeTab === tabKey) return; listState.activeTab = tabKey; rerenderList(); }); tabsEl.append(tabBtn); }); const bottomCta = document.createElement('button'); bottomCta.type = 'button'; const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate }); const rerenderList = () => { try { const expectedPath = `/channels-list/${listState.activeTab}`; if (window.location.pathname !== expectedPath) { window.history.replaceState({}, '', expectedPath); } } catch { // ignore history errors } const isTabEmpty = !(listState.channels || []).some((channel) => channel.tabCategory === listState.activeTab); closeChannelMenu(listState); renderListContent({ screen, container: contentEl, listState, navigate, refreshFeed: reloadFeed, }); updateBottomCta({ button: bottomCta, listState, navigate, isTabEmpty, }); tabsEl.querySelectorAll('.channels-tab-btn').forEach((btn, idx) => { const key = TAB_ORDER[idx]; btn.classList.toggle('is-active', key === listState.activeTab); }); }; let touchStartX = 0; let touchStartY = 0; contentEl.addEventListener('touchstart', (event) => { const p = event.changedTouches?.[0]; if (!p) return; touchStartX = p.clientX; touchStartY = p.clientY; }, { passive: true }); contentEl.addEventListener('touchend', (event) => { const p = event.changedTouches?.[0]; if (!p) return; const dx = p.clientX - touchStartX; const dy = p.clientY - touchStartY; if (Math.abs(dx) < 45 || Math.abs(dx) < Math.abs(dy)) return; const index = TAB_ORDER.indexOf(listState.activeTab); if (index < 0) return; if (dx < 0 && index < TAB_ORDER.length - 1) listState.activeTab = TAB_ORDER[index + 1]; if (dx > 0 && index > 0) listState.activeTab = TAB_ORDER[index - 1]; rerenderList(); }, { passive: true }); screen.append(tabsEl, contentEl, bottomCta); if (createSuccessFlash) { showToast(createSuccessFlash); } updateBottomCta({ button: bottomCta, listState, navigate, isTabEmpty: true, }); loadFeedAndRender({ screen, listState, contentEl, navigate }); screen.cleanup = () => { closeChannelMenu(listState); appScreen?.classList.remove('channels-scroll-clean'); }; return screen; }