SHA256
522 lines
18 KiB
JavaScript
522 lines
18 KiB
JavaScript
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;
|
|
}
|
|
|