SHA256
Compare commits
3
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
c530627078 | ||
|
|
f961947e1e | ||
|
|
e9e6628b21 |
+1
-1
@@ -1,2 +1,2 @@
|
||||
client.version=1.4.0
|
||||
client.version=1.4.2
|
||||
server.version=1.4.0
|
||||
|
||||
@@ -36,3 +36,36 @@ 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`.
|
||||
|
||||
Подробная спецификация: [15_TEXT_Attachments.md](./15_TEXT_Attachments.md).
|
||||
|
||||
Бинарный формат `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.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Вложения в TEXT-сообщениях (`SHiNE:attach v=1`)
|
||||
|
||||
Документ фиксирует текущий формат вложений в текстовых блоках SHiNE.
|
||||
|
||||
## Область применения
|
||||
|
||||
Формат применяется к UTF-8 полю `text` в TEXT-блоках:
|
||||
|
||||
- `TEXT_POST`
|
||||
- `TEXT_REPLY`
|
||||
- `TEXT_EDIT_POST`
|
||||
- `TEXT_EDIT_REPLY`
|
||||
|
||||
Бинарный формат `TextBody` не меняется. Вложения являются техническим префиксом внутри обычного текста.
|
||||
|
||||
## Общий вид
|
||||
|
||||
Один attach-блок:
|
||||
|
||||
```text
|
||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
|
||||
```
|
||||
|
||||
Несколько вложений идут подряд в самом начале текста:
|
||||
|
||||
```text
|
||||
<SHiNE:attach;v=1;name=photo.jpg;size=248193;sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;ar=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB>
|
||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc;ar=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD>
|
||||
Текст сообщения
|
||||
```
|
||||
|
||||
После последнего attach-блока идёт обычный пользовательский текст. Пользовательский текст может быть пустым, если есть хотя бы одно валидное вложение.
|
||||
|
||||
Клиент SHiNE ограничивает одно сообщение максимум 10 вложениями и сохраняет порядок attach-блоков в том порядке, в котором пользователь прикрепил файлы.
|
||||
|
||||
## Поля
|
||||
|
||||
Обязательные поля:
|
||||
|
||||
- `v=1` - версия формата attach-блока;
|
||||
- `name` - имя файла, закодированное через `encodeURIComponent`;
|
||||
- `size` - размер файла в байтах;
|
||||
- `sha256` - SHA-256 исходного файла в hex, 64 символа;
|
||||
- `ar` - короткий Arweave Transaction ID, 43 символа, без gateway URL.
|
||||
|
||||
## Хранение файла
|
||||
|
||||
Файл хранится вне блокчейна в Arweave. В блокчейне хранится только метаинформация, достаточная для отображения и проверки:
|
||||
|
||||
- имя файла;
|
||||
- размер;
|
||||
- SHA-256;
|
||||
- Arweave `txId`.
|
||||
|
||||
## Создание вложения в UI
|
||||
|
||||
UI поддерживает три сценария:
|
||||
|
||||
- загрузить новый файл в Arweave выбранным Arweave-кошельком;
|
||||
- ввести существующий `txId`, скачать файл через Arweave gateway и локально посчитать `size/sha256`;
|
||||
- выбрать файл из журнала файлов, загруженных в текущей браузерной сессии.
|
||||
|
||||
Журнал хранится только в `sessionStorage` текущего браузера и не является частью блокчейна.
|
||||
|
||||
## Отображение
|
||||
|
||||
Новый клиент при чтении сообщения:
|
||||
|
||||
1. читает валидные attach-блоки только из начала текста;
|
||||
2. скрывает технические attach-строки;
|
||||
3. отображает вложения над пользовательским текстом;
|
||||
4. показывает вложения горизонтальной каруселью: одно вложение на экране, переключение свайпом или стрелками, счётчик вида `1 из 3`;
|
||||
5. определяет тип вложения по расширению имени файла:
|
||||
- изображение показывает как ограниченное по размеру превью;
|
||||
- видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию;
|
||||
- обычный файл показывает как карточку с именем, расширением, размером и скачиванием;
|
||||
6. при перелистывании останавливает воспроизводящееся видео;
|
||||
7. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение.
|
||||
|
||||
Если файл по ссылке Arweave gateway недоступен, клиент показывает диагностический блок:
|
||||
|
||||
- если сообщение создано менее 20 минут назад: файл, скорее всего, ещё не распространился в Arweave, нужно повторить через несколько минут;
|
||||
- если сообщение старше 20 минут: файл считается недоступным.
|
||||
|
||||
Старые клиенты без поддержки формата могут показывать attach-блоки как обычный текст.
|
||||
|
||||
## Редактирование
|
||||
|
||||
При редактировании сообщения UI сохраняет существующие attach-блоки и меняет только пользовательский текст. Удаление сообщения по-прежнему выполняется через edit-блок с пустым текстом, как описано в `11_TEXT_Blocks.md`.
|
||||
|
||||
## Ограничения v1
|
||||
|
||||
- `ar` допускает только короткий Arweave `txId`, полный URL не используется;
|
||||
- MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла;
|
||||
- MIME type, width, height, duration и thumbnail не записываются в блокчейн;
|
||||
- проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки.
|
||||
@@ -1,5 +1,24 @@
|
||||
# История изменений документации блокчейна
|
||||
|
||||
## 2026-07-31 12:47:41 +0400
|
||||
- Базовый коммит-ориентир: `f961947`.
|
||||
- Для `SHiNE:attach v=1` уточнено клиентское отображение вложений: горизонтальная карусель над текстом сообщения, счётчик позиции, просмотр image/video в модальном окне и скачивание файлов.
|
||||
- Зафиксирован клиентский лимит: максимум 10 вложений в одном сообщении с сохранением порядка attach-блоков.
|
||||
- Описана диагностика недоступного файла через Arweave gateway: отдельное сообщение для свежих сообщений младше 20 минут и отдельное сообщение для старых недоступных файлов.
|
||||
|
||||
## 2026-07-30 11:12:47 +0400
|
||||
- Базовый коммит-ориентир: `e9e6628`.
|
||||
- Добавлен отдельный документ `docs/Blockchain/15_TEXT_Attachments.md` с полной спецификацией `SHiNE:attach v=1`.
|
||||
- В `docs/Blockchain/README.md` добавлена ссылка на документ вложений.
|
||||
- В UI порядок отображения сообщения с вложениями закреплён как: вложения сверху, пользовательский текст снизу.
|
||||
|
||||
## 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` изменён магик файла чекпоинта:
|
||||
|
||||
+12
-10
@@ -3,25 +3,27 @@
|
||||
Этот каталог описывает только текущий рабочий формат протокола для MVP.
|
||||
|
||||
## Основные документы
|
||||
1. [01_Common_Block_Format.md](./01_Common_Block_Format.md)
|
||||
1. [01_Common_Block_Format.md](./01_Common_Block_Format.md)
|
||||
Единый бинарный формат блока (Frame v0), подпись, базовые проверки.
|
||||
2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md)
|
||||
2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md)
|
||||
Виды цепочек и правила line-полей.
|
||||
3. [10_TECH_Blocks.md](./10_TECH_Blocks.md)
|
||||
3. [10_TECH_Blocks.md](./10_TECH_Blocks.md)
|
||||
Системные блоки (`msg_type=0`).
|
||||
4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md)
|
||||
4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md)
|
||||
Текстовые блоки (`msg_type=1`).
|
||||
5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md)
|
||||
5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md)
|
||||
Реакции (`msg_type=2`).
|
||||
6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md)
|
||||
6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md)
|
||||
Социальные связи (`msg_type=3`).
|
||||
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
|
||||
7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md)
|
||||
Параметры пользователя (`msg_type=4`).
|
||||
8. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
|
||||
8. [15_TEXT_Attachments.md](./15_TEXT_Attachments.md)
|
||||
Вложения в TEXT-сообщениях через `SHiNE:attach v=1`.
|
||||
9. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
|
||||
Типы каналов и формат `CreateChannelBody`.
|
||||
9. [02_Channel_Commands.md](./02_Channel_Commands.md)
|
||||
10. [02_Channel_Commands.md](./02_Channel_Commands.md)
|
||||
Команды в текстовых сообщениях каналов.
|
||||
10. [CHANGELOG.md](./CHANGELOG.md)
|
||||
11. [CHANGELOG.md](./CHANGELOG.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();
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,13 @@ 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,
|
||||
createAttachmentCarouselElement,
|
||||
MAX_MESSAGE_ATTACHMENTS,
|
||||
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 +338,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 +364,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 +376,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 +399,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 +408,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await onSubmit(text);
|
||||
await onSubmit(composeMessageWithAttachments(text, attachments));
|
||||
close();
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
@@ -385,6 +416,25 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelector('#thread-reply-attach')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
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 +554,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 +581,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 +619,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 +671,16 @@ 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);
|
||||
card.append(authorTile);
|
||||
if (!isDeletedMessage && parsedText.attachments.length > 0) {
|
||||
card.append(createAttachmentCarouselElement(parsedText.attachments, {
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
messageTimestampMs: node?.createdAtMs,
|
||||
}));
|
||||
}
|
||||
card.append(body);
|
||||
|
||||
const target = buildTargetFromNode(node);
|
||||
const refKey = messageRefKey(target);
|
||||
@@ -740,8 +798,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 }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,13 @@ 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,
|
||||
createAttachmentCarouselElement,
|
||||
MAX_MESSAGE_ATTACHMENTS,
|
||||
parseMessageAttachments,
|
||||
} from '../services/attachment-format.js';
|
||||
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import {
|
||||
extractLoginFromBlockchainName,
|
||||
@@ -306,6 +313,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 +339,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 +351,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 +374,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 +383,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await onSubmit(text);
|
||||
await onSubmit(composeMessageWithAttachments(text, attachments));
|
||||
close();
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
@@ -360,6 +391,25 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelector('#reply-attach')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
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 +497,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 +509,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 +532,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 +541,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await onSubmit(body);
|
||||
await onSubmit(composeMessageWithAttachments(body, attachments));
|
||||
close();
|
||||
} catch (error) {
|
||||
setBusy(false);
|
||||
@@ -494,6 +549,25 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelector('#channel-message-attach')?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
|
||||
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
|
||||
return;
|
||||
}
|
||||
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 +609,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 +637,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 +1006,19 @@ 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);
|
||||
card.append(authorTile);
|
||||
if (!isDeletedMessage && parsedBody.attachments.length > 0) {
|
||||
card.append(createAttachmentCarouselElement(parsedBody.attachments, {
|
||||
gateway: state.entrySettings.arweaveServer,
|
||||
messageTimestampMs: post.timestampMs,
|
||||
}));
|
||||
}
|
||||
card.append(body);
|
||||
|
||||
const refKey = messageRefKey(post.messageRef);
|
||||
if (refKey) {
|
||||
@@ -1048,8 +1130,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 }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './arweave-file-service.js';
|
||||
|
||||
const ATTACH_PREFIX = '<SHiNE:attach;';
|
||||
const ATTACH_BLOCK_RE = /^<SHiNE:attach;([^>]*)>\n?/u;
|
||||
export const MAX_MESSAGE_ATTACHMENTS = 10;
|
||||
const RECENT_UNAVAILABLE_MS = 20 * 60 * 1000;
|
||||
const IMAGE_EXTENSIONS = new Set(['apng', 'avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp']);
|
||||
const VIDEO_EXTENSIONS = new Set(['avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'ogg', 'ogv', 'webm']);
|
||||
|
||||
export function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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 : [])
|
||||
.slice(0, MAX_MESSAGE_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 getAttachmentExtension(name) {
|
||||
const clean = String(name || '').trim().toLowerCase();
|
||||
const dot = clean.lastIndexOf('.');
|
||||
if (dot < 0 || dot === clean.length - 1) return '';
|
||||
return clean.slice(dot + 1).replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
export function getAttachmentKind(attachment = {}) {
|
||||
const ext = getAttachmentExtension(attachment.name);
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return 'image';
|
||||
if (VIDEO_EXTENSIONS.has(ext)) return 'video';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
function isRecentMessage(messageTimestampMs) {
|
||||
const ts = Number(messageTimestampMs || 0);
|
||||
if (!Number.isFinite(ts) || ts <= 0) return false;
|
||||
return Date.now() - ts < RECENT_UNAVAILABLE_MS;
|
||||
}
|
||||
|
||||
function createUnavailableBlock(messageTimestampMs) {
|
||||
const block = document.createElement('div');
|
||||
block.className = 'message-attachment-unavailable';
|
||||
block.textContent = isRecentMessage(messageTimestampMs)
|
||||
? 'Скорее всего файл недоступен, потому что сообщение было создано недавно и файл ещё не распространился в Arweave. Попробуйте повторить через несколько минут.'
|
||||
: 'Файл недоступен.';
|
||||
return block;
|
||||
}
|
||||
|
||||
function stopVideos(root) {
|
||||
root.querySelectorAll('video').forEach((video) => {
|
||||
try {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
} catch {
|
||||
// Если браузер не даёт управлять видео, просто продолжаем перелистывание.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createDownloadLink(url, label = 'Скачать') {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'message-attachment-download';
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
link.download = '';
|
||||
link.textContent = label;
|
||||
link.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
return link;
|
||||
}
|
||||
|
||||
function openAttachmentViewer({ item, url, kind }) {
|
||||
const root = document.getElementById('modal-root') || document.body;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal attachment-viewer-modal';
|
||||
overlay.innerHTML = `
|
||||
<div class="attachment-viewer-card" role="dialog" aria-modal="true">
|
||||
<div class="attachment-viewer-head">
|
||||
<div class="attachment-viewer-title">${escapeHtml(item.name)}</div>
|
||||
<button class="icon-btn attachment-viewer-close" type="button" aria-label="Закрыть">×</button>
|
||||
</div>
|
||||
<div class="attachment-viewer-body"></div>
|
||||
<div class="attachment-viewer-actions"></div>
|
||||
</div>
|
||||
`;
|
||||
const close = () => {
|
||||
stopVideos(overlay);
|
||||
overlay.remove();
|
||||
};
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) close();
|
||||
});
|
||||
overlay.querySelector('.attachment-viewer-close')?.addEventListener('click', close);
|
||||
const body = overlay.querySelector('.attachment-viewer-body');
|
||||
if (kind === 'video') {
|
||||
const video = document.createElement('video');
|
||||
video.className = 'attachment-viewer-media';
|
||||
video.src = url;
|
||||
video.controls = true;
|
||||
video.autoplay = true;
|
||||
body.append(video);
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'attachment-viewer-media';
|
||||
img.src = url;
|
||||
img.alt = item.name;
|
||||
body.append(img);
|
||||
}
|
||||
overlay.querySelector('.attachment-viewer-actions')?.append(createDownloadLink(url));
|
||||
root.append(overlay);
|
||||
}
|
||||
|
||||
function replaceWithUnavailable(slide, messageTimestampMs) {
|
||||
if (!slide || slide.dataset.unavailable === '1') return;
|
||||
slide.dataset.unavailable = '1';
|
||||
slide.replaceChildren(createUnavailableBlock(messageTimestampMs));
|
||||
}
|
||||
|
||||
function createFileCard({ item, url, messageTimestampMs, slide }) {
|
||||
const ext = getAttachmentExtension(item.name).toUpperCase() || 'FILE';
|
||||
const card = document.createElement('div');
|
||||
card.className = 'message-attachment-file-card';
|
||||
card.role = 'button';
|
||||
card.tabIndex = 0;
|
||||
card.title = 'Скачать файл';
|
||||
card.innerHTML = `
|
||||
<div class="message-attachment-file-ext">${escapeHtml(ext)}</div>
|
||||
<div class="message-attachment-file-main">
|
||||
<div class="message-attachment-name">${escapeHtml(item.name)}</div>
|
||||
<div class="message-attachment-meta">${escapeHtml(formatBytes(item.size))} · ${escapeHtml(item.ar)}</div>
|
||||
</div>
|
||||
`;
|
||||
const openFile = () => window.open(url, '_blank', 'noopener');
|
||||
card.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
openFile();
|
||||
});
|
||||
card.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
openFile();
|
||||
});
|
||||
card.append(createDownloadLink(url));
|
||||
void fetch(url, { method: 'HEAD', cache: 'no-store' })
|
||||
.then((response) => {
|
||||
if (!response.ok) replaceWithUnavailable(slide, messageTimestampMs);
|
||||
})
|
||||
.catch(() => replaceWithUnavailable(slide, messageTimestampMs));
|
||||
return card;
|
||||
}
|
||||
|
||||
function createMediaSlide({ item, url, kind, messageTimestampMs, slide }) {
|
||||
const frame = document.createElement('div');
|
||||
frame.className = `message-attachment-media-frame message-attachment-media-frame--${kind}`;
|
||||
frame.role = 'button';
|
||||
frame.tabIndex = 0;
|
||||
frame.title = kind === 'video' ? 'Открыть видео' : 'Открыть изображение';
|
||||
frame.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
openAttachmentViewer({ item, url, kind });
|
||||
});
|
||||
frame.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
openAttachmentViewer({ item, url, kind });
|
||||
});
|
||||
|
||||
if (kind === 'image') {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'message-attachment-media';
|
||||
img.src = url;
|
||||
img.alt = item.name;
|
||||
img.loading = 'lazy';
|
||||
img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
|
||||
frame.append(img);
|
||||
} else {
|
||||
const video = document.createElement('video');
|
||||
video.className = 'message-attachment-media';
|
||||
video.src = url;
|
||||
video.preload = 'metadata';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
|
||||
const play = document.createElement('span');
|
||||
play.className = 'message-attachment-play';
|
||||
play.textContent = '▶';
|
||||
frame.append(video, play);
|
||||
}
|
||||
|
||||
frame.append(createDownloadLink(url));
|
||||
return frame;
|
||||
}
|
||||
|
||||
export function createAttachmentCarouselElement(attachments = [], { gateway = '', messageTimestampMs = 0 } = {}) {
|
||||
const items = (Array.isArray(attachments) ? attachments : [])
|
||||
.slice(0, MAX_MESSAGE_ATTACHMENTS)
|
||||
.map((raw) => {
|
||||
try {
|
||||
return normalizeAttachment(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'message-attachments message-attachment-carousel';
|
||||
wrap.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
if (!items.length) return wrap;
|
||||
|
||||
let index = 0;
|
||||
let pointerStartX = 0;
|
||||
const viewport = document.createElement('div');
|
||||
viewport.className = 'message-attachment-carousel-viewport';
|
||||
const slide = document.createElement('div');
|
||||
slide.className = 'message-attachment-slide';
|
||||
const prev = document.createElement('button');
|
||||
prev.type = 'button';
|
||||
prev.className = 'message-attachment-arrow message-attachment-arrow--prev';
|
||||
prev.textContent = '‹';
|
||||
prev.setAttribute('aria-label', 'Предыдущее вложение');
|
||||
const next = document.createElement('button');
|
||||
next.type = 'button';
|
||||
next.className = 'message-attachment-arrow message-attachment-arrow--next';
|
||||
next.textContent = '›';
|
||||
next.setAttribute('aria-label', 'Следующее вложение');
|
||||
const counter = document.createElement('div');
|
||||
counter.className = 'message-attachment-counter';
|
||||
|
||||
const render = () => {
|
||||
stopVideos(wrap);
|
||||
const item = items[index];
|
||||
const kind = getAttachmentKind(item);
|
||||
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
|
||||
slide.dataset.unavailable = '0';
|
||||
slide.replaceChildren();
|
||||
slide.append(kind === 'file'
|
||||
? createFileCard({ item, url, messageTimestampMs, slide })
|
||||
: createMediaSlide({ item, url, kind, messageTimestampMs, slide }));
|
||||
prev.disabled = index <= 0;
|
||||
next.disabled = index >= items.length - 1;
|
||||
counter.textContent = `${index + 1} из ${items.length}`;
|
||||
};
|
||||
|
||||
const move = (delta) => {
|
||||
const nextIndex = Math.max(0, Math.min(items.length - 1, index + delta));
|
||||
if (nextIndex === index) return;
|
||||
index = nextIndex;
|
||||
render();
|
||||
};
|
||||
|
||||
prev.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
move(-1);
|
||||
});
|
||||
next.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
move(1);
|
||||
});
|
||||
viewport.addEventListener('pointerdown', (event) => {
|
||||
pointerStartX = event.clientX;
|
||||
});
|
||||
viewport.addEventListener('pointerup', (event) => {
|
||||
const dx = event.clientX - pointerStartX;
|
||||
if (Math.abs(dx) < 40) return;
|
||||
move(dx < 0 ? 1 : -1);
|
||||
});
|
||||
|
||||
viewport.append(slide, prev, next);
|
||||
wrap.append(viewport, counter);
|
||||
render();
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function createAttachmentListElement(attachments = [], options = {}) {
|
||||
return createAttachmentCarouselElement(attachments, options);
|
||||
}
|
||||
@@ -2151,6 +2151,358 @@ 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-carousel {
|
||||
gap: 0.35rem;
|
||||
margin: 0.5rem 0 0.4rem;
|
||||
max-width: min(100%, 34rem);
|
||||
}
|
||||
|
||||
.message-attachment-carousel-viewport {
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.05), rgba(20, 184, 166, 0.08));
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
border-radius: 1.15rem;
|
||||
display: grid;
|
||||
min-height: 12rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.message-attachment-slide {
|
||||
align-items: stretch;
|
||||
display: grid;
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
.message-attachment-arrow {
|
||||
align-items: center;
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-size: 1.85rem;
|
||||
height: 2.35rem;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2.35rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.message-attachment-arrow:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.message-attachment-arrow--prev {
|
||||
left: 0.55rem;
|
||||
}
|
||||
|
||||
.message-attachment-arrow--next {
|
||||
right: 0.55rem;
|
||||
}
|
||||
|
||||
.message-attachment-counter {
|
||||
color: #64748b;
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message-attachment-media-frame {
|
||||
align-items: center;
|
||||
background: #0f172a;
|
||||
cursor: zoom-in;
|
||||
display: grid;
|
||||
min-height: 12rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-attachment-media {
|
||||
display: block;
|
||||
height: min(52vh, 20rem);
|
||||
max-height: 20rem;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-attachment-play {
|
||||
align-items: center;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
font-size: 1.7rem;
|
||||
height: 4rem;
|
||||
justify-content: center;
|
||||
left: 50%;
|
||||
padding-left: 0.18rem;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
.message-attachment-download {
|
||||
background: rgba(20, 184, 166, 0.95);
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
padding: 0.45rem 0.75rem;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
width: max-content;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.message-attachment-media-frame > .message-attachment-download {
|
||||
bottom: 0.75rem;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
}
|
||||
|
||||
.message-attachment-file-card {
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(240, 253, 250, 0.95), rgba(239, 246, 255, 0.95));
|
||||
color: #0f172a;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.message-attachment-file-ext {
|
||||
align-items: center;
|
||||
background: #0f766e;
|
||||
border-radius: 0.9rem;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 800;
|
||||
height: 3rem;
|
||||
justify-content: center;
|
||||
max-width: 4rem;
|
||||
min-width: 3rem;
|
||||
padding: 0 0.45rem;
|
||||
}
|
||||
|
||||
.message-attachment-file-main {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.message-attachment-unavailable {
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(254, 242, 242, 0.96), rgba(255, 247, 237, 0.96));
|
||||
color: #7f1d1d;
|
||||
display: flex;
|
||||
font-weight: 700;
|
||||
justify-content: center;
|
||||
min-height: 12rem;
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.attachment-viewer-modal {
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.attachment-viewer-card {
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 1.2rem;
|
||||
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
max-height: 92vh;
|
||||
max-width: min(94vw, 68rem);
|
||||
padding: 0.9rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attachment-viewer-head,
|
||||
.attachment-viewer-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.attachment-viewer-title {
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-viewer-body {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
.attachment-viewer-media {
|
||||
background: #020617;
|
||||
border-radius: 0.8rem;
|
||||
max-height: 76vh;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.message-attachment-carousel {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-attachment-carousel-viewport,
|
||||
.message-attachment-slide,
|
||||
.message-attachment-media-frame,
|
||||
.message-attachment-unavailable {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.message-attachment-media {
|
||||
height: min(42vh, 16rem);
|
||||
}
|
||||
|
||||
.message-attachment-file-card {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.message-attachment-file-card .message-attachment-download {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user