diff --git a/Dev_Docs/API/04_Add_Block_to_Blockchain_API.md b/Dev_Docs/API/04_Add_Block_to_Blockchain_API.md index 5cf882f..62d839d 100644 --- a/Dev_Docs/API/04_Add_Block_to_Blockchain_API.md +++ b/Dev_Docs/API/04_Add_Block_to_Blockchain_API.md @@ -96,6 +96,7 @@ - `TEXT_EDIT_POST (11)` - `TEXT_REPLY (20)` - `TEXT_EDIT_REPLY (21)` + - `TEXT_REPOST (30)` 3. **REACTION (type=2)** - `REACTION_LIKE (1)` diff --git a/Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md b/Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md index a313280..f9067ff 100644 --- a/Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md +++ b/Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md @@ -12,7 +12,7 @@ 2. Персональный публичный чат хранится как канал типа `100`: - у `A` канал с `channelName = B`; - у `B` зеркальный канал с `channelName = A`. -3. Сообщения канала — `TEXT_POST` в линии `line_code = rootBlockNumber` канала. +3. Сообщения канала — `TEXT_POST` и `TEXT_REPOST` в линии `line_code = rootBlockNumber` канала. 4. Запись блока возможна только при валидной подписи blockchain-ключом владельца цепочки. ## 2. Что должен уметь MCP-инструмент diff --git a/Dev_Docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md b/Dev_Docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md index 8c1fd2f..43d5b23 100644 --- a/Dev_Docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md +++ b/Dev_Docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md @@ -15,7 +15,7 @@ ## Быстрая карта типов - `type=0` — TECH: HEADER, CREATE_CHANNEL. -- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY. +- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/REPOST. - `type=2` — REACTION: LIKE/UNLIKE. - `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции. - `type=4` — USER_PARAM: key/value-параметры пользователя. diff --git a/Dev_Docs/Blockchain/11_TEXT_Blocks.md b/Dev_Docs/Blockchain/11_TEXT_Blocks.md index 2afad12..c9e5bf0 100644 --- a/Dev_Docs/Blockchain/11_TEXT_Blocks.md +++ b/Dev_Docs/Blockchain/11_TEXT_Blocks.md @@ -21,6 +21,11 @@ TEXT-тип хранит сообщения и редактирования. - target на исходный REPLY + новый текст. - допускается пустой `text` для логического удаления сообщения (без физического удаления блока). +5. `subType=30` — `TEXT_REPOST` + - репост сообщения в линию канала; + - содержит line-поля + target на оригинальное сообщение + текст комментария; + - на текущем этапе продуктовой логики репост не редактируется (версии не накапливаются). + ## Правило для edit `EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit. diff --git a/Dev_Docs/Blockchain/CHANGELOG.md b/Dev_Docs/Blockchain/CHANGELOG.md index ce58f2e..7b8e486 100644 --- a/Dev_Docs/Blockchain/CHANGELOG.md +++ b/Dev_Docs/Blockchain/CHANGELOG.md @@ -1,5 +1,13 @@ # История изменений документации блокчейна +## 2026-05-21 19:05:00 +0300 +- Базовый коммит-ориентир: `5344c42`. +- Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`: + - обновлён перечень типов в `11_TEXT_Blocks.md`; + - обновлена быстрая карта типов в `00_Blockchain_Formats_and_Block_Types.md`. +- Уточнено API-описание поддержанных подтипов в `Dev_Docs/API/04_Add_Block_to_Blockchain_API.md`. +- В документе `Dev_Docs/API/08_MCP_Чтение_и_дозапись_персонального_публичного_чата.md` зафиксировано, что чтение канала учитывает `TEXT_POST` и `TEXT_REPOST`. + ## 2026-05-20 11:34:17 +0300 - Базовый коммит-ориентир: `a53444b`. - В `13_CONNECTION_Blocks.md` добавлены новые CONNECTION подтипы: diff --git a/Dev_Docs/Pending_Features/2026-05-21_1908_репосты_в_каналах_и_тредах.md b/Dev_Docs/Pending_Features/2026-05-21_1908_репосты_в_каналах_и_тредах.md new file mode 100644 index 0000000..a4ad03d --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-21_1908_репосты_в_каналах_и_тредах.md @@ -0,0 +1,21 @@ +# Репосты в каналах и тредах + +- Краткое описание: + Добавлен подтип `TEXT_REPOST (30)` и UI-режим репоста с комментарием. Репост можно делать как из сообщения канала, так и из сообщения в треде. Для репоста выбирается один из своих каналов. + +- Что проверять: + 1. В канале открыть любое сообщение и нажать `Репост`. + 2. Выбрать свой канал, ввести комментарий, отправить. + 3. Убедиться, что в целевом канале появился новый пост-репост. + 4. Нажать `Оригинал` у репоста и подтвердить переход. + 5. Проверить, что переход открывает исходное сообщение. + 6. Повторить сценарий из треда (для сообщения-ответа). + +- Ожидаемый результат: + - Репост успешно записывается в блокчейн как `TEXT_REPOST`. + - В выдаче `GetChannelMessages`/`GetMessageThread` возвращаются поля target (`targetBlockchainName`, `targetBlockNumber`, `targetBlockHash`) для репоста. + - Кнопка `Оригинал` открывает нужное исходное сообщение. + - Для репоста не отображается история редактирования (одна версия). + +- Статус: + `pending` diff --git a/VERSION.properties b/VERSION.properties index 3453e07..419db72 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.81 -server.version=1.2.75 +client.version=1.2.82 +server.version=1.2.76 diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index 17001de..78287e8 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -283,6 +283,14 @@ function buildTargetFromNode(node) { return { blockchainName, blockNumber, blockHash }; } +function buildRepostTargetFromNode(node) { + const blockchainName = String(node?.targetBlockchainName || '').trim(); + const blockNumber = Number(node?.targetBlockNumber); + const blockHash = normalizeMessageHash(node?.targetBlockHash); + if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null; + return { blockchainName, blockNumber, blockHash }; +} + function firstNonEmptyText(...candidates) { for (const candidate of candidates) { if (typeof candidate !== 'string') continue; @@ -379,6 +387,95 @@ function openReplyModal({ onSubmit, navigate }) { if (textEl) textEl.focus(); } +function openRepostModal({ navigate, channels = [], onSubmit }) { + const root = document.getElementById('modal-root'); + const options = (Array.isArray(channels) ? channels : []) + .filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber))) + .map((item, index) => { + const owner = String(item?.ownerLogin || '').trim(); + const name = String(item?.channelName || '').trim(); + const label = `${owner || 'my'} / ${name || 'stories'}`; + return ``; + }) + .join(''); + + root.innerHTML = ` + + `; + + const selectEl = root.querySelector('#thread-repost-channel-select'); + const textEl = root.querySelector('#thread-repost-comment'); + const errorEl = root.querySelector('#thread-repost-error'); + const submitEl = root.querySelector('#thread-repost-submit'); + let inFlight = false; + + const setBusy = (busy) => { + inFlight = !!busy; + if (selectEl) selectEl.disabled = inFlight; + if (textEl) textEl.disabled = inFlight; + if (submitEl) { + submitEl.disabled = inFlight; + submitEl.textContent = inFlight ? 'Публикуем...' : 'Опубликовать репост'; + } + }; + + const close = () => { + root.innerHTML = ''; + }; + + root.querySelector('#thread-repost-cancel')?.addEventListener('click', close); + root.querySelector('#thread-repost-voice')?.addEventListener('click', async () => { + await openSpeechInputModal({ + navigate, + onTextReady: (text) => { + const prev = String(textEl?.value || '').trim(); + if (textEl) textEl.value = prev ? `${prev} ${text}` : text; + }, + }); + }); + + submitEl?.addEventListener('click', async () => { + if (inFlight) return; + const idx = Number(selectEl?.value ?? -1); + if (!Number.isFinite(idx) || idx < 0 || idx >= channels.length) { + errorEl.textContent = 'Выберите канал для репоста.'; + return; + } + const text = String(textEl?.value || '').trim(); + if (!text) { + errorEl.textContent = 'Введите комментарий к репосту.'; + return; + } + setBusy(true); + errorEl.textContent = ''; + try { + await onSubmit({ channel: channels[idx].selector, text }); + close(); + } catch (error) { + setBusy(false); + errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.'); + } + }); + + if (textEl) textEl.focus(); +} + function openMessageHistoryModal({ versions = [], title = 'История изменений' }) { const root = document.getElementById('modal-root'); const rows = Array.isArray(versions) ? versions : []; @@ -477,6 +574,8 @@ function renderNodeCard(node, heading, handlers, localNumber) { const replies = Number(node?.repliesCount || 0); const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(); const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0; + const msgSubType = Number(node?.msgSubType || 0); + const repostTarget = msgSubType === 30 ? buildRepostTargetFromNode(node) : null; const headingText = String(heading || '').trim(); if (headingText) { @@ -610,7 +709,46 @@ function renderNodeCard(node, heading, handlers, localNumber) { await handlers.onShare(target); }); - actions.append(likeButton, replyButton, shareButton); + const repostButton = document.createElement('button'); + repostButton.type = 'button'; + repostButton.className = 'channel-action-item thread-reply-btn'; + repostButton.innerHTML = ` + + Репост + `; + repostButton.addEventListener('click', async (event) => { + event.stopPropagation(); + animatePress(event.currentTarget); + try { + await handlers.onRepost(target); + } catch (error) { + handlers?.onActionError?.(error, 'repost'); + } + }); + + actions.append(likeButton, replyButton, repostButton, shareButton); + if (repostTarget) { + const originalButton = document.createElement('button'); + originalButton.type = 'button'; + originalButton.className = 'channel-action-item'; + originalButton.innerHTML = ` + + Оригинал + `; + originalButton.addEventListener('click', (event) => { + event.stopPropagation(); + const ok = window.confirm('Перейти к оригинальному сообщению?'); + if (!ok) return; + const ownerLogin = extractLoginFromBlockchainName(repostTarget.blockchainName); + if (!ownerLogin) return; + handlers.navigate(makeShineMessageRoute({ + ownerLogin, + messageBlockchainName: repostTarget.blockchainName, + messageBlockNumber: repostTarget.blockNumber, + })); + }); + actions.append(originalButton); + } if (isOwnMessage) { const editButton = document.createElement('button'); editButton.type = 'button'; @@ -797,6 +935,45 @@ export function render({ navigate, route }) { showStatus(''); rerender(); }, + onRepost: async (target) => { + const { login, storagePwd } = requireSigningSession(); + const feed = await authService.listSubscriptionsFeed(login, 1000); + const channels = (Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []) + .map((row) => { + const selectorRow = { + ownerBlockchainName: String(row?.channel?.ownerBlockchainName || '').trim(), + channelRootBlockNumber: Number(row?.channel?.channelRoot?.blockNumber), + channelRootBlockHash: normalizeRouteHash(row?.channel?.channelRoot?.blockHash), + }; + if (!selectorRow.ownerBlockchainName || !Number.isFinite(selectorRow.channelRootBlockNumber) || selectorRow.channelRootBlockNumber < 0) { + return null; + } + return { + ownerLogin: String(row?.channel?.ownerLogin || '').trim(), + channelName: String(row?.channel?.channelName || '').trim(), + selector: selectorRow, + }; + }) + .filter(Boolean); + if (!channels.length) throw new Error('У вас пока нет каналов для репоста.'); + + openRepostModal({ + navigate, + channels, + onSubmit: async ({ channel, text }) => { + await authService.addBlockRepost({ + login, + storagePwd, + channel, + message: target, + text, + }); + softHaptic(12); + showToast('Репост опубликован'); + showStatus(''); + }, + }); + }, onShare: async (target) => { try { const routePath = buildThreadRouteFromTarget(target, selector); @@ -824,7 +1001,9 @@ export function render({ navigate, route }) { onActionError: (error, action) => { const fallback = action === 'unlike' ? 'Не удалось убрать лайк.' - : 'Не удалось поставить лайк.'; + : action === 'repost' + ? 'Не удалось сделать репост.' + : 'Не удалось поставить лайк.'; showStatus(toUserMessage(error, fallback)); }, onEdit: async (target, textValue, meta = {}) => { diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 32a3d09..90775d7 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -359,6 +359,90 @@ function openReplyModal({ onSubmit, navigate }) { if (textEl) textEl.focus(); } +function openRepostModal({ navigate, channels = [], onSubmit }) { + const root = document.getElementById('modal-root'); + const options = (Array.isArray(channels) ? channels : []) + .filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber))) + .map((item, index) => { + const owner = String(item?.ownerLogin || '').trim(); + const name = String(item?.channelName || '').trim(); + const label = `${owner || 'my'} / ${name || 'stories'}`; + return ``; + }) + .join(''); + + root.innerHTML = ` + + `; + + const selectEl = root.querySelector('#repost-channel-select'); + const textEl = root.querySelector('#repost-comment'); + const errorEl = root.querySelector('#repost-error'); + const submitEl = root.querySelector('#repost-submit'); + let inFlight = false; + + const setBusy = (busy) => { + inFlight = !!busy; + if (selectEl) selectEl.disabled = inFlight; + if (textEl) textEl.disabled = inFlight; + if (submitEl) { + submitEl.disabled = inFlight; + submitEl.textContent = inFlight ? 'Публикуем...' : 'Опубликовать репост'; + } + }; + + const close = () => { root.innerHTML = ''; }; + root.querySelector('#repost-cancel')?.addEventListener('click', close); + root.querySelector('#repost-voice')?.addEventListener('click', async () => { + await openSpeechInputModal({ + navigate, + onTextReady: (text) => { + const prev = String(textEl?.value || '').trim(); + if (textEl) textEl.value = prev ? `${prev} ${text}` : text; + }, + }); + }); + submitEl?.addEventListener('click', async () => { + if (inFlight) return; + const idx = Number(selectEl?.value ?? -1); + if (!Number.isFinite(idx) || idx < 0 || idx >= channels.length) { + errorEl.textContent = 'Выберите канал для репоста.'; + return; + } + const text = String(textEl?.value || '').trim(); + if (!text) { + errorEl.textContent = 'Введите комментарий к репосту.'; + return; + } + setBusy(true); + errorEl.textContent = ''; + try { + await onSubmit({ channel: channels[idx].selector, text }); + close(); + } catch (error) { + setBusy(false); + errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.'); + } + }); + if (textEl) textEl.focus(); +} + function openAddMessageModal({ channelName, onSubmit, navigate }) { const root = document.getElementById('modal-root'); root.innerHTML = ` @@ -544,6 +628,14 @@ function mapApiMessageToPost(message, selector, localNumber) { repliesCount: Number(message?.repliesCount || 0), timestampMs: resolveMessageTimestampMs(message), messageRef, + msgSubType: Number(message?.msgSubType || 0), + targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber)) + ? { + blockchainName: String(message.targetBlockchainName).trim(), + blockNumber: Number(message.targetBlockNumber), + blockHash: normalizeMessageHash(message?.targetBlockHash), + } + : null, reactionState: messageRef ? getMessageReactionState(messageRef) : '', isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(), }; @@ -791,6 +883,7 @@ function renderPostCard(post, { selector, onToggleLike, onReply, + onRepost, onShare, onEdit, }) { @@ -911,7 +1004,19 @@ function renderPostCard(post, { onSubmit: async (text) => onReply(post.messageRef, text), }); }); - actions.append(likeButton, replyButton); + const repostButton = document.createElement('button'); + repostButton.type = 'button'; + repostButton.className = 'channel-action-item channel-action-reply'; + repostButton.innerHTML = ` + + Репост + `; + repostButton.addEventListener('click', (event) => { + event.stopPropagation(); + animatePress(event.currentTarget); + onRepost(post.messageRef); + }); + actions.append(likeButton, replyButton, repostButton); const shareButton = document.createElement('button'); shareButton.type = 'button'; @@ -928,6 +1033,28 @@ function renderPostCard(post, { }); actions.append(shareButton); + if (post.msgSubType === 30 && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) { + const originalBtn = document.createElement('button'); + originalBtn.type = 'button'; + originalBtn.className = 'channel-action-item'; + originalBtn.innerHTML = ` + + Оригинал + `; + originalBtn.addEventListener('click', (event) => { + event.stopPropagation(); + const ownerLogin = extractLoginFromBlockchainName(post.targetRef.blockchainName); + if (!ownerLogin) return; + const ok = window.confirm('Перейти к оригинальному сообщению?'); + if (!ok) return; + navigate(makeShineMessageRoute({ + ownerLogin, + messageBlockchainName: post.targetRef.blockchainName, + messageBlockNumber: post.targetRef.blockNumber, + })); + }); + actions.append(originalBtn); + } if (post.isOwnMessage) { const editButton = document.createElement('button'); editButton.type = 'button'; @@ -979,6 +1106,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { selector: channelData.selector, onToggleLike: handlers.onToggleLike, onReply: handlers.onReply, + onRepost: handlers.onRepost, onShare: handlers.onShare, onEdit: handlers.onEdit, }); @@ -1123,6 +1251,59 @@ export function render({ navigate, route }) { rerender(); }; + const loadOwnedChannelsForRepost = async (login) => { + const feed = await authService.listSubscriptionsFeed(login, 1000); + const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []; + return rows + .map((row) => { + const selector = { + ownerBlockchainName: String(row?.channel?.ownerBlockchainName || '').trim(), + channelRootBlockNumber: Number(row?.channel?.channelRoot?.blockNumber), + channelRootBlockHash: normalizeRouteHash(row?.channel?.channelRoot?.blockHash), + }; + if (!selector.ownerBlockchainName || !Number.isFinite(selector.channelRootBlockNumber) || selector.channelRootBlockNumber < 0) { + return null; + } + return { + ownerLogin: String(row?.channel?.ownerLogin || '').trim(), + channelName: String(row?.channel?.channelName || '').trim(), + selector, + }; + }) + .filter(Boolean); + }; + + const isSameChannelSelector = (a, b) => ( + String(a?.ownerBlockchainName || '').trim() === String(b?.ownerBlockchainName || '').trim() + && Number(a?.channelRootBlockNumber) === Number(b?.channelRootBlockNumber) + && normalizeRouteHash(a?.channelRootBlockHash) === normalizeRouteHash(b?.channelRootBlockHash) + ); + + const onRepost = async (messageRef) => { + const { login, storagePwd } = requireSigningSession(); + const channels = await loadOwnedChannelsForRepost(login); + if (!channels.length) throw new Error('У вас пока нет каналов для репоста.'); + openRepostModal({ + navigate, + channels, + onSubmit: async ({ channel, text }) => { + await authService.addBlockRepost({ + login, + storagePwd, + channel, + message: messageRef, + text, + }); + if (isSameChannelSelector(channel, activeSelector)) { + pendingScrollByRoute.set(routeKey, '__LAST__'); + rerender(); + } + softHaptic(12); + showToast('Репост опубликован'); + }, + }); + }; + const onShare = async (routePath) => { try { const routeToShare = String(routePath || '').trim(); @@ -1241,6 +1422,14 @@ export function render({ navigate, route }) { throw new Error(toUserMessage(error, 'Не удалось отправить ответ.')); } }, + onRepost: async (messageRef) => { + try { + await onRepost(messageRef); + showStatus(''); + } catch (error) { + showStatus(toUserMessage(error, 'Не удалось сделать репост.')); + } + }, onAddPost: async (bodyText) => { try { await onAddPost(bodyText); diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index e5544cc..4c9ea18 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -40,6 +40,7 @@ const MSG_SUBTYPE_TEXT_POST = 10; const MSG_SUBTYPE_TEXT_EDIT_POST = 11; const MSG_SUBTYPE_TEXT_REPLY = 20; const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21; +const MSG_SUBTYPE_TEXT_REPOST = 30; const MSG_SUBTYPE_REACTION_LIKE = 1; const MSG_SUBTYPE_REACTION_UNLIKE = 2; const MSG_SUBTYPE_CONNECTION_FOLLOW = 30; @@ -371,6 +372,50 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe ); } +function makeTextRepostBodyBytes({ + lineCode, + prevLineNumber, + prevLineHashHex, + thisLineNumber, + toBlockchainName, + toBlockNumber, + toBlockHashHex, + text, +}) { + const message = String(text || '').trim(); + if (!message) throw new Error('Комментарий к репосту обязателен'); + + const bch = String(toBlockchainName || '').trim(); + if (!bch) throw new Error('toBlockchainName is required for repost'); + const bchBytes = utf8Bytes(bch); + if (bchBytes.length < 1 || bchBytes.length > 255) { + throw new Error('toBlockchainName must be 1..255 bytes'); + } + + const blockNumber = Number(toBlockNumber); + if (!Number.isFinite(blockNumber) || blockNumber < 0) { + throw new Error('Invalid toBlockNumber for repost'); + } + + const textBytes = utf8Bytes(message); + if (textBytes.length < 1 || textBytes.length > 65535) { + throw new Error('Repost comment must be 1..65535 UTF-8 bytes'); + } + + return concatBytes( + int32Bytes(lineCode), + int32Bytes(prevLineNumber), + hexToBytes(normalizeHex32(prevLineHashHex)), + int32Bytes(thisLineNumber), + int8Byte(bchBytes.length), + bchBytes, + int32Bytes(blockNumber), + hexToBytes(normalizeHex32(toBlockHashHex)), + int16Bytes(textBytes.length), + textBytes, + ); +} + function makeTextEditPostBodyBytes({ lineCode, prevLineNumber, @@ -1010,6 +1055,77 @@ export class AuthService { }); } + async addBlockRepost({ login, channel, message, text, storagePwd }) { + const cleanLogin = String(login || '').trim(); + if (!cleanLogin) throw new Error('Missing login'); + const cleanText = String(text || '').trim(); + if (!cleanText) throw new Error('Комментарий к репосту обязателен'); + const target = normalizeMessageRefTarget(message, 'repost'); + const selector = channel || {}; + const owner = String(selector?.ownerBlockchainName || '').trim(); + const root = Number(selector?.channelRootBlockNumber); + const key = `repost:${cleanLogin}:${owner}:${root}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`; + + return this.runWriteLocked(key, async () => { + const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd); + const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); + if (!owner || !Number.isFinite(root) || root < 0) throw new Error('Invalid channel selector'); + if (owner !== blockchainName) throw new Error('Repost is allowed only to your own channels'); + + let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64); + if (rootHashHex === ZERO64) { + const ownChannels = await this.listOwnChannelsForBlockchain(cleanLogin, blockchainName); + const rootChannel = ownChannels.find((item) => item.rootBlockNumber === root); + if (!rootChannel) throw new Error('Channel root not found'); + rootHashHex = normalizeHex32(rootChannel.rootBlockHash, ZERO64); + } + + let prevLineNumber = root; + let prevLineHashHex = rootHashHex; + let thisLineNumber = 0; + try { + const latestPayload = await this.getChannelMessages({ + ownerBlockchainName: owner, + channelRootBlockNumber: root, + channelRootBlockHash: rootHashHex, + }, 1, 'desc', cleanLogin); + const latestMessage = Array.isArray(latestPayload?.messages) ? latestPayload.messages[0] : null; + const latestBlockNumber = Number(latestMessage?.messageRef?.blockNumber); + const latestBlockHash = normalizeHex32(latestMessage?.messageRef?.blockHash, ''); + const latestVersionsTotal = Number(latestMessage?.versionsTotal); + if (Number.isFinite(latestBlockNumber) && latestBlockNumber >= 0 && latestBlockHash) { + prevLineNumber = latestBlockNumber; + prevLineHashHex = latestBlockHash; + thisLineNumber = Number.isFinite(latestVersionsTotal) && latestVersionsTotal > 0 + ? Math.max(0, latestVersionsTotal) + : 1; + } + } catch { + // fallback to root anchor + } + + const bodyBytes = makeTextRepostBodyBytes({ + lineCode: root, + prevLineNumber, + prevLineHashHex, + thisLineNumber, + toBlockchainName: target.blockchainName, + toBlockNumber: target.blockNumber, + toBlockHashHex: target.blockHash, + text: cleanText, + }); + + return this.addBlockSigned({ + login: cleanLogin, + storagePwd, + msgType: MSG_TYPE_TEXT, + msgSubType: MSG_SUBTYPE_TEXT_REPOST, + msgVersion: 1, + bodyBytes, + }); + }); + } + async addBlockEditMessage({ login, message, diff --git a/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java b/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java index c487b21..c01390c 100644 --- a/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java +++ b/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java @@ -35,7 +35,8 @@ public final class BodyRecordParser { BodyRecord r = switch (key) { case TextBody.KEY -> { if (st == (MsgSubType.TEXT_POST & 0xFFFF) - || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { + || st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) + || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { yield new TextLineBody(subType, version, bodyBytes); } diff --git a/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java b/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java index 7d1f309..3464d6f 100644 --- a/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java +++ b/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java @@ -64,6 +64,12 @@ public final class MsgSubType { */ public static final short TEXT_EDIT_REPLY = 21; + /** + * REPOST — репост сообщения в линии канала. + * Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария. + */ + public static final short TEXT_REPOST = 30; + /* ===================== REACTION (msg_type=2) ===================== */ /** Лайк (LIKE). */ diff --git a/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java b/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java index 58f1adc..748a746 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/TextLineBody.java @@ -16,6 +16,7 @@ import java.util.Objects; * subType: * - POST (10) * - EDIT_POST (11) + * - REPOST (30) * * Формат bodyBytes (BigEndian): * @@ -36,6 +37,18 @@ import java.util.Objects; * [32] toBlockHash32 * [2] textLenBytes (uint16) * [N] text UTF-8 + * + * REPOST: + * [4] lineCode + * [4] prevLineNumber + * [32] prevLineHash32 + * [4] thisLineNumber + * [1] toBlockchainNameLen (uint8) + * [N] toBlockchainName UTF-8 + * [4] toBlockGlobalNumber (int32) + * [32] toBlockHash32 + * [2] textLenBytes (uint16) + * [N] text UTF-8 */ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarget { @@ -53,7 +66,8 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge public final byte[] prevLineHash32; // 32 (может быть нули) public final int thisLineNumber; - // target (только для EDIT_POST) + // target (для EDIT_POST / REPOST) + public final String toBlockchainName; // nullable для POST/EDIT_POST public final Integer toBlockGlobalNumber; // nullable для POST public final byte[] toBlockHash32; // nullable для POST @@ -73,8 +87,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge } int st = this.subType & 0xFFFF; - if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST, got subType=" + st); + if (st != (MsgSubType.TEXT_POST & 0xFFFF) + && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF) + && st != (MsgSubType.TEXT_REPOST & 0xFFFF)) { + throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST, got subType=" + st); } ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); @@ -97,10 +113,22 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge byte[] tgtHash = new byte[32]; bb.get(tgtHash); + this.toBlockchainName = null; this.toBlockGlobalNumber = tgtNum; this.toBlockHash32 = tgtHash; - + } else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { + ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPOST missing target"); + int nameLen = Byte.toUnsignedInt(bb.get()); + if (nameLen <= 0) throw new IllegalArgumentException("REPOST toBlockchainNameLen is 0"); + ensureMin(bb, nameLen + 4 + 32 + 2, "REPOST payload too short"); + byte[] nameBytes = new byte[nameLen]; + bb.get(nameBytes); + this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8); + this.toBlockGlobalNumber = bb.getInt(); + this.toBlockHash32 = new byte[32]; + bb.get(this.toBlockHash32); } else { + this.toBlockchainName = null; this.toBlockGlobalNumber = null; this.toBlockHash32 = null; } @@ -119,13 +147,16 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge short subType, Integer toBlockGlobalNumber, byte[] toBlockHash32, + String toBlockchainName, String message) { Objects.requireNonNull(message, "message == null"); int st = subType & 0xFFFF; - if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { - throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST"); + if (st != (MsgSubType.TEXT_POST & 0xFFFF) + && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF) + && st != (MsgSubType.TEXT_REPOST & 0xFFFF)) { + throw new IllegalArgumentException("TextLineBody supports only POST/EDIT_POST/REPOST"); } if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); @@ -147,9 +178,22 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); + this.toBlockchainName = null; + this.toBlockGlobalNumber = toBlockGlobalNumber; + this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); + } else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { + Objects.requireNonNull(toBlockchainName, "toBlockchainName == null"); + if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank"); + Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null"); + Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null"); + if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0"); + if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32"); + + this.toBlockchainName = toBlockchainName; this.toBlockGlobalNumber = toBlockGlobalNumber; this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32); } else { + this.toBlockchainName = null; this.toBlockGlobalNumber = null; this.toBlockHash32 = null; } @@ -160,7 +204,9 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge @Override public TextLineBody check() { int st = subType & 0xFFFF; - if (st != (MsgSubType.TEXT_POST & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) + if (st != (MsgSubType.TEXT_POST & 0xFFFF) + && st != (MsgSubType.TEXT_EDIT_POST & 0xFFFF) + && st != (MsgSubType.TEXT_REPOST & 0xFFFF)) throw new IllegalArgumentException("Bad TextLineBody subType: " + st); if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); @@ -173,10 +219,21 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid"); + if (toBlockchainName != null) + throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName"); + } else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { + if (message == null || message.isBlank()) + throw new IllegalArgumentException("REPOST message is blank"); + if (toBlockchainName == null || toBlockchainName.isBlank()) + throw new IllegalArgumentException("REPOST toBlockchainName is blank"); + if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0) + throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid"); + if (toBlockHash32 == null || toBlockHash32.length != 32) + throw new IllegalArgumentException("REPOST toBlockHash32 invalid"); } else { if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank"); - if (toBlockGlobalNumber != null || toBlockHash32 != null) + if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null) throw new IllegalArgumentException("POST must not contain target fields"); } @@ -196,11 +253,20 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge int cap; if (st == (MsgSubType.TEXT_POST & 0xFFFF)) { cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length; - } else { + } else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { // EDIT_POST if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber"); if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32"); cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length; + } else { + if (toBlockchainName == null) throw new IllegalArgumentException("REPOST missing toBlockchainName"); + byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); + if (nameUtf8.length == 0 || nameUtf8.length > 255) { + throw new IllegalArgumentException("REPOST toBlockchainName utf8 len must be 1..255"); + } + if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPOST missing toBlockGlobalNumber"); + if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 != 32"); + cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length; } ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); @@ -213,6 +279,12 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { bb.putInt(toBlockGlobalNumber); bb.put(toBlockHash32); + } else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) { + byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8); + bb.put((byte) nameUtf8.length); + bb.put(nameUtf8); + bb.putInt(toBlockGlobalNumber); + bb.put(toBlockHash32); } bb.putShort((short) msgUtf8.length); @@ -228,7 +300,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge @Override public int lineSeq() { return thisLineNumber; } /* ====================== BodyHasTarget ===================== */ - @Override public String toBchName() { return null; } // по ТЗ: не хранить + @Override public String toBchName() { return toBlockchainName; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; } 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 2afb541..b2b9f0b 100644 --- a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -35,6 +35,7 @@ public final class DatabaseInitializer { public static final short TEXT_EDIT_POST = 11; public static final short TEXT_REPLY = 20; public static final short TEXT_EDIT_REPLY = 21; + public static final short TEXT_REPOST = 30; /* ===================== REACTION (msg_type=2) ===================== */ diff --git a/shine-server-db/src/main/java/shine/db/MsgSubType.java b/shine-server-db/src/main/java/shine/db/MsgSubType.java index 7d3083a..07c727c 100644 --- a/shine-server-db/src/main/java/shine/db/MsgSubType.java +++ b/shine-server-db/src/main/java/shine/db/MsgSubType.java @@ -30,6 +30,9 @@ public final class MsgSubType { /** EDIT_REPLY — редактирование исходного ответа. */ public static final short TEXT_EDIT_REPLY = 21; + /** REPOST — репост сообщения в линии канала (с комментарием и target на оригинал). */ + public static final short TEXT_REPOST = 30; + /* ===================== REACTION (msg_type=2) ===================== */ /** Лайк (LIKE). */ diff --git a/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java b/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java index f85f9dd..3fe7b0c 100644 --- a/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java +++ b/shine-server-db/src/main/java/shine/db/dao/SubscriptionsDAO.java @@ -131,7 +131,7 @@ public final class SubscriptionsDAO { ON s.channel_login = b.login AND s.channel_bch_name = b.bch_name WHERE b.msg_type = ? - AND b.msg_sub_type = ? + AND b.msg_sub_type IN (?, ?) GROUP BY b.login, b.bch_name ), last_pub AS ( @@ -144,7 +144,7 @@ public final class SubscriptionsDAO { ON s.channel_login = b.login AND s.channel_bch_name = b.bch_name WHERE b.msg_type = ? - AND b.msg_sub_type = ? + AND b.msg_sub_type IN (?, ?) GROUP BY b.login, b.bch_name ), last_pub_block AS ( @@ -208,10 +208,12 @@ public final class SubscriptionsDAO { // pub_counts ps.setInt(i++, MSG_TYPE_TEXT); ps.setInt(i++, (int) MsgSubType.TEXT_POST); + ps.setInt(i++, (int) MsgSubType.TEXT_REPOST); // last_pub ps.setInt(i++, MSG_TYPE_TEXT); ps.setInt(i++, (int) MsgSubType.TEXT_POST); + ps.setInt(i++, (int) MsgSubType.TEXT_REPOST); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { 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 26c7dfc..476253b 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 @@ -89,12 +89,13 @@ final class ChannelsReadSupport { } static int countPosts(Connection c, String ownerBch, int lineCode) throws SQLException { - String sql = "SELECT COUNT(*) AS cnt FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?"; + String sql = "SELECT COUNT(*) AS cnt FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=?"; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, ownerBch); ps.setInt(2, MSG_TYPE_TEXT); ps.setInt(3, MsgSubType.TEXT_POST); - ps.setInt(4, lineCode); + ps.setInt(4, MsgSubType.TEXT_REPOST); + ps.setInt(5, lineCode); try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getInt("cnt") : 0; } @@ -105,7 +106,7 @@ final class ChannelsReadSupport { String sql = """ SELECT login,bch_name,block_number,block_hash,block_bytes FROM blocks - WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=? + WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=? ORDER BY block_number DESC LIMIT 1 """; @@ -113,7 +114,8 @@ final class ChannelsReadSupport { ps.setString(1, ownerBch); ps.setInt(2, MSG_TYPE_TEXT); ps.setInt(3, MsgSubType.TEXT_POST); - ps.setInt(4, lineCode); + ps.setInt(4, MsgSubType.TEXT_REPOST); + ps.setInt(5, lineCode); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) return null; PostBlock pb = new PostBlock(); @@ -177,17 +179,18 @@ final class ChannelsReadSupport { static List channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException { String order = asc ? "ASC" : "DESC"; String sql = """ - SELECT login,bch_name,block_number,block_hash,block_bytes + SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type FROM blocks - WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=? + WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=? ORDER BY block_number """ + order + " LIMIT ?"; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, ownerBch); ps.setInt(2, MSG_TYPE_TEXT); ps.setInt(3, MsgSubType.TEXT_POST); - ps.setInt(4, lineCode); - ps.setInt(5, limit); + ps.setInt(4, MsgSubType.TEXT_REPOST); + ps.setInt(5, lineCode); + ps.setInt(6, limit); try (ResultSet rs = ps.executeQuery()) { List out = new ArrayList<>(); while (rs.next()) { @@ -197,6 +200,10 @@ final class ChannelsReadSupport { pb.blockNumber = rs.getInt("block_number"); pb.blockHash = rs.getBytes("block_hash"); pb.blockBytes = rs.getBytes("block_bytes"); + pb.toBchName = rs.getString("to_bch_name"); + pb.toBlockNumber = (Integer) rs.getObject("to_block_number"); + pb.toBlockHash = rs.getBytes("to_block_hash"); + pb.msgSubType = rs.getInt("msg_sub_type"); out.add(pb); } return out; @@ -357,7 +364,7 @@ final class ChannelsReadSupport { String sql = """ SELECT block_bytes FROM blocks - WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=? + WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?) AND line_code=? ORDER BY block_number DESC LIMIT 300 """; @@ -365,7 +372,8 @@ final class ChannelsReadSupport { ps.setString(1, ownerBch); ps.setInt(2, MSG_TYPE_TEXT); ps.setInt(3, MsgSubType.TEXT_POST); - ps.setInt(4, lineCode); + ps.setInt(4, MsgSubType.TEXT_REPOST); + ps.setInt(5, lineCode); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { TextInfo info = parseTextAndTime(rs.getBytes("block_bytes")); @@ -501,6 +509,10 @@ final class ChannelsReadSupport { int blockNumber; byte[] blockHash; byte[] blockBytes; + String toBchName; + Integer toBlockNumber; + byte[] toBlockHash; + int msgSubType; } static final class TextInfo { 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 9c501f9..42018db 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 @@ -11,6 +11,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMe import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.SqliteDbController; +import shine.db.MsgSubType; import utils.blockchain.BlockchainNameUtil; import blockchain.body.CreateChannelBody; @@ -96,8 +97,12 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { msgRef.setBlockNumber(post.blockNumber); msgRef.setBlockHash(ChannelsReadSupport.toHex(post.blockHash)); item.setMessageRef(msgRef); + item.setMsgSubType(post.msgSubType); item.setAuthorLogin(post.login); item.setAuthorBlockchainName(post.bchName); + item.setTargetBlockchainName(post.toBchName); + item.setTargetBlockNumber(post.toBlockNumber); + item.setTargetBlockHash(ChannelsReadSupport.toHex(post.toBlockHash)); List versionsOut = new ArrayList<>(); int index = 1; @@ -111,16 +116,18 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { v1.setCreatedAtMs(postText.createdAtMs); versionsOut.add(v1); - List edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash); - for (ChannelsReadSupport.PostBlock edit : edits) { - ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes); - Net_GetChannelMessages_Response.VersionItem ve = new Net_GetChannelMessages_Response.VersionItem(); - ve.setVersionIndex(index++); - ve.setBlockNumber(edit.blockNumber); - ve.setBlockHash(ChannelsReadSupport.toHex(edit.blockHash)); - ve.setText(editText.text); - ve.setCreatedAtMs(editText.createdAtMs); - versionsOut.add(ve); + if (post.msgSubType == MsgSubType.TEXT_POST) { + List edits = ChannelsReadSupport.versionsForPost(c, post.bchName, post.blockNumber, post.blockHash); + for (ChannelsReadSupport.PostBlock edit : edits) { + ChannelsReadSupport.TextInfo editText = ChannelsReadSupport.parseTextAndTime(edit.blockBytes); + Net_GetChannelMessages_Response.VersionItem ve = new Net_GetChannelMessages_Response.VersionItem(); + ve.setVersionIndex(index++); + ve.setBlockNumber(edit.blockNumber); + ve.setBlockHash(ChannelsReadSupport.toHex(edit.blockHash)); + ve.setText(editText.text); + ve.setCreatedAtMs(editText.createdAtMs); + versionsOut.add(ve); + } } item.setVersions(versionsOut); 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 f58ad34..f17eddd 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 @@ -143,8 +143,12 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler { ref.setBlockNumber(row.blockNumber); ref.setBlockHash(ChannelsReadSupport.toHex(row.blockHash)); node.setMessageRef(ref); + node.setMsgSubType(row.msgSubType); node.setAuthorLogin(row.login); node.setAuthorBlockchainName(row.bchName); + node.setTargetBlockchainName(row.toBchName); + node.setTargetBlockNumber(row.toBlockNumber); + node.setTargetBlockHash(ChannelsReadSupport.toHex(row.toBlockHash)); ChannelsReadSupport.TextInfo base = ChannelsReadSupport.parseTextAndTime(row.blockBytes); node.setCreatedAtMs(base.createdAtMs); @@ -158,16 +162,18 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler { first.setCreatedAtMs(base.createdAtMs); versions.add(first); - short editType = row.msgSubType == MsgSubType.TEXT_REPLY ? MsgSubType.TEXT_EDIT_REPLY : MsgSubType.TEXT_EDIT_POST; - for (PostRow edit : findEdits(c, row.bchName, row.blockNumber, row.blockHash, editType)) { - ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes); - Net_GetChannelMessages_Response.VersionItem v = new Net_GetChannelMessages_Response.VersionItem(); - v.setVersionIndex(versions.size() + 1); - v.setBlockNumber(edit.blockNumber); - v.setBlockHash(ChannelsReadSupport.toHex(edit.blockHash)); - v.setText(et.text); - v.setCreatedAtMs(et.createdAtMs); - versions.add(v); + if (row.msgSubType == MsgSubType.TEXT_REPLY || row.msgSubType == MsgSubType.TEXT_POST) { + short editType = row.msgSubType == MsgSubType.TEXT_REPLY ? MsgSubType.TEXT_EDIT_REPLY : MsgSubType.TEXT_EDIT_POST; + for (PostRow edit : findEdits(c, row.bchName, row.blockNumber, row.blockHash, editType)) { + ChannelsReadSupport.TextInfo et = ChannelsReadSupport.parseTextAndTime(edit.blockBytes); + Net_GetChannelMessages_Response.VersionItem v = new Net_GetChannelMessages_Response.VersionItem(); + v.setVersionIndex(versions.size() + 1); + v.setBlockNumber(edit.blockNumber); + v.setBlockHash(ChannelsReadSupport.toHex(edit.blockHash)); + v.setText(et.text); + v.setCreatedAtMs(et.createdAtMs); + versions.add(v); + } } node.setVersions(versions); 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 926f745..46398f5 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 @@ -49,8 +49,12 @@ public class Net_GetChannelMessages_Response extends Net_Response { public static class MessageItem { private BlockRef messageRef; + private Integer msgSubType; private String authorLogin; private String authorBlockchainName; + private String targetBlockchainName; + private Integer targetBlockNumber; + private String targetBlockHash; private long createdAtMs; private String text; private int likesCount; @@ -61,6 +65,8 @@ public class Net_GetChannelMessages_Response extends Net_Response { public BlockRef getMessageRef() { return messageRef; } public void setMessageRef(BlockRef messageRef) { this.messageRef = messageRef; } + public Integer getMsgSubType() { return msgSubType; } + public void setMsgSubType(Integer msgSubType) { this.msgSubType = msgSubType; } public String getAuthorLogin() { return authorLogin; } public void setAuthorLogin(String authorLogin) { this.authorLogin = authorLogin; } @@ -68,6 +74,15 @@ public class Net_GetChannelMessages_Response extends Net_Response { public String getAuthorBlockchainName() { return authorBlockchainName; } public void setAuthorBlockchainName(String authorBlockchainName) { this.authorBlockchainName = authorBlockchainName; } + public String getTargetBlockchainName() { return targetBlockchainName; } + public void setTargetBlockchainName(String targetBlockchainName) { this.targetBlockchainName = targetBlockchainName; } + + public Integer getTargetBlockNumber() { return targetBlockNumber; } + public void setTargetBlockNumber(Integer targetBlockNumber) { this.targetBlockNumber = targetBlockNumber; } + + public String getTargetBlockHash() { return targetBlockHash; } + public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; } + public long getCreatedAtMs() { return createdAtMs; } public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }