UI: добавить вложения Arweave в каналы

This commit is contained in:
AidarKC
2026-07-30 11:04:50 +04:00
parent c7684d6fe1
commit e9e6628b21
10 changed files with 974 additions and 29 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.4.0
client.version=1.4.1
server.version=1.4.0
+31
View File
@@ -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
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123...;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
```
Несколько вложений идут подряд в самом начале текста, по одному блоку на строку. После последнего блока идёт обычный пользовательский текст.
Обязательные поля:
- `v=1`
- `name` - имя файла, закодированное через `encodeURIComponent`
- `size` - размер файла в байтах
- `sha256` - SHA-256 исходного файла в hex
- `ar` - короткий Arweave Transaction ID, без полного URL
Правила клиента:
- если сообщение начинается с одного или нескольких валидных `<SHiNE:attach;...>` блоков, новый клиент скрывает эти блоки и показывает карточки вложений;
- если блок битый, клиент может игнорировать только этот блок и продолжить разбор остальных;
- старые клиенты без поддержки вложений могут показывать технические строки как обычный текст;
- пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок.
Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер и SHA-256.
+7
View File
@@ -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` изменён магик файла чекпоинта:
@@ -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) => (
`<option value="${escapeHtml(wallet.id)}">${escapeHtml(wallet.label)} · ${escapeHtml(shortAddress(wallet.address))}</option>`
)).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 = `
<div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card">
<h3 class="modal-title">Добавить вложение</h3>
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
<select class="input" id="ar-attach-wallet"></select>
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
<input class="input" id="ar-attach-file" type="file" />
<div class="ar-attachment-meta" data-meta="true"></div>
<p class="meta-muted inline-error" data-error="true"></p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" data-action="existing">Ввести txId</button>
<button class="secondary-btn" type="button" data-action="history">История</button>
<button class="primary-btn" type="button" data-action="upload" disabled>Загрузить</button>
</div>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div>
</div>
`;
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 = `
<div>Имя: ${escapeHtml(selectedFile.name || 'file')}</div>
<div>Размер: ${escapeHtml(formatBytes(selectedFile.size))}</div>
<div>SHA-256: ${escapeHtml(selectedSha256)}</div>
<div>Цена: ${escapeHtml(Number(priceInfo.ar).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
<div>Баланс кошелька: ${escapeHtml(Number(balanceInfo.ar).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
`;
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 = `
<div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card">
<h3 class="modal-title">Существующий файл Arweave</h3>
<label class="meta-muted" for="ar-existing-txid">Transaction ID</label>
<input class="input" id="ar-existing-txid" type="text" maxlength="64" placeholder="43 символа txId" />
<label class="meta-muted" for="ar-existing-name">Имя файла в сообщении</label>
<input class="input" id="ar-existing-name" type="text" maxlength="180" placeholder="например report.pdf" />
<div class="ar-attachment-meta" data-meta="true"></div>
<p class="meta-muted inline-error" data-error="true"></p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="check">Скачать и проверить</button>
</div>
</div>
</div>
`;
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 = `
<div>Размер: ${escapeHtml(formatBytes(item.size))}</div>
<div>SHA-256: ${escapeHtml(item.sha256)}</div>
`;
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 = `
<div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card ar-attachment-manager-card--wide">
<h3 class="modal-title">Журнал загруженных файлов</h3>
<p class="meta-muted">Журнал хранится только в этой браузерной сессии.</p>
<div class="ar-attachment-history-table-wrap">
<table class="ar-attachment-history-table">
<thead>
<tr><th>Файл</th><th>txId</th><th>Размер</th><th>Дата</th><th></th></tr>
</thead>
<tbody>
${rows.length ? rows.map((item, index) => `
<tr>
<td>${escapeHtml(item.name)}</td>
<td class="mono-cell">${escapeHtml(item.ar)}</td>
<td>${escapeHtml(formatBytes(item.size))}</td>
<td>${escapeHtml(formatDateTime(item.uploadedAtMs))}</td>
<td><button class="ghost-btn" type="button" data-pick="${index}">Выбрать</button></td>
</tr>
`).join('') : '<tr><td colspan="5">В этой сессии ещё нет загруженных файлов.</td></tr>'}
</tbody>
</table>
</div>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
</div>
</div>
</div>
`;
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 = `
<div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card">
<h3 class="modal-title">Добавить Arweave-кошелёк</h3>
<p class="meta-muted">Вставьте JSON JWK или короткую строку base64url(JSON JWK). Ключ будет сохранён в зашифрованном контейнере ключей этого пользователя на устройстве.</p>
<label class="meta-muted" for="ar-wallet-label">Название</label>
<input class="input" id="ar-wallet-label" type="text" maxlength="80" placeholder="например Основной AR" />
<label class="meta-muted" for="ar-wallet-secret">Секрет Arweave</label>
<textarea class="input" id="ar-wallet-secret" rows="7" placeholder="JSON JWK или base64url(JSON)"></textarea>
<p class="meta-muted inline-error" data-error="true"></p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="save">Добавить</button>
</div>
</div>
</div>
`;
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();
});
}
+58 -8
View File
@@ -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 }) {
<div class="modal-card stack">
<h3 class="modal-title">Ответ</h3>
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
<div class="draft-attachments" id="thread-reply-attachments"></div>
<button class="secondary-btn attachment-trigger-btn" id="thread-reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
<div class="meta-muted inline-error" id="thread-reply-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="thread-reply-cancel" type="button">Отмена</button>
@@ -348,14 +375,17 @@ function openReplyModal({ onSubmit, navigate }) {
`;
const textEl = root.querySelector('#thread-reply-text');
const attachmentsEl = root.querySelector('#thread-reply-attachments');
const errorEl = root.querySelector('#thread-reply-error');
const submitEl = root.querySelector('#thread-reply-submit');
const attachments = [];
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
root.querySelector('#thread-reply-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
@@ -368,8 +398,8 @@ function openReplyModal({ onSubmit, navigate }) {
if (inFlight) return;
const text = String(textEl?.value || '').trim();
if (!text) {
errorEl.textContent = 'Введите текст ответа.';
if (!text && attachments.length === 0) {
errorEl.textContent = 'Введите текст ответа или добавьте вложение.';
return;
}
@@ -377,7 +407,7 @@ function openReplyModal({ onSubmit, navigate }) {
errorEl.textContent = '';
try {
await onSubmit(text);
await onSubmit(composeMessageWithAttachments(text, attachments));
close();
} catch (error) {
setBusy(false);
@@ -385,6 +415,21 @@ function openReplyModal({ onSubmit, navigate }) {
}
});
root.querySelector('#thread-reply-attach')?.addEventListener('click', async () => {
try {
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
});
if (!item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
bindSubmitOnPlainEnter(textEl, () => root.querySelector('#thread-reply-submit')?.click());
if (textEl) textEl.focus();
@@ -504,7 +549,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
});
}
function openEditMessageModal({ initialText = '', onSave, onDelete }) {
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="thread-edit-modal">
@@ -531,7 +576,7 @@ function openEditMessageModal({ initialText = '', onSave, onDelete }) {
root.querySelector('#thread-edit-cancel')?.addEventListener('click', close);
root.querySelector('#thread-edit-save')?.addEventListener('click', async () => {
const value = String(textEl?.value || '').trim();
if (!value) {
if (!value && !allowEmptyText) {
errorEl.textContent = 'Введите текст сообщения.';
return;
}
@@ -569,6 +614,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0;
const msgSubType = Number(node?.msgSubType || 0);
const repostTarget = msgSubType === 30 ? buildRepostTargetFromNode(node) : null;
const parsedText = parseMessageAttachments(text);
const headingText = String(heading || '').trim();
if (headingText) {
@@ -620,9 +666,12 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const isDeletedMessage = String(text || '').trim().toLowerCase() === 'удалено';
const body = document.createElement('p');
body.className = `channel-message-body${isDeletedMessage ? ' channel-message-body--deleted' : ''}`;
body.textContent = isDeletedMessage ? 'Сообщение удалено' : text;
body.textContent = isDeletedMessage ? 'Сообщение удалено' : parsedText.text;
card.append(authorTile, body);
if (!isDeletedMessage && parsedText.attachments.length > 0) {
card.append(createAttachmentListElement(parsedText.attachments, { gateway: state.entrySettings.arweaveServer }));
}
const target = buildTargetFromNode(node);
const refKey = messageRefKey(target);
@@ -740,8 +789,9 @@ function renderNodeCard(node, heading, handlers, localNumber) {
event.stopPropagation();
animatePress(event.currentTarget);
openEditMessageModal({
initialText: String(text || '').trim() === 'удалено' ? '' : text,
onSave: async (nextText) => handlers.onEdit(target, nextText, { isChannelPost }),
initialText: String(text || '').trim() === 'удалено' ? '' : parsedText.text,
allowEmptyText: parsedText.attachments.length > 0,
onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsedText.attachments), { isChannelPost }),
onDelete: async () => handlers.onEdit(target, '', { isChannelPost, isDelete: true }),
});
});
+81 -11
View File
@@ -18,6 +18,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,
@@ -306,6 +312,25 @@ function bindSubmitOnPlainEnter(textarea, submit) {
});
}
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 = `
@@ -313,6 +338,8 @@ function openReplyModal({ onSubmit, navigate }) {
<div class="modal-card stack">
<h3 class="modal-title">Ответ</h3>
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
<div class="draft-attachments" id="reply-attachments"></div>
<button class="secondary-btn attachment-trigger-btn" id="reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
<div class="meta-muted inline-error" id="reply-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="reply-cancel" type="button">Отмена</button>
@@ -323,14 +350,17 @@ function openReplyModal({ onSubmit, navigate }) {
`;
const textEl = root.querySelector('#reply-text');
const attachmentsEl = root.querySelector('#reply-attachments');
const errorEl = root.querySelector('#reply-error');
const submitEl = root.querySelector('#reply-submit');
const attachments = [];
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
root.querySelector('#reply-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
@@ -343,8 +373,8 @@ function openReplyModal({ onSubmit, navigate }) {
if (inFlight) return;
const text = String(textEl?.value || '').trim();
if (!text) {
errorEl.textContent = 'Введите текст ответа.';
if (!text && attachments.length === 0) {
errorEl.textContent = 'Введите текст ответа или добавьте вложение.';
return;
}
@@ -352,7 +382,7 @@ function openReplyModal({ onSubmit, navigate }) {
errorEl.textContent = '';
try {
await onSubmit(text);
await onSubmit(composeMessageWithAttachments(text, attachments));
close();
} catch (error) {
setBusy(false);
@@ -360,6 +390,21 @@ function openReplyModal({ onSubmit, navigate }) {
}
});
root.querySelector('#reply-attach')?.addEventListener('click', async () => {
try {
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
});
if (!item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
if (textEl) textEl.focus();
@@ -447,6 +492,8 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
<h3 class="modal-title">Новое сообщение в канале</h3>
<p class="meta-muted">${channelName}</p>
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
<div class="draft-attachments" id="channel-message-attachments"></div>
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
<div class="meta-muted inline-error" id="channel-message-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
@@ -457,14 +504,17 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
`;
const textEl = root.querySelector('#channel-message-text');
const attachmentsEl = root.querySelector('#channel-message-attachments');
const errorEl = root.querySelector('#channel-message-error');
const submitEl = root.querySelector('#channel-message-submit');
const attachments = [];
let inFlight = false;
const setBusy = (busy) => {
inFlight = !!busy;
submitEl.disabled = inFlight;
if (textEl) textEl.disabled = inFlight;
root.querySelector('#channel-message-attach')?.toggleAttribute('disabled', inFlight);
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
};
@@ -477,8 +527,8 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
if (inFlight) return;
const body = String(textEl?.value || '').trim();
if (!body) {
errorEl.textContent = 'Введите текст сообщения.';
if (!body && attachments.length === 0) {
errorEl.textContent = 'Введите текст сообщения или добавьте вложение.';
return;
}
@@ -486,7 +536,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
errorEl.textContent = '';
try {
await onSubmit(body);
await onSubmit(composeMessageWithAttachments(body, attachments));
close();
} catch (error) {
setBusy(false);
@@ -494,6 +544,21 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
}
});
root.querySelector('#channel-message-attach')?.addEventListener('click', async () => {
try {
const item = await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
});
if (!item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
bindSubmitOnPlainEnter(textEl, () => submitEl?.click());
if (textEl) textEl.focus();
@@ -535,7 +600,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
});
}
function openEditMessageModal({ initialText = '', onSave, onDelete }) {
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="edit-message-modal">
@@ -563,7 +628,7 @@ function openEditMessageModal({ initialText = '', onSave, onDelete }) {
root.querySelector('#edit-message-cancel')?.addEventListener('click', close);
root.querySelector('#edit-message-save')?.addEventListener('click', async () => {
const value = String(textEl?.value || '').trim();
if (!value) {
if (!value && !allowEmptyText) {
errorEl.textContent = 'Введите текст сообщения.';
return;
}
@@ -932,11 +997,15 @@ 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 ? 'Сообщение удалено' : post.body;
body.textContent = isDeletedMessage ? 'Сообщение удалено' : parsedBody.text;
card.append(authorTile, body);
if (!isDeletedMessage && parsedBody.attachments.length > 0) {
card.append(createAttachmentListElement(parsedBody.attachments, { gateway: state.entrySettings.arweaveServer }));
}
const refKey = messageRefKey(post.messageRef);
if (refKey) {
@@ -1048,8 +1117,9 @@ function renderPostCard(post, {
event.stopPropagation();
animatePress(event.currentTarget);
openEditMessageModal({
initialText: String(post.body || '').trim() === 'удалено' ? '' : post.body,
onSave: async (nextText) => onEdit(post.messageRef, nextText, { isDelete: false }),
initialText: String(post.body || '').trim() === 'удалено' ? '' : parsedBody.text,
allowEmptyText: parsedBody.attachments.length > 0,
onSave: async (nextText) => onEdit(post.messageRef, composeMessageWithAttachments(nextText, parsedBody.attachments), { isDelete: false }),
onDelete: async () => onEdit(post.messageRef, '', { isDelete: true }),
});
});
+39 -9
View File
@@ -1,6 +1,7 @@
const DEFAULT_ARWEAVE_GATEWAY = 'https://arweave.net';
const WINSTON_PER_AR = 1_000_000_000_000n;
const MAX_AVATAR_SOURCE_BYTES = 10 * 1024 * 1024;
const MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES = 50 * 1024 * 1024;
const MAX_AVATAR_SIDE_PX = 768;
const AVATAR_QUALITY = 0.86;
const TX_ID_RE = /^[A-Za-z0-9_-]{43}$/;
@@ -268,12 +269,42 @@ export async function prepareAvatarImageFile(file) {
}
}
export async function uploadArweaveFile({ gateway, jwk, file, tags = [] }) {
export async function loadArweaveFileMetadata({ gateway, txId, maxBytes = MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES } = {}) {
const url = buildArweaveDataUrl({ gateway, txId });
const limit = Math.max(1, Number(maxBytes || MAX_ARWEAVE_ATTACHMENT_METADATA_BYTES));
const response = await fetch(url, { method: 'GET', cache: 'no-store' });
if (!response.ok) {
throw new Error(`Не удалось скачать файл Arweave (${response.status} ${response.statusText})`);
}
const lengthHeader = String(response.headers.get('content-length') || '').trim();
const declaredSize = Number(lengthHeader);
if (Number.isFinite(declaredSize) && declaredSize > limit) {
throw new Error(`Файл слишком большой для локальной проверки. Максимум ${Math.round(limit / 1024 / 1024)} MB.`);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength > limit) {
throw new Error(`Файл слишком большой для локальной проверки. Максимум ${Math.round(limit / 1024 / 1024)} MB.`);
}
const cleanType = String(response.headers.get('content-type') || '').split(';')[0].trim();
const sha256Hex = await sha256HexFromArrayBuffer(buffer);
return {
txId: String(txId || '').trim(),
sizeBytes: buffer.byteLength,
sha256Hex,
contentType: cleanType,
gateway: normalizeGateway(gateway),
};
}
export async function uploadArweaveFile({ gateway, jwk, file, tags = [], shineType = 'avatar' }) {
if (!jwk || typeof jwk !== 'object') {
throw new Error('Arweave-кошелёк не инициализирован.');
}
if (!(file instanceof File)) {
throw new Error('Выберите файл изображения.');
throw new Error('Выберите файл.');
}
const normalizedGateway = normalizeGateway(gateway);
@@ -287,14 +318,13 @@ export async function uploadArweaveFile({ gateway, jwk, file, tags = [] }) {
const tx = await arweave.createTransaction({ data }, jwk);
tx.addTag('Content-Type', String(file.type || 'application/octet-stream'));
tx.addTag('App-Name', 'SHiNE');
tx.addTag('SHiNE-Type', 'avatar');
tx.addTag('SHiNE-Type', String(shineType || 'file'));
const extraTags = Array.isArray(tags) ? tags : [];
const profileLoginTag = extraTags.find((item) => String(item?.name || '').trim() === 'SHiNE-Profile-Login');
const profileLogin = String(profileLoginTag?.value || '').trim();
if (!profileLogin) {
throw new Error('Не указан логин профиля для тега SHiNE-Profile-Login');
}
tx.addTag('SHiNE-Profile-Login', profileLogin);
extraTags.forEach((item) => {
const name = String(item?.name || '').trim();
const value = String(item?.value || '').trim();
if (name && value) tx.addTag(name, value);
});
await arweave.transactions.sign(tx, jwk);
const postResult = await arweave.transactions.post(tx);
@@ -93,6 +93,53 @@ function pickCachedWallet(secrets) {
};
}
function normalizeExtraWallets(secrets) {
const rows = Array.isArray(secrets?.arweaveWallets) ? secrets.arweaveWallets : [];
return rows
.filter((item) => item && typeof item === 'object')
.map((item) => ({
id: String(item.id || item.address || '').trim(),
label: String(item.label || '').trim(),
address: String(item.address || '').trim(),
owner: String(item.owner || '').trim(),
jwk: item.jwk,
createdAtMs: Number(item.createdAtMs || 0),
}))
.filter((item) => item.id && item.address && item.jwk && typeof item.jwk === 'object');
}
function parseJwkSecretString(secret) {
const raw = String(secret || '').trim();
if (!raw) throw new Error('Введите секрет Arweave-кошелька.');
const tryParse = (value) => {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Некорректный JWK Arweave.');
}
return parsed;
};
if (raw.startsWith('{')) return tryParse(raw);
const normalized = raw
.replace(/-/g, '+')
.replace(/_/g, '/')
.padEnd(Math.ceil(raw.length / 4) * 4, '=');
try {
return tryParse(atob(normalized));
} catch {
throw new Error('Секрет должен быть JSON JWK или base64url(JSON JWK).');
}
}
function validateArweaveJwk(jwk) {
if (!jwk || typeof jwk !== 'object') throw new Error('Некорректный JWK Arweave.');
['kty', 'e', 'n', 'd', 'p', 'q', 'dp', 'dq', 'qi'].forEach((key) => {
if (!String(jwk[key] || '').trim()) throw new Error(`В JWK Arweave нет поля ${key}.`);
});
}
function safeStatus(onStatus, text) {
if (typeof onStatus !== 'function') return;
try {
@@ -102,6 +149,63 @@ function safeStatus(onStatus, text) {
}
}
export async function getArweaveWalletChoices({ login, storagePwd, onStatus } = {}) {
const derived = await getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus });
const secrets = await loadEncryptedUserSecrets(String(login || '').trim(), String(storagePwd || '').trim());
const extras = normalizeExtraWallets(secrets);
return [
{
id: 'derived-client-key',
label: 'Стандартный из client key',
address: derived.address,
owner: derived.owner,
jwk: derived.jwk,
isDefault: true,
},
...extras.map((item) => ({
...item,
label: item.label || `Arweave ${item.address.slice(0, 6)}...${item.address.slice(-6)}`,
isDefault: false,
})),
];
}
export async function addArweaveWalletSecret({ login, storagePwd, secret, label = '' } = {}) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
if (!cleanLogin || !cleanPwd) throw new Error('Нет активной сессии.');
const jwk = parseJwkSecretString(secret);
validateArweaveJwk(jwk);
const moduleRef = await loadArweaveLib();
const Arweave = moduleRef?.default || moduleRef;
const arweave = Arweave.init({ host: 'arweave.net', port: 443, protocol: 'https' });
const address = await arweave.wallets.jwkToAddress(jwk);
const owner = String(jwk.n || '').trim();
const id = `manual-${address}`;
const createdAtMs = Date.now();
const wallet = {
id,
label: String(label || '').trim() || `Arweave ${address.slice(0, 6)}...${address.slice(-6)}`,
address,
owner,
jwk,
createdAtMs,
};
await updateEncryptedUserSecrets(cleanLogin, cleanPwd, (current) => {
const wallets = normalizeExtraWallets(current).filter((item) => item.address !== address);
wallets.push(wallet);
return {
...current,
arweaveWallets: wallets,
};
});
return wallet;
}
export async function getArweaveWalletFromStoredClientKey({ login, storagePwd, onStatus } = {}) {
const cleanLogin = String(login || '').trim();
const cleanPwd = String(storagePwd || '').trim();
+132
View File
@@ -0,0 +1,132 @@
import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './arweave-file-service.js';
const ATTACH_PREFIX = '<SHiNE:attach;';
const ATTACH_BLOCK_RE = /^<SHiNE:attach;([^>]*)>\n?/u;
export function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
function normalizeName(name) {
const clean = String(name || 'file')
.replace(/[\u0000-\u001f\u007f]/g, '')
.replace(/[\\/]/g, '_')
.trim();
return clean || 'file';
}
export function normalizeAttachment(input = {}) {
const txId = String(input.ar || input.txId || '').trim();
const sha256Hex = String(input.sha256 || input.sha256Hex || '').trim().toLowerCase();
const size = Number(input.size || input.sizeBytes || 0);
const name = normalizeName(input.name || input.fileName || 'file');
if (!validateArweaveTxId(txId)) throw new Error('Некорректный Transaction ID Arweave.');
if (!validateSha256Hex(sha256Hex)) throw new Error('Некорректный SHA-256 файла.');
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер файла.');
return {
v: 1,
name,
size,
sha256: sha256Hex,
ar: txId,
uploadedAtMs: Number(input.uploadedAtMs || 0) || Date.now(),
};
}
export function buildAttachmentBlock(attachment) {
const item = normalizeAttachment(attachment);
const encodedName = encodeURIComponent(item.name);
return `<SHiNE:attach;v=1;name=${encodedName};size=${item.size};sha256=${item.sha256};ar=${item.ar}>`;
}
export function composeMessageWithAttachments(text, attachments = []) {
const cleanText = String(text || '').trim();
const rows = (Array.isArray(attachments) ? attachments : []).map(buildAttachmentBlock);
if (!rows.length) return cleanText;
return `${rows.join('\n')}${cleanText ? `\n${cleanText}` : ''}`;
}
function parseFields(rawFields) {
const out = {};
String(rawFields || '').split(';').forEach((part) => {
const eq = part.indexOf('=');
if (eq <= 0) return;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key) out[key] = value;
});
return out;
}
export function parseMessageAttachments(rawText) {
let rest = String(rawText || '');
const attachments = [];
while (rest.startsWith(ATTACH_PREFIX)) {
const match = rest.match(ATTACH_BLOCK_RE);
if (!match) break;
const fields = parseFields(match[1]);
try {
attachments.push(normalizeAttachment({
name: decodeURIComponent(String(fields.name || 'file')),
size: fields.size,
sha256: fields.sha256,
ar: fields.ar,
}));
} catch {
// Битый attach-блок скрываем из UI, но не ломаем отображение текста.
}
rest = rest.slice(match[0].length);
}
return {
attachments,
text: rest.replace(/^\n+/u, ''),
};
}
export function createAttachmentListElement(attachments = [], { gateway = '', compact = false } = {}) {
const items = Array.isArray(attachments) ? attachments : [];
const wrap = document.createElement('div');
wrap.className = `message-attachments${compact ? ' message-attachments--compact' : ''}`;
items.forEach((raw) => {
let item;
try {
item = normalizeAttachment(raw);
} catch {
return;
}
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
const link = document.createElement('a');
link.className = 'message-attachment-card';
link.href = url;
link.target = '_blank';
link.rel = 'noopener';
link.title = 'Открыть файл в Arweave';
link.addEventListener('click', (event) => {
event.stopPropagation();
});
link.innerHTML = `
<span class="message-attachment-icon" aria-hidden="true">📎</span>
<span class="message-attachment-main">
<span class="message-attachment-name">${escapeHtml(item.name)}</span>
<span class="message-attachment-meta">${escapeHtml(formatBytes(item.size))} · ${escapeHtml(item.ar)}</span>
</span>
`;
wrap.append(link);
});
return wrap;
}
+119
View File
@@ -2151,6 +2151,125 @@ textarea.input {
gap: 10px;
}
.attachment-trigger-btn {
justify-self: start;
min-width: 3.25rem;
padding-inline: 0.8rem;
}
.draft-attachments {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.draft-attachment-chip {
border: 1px solid rgba(30, 64, 175, 0.2);
border-radius: 999px;
background: rgba(219, 234, 254, 0.92);
color: #1e3a8a;
cursor: pointer;
font-size: 0.82rem;
max-width: 100%;
overflow: hidden;
padding: 0.4rem 0.65rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-attachments {
display: grid;
gap: 0.5rem;
}
.message-attachment-card {
align-items: center;
background: linear-gradient(135deg, rgba(240, 253, 250, 0.95), rgba(239, 246, 255, 0.95));
border: 1px solid rgba(20, 184, 166, 0.25);
border-radius: 1rem;
color: #0f172a;
display: flex;
gap: 0.7rem;
padding: 0.75rem 0.85rem;
text-decoration: none;
}
.message-attachment-icon {
align-items: center;
background: #0f766e;
border-radius: 0.8rem;
color: #fff;
display: inline-flex;
flex: 0 0 auto;
height: 2.1rem;
justify-content: center;
width: 2.1rem;
}
.message-attachment-main {
display: grid;
min-width: 0;
}
.message-attachment-name {
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-attachment-meta {
color: #64748b;
font-size: 0.78rem;
overflow-wrap: anywhere;
}
.ar-attachment-manager-card {
max-width: min(92vw, 34rem);
}
.ar-attachment-manager-card--wide {
max-width: min(96vw, 58rem);
}
.ar-attachment-meta {
color: #cbd5e1;
display: grid;
font-size: 0.86rem;
gap: 0.25rem;
overflow-wrap: anywhere;
}
.ar-attachment-history-table-wrap {
max-height: 55vh;
overflow: auto;
}
.ar-attachment-history-table {
border-collapse: collapse;
font-size: 0.84rem;
min-width: 48rem;
width: 100%;
}
.ar-attachment-history-table th,
.ar-attachment-history-table td {
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
padding: 0.55rem;
text-align: left;
vertical-align: top;
}
.ar-attachment-history-table th {
color: #cbd5e1;
font-weight: 700;
}
.mono-cell {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
overflow-wrap: anywhere;
}
.modal-danger-action {
width: 100%;
}