1243 lines
43 KiB
JavaScript
1243 lines
43 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 {
|
||
animatePress,
|
||
createSkeletonCard,
|
||
formatRelativeTime,
|
||
readChannelNotificationsState,
|
||
showToast,
|
||
softHaptic,
|
||
writeChannelNotificationsState,
|
||
} from '../services/channels-ux.js';
|
||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||
import { navigateBack } from '../router.js';
|
||
|
||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||
|
||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
|
||
const CHANNEL_TYPE_STORIES = 0;
|
||
const CHANNEL_TYPE_PERSONAL = 100;
|
||
|
||
function isChannelsDemoMode() {
|
||
try {
|
||
const qs = new URLSearchParams(window.location.search);
|
||
if (qs.get('channelsDemo') === '1') return true;
|
||
return localStorage.getItem('shine-channels-demo') === '1';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function normalizeHash(hash) {
|
||
const normalized = String(hash || '').trim().toLowerCase();
|
||
return normalized || '0';
|
||
}
|
||
|
||
function encodeRoutePart(value = '') {
|
||
return encodeURIComponent(String(value));
|
||
}
|
||
|
||
function normalizeLoginInput(value) {
|
||
return String(value || '').trim().replace(/^@+/, '');
|
||
}
|
||
|
||
function buildChannelRouteFromSummary(summary, fallbackId) {
|
||
const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim();
|
||
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
|
||
const channelName = String(summary?.channel?.channelName || '').trim();
|
||
return makeShineChannelRoute({
|
||
ownerLogin,
|
||
ownerBlockchainName: ownerBch,
|
||
channelName: channelName || fallbackId,
|
||
});
|
||
}
|
||
|
||
function 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 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 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 = '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,
|
||
route: makeShineChannelRoute({
|
||
ownerLogin: String(channel.ownerName || 'channel'),
|
||
ownerBlockchainName: String(channel.ownerName || ''),
|
||
channelName: String(channel.channelName || channel.title || channel.id),
|
||
}),
|
||
messagePreview: channel.lastMessage || 'Ждем ваших начинаний',
|
||
isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal',
|
||
isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal',
|
||
notificationsEnabled: false,
|
||
messagesCount: Number(channel.messagesCount || 0),
|
||
unreadCount: 0,
|
||
lastMessageAt: 0,
|
||
ownerName: String(channel.ownerName || 'неизвестно'),
|
||
channelName: String(channel.channelName || channel.title || ''),
|
||
title: String(channel.title || channel.channelName || ''),
|
||
});
|
||
|
||
const ownChannels = mockChannels
|
||
.filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own')
|
||
.map((item) => mapRow(item))
|
||
.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 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 = `${ownerLogin} / ${channelName}`;
|
||
|
||
return {
|
||
id: rowId,
|
||
route: buildChannelRouteFromSummary(summary, rowId),
|
||
ownerName: ownerLogin,
|
||
ownerBlockchainName: summary?.channel?.ownerBlockchainName || '',
|
||
channelRootBlockNumber: Number(summary?.channel?.channelRoot?.blockNumber),
|
||
channelRootBlockHash: normalizeHash(summary?.channel?.channelRoot?.blockHash),
|
||
avatar: avatarLetterFromName(channelName),
|
||
title,
|
||
channelName,
|
||
channelDescription,
|
||
channelTypeCode,
|
||
channelTypeVersion,
|
||
messagePreview: summary?.lastMessage?.text || 'Ждем ваших начинаний',
|
||
messagesCount: Number(summary?.messagesCount || 0),
|
||
unreadCount: Number(summary?.unreadCount || 0),
|
||
lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 0),
|
||
isOwnChannel: isOwn,
|
||
isSubscribed: !isOwn,
|
||
notificationsEnabled: notificationsState[rowId] === true,
|
||
pending: false,
|
||
};
|
||
}
|
||
|
||
function pullCreateSuccessFlash() {
|
||
try {
|
||
const value = String(sessionStorage.getItem(CREATE_CHANNEL_FLASH_KEY) || '').trim();
|
||
if (value) sessionStorage.removeItem(CREATE_CHANNEL_FLASH_KEY);
|
||
return value;
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function mapApiFeed(feed, notificationsState) {
|
||
const index = {};
|
||
const ownChannels = (feed?.ownedChannels || [])
|
||
.filter(isVisibleChannelSummary)
|
||
.map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState));
|
||
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) {
|
||
const info = document.createElement('div');
|
||
info.className = 'card stack';
|
||
info.innerHTML = `
|
||
<strong>Включен демо-режим</strong>
|
||
<p class="meta-muted">Данные сервера недоступны. Показаны мок-каналы, потому что включен channelsDemo.</p>
|
||
<p class="meta-muted">${toUserMessage(error, 'Ошибка API/WS')}</p>
|
||
`;
|
||
|
||
const retry = document.createElement('button');
|
||
retry.className = 'secondary-btn';
|
||
retry.type = 'button';
|
||
retry.textContent = 'Повторить запрос к серверу';
|
||
retry.addEventListener('click', onRetry);
|
||
|
||
info.append(retry);
|
||
container.append(info);
|
||
|
||
const groups = mapMockGroups();
|
||
const list = document.createElement('div');
|
||
list.className = 'stack';
|
||
|
||
toListModel(groups).forEach((channel) => {
|
||
const row = document.createElement('article');
|
||
row.className = 'channel-row';
|
||
row.innerHTML = `
|
||
<div class="avatar">${channel.avatar || channel.initials || '#'}</div>
|
||
<div class="channel-row-main">
|
||
<strong class="channel-row-title">${channel.title || channel.displayName || channel.name}</strong>
|
||
<p class="channel-row-message">${channel.messagePreview || 'Ждем ваших начинаний'}</p>
|
||
</div>
|
||
<div class="channel-row-controls">
|
||
<span class="channel-row-time">—</span>
|
||
</div>
|
||
`;
|
||
row.addEventListener('click', () => {
|
||
const route = channel.route || makeShineChannelRoute({
|
||
ownerLogin: String(channel.ownerName || 'channel'),
|
||
ownerBlockchainName: String(channel.ownerName || ''),
|
||
channelName: String(channel.channelName || channel.id),
|
||
});
|
||
if (route) navigate(route);
|
||
});
|
||
list.append(row);
|
||
});
|
||
|
||
container.append(list);
|
||
}
|
||
|
||
function closeChannelMenu(listState, clearOpenMenuId = true) {
|
||
if (typeof listState.menuCleanup === 'function') {
|
||
listState.menuCleanup();
|
||
}
|
||
listState.menuCleanup = null;
|
||
const root = document.getElementById('modal-root');
|
||
if (root) {
|
||
const overlay = root.querySelector(`#${MENU_OVERLAY_ID}`);
|
||
if (overlay) overlay.remove();
|
||
}
|
||
if (clearOpenMenuId) {
|
||
listState.openMenuId = null;
|
||
}
|
||
}
|
||
|
||
function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderList }) {
|
||
closeChannelMenu(listState, false);
|
||
|
||
const root = document.getElementById('modal-root');
|
||
if (!root || !anchorEl) return;
|
||
|
||
const rect = anchorEl.getBoundingClientRect();
|
||
const menuWidth = Math.min(250, Math.max(220, window.innerWidth - 28));
|
||
let left = rect.right - menuWidth;
|
||
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
|
||
|
||
const estimatedHeight = 210;
|
||
let top = rect.bottom + 8;
|
||
if (top + estimatedHeight > window.innerHeight - 10) {
|
||
top = Math.max(12, rect.top - estimatedHeight - 8);
|
||
}
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = MENU_OVERLAY_ID;
|
||
overlay.className = 'channels-menu-overlay';
|
||
|
||
const menu = document.createElement('div');
|
||
menu.className = 'channel-menu-wrap channel-menu-wrap--portal';
|
||
menu.style.left = `${Math.round(left)}px`;
|
||
menu.style.top = `${Math.round(top)}px`;
|
||
menu.style.width = `${Math.round(menuWidth)}px`;
|
||
|
||
const canToggleSubscription = !channel.isOwnChannel;
|
||
const actionBtn = document.createElement('button');
|
||
actionBtn.type = 'button';
|
||
actionBtn.className = `channel-menu-item ${channel.isSubscribed ? 'destructive' : ''}`.trim();
|
||
|
||
if (canToggleSubscription) {
|
||
actionBtn.textContent = channel.pending
|
||
? 'Выполняется...'
|
||
: channel.isSubscribed
|
||
? 'Отписаться'
|
||
: 'Подписаться';
|
||
actionBtn.disabled = !!channel.pending;
|
||
|
||
actionBtn.addEventListener('click', async (event) => {
|
||
event.stopPropagation();
|
||
if (channel.pending) return;
|
||
|
||
const login = state.session.login;
|
||
const storagePwd = state.session.storagePwdInMemory;
|
||
if (!login || !storagePwd) {
|
||
showToast('Сессия недействительна. Выполните вход заново.', { kind: 'error' });
|
||
return;
|
||
}
|
||
|
||
channel.pending = true;
|
||
actionBtn.disabled = true;
|
||
actionBtn.textContent = 'Выполняется...';
|
||
|
||
const nextSubscribed = !channel.isSubscribed;
|
||
try {
|
||
await authService.addBlockFollowChannel({
|
||
login,
|
||
storagePwd,
|
||
targetBlockchainName: channel.ownerBlockchainName,
|
||
targetBlockNumber: channel.channelRootBlockNumber,
|
||
targetBlockHashHex: channel.channelRootBlockHash,
|
||
unfollow: !nextSubscribed,
|
||
});
|
||
|
||
channel.isSubscribed = nextSubscribed;
|
||
channel.pending = false;
|
||
softHaptic(15);
|
||
showToast(nextSubscribed ? 'Подписка на канал включена' : 'Подписка на канал отключена');
|
||
closeChannelMenu(listState);
|
||
await refreshFeed();
|
||
} catch (error) {
|
||
channel.pending = false;
|
||
actionBtn.disabled = false;
|
||
actionBtn.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться';
|
||
showToast(toUserMessage(error, 'Не удалось изменить подписку.'), { kind: 'error' });
|
||
rerenderList();
|
||
}
|
||
});
|
||
} else {
|
||
actionBtn.textContent = 'Собственный канал';
|
||
actionBtn.disabled = true;
|
||
}
|
||
|
||
const toggleWrap = document.createElement('div');
|
||
toggleWrap.className = 'channel-menu-toggle';
|
||
|
||
const toggleLabel = document.createElement('span');
|
||
toggleLabel.textContent = 'Уведомления';
|
||
|
||
const toggleBtn = document.createElement('button');
|
||
toggleBtn.type = 'button';
|
||
toggleBtn.className = `channel-toggle-btn ${channel.notificationsEnabled ? 'is-on' : ''}`.trim();
|
||
toggleBtn.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
animatePress(toggleBtn);
|
||
|
||
channel.notificationsEnabled = !channel.notificationsEnabled;
|
||
const next = { ...listState.notificationsState, [channel.id]: channel.notificationsEnabled };
|
||
listState.notificationsState = next;
|
||
writeChannelNotificationsState(next);
|
||
|
||
toggleBtn.classList.toggle('is-on', channel.notificationsEnabled);
|
||
softHaptic(10);
|
||
});
|
||
|
||
toggleWrap.append(toggleLabel, toggleBtn);
|
||
menu.append(actionBtn, toggleWrap);
|
||
overlay.append(menu);
|
||
root.append(overlay);
|
||
|
||
const onOverlayClick = (event) => {
|
||
if (event.target === overlay) {
|
||
closeChannelMenu(listState);
|
||
rerenderList();
|
||
}
|
||
};
|
||
|
||
const onWindowResize = () => {
|
||
closeChannelMenu(listState);
|
||
rerenderList();
|
||
};
|
||
|
||
overlay.addEventListener('click', onOverlayClick);
|
||
window.addEventListener('resize', onWindowResize);
|
||
|
||
listState.menuCleanup = () => {
|
||
overlay.removeEventListener('click', onOverlayClick);
|
||
window.removeEventListener('resize', onWindowResize);
|
||
};
|
||
}
|
||
|
||
function renderChannelMain(channel) {
|
||
const main = document.createElement('div');
|
||
main.className = 'channel-row-main';
|
||
|
||
const title = document.createElement('strong');
|
||
title.className = 'channel-row-title';
|
||
title.textContent = channel.title;
|
||
|
||
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);
|
||
main.append(preview, meta);
|
||
return main;
|
||
}
|
||
|
||
function renderListContent({ screen, container, listState, navigate, refreshFeed }) {
|
||
container.innerHTML = '';
|
||
|
||
const allChannels = listState.channels || [];
|
||
const filtered = allChannels;
|
||
|
||
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 = document.createElement('div');
|
||
avatar.className = 'avatar';
|
||
avatar.textContent = channel.avatar;
|
||
|
||
const main = renderChannelMain(channel);
|
||
|
||
const isGuest = !state.session.isAuthorized;
|
||
const controls = document.createElement('div');
|
||
controls.className = 'channel-row-controls';
|
||
|
||
const time = document.createElement('span');
|
||
time.className = 'channel-row-time';
|
||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '—';
|
||
|
||
const count = document.createElement('span');
|
||
count.className = 'unread channel-row-count';
|
||
const unreadCount = Number(channel.unreadCount || 0);
|
||
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
|
||
count.classList.toggle('is-empty', unreadCount <= 0);
|
||
|
||
if (!isGuest) {
|
||
const menuButton = document.createElement('button');
|
||
menuButton.type = 'button';
|
||
menuButton.className = 'channel-menu-trigger';
|
||
menuButton.textContent = '…';
|
||
menuButton.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
animatePress(menuButton);
|
||
listState.revealedCounters.add(channel.id);
|
||
|
||
if (listState.openMenuId === channel.id) {
|
||
closeChannelMenu(listState);
|
||
rerenderList();
|
||
return;
|
||
}
|
||
|
||
listState.openMenuId = channel.id;
|
||
openChannelMenu({
|
||
listState,
|
||
channel,
|
||
anchorEl: menuButton,
|
||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl: container, navigate }),
|
||
rerenderList,
|
||
});
|
||
rerenderList();
|
||
});
|
||
controls.append(menuButton);
|
||
}
|
||
controls.append(time, count);
|
||
|
||
row.append(avatar, main, controls);
|
||
row.addEventListener('click', () => {
|
||
const route = channel.route || makeShineChannelRoute({
|
||
ownerLogin: String(channel.ownerName || 'channel'),
|
||
ownerBlockchainName: String(channel.ownerName || ''),
|
||
channelName: String(channel.channelName || channel.id),
|
||
});
|
||
if (route) navigate(route);
|
||
});
|
||
list.append(row);
|
||
});
|
||
|
||
container.append(list);
|
||
}
|
||
|
||
function updateBottomCta({ button, listState, navigate, isTabEmpty = false }) {
|
||
const baseClass = `primary-btn channels-bottom-action${isTabEmpty ? ' is-empty-lift' : ''}`;
|
||
button.textContent = 'Поиск каналов';
|
||
button.className = baseClass;
|
||
button.onclick = () => openChannelFinderModal({ navigate });
|
||
}
|
||
|
||
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||
closeChannelMenu(listState);
|
||
renderSkeletonList(contentEl, 5);
|
||
|
||
if (!state.session.isAuthorized) {
|
||
setChannelsFeed(null, {});
|
||
listState.channels = [];
|
||
renderListContent({
|
||
screen,
|
||
container: contentEl,
|
||
listState,
|
||
navigate,
|
||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
|
||
const groups = mapApiFeed(feed, listState.notificationsState);
|
||
|
||
listState.channels = toListModel(groups);
|
||
setChannelsFeed(feed, groups.index);
|
||
|
||
renderListContent({
|
||
screen,
|
||
container: contentEl,
|
||
listState,
|
||
navigate,
|
||
refreshFeed: async () => loadFeedAndRender({ screen, listState, contentEl, navigate }),
|
||
});
|
||
} catch (error) {
|
||
setChannelsFeed(null, {});
|
||
contentEl.innerHTML = '';
|
||
|
||
if (isChannelsDemoMode()) {
|
||
renderDemoFallback(contentEl, navigate, error, () => loadFeedAndRender({ screen, listState, contentEl, navigate }));
|
||
return;
|
||
}
|
||
|
||
renderErrorState(contentEl, error, () => loadFeedAndRender({ screen, listState, contentEl, navigate }));
|
||
}
|
||
}
|
||
|
||
export function render({ navigate, route }) {
|
||
const screen = document.createElement('section');
|
||
screen.className = 'stack channels-screen channels-screen--list';
|
||
const appScreen = document.getElementById('app-screen');
|
||
appScreen?.classList.add('channels-scroll-clean');
|
||
|
||
const createSuccessFlash = pullCreateSuccessFlash();
|
||
const notificationsState = readChannelNotificationsState();
|
||
|
||
const isGuest = !state.session.isAuthorized;
|
||
const listState = {
|
||
openMenuId: null,
|
||
notificationsState,
|
||
revealedCounters: new Set(),
|
||
channels: [],
|
||
menuCleanup: null,
|
||
};
|
||
|
||
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 backBtn = document.createElement('button');
|
||
backBtn.type = 'button';
|
||
backBtn.className = 'icon-btn channels-top-back-btn';
|
||
backBtn.textContent = '←';
|
||
backBtn.setAttribute('aria-label', 'Назад');
|
||
backBtn.addEventListener('click', () => navigateBack());
|
||
|
||
const topTitle = document.createElement('strong');
|
||
topTitle.className = 'channels-top-title';
|
||
|
||
const topBarRight = document.createElement('div');
|
||
topBarRight.className = 'channels-top-right';
|
||
|
||
const findChannelBtn = document.createElement('button');
|
||
findChannelBtn.type = 'button';
|
||
findChannelBtn.className = 'icon-btn channels-top-search-btn';
|
||
findChannelBtn.textContent = '🔎';
|
||
findChannelBtn.setAttribute('aria-label', 'Найти канал');
|
||
findChannelBtn.addEventListener('click', () => openChannelFinderModal({ navigate }));
|
||
|
||
const createInMyBtn = document.createElement('button');
|
||
createInMyBtn.type = 'button';
|
||
createInMyBtn.className = 'icon-btn channels-top-add-btn';
|
||
createInMyBtn.textContent = '+';
|
||
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
||
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
|
||
|
||
topBarLeft.append(backBtn, topTitle);
|
||
topBarRight.append(findChannelBtn, createInMyBtn);
|
||
topBarEl.append(topBarLeft, topBarRight);
|
||
|
||
const bottomCta = document.createElement('button');
|
||
bottomCta.type = 'button';
|
||
|
||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||
|
||
const rerenderList = () => {
|
||
try {
|
||
const expectedPath = '/channels-list';
|
||
if (window.location.pathname !== expectedPath) {
|
||
window.history.replaceState({}, '', expectedPath);
|
||
}
|
||
} catch {
|
||
// ignore history errors
|
||
}
|
||
|
||
const isTabEmpty = !(listState.channels || []).length;
|
||
|
||
closeChannelMenu(listState);
|
||
|
||
renderListContent({
|
||
screen,
|
||
container: contentEl,
|
||
listState,
|
||
navigate,
|
||
refreshFeed: reloadFeed,
|
||
});
|
||
|
||
topTitle.textContent = 'Каналы';
|
||
findChannelBtn.style.display = '';
|
||
createInMyBtn.style.display = '';
|
||
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
||
|
||
updateBottomCta({
|
||
button: bottomCta,
|
||
listState,
|
||
navigate,
|
||
isTabEmpty,
|
||
});
|
||
};
|
||
|
||
screen.append(topBarEl, contentEl, bottomCta);
|
||
|
||
if (createSuccessFlash) {
|
||
showToast(createSuccessFlash);
|
||
}
|
||
|
||
updateBottomCta({
|
||
button: bottomCta,
|
||
listState,
|
||
navigate,
|
||
isTabEmpty: true,
|
||
});
|
||
|
||
// Применяем корректное состояние хедера сразу на первом рендере,
|
||
// чтобы не показывать лишние кнопки до первой перерисовки.
|
||
rerenderList();
|
||
loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||
|
||
screen.cleanup = () => {
|
||
closeChannelMenu(listState);
|
||
appScreen?.classList.remove('channels-scroll-clean');
|
||
};
|
||
|
||
return screen;
|
||
}
|