diff --git a/VERSION.properties b/VERSION.properties index dd9a30d5..3c0a9804 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.4.0 +client.version=1.4.1 server.version=1.4.0 diff --git a/docs/Blockchain/11_TEXT_Blocks.md b/docs/Blockchain/11_TEXT_Blocks.md index fdf58f25..17d86fe8 100644 --- a/docs/Blockchain/11_TEXT_Blocks.md +++ b/docs/Blockchain/11_TEXT_Blocks.md @@ -36,3 +36,34 @@ TEXT-тип хранит сообщения и редактирования. - Для `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` допустим `textLen=0`. - Такой edit трактуется как логическое удаление содержимого сообщения. - Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет. + +## Вложения в текстовых сообщениях (`SHiNE:attach v=1`) + +Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` клиент может хранить вложения как технические строки в начале обычного `text`. + +Бинарный формат `TextBody` не меняется: вложения являются частью UTF-8 текста. + +Один блок вложения: + +```text + +``` + +Несколько вложений идут подряд в самом начале текста, по одному блоку на строку. После последнего блока идёт обычный пользовательский текст. + +Обязательные поля: + +- `v=1` +- `name` - имя файла, закодированное через `encodeURIComponent` +- `size` - размер файла в байтах +- `sha256` - SHA-256 исходного файла в hex +- `ar` - короткий Arweave Transaction ID, без полного URL + +Правила клиента: + +- если сообщение начинается с одного или нескольких валидных `` блоков, новый клиент скрывает эти блоки и показывает карточки вложений; +- если блок битый, клиент может игнорировать только этот блок и продолжить разбор остальных; +- старые клиенты без поддержки вложений могут показывать технические строки как обычный текст; +- пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок. + +Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер и SHA-256. diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index 25fabfeb..f68a0e20 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -1,5 +1,12 @@ # История изменений документации блокчейна +## 2026-07-30 10:31:26 +0400 +- Базовый коммит-ориентир: `c7684d6`. +- Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` зафиксирован клиентский формат вложений `SHiNE:attach v=1`. +- Бинарный `TextBody` не изменён: вложения записываются техническими строками в начале UTF-8 `text`. +- Вложение хранит `name`, `size`, `sha256` и короткий Arweave `txId` в поле `ar`; имя файла кодируется через `encodeURIComponent`. +- В `docs/Blockchain/11_TEXT_Blocks.md` добавлены правила совместимости, отображения и допустимости пустого пользовательского текста при наличии вложений. + ## 2026-07-14 20:43:43 +0400 - Базовый коммит-ориентир: `118be41`. - В `docs/ИТХ/Спецификация_ИТХ_v1.md` изменён магик файла чекпоинта: diff --git a/shine-UI/js/components/arweave-attachment-manager.js b/shine-UI/js/components/arweave-attachment-manager.js new file mode 100644 index 00000000..abfa8bc3 --- /dev/null +++ b/shine-UI/js/components/arweave-attachment-manager.js @@ -0,0 +1,402 @@ +import { + getArweaveUploadPrice, + loadArweaveFileMetadata, + sha256HexFromArrayBuffer, + uploadArweaveFile, + validateArweaveTxId, +} from '../services/arweave-file-service.js'; +import { addArweaveWalletSecret, getArweaveBalance, getArweaveWalletChoices } from '../services/arweave-wallet-service.js'; +import { escapeHtml, formatBytes, normalizeAttachment } from '../services/attachment-format.js'; + +const HISTORY_KEY = 'shine-ui-arweave-attachment-history-v1'; +const MAX_HISTORY_ITEMS = 100; + +function readHistory(login) { + try { + const raw = sessionStorage.getItem(HISTORY_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + const key = String(login || '').trim().toLowerCase(); + const rows = Array.isArray(parsed?.[key]) ? parsed[key] : []; + return rows.map((item) => normalizeAttachment(item)).filter(Boolean); + } catch { + return []; + } +} + +function saveHistory(login, rows) { + try { + const key = String(login || '').trim().toLowerCase(); + const raw = sessionStorage.getItem(HISTORY_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + parsed[key] = (Array.isArray(rows) ? rows : []).slice(0, MAX_HISTORY_ITEMS); + sessionStorage.setItem(HISTORY_KEY, JSON.stringify(parsed)); + } catch { + // ignore sessionStorage errors + } +} + +function addToHistory(login, attachment) { + const item = normalizeAttachment(attachment); + const rows = readHistory(login).filter((row) => row.ar !== item.ar); + rows.unshift(item); + saveHistory(login, rows); + return item; +} + +function formatDateTime(ms) { + const value = Number(ms || 0); + if (!Number.isFinite(value) || value <= 0) return '—'; + return new Date(value).toLocaleString('ru-RU'); +} + +function setText(node, text) { + if (node) node.textContent = String(text || ''); +} + +function shortAddress(value) { + const raw = String(value || '').trim(); + if (raw.length <= 16) return raw; + return `${raw.slice(0, 8)}...${raw.slice(-6)}`; +} + +export function openArweaveAttachmentManager({ + login, + storagePwd, + gateway, + onSelect, +} = {}) { + const cleanLogin = String(login || '').trim(); + const cleanStoragePwd = String(storagePwd || '').trim(); + const cleanGateway = String(gateway || 'https://arweave.net').trim(); + if (!cleanLogin || !cleanStoragePwd) return Promise.reject(new Error('Нет активной сессии.')); + + const root = document.createElement('div'); + root.className = 'ar-attachment-manager-root'; + document.body.append(root); + + let closed = false; + let wallets = []; + let selectedWalletId = 'derived-client-key'; + let selectedFile = null; + let selectedSha256 = ''; + let priceInfo = null; + let balanceInfo = null; + let autoOpenedFileDialog = false; + + function selectedWallet() { + return wallets.find((item) => String(item.id) === String(selectedWalletId)) || wallets[0] || null; + } + + function close(resolve, result = null) { + if (closed) return; + closed = true; + root.remove(); + resolve(result); + } + + function finish(resolve, attachment) { + const item = addToHistory(cleanLogin, attachment); + if (typeof onSelect === 'function') onSelect(item); + close(resolve, item); + } + + return new Promise((resolve) => { + const bindBackdrop = () => { + const modal = root.querySelector('[data-ar-attach-modal="true"]'); + modal?.addEventListener('click', (event) => { + if (event.target === modal) close(resolve, null); + }); + }; + + const renderWalletOptions = () => wallets.map((wallet) => ( + `` + )).join(''); + + const loadWallets = async (errorEl = null) => { + try { + wallets = await getArweaveWalletChoices({ login: cleanLogin, storagePwd: cleanStoragePwd }); + if (!wallets.some((item) => String(item.id) === String(selectedWalletId))) { + selectedWalletId = String(wallets[0]?.id || 'derived-client-key'); + } + } catch (error) { + setText(errorEl, error?.message || 'Не удалось загрузить Arweave-кошельки.'); + } + }; + + const showUpload = async () => { + root.innerHTML = ` + + `; + bindBackdrop(); + const walletEl = root.querySelector('#ar-attach-wallet'); + const fileEl = root.querySelector('#ar-attach-file'); + const metaEl = root.querySelector('[data-meta="true"]'); + const errorEl = root.querySelector('[data-error="true"]'); + const uploadBtn = root.querySelector('[data-action="upload"]'); + + await loadWallets(errorEl); + if (walletEl) { + walletEl.innerHTML = renderWalletOptions(); + walletEl.value = selectedWalletId; + walletEl.addEventListener('change', () => { + selectedWalletId = String(walletEl.value || ''); + uploadBtn.disabled = true; + selectedFile = null; + selectedSha256 = ''; + priceInfo = null; + balanceInfo = null; + if (fileEl) fileEl.value = ''; + setText(metaEl, ''); + }); + } + + root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(resolve, null)); + root.querySelector('[data-action="existing"]')?.addEventListener('click', showExisting); + root.querySelector('[data-action="history"]')?.addEventListener('click', showHistory); + root.querySelector('[data-action="add-wallet"]')?.addEventListener('click', showAddWallet); + + fileEl?.addEventListener('change', async () => { + selectedFile = fileEl.files?.[0] || null; + selectedSha256 = ''; + priceInfo = null; + balanceInfo = null; + uploadBtn.disabled = true; + setText(errorEl, ''); + setText(metaEl, ''); + if (!selectedFile) return; + + try { + const wallet = selectedWallet(); + if (!wallet?.address) throw new Error('Выберите Arweave-кошелёк.'); + const buffer = await selectedFile.arrayBuffer(); + selectedSha256 = await sha256HexFromArrayBuffer(buffer); + priceInfo = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedFile.size }); + balanceInfo = await getArweaveBalance({ gateway: cleanGateway, address: wallet.address }); + const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston); + metaEl.innerHTML = ` +
Имя: ${escapeHtml(selectedFile.name || 'file')}
+
Размер: ${escapeHtml(formatBytes(selectedFile.size))}
+
SHA-256: ${escapeHtml(selectedSha256)}
+
Цена: ${escapeHtml(Number(priceInfo.ar).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR
+
Баланс кошелька: ${escapeHtml(Number(balanceInfo.ar).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR
+ `; + if (!hasFunds) { + setText(errorEl, 'Недостаточно AR на выбранном кошельке.'); + return; + } + uploadBtn.disabled = false; + } catch (error) { + setText(errorEl, error?.message || 'Не удалось подготовить файл.'); + } + }); + + uploadBtn?.addEventListener('click', async () => { + if (!selectedFile || !selectedSha256) { + setText(errorEl, 'Выберите файл.'); + return; + } + const wallet = selectedWallet(); + if (!wallet?.jwk) { + setText(errorEl, 'Выберите Arweave-кошелёк.'); + return; + } + uploadBtn.disabled = true; + setText(errorEl, 'Загружаем файл в Arweave...'); + try { + const uploaded = await uploadArweaveFile({ + gateway: cleanGateway, + jwk: wallet.jwk, + file: selectedFile, + shineType: 'attachment', + tags: [ + { name: 'SHiNE-Login', value: cleanLogin }, + { name: 'SHiNE-Attachment-Name', value: selectedFile.name || 'file' }, + ], + }); + finish(resolve, { + name: selectedFile.name || 'file', + size: selectedFile.size, + sha256: selectedSha256, + ar: uploaded.id, + uploadedAtMs: Date.now(), + }); + } catch (error) { + uploadBtn.disabled = false; + setText(errorEl, error?.message || 'Не удалось загрузить файл в Arweave.'); + } + }); + + if (!autoOpenedFileDialog) { + autoOpenedFileDialog = true; + window.setTimeout(() => fileEl?.click(), 0); + } + }; + + const showExisting = () => { + root.innerHTML = ` + + `; + bindBackdrop(); + const txEl = root.querySelector('#ar-existing-txid'); + const nameEl = root.querySelector('#ar-existing-name'); + const metaEl = root.querySelector('[data-meta="true"]'); + const errorEl = root.querySelector('[data-error="true"]'); + const checkBtn = root.querySelector('[data-action="check"]'); + root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload); + checkBtn?.addEventListener('click', async () => { + const txId = String(txEl?.value || '').trim(); + const name = String(nameEl?.value || '').trim() || 'arweave-file'; + if (!validateArweaveTxId(txId)) { + setText(errorEl, 'Некорректный Transaction ID Arweave.'); + return; + } + checkBtn.disabled = true; + setText(errorEl, 'Скачиваем файл и считаем SHA-256...'); + setText(metaEl, ''); + try { + const meta = await loadArweaveFileMetadata({ gateway: cleanGateway, txId }); + const item = normalizeAttachment({ + name, + size: meta.sizeBytes, + sha256: meta.sha256Hex, + ar: txId, + uploadedAtMs: Date.now(), + }); + metaEl.innerHTML = ` +
Размер: ${escapeHtml(formatBytes(item.size))}
+
SHA-256: ${escapeHtml(item.sha256)}
+ `; + setText(errorEl, ''); + const addBtn = document.createElement('button'); + addBtn.className = 'primary-btn'; + addBtn.type = 'button'; + addBtn.textContent = 'Добавить в сообщение'; + addBtn.addEventListener('click', () => finish(resolve, item)); + metaEl.append(addBtn); + } catch (error) { + setText(errorEl, error?.message || 'Не удалось проверить файл Arweave.'); + checkBtn.disabled = false; + } + }); + window.setTimeout(() => txEl?.focus(), 0); + }; + + const showHistory = () => { + const rows = readHistory(cleanLogin); + root.innerHTML = ` + + `; + bindBackdrop(); + root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload); + root.querySelectorAll('[data-pick]').forEach((button) => { + button.addEventListener('click', () => { + const index = Number(button.getAttribute('data-pick')); + const item = rows[index]; + if (item) finish(resolve, item); + }); + }); + }; + + const showAddWallet = () => { + root.innerHTML = ` + + `; + bindBackdrop(); + const labelEl = root.querySelector('#ar-wallet-label'); + const secretEl = root.querySelector('#ar-wallet-secret'); + const errorEl = root.querySelector('[data-error="true"]'); + const saveBtn = root.querySelector('[data-action="save"]'); + root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload); + saveBtn?.addEventListener('click', async () => { + saveBtn.disabled = true; + setText(errorEl, ''); + try { + const wallet = await addArweaveWalletSecret({ + login: cleanLogin, + storagePwd: cleanStoragePwd, + label: String(labelEl?.value || '').trim(), + secret: String(secretEl?.value || '').trim(), + }); + selectedWalletId = wallet.id; + await showUpload(); + } catch (error) { + saveBtn.disabled = false; + setText(errorEl, error?.message || 'Не удалось добавить Arweave-кошелёк.'); + } + }); + }; + + void showUpload(); + }); +} diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index 6761d663..8ffa4a82 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -12,6 +12,12 @@ import { } from '../services/channels-ux.js'; import { navigateBack } from '../router.js'; import { renderUserAvatar } from '../components/avatar-image.js'; +import { openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js'; +import { + composeMessageWithAttachments, + createAttachmentListElement, + parseMessageAttachments, +} from '../services/attachment-format.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js'; @@ -331,6 +337,25 @@ function resolveNodeText(node) { ); } +function renderDraftAttachments(container, attachments) { + if (!container) return; + container.innerHTML = ''; + (Array.isArray(attachments) ? attachments : []).forEach((item, index) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'draft-attachment-chip'; + button.textContent = `${item.name} · ${item.ar}`; + button.title = 'Нажмите, чтобы убрать вложение'; + button.addEventListener('click', () => { + const ok = window.confirm('Отменить вложение?'); + if (!ok) return; + attachments.splice(index, 1); + renderDraftAttachments(container, attachments); + }); + container.append(button); + }); +} + function openReplyModal({ onSubmit, navigate }) { const root = document.getElementById('modal-root'); root.innerHTML = ` @@ -338,6 +363,8 @@ function openReplyModal({ onSubmit, navigate }) {