Каналы: типы 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
+97 -1
View File
@@ -8,6 +8,12 @@ const ITEMS = [
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' },
{ pageId: 'profile-view', label: 'Профиль', icon: '👤' },
];
const CHANNEL_HOLD_MS = 260;
const CHANNEL_MODES = Object.freeze([
{ key: 'feed', label: 'Каналы' },
{ key: 'dialogs', label: 'Чаты' },
{ key: 'my', label: 'Мои' },
]);
function getTotalUnreadMessages() {
const chats = Object.values(state.chats || {});
@@ -54,9 +60,99 @@ export function renderToolbar(currentPageId, navigate) {
badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`);
btn.append(badge);
}
btn.addEventListener('click', () => navigate(item.pageId));
if (item.pageId === 'channels-list') {
installChannelsHoldSwitcher(btn, navigate);
} else {
btn.addEventListener('click', () => navigate(item.pageId));
}
root.append(btn);
});
return root;
}
function installChannelsHoldSwitcher(button, navigate) {
let holdTimer = 0;
let pressed = false;
let holdActive = false;
let overlay = null;
let selectedMode = 'dialogs';
const clearTimer = () => {
if (holdTimer) {
window.clearTimeout(holdTimer);
holdTimer = 0;
}
};
const closeOverlay = () => {
if (overlay) overlay.remove();
overlay = null;
holdActive = false;
};
const setSelectedModeByX = (clientX) => {
if (!overlay) return;
const rect = overlay.getBoundingClientRect();
const part = rect.width / 3;
const localX = Math.max(0, Math.min(rect.width - 1, clientX - rect.left));
const index = Math.max(0, Math.min(2, Math.floor(localX / Math.max(1, part))));
selectedMode = CHANNEL_MODES[index].key;
const buttons = overlay.querySelectorAll('.toolbar-channels-hold-item');
buttons.forEach((el, idx) => {
el.classList.toggle('is-active', idx === index);
});
};
const openOverlay = () => {
const rect = button.getBoundingClientRect();
overlay = document.createElement('div');
overlay.className = 'toolbar-channels-hold-overlay';
overlay.innerHTML = CHANNEL_MODES.map((mode) => (
`<button type="button" class="toolbar-channels-hold-item${mode.key === selectedMode ? ' is-active' : ''}" data-mode="${mode.key}">${mode.label}</button>`
)).join('');
overlay.style.left = `${Math.round(rect.left + rect.width / 2)}px`;
overlay.style.top = `${Math.round(rect.top - 12)}px`;
document.body.append(overlay);
holdActive = true;
};
button.addEventListener('pointerdown', (event) => {
pressed = true;
holdActive = false;
selectedMode = 'dialogs';
clearTimer();
holdTimer = window.setTimeout(() => {
if (!pressed) return;
openOverlay();
setSelectedModeByX(event.clientX);
}, CHANNEL_HOLD_MS);
});
button.addEventListener('pointermove', (event) => {
if (holdActive) setSelectedModeByX(event.clientX);
});
button.addEventListener('pointerup', () => {
clearTimer();
const wasHold = holdActive;
const mode = selectedMode;
pressed = false;
closeOverlay();
if (wasHold) {
navigate(`channels-list/${mode}`);
return;
}
navigate('channels-list/dialogs');
});
button.addEventListener('pointercancel', () => {
clearTimer();
pressed = false;
closeOverlay();
});
button.addEventListener('contextmenu', (event) => {
event.preventDefault();
});
}
+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);
+9
View File
@@ -123,6 +123,15 @@ export function getRoute() {
};
}
if (pageId === 'channels-list') {
return {
pageId,
params: {
mode: segments[1] ? decodePart(segments[1]) : '',
},
};
}
return { pageId, params: {} };
}
+34 -10
View File
@@ -42,7 +42,12 @@ const MSG_SUBTYPE_REACTION_LIKE = 1;
const MSG_SUBTYPE_REACTION_UNLIKE = 2;
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
const CREATE_CHANNEL_BODY_VERSION = 2;
const CREATE_CHANNEL_BODY_VERSION = 3;
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_TYPE_PERSONAL = 100;
const CHANNEL_TYPE_GROUP = 200;
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
const CONNECTION_SUBTYPES = Object.freeze({
// Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11.
@@ -404,13 +409,15 @@ function normalizeChannelDescription(value) {
return text;
}
function makeCreateChannelBodyV2Bytes({
function makeCreateChannelBodyV3Bytes({
lineCode,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName,
channelDescription = '',
channelType = CHANNEL_TYPE_PUBLIC,
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
}) {
const check = validateChannelDisplayName(channelName);
if (!check.ok) throw new Error(channelNameErrorText(check.code));
@@ -426,6 +433,14 @@ function makeCreateChannelBodyV2Bytes({
if (descriptionBytes.length > 200) {
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
}
const typeCode = Number(channelType);
const typeVer = Number(channelTypeVersion);
if (!Number.isFinite(typeCode) || typeCode < 0 || typeCode > 65535) {
throw new Error('Некорректный тип канала.');
}
if (!Number.isFinite(typeVer) || typeVer < 0 || typeVer > 65535) {
throw new Error('Некорректная версия типа канала.');
}
return concatBytes(
int32Bytes(lineCode),
@@ -436,6 +451,8 @@ function makeCreateChannelBodyV2Bytes({
nameBytes,
int16Bytes(descriptionBytes.length),
descriptionBytes,
uint16Bytes(typeCode),
uint16Bytes(typeVer),
);
}
@@ -1004,11 +1021,19 @@ export class AuthService {
rootBlockNumber: Number(item?.channel?.channelRoot?.blockNumber),
rootBlockHash: normalizeHex32(item?.channel?.channelRoot?.blockHash, ZERO64),
channelName: String(item?.channel?.channelName || ''),
channelTypeCode: Number(item?.channel?.channelTypeCode ?? CHANNEL_TYPE_PUBLIC),
}))
.filter((item) => Number.isFinite(item.rootBlockNumber) && item.rootBlockNumber >= 0);
}
async addBlockCreateChannel({ login, channelName, channelDescription = '', storagePwd }) {
async addBlockCreateChannel({
login,
channelName,
channelDescription = '',
channelType = CHANNEL_TYPE_PUBLIC,
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
storagePwd,
}) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login');
@@ -1018,7 +1043,9 @@ export class AuthService {
const cleanChannelDescription = normalizeChannelDescription(channelDescription);
const channelSlug = toCanonicalChannelSlug(cleanChannelName);
const key = `create-channel:${cleanLogin}:${channelSlug || cleanChannelName.toLowerCase()}`;
const typeCode = Number(channelType);
const typeVersion = Number(channelTypeVersion);
const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`;
return this.runWriteLocked(key, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
@@ -1053,13 +1080,15 @@ export class AuthService {
msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: CREATE_CHANNEL_BODY_VERSION,
bodyBytes: makeCreateChannelBodyV2Bytes({
bodyBytes: makeCreateChannelBodyV3Bytes({
lineCode: 0,
prevLineNumber,
prevLineHashHex,
thisLineNumber,
channelName: cleanChannelName,
channelDescription: cleanChannelDescription,
channelType: typeCode,
channelTypeVersion: typeVersion,
}),
});
@@ -1099,11 +1128,6 @@ export class AuthService {
if (ownerBlockchainName !== blockchainName) {
throw new Error('Posting is allowed only to your own channels');
}
// Канал 0 оставляем как технический root-поток.
// Контент-публикации в него временно отключены (пишем только в именованные каналы).
if (lineCode === 0) {
throw new Error('Публикации в канал 0 временно отключены. Создайте отдельный канал.');
}
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
if (rootHashHex === ZERO64) {