Каналы: типы 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
+44 -1
View File
@@ -11,6 +11,9 @@ import {
export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_TYPE_PERSONAL = 100;
const CHANNEL_TYPE_GROUP = 200;
function persistCreateSuccessFlash(message) {
try {
@@ -45,7 +48,15 @@ export function render({ navigate }) {
form.innerHTML = `
<strong class="channel-head-title">Создание канала</strong>
<p class="channel-head-meta">Можно использовать только латиницу, цифры, _ и -.</p>
<p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название уникально во всей системе.</p>
<p class="channel-head-meta">Длина названия: от 3 до 32 символов.</p>
<label for="channel-type">Тип канала</label>
<select id="channel-type" class="input">
<option value="${CHANNEL_TYPE_PUBLIC}">Публичный (1)</option>
<option value="${CHANNEL_TYPE_PERSONAL}">Персональный (100)</option>
<option value="${CHANNEL_TYPE_GROUP}">Групповой (200)</option>
</select>
<div id="channel-type-hint" class="meta-muted">Публичный канал видят все. Писать может только владелец.</div>
<label for="channel-name">Название канала</label>
<input id="channel-name" class="input" maxlength="32" placeholder="Например: my_channel-1" required />
@@ -64,6 +75,8 @@ export function render({ navigate }) {
`;
const nameEl = form.querySelector('#channel-name');
const typeEl = form.querySelector('#channel-type');
const typeHintEl = form.querySelector('#channel-type-hint');
const descriptionEl = form.querySelector('#channel-description');
const nameErrorEl = form.querySelector('#channel-name-error');
const descriptionErrorEl = form.querySelector('#channel-description-error');
@@ -79,10 +92,27 @@ export function render({ navigate }) {
submitEl.disabled = submitInFlight;
cancelEl.disabled = submitInFlight;
nameEl.disabled = submitInFlight;
typeEl.disabled = submitInFlight;
descriptionEl.disabled = submitInFlight;
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
};
const updateTypeHint = () => {
const typeCode = Number(typeEl.value || CHANNEL_TYPE_PUBLIC);
if (typeCode === CHANNEL_TYPE_PERSONAL) {
typeHintEl.textContent = 'Для персонального канала название должно быть login собеседника.';
nameEl.placeholder = 'Например: aidar';
return;
}
if (typeCode === CHANNEL_TYPE_GROUP) {
typeHintEl.textContent = 'Для группового канала участников добавляют командами /.add и /.remove.';
nameEl.placeholder = 'Например: team_room';
return;
}
typeHintEl.textContent = 'Публичный канал видят все. Писать может только владелец.';
nameEl.placeholder = 'Например: my_channel-1';
};
const updateValidation = () => {
const nameCheck = validateChannelDisplayName(nameEl.value);
const descriptionCheck = validateDescription(descriptionEl.value);
@@ -124,11 +154,22 @@ export function render({ navigate }) {
errorEl.textContent = '';
try {
const channelType = Number(typeEl.value || CHANNEL_TYPE_PUBLIC);
if (channelType === CHANNEL_TYPE_PERSONAL) {
const targetLogin = normalizeChannelDisplayName(check.name);
const foundUser = await authService.getUser(targetLogin);
if (!foundUser?.exists) {
throw new Error('Логин для персонального канала не найден.');
}
}
await authService.addBlockCreateChannel({
login,
storagePwd,
channelName: normalizeChannelDisplayName(check.name),
channelDescription: normalizeChannelDescription(check.description),
channelType,
channelTypeVersion: 1,
});
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
@@ -141,9 +182,11 @@ export function render({ navigate }) {
});
cancelEl.addEventListener('click', () => navigate('channels-list'));
typeEl.addEventListener('change', updateTypeHint);
screen.append(form);
nameEl.focus();
updateTypeHint();
updateValidation();
return screen;
}
+47 -42
View File
@@ -518,6 +518,7 @@ function renderPostCard(post, {
navigate,
routeKey,
selector,
canWrite,
onToggleLike,
onReply,
onShare,
@@ -579,52 +580,55 @@ function renderPostCard(post, {
if (!post.messageRef || !selector) return card;
const actionKey = makeReactionActionKey(post.messageRef);
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
const actions = document.createElement('div');
actions.className = 'channel-message-actions';
const likeButton = document.createElement('button');
likeButton.type = 'button';
likeButton.className = 'channel-action-item channel-action-like';
const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${post.likesCount || 0}</span>
`;
likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => {
animatePress(event.currentTarget);
if (isPending) return;
if (!isLiked) {
const ok = window.confirm('Поставить лайк?');
if (!ok) return;
}
revealCounters();
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true;
const labelEl = likeButton.querySelector('.channel-action-label');
if (labelEl) labelEl.textContent = 'Лайк...';
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
});
if (canWrite) {
const actionKey = makeReactionActionKey(post.messageRef);
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'channel-action-item channel-action-reply';
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">⟳</span>
<span class="channel-action-label">Ответить</span>
`;
replyButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
revealCounters();
openReplyModal({
onSubmit: async (text) => onReply(post.messageRef, text),
const likeButton = document.createElement('button');
likeButton.type = 'button';
likeButton.className = 'channel-action-item channel-action-like';
const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
<span class="channel-action-counter">${post.likesCount || 0}</span>
`;
likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => {
animatePress(event.currentTarget);
if (isPending) return;
if (!isLiked) {
const ok = window.confirm('Поставить лайк?');
if (!ok) return;
}
revealCounters();
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true;
const labelEl = likeButton.querySelector('.channel-action-label');
if (labelEl) labelEl.textContent = 'Лайк...';
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
});
});
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'channel-action-item channel-action-reply';
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">⟳</span>
<span class="channel-action-label">Ответить</span>
`;
replyButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
revealCounters();
openReplyModal({
onSubmit: async (text) => onReply(post.messageRef, text),
});
});
actions.append(likeButton, replyButton);
}
const openThreadButton = document.createElement('button');
openThreadButton.type = 'button';
@@ -656,7 +660,7 @@ function renderPostCard(post, {
await onShare(route);
});
actions.append(likeButton, replyButton, openThreadButton, shareButton);
actions.append(openThreadButton, shareButton);
card.append(actions);
return card;
}
@@ -704,6 +708,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
navigate,
routeKey,
selector: channelData.selector,
canWrite: channelData.isOwnChannel,
onToggleLike: handlers.onToggleLike,
onReply: handlers.onReply,
onShare: handlers.onShare,
+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);