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

522 lines
18 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 { renderHeader } from '../components/header.js';
import { channels as mockChannels } from '../mock-data.js';
import { authService, setChannelsFeed, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
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 buildChannelRouteFromSummary(summary, fallbackId) {
const ownerBch = summary?.channel?.ownerBlockchainName;
const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber;
const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash);
if (!ownerBch || rootBlockNumber == null) return `channel-view/${fallbackId}`;
return `channel-view/${encodeRoutePart(ownerBch)}/${Number(rootBlockNumber)}/${rootBlockHash}`;
}
function initialsFromName(name = '') {
const parts = name.split(/\s+/).filter(Boolean);
return (parts[0]?.[0] || '#') + (parts[1]?.[0] || '');
}
function allFeedSummaries() {
const feed = state.channelsFeed || {};
return [
...(feed.ownedChannels || []),
...(feed.followedUsersChannels || []),
...(feed.followedChannels || []),
];
}
function resolveChannelTargetFromInput(rawInput) {
const input = String(rawInput || '').trim();
if (!input) return null;
const bySelector = input.match(/^([A-Za-z0-9._-]+-\d+)\s*[:/]\s*(\d+)\s*[:/]\s*([A-Fa-f0-9]{1,64})$/);
if (bySelector) {
return {
ownerBlockchainName: bySelector[1],
rootBlockNumber: Number(bySelector[2]),
rootBlockHash: normalizeHash(bySelector[3]),
};
}
const summaries = allFeedSummaries();
const byOwnerAndName = input.match(/^@?([^/#\s]+)\s*\/\s*#?(.+)$/);
if (byOwnerAndName) {
const owner = byOwnerAndName[1].trim().toLowerCase();
const channelName = byOwnerAndName[2].trim().toLowerCase();
const match = summaries.find((summary) => (
String(summary?.channel?.ownerLogin || '').toLowerCase() === owner
&& String(summary?.channel?.channelName || '').toLowerCase() === channelName
));
if (!match) return null;
return {
ownerBlockchainName: match.channel?.ownerBlockchainName,
rootBlockNumber: Number(match.channel?.channelRoot?.blockNumber),
rootBlockHash: normalizeHash(match.channel?.channelRoot?.blockHash),
};
}
const byNameOnly = input.replace(/^#/, '').trim().toLowerCase();
if (!byNameOnly) return null;
const matches = summaries.filter((summary) => (
String(summary?.channel?.channelName || '').toLowerCase() === byNameOnly
));
if (matches.length !== 1) return null;
return {
ownerBlockchainName: matches[0].channel?.ownerBlockchainName,
rootBlockNumber: Number(matches[0].channel?.channelRoot?.blockNumber),
rootBlockHash: normalizeHash(matches[0].channel?.channelRoot?.blockHash),
};
}
function openSimpleSubscribeModal({ kind, kindLabel, submitLabel, unfollow = false, onSuccess }) {
const targetHint = kind === 'channel'
? '<p class="meta-muted">Цель канала: owner/channel или bch:number:hash.</p>'
: '<p class="meta-muted">Цель пользователя: @login.</p>';
const submitText = submitLabel || (unfollow ? 'Отписаться' : 'Подписаться');
const placeholder = kind === 'channel' ? '@owner/#channel или bch:number:hash' : '@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}" />
<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 errorEl = root.querySelector('#subscribe-error');
const submitEl = root.querySelector('#sub-submit');
const close = () => {
root.innerHTML = '';
};
root.querySelector('#sub-cancel').addEventListener('click', close);
submitEl.addEventListener('click', async () => {
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;
}
submitEl.disabled = true;
errorEl.textContent = '';
try {
if (kind === 'user') {
await authService.addBlockFollowUser({
login,
targetLogin: value.replace(/^@+/, ''),
storagePwd,
unfollow,
});
} else if (kind === 'channel') {
const target = resolveChannelTargetFromInput(value);
if (!target?.ownerBlockchainName || !Number.isFinite(target.rootBlockNumber)) {
throw new Error('Канал не найден. Используйте owner/channel или bch:number:hash.');
}
await authService.addBlockFollowChannel({
login,
storagePwd,
targetBlockchainName: target.ownerBlockchainName,
targetBlockNumber: target.rootBlockNumber,
targetBlockHashHex: target.rootBlockHash,
unfollow,
});
} else {
throw new Error('Неподдерживаемый тип подписки');
}
close();
if (typeof onSuccess === 'function') onSuccess();
} catch (error) {
errorEl.textContent = toUserMessage(error, `${submitText} не удалось.`);
submitEl.disabled = false;
}
});
if (inputEl) inputEl.focus();
}
function mapMockGroups() {
const mapRow = (channel) => ({
...channel,
route: `channel-view/${channel.id}`,
});
const ownChannels = mockChannels
.filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own')
.map(mapRow);
const followedUserChannels = mockChannels
.filter((channel) => channel.kind === 'followed-user-channel')
.map(mapRow);
const subscribedChannels = mockChannels
.filter((channel) => channel.kind === 'subscribed')
.map(mapRow);
return { ownChannels, followedUserChannels, subscribedChannels, index: {} };
}
function mapApiChannelRow(summary, bucketKey, idx, index) {
const rowId = `${bucketKey}-${idx}`;
index[rowId] = summary;
const ownerLogin = summary.channel?.ownerLogin || 'неизвестно';
const channelName = summary.channel?.channelName || '(без названия)';
const displayName = `${ownerLogin}/${channelName}`;
return {
id: rowId,
source: 'api',
route: buildChannelRouteFromSummary(summary, rowId),
ownerName: ownerLogin,
initials: initialsFromName(channelName || ownerLogin || '?'),
name: channelName,
displayName,
description: `bch=${summary.channel?.ownerBlockchainName || '-'}`,
lastMessage: summary.lastMessage?.text || 'Сообщений пока нет',
time: summary.lastMessage?.createdAtMs ? new Date(summary.lastMessage.createdAtMs).toLocaleString('ru-RU') : '-',
messagesCount: summary.messagesCount || 0,
};
}
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) {
const index = {};
const ownChannels = (feed?.ownedChannels || []).map((it, idx) => mapApiChannelRow(it, 'own', idx, index));
const followedUserChannels = (feed?.followedUsersChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index));
const subscribedChannels = (feed?.followedChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedChannels', idx, index));
ownChannels.sort((a, b) => {
const ap = index[a.id]?.channel?.personal === true;
const bp = index[b.id]?.channel?.personal === true;
if (ap && !bp) return -1;
if (!ap && bp) return 1;
return a.name.localeCompare(b.name, 'ru');
});
return { ownChannels, followedUserChannels, subscribedChannels, index };
}
function renderChannelRow(channel, navigate) {
const row = document.createElement('article');
row.className = 'channel-row';
row.innerHTML = `
<div class="avatar">${channel.initials}</div>
<div class="channel-row-main">
<strong class="channel-row-title">${channel.displayName || channel.name}</strong>
<p class="channel-row-description">${channel.description}</p>
<p class="channel-row-message">${channel.lastMessage}</p>
<p class="channel-row-owner">Владелец: ${channel.ownerName}</p>
</div>
<div class="channel-row-meta">
<span class="channel-row-kind">Канал</span>
<span class="channel-row-time">${channel.time}</span>
<span class="unread channel-row-count">${channel.messagesCount}</span>
</div>
`;
row.addEventListener('click', () => navigate(channel.route || `channel-view/${channel.id}`));
return row;
}
function renderSection(title, items, navigate) {
const wrap = document.createElement('section');
wrap.className = 'stack channels-section';
const header = document.createElement('h3');
header.className = 'section-title';
header.textContent = title;
wrap.append(header);
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'channels-list-empty';
empty.textContent = 'Пока пусто.';
wrap.append(empty);
return wrap;
}
items.forEach((channel) => wrap.append(renderChannelRow(channel, navigate)));
return wrap;
}
function renderGroupedList(container, navigate, groups) {
const listWrap = document.createElement('div');
listWrap.className = 'channels-scroll-wrap';
const list = document.createElement('div');
list.className = 'stack channels-groups';
list.append(renderSection('Мои каналы', groups.ownChannels, navigate));
const dividerOne = document.createElement('hr');
dividerOne.className = 'channels-divider';
list.append(dividerOne);
list.append(renderSection('Каналы пользователей, на которых я подписан', groups.followedUserChannels, navigate));
const dividerTwo = document.createElement('hr');
dividerTwo.className = 'channels-divider';
list.append(dividerTwo);
list.append(renderSection('Каналы, на которые я подписан', groups.subscribedChannels, navigate));
const addChannelButton = document.createElement('button');
addChannelButton.className = 'primary-btn channels-bottom-action';
addChannelButton.textContent = 'Создать канал';
addChannelButton.addEventListener('click', () => navigate('add-channel-view'));
list.append(addChannelButton);
const scrollHint = document.createElement('div');
scrollHint.className = 'channels-scroll-hint';
listWrap.append(list, scrollHint);
container.append(listWrap);
}
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);
renderGroupedList(container, navigate, mapMockGroups());
}
async function loadFeedAndRender(container, navigate) {
container.innerHTML = '';
const status = document.createElement('div');
status.className = 'card meta-muted';
status.textContent = 'Загрузка каналов...';
container.append(status);
try {
if (!state.session.login) throw new Error('not_authorized');
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
const groups = mapApiFeed(feed);
setChannelsFeed(feed, groups.index);
container.innerHTML = '';
renderGroupedList(container, navigate, groups);
} catch (error) {
setChannelsFeed(null, {});
container.innerHTML = '';
if (isChannelsDemoMode()) {
renderDemoFallback(container, navigate, error, () => loadFeedAndRender(container, navigate));
return;
}
renderErrorState(container, error, () => loadFeedAndRender(container, navigate));
}
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--list';
const createSuccessFlash = pullCreateSuccessFlash();
const hero = document.createElement('div');
hero.className = 'card channels-hero';
hero.innerHTML = `
<div class="channels-hero-emblem" aria-hidden="true"></div>
<div class="channels-hero-copy">
<p class="channels-hero-kicker">SHiNE</p>
<p class="channels-hero-title">Каналы</p>
<p class="channels-hero-subtitle">Ленты, треды и подписки в одном экране.</p>
</div>
`;
const currentUser = document.createElement('div');
currentUser.className = 'card channels-user-chip';
currentUser.textContent = `Вы вошли как @${state.session.login || 'неизвестно'}`;
let flashCard = null;
if (createSuccessFlash) {
flashCard = document.createElement('div');
flashCard.className = 'card status-line is-available';
flashCard.textContent = createSuccessFlash;
}
const help = document.createElement('div');
help.className = 'card stack channels-help-card';
help.innerHTML = `
<strong>Быстрый ручной тест</strong>
<p class="meta-muted">
1) Создайте пользователей A и B.<br />
2) Под A создайте 2 канала и сообщения.<br />
3) Под B проверьте follow/unfollow user и channel.<br />
4) Откройте канал: проверьте like/unlike, reply и thread.
</p>
`;
const content = document.createElement('div');
const refresh = () => {
loadFeedAndRender(content, navigate);
};
screen.append(
renderHeader({
title: 'Каналы',
})
);
const actions = document.createElement('div');
actions.className = 'channels-action-grid';
const actionButtons = [
{
label: 'Подписаться на пользователя',
className: 'primary-btn channels-action-btn',
onClick: () => openSimpleSubscribeModal({
kind: 'user',
kindLabel: 'Подписка на пользователя',
submitLabel: 'Подписаться',
onSuccess: refresh,
}),
},
{
label: 'Отписаться от пользователя',
className: 'destructive-btn channels-action-btn',
onClick: () => openSimpleSubscribeModal({
kind: 'user',
kindLabel: 'Отписка от пользователя',
submitLabel: 'Отписаться',
unfollow: true,
onSuccess: refresh,
}),
},
{
label: 'Подписаться на канал',
className: 'primary-btn channels-action-btn',
onClick: () => openSimpleSubscribeModal({
kind: 'channel',
kindLabel: 'Подписка на канал',
submitLabel: 'Подписаться',
onSuccess: refresh,
}),
},
{
label: 'Отписаться от канала',
className: 'destructive-btn channels-action-btn',
onClick: () => openSimpleSubscribeModal({
kind: 'channel',
kindLabel: 'Отписка от канала',
submitLabel: 'Отписаться',
unfollow: true,
onSuccess: refresh,
}),
},
];
actionButtons.forEach((config) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = config.className;
btn.textContent = config.label;
btn.addEventListener('click', config.onClick);
actions.append(btn);
});
const limitations = document.createElement('div');
limitations.className = 'channels-info-strip';
limitations.textContent = 'Подписка на пользователя и подписка на конкретный канал работают независимо.';
screen.append(hero);
screen.append(actions);
screen.append(currentUser);
if (flashCard) screen.append(flashCard);
screen.append(help);
screen.append(limitations);
screen.append(content);
refresh();
return screen;
}