Add channels IT coverage, live UI loading, and runbook

This commit is contained in:
ai5590
2026-03-30 16:06:28 +03:00
parent 9723696b2c
commit c0fba4af94
10 changed files with 549 additions and 69 deletions
+93 -20
View File
@@ -1,46 +1,119 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { channelPosts, channels } from '../mock-data.js?v=20260330001044';
import { authService, state } from '../state.js?v=20260330001044';
export const pageMeta = { id: 'channel-view', title: 'Канал' };
export function render({ navigate, route }) {
const channelId = route.params.channelId || 'ch1';
function findMockChannel(channelId) {
const channel = channels.find((c) => c.id === channelId) || channels[0];
const posts = channelPosts[channelId] || [];
const isOwnChannel = channel.ownerLogin === '@shine.alex';
return {
channel,
posts: (channelPosts[channel.id] || []).map((post) => ({ title: post.title, body: post.body })),
isOwnChannel: channel.ownerLogin === '@shine.alex',
};
}
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: `Канал: ${channel.name}`,
leftAction: { label: '←', onClick: () => navigate('channels-list') },
})
);
function mapApiMessageToPost(message) {
return {
title: `${message.authorLogin || 'author'} • #${message.messageRef?.blockNumber ?? '?'}`,
body: message.text || '(пусто)',
};
}
function renderBody(screen, navigate, channelData) {
const head = document.createElement('div');
head.className = 'card';
head.innerHTML = `
<strong># ${channel.name}</strong>
<p class="meta-muted" style="margin-top:4px;">${channel.description}</p>
<p class="meta-muted" style="margin-top:8px;">Владелец: ${channel.ownerName}</p>
<strong># ${channelData.channel.name}</strong>
<p class="meta-muted" style="margin-top:4px;">${channelData.channel.description}</p>
<p class="meta-muted" style="margin-top:8px;">Владелец: ${channelData.channel.ownerName}</p>
`;
const actionButton = document.createElement('button');
actionButton.className = isOwnChannel ? 'primary-btn' : 'secondary-btn';
actionButton.textContent = isOwnChannel ? 'Добавить сообщение в канал' : 'Отписаться от канала';
actionButton.className = channelData.isOwnChannel ? 'primary-btn' : 'secondary-btn';
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение в канал' : 'Отписаться от канала';
const feed = document.createElement('div');
feed.className = 'stack';
posts.forEach((post) => {
channelData.posts.forEach((post) => {
const card = document.createElement('article');
card.className = 'card stack';
card.innerHTML = `<strong>${post.title}</strong><p class="meta-muted">${post.body}</p>`;
feed.append(card);
});
screen.append(head, actionButton, feed);
const backButton = document.createElement('button');
backButton.className = 'secondary-btn';
backButton.textContent = 'Назад к списку';
backButton.addEventListener('click', () => navigate('channels-list'));
screen.append(head, actionButton, feed, backButton);
}
async function loadFromApi(channelId) {
const summary = state.channelsIndex[channelId];
if (!summary) return null;
const selector = {
ownerBlockchainName: summary.channel?.ownerBlockchainName,
channelRootBlockNumber: summary.channel?.channelRoot?.blockNumber,
channelRootBlockHash: summary.channel?.channelRoot?.blockHash,
};
if (!selector.ownerBlockchainName || selector.channelRootBlockNumber == null) return null;
const payload = await authService.getChannelMessages(selector, 200, 'asc');
const posts = (payload.messages || []).map(mapApiMessageToPost);
return {
channel: {
name: payload.channel?.channelName || summary.channel?.channelName || 'unknown',
description: `bch=${payload.channel?.ownerBlockchainName || selector.ownerBlockchainName}`,
ownerName: payload.channel?.ownerLogin || summary.channel?.ownerLogin || 'unknown',
},
posts,
isOwnChannel: (payload.channel?.ownerLogin || '').toLowerCase() === (state.session.login || '').toLowerCase(),
};
}
export function render({ navigate, route }) {
const channelId = route.params.channelId || 'ch1';
const screen = document.createElement('section');
screen.className = 'stack';
const headerTitle = state.channelsIndex[channelId]?.channel?.channelName
? `Канал: ${state.channelsIndex[channelId].channel.channelName}`
: `Канал: ${(channels.find((c) => c.id === channelId) || channels[0]).name}`;
screen.append(
renderHeader({
title: headerTitle,
leftAction: { label: '←', onClick: () => navigate('channels-list') },
})
);
const loading = document.createElement('div');
loading.className = 'card meta-muted';
loading.textContent = 'Загрузка канала...';
screen.append(loading);
(async () => {
try {
const apiData = await loadFromApi(channelId);
loading.remove();
if (apiData) {
renderBody(screen, navigate, apiData);
return;
}
} catch {
// fallback to mock below
}
loading.remove();
renderBody(screen, navigate, findMockChannel(channelId));
})();
return screen;
}
+106 -48
View File
@@ -1,5 +1,6 @@
import { renderHeader } from '../components/header.js?v=20260330001044';
import { channels } from '../mock-data.js?v=20260330001044';
import { channels as mockChannels } from '../mock-data.js?v=20260330001044';
import { authService, setChannelsFeed, state } from '../state.js?v=20260330001044';
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
@@ -27,6 +28,53 @@ function openSimpleSubscribeModal(kindLabel) {
root.querySelector('#sub-submit').addEventListener('click', close);
}
function initialsFromName(name = '') {
const parts = name.split(/\s+/).filter(Boolean);
return (parts[0]?.[0] || '#') + (parts[1]?.[0] || '');
}
function mapMockGroups() {
const ownChannels = mockChannels.filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own');
const followedUserChannels = mockChannels.filter((channel) => channel.kind === 'followed-user-channel');
const subscribedChannels = mockChannels.filter((channel) => channel.kind === 'subscribed');
return { ownChannels, followedUserChannels, subscribedChannels, index: {} };
}
function mapApiChannelRow(summary, bucketKey, idx, index) {
const rowId = `${bucketKey}-${idx}`;
index[rowId] = summary;
return {
id: rowId,
source: 'api',
ownerName: summary.channel?.ownerLogin || 'unknown',
initials: initialsFromName(summary.channel?.channelName || summary.channel?.ownerLogin || '?'),
name: summary.channel?.channelName || '(без имени)',
description: `owner=${summary.channel?.ownerLogin || '-'} / 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 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 = 'list-item';
@@ -57,14 +105,66 @@ function renderSection(title, items, navigate) {
header.textContent = title;
wrap.append(header);
items.forEach((channel) => {
wrap.append(renderChannelRow(channel, navigate));
});
items.forEach((channel) => wrap.append(renderChannelRow(channel, navigate)));
return wrap;
}
function renderGroupedList(screen, 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';
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);
screen.append(listWrap);
}
async function loadFeedAndRender(screen, navigate) {
const status = document.createElement('div');
status.className = 'card meta-muted';
status.textContent = 'Загрузка каналов с сервера...';
screen.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);
status.remove();
renderGroupedList(screen, navigate, groups);
} catch {
setChannelsFeed(null, {});
status.textContent = 'Сервер недоступен или нет данных. Показаны демо-каналы.';
renderGroupedList(screen, navigate, mapMockGroups());
}
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -79,48 +179,6 @@ export function render({ navigate }) {
})
);
const ownChannels = channels
.filter((channel) => channel.kind === 'own-personal' || channel.kind === 'own')
.sort((a, b) => {
if (a.kind === 'own-personal') return -1;
if (b.kind === 'own-personal') return 1;
return a.name.localeCompare(b.name, 'ru');
});
const followedUserChannels = channels.filter((channel) => channel.kind === 'followed-user-channel');
const subscribedChannels = channels.filter((channel) => channel.kind === 'subscribed');
const listWrap = document.createElement('div');
listWrap.className = 'channels-scroll-wrap';
const list = document.createElement('div');
list.className = 'stack channels-groups';
list.append(renderSection('Мои каналы', ownChannels, navigate));
const dividerOne = document.createElement('hr');
dividerOne.className = 'channels-divider';
list.append(dividerOne);
list.append(renderSection('Каналы пользователей, на кого вы подписаны', followedUserChannels, navigate));
const dividerTwo = document.createElement('hr');
dividerTwo.className = 'channels-divider';
list.append(dividerTwo);
list.append(renderSection('Каналы, на которые вы подписаны', subscribedChannels, navigate));
const addChannelButton = document.createElement('button');
addChannelButton.className = 'primary-btn';
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);
screen.append(listWrap);
loadFeedAndRender(screen, navigate);
return screen;
}
+18
View File
@@ -217,6 +217,24 @@ export class AuthService {
if (response.status !== 200) throw opError('CloseActiveSession', response);
}
async listSubscriptionsFeed(login, limit = 200) {
const response = await this.ws.request('ListSubscriptionsFeed', { login, limit });
if (response.status !== 200) throw opError('ListSubscriptionsFeed', response);
return response.payload || {};
}
async getChannelMessages(channel, limit = 200, sort = 'asc') {
const response = await this.ws.request('GetChannelMessages', { channel, limit, sort });
if (response.status !== 200) throw opError('GetChannelMessages', response);
return response.payload || {};
}
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50) {
const response = await this.ws.request('GetMessageThread', { message, depthUp, depthDown, limitChildrenPerNode });
if (response.status !== 200) throw opError('GetMessageThread', response);
return response.payload || {};
}
close() {
this.ws.close();
}
+7
View File
@@ -97,6 +97,8 @@ function createInitialState({ withStoredSession = true } = {}) {
info: '',
},
sessions: [],
channelsFeed: null,
channelsIndex: {},
};
}
@@ -232,3 +234,8 @@ export function refreshRegistrationBalance() {
state.registrationPayment.balanceSOL = next;
return next;
}
export function setChannelsFeed(feed, index) {
state.channelsFeed = feed || null;
state.channelsIndex = index || {};
}