From acdd6c928b3afd5117b875f28d7d1723f0fa0b716257d1533213d9c40782235c Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 8 May 2026 01:15:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=B0=D0=BD=D0=B0=D0=BB=D1=8B:=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B9=20=D1=80=D0=BE=D1=83=D1=82=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3,=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA,=20=D0=B2=D1=85=D0=BE?= =?UTF-8?q?=D0=B4-=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82,=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D0=BC=D0=BE=D1=82=D1=80=D0=BE=D0=B2=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 4 +- shine-UI/js/pages/channel-thread-view.js | 83 ++++-- shine-UI/js/pages/channel-view.js | 278 ++++-------------- shine-UI/js/pages/channels-list.js | 139 ++++++++- shine-UI/js/pages/registration-keys-view.js | 8 +- shine-UI/js/router.js | 36 +++ shine-UI/js/services/auth-service.js | 25 +- shine-UI/js/state.js | 1 + .../java/shine/db/DatabaseInitializer.java | 30 +- .../java/shine/db/SqliteDbController.java | 54 ++-- .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 - .../channels/ChannelsReadSupport.java | 105 ------- .../Net_GetChannelMessages_Handler.java | 11 - .../Net_GetMessageThread_Handler.java | 4 - .../Net_ListSubscriptionsFeed_Handler.java | 2 +- .../Net_GetChannelMessages_Response.java | 15 - Как_устроены_каналы_в_блокчейне_SHiNE.md | 175 +++++++++++ Типы_блоков_и_сообщений_SHiNE.md | 141 +++++++++ 18 files changed, 657 insertions(+), 458 deletions(-) create mode 100644 Как_устроены_каналы_в_блокчейне_SHiNE.md create mode 100644 Типы_блоков_и_сообщений_SHiNE.md diff --git a/VERSION.properties b/VERSION.properties index 370bc3c..a33be7a 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.40 -server.version=1.2.34 +client.version=1.2.42 +server.version=1.2.36 diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index 70f2ce7..a9d99ef 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -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); } diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index abddf46..df91883 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -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 = ` - - ${isPending ? 'Сияние...' : 'Сияние'} + + ${isPending ? 'Лайк...' : 'Лайк'} ${post.likesCount || 0} `; 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 = ` - Отразить + Ответить `; replyButton.addEventListener('click', (event) => { animatePress(event.currentTarget); @@ -754,7 +779,7 @@ function renderPostCard(post, { shareButton.className = 'channel-action-item channel-action-share'; shareButton.innerHTML = ` - Транслировать + Отправить `; 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 = ` - - - `; - 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(); diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index db46935..5a25d19 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -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 = ` + + `; + + 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: 'Авторы' }, ]; diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js index d23d202..c24a5e8 100644 --- a/shine-UI/js/pages/registration-keys-view.js +++ b/shine-UI/js/pages/registration-keys-view.js @@ -130,7 +130,13 @@ export function render({ navigate }) { setAuthInfo(isLoginFlow ? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».` : `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`); - navigate('profile-view'); + 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); diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index 9449a3e..40f6aa4 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -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, diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 3899267..e789dec 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -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'); diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 1857cf8..dbec24a 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -252,6 +252,7 @@ function createInitialState({ withStoredSession = true } = {}) { error: '', info: '', }, + authReturnHash: '', sessions: [], channelsFeed: null, channelsIndex: {}, diff --git a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 3ca5e5b..a9c73b9 100644 --- a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -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, diff --git a/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/shine-server-db/src/main/java/shine/db/SqliteDbController.java index c13a381..715f72a 100644 --- a/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ b/shine-server-db/src/main/java/shine/db/SqliteDbController.java @@ -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 ( diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 58233ee..464002b 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -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), diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index 65f9a6a..6b996c0 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -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 ""; diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index 5e9a4ce..64d5e3d 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -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); diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java index 9fa2148..e6cce70 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java @@ -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; } } - diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java index c6e072f..42f0891 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java @@ -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) { diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java index 60c6f24..1b3ae57 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java @@ -8,8 +8,6 @@ import java.util.List; public class Net_GetChannelMessages_Response extends Net_Response { private Channel channel; private List 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 getMessages() { return messages; } public void setMessages(List 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 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; } diff --git a/Как_устроены_каналы_в_блокчейне_SHiNE.md b/Как_устроены_каналы_в_блокчейне_SHiNE.md new file mode 100644 index 0000000..e121599 --- /dev/null +++ b/Как_устроены_каналы_в_блокчейне_SHiNE.md @@ -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 может показывать удобный “чатовый” вид, но источник истины — блоки. diff --git a/Типы_блоков_и_сообщений_SHiNE.md b/Типы_блоков_и_сообщений_SHiNE.md new file mode 100644 index 0000000..94cf9b3 --- /dev/null +++ b/Типы_блоков_и_сообщений_SHiNE.md @@ -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). + +Так получится и удобство, и проверяемость.