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; }