import { renderHeader } from '../components/header.js'; import { channels as mockChannels } from '../mock-data.js'; import { authService, setChannelsFeed, state } from '../state.js'; import { toUserMessage } from '../services/ui-error-texts.js'; export const pageMeta = { id: 'channels-list', title: 'Каналы' }; const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; 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 buildChannelRouteFromSummary(summary, fallbackId) { const ownerBch = summary?.channel?.ownerBlockchainName; const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber; const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash); if (!ownerBch || rootBlockNumber == null) return `channel-view/${fallbackId}`; return `channel-view/${encodeRoutePart(ownerBch)}/${Number(rootBlockNumber)}/${rootBlockHash}`; } function initialsFromName(name = '') { const parts = name.split(/\s+/).filter(Boolean); return (parts[0]?.[0] || '#') + (parts[1]?.[0] || ''); } function allFeedSummaries() { const feed = state.channelsFeed || {}; return [ ...(feed.ownedChannels || []), ...(feed.followedUsersChannels || []), ...(feed.followedChannels || []), ]; } function resolveChannelTargetFromInput(rawInput) { const input = String(rawInput || '').trim(); if (!input) return null; 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 summaries = allFeedSummaries(); const byOwnerAndName = input.match(/^@?([^/#\s]+)\s*\/\s*#?(.+)$/); if (byOwnerAndName) { const owner = byOwnerAndName[1].trim().toLowerCase(); const channelName = byOwnerAndName[2].trim().toLowerCase(); const match = summaries.find((summary) => ( String(summary?.channel?.ownerLogin || '').toLowerCase() === owner && String(summary?.channel?.channelName || '').toLowerCase() === channelName )); if (!match) return null; return { ownerBlockchainName: match.channel?.ownerBlockchainName, rootBlockNumber: Number(match.channel?.channelRoot?.blockNumber), rootBlockHash: normalizeHash(match.channel?.channelRoot?.blockHash), }; } const byNameOnly = input.replace(/^#/, '').trim().toLowerCase(); if (!byNameOnly) return null; const matches = summaries.filter((summary) => ( String(summary?.channel?.channelName || '').toLowerCase() === byNameOnly )); if (matches.length !== 1) return null; return { ownerBlockchainName: matches[0].channel?.ownerBlockchainName, rootBlockNumber: Number(matches[0].channel?.channelRoot?.blockNumber), rootBlockHash: normalizeHash(matches[0].channel?.channelRoot?.blockHash), }; } function openSimpleSubscribeModal({ kind, kindLabel, submitLabel, unfollow = false, onSuccess }) { const targetHint = kind === 'channel' ? '

Цель канала: owner/channel или bch:number:hash.

' : '

Цель пользователя: @login.

'; const submitText = submitLabel || (unfollow ? 'Отписаться' : 'Подписаться'); const placeholder = kind === 'channel' ? '@owner/#channel или bch:number:hash' : '@login'; const root = document.getElementById('modal-root'); root.innerHTML = ` `; const inputEl = root.querySelector('#subscribe-input'); const errorEl = root.querySelector('#subscribe-error'); const submitEl = root.querySelector('#sub-submit'); const close = () => { root.innerHTML = ''; }; root.querySelector('#sub-cancel').addEventListener('click', close); submitEl.addEventListener('click', async () => { 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; } submitEl.disabled = true; errorEl.textContent = ''; try { if (kind === 'user') { await authService.addBlockFollowUser({ login, targetLogin: value.replace(/^@+/, ''), storagePwd, unfollow, }); } else if (kind === 'channel') { const target = resolveChannelTargetFromInput(value); if (!target?.ownerBlockchainName || !Number.isFinite(target.rootBlockNumber)) { throw new Error('Канал не найден. Используйте owner/channel или bch:number:hash.'); } await authService.addBlockFollowChannel({ login, storagePwd, targetBlockchainName: target.ownerBlockchainName, targetBlockNumber: target.rootBlockNumber, targetBlockHashHex: target.rootBlockHash, unfollow, }); } else { throw new Error('Неподдерживаемый тип подписки'); } close(); if (typeof onSuccess === 'function') onSuccess(); } catch (error) { errorEl.textContent = toUserMessage(error, `${submitText} не удалось.`); submitEl.disabled = false; } }); if (inputEl) inputEl.focus(); } function mapMockGroups() { const mapRow = (channel) => ({ ...channel, route: `channel-view/${channel.id}`, }); const ownChannels = mockChannels .filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own') .map(mapRow); const followedUserChannels = mockChannels .filter((channel) => channel.kind === 'followed-user-channel') .map(mapRow); const subscribedChannels = mockChannels .filter((channel) => channel.kind === 'subscribed') .map(mapRow); return { ownChannels, followedUserChannels, subscribedChannels, index: {} }; } function mapApiChannelRow(summary, bucketKey, idx, index) { const rowId = `${bucketKey}-${idx}`; index[rowId] = summary; const ownerLogin = summary.channel?.ownerLogin || 'неизвестно'; const channelName = summary.channel?.channelName || '(без названия)'; const displayName = `${ownerLogin}/${channelName}`; return { id: rowId, source: 'api', route: buildChannelRouteFromSummary(summary, rowId), ownerName: ownerLogin, initials: initialsFromName(channelName || ownerLogin || '?'), name: channelName, displayName, description: `bch=${summary.channel?.ownerBlockchainName || '-'}`, lastMessage: summary.lastMessage?.text || 'Сообщений пока нет', time: summary.lastMessage?.createdAtMs ? new Date(summary.lastMessage.createdAtMs).toLocaleString('ru-RU') : '-', messagesCount: summary.messagesCount || 0, }; } 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) { const index = {}; const ownChannels = (feed?.ownedChannels || []).map((it, idx) => mapApiChannelRow(it, 'own', idx, index)); const followedUserChannels = (feed?.followedUsersChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index)); const subscribedChannels = (feed?.followedChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedChannels', idx, index)); ownChannels.sort((a, b) => { const ap = index[a.id]?.channel?.personal === true; const bp = index[b.id]?.channel?.personal === true; if (ap && !bp) return -1; if (!ap && bp) return 1; return a.name.localeCompare(b.name, 'ru'); }); return { ownChannels, followedUserChannels, subscribedChannels, index }; } function renderChannelRow(channel, navigate) { const row = document.createElement('article'); row.className = 'channel-row'; row.innerHTML = `
${channel.initials}
${channel.displayName || channel.name}

${channel.description}

${channel.lastMessage}

Владелец: ${channel.ownerName}

Канал ${channel.time} ${channel.messagesCount}
`; row.addEventListener('click', () => navigate(channel.route || `channel-view/${channel.id}`)); return row; } function renderSection(title, items, navigate) { const wrap = document.createElement('section'); wrap.className = 'stack channels-section'; const header = document.createElement('h3'); header.className = 'section-title'; header.textContent = title; wrap.append(header); if (!items.length) { const empty = document.createElement('div'); empty.className = 'channels-list-empty'; empty.textContent = 'Пока пусто.'; wrap.append(empty); return wrap; } items.forEach((channel) => wrap.append(renderChannelRow(channel, navigate))); return wrap; } function renderGroupedList(container, navigate, groups) { const listWrap = document.createElement('div'); listWrap.className = 'channels-scroll-wrap'; const list = document.createElement('div'); list.className = 'stack channels-groups'; list.append(renderSection('Мои каналы', groups.ownChannels, navigate)); const dividerOne = document.createElement('hr'); dividerOne.className = 'channels-divider'; list.append(dividerOne); list.append(renderSection('Каналы пользователей, на которых я подписан', groups.followedUserChannels, navigate)); const dividerTwo = document.createElement('hr'); dividerTwo.className = 'channels-divider'; list.append(dividerTwo); list.append(renderSection('Каналы, на которые я подписан', groups.subscribedChannels, navigate)); const addChannelButton = document.createElement('button'); addChannelButton.className = 'primary-btn channels-bottom-action'; addChannelButton.textContent = 'Создать канал'; addChannelButton.addEventListener('click', () => navigate('add-channel-view')); list.append(addChannelButton); const scrollHint = document.createElement('div'); scrollHint.className = 'channels-scroll-hint'; listWrap.append(list, scrollHint); container.append(listWrap); } 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); renderGroupedList(container, navigate, mapMockGroups()); } async function loadFeedAndRender(container, navigate) { container.innerHTML = ''; const status = document.createElement('div'); status.className = 'card meta-muted'; status.textContent = 'Загрузка каналов...'; container.append(status); try { if (!state.session.login) throw new Error('not_authorized'); const feed = await authService.listSubscriptionsFeed(state.session.login, 200); const groups = mapApiFeed(feed); setChannelsFeed(feed, groups.index); container.innerHTML = ''; renderGroupedList(container, navigate, groups); } catch (error) { setChannelsFeed(null, {}); container.innerHTML = ''; if (isChannelsDemoMode()) { renderDemoFallback(container, navigate, error, () => loadFeedAndRender(container, navigate)); return; } renderErrorState(container, error, () => loadFeedAndRender(container, navigate)); } } export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack channels-screen channels-screen--list'; const createSuccessFlash = pullCreateSuccessFlash(); const hero = document.createElement('div'); hero.className = 'card channels-hero'; hero.innerHTML = `

SHiNE

Каналы

Ленты, треды и подписки в одном экране.

`; const currentUser = document.createElement('div'); currentUser.className = 'card channels-user-chip'; currentUser.textContent = `Вы вошли как @${state.session.login || 'неизвестно'}`; let flashCard = null; if (createSuccessFlash) { flashCard = document.createElement('div'); flashCard.className = 'card status-line is-available'; flashCard.textContent = createSuccessFlash; } const help = document.createElement('div'); help.className = 'card stack channels-help-card'; help.innerHTML = ` Быстрый ручной тест

1) Создайте пользователей A и B.
2) Под A создайте 2 канала и сообщения.
3) Под B проверьте follow/unfollow user и channel.
4) Откройте канал: проверьте like/unlike, reply и thread.

`; const content = document.createElement('div'); const refresh = () => { loadFeedAndRender(content, navigate); }; screen.append( renderHeader({ title: 'Каналы', }) ); const actions = document.createElement('div'); actions.className = 'channels-action-grid'; const actionButtons = [ { label: 'Подписаться на пользователя', className: 'primary-btn channels-action-btn', onClick: () => openSimpleSubscribeModal({ kind: 'user', kindLabel: 'Подписка на пользователя', submitLabel: 'Подписаться', onSuccess: refresh, }), }, { label: 'Отписаться от пользователя', className: 'destructive-btn channels-action-btn', onClick: () => openSimpleSubscribeModal({ kind: 'user', kindLabel: 'Отписка от пользователя', submitLabel: 'Отписаться', unfollow: true, onSuccess: refresh, }), }, { label: 'Подписаться на канал', className: 'primary-btn channels-action-btn', onClick: () => openSimpleSubscribeModal({ kind: 'channel', kindLabel: 'Подписка на канал', submitLabel: 'Подписаться', onSuccess: refresh, }), }, { label: 'Отписаться от канала', className: 'destructive-btn channels-action-btn', onClick: () => openSimpleSubscribeModal({ kind: 'channel', kindLabel: 'Отписка от канала', submitLabel: 'Отписаться', unfollow: true, onSuccess: refresh, }), }, ]; actionButtons.forEach((config) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = config.className; btn.textContent = config.label; btn.addEventListener('click', config.onClick); actions.append(btn); }); const limitations = document.createElement('div'); limitations.className = 'channels-info-strip'; limitations.textContent = 'Подписка на пользователя и подписка на конкретный канал работают независимо.'; screen.append(hero); screen.append(actions); screen.append(currentUser); if (flashCard) screen.append(flashCard); screen.append(help); screen.append(limitations); screen.append(content); refresh(); return screen; }