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

1260 lines
45 KiB
JavaScript

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 { createDropdownMenu } from '../components/dropdown-menu.js';
import { createTopBar } from '../components/topbar.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden' } };
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 CHANNEL_READ_SETTING_TYPE = 1;
const CHANNELS_VIEW_ALL = 'all';
const CHANNELS_VIEW_OWNED = 'owned';
const CHANNELS_VIEW_FOLLOWING = 'following';
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 channelReadSettingKey(summary) {
const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim();
const channelName = String(summary?.channel?.channelName || '').trim();
if (!ownerBch || !channelName) return '';
return `${ownerBch}/${channelName}`;
}
async function ensureChannelReadBaselines(feed) {
const login = String(state.session.login || '').trim();
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) return;
let settingsPayload;
try {
settingsPayload = await authService.listUserSettings(login);
} catch {
return;
}
const existing = new Set(
(Array.isArray(settingsPayload?.settings) ? settingsPayload.settings : [])
.filter((item) => Number(item?.setting_type) === CHANNEL_READ_SETTING_TYPE)
.map((item) => String(item?.setting_key || '').trim())
.filter(Boolean),
);
const summaries = [
...(Array.isArray(feed?.followedUsersChannels) ? feed.followedUsersChannels : []),
...(Array.isArray(feed?.followedChannels) ? feed.followedChannels : []),
].filter(isVisibleChannelSummary);
for (const summary of summaries) {
const settingKey = channelReadSettingKey(summary);
if (!settingKey || existing.has(settingKey)) continue;
try {
await authService.upsertUserSetting({
login,
settingType: CHANNEL_READ_SETTING_TYPE,
settingKey,
timeMs: Date.now(),
valueText: '',
valueNum: Math.max(0, Number(summary?.messagesCount || 0)),
storagePwd,
});
existing.add(settingKey);
} catch {
// Не ломаем экран каналов из-за фоновой инициализации read-state.
}
}
}
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',
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 || 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 = `
<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 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 previewLine = document.createElement('div');
previewLine.className = 'channel-row-preview-line';
const preview = document.createElement('p');
preview.className = 'channel-row-message';
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
const time = document.createElement('span');
time.className = 'channel-row-time';
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
previewLine.append(preview, time);
const meta = document.createElement('p');
meta.className = 'channel-row-owner channel-counter-meta';
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
main.prepend(title, 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;
});
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 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);
}
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 }) {
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);
void ensureChannelReadBaselines(feed);
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 createSuccessFlash = pullCreateSuccessFlash();
const notificationsState = readChannelNotificationsState();
const isGuest = !state.session.isAuthorized;
const listState = {
notificationsState,
revealedCounters: new Set(),
channels: [],
viewMode: normalizeChannelsViewMode(route),
};
const contentEl = document.createElement('div');
contentEl.className = 'channels-list-content';
const topTitle = document.createElement('button');
topTitle.type = 'button';
topTitle.className = 'channels-top-title channels-filter-title';
const channelsFilterMenu = createDropdownMenu({
transparent: true,
anchorEl: topTitle,
placement: 'bottom-start',
leftShift: 72,
minWidth: 220,
items: [
{ label: 'Все каналы', iconHtml: channelMenuIcon('all'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_ALL)) },
{ label: 'Мои каналы', iconHtml: channelMenuIcon('mine'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_OWNED)) },
{ label: 'Подписки', iconHtml: channelMenuIcon('following'), action: () => navigate(buildChannelsViewRoute(CHANNELS_VIEW_FOLLOWING)) },
],
});
const topBarEl = createTopBar({
center: topTitle,
actions: [
{
iconNode: createOverflowDots(),
title: 'Ещё действия',
ariaLabel: 'Ещё действия',
className: 'channels-top-more-btn',
menu: {
minWidth: 220,
items: [
{ label: 'Найти канал', iconHtml: channelMenuIcon('search'), action: () => openChannelFinderModal({ navigate }) },
{ label: 'Новый канал', iconHtml: channelMenuIcon('add'), action: () => navigate('add-channel-view') },
],
},
},
],
});
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
const bottomCta = document.createElement('button');
bottomCta.type = 'button';
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
const rerenderList = () => {
listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} });
renderListContent({
screen,
container: contentEl,
listState,
navigate,
refreshFeed: reloadFeed,
});
topTitle.textContent = channelsViewTitle(listState.viewMode);
topMenuBtn.style.display = '';
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 = () => {
channelsFilterMenu.destroy();
};
return screen;
}