Каналы: типы 0/1/100/200, CreateChannel v3, state для chat200, новые API и деплой на prod

This commit is contained in:
AidarKC
2026-05-13 01:17:23 +03:00
parent 97d37a2eb6
commit e95f65ac78
36 changed files with 1764 additions and 187 deletions
+80 -76
View File
@@ -15,6 +15,9 @@ 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;
const TAB_ORDER = ['feed', 'dialogs', 'my'];
function isChannelsDemoMode() {
try {
@@ -40,16 +43,18 @@ function normalizeLoginInput(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/${encodeRoutePart(ownerBch)}/${Number(rootBlockNumber)}/${rootBlockHash}`;
}
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
const channelName = String(summary?.channel?.channelName || '').trim();
if (ownerLogin && channelName) {
return `channel/${encodeRoutePart(ownerLogin)}/${encodeRoutePart(channelName)}`;
}
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}`;
return `channel-view/${fallbackId}`;
}
function avatarLetterFromName(name = '') {
@@ -526,7 +531,11 @@ function mapMockGroups() {
const mapRow = (channel) => ({
...channel,
route: `channel-view/${channel.id}`,
tabCategory: channel.kind === 'own' || channel.kind === 'own-personal' ? 'my' : 'subscriptions',
tabCategory: channel.kind === 'own'
? 'my'
: channel.kind === 'own-personal'
? 'dialogs'
: 'feed',
messagePreview: channel.lastMessage || 'Ждем ваших начинаний',
isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal',
isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal',
@@ -545,11 +554,11 @@ function mapMockGroups() {
const followedUserChannels = mockChannels
.filter((channel) => channel.kind === 'followed-user-channel')
.map((item) => ({ ...mapRow(item), tabCategory: 'authors' }));
.map((item) => ({ ...mapRow(item), tabCategory: 'feed' }));
const subscribedChannels = mockChannels
.filter((channel) => channel.kind === 'subscribed')
.map((item) => ({ ...mapRow(item), tabCategory: 'subscriptions' }));
.map((item) => ({ ...mapRow(item), tabCategory: 'feed' }));
return { ownChannels, followedUserChannels, subscribedChannels, index: {} };
}
@@ -561,18 +570,14 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
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 tabCategory = bucketKey === 'own'
? 'my'
: bucketKey === 'followedUsers'
? 'authors'
: 'subscriptions';
const tabCategory = isOwn
? (channelTypeCode === CHANNEL_TYPE_PERSONAL ? 'dialogs' : 'my')
: 'feed';
const title = isOwn
? channelName
: tabCategory === 'authors'
? channelName
: `${ownerLogin}/${channelName}`;
const title = isOwn ? channelName : `${ownerLogin}/${channelName}`;
return {
id: rowId,
@@ -585,6 +590,8 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
title,
channelName,
channelDescription,
channelTypeCode,
channelTypeVersion,
messagePreview: summary?.lastMessage?.text || 'Ждем ваших начинаний',
messagesCount: Number(summary?.messagesCount || 0),
unreadCount: Number(summary?.unreadCount || 0),
@@ -597,20 +604,6 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
};
}
function isSyntheticDefaultChannel(row) {
if (!row || !row.isOwnChannel) return false;
const name = String(row.channelName || '').trim();
if (name !== '0') return false;
const hasDescription = Boolean(String(row.channelDescription || '').trim());
const hasMessages = Number(row.messagesCount || 0) > 0;
const hasTimestamp = Number(row.lastMessageAt || 0) > 0;
const preview = String(row.messagePreview || '').trim();
const hasCustomPreview = preview && preview !== 'Ждем ваших начинаний';
return !hasDescription && !hasMessages && !hasTimestamp && !hasCustomPreview;
}
function pullCreateSuccessFlash() {
try {
const value = String(sessionStorage.getItem(CREATE_CHANNEL_FLASH_KEY) || '').trim();
@@ -624,8 +617,7 @@ function pullCreateSuccessFlash() {
function mapApiFeed(feed, notificationsState) {
const index = {};
const ownChannels = (feed?.ownedChannels || [])
.map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState))
.filter((row) => !isSyntheticDefaultChannel(row));
.map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState));
const followedUserChannels = (feed?.followedUsersChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState));
const subscribedChannels = (feed?.followedChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedChannels', idx, index, notificationsState));
@@ -645,12 +637,14 @@ function renderEmptyState(activeTab, navigate) {
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
const text = document.createElement('p');
text.className = 'meta-muted';
if (activeTab === 'subscriptions') {
text.textContent = 'Вы пока не подписаны на каналы.';
if (activeTab === 'feed') {
text.textContent = 'Нет подписок и найденных каналов.';
} else if (activeTab === 'dialogs') {
text.textContent = 'У вас пока нет персональных каналов.';
} else if (activeTab === 'my') {
text.textContent = 'У вас пока нет каналов.';
} else {
text.textContent = 'Пока нет каналов авторов.';
text.textContent = усто.';
}
wrap.append(text);
@@ -882,7 +876,7 @@ function renderChannelMain(channel, activeTab) {
const main = document.createElement('div');
main.className = 'channel-row-main';
if (activeTab === 'authors') {
if (activeTab === 'feed') {
const author = document.createElement('p');
author.className = 'channel-row-author';
author.textContent = `@${channel.ownerName}`;
@@ -907,7 +901,7 @@ function renderChannelMain(channel, activeTab) {
title.className = 'channel-row-title';
title.textContent = activeTab === 'my' ? channel.channelName : channel.title;
if (activeTab === 'my' && channel.channelDescription) {
if ((activeTab === 'my' || activeTab === 'dialogs') && channel.channelDescription) {
const desc = document.createElement('p');
desc.className = 'channel-row-description';
desc.textContent = channel.channelDescription;
@@ -1009,7 +1003,7 @@ function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = f
const tab = listState.activeTab;
const baseClass = `primary-btn channels-bottom-action${isTabEmpty && tab !== 'my' ? ' is-empty-lift' : ''}`;
if (tab === 'subscriptions') {
if (tab === 'feed') {
button.textContent = 'Подписаться на канал';
button.className = baseClass;
button.onclick = () => openSimpleSubscribeModal({
@@ -1021,16 +1015,23 @@ function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = f
return;
}
if (tab === 'authors') {
button.textContent = '🔍 Поиск каналов';
if (tab === 'dialogs') {
button.textContent = 'Новый персональный канал';
button.className = baseClass;
button.onclick = () => openChannelFinderModal({ navigate });
button.onclick = () => navigate('add-channel-view');
return;
}
button.textContent = 'Создать канал';
if (tab === 'my') {
button.textContent = 'Создать канал';
button.className = baseClass;
button.onclick = () => navigate('add-channel-view');
return;
}
button.textContent = 'Поиск каналов';
button.className = baseClass;
button.onclick = () => navigate('add-channel-view');
button.onclick = () => openChannelFinderModal({ navigate });
}
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
@@ -1065,7 +1066,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
}
}
export function render({ 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');
@@ -1075,7 +1076,9 @@ export function render({ navigate }) {
const notificationsState = readChannelNotificationsState();
const listState = {
activeTab: 'subscriptions',
activeTab: TAB_ORDER.includes(String(route?.params?.mode || '').trim())
? String(route?.params?.mode).trim()
: 'dialogs',
openMenuId: null,
notificationsState,
revealedCounters: new Set(),
@@ -1083,9 +1086,6 @@ export function render({ navigate }) {
menuCleanup: null,
};
const tabs = document.createElement('div');
tabs.className = 'channels-tabs channels-tabs--sticky';
const contentEl = document.createElement('div');
contentEl.className = 'channels-list-content';
@@ -1095,12 +1095,16 @@ export function render({ navigate }) {
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
const rerenderList = () => {
const isTabEmpty = !(listState.channels || []).some((channel) => channel.tabCategory === listState.activeTab);
try {
const expectedHash = `#/channels-list/${listState.activeTab}`;
if (window.location.hash !== expectedHash) {
window.history.replaceState({}, '', expectedHash);
}
} catch {
// ignore history errors
}
tabItems.forEach((tab) => {
const btn = tabs.querySelector(`[data-tab="${tab.key}"]`);
if (btn) btn.classList.toggle('is-active', tab.key === listState.activeTab);
});
const isTabEmpty = !(listState.channels || []).some((channel) => channel.tabCategory === listState.activeTab);
closeChannelMenu(listState);
@@ -1121,28 +1125,28 @@ export function render({ navigate }) {
});
};
const tabItems = [
{ key: 'subscriptions', label: 'Каналы' },
{ key: 'my', label: 'Мои' },
{ key: 'authors', label: 'Авторы' },
];
let touchStartX = 0;
let touchStartY = 0;
contentEl.addEventListener('touchstart', (event) => {
const p = event.changedTouches?.[0];
if (!p) return;
touchStartX = p.clientX;
touchStartY = p.clientY;
}, { passive: true });
contentEl.addEventListener('touchend', (event) => {
const p = event.changedTouches?.[0];
if (!p) return;
const dx = p.clientX - touchStartX;
const dy = p.clientY - touchStartY;
if (Math.abs(dx) < 45 || Math.abs(dx) < Math.abs(dy)) return;
const index = TAB_ORDER.indexOf(listState.activeTab);
if (index < 0) return;
if (dx < 0 && index < TAB_ORDER.length - 1) listState.activeTab = TAB_ORDER[index + 1];
if (dx > 0 && index > 0) listState.activeTab = TAB_ORDER[index - 1];
rerenderList();
}, { passive: true });
tabItems.forEach((tab) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.dataset.tab = tab.key;
btn.className = `channels-tab-btn ${tab.key === listState.activeTab ? 'is-active' : ''}`;
btn.textContent = tab.label;
btn.addEventListener('click', () => {
if (listState.activeTab === tab.key) return;
listState.activeTab = tab.key;
animatePress(btn);
rerenderList();
});
tabs.append(btn);
});
screen.append(tabs, contentEl, bottomCta);
screen.append(contentEl, bottomCta);
if (createSuccessFlash) {
showToast(createSuccessFlash);