Каналы: новый роутинг, поиск, вход-возврат, удаление просмотров и документация

This commit is contained in:
AidarKC 2026-05-08 01:15:54 +03:00
parent 6774c26ea1
commit acdd6c928b
18 changed files with 657 additions and 458 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.40
server.version=1.2.34
client.version=1.2.42
server.version=1.2.36

View File

@ -98,6 +98,24 @@ function buildAbsoluteRouteUrl(routePath = '') {
function parseThreadSelector(route) {
const params = route?.params || {};
if (params.ownerLogin && params.channelName && params.messageBlockNumber && params.messageBlockHash) {
return {
short: {
ownerLogin: String(params.ownerLogin || '').trim(),
channelName: String(params.channelName || '').trim(),
},
message: {
blockchainName: '',
blockNumber: toSafeInt(params.messageBlockNumber),
blockHash: normalizeRouteHash(params.messageBlockHash),
},
channel: {
ownerBlockchainName: '',
rootBlockNumber: null,
rootBlockHash: '0',
},
};
}
const blockNumber = toSafeInt(params.messageBlockNumber);
if (!params.messageBlockchainName || blockNumber == null) return null;
@ -153,7 +171,17 @@ function buildBackRoute(selector) {
}
function buildThreadRouteFromTarget(target, selector) {
if (!target || !selector?.channel?.ownerBlockchainName || selector.channel.rootBlockNumber == null) return '';
if (!target) return '';
if (selector?.short?.ownerLogin && selector?.short?.channelName) {
return [
'channel',
encodeRoutePart(selector.short.ownerLogin),
encodeRoutePart(selector.short.channelName),
target.blockNumber,
normalizeRouteHash(target.blockHash),
].join('/');
}
if (!selector?.channel?.ownerBlockchainName || selector.channel.rootBlockNumber == null) return '';
return [
'channel-thread-view',
encodeRoutePart(target.blockchainName),
@ -293,13 +321,6 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
card.append(meta, body, stats);
if (options.showViews === true) {
const views = document.createElement('p');
views.className = 'thread-node-views';
views.textContent = `Просмотры: ${Number(node?.viewCount || 0)}`;
card.append(views);
}
const target = buildTargetFromNode(node);
const refKey = messageRefKey(target);
const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true;
@ -337,15 +358,19 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
likeButton.type = 'button';
likeButton.className = 'secondary-btn thread-like-btn';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.textContent = isPending ? '✦ Сияние...' : '✦ Сияние';
likeButton.textContent = isPending ? '❤️ Лайк...' : (isLiked ? '❤️ Лайк' : '🤍 Лайк');
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;
likeButton.textContent = 'Сияние...';
likeButton.textContent = 'Лайк...';
try {
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
} catch (error) {
@ -361,7 +386,7 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'secondary-btn thread-reply-btn';
replyButton.textContent = '⟳ Отразить';
replyButton.textContent = '💬 Ответить';
replyButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
revealCounters();
@ -373,7 +398,7 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
const shareButton = document.createElement('button');
shareButton.type = 'button';
shareButton.className = 'secondary-btn thread-share-btn';
shareButton.textContent = '↗ Транслировать';
shareButton.textContent = '↗ Отправить';
shareButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
@ -454,10 +479,6 @@ export function render({ navigate, route }) {
const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean');
const userIndicator = document.createElement('div');
userIndicator.className = 'card channels-user-chip';
userIndicator.textContent = `Вы вошли как @${state.session.login || 'неизвестно'}`;
const channelIndicator = document.createElement('div');
channelIndicator.className = 'card channels-user-chip';
channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`;
@ -490,7 +511,11 @@ export function render({ navigate, route }) {
const requireSigningSession = () => {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) throw new Error('Сессия недействительна. Выполните вход заново.');
if (!login || !storagePwd) {
state.authReturnHash = window.location.hash || '#/channels-list';
navigate('login-view');
throw new Error('Для этого действия нужно войти');
}
return { login, storagePwd };
};
@ -562,7 +587,7 @@ export function render({ navigate, route }) {
leftAction: { label: '<', onClick: () => navigate(backRoute) },
}),
);
screen.append(userIndicator, channelIndicator, statusBox);
screen.append(channelIndicator, statusBox);
if (!selector) {
const invalid = document.createElement('div');
@ -576,7 +601,25 @@ export function render({ navigate, route }) {
(async () => {
try {
const payload = await authService.getMessageThread(selector.message, 20, 2, 50, state.session.login);
let resolvedMessage = selector.message;
if (selector.short?.ownerLogin && selector.short?.channelName) {
const ownerFeed = await authService.listSubscriptionsFeed(selector.short.ownerLogin, 1000);
const ownChannels = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
const channel = ownChannels.find((item) => (
String(item?.channel?.channelName || '').trim().toLowerCase() === selector.short.channelName.toLowerCase()
));
const ownerBch = String(channel?.channel?.ownerBlockchainName || '').trim();
if (!ownerBch || !Number.isFinite(resolvedMessage?.blockNumber)) {
throw new Error('Канал или сообщение не найдено.');
}
resolvedMessage = {
blockchainName: ownerBch,
blockNumber: resolvedMessage.blockNumber,
blockHash: normalizeRouteHash(resolvedMessage.blockHash),
};
}
const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login);
skeleton.remove();
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
@ -605,7 +648,7 @@ export function render({ navigate, route }) {
if (focus) {
const focusWrap = document.createElement('div');
focusWrap.className = 'stack thread-block thread-block--focus';
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber(), routeKey, { showViews: true }));
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber(), routeKey, { showViews: false }));
screen.append(focusWrap);
}

View File

@ -21,9 +21,6 @@ export const pageMeta = { id: 'channel-view', title: 'Канал' };
const pendingReactionActions = new Set();
const pendingScrollByRoute = new Map();
const revealedCountersByRoute = new Map();
const seenFlushTimersByRoute = new Map();
const seenPendingByRoute = new Map();
const firstUnreadJumpByRoute = new Map();
function isChannelsDemoMode() {
try {
@ -164,6 +161,13 @@ function parseDescriptionOverride(payload) {
function buildSelectorFromRoute(route, channelId) {
const params = route?.params || {};
if (params.ownerLogin && params.channelName) {
return {
ownerLogin: String(params.ownerLogin || '').trim(),
channelName: String(params.channelName || '').trim(),
};
}
if (params.ownerBlockchainName) {
const rootBlockNumber = toSafeInt(params.channelRootBlockNumber);
if (rootBlockNumber != null) {
@ -186,6 +190,17 @@ function buildSelectorFromRoute(route, channelId) {
function buildThreadRoute(messageRef, selector) {
if (!messageRef || !selector) return '';
const ownerLogin = String(selector.ownerLogin || '').trim();
const channelName = String(selector.channelName || '').trim();
if (ownerLogin && channelName) {
return [
'channel',
encodeRoutePart(ownerLogin),
encodeRoutePart(channelName),
messageRef.blockNumber,
normalizeRouteHash(messageRef.blockHash),
].join('/');
}
return [
'channel-thread-view',
encodeRoutePart(messageRef.blockchainName),
@ -502,8 +517,6 @@ function mapApiMessageToPost(message, selector, localNumber) {
body: resolvedText || '(пусто)',
likesCount: Number(message?.likesCount || 0),
repliesCount: Number(message?.repliesCount || 0),
viewCount: Number(message?.viewCount || 0),
seenByMe: message?.seenByMe === true,
timestampMs: resolveMessageTimestampMs(message),
messageRef,
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
@ -511,7 +524,25 @@ function mapApiMessageToPost(message, selector, localNumber) {
}
async function loadFromApi(route, channelId) {
const selector = buildSelectorFromRoute(route, channelId);
let selector = buildSelectorFromRoute(route, channelId);
if (selector?.ownerLogin && selector?.channelName) {
const ownerFeed = await authService.listSubscriptionsFeed(selector.ownerLogin, 1000);
const ownChannels = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
const channel = ownChannels.find((item) => (
String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
));
if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
throw new Error('Канал не найден.');
}
selector = {
ownerBlockchainName: String(channel.channel.ownerBlockchainName),
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
channelRootBlockHash: normalizeRouteHash(channel.channel.channelRoot.blockHash),
ownerLogin: selector.ownerLogin,
channelName: selector.channelName,
};
}
if (!selector?.ownerBlockchainName || selector.channelRootBlockNumber == null) {
throw new Error('Не удалось определить канал из адреса страницы.');
}
@ -520,8 +551,6 @@ async function loadFromApi(route, channelId) {
const messages = Array.isArray(payload.messages) ? payload.messages : [];
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
const firstUnreadKey = blockRefToMessageKey(payload.firstUnreadMessageRef, selector.ownerBlockchainName);
const unreadFromPayload = Number(payload.unreadCount || 0);
const readDescription = async () => {
const sourceDescription = String(payload.channel?.channelDescription || '').trim();
@ -547,10 +576,6 @@ async function loadFromApi(route, channelId) {
ownerName: ownerLogin || 'неизвестно',
},
posts,
unreadCount: Number.isFinite(unreadFromPayload)
? Math.max(0, unreadFromPayload)
: posts.filter((post) => post.seenByMe !== true).length,
firstUnreadKey,
isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(),
selector,
};
@ -665,11 +690,7 @@ function renderPostCard(post, {
body.className = 'channel-message-body';
body.textContent = post.body;
const views = document.createElement('p');
views.className = 'channel-message-views';
views.textContent = `Просмотры: ${Number(post.viewCount || 0)}`;
card.append(topRow, body, views);
card.append(topRow, body);
const refKey = messageRefKey(post.messageRef);
if (refKey) {
@ -703,19 +724,23 @@ function renderPostCard(post, {
const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">${isPending ? 'Сияние...' : 'Сияние'}</span>
<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 = 'Сияние...';
if (labelEl) labelEl.textContent = 'Лайк...';
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
});
@ -724,7 +749,7 @@ function renderPostCard(post, {
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>
<span class="channel-action-label">Ответить</span>
`;
replyButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
@ -754,7 +779,7 @@ function renderPostCard(post, {
shareButton.className = 'channel-action-item channel-action-share';
shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">Транслировать</span>
<span class="channel-action-label">Отправить</span>
`;
shareButton.addEventListener('click', async (event) => {
event.stopPropagation();
@ -816,25 +841,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
actionButton.className = channelData.isOwnChannel
? 'primary-btn channel-main-action'
: 'destructive-btn channel-main-action';
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Отписаться от канала';
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Подписаться на канал';
const feed = document.createElement('div');
feed.className = 'stack channel-feed';
const unreadDivider = document.createElement('div');
unreadDivider.className = 'channels-unread-divider';
unreadDivider.textContent = 'Непрочитанные сообщения';
const unreadJump = document.createElement('button');
unreadJump.type = 'button';
unreadJump.className = 'channels-unread-jump';
unreadJump.innerHTML = `
<span class="channels-unread-jump-icon" aria-hidden="true"></span>
<span class="channels-unread-jump-badge"></span>
`;
const unreadBadge = unreadJump.querySelector('.channels-unread-jump-badge');
const postsByKey = new Map();
const unreadKeys = new Set();
let seenFlushInFlight = false;
let seenObserver = null;
if (channelData.posts.length) {
channelData.posts.forEach((post) => {
@ -849,7 +860,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const key = messageRefKey(post.messageRef);
if (key) {
postsByKey.set(key, post);
if (post.seenByMe !== true) unreadKeys.add(key);
}
feed.append(row);
});
@ -860,101 +870,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
feed.append(empty);
}
const syncUnreadState = () => {
unreadKeys.clear();
postsByKey.forEach((post, key) => {
if (post.seenByMe !== true) unreadKeys.add(key);
});
};
const updateUnreadJump = () => {
const unreadCount = unreadKeys.size;
unreadJump.classList.toggle('is-visible', unreadCount > 0);
unreadJump.hidden = unreadCount <= 0;
if (unreadBadge) unreadBadge.textContent = unreadCount > 0 ? String(unreadCount) : '';
};
const mountUnreadDivider = () => {
unreadDivider.remove();
if (!unreadKeys.size) return;
const firstUnread = channelData.posts.find((post) => {
const key = messageRefKey(post.messageRef);
return key && unreadKeys.has(key);
});
const firstUnreadKey = messageRefKey(firstUnread?.messageRef);
if (!firstUnreadKey) return;
const target = feed.querySelector(`[data-message-key="${firstUnreadKey}"]`);
if (target) {
feed.insertBefore(unreadDivider, target);
}
};
const routePending = (() => {
let bucket = seenPendingByRoute.get(routeKey);
if (!bucket) {
bucket = new Set();
seenPendingByRoute.set(routeKey, bucket);
}
return bucket;
})();
const scheduleSeenFlush = () => {
const oldTimer = seenFlushTimersByRoute.get(routeKey);
if (oldTimer) clearTimeout(oldTimer);
const timer = setTimeout(async () => {
seenFlushTimersByRoute.delete(routeKey);
if (seenFlushInFlight) return;
const pendingKeys = [...routePending].filter((key) => {
const post = postsByKey.get(key);
return !!post && post.seenByMe !== true;
});
if (!pendingKeys.length) return;
const refs = pendingKeys
.map((key) => parseMessageRefKey(key))
.filter(Boolean);
if (!refs.length) return;
pendingKeys.forEach((key) => routePending.delete(key));
seenFlushInFlight = true;
try {
await handlers.onMarkSeenBatch(refs);
refs.forEach((ref) => {
const key = messageRefKey(ref);
const post = key ? postsByKey.get(key) : null;
if (post) post.seenByMe = true;
});
syncUnreadState();
mountUnreadDivider();
updateUnreadJump();
} catch (error) {
refs.forEach((ref) => {
const key = messageRefKey(ref);
if (!key) return;
const node = feed.querySelector(`[data-message-key="${key}"]`);
if (node) seenObserver?.observe(node);
});
handlers.onSeenError?.(error);
} finally {
seenFlushInFlight = false;
if (routePending.size) scheduleSeenFlush();
}
}, 220);
seenFlushTimersByRoute.set(routeKey, timer);
};
unreadJump.addEventListener('click', () => {
const unreadPosts = channelData.posts.filter((post) => {
const key = messageRefKey(post.messageRef);
return key && unreadKeys.has(key);
});
const targetPost = unreadPosts.length ? unreadPosts[unreadPosts.length - 1] : channelData.posts[channelData.posts.length - 1];
const key = messageRefKey(targetPost?.messageRef);
if (!key) return;
const target = feed.querySelector(`[data-message-key="${key}"]`);
target?.scrollIntoView({ behavior: 'smooth', block: 'end' });
});
if (channelData.isOwnChannel) {
actionButton.addEventListener('click', (event) => {
@ -965,7 +880,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
});
});
} else {
actionButton.addEventListener('click', handlers.onUnfollowChannel);
actionButton.addEventListener('click', handlers.onSubscribeChannel);
}
const backButton = document.createElement('button');
@ -973,57 +888,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
backButton.textContent = 'Назад к каналам';
backButton.addEventListener('click', () => navigate('channels-list'));
screen.append(head, actionButton, feed, backButton, unreadJump);
if (state.session.login && channelData.selector && channelData.posts.length) {
seenObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting || entry.intersectionRatio < 0.6) return;
const key = String(entry.target?.dataset?.messageKey || '').trim();
if (!key) return;
const post = postsByKey.get(key);
if (!post || post.seenByMe === true) return;
routePending.add(key);
seenObserver?.unobserve(entry.target);
});
if (routePending.size) scheduleSeenFlush();
}, {
root: document.getElementById('app-screen') || null,
threshold: [0.6],
});
feed.querySelectorAll('[data-message-key]').forEach((node) => {
const key = String(node.dataset.messageKey || '').trim();
if (key && unreadKeys.has(key)) seenObserver?.observe(node);
});
}
syncUnreadState();
mountUnreadDivider();
updateUnreadJump();
const firstUnreadCandidate = channelData.firstUnreadKey
|| (() => {
const first = channelData.posts.find((post) => post.seenByMe !== true);
return messageRefKey(first?.messageRef);
})();
if (firstUnreadCandidate) {
const previous = firstUnreadJumpByRoute.get(routeKey);
if (previous !== firstUnreadCandidate) {
pendingScrollByRoute.set(routeKey, firstUnreadCandidate);
firstUnreadJumpByRoute.set(routeKey, firstUnreadCandidate);
}
} else {
firstUnreadJumpByRoute.delete(routeKey);
}
screen.append(head, actionButton, feed, backButton);
applyPendingScroll(screen, routeKey);
return () => {
seenObserver?.disconnect();
const timer = seenFlushTimersByRoute.get(routeKey);
if (timer) clearTimeout(timer);
seenFlushTimersByRoute.delete(routeKey);
seenPendingByRoute.delete(routeKey);
// noop
};
}
@ -1070,7 +939,9 @@ export function render({ navigate, route }) {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
throw new Error('Сессия недействительна. Выполните вход заново.');
state.authReturnHash = window.location.hash || '#/channels-list';
navigate('login-view');
throw new Error('Для этого действия нужно войти');
}
return { login, storagePwd };
};
@ -1117,21 +988,6 @@ export function render({ navigate, route }) {
rerender();
};
const onMarkSeenBatch = async (refs) => {
if (!Array.isArray(refs) || !refs.length) return;
const login = String(state.session.login || '').trim();
if (!login || !routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) return;
await authService.markChannelMessagesSeen({
login,
channel: {
ownerBlockchainName: routeSelector.ownerBlockchainName,
channelRootBlockNumber: routeSelector.channelRootBlockNumber,
channelRootBlockHash: routeSelector.channelRootBlockHash,
},
messages: refs,
});
};
const onShare = async (routePath) => {
try {
const routeToShare = String(routePath || '').trim();
@ -1145,7 +1001,7 @@ export function render({ navigate, route }) {
if (result === 'shared') showToast('Ссылка передана');
if (result === 'shared' || result === 'copied') softHaptic(10);
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
showStatus(toUserMessage(error, 'Не удалось отправить ссылку.'));
}
};
@ -1237,11 +1093,14 @@ export function render({ navigate, route }) {
throw new Error(toUserMessage(error, 'Не удалось сохранить описание.'));
}
},
onUnfollowChannel: async (event) => {
onSubscribeChannel: async (event) => {
animatePress(event?.currentTarget);
try {
const { login, storagePwd } = requireSigningSession();
if (!apiData.selector) throw new Error('Не удалось определить канал для отписки.');
if (!apiData.selector) throw new Error('Не удалось определить канал для подписки.');
const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
const ok = window.confirm(`Подписаться на канал ${targetName}?`);
if (!ok) return;
await authService.addBlockFollowChannel({
login,
@ -1249,26 +1108,15 @@ export function render({ navigate, route }) {
targetBlockchainName: apiData.selector.ownerBlockchainName,
targetBlockNumber: apiData.selector.channelRootBlockNumber,
targetBlockHashHex: apiData.selector.channelRootBlockHash,
unfollow: true,
unfollow: false,
});
softHaptic(15);
showToast('Отписка от канала выполнена');
navigate('channels-list');
showToast('Подписка на канал выполнена');
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
showStatus(toUserMessage(error, 'Не удалось подписаться на канал.'));
}
},
onMarkSeenBatch: async (refs) => {
try {
await onMarkSeenBatch(refs);
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось отметить сообщения как прочитанные.'));
}
},
onSeenError: (error) => {
showStatus(toUserMessage(error, 'Не удалось обновить статус прочтения.'));
},
});
} catch (error) {
skeleton.remove();

View File

@ -40,6 +40,11 @@ function normalizeLoginInput(value) {
}
function buildChannelRouteFromSummary(summary, fallbackId) {
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);
@ -406,6 +411,117 @@ function openSimpleSubscribeModal({ kind, kindLabel, submitLabel, unfollow = fal
if (inputEl) inputEl.focus();
}
function openChannelFinderModal({ navigate }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channels-find-modal">
<div class="modal-card stack" style="max-width: min(96vw, 760px); width: min(96vw, 760px);">
<h3 class="modal-title">Поиск каналов</h3>
<p class="meta-muted">Введите логин или формат login/channel</p>
<input id="channels-find-input" class="input" placeholder="login/channel" autocomplete="off" />
<div id="channels-find-suggest" class="channels-search-suggest" style="display:none"></div>
<div id="channels-find-list" class="channels-search-suggest" style="display:none"></div>
<div id="channels-find-error" class="meta-muted inline-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channels-find-close" type="button">Закрыть</button>
</div>
</div>
</div>
`;
const inputEl = root.querySelector('#channels-find-input');
const suggestEl = root.querySelector('#channels-find-suggest');
const channelsEl = root.querySelector('#channels-find-list');
const errorEl = root.querySelector('#channels-find-error');
const close = () => { root.innerHTML = ''; };
const renderButtons = (container, values, onPick) => {
container.innerHTML = '';
if (!values.length) {
container.style.display = 'none';
return;
}
container.style.display = '';
values.forEach((value) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'channel-search-item';
btn.textContent = value.label;
btn.addEventListener('click', () => onPick(value));
container.append(btn);
});
};
const loadChannelsForLogin = async (login, filterChannel = '') => {
const ownerLogin = normalizeLoginInput(login);
if (!ownerLogin) return;
const feed = await authService.listSubscriptionsFeed(ownerLogin, 1000);
const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [];
const needle = String(filterChannel || '').trim().toLowerCase();
const channels = rows
.map((item) => String(item?.channel?.channelName || '').trim())
.filter(Boolean)
.filter((name) => !needle || name.toLowerCase().includes(needle))
.slice(0, 200)
.map((name) => ({ label: `${ownerLogin}/${name}`, ownerLogin, channelName: name }));
renderButtons(channelsEl, channels, (item) => {
close();
navigate(`channel/${encodeRoutePart(item.ownerLogin)}/${encodeRoutePart(item.channelName)}`);
});
};
const refresh = createDebounced(async () => {
const raw = String(inputEl?.value || '').trim();
errorEl.textContent = '';
if (!raw) {
suggestEl.style.display = 'none';
suggestEl.innerHTML = '';
channelsEl.style.display = 'none';
channelsEl.innerHTML = '';
return;
}
const parts = raw.split('/');
const loginPrefix = normalizeLoginInput(parts[0] || '');
const channelFilter = String(parts[1] || '').trim();
try {
if (raw.includes('/')) {
suggestEl.style.display = 'none';
suggestEl.innerHTML = '';
await loadChannelsForLogin(loginPrefix, channelFilter);
return;
}
if (loginPrefix.length < 2) {
suggestEl.style.display = 'none';
suggestEl.innerHTML = '';
channelsEl.style.display = 'none';
channelsEl.innerHTML = '';
return;
}
const logins = await authService.searchUsers(loginPrefix);
const items = (Array.isArray(logins) ? logins : []).slice(0, 15).map((login) => ({
label: login,
login,
}));
renderButtons(suggestEl, items, async (item) => {
inputEl.value = `${item.login}/`;
suggestEl.style.display = 'none';
suggestEl.innerHTML = '';
await loadChannelsForLogin(item.login, '');
});
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось выполнить поиск.');
}
}, 220);
root.querySelector('#channels-find-close')?.addEventListener('click', close);
inputEl?.addEventListener('input', refresh);
if (inputEl) inputEl.focus();
}
function mapMockGroups() {
const mapRow = (channel) => ({
...channel,
@ -527,6 +643,16 @@ function toListModel(groups) {
function renderEmptyState(activeTab, navigate) {
const wrap = document.createElement('div');
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 = 'Вы пока не подписаны на каналы.';
} else if (activeTab === 'my') {
text.textContent = 'У вас пока нет каналов.';
} else {
text.textContent = 'Пока нет каналов авторов.';
}
wrap.append(text);
return wrap;
}
@ -896,14 +1022,9 @@ function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = f
}
if (tab === 'authors') {
button.textContent = 'Подписаться на автора';
button.textContent = '🔍 Поиск каналов';
button.className = baseClass;
button.onclick = () => openSimpleSubscribeModal({
kind: 'user',
kindLabel: 'Подписка на автора',
submitLabel: 'Подписаться',
onSuccess: onReload,
});
button.onclick = () => openChannelFinderModal({ navigate });
return;
}
@ -954,7 +1075,7 @@ export function render({ navigate }) {
const notificationsState = readChannelNotificationsState();
const listState = {
activeTab: 'my',
activeTab: 'subscriptions',
openMenuId: null,
notificationsState,
revealedCounters: new Set(),
@ -1001,8 +1122,8 @@ export function render({ navigate }) {
};
const tabItems = [
{ key: 'subscriptions', label: 'Каналы' },
{ key: 'my', label: 'Мои' },
{ key: 'subscriptions', label: 'Подписки' },
{ key: 'authors', label: 'Авторы' },
];

View File

@ -130,7 +130,13 @@ export function render({ navigate }) {
setAuthInfo(isLoginFlow
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
const nextHash = String(state.authReturnHash || '').trim();
state.authReturnHash = '';
if (nextHash.startsWith('#/')) {
navigate(nextHash.slice(2));
} else {
navigate('profile-view');
}
} catch (error) {
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
setAuthError(message);

View File

@ -50,6 +50,42 @@ export function getRoute() {
return { pageId, params: { channelId: dynamicId || '' } };
}
if (pageId === 'channel') {
// Новый короткий формат:
// #/channel/{login}/{channelName}
// #/channel/{login}/{channelName}/{messageBlockNumber}/{messageBlockHash}
const ownerLogin = decodePart(segments[1] || '');
const channelName = decodePart(segments[2] || '');
const messageBlockNumber = segments[3] || '';
const messageBlockHash = segments[4] || '';
if (ownerLogin && channelName && messageBlockNumber && messageBlockHash) {
return {
pageId: 'channel-thread-view',
params: {
ownerLogin,
channelName,
messageBlockNumber,
messageBlockHash,
// поддержка старого контракта страницы треда
messageBlockchainName: '',
channelOwnerBlockchainName: '',
channelRootBlockNumber: '',
channelRootBlockHash: '',
},
};
}
return {
pageId: 'channel-view',
params: {
ownerLogin,
channelName,
channelId: '',
},
};
}
if (pageId === 'channel-thread-view') {
return {
pageId,

View File

@ -740,7 +740,12 @@ export class AuthService {
}
async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') {
const payload = { channel, limit, sort };
const normalizedChannel = {
ownerBlockchainName: String(channel?.ownerBlockchainName || '').trim(),
channelRootBlockNumber: Number(channel?.channelRootBlockNumber),
channelRootBlockHash: String(channel?.channelRootBlockHash || '').trim(),
};
const payload = { channel: normalizedChannel, limit, sort };
const cleanLogin = String(login || '').trim();
if (cleanLogin) payload.login = cleanLogin;
const response = await this.ws.request('GetChannelMessages', payload);
@ -749,7 +754,12 @@ export class AuthService {
}
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
const payload = { message, depthUp, depthDown, limitChildrenPerNode };
const normalizedMessage = {
blockchainName: String(message?.blockchainName || '').trim(),
blockNumber: Number(message?.blockNumber),
blockHash: String(message?.blockHash || '').trim(),
};
const payload = { message: normalizedMessage, depthUp, depthDown, limitChildrenPerNode };
const cleanLogin = String(login || '').trim();
if (cleanLogin) payload.login = cleanLogin;
const response = await this.ws.request('GetMessageThread', payload);
@ -757,17 +767,6 @@ export class AuthService {
return response.payload || {};
}
async markChannelMessagesSeen({ login, channel, messages }) {
const cleanLogin = String(login || '').trim();
const refs = Array.isArray(messages) ? messages : [];
const payload = { channel, messages: refs };
if (cleanLogin) payload.login = cleanLogin;
const response = await this.ws.request('MarkChannelMessagesSeen', payload);
if (response.status !== 200) throw opError('MarkChannelMessagesSeen', response);
return response.payload || {};
}
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login for AddBlock');

View File

@ -252,6 +252,7 @@ function createInitialState({ withStoredSession = true } = {}) {
error: '',
info: '',
},
authReturnHash: '',
sessions: [],
channelsFeed: null,
channelsIndex: {},

View File

@ -360,35 +360,7 @@ public final class DatabaseInitializer {
ON message_stats (to_login);
""");
// 8.0) message_views_state (уникальный просмотр/прочтение сообщения пользователем)
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS message_views_state (
viewer_login TEXT NOT NULL,
to_bch_name TEXT NOT NULL,
to_block_number INTEGER NOT NULL,
to_block_hash BLOB NOT NULL,
first_seen_at_ms INTEGER NOT NULL,
UNIQUE (
viewer_login,
to_bch_name,
to_block_number,
to_block_hash
)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_message_views_state_target
ON message_views_state (to_bch_name, to_block_number, to_block_hash);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_message_views_state_viewer_channel
ON message_views_state (viewer_login, to_bch_name);
""");
// 8.1) reactions_state (идемпотентный LIKE/UNLIKE per actor/target)
// 8.0) reactions_state (идемпотентный LIKE/UNLIKE per actor/target)
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS reactions_state (
from_login TEXT NOT NULL,

View File

@ -14,7 +14,7 @@ import java.sql.Statement;
public final class SqliteDbController {
private static volatile SqliteDbController instance;
private static final int LATEST_SCHEMA_VERSION = DatabaseInitializer.SCHEMA_VERSION_1;
private static final int LATEST_SCHEMA_VERSION = 2;
private final String jdbcUrl;
@ -84,6 +84,7 @@ public final class SqliteDbController {
private void applyMigration(int targetVersion) {
switch (targetVersion) {
case 1 -> migrateToV1();
case 2 -> migrateToV2();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
}
}
@ -123,6 +124,29 @@ public final class SqliteDbController {
}
}
private void migrateToV2() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
st.execute("PRAGMA foreign_keys = OFF");
st.executeUpdate("DROP TABLE IF EXISTS message_views_state");
st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_target");
st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_viewer_channel");
setSchemaVersion(c, 2);
st.execute("PRAGMA foreign_keys = ON");
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v2 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v2 failed", e);
}
}
private int getCurrentSchemaVersion() {
try (Connection c = DriverManager.getConnection(jdbcUrl)) {
if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) {
@ -183,23 +207,6 @@ public final class SqliteDbController {
""");
}
private static void ensureMessageViewsStateTable(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS message_views_state (
viewer_login TEXT NOT NULL,
to_bch_name TEXT NOT NULL,
to_block_number INTEGER NOT NULL,
to_block_hash BLOB NOT NULL,
first_seen_at_ms INTEGER NOT NULL,
UNIQUE (
viewer_login,
to_bch_name,
to_block_number,
to_block_hash
)
);
""");
}
private static void createConnectionsStateTable(Statement st) throws SQLException {
st.executeUpdate("""
@ -246,17 +253,6 @@ public final class SqliteDbController {
""");
}
private static void ensureMessageViewsIndexes(Statement st) throws SQLException {
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_message_views_state_target
ON message_views_state (to_bch_name, to_block_number, to_block_hash);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_message_views_state_viewer_channel
ON message_views_state (viewer_login, to_bch_name);
""");
}
private static void ensureChannelNamesStateTable(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS channel_names_state (

View File

@ -49,11 +49,9 @@ import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstra
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_MarkChannelMessagesSeen_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_MarkChannelMessagesSeen_Request;
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
@ -131,7 +129,6 @@ public final class JsonHandlerRegistry {
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
Map.entry("MarkChannelMessagesSeen", new Net_MarkChannelMessagesSeen_Handler()),
Map.entry("ListContacts", new Net_ListContacts_Handler()),
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
@ -187,7 +184,6 @@ public final class JsonHandlerRegistry {
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
Map.entry("MarkChannelMessagesSeen", Net_MarkChannelMessagesSeen_Request.class),
Map.entry("ListContacts", Net_ListContacts_Request.class),
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),

View File

@ -212,111 +212,6 @@ final class ChannelsReadSupport {
}
}
static int countViews(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
String sql = """
SELECT COUNT(*) AS cnt
FROM message_views_state
WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bch);
ps.setInt(2, blockNumber);
ps.setBytes(3, blockHash);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt("cnt") : 0;
}
}
}
static boolean isSeenByLogin(Connection c, String login, String toBch, int toBlockNumber, byte[] toBlockHash) throws SQLException {
if (login == null || login.isBlank() || toBch == null || toBch.isBlank() || toBlockHash == null || toBlockHash.length != 32) {
return false;
}
String sql = """
SELECT 1
FROM message_views_state
WHERE viewer_login = ? COLLATE NOCASE
AND to_bch_name = ?
AND to_block_number = ?
AND to_block_hash = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setString(2, toBch);
ps.setInt(3, toBlockNumber);
ps.setBytes(4, toBlockHash);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
static int countUnreadPosts(Connection c, String viewerLogin, String ownerBch, int lineCode) throws SQLException {
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank() || lineCode < 0) return 0;
String sql = """
SELECT COUNT(*) AS cnt
FROM blocks b
LEFT JOIN message_views_state v
ON v.viewer_login = ?
AND v.to_bch_name = b.bch_name
AND v.to_block_number = b.block_number
AND v.to_block_hash = b.block_hash
WHERE b.bch_name = ?
AND b.msg_type = ?
AND b.msg_sub_type = ?
AND b.line_code = ?
AND v.viewer_login IS NULL
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setString(2, ownerBch);
ps.setInt(3, MSG_TYPE_TEXT);
ps.setInt(4, MsgSubType.TEXT_POST);
ps.setInt(5, lineCode);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt("cnt") : 0;
}
}
}
static PostBlock firstUnreadPost(Connection c, String viewerLogin, String ownerBch, int lineCode) throws SQLException {
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank() || lineCode < 0) return null;
String sql = """
SELECT b.login,b.bch_name,b.block_number,b.block_hash,b.block_bytes
FROM blocks b
LEFT JOIN message_views_state v
ON v.viewer_login = ?
AND v.to_bch_name = b.bch_name
AND v.to_block_number = b.block_number
AND v.to_block_hash = b.block_hash
WHERE b.bch_name = ?
AND b.msg_type = ?
AND b.msg_sub_type = ?
AND b.line_code = ?
AND v.viewer_login IS NULL
ORDER BY b.block_number ASC
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setString(2, ownerBch);
ps.setInt(3, MSG_TYPE_TEXT);
ps.setInt(4, MsgSubType.TEXT_POST);
ps.setInt(5, lineCode);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
PostBlock pb = new PostBlock();
pb.login = rs.getString("login");
pb.bchName = rs.getString("bch_name");
pb.blockNumber = rs.getInt("block_number");
pb.blockHash = rs.getBytes("block_hash");
pb.blockBytes = rs.getBytes("block_bytes");
return pb;
}
}
}
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
if (rootNumber == 0) return "";

View File

@ -108,22 +108,11 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
item.setLikesCount(stats[0]);
item.setRepliesCount(stats[1]);
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
item.setViewCount(ChannelsReadSupport.countViews(c, post.bchName, post.blockNumber, post.blockHash));
item.setSeenByMe(ChannelsReadSupport.isSeenByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
items.add(item);
}
resp.setMessages(items);
int unreadCount = ChannelsReadSupport.countUnreadPosts(c, viewerLogin, ownerBch, lineCode);
resp.setUnreadCount(unreadCount);
ChannelsReadSupport.PostBlock firstUnread = ChannelsReadSupport.firstUnreadPost(c, viewerLogin, ownerBch, lineCode);
if (firstUnread != null) {
Net_GetChannelMessages_Response.BlockRef firstUnreadRef = new Net_GetChannelMessages_Response.BlockRef();
firstUnreadRef.setBlockNumber(firstUnread.blockNumber);
firstUnreadRef.setBlockHash(ChannelsReadSupport.toHex(firstUnread.blockHash));
resp.setFirstUnreadMessageRef(firstUnreadRef);
}
return resp;
} catch (Exception e) {
log.error("GetChannelMessages failed", e);

View File

@ -178,9 +178,6 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
node.setLikesCount(stats[0]);
node.setRepliesCount(stats[1]);
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
node.setViewCount(ChannelsReadSupport.countViews(c, row.bchName, row.blockNumber, row.blockHash));
node.setSeenByMe(ChannelsReadSupport.isSeenByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
if (row.lineCode != null && row.lineCode >= 0) {
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
ci.setOwnerBlockchainName(row.bchName);
@ -229,4 +226,3 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
int msgSubType;
}
}

View File

@ -74,7 +74,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
row.setChannel(channelRef);
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
row.setUnreadCount(ChannelsReadSupport.countUnreadPosts(c, viewerLogin, key.ownerBch, key.rootNumber));
row.setUnreadCount(0);
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
if (lastPost != null) {

View File

@ -8,8 +8,6 @@ import java.util.List;
public class Net_GetChannelMessages_Response extends Net_Response {
private Channel channel;
private List<MessageItem> messages = new ArrayList<>();
private int unreadCount;
private BlockRef firstUnreadMessageRef;
public Channel getChannel() { return channel; }
public void setChannel(Channel channel) { this.channel = channel; }
@ -17,11 +15,6 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public List<MessageItem> getMessages() { return messages; }
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
public int getUnreadCount() { return unreadCount; }
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
public BlockRef getFirstUnreadMessageRef() { return firstUnreadMessageRef; }
public void setFirstUnreadMessageRef(BlockRef firstUnreadMessageRef) { this.firstUnreadMessageRef = firstUnreadMessageRef; }
public static class Channel {
private String ownerLogin;
@ -55,8 +48,6 @@ public class Net_GetChannelMessages_Response extends Net_Response {
private int likesCount;
private boolean likedByMe;
private int repliesCount;
private int viewCount;
private boolean seenByMe;
private int versionsTotal;
private List<VersionItem> versions = new ArrayList<>();
@ -84,12 +75,6 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public int getRepliesCount() { return repliesCount; }
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
public int getViewCount() { return viewCount; }
public void setViewCount(int viewCount) { this.viewCount = viewCount; }
public boolean isSeenByMe() { return seenByMe; }
public void setSeenByMe(boolean seenByMe) { this.seenByMe = seenByMe; }
public int getVersionsTotal() { return versionsTotal; }
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }

View File

@ -0,0 +1,175 @@
# Как устроены каналы в блокчейне SHiNE
## 1) Коротко: что такое “канал” в текущей реализации
В SHiNE канал — это не отдельная таблица сообщений, а **линия блоков** внутри блокчейна пользователя:
- канал создается TECH-блоком `CreateChannelBody` (`msg_type=0`, `msg_sub_type=1`);
- сообщения канала — это `TEXT_POST` (`msg_type=1`, `msg_sub_type=10`) с `line_code = rootBlockNumber канала`;
- ответы (треды) — это `TEXT_REPLY` (`msg_sub_type=20`) с target-ссылкой на конкретный блок-сообщение.
То есть канал = “корневой блок канала” + все посты в его линии + связанные ответы.
---
## 2) Как канал появляется
Создание канала идет через `AddBlock`:
1. UI собирает `CreateChannelBody` (v2, а при legacy-ошибке fallback на v1).
2. UI подписывает блок приватным blockchain-ключом на устройстве.
3. UI отправляет на сервер `AddBlock` с `blockBytesB64` (полный бинарный блок: preimage + sigMarker + signature).
4. Сервер:
- проверяет цепочку (`prevHash`, `blockNumber=last+1`);
- парсит body;
- валидирует подпись;
- валидирует имя канала;
- сохраняет блок и обновляет state.
Дополнительно сервер поддерживает `channel_names_state` как нормализованное состояние названий каналов.
---
## 3) Правила имени и описания канала
### Имя канала (`ChannelNameRules`)
- длина: `3..32` символов (code points);
- допустимые символы: Latin/Cyrillic, цифры, пробел, `_`, `-`;
- имя нормализуется (trim + схлопывание пробелов);
- канонический slug строится в lower-case, `ё -> е`, разделители -> `-`.
### Уникальность
Проверяется по slug. При конфликте сервер возвращает `channel_name_already_exists`.
### Описание канала
В `CreateChannelBody v2` описание хранится прямо в блоке (до 200 байт UTF-8).
Для совместимости с legacy-v1 есть fallback: описание может сохраняться как `USER_PARAM` ключа вида:
`channel_desc:{ownerBlockchainName}:{rootBlockNumber}:{rootBlockHash}`
В UI при чтении описание берется из ответа канала и при наличии override — перекрывается значением из `USER_PARAM`.
---
## 4) Канал “0”
`rootBlockNumber=0` — технический root-канал.
Публикации `TEXT_POST` в канал `0` сейчас отключены (на сервере есть явный запрет).
---
## 5) Как идут сообщения в канале
### Публикация поста
UI вызывает `addBlockTextPost` -> `AddBlock` с `TEXT_POST`.
Ограничения:
- писать можно только в **свой** блокчейн и свои каналы;
- для поста задается `line_code` канала;
- пост в канале — это новый неизменяемый блок.
### Ответы
Ответы (`TEXT_REPLY`) не обязаны лежать в той же линии.
Они ссылаются на целевой блок через target (`to_bch_name`, `to_block_number`, `to_block_hash`).
Это позволяет отвечать и из других блокчейнов (межпользовательский тред).
---
## 6) Редактирование и удаление сообщений
### Редактирование
Поддержано на уровне протокола:
- `TEXT_EDIT_POST` (11) — правка поста;
- `TEXT_EDIT_REPLY` (21) — правка ответа.
Правка — это **новый блок**, ссылающийся на оригинал.
Оригинальный блок не меняется.
Серверные read-API уже собирают `versions[]` и `versionsTotal`.
### Удаление
Сейчас отдельного subtype “delete post/reply” нет.
Физического удаления блоков из цепочки нет (блоки иммутабельны).
Итог:
- изменить можно через edit-блок;
- удалить “как в чате” сейчас нельзя.
---
## 7) Как UI получает канал
Основные read-API:
- `ListSubscriptionsFeed` — список каналов/подписок;
- `GetChannelMessages` — посты канала;
- `GetMessageThread` — тред вокруг выбранного сообщения.
Важно: UI получает **JSON-представление**, собранное сервером из блоков БД, а не сырые блоки по умолчанию.
В JSON возвращаются:
- `messageRef` (номер и hash блока),
- автор,
- текущий текст,
- `versions[]` (оригинал + правки),
- counters (`likesCount`, `repliesCount`).
---
## 8) Как строится тред
`GetMessageThread`:
1. Находит focus-сообщение по `(blockchainName, blockNumber)`.
2. Строит `ancestors` вверх по target-ссылкам.
3. Строит `descendants` вниз: replies, где target = focus.
Запросы в БД идут по `to_bch_name + to_block_number + to_block_hash`, поэтому ответы из других блокчейнов тоже связываются.
---
## 9) Что проверяется криптографически
При записи (`AddBlock`) сервер проверяет:
- корректность формата блока;
- непрерывность цепочки;
- `SHA-256(preimage)` и Ed25519-подпись;
- соответствие публичного blockchain-ключа пользователя.
На чтении (`GetChannelMessages`, `GetMessageThread`) сервер отдает уже сохраненные данные (JSON из БД).
Повторная верификация каждой записи при каждом чтении не делается.
---
## 10) UI-статус на сегодня (важно)
На момент этого документа:
- в UI нет полноценного экрана истории правок сообщения (хотя `versions` уже приходят);
- нет операции удаления сообщений (и на протоколе нет delete subtype);
- канал читается как JSON-слой поверх блоков, а не как “сырой бинарный блок-объект”.
---
## 11) Практический вывод по модели данных
Каналы в SHiNE — это append-only модель:
- каждое действие = новый подписанный блок;
- “изменение” = добавление новой версии, не перезапись старой;
- целостность и авторство обеспечиваются подписью и связностью цепочки;
- UI может показывать удобный “чатовый” вид, но источник истины — блоки.

View File

@ -0,0 +1,141 @@
# Типы блоков и сообщений SHiNE (карта протокола)
## 1) Главный принцип
В блокчейн попадают только записи `AddBlock` (подписанные бинарные блоки).
Все остальное (например, call signaling, push-события, служебные JSON-операции) — не блокчейн-данные.
---
## 2) Базовые `msg_type`
## `msg_type=0` — TECH
- `subType=0``HEADER_COMPAT` (техническая совместимость);
- `subType=1``TECH_CREATE_CHANNEL` (создание канала).
Используется для структуры каналов.
## `msg_type=1` — TEXT
- `10``TEXT_POST` (пост в линии канала);
- `11``TEXT_EDIT_POST` (правка поста);
- `20``TEXT_REPLY` (ответ на сообщение через target);
- `21``TEXT_EDIT_REPLY` (правка ответа).
Это основной контент каналов и тредов.
## `msg_type=2` — REACTION
- `1``REACTION_LIKE`;
- `2``REACTION_UNLIKE`.
Лайки/снятие лайка, считаются через state-триггеры и/или агрегации.
## `msg_type=3` — CONNECTION
Связи между пользователями (friend/contact/follow/spouse/parent/child/sibling + обратные UN*).
Используется для соцграфа и подписок:
- `FOLLOW/UNFOLLOW` — подписки на авторов/каналы.
## `msg_type=4` — USER_PARAM
Ключ-значение параметра пользователя (profile / тех.параметры / fallback-метаданные).
Пример для каналов: fallback-описание `channel_desc:...`.
---
## 3) Что **не** является блокчейн-типом
Ниже операции есть в протоколе, но не через `AddBlock`:
- `CallInviteBroadcast`, `CallSignalToSession` (сигналинг звонков),
- `SendDirectMessage`, `ReceiveIncomingMessage`, `ReceiveOutcomingMessage`,
- `AckSessionDelivery`, `UpsertPushToken`, `SendTestWebPush`,
- системные `Ping`, `GetServerInfo`, логи и т.п.
Это JSON-операции поверх WS/серверной логики.
---
## 4) Формат блока (высокоуровнево)
Блок включает:
1. preimage (header + body),
2. `sigMarker`,
3. `signature64`.
`hash32 = SHA-256(preimage)`, подпись Ed25519 проверяется сервером при `AddBlock`.
Ключевые проверки на сервере:
- `blockNumber == last + 1`,
- `prevHash` совпадает с последним хэшем цепочки,
- body валиден по типу/версии/subtype,
- подпись корректна.
---
## 5) Где и как это используется в UI
## Уже активно
- создание канала (`TECH_CREATE_CHANNEL`);
- пост в канал (`TEXT_POST`);
- ответ (`TEXT_REPLY`);
- лайк/анлайк (`REACTION_*`);
- follow/unfollow через connection-блоки.
## Частично готово на API, но не доведено в UI
- отображение полной истории правок (`versions[]` есть в API, но UI показывает не полностью как отдельный workflow);
- редактирование поста/ответа (типы в протоколе есть, UI-сценарий не завершен);
- удаление сообщений отсутствует как тип.
---
## 6) Про “AI сообщения”
Отдельного `msg_type/subType` “AI message” в текущем протоколе нет.
Если нужно, это обычно делают либо:
- как новый `TEXT_*` subtype (если это контент канала),
- либо как отдельный новый `msg_type` (если нужна независимая семантика/правила).
---
## 7) Почему в UI виден JSON, а не “сырой блок”
Текущий read-path сделан так:
- сервер читает блоки из БД;
- парсит и собирает удобное JSON-представление;
- UI рендерит его как сообщения/треды.
Плюсы:
- проще и быстрее для интерфейса;
- не дублируется сложная логика парсинга блоков на клиенте.
Минусы:
- клиент не делает локальную крипто-верификацию каждого прочитанного элемента.
При необходимости можно добавить режим “raw block view” и верификацию на клиенте как отдельный экспертный экран.
---
## 8) Рекомендация по UX/протоколу
Для обычного пользователя лучше оставить “UI-сообщения” (читаемо и быстро).
Для аудита/доверия имеет смысл добавить отдельный режим:
- показать `blockNumber/hash/signature`,
- показать все версии,
- кнопка “проверить подпись локально” (advanced).
Так получится и удобство, и проверяемость.