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 = ` + + `; + 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 = ` + + `; + 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);