Files
SHiNE-server/shine-UI/js/pages/channels-list.js
T
qandClaude Opus 5.5 2e62616b46 UI v4: новый стиль по макету V3/V4 и доводка экранов
Палитра «Сияние» по умолчанию, новые роли цветов. Лента канала: плоские записи, ряд действий
с равным шагом иконок, дата у записи. Обсуждение со строкой ввода, личный чат, шторки.
Вход и регистрация: стартовый экран (надпись «СИЯНИЕ», сияние мандалы), настройки входа
со статусами серверов, статус логина в одну строку, вход с другого устройства (код, таймер, шаги),
сохранение ключей с переключателями. Корневые разделы – заголовок по центру; фильтр чатов –
переключатель; уведомления – список как в чатах; фото – просмотр на весь экран.
Общее: один зазор под шапкой (--page-top-gap), переключатели высотой с поле ввода,
второстепенные кнопки по одной роли (в том числе на экранах устройств и серверов),
иконки только из ui-icon.js, полоса прокрутки скрыта, среднее тире в текстах интерфейса.

Что нужно от сервера и разработчика – docs/UI-Design/UI-v4-for-dev.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:36:52 +03:00

1280 lines
48 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 { iconHtml } from '../components/ui-icon.js';
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,
formatListTime,
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 { createDropdownMenu } from '../components/dropdown-menu.js';
import { createTopBar } from '../components/topbar.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
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';
const listQueries = new Map();
function channelMenuIcon(name) {
const paths = {
search: '<circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/>',
add: '<path d="M12 5v14M5 12h14"/>',
all: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><rect x="14" y="14" width="6" height="6" rx="1"/>',
mine: '<circle cx="12" cy="8" r="4"/><path d="M5 21a7 7 0 0 1 14 0"/>',
following: '<path d="M12 21s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 11c0 5.6-7 10-7 10Z"/>',
subscribe: '<path d="M12 3a6 6 0 0 0-6 6c0 7-3 7-3 9h18c0-2-3-2-3-9a6 6 0 0 0-6-6Z"/><path d="M10 21h4"/>',
notifications: '<path d="M12 3a6 6 0 0 0-6 6c0 7-3 7-3 9h18c0-2-3-2-3-9a6 6 0 0 0-6-6Z"/><path d="M10 21h4"/>',
};
return `<svg class="channel-menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || ''}</svg>`;
}
function cleanChannelMessagePreview(text) {
const parsed = parseMessageAttachments(text);
const dmParsed = parseDmTechBlocks(String(parsed.text || ''));
return String(dmParsed.displayText || dmParsed.visibleText || '').trim()
|| (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
}
// Keep the channel-list preview tolerant to small API naming changes. The current
// server uses lastMessage.text/createdAtMs; legacy/alternate payloads are accepted
// so the third line (last message + time) does not silently disappear.
function resolveChannelLastMessage(summary) {
const row = summary?.lastMessage || summary?.latestMessage || summary?.last_message || null;
if (!row || typeof row !== 'object') return { text: '', createdAtMs: 0 };
return {
text: String(row.text ?? row.messageText ?? row.preview ?? row.body ?? '').trim(),
createdAtMs: Number(row.createdAtMs ?? row.timeMs ?? row.created_at_ms ?? 0),
};
}
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: 'lg',
className: 'avatar-plain',
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 = 'ui-button 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'
? '<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 = 'ui-button 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}`;
const lastMessage = resolveChannelLastMessage(summary);
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(lastMessage.text),
messagesCount: Number(summary?.messagesCount || 0),
unreadCount: Number(summary?.unreadCount || 0),
lastMessageAt: Number(lastMessage.createdAtMs || summary?.channel?.metaUpdatedAtMs || 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(listState, navigate) {
const wrap = document.createElement('div');
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
const heading = document.createElement('strong');
heading.textContent = listState.query ? 'Ничего не найдено' : listState.viewMode === CHANNELS_VIEW_FOLLOWING ? 'Пока нет подписок' : listState.viewMode === CHANNELS_VIEW_OWNED ? 'Здесь будут ваши каналы' : 'Откройте свой первый канал';
const text = document.createElement('p');
text.className = 'meta-muted';
text.textContent = state.session.isAuthorized ? 'Найдите канал по имени автора или создайте свой.' : 'Войдите, чтобы видеть свои каналы и подписки.';
const action = document.createElement('button');
action.type = 'button';
action.className = 'primary-btn';
action.textContent = state.session.isAuthorized ? 'Найти по @автору' : 'Войти';
action.addEventListener('click', () => state.session.isAuthorized ? openChannelFinderModal({ navigate }) : navigate('login-view'));
wrap.append(heading, text, action);
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 = `
<strong>${localOnly ? 'Локальный тестовый режим' : 'Включен демо-режим'}</strong>
<p class="meta-muted">${localOnly
? 'Показаны мок-каналы. Подключение к серверу отключено.'
: 'Данные сервера недоступны. Показаны мок-каналы, потому что включен channelsDemo.'}</p>
${localOnly ? '' : `<p class="meta-muted">${toUserMessage(error, 'Ошибка API/WS')}</p>`}
`;
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 = `
<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.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 renderChannelMain(channel) {
const main = document.createElement('div');
main.className = 'channel-row-main';
const titleLine = document.createElement('div');
titleLine.className = 'channel-row-title-line';
const title = document.createElement('strong');
title.className = 'channel-row-title';
title.textContent = channel.title;
const time = document.createElement('span');
time.className = 'channel-row-time';
time.textContent = channel.lastMessageAt ? formatListTime(channel.lastMessageAt) : '';
titleLine.append(title, time);
const technical = document.createElement('p');
technical.className = 'channel-row-technical';
technical.textContent = `@${channel.ownerName || 'автор'}`;
technical.hidden = !!channel.isOwnChannel;
if (channel.channelDescription) {
const desc = document.createElement('p');
desc.className = 'channel-row-description';
desc.textContent = channel.channelDescription;
main.append(desc);
}
const previewLine = document.createElement('div');
previewLine.className = 'channel-row-preview-line';
const preview = document.createElement('p');
preview.className = 'channel-row-message';
preview.textContent = channel.messagePreview || 'Пока нет сообщений';
if (!channel.messagePreview) preview.classList.add('is-empty');
previewLine.append(preview);
const meta = document.createElement('p');
meta.className = 'channel-row-owner channel-counter-meta';
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
main.prepend(titleLine, technical);
main.append(previewLine, 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;
}).filter((channel) => {
const query = String(listState.query || '').trim().toLowerCase();
if (!query) return true;
return [channel.title, channel.ownerName, channel.channelName, channel.technicalLabel]
.some((value) => String(value || '').toLowerCase().includes(query));
});
if (!filtered.length) {
container.append(renderEmptyState(listState, 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('button');
row.type = 'button';
row.className = 'channel-row';
const countersVisible = listState.revealedCounters.has(channel.id);
row.classList.toggle('is-counters-visible', countersVisible);
row.classList.toggle('has-unread', Number(channel.unreadCount || 0) > 0);
const avatar = createChannelAvatar(channel);
const main = renderChannelMain(channel);
const controls = document.createElement('div');
controls.className = 'channel-row-controls';
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(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);
}
async function loadFeedAndRender({ screen, listState, contentEl, navigate, silent = false }) {
if (listState.disposed) return;
const seq = ++listState.loadSeq;
if (!silent) 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);
if (listState.disposed || seq !== listState.loadSeq) return;
// FEATURE DISABLED: personal Diary is intentionally hidden from the Channels UI.
// The server/API implementation is preserved so the feature can be restored later.
// See: docs/закомментировано/Личный_дневник.md
// let diaryPayload = null;
// try {
// diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc');
// } catch {
// diaryPayload = null;
// }
const 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 }),
});
if (Number.isFinite(listState.restorePosition)) {
restoreChannelPosition(listState.restorePosition);
listState.restorePosition = undefined;
}
} catch (error) {
if (listState.disposed || seq !== listState.loadSeq) return;
if (silent) return;
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 createSuccessFlash = pullCreateSuccessFlash();
const notificationsState = readChannelNotificationsState();
const isGuest = !state.session.isAuthorized;
const positionKey = `${state.session.login}:channels:${normalizeChannelsViewMode(route)}`;
const listState = {
restorePosition: readChannelPosition(positionKey),
disposed: false,
loadSeq: 0,
notificationsState,
revealedCounters: new Set(),
channels: [],
viewMode: normalizeChannelsViewMode(route),
query: listQueries.get(positionKey) || '',
};
const contentEl = document.createElement('div');
contentEl.className = 'channels-list-content';
const topBarEl = createTopBar({
title: 'Каналы',
className: 'topbar--root',
actions: [
{
icon: 'plus',
title: 'Создать канал',
ariaLabel: 'Создать канал',
className: 'channels-create-btn',
onClick: () => navigate('add-channel-view'),
},
],
});
const controls = document.createElement('div');
controls.className = 'channels-list-controls';
const searchWrap = document.createElement('div');
searchWrap.className = 'channels-inline-search';
searchWrap.innerHTML = `<span class="channels-inline-search__icon" aria-hidden="true">${iconHtml('search')}</span><span class="sr-only">Поиск каналов</span>`;
const searchInput = document.createElement('input');
searchInput.type = 'search';
searchInput.value = listState.query;
searchInput.placeholder = 'В вашем списке';
searchInput.setAttribute('aria-label', 'Найти канал или автора');
const serverSearchButton = document.createElement('button');
serverSearchButton.type = 'button';
serverSearchButton.className = 'text-btn channels-server-search';
serverSearchButton.textContent = 'По @автору';
serverSearchButton.addEventListener('click', () => openChannelFinderModal({ navigate }));
searchWrap.append(searchInput, serverSearchButton);
const tabs = document.createElement('div');
tabs.className = 'tabs tabs--three';
tabs.setAttribute('role', 'tablist');
tabs.setAttribute('aria-label', 'Фильтр каналов');
tabs.addEventListener('keydown', (event) => {
const buttons = [...tabs.querySelectorAll('button')];
const index = buttons.indexOf(document.activeElement);
if (index < 0 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length;
buttons[next].focus();
});
[
[CHANNELS_VIEW_ALL, 'Все'],
[CHANNELS_VIEW_FOLLOWING, 'Подписки'],
[CHANNELS_VIEW_OWNED, 'Мои'],
].forEach(([mode, label]) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'tab-btn';
button.textContent = label;
button.setAttribute('role', 'tab');
const selected = listState.viewMode === mode;
button.classList.toggle('active', selected);
button.setAttribute('aria-selected', String(selected));
button.addEventListener('click', () => navigate(buildChannelsViewRoute(mode)));
tabs.append(button);
});
controls.append(searchWrap, tabs);
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
let countersRefreshTimer = null;
let lastChannelsUnreadCount = Math.max(0, Number(state.userCounters?.channelsUnreadCount || 0) || 0);
const unsubscribeCountersChanged = authService.onEvent('UserCountersChanged', (event) => {
const nextChannelsUnreadCount = Math.max(0, Number(event?.payload?.channelsUnreadCount || 0) || 0);
if (nextChannelsUnreadCount === lastChannelsUnreadCount) return;
lastChannelsUnreadCount = nextChannelsUnreadCount;
if (countersRefreshTimer) window.clearTimeout(countersRefreshTimer);
countersRefreshTimer = window.setTimeout(() => {
countersRefreshTimer = null;
void loadFeedAndRender({ screen, listState, contentEl, navigate, silent: true });
}, 120);
});
const rerenderList = () => {
listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} });
renderListContent({
screen,
container: contentEl,
listState,
navigate,
refreshFeed: reloadFeed,
});
};
searchInput.addEventListener('input', () => {
listState.restorePosition = undefined;
listState.query = searchInput.value;
rerenderList();
});
chrome?.setTopbar(topBarEl);
screen.append(controls, contentEl);
if (createSuccessFlash) {
showToast(createSuccessFlash);
}
// Применяем корректное состояние хедера сразу на первом рендере,
// чтобы не показывать лишние кнопки до первой перерисовки.
rerenderList();
loadFeedAndRender({ screen, listState, contentEl, navigate });
screen.cleanup = () => {
if (listState.disposed) return;
rememberChannelPosition(positionKey);
listQueries.set(positionKey, listState.query);
listState.disposed = true;
if (countersRefreshTimer) window.clearTimeout(countersRefreshTimer);
unsubscribeCountersChanged();
};
return screen;
}