SHiNE-server/shine-UI/js/pages/channels-list.js

1316 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import { makeShineChannelRoute } from '../services/shine-routes.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 = ['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 = String(summary?.channel?.ownerBlockchainName || '').trim();
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
const channelName = String(summary?.channel?.channelName || '').trim();
return makeShineChannelRoute({
ownerLogin,
ownerBlockchainName: ownerBch,
channelName: 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'
? '<p class="meta-muted">Канал: user/channel или имя канала.</p>'
: '<p class="meta-muted">Автор: @login или login.</p>';
const submitText = submitLabel || (unfollow ? 'Отписаться' : 'Подписаться');
const placeholder = kind === 'channel' ? '@owner/channel' : '@login';
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channels-subscribe-modal">
<div class="modal-card stack">
<h3 class="modal-title">${kindLabel}</h3>
${targetHint}
<label class="meta-muted" for="subscribe-input">Поиск</label>
<input id="subscribe-input" class="input" placeholder="${placeholder}" autocomplete="off" />
<div id="subscribe-suggest" class="channels-search-suggest" style="display:none"></div>
<div id="subscribe-error" class="meta-muted inline-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="sub-cancel" type="button">Отмена</button>
<button class="primary-btn" id="sub-submit" type="button">${submitText}</button>
</div>
</div>
</div>
`;
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 = `
<div class="modal" id="channels-find-modal">
<div class="modal-card stack" style="max-width: min(96vw, 760px); width: min(96vw, 760px);">
<h3 class="modal-title">Поиск каналов</h3>
<p class="meta-muted">Введите логин (или начало логина), затем выберите пользователя и канал.</p>
<input id="channels-find-input" class="input" placeholder="Например: aidar" autocomplete="off" />
<div id="channels-find-suggest" class="channels-search-suggest" style="display:none"></div>
<div id="channels-find-list" class="channels-search-suggest" style="display:none"></div>
<div id="channels-find-error" class="meta-muted inline-error"></div>
<div class="form-actions-grid">
<button class="primary-btn" id="channels-find-run" type="button">Найти</button>
<button class="secondary-btn" id="channels-find-close" type="button">Закрыть</button>
</div>
</div>
</div>
`;
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 route = makeShineChannelRoute({
ownerLogin: String(item.ownerLogin || '').trim(),
ownerBlockchainName: String(item.ownerBlockchainName || '').trim(),
channelName: String(item.channelName || '').trim(),
});
if (route) navigate(route);
});
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: makeShineChannelRoute({
ownerLogin: String(channel.ownerName || 'channel'),
ownerBlockchainName: String(channel.ownerName || ''),
channelName: String(channel.channelName || channel.title || channel.id),
}),
tabCategory: channel.kind === 'own' || channel.kind === 'own-personal'
? 'my'
: '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 ? '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';
if (!state.session.isAuthorized) {
return wrap;
}
const text = document.createElement('p');
text.className = 'meta-muted';
if (activeTab === 'feed') {
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 = `
<strong>Включен демо-режим</strong>
<p class="meta-muted">Данные сервера недоступны. Показаны мок-каналы, потому что включен channelsDemo.</p>
<p class="meta-muted">${toUserMessage(error, 'Ошибка API/WS')}</p>
`;
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 = `
<div class="avatar">${channel.avatar || channel.initials || '#'}</div>
<div class="channel-row-main">
<strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong>
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
</div>
<div class="channel-row-controls">
<span class="channel-row-time">—</span>
</div>
`;
row.addEventListener('click', () => {
const route = channel.route || makeShineChannelRoute({
ownerLogin: String(channel.ownerName || 'channel'),
ownerBlockchainName: String(channel.ownerName || ''),
channelName: String(channel.channelName || channel.id),
});
if (route) navigate(route);
});
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' && 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 isGuest = !state.session.isAuthorized;
const controls = document.createElement('div');
controls.className = 'channel-row-controls';
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);
if (!isGuest) {
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();
});
controls.append(menuButton);
}
controls.append(time, count);
row.append(avatar, main, controls);
row.addEventListener('click', () => {
const route = channel.route || makeShineChannelRoute({
ownerLogin: String(channel.ownerName || 'channel'),
ownerBlockchainName: String(channel.ownerName || ''),
channelName: String(channel.channelName || channel.id),
});
if (route) navigate(route);
});
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 === 'my') {
button.textContent = 'Найти канал';
button.className = baseClass;
button.onclick = () => openChannelFinderModal({ navigate });
return;
}
button.textContent = 'Поиск каналов';
button.className = baseClass;
button.onclick = () => openChannelFinderModal({ navigate });
}
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
closeChannelMenu(listState);
renderSkeletonList(contentEl, 5);
if (!state.session.isAuthorized) {
setChannelsFeed(null, {});
listState.channels = [];
renderListContent({
screen,
container: contentEl,
listState,
navigate,
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
});
return;
}
try {
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 isGuest = !state.session.isAuthorized;
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,
};
if (isGuest && listState.activeTab === 'my') {
listState.activeTab = 'feed';
}
const contentEl = document.createElement('div');
contentEl.className = 'channels-list-content';
const topBarEl = document.createElement('div');
topBarEl.className = 'channels-top-bar';
const topBarLeft = document.createElement('div');
topBarLeft.className = 'channels-top-left';
const backToFeedBtn = document.createElement('button');
backToFeedBtn.type = 'button';
backToFeedBtn.className = 'icon-btn channels-top-back-btn';
backToFeedBtn.textContent = '←';
backToFeedBtn.setAttribute('aria-label', 'Назад');
backToFeedBtn.addEventListener('click', () => {
if (listState.activeTab === 'feed') return;
listState.activeTab = 'feed';
rerenderList();
});
const allChannelsBtn = document.createElement('button');
allChannelsBtn.type = 'button';
allChannelsBtn.className = 'secondary-btn channels-top-switch-btn';
allChannelsBtn.textContent = 'Все каналы';
allChannelsBtn.addEventListener('click', () => {
if (listState.activeTab === 'feed') return;
listState.activeTab = 'feed';
rerenderList();
});
const topTitle = document.createElement('strong');
topTitle.className = 'channels-top-title';
const myChannelsBtn = document.createElement('button');
myChannelsBtn.type = 'button';
myChannelsBtn.className = 'secondary-btn channels-top-switch-btn';
myChannelsBtn.textContent = 'Мои каналы';
myChannelsBtn.addEventListener('click', () => {
if (listState.activeTab === 'my') return;
listState.activeTab = 'my';
rerenderList();
});
const createInMyBtn = document.createElement('button');
createInMyBtn.type = 'button';
createInMyBtn.className = 'icon-btn channels-top-add-btn';
createInMyBtn.textContent = '+';
createInMyBtn.setAttribute('aria-label', 'Создать канал');
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
topBarLeft.append(backToFeedBtn, allChannelsBtn, topTitle, myChannelsBtn);
topBarEl.append(topBarLeft, createInMyBtn);
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,
});
if (listState.activeTab === 'my' && !isGuest) {
backToFeedBtn.style.display = '';
allChannelsBtn.style.display = '';
myChannelsBtn.style.display = 'none';
topTitle.textContent = 'Мои каналы';
createInMyBtn.style.display = '';
} else {
backToFeedBtn.style.display = 'none';
allChannelsBtn.style.display = 'none';
myChannelsBtn.style.display = isGuest ? 'none' : '';
topTitle.textContent = 'Каналы';
createInMyBtn.style.display = 'none';
}
updateBottomCta({
button: bottomCta,
listState,
navigate,
isTabEmpty,
});
};
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];
if (isGuest && listState.activeTab === 'my') listState.activeTab = 'feed';
rerenderList();
}, { passive: true });
screen.append(topBarEl, 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;
}