import { channels as mockChannels } from '../mock-data.js';
import { authService, setChannelsFeed, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { parseMessageAttachments } from '../services/attachment-format.js';
import {
animatePress,
createSkeletonCard,
formatRelativeTime,
readChannelNotificationsState,
showToast,
softHaptic,
writeChannelNotificationsState,
} from '../services/channels-ux.js';
import { makeShineChannelRoute } from '../services/shine-routes.js';
import { makeShineChannelShortRoute } from '../services/shine-routes.js';
import { renderAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.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 TOP_MENU_OVERLAY_ID = 'channels-top-menu-overlay';
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const DIARY_CHANNEL_NAME = 'diary';
const DIARY_DISPLAY_NAME = 'Дневник';
const CHANNELS_VIEW_ALL = 'all';
const CHANNELS_VIEW_OWNED = 'owned';
const CHANNELS_VIEW_FOLLOWING = 'following';
function cleanChannelMessagePreview(text) {
const parsed = parseMessageAttachments(text);
const dmParsed = parseDmTechBlocks(String(parsed.text || ''));
return String(dmParsed.displayText || dmParsed.visibleText || '').trim()
|| (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
}
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 makeShineChannelShortRoute({
ownerBlockchainName: ownerBch,
channelName: channelName || fallbackId || ownerLogin,
});
}
function isStoriesChannel(summaryOrChannel = null) {
const typeCode = Number(summaryOrChannel?.channel?.channelTypeCode ?? summaryOrChannel?.channelTypeCode ?? 1);
const channelName = String(summaryOrChannel?.channel?.channelName || summaryOrChannel?.channelName || '').trim().toLowerCase();
return typeCode === CHANNEL_TYPE_STORIES || channelName === 'stories';
}
function isVisibleChannelSummary(summary) {
if (!summary?.channel) return false;
if (isStoriesChannel(summary)) return false;
const ownerLogin = String(summary.channel.ownerLogin || '').trim();
const channelName = String(summary.channel.channelName || '').trim();
return !!ownerLogin && !!channelName;
}
function avatarLetterFromName(name = '') {
const first = Array.from(String(name || '').trim())[0] || '#';
return first.toUpperCase();
}
function createChannelAvatar(channel = {}) {
return renderAvatar({
initials: channel.avatar || channel.initials || avatarLetterFromName(channel.displayTitle || channel.title || channel.name),
avatar: channel.avaAr ? { ar: String(channel.avaAr || '').trim() } : null,
size: 'md',
title: channel.displayTitle || channel.title || channel.name || 'Канал',
alt: 'Аватар канала',
});
}
function allFeedSummaries() {
const feed = state.channelsFeed || {};
return [
...(feed.ownedChannels || []),
...(feed.followedUsersChannels || []),
...(feed.followedChannels || []),
].filter(isVisibleChannelSummary);
}
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.filter(isVisibleChannelSummary) : [];
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 normalizeChannelsViewMode(route = null) {
const mode = String(route?.params?.mode || '').trim().toLowerCase();
const scope = String(route?.params?.scope || '').trim().toLowerCase();
if (mode === 'my' || scope === 'owned') return CHANNELS_VIEW_OWNED;
if (mode === 'following' || scope === 'following') return CHANNELS_VIEW_FOLLOWING;
return CHANNELS_VIEW_ALL;
}
function buildChannelsViewRoute(mode) {
if (mode === CHANNELS_VIEW_OWNED) return 'channels/my';
if (mode === CHANNELS_VIEW_FOLLOWING) return 'channels/following';
return 'channels';
}
function channelsViewTitle(mode) {
if (mode === CHANNELS_VIEW_OWNED) return 'Мои каналы';
if (mode === CHANNELS_VIEW_FOLLOWING) return 'Подписки';
return 'Каналы';
}
function isFollowedUserVisible(targetLogin) {
const expected = normalizeComparableLogin(targetLogin);
if (!expected) return false;
const rows = Array.isArray(state.channelsFeed?.followedUsersChannels)
? state.channelsFeed.followedUsersChannels
: [];
return rows
.filter(isVisibleChannelSummary)
.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.filter(isVisibleChannelSummary).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 = `
${kindLabel}
${targetHint}
`;
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 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.filter(isVisibleChannelSummary) : [];
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,
sourceBucket: channel.kind === 'subscribed'
? 'followedChannels'
: channel.kind === 'followed-user-channel'
? 'followedUsers'
: 'own',
route: makeShineChannelRoute({
ownerLogin: String(channel.ownerName || 'channel'),
ownerBlockchainName: String(channel.ownerName || ''),
channelName: String(channel.channelName || channel.title || channel.id),
}),
messagePreview: cleanChannelMessagePreview(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))
.filter((item) => !isStoriesChannel(item));
const followedUserChannels = mockChannels
.filter((channel) => channel.kind === 'followed-user-channel')
.map((item) => mapRow(item))
.filter((item) => !isStoriesChannel(item));
const subscribedChannels = mockChannels
.filter((channel) => channel.kind === 'subscribed')
.map((item) => mapRow(item))
.filter((item) => !isStoriesChannel(item));
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 displayTitle = String(summary?.channel?.displayName || channelName).trim();
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 title = displayTitle || channelName;
const technicalLabel = `${ownerLogin} / ${channelName}`;
return {
id: rowId,
sourceBucket: bucketKey,
route: buildChannelRouteFromSummary(summary, rowId),
ownerName: ownerLogin,
ownerBlockchainName: summary?.channel?.ownerBlockchainName || '',
channelRootBlockNumber: Number(summary?.channel?.channelRoot?.blockNumber),
channelRootBlockHash: normalizeHash(summary?.channel?.channelRoot?.blockHash),
avatar: avatarLetterFromName(displayTitle || channelName),
avaAr: String(summary?.channel?.avaAr || '').trim(),
title,
technicalLabel,
channelName,
displayTitle,
channelDescription,
channelTypeCode,
channelTypeVersion,
messagePreview: cleanChannelMessagePreview(summary?.lastMessage?.text),
messagesCount: Number(summary?.messagesCount || 0),
unreadCount: Number(summary?.unreadCount || 0),
lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 0),
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 buildDiaryChannelRow(diaryPayload, ownRows = [], notificationsState = {}, index = {}) {
const messages = Array.isArray(diaryPayload?.messages) ? diaryPayload.messages : [];
if (!messages.length) return null;
const ownerBlockchainName = String(
diaryPayload?.channel?.ownerBlockchainName
|| ownRows[0]?.channel?.ownerBlockchainName
|| ''
).trim();
if (!ownerBlockchainName) return null;
const ownerLogin = String(diaryPayload?.channel?.ownerLogin || state.session.login || '').trim();
const lastMessage = messages[messages.length - 1] || null;
const rowId = 'own-diary';
index[rowId] = { diary: true, payload: diaryPayload };
return {
id: rowId,
sourceBucket: 'own',
route: makeShineChannelRoute({
ownerLogin,
ownerBlockchainName,
channelName: DIARY_CHANNEL_NAME,
}),
ownerName: ownerLogin || 'я',
ownerBlockchainName,
channelRootBlockNumber: 0,
channelRootBlockHash: '0',
avatar: 'Д',
avaAr: '',
title: DIARY_DISPLAY_NAME,
technicalLabel: 'Виртуальная лента Дневника',
channelName: DIARY_CHANNEL_NAME,
displayTitle: DIARY_DISPLAY_NAME,
channelDescription: 'История действий по упражнениям, услугам и курсам',
channelTypeCode: 900,
channelTypeVersion: 1,
messagePreview: String(lastMessage?.text || '').trim()
? cleanChannelMessagePreview(lastMessage?.text)
: 'Новая запись в Дневнике',
messagesCount: messages.length,
unreadCount: 0,
lastMessageAt: Number(lastMessage?.createdAtMs || 0),
isOwnChannel: true,
isSubscribed: false,
notificationsEnabled: notificationsState[rowId] === true,
pending: false,
};
}
function mapApiFeed(feed, notificationsState, diaryPayload = null) {
const index = {};
const ownChannels = (feed?.ownedChannels || [])
.filter(isVisibleChannelSummary)
.map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState));
const diaryChannel = diaryPayload
? buildDiaryChannelRow(diaryPayload, feed?.ownedChannels || [], notificationsState, index)
: null;
if (diaryChannel) ownChannels.unshift(diaryChannel);
const followedUserChannels = (feed?.followedUsersChannels || [])
.filter(isVisibleChannelSummary)
.map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState));
const subscribedChannels = (feed?.followedChannels || [])
.filter(isVisibleChannelSummary)
.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() {
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';
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, { localOnly = false } = {}) {
const info = document.createElement('div');
info.className = 'card stack';
info.innerHTML = `
${localOnly ? 'Локальный тестовый режим' : 'Включен демо-режим'}
${localOnly
? 'Показаны мок-каналы. Подключение к серверу отключено.'
: 'Данные сервера недоступны. Показаны мок-каналы, потому что включен channelsDemo.'}
${localOnly ? '' : `${toUserMessage(error, 'Ошибка API/WS')}
`}
`;
if (!localOnly && typeof onRetry === 'function') {
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.title || channel.displayName || channel.name}
${channel.messagePreview || 'Ждем ваших начинаний'}
—
`;
row.prepend(createChannelAvatar(channel));
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 closeTopChannelsMenu(listState) {
if (typeof listState.topMenuCleanup === 'function') {
listState.topMenuCleanup();
}
listState.topMenuCleanup = null;
const root = document.getElementById('modal-root');
if (root) {
const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`);
if (overlay) overlay.remove();
}
}
function openTopChannelsMenu({
listState,
anchorEl,
navigate,
onFindChannel,
}) {
closeTopChannelsMenu(listState);
const root = document.getElementById('modal-root');
if (!root || !anchorEl) return;
const rect = anchorEl.getBoundingClientRect();
const menuWidth = Math.min(280, Math.max(220, window.innerWidth - 28));
let left = rect.right - menuWidth;
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
const estimatedHeight = 250;
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 = TOP_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 items = [
{ label: 'Поиск', action: () => onFindChannel?.() },
{ label: 'Новый канал', action: () => navigate('add-channel-view') },
{ divider: true },
{ label: 'Все каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
{ label: 'Мои каналы', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
{ label: 'Подписки', action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
];
items.forEach((item) => {
if (item.divider) {
const divider = document.createElement('div');
divider.className = 'channel-menu-divider';
divider.style.height = '1px';
divider.style.background = 'rgba(255,255,255,0.08)';
divider.style.margin = '6px 0';
menu.append(divider);
return;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'channel-menu-item';
btn.textContent = item.label;
btn.addEventListener('click', () => {
closeTopChannelsMenu(listState);
item.action?.();
});
menu.append(btn);
});
overlay.append(menu);
root.append(overlay);
const onOverlayClick = (event) => {
if (event.target === overlay) closeTopChannelsMenu(listState);
};
const onWindowResize = () => closeTopChannelsMenu(listState);
overlay.addEventListener('click', onOverlayClick);
window.addEventListener('resize', onWindowResize);
listState.topMenuCleanup = () => {
overlay.removeEventListener('click', onOverlayClick);
window.removeEventListener('resize', onWindowResize);
};
}
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) {
const main = document.createElement('div');
main.className = 'channel-row-main';
const title = document.createElement('strong');
title.className = 'channel-row-title';
title.textContent = channel.title;
const technical = document.createElement('p');
technical.className = 'channel-row-technical';
technical.textContent = channel.technicalLabel || `${channel.ownerName || ''} / ${channel.channelName || ''}`;
if (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, technical);
main.append(preview, meta);
return main;
}
function renderListContent({ screen, container, listState, navigate, refreshFeed }) {
container.innerHTML = '';
const allChannels = listState.channels || [];
const filtered = allChannels.filter((channel) => {
if (listState.viewMode === CHANNELS_VIEW_OWNED) return channel.isOwnChannel === true;
if (listState.viewMode === CHANNELS_VIEW_FOLLOWING) return channel.sourceBucket === 'followedChannels';
return true;
});
if (!filtered.length) {
container.append(renderEmptyState());
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 = createChannelAvatar(channel);
const main = renderChannelMain(channel);
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.hidden = unreadCount <= 0;
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
count.classList.toggle('is-empty', unreadCount <= 0);
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 }) {
if (!button) return;
button.hidden = true;
button.textContent = '';
button.className = 'channels-bottom-action';
button.onclick = null;
}
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;
}
if (state.session.isLocalDemo) {
setChannelsFeed(null, {});
listState.channels = [];
contentEl.innerHTML = '';
renderDemoFallback(contentEl, navigate, null, null, { localOnly: true });
return;
}
try {
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
let diaryPayload = null;
try {
diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc');
} catch {
diaryPayload = null;
}
const groups = mapApiFeed(feed, listState.notificationsState, diaryPayload);
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, chrome }) {
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 = {
openMenuId: null,
topMenuCleanup: null,
notificationsState,
revealedCounters: new Set(),
channels: [],
menuCleanup: null,
viewMode: normalizeChannelsViewMode(route),
};
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 topTitle = document.createElement('strong');
topTitle.className = 'channels-top-title';
const topBarRight = document.createElement('div');
topBarRight.className = 'channels-top-right';
const topMenuBtn = document.createElement('button');
topMenuBtn.type = 'button';
topMenuBtn.className = 'icon-btn channels-top-more-btn';
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
topMenuBtn.title = 'Ещё действия';
topMenuBtn.append(createOverflowDots());
topMenuBtn.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(topMenuBtn);
openTopChannelsMenu({
listState,
anchorEl: topMenuBtn,
navigate,
onFindChannel: () => openChannelFinderModal({ navigate }),
});
});
topBarRight.append(topMenuBtn);
topBarEl.append(topBarLeft, topTitle, topBarRight);
const bottomCta = document.createElement('button');
bottomCta.type = 'button';
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
const rerenderList = () => {
listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} });
closeChannelMenu(listState);
closeTopChannelsMenu(listState);
renderListContent({
screen,
container: contentEl,
listState,
navigate,
refreshFeed: reloadFeed,
});
topTitle.textContent = channelsViewTitle(listState.viewMode);
topMenuBtn.style.display = '';
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
updateBottomCta({ button: bottomCta });
};
chrome?.setTopbar(topBarEl);
screen.append(contentEl, bottomCta);
if (createSuccessFlash) {
showToast(createSuccessFlash);
}
updateBottomCta({ button: bottomCta });
// Применяем корректное состояние хедера сразу на первом рендере,
// чтобы не показывать лишние кнопки до первой перерисовки.
rerenderList();
loadFeedAndRender({ screen, listState, contentEl, navigate });
screen.cleanup = () => {
closeChannelMenu(listState);
closeTopChannelsMenu(listState);
appScreen?.classList.remove('channels-scroll-clean');
};
return screen;
}