SHA256
Add channels IT coverage, live UI loading, and runbook
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 || {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user