diff --git a/VERSION.properties b/VERSION.properties
index f038b391..7b5c697d 100644
--- a/VERSION.properties
+++ b/VERSION.properties
@@ -1,2 +1,2 @@
-client.version=1.4.8
+client.version=1.4.9
server.version=1.4.0
diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md
index fcfc7309..ff8fdd64 100644
--- a/docs/Blockchain/CHANGELOG.md
+++ b/docs/Blockchain/CHANGELOG.md
@@ -113,6 +113,12 @@
- В `11_TEXT_Blocks.md` зафиксировано, что запись `TEXT_REPOST` временно не используется до будущей реализации.
- В `docs/API/04_Add_Block_to_Blockchain_API.md` добавлен код отказа `repost_disabled`.
+## 2026-07-31 17:40:31 +0400
+- Базовый коммит-ориентир: `d640dd6`.
+- Формат блокчейна не менялся.
+- В UI каналов добавлен технический просмотр данных сообщения из уже полученного ответа сервера: цепочка автора, номер записи, хэш, тип, время, текст, public key/подпись при наличии и сырой JSON-блок.
+- Удалённые сообщения в UI теперь отображаются компактной строкой с переходом в историю версий.
+
## 2026-05-21 19:05:00 +0300
- Базовый коммит-ориентир: `5344c42`.
- Добавлен новый TEXT-подтип `TEXT_REPOST (subType=30)`:
diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js
index 61bd58f0..4b1079a6 100644
--- a/shine-UI/js/pages/channel-thread-view.js
+++ b/shine-UI/js/pages/channel-thread-view.js
@@ -16,6 +16,7 @@ import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../co
import {
composeMessageWithAttachments,
createAttachmentCarouselElement,
+ escapeHtml,
MAX_MESSAGE_ATTACHMENTS,
parseMessageAttachments,
} from '../services/attachment-format.js';
@@ -329,6 +330,91 @@ function bindSubmitOnPlainEnter(textarea, submit) {
});
}
+function setActionTitle(button, label) {
+ if (!button) return;
+ button.title = label;
+ button.setAttribute('aria-label', label);
+ const labelEl = button.querySelector('.channel-action-label');
+ if (labelEl) labelEl.textContent = label;
+}
+
+async function copyTextToClipboard(text) {
+ if (navigator?.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.setAttribute('readonly', '');
+ ta.style.position = 'fixed';
+ ta.style.opacity = '0';
+ document.body.append(ta);
+ ta.select();
+ const ok = document.execCommand('copy');
+ ta.remove();
+ return !!ok;
+}
+
+function buildBlockchainDetails({ target, authorLogin, timestampMs, text, raw, localNumber, msgSubType }) {
+ const source = raw && typeof raw === 'object' ? raw : {};
+ return {
+ authorLogin,
+ authorBlockchainName: target?.blockchainName || source.authorBlockchainName || '',
+ blockNumber: target?.blockNumber ?? source?.messageRef?.blockNumber ?? '',
+ blockHash: target?.blockHash || source?.messageRef?.blockHash || '',
+ localNumber,
+ msgSubType: msgSubType ?? source.msgSubType ?? '',
+ createdAtMs: timestampMs || source.createdAtMs || '',
+ text: String(text || ''),
+ signature: source.signature || source.blockSignature || source.authorSignature || 'нет в ответе сервера',
+ publicKey: source.publicKey || source.authorPublicKey || 'нет в ответе сервера',
+ raw,
+ };
+}
+
+function openBlockchainDetailsModal(details) {
+ const root = document.getElementById('modal-root');
+ const rawText = JSON.stringify(details.raw || details, null, 2);
+ root.innerHTML = `
+
+
+
Данные блокчейна сообщения
+
Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.
+
+ Автор${escapeHtml(details.authorLogin)}
+ Блокчейн${escapeHtml(details.authorBlockchainName)}
+ Номер записи${escapeHtml(details.blockNumber)}
+ Хэш${escapeHtml(details.blockHash)}
+ Тип${escapeHtml(details.msgSubType)}
+ Время${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '—')}
+ Public key${escapeHtml(details.publicKey)}
+ Подпись${escapeHtml(details.signature)}
+
+
+
+
${escapeHtml(rawText)}
+
+
+
+
+
+
+
+ `;
+ root.querySelector('#thread-blockchain-details-close')?.addEventListener('click', () => {
+ root.innerHTML = '';
+ });
+ root.querySelector('#thread-blockchain-details-copy')?.addEventListener('click', async () => {
+ await copyTextToClipboard(rawText);
+ showToast('Данные блокчейна скопированы');
+ });
+ root.querySelector('#thread-blockchain-details-raw')?.addEventListener('click', () => {
+ const rawEl = root.querySelector('#thread-blockchain-raw-block');
+ if (!rawEl) return;
+ rawEl.hidden = !rawEl.hidden;
+ });
+}
+
function resolveNodeText(node) {
return firstNonEmptyText(
node?.text,
@@ -671,18 +757,35 @@ function renderNodeCard(node, heading, handlers, localNumber) {
authorTile.append(avatar, authorBlock);
const isDeletedMessage = String(text || '').trim().toLowerCase() === 'удалено';
- const body = document.createElement('p');
- body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
- body.textContent = isDeletedMessage ? 'Сообщение удалено' : parsedText.text;
- card.append(authorTile);
- if (!isDeletedMessage && parsedText.attachments.length > 0) {
- card.append(createAttachmentCarouselElement(parsedText.attachments, {
- gateway: state.entrySettings.arweaveServer,
- messageTimestampMs: node?.createdAtMs,
- }));
+ if (isDeletedMessage) {
+ card.classList.add('channel-message-card--deleted-compact');
+ const deleted = document.createElement('button');
+ deleted.type = 'button';
+ deleted.className = 'deleted-message-pill';
+ deleted.textContent = `Удалённое сообщение от ${author}`;
+ deleted.title = 'Открыть историю изменений';
+ deleted.addEventListener('click', (event) => {
+ event.stopPropagation();
+ openMessageHistoryModal({
+ title: `История #${localNumber}`,
+ versions,
+ });
+ });
+ card.append(deleted);
+ } else {
+ card.append(authorTile);
+ if (parsedText.attachments.length > 0) {
+ card.append(createAttachmentCarouselElement(parsedText.attachments, {
+ gateway: state.entrySettings.arweaveServer,
+ messageTimestampMs: node?.createdAtMs,
+ }));
+ }
+ const body = document.createElement('p');
+ body.className = 'channel-message-body';
+ body.textContent = parsedText.text;
+ card.append(body);
}
- card.append(body);
const target = buildTargetFromNode(node);
const refKey = messageRefKey(target);
@@ -709,6 +812,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
${isPending ? 'Лайк...' : 'Лайк'}
${likes}
`;
+ setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => {
event.stopPropagation();
@@ -720,6 +824,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
}
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true;
+ setActionTitle(likeButton, 'Лайк...');
try {
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
} catch (error) {
@@ -740,6 +845,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
Ответить
${replies}
`;
+ setActionTitle(replyButton, 'Ответить');
replyButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
@@ -756,6 +862,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
↗
Отправить
`;
+ setActionTitle(shareButton, 'Отправить');
shareButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
@@ -773,6 +880,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
↪
Оригинал
`;
+ setActionTitle(originalButton, 'Оригинал');
originalButton.addEventListener('click', (event) => {
event.stopPropagation();
const ok = window.confirm('Перейти к оригинальному сообщению?');
@@ -787,6 +895,27 @@ function renderNodeCard(node, heading, handlers, localNumber) {
});
actions.append(originalButton);
}
+ const detailsButton = document.createElement('button');
+ detailsButton.type = 'button';
+ detailsButton.className = 'channel-action-item';
+ detailsButton.innerHTML = `
+ ⛓
+ Данные блокчейна
+ `;
+ setActionTitle(detailsButton, 'Данные блокчейна');
+ detailsButton.addEventListener('click', (event) => {
+ event.stopPropagation();
+ openBlockchainDetailsModal(buildBlockchainDetails({
+ target,
+ authorLogin: author,
+ timestampMs: node?.createdAtMs,
+ text,
+ raw: node,
+ localNumber,
+ msgSubType,
+ }));
+ });
+ actions.append(detailsButton);
if (isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js
index 1829ca30..c6c2a82d 100644
--- a/shine-UI/js/pages/channel-view.js
+++ b/shine-UI/js/pages/channel-view.js
@@ -22,6 +22,7 @@ import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from '../co
import {
composeMessageWithAttachments,
createAttachmentCarouselElement,
+ escapeHtml,
MAX_MESSAGE_ATTACHMENTS,
parseMessageAttachments,
} from '../services/attachment-format.js';
@@ -313,6 +314,91 @@ function bindSubmitOnPlainEnter(textarea, submit) {
});
}
+function setActionTitle(button, label) {
+ if (!button) return;
+ button.title = label;
+ button.setAttribute('aria-label', label);
+ const labelEl = button.querySelector('.channel-action-label');
+ if (labelEl) labelEl.textContent = label;
+}
+
+async function copyTextToClipboard(text) {
+ if (navigator?.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.setAttribute('readonly', '');
+ ta.style.position = 'fixed';
+ ta.style.opacity = '0';
+ document.body.append(ta);
+ ta.select();
+ const ok = document.execCommand('copy');
+ ta.remove();
+ return !!ok;
+}
+
+function buildBlockchainDetails({ messageRef, authorLogin, timestampMs, text, raw, localNumber, msgSubType }) {
+ const source = raw && typeof raw === 'object' ? raw : {};
+ return {
+ authorLogin,
+ authorBlockchainName: messageRef?.blockchainName || source.authorBlockchainName || '',
+ blockNumber: messageRef?.blockNumber ?? source?.messageRef?.blockNumber ?? '',
+ blockHash: messageRef?.blockHash || source?.messageRef?.blockHash || '',
+ localNumber,
+ msgSubType: msgSubType ?? source.msgSubType ?? '',
+ createdAtMs: timestampMs || source.createdAtMs || '',
+ text: String(text || ''),
+ signature: source.signature || source.blockSignature || source.authorSignature || 'нет в ответе сервера',
+ publicKey: source.publicKey || source.authorPublicKey || 'нет в ответе сервера',
+ raw,
+ };
+}
+
+function openBlockchainDetailsModal(details) {
+ const root = document.getElementById('modal-root');
+ const rawText = JSON.stringify(details.raw || details, null, 2);
+ root.innerHTML = `
+
+
+
Данные блокчейна сообщения
+
Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.
+
+ Автор${escapeHtml(details.authorLogin)}
+ Блокчейн${escapeHtml(details.authorBlockchainName)}
+ Номер записи${escapeHtml(details.blockNumber)}
+ Хэш${escapeHtml(details.blockHash)}
+ Тип${escapeHtml(details.msgSubType)}
+ Время${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '—')}
+ Public key${escapeHtml(details.publicKey)}
+ Подпись${escapeHtml(details.signature)}
+
+
+
+
${escapeHtml(rawText)}
+
+
+
+
+
+
+
+ `;
+ root.querySelector('#blockchain-details-close')?.addEventListener('click', () => {
+ root.innerHTML = '';
+ });
+ root.querySelector('#blockchain-details-copy')?.addEventListener('click', async () => {
+ await copyTextToClipboard(rawText);
+ showToast('Данные блокчейна скопированы');
+ });
+ root.querySelector('#blockchain-details-raw')?.addEventListener('click', () => {
+ const rawEl = root.querySelector('#blockchain-raw-block');
+ if (!rawEl) return;
+ rawEl.hidden = !rawEl.hidden;
+ });
+}
+
function renderDraftAttachments(container, attachments) {
if (!container) return;
container.innerHTML = '';
@@ -693,6 +779,7 @@ function mapApiMessageToPost(message, selector, localNumber) {
repliesCount: Number(message?.repliesCount || 0),
timestampMs: resolveMessageTimestampMs(message),
messageRef,
+ rawMessage: message,
msgSubType: Number(message?.msgSubType || 0),
targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber))
? {
@@ -1011,18 +1098,35 @@ function renderPostCard(post, {
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
const parsedBody = parseMessageAttachments(post.body);
- const body = document.createElement('p');
- body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
- body.textContent = isDeletedMessage ? 'Сообщение удалено' : parsedBody.text;
- card.append(authorTile);
- if (!isDeletedMessage && parsedBody.attachments.length > 0) {
- card.append(createAttachmentCarouselElement(parsedBody.attachments, {
- gateway: state.entrySettings.arweaveServer,
- messageTimestampMs: post.timestampMs,
- }));
+ if (isDeletedMessage) {
+ card.classList.add('channel-message-card--deleted-compact');
+ const deleted = document.createElement('button');
+ deleted.type = 'button';
+ deleted.className = 'deleted-message-pill';
+ deleted.textContent = `Удалённое сообщение от ${post.authorLogin}`;
+ deleted.title = 'Открыть историю изменений';
+ deleted.addEventListener('click', (event) => {
+ event.stopPropagation();
+ openMessageHistoryModal({
+ title: `История #${post.localNumber}`,
+ versions: post.versions,
+ });
+ });
+ card.append(deleted);
+ } else {
+ card.append(authorTile);
+ if (parsedBody.attachments.length > 0) {
+ card.append(createAttachmentCarouselElement(parsedBody.attachments, {
+ gateway: state.entrySettings.arweaveServer,
+ messageTimestampMs: post.timestampMs,
+ }));
+ }
+ const body = document.createElement('p');
+ body.className = 'channel-message-body';
+ body.textContent = parsedBody.text;
+ card.append(body);
}
- card.append(body);
const refKey = messageRefKey(post.messageRef);
if (refKey) {
@@ -1048,6 +1152,7 @@ function renderPostCard(post, {
${isPending ? 'Лайк...' : 'Лайк'}
${post.likesCount || 0}
`;
+ setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => {
event.stopPropagation();
@@ -1059,8 +1164,7 @@ function renderPostCard(post, {
}
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true;
- const labelEl = likeButton.querySelector('.channel-action-label');
- if (labelEl) labelEl.textContent = 'Лайк...';
+ setActionTitle(likeButton, 'Лайк...');
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
});
@@ -1072,6 +1176,7 @@ function renderPostCard(post, {
Ответить
${post.repliesCount || 0}
`;
+ setActionTitle(replyButton, 'Ответить');
replyButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
@@ -1091,6 +1196,7 @@ function renderPostCard(post, {
↗
Отправить
`;
+ setActionTitle(shareButton, 'Отправить');
shareButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
@@ -1107,6 +1213,7 @@ function renderPostCard(post, {
↪
Оригинал
`;
+ setActionTitle(originalBtn, 'Оригинал');
originalBtn.addEventListener('click', (event) => {
event.stopPropagation();
const ownerLogin = extractLoginFromBlockchainName(post.targetRef.blockchainName);
@@ -1121,6 +1228,27 @@ function renderPostCard(post, {
});
actions.append(originalBtn);
}
+ const detailsButton = document.createElement('button');
+ detailsButton.type = 'button';
+ detailsButton.className = 'channel-action-item';
+ detailsButton.innerHTML = `
+ ⛓
+ Данные блокчейна
+ `;
+ setActionTitle(detailsButton, 'Данные блокчейна');
+ detailsButton.addEventListener('click', (event) => {
+ event.stopPropagation();
+ openBlockchainDetailsModal(buildBlockchainDetails({
+ messageRef: post.messageRef,
+ authorLogin: post.authorLogin,
+ timestampMs: post.timestampMs,
+ text: post.body,
+ raw: post.rawMessage,
+ localNumber: post.localNumber,
+ msgSubType: post.msgSubType,
+ }));
+ });
+ actions.append(detailsButton);
if (post.isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css
index 0a1ba49f..af13b9e1 100644
--- a/shine-UI/styles/components.css
+++ b/shine-UI/styles/components.css
@@ -3878,6 +3878,36 @@ textarea.input {
padding: 8px 10px;
}
+.channel-message-card--deleted-compact {
+ gap: 7px;
+ padding-top: 9px;
+ padding-bottom: 9px;
+}
+
+.deleted-message-pill {
+ appearance: none;
+ width: 100%;
+ border: 1px solid rgba(255, 160, 120, 0.28);
+ border-radius: 12px;
+ background: rgba(70, 31, 25, 0.45);
+ color: rgba(255, 216, 190, 0.9);
+ font-size: 13px;
+ line-height: 1.2;
+ text-align: left;
+ padding: 8px 10px;
+ cursor: pointer;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.deleted-message-pill:hover,
+.deleted-message-pill:focus-visible {
+ border-color: rgba(255, 196, 142, 0.55);
+ background: rgba(98, 42, 31, 0.58);
+ outline: none;
+}
+
.channel-message-time {
font-size: 11px;
color: rgba(255, 255, 255, 0.48);
@@ -3919,12 +3949,15 @@ textarea.input {
appearance: none;
border: 0;
background: transparent;
+ position: relative;
min-height: 0;
padding: 0;
margin: 0;
display: inline-flex;
align-items: center;
+ justify-content: center;
gap: 5px;
+ min-width: 24px;
color: rgba(255, 255, 255, 0.55);
cursor: pointer;
transition: color 0.18s ease, text-shadow 0.18s ease, transform 0.18s ease;
@@ -3954,9 +3987,37 @@ textarea.input {
}
.channel-action-label {
- font-size: 12px;
- line-height: 1;
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.channel-action-item:hover::after,
+.channel-action-item:focus-visible::after {
+ content: attr(title);
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + 8px);
+ z-index: 30;
+ transform: translateX(-50%);
+ max-width: 180px;
+ padding: 5px 8px;
+ border: 1px solid rgba(255, 220, 140, 0.24);
+ border-radius: 8px;
+ background: rgba(20, 20, 24, 0.94);
+ color: rgba(255, 244, 210, 0.96);
+ font-size: 11px;
+ line-height: 1.15;
letter-spacing: 0.01em;
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
+ pointer-events: none;
+ white-space: nowrap;
}
.message-edited-marker {
@@ -3995,6 +4056,45 @@ textarea.input {
transform: translateX(0);
}
+.blockchain-details-card {
+ max-width: min(620px, calc(100vw - 28px));
+}
+
+.blockchain-details-grid {
+ display: grid;
+ grid-template-columns: max-content minmax(0, 1fr);
+ gap: 8px 12px;
+ padding: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 14px;
+ background: rgba(255, 255, 255, 0.035);
+}
+
+.blockchain-details-grid span {
+ color: rgba(255, 255, 255, 0.58);
+ font-size: 12px;
+}
+
+.blockchain-details-grid code,
+.blockchain-details-grid strong {
+ min-width: 0;
+ color: rgba(255, 244, 210, 0.96);
+ font-size: 12px;
+ overflow-wrap: anywhere;
+}
+
+.blockchain-raw-block {
+ max-height: 280px;
+ overflow: auto;
+ padding: 12px;
+ border-radius: 14px;
+ background: rgba(0, 0, 0, 0.32);
+ color: rgba(255, 255, 255, 0.82);
+ font-size: 11px;
+ line-height: 1.45;
+ white-space: pre-wrap;
+}
+
.thread-summary {
color: #efd9a4;
border-color: rgba(212, 171, 90, 0.36);