SHA256
Добавить Turbo-загрузку и превью для вложений
This commit is contained in:
@@ -141,7 +141,7 @@
|
|||||||
|
|
||||||
### Вложения в сообщениях
|
### Вложения в сообщениях
|
||||||
|
|
||||||
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `SHiNE:attach v=1`.
|
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `SHiNE:attach v=1` или `v=2`.
|
||||||
|
|
||||||
Пример текстового содержимого body:
|
Пример текстового содержимого body:
|
||||||
|
|
||||||
@@ -151,6 +151,12 @@
|
|||||||
Текст сообщения
|
Текст сообщения
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Пример вложения с отдельным preview-файлом:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
|
||||||
|
```
|
||||||
|
|
||||||
Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`.
|
Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`.
|
||||||
|
|
||||||
### Создание публичного канала с профилем
|
### Создание публичного канала с профилем
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ TEXT-тип хранит сообщения и редактирования.
|
|||||||
- Такой edit трактуется как логическое удаление содержимого сообщения.
|
- Такой edit трактуется как логическое удаление содержимого сообщения.
|
||||||
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
|
- Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет.
|
||||||
|
|
||||||
## Вложения в текстовых сообщениях (`SHiNE:attach v=1`)
|
## Вложения в текстовых сообщениях (`SHiNE:attach v=1/v=2`)
|
||||||
|
|
||||||
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` клиент может хранить вложения как технические строки в начале обычного `text`.
|
Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` клиент может хранить вложения как технические строки в начале обычного `text`.
|
||||||
|
|
||||||
@@ -59,15 +59,22 @@ TEXT-тип хранит сообщения и редактирования.
|
|||||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123...;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
|
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123...;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Для файлов с отдельным превью клиент может использовать расширенный вариант:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbb...;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeee...>
|
||||||
|
```
|
||||||
|
|
||||||
Несколько вложений идут подряд в самом начале текста, по одному блоку на строку. После последнего блока идёт обычный пользовательский текст.
|
Несколько вложений идут подряд в самом начале текста, по одному блоку на строку. После последнего блока идёт обычный пользовательский текст.
|
||||||
|
|
||||||
Обязательные поля:
|
Обязательные поля:
|
||||||
|
|
||||||
- `v=1`
|
- `v=1` или `v=2`
|
||||||
- `name` - имя файла, закодированное через `encodeURIComponent`
|
- `name` - имя файла, закодированное через `encodeURIComponent`
|
||||||
- `size` - размер файла в байтах
|
- `size` - размер файла в байтах
|
||||||
- `sha256` - SHA-256 исходного файла в hex
|
- `sha256` - SHA-256 исходного файла в hex
|
||||||
- `ar` - короткий Arweave Transaction ID, без полного URL
|
- `ar` - короткий Arweave Transaction ID, без полного URL
|
||||||
|
- для `v=2` дополнительно могут присутствовать `previewAr` и `previewSha256`
|
||||||
|
|
||||||
Правила клиента:
|
Правила клиента:
|
||||||
|
|
||||||
@@ -76,4 +83,4 @@ TEXT-тип хранит сообщения и редактирования.
|
|||||||
- старые клиенты без поддержки вложений могут показывать технические строки как обычный текст;
|
- старые клиенты без поддержки вложений могут показывать технические строки как обычный текст;
|
||||||
- пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок.
|
- пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок.
|
||||||
|
|
||||||
Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер и SHA-256.
|
Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `previewAr/previewSha256`.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Вложения в TEXT-сообщениях (`SHiNE:attach v=1`)
|
# Вложения в TEXT-сообщениях (`SHiNE:attach v=1/v=2`)
|
||||||
|
|
||||||
Документ фиксирует текущий формат вложений в текстовых блоках SHiNE.
|
Документ фиксирует текущий формат вложений в текстовых блоках SHiNE.
|
||||||
|
|
||||||
@@ -21,6 +21,12 @@
|
|||||||
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
|
<SHiNE:attach;v=1;name=report.pdf;size=845221;sha256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef;ar=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Блок с превью:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<SHiNE:attach;v=2;name=video.mp4;size=5820193;sha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;ar=CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC;previewAr=DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD;previewSha256=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee>
|
||||||
|
```
|
||||||
|
|
||||||
Несколько вложений идут подряд в самом начале текста:
|
Несколько вложений идут подряд в самом начале текста:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -43,6 +49,11 @@
|
|||||||
- `sha256` - SHA-256 исходного файла в hex, 64 символа;
|
- `sha256` - SHA-256 исходного файла в hex, 64 символа;
|
||||||
- `ar` - короткий Arweave Transaction ID, 43 символа, без gateway URL.
|
- `ar` - короткий Arweave Transaction ID, 43 символа, без gateway URL.
|
||||||
|
|
||||||
|
Дополнительные поля для `v=2`:
|
||||||
|
|
||||||
|
- `previewAr` - короткий Arweave Transaction ID файла-превью;
|
||||||
|
- `previewSha256` - SHA-256 файла-превью в hex.
|
||||||
|
|
||||||
## Хранение файла
|
## Хранение файла
|
||||||
|
|
||||||
Файл хранится вне блокчейна в Arweave. В блокчейне хранится только метаинформация, достаточная для отображения и проверки:
|
Файл хранится вне блокчейна в Arweave. В блокчейне хранится только метаинформация, достаточная для отображения и проверки:
|
||||||
@@ -52,6 +63,8 @@
|
|||||||
- SHA-256;
|
- SHA-256;
|
||||||
- Arweave `txId`.
|
- Arweave `txId`.
|
||||||
|
|
||||||
|
Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `previewAr/previewSha256`.
|
||||||
|
|
||||||
## Создание вложения в UI
|
## Создание вложения в UI
|
||||||
|
|
||||||
UI поддерживает три сценария:
|
UI поддерживает три сценария:
|
||||||
@@ -60,6 +73,15 @@ UI поддерживает три сценария:
|
|||||||
- ввести существующий `txId`, скачать файл через Arweave gateway и локально посчитать `size/sha256`;
|
- ввести существующий `txId`, скачать файл через Arweave gateway и локально посчитать `size/sha256`;
|
||||||
- выбрать файл из журнала файлов, загруженных в текущей браузерной сессии.
|
- выбрать файл из журнала файлов, загруженных в текущей браузерной сессии.
|
||||||
|
|
||||||
|
Для видеофайлов UI может по желанию пользователя автоматически создать превью из первого кадра. Технически клиент загружает два файла:
|
||||||
|
|
||||||
|
- основной видеофайл;
|
||||||
|
- отдельное изображение-превью.
|
||||||
|
|
||||||
|
После успешной загрузки в журнале хранится один элемент основного файла, но с полями `previewAr/previewSha256`. В UI такой элемент помечается как файл `С превью`.
|
||||||
|
|
||||||
|
При ручном добавлении существующего `txId` для видео UI также позволяет вручную указать `txId` файла-превью.
|
||||||
|
|
||||||
Журнал хранится только в `sessionStorage` текущего браузера и не является частью блокчейна.
|
Журнал хранится только в `sessionStorage` текущего браузера и не является частью блокчейна.
|
||||||
В настройках клиента есть отдельный экран `Загрузка файлов`: пользователь может заранее загрузить файл в Arweave без создания сообщения, увидеть компактную плитку с именем, размером, временем загрузки, `txId` и статусом доступности через gateway, а затем выбрать этот файл из истории при создании сообщения.
|
В настройках клиента есть отдельный экран `Загрузка файлов`: пользователь может заранее загрузить файл в Arweave без создания сообщения, увидеть компактную плитку с именем, размером, временем загрузки, `txId` и статусом доступности через gateway, а затем выбрать этот файл из истории при создании сообщения.
|
||||||
Если файл загружен заранее, но ещё не был отправлен ни в одном сообщении SHiNE, клиент показывает локальный флаг `Не добавлен в SHiNE`. Флаг снимается после успешной отправки сообщения с этим вложением.
|
Если файл загружен заранее, но ещё не был отправлен ни в одном сообщении SHiNE, клиент показывает локальный флаг `Не добавлен в SHiNE`. Флаг снимается после успешной отправки сообщения с этим вложением.
|
||||||
@@ -77,8 +99,9 @@ UI поддерживает три сценария:
|
|||||||
- изображение показывает как ограниченное по размеру превью;
|
- изображение показывает как ограниченное по размеру превью;
|
||||||
- видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию;
|
- видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию;
|
||||||
- обычный файл показывает как карточку с именем, расширением, размером и скачиванием;
|
- обычный файл показывает как карточку с именем, расширением, размером и скачиванием;
|
||||||
6. при перелистывании останавливает воспроизводящееся видео;
|
6. если у видео есть `previewAr/previewSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок;
|
||||||
7. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение.
|
7. при перелистывании останавливает воспроизводящееся видео;
|
||||||
|
8. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение.
|
||||||
|
|
||||||
Если файл по ссылке Arweave gateway недоступен, клиент показывает диагностический блок:
|
Если файл по ссылке Arweave gateway недоступен, клиент показывает диагностический блок:
|
||||||
|
|
||||||
@@ -91,9 +114,11 @@ UI поддерживает три сценария:
|
|||||||
|
|
||||||
При редактировании сообщения UI сохраняет существующие attach-блоки и меняет только пользовательский текст. Удаление сообщения по-прежнему выполняется через edit-блок с пустым текстом, как описано в `11_TEXT_Blocks.md`.
|
При редактировании сообщения UI сохраняет существующие attach-блоки и меняет только пользовательский текст. Удаление сообщения по-прежнему выполняется через edit-блок с пустым текстом, как описано в `11_TEXT_Blocks.md`.
|
||||||
|
|
||||||
## Ограничения v1
|
## Совместимость и ограничения
|
||||||
|
|
||||||
- `ar` допускает только короткий Arweave `txId`, полный URL не используется;
|
- `ar` допускает только короткий Arweave `txId`, полный URL не используется;
|
||||||
- MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла;
|
- MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла;
|
||||||
- MIME type, width, height, duration и thumbnail не записываются в блокчейн;
|
- старые блоки `v=1` без превью остаются валидными и читаются без изменений;
|
||||||
|
- `previewAr/previewSha256` используются только если присутствуют оба поля и оба валидны;
|
||||||
|
- MIME type, width, height, duration и thumbnail не записываются в блокчейн отдельными полями;
|
||||||
- проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки.
|
- проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# История изменений документации блокчейна
|
# История изменений документации блокчейна
|
||||||
|
|
||||||
|
## 2026-08-04 12:00:00 +0400
|
||||||
|
- Базовый коммит-ориентир: `391b18a`.
|
||||||
|
- Формат вложений расширен до `SHiNE:attach v=2` для опционального второго preview-файла: добавлены поля `previewAr` и `previewSha256` при сохранении совместимости со старыми `v=1`.
|
||||||
|
- В UI загрузки для видео добавлен сценарий `Добавить превью`: клиент может извлечь первый кадр, загрузить его отдельным файлом в Arweave или через Turbo и сохранить связь preview с основным вложением.
|
||||||
|
- В журнале загрузок файлы с preview помечаются явно и показываются расширенной плиткой с картинкой превью.
|
||||||
|
|
||||||
## 2026-07-31 17:22:07 +0400
|
## 2026-07-31 17:22:07 +0400
|
||||||
- Базовый коммит-ориентир: `05bd9f5`.
|
- Базовый коммит-ориентир: `05bd9f5`.
|
||||||
- Уточнён UI вложений: превью последнего сообщения в списке каналов скрывает технические attach-блоки, карусель не показывает неактивные стрелки на краях, обычный файл отображается компактной кликабельной полоской без `txId`; формат attach-блока не изменён.
|
- Уточнён UI вложений: превью последнего сообщения в списке каналов скрывает технические attach-блоки, карусель не показывает неактивные стрелки на краях, обычный файл отображается компактной кликабельной полоской без `txId`; формат attach-блока не изменён.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
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`).
|
Параметры пользователя (`msg_type=4`).
|
||||||
8. [15_TEXT_Attachments.md](./15_TEXT_Attachments.md)
|
8. [15_TEXT_Attachments.md](./15_TEXT_Attachments.md)
|
||||||
Вложения в TEXT-сообщениях через `SHiNE:attach v=1`.
|
Вложения в TEXT-сообщениях через `SHiNE:attach v=1/v=2`, включая опциональные `previewAr/previewSha256` для видео и крупных изображений.
|
||||||
9. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md)
|
9. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md)
|
||||||
Скрытый `TEXT_CHANNEL_META` для профиля канала.
|
Скрытый `TEXT_CHANNEL_META` для профиля канала.
|
||||||
10. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
|
10. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
|
||||||
|
|||||||
@@ -7,10 +7,20 @@ import {
|
|||||||
validateArweaveTxId,
|
validateArweaveTxId,
|
||||||
} from '../services/arweave-file-service.js';
|
} from '../services/arweave-file-service.js';
|
||||||
import { addArweaveWalletSecret, getArweaveBalance, getArweaveWalletChoices } from '../services/arweave-wallet-service.js';
|
import { addArweaveWalletSecret, getArweaveBalance, getArweaveWalletChoices } from '../services/arweave-wallet-service.js';
|
||||||
import { escapeHtml, formatBytes, normalizeAttachment } from '../services/attachment-format.js';
|
import { escapeHtml, formatBytes, getAttachmentKind, normalizeAttachment } from '../services/attachment-format.js';
|
||||||
|
import { getStoredSolanaWalletChoices } from '../services/solana-wallet-service.js';
|
||||||
|
import {
|
||||||
|
estimateTurboUploadPrice,
|
||||||
|
formatTurboCredits,
|
||||||
|
getFreeTurboUploadBytesLimit,
|
||||||
|
getTurboBalanceForStoredSolanaKey,
|
||||||
|
topUpTurboWithSolana,
|
||||||
|
uploadFileWithTurbo,
|
||||||
|
} from '../services/turbo-upload-service.js';
|
||||||
|
|
||||||
const HISTORY_KEY = 'shine-ui-arweave-attachment-history-v1';
|
const HISTORY_KEY = 'shine-ui-arweave-attachment-history-v1';
|
||||||
const LAST_WALLET_KEY_PREFIX = 'shine-ui-arweave-last-wallet-v1:';
|
const LAST_WALLET_KEY_PREFIX = 'shine-ui-arweave-last-wallet-v1:';
|
||||||
|
const LAST_TURBO_KEY_SOURCE_PREFIX = 'shine-ui-turbo-last-key-source-v1:';
|
||||||
const MAX_HISTORY_ITEMS = 100;
|
const MAX_HISTORY_ITEMS = 100;
|
||||||
const RECENT_UPLOAD_MS = 20 * 60 * 1000;
|
const RECENT_UPLOAD_MS = 20 * 60 * 1000;
|
||||||
|
|
||||||
@@ -26,6 +36,26 @@ function normalizeHistoryItem(input = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasAttachmentPreview(item) {
|
||||||
|
return !!String(item?.preview?.ar || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoAttachmentName(name) {
|
||||||
|
return getAttachmentKind({ name }) === 'video';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHistoryPreviewMarkup(item, gateway) {
|
||||||
|
if (!hasAttachmentPreview(item)) return '';
|
||||||
|
const previewTxId = String(item.preview.ar || '').trim();
|
||||||
|
const previewUrl = buildArweaveDataUrl({ gateway, txId: previewTxId });
|
||||||
|
return `
|
||||||
|
<div class="ar-attachment-history-preview">
|
||||||
|
<img class="ar-attachment-history-preview-image" src="${escapeHtml(previewUrl)}" alt="Превью ${escapeHtml(item.name)}" loading="lazy" />
|
||||||
|
<div class="ar-attachment-history-preview-badge">Есть превью</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
export function readArweaveAttachmentHistory(login) {
|
export function readArweaveAttachmentHistory(login) {
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(HISTORY_KEY);
|
const raw = sessionStorage.getItem(HISTORY_KEY);
|
||||||
@@ -140,6 +170,10 @@ function lastWalletStorageKey(login) {
|
|||||||
return `${LAST_WALLET_KEY_PREFIX}${String(login || '').trim().toLowerCase() || 'anonymous'}`;
|
return `${LAST_WALLET_KEY_PREFIX}${String(login || '').trim().toLowerCase() || 'anonymous'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lastTurboKeySourceStorageKey(login) {
|
||||||
|
return `${LAST_TURBO_KEY_SOURCE_PREFIX}${String(login || '').trim().toLowerCase() || 'anonymous'}`;
|
||||||
|
}
|
||||||
|
|
||||||
function readLastWalletId(login) {
|
function readLastWalletId(login) {
|
||||||
try {
|
try {
|
||||||
return sessionStorage.getItem(lastWalletStorageKey(login)) || '';
|
return sessionStorage.getItem(lastWalletStorageKey(login)) || '';
|
||||||
@@ -157,6 +191,23 @@ function writeLastWalletId(login, walletId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readLastTurboKeySource(login, fallback = 'client') {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(lastTurboKeySourceStorageKey(login)) || String(fallback || 'client');
|
||||||
|
} catch {
|
||||||
|
return String(fallback || 'client');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLastTurboKeySource(login, keySource) {
|
||||||
|
try {
|
||||||
|
const clean = String(keySource || '').trim() || 'client';
|
||||||
|
sessionStorage.setItem(lastTurboKeySourceStorageKey(login), clean);
|
||||||
|
} catch {
|
||||||
|
// ignore sessionStorage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resizeImageFileToAvatar(file) {
|
async function resizeImageFileToAvatar(file) {
|
||||||
const source = file instanceof File ? file : null;
|
const source = file instanceof File ? file : null;
|
||||||
if (!source) throw new Error('Выберите изображение.');
|
if (!source) throw new Error('Выберите изображение.');
|
||||||
@@ -187,6 +238,45 @@ async function resizeImageFileToAvatar(file) {
|
|||||||
return new File([blob], 'avatar.jpg', { type: 'image/jpeg', lastModified: Date.now() });
|
return new File([blob], 'avatar.jpg', { type: 'image/jpeg', lastModified: Date.now() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createVideoPreviewFile(file) {
|
||||||
|
const source = file instanceof File ? file : null;
|
||||||
|
if (!source) throw new Error('Выберите видеофайл.');
|
||||||
|
const blobUrl = URL.createObjectURL(source);
|
||||||
|
try {
|
||||||
|
const video = await new Promise((resolve, reject) => {
|
||||||
|
const node = document.createElement('video');
|
||||||
|
node.preload = 'metadata';
|
||||||
|
node.muted = true;
|
||||||
|
node.playsInline = true;
|
||||||
|
node.onloadeddata = () => resolve(node);
|
||||||
|
node.onerror = () => reject(new Error('Не удалось открыть видео для создания превью.'));
|
||||||
|
node.src = blobUrl;
|
||||||
|
});
|
||||||
|
const width = Number(video.videoWidth || 0);
|
||||||
|
const height = Number(video.videoHeight || 0);
|
||||||
|
if (!width || !height) {
|
||||||
|
throw new Error('Не удалось определить размер видео для превью.');
|
||||||
|
}
|
||||||
|
const maxSide = 640;
|
||||||
|
const scale = Math.min(1, maxSide / Math.max(width, height));
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.max(1, Math.round(width * scale));
|
||||||
|
canvas.height = Math.max(1, Math.round(height * scale));
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('Canvas недоступен для создания превью.');
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.86));
|
||||||
|
if (!blob) throw new Error('Не удалось собрать preview image.');
|
||||||
|
const baseName = String(source.name || 'video').replace(/\.[^.]+$/u, '') || 'video';
|
||||||
|
return new File([blob], `${baseName}-preview.jpg`, {
|
||||||
|
type: 'image/jpeg',
|
||||||
|
lastModified: Date.now(),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function openArweaveAttachmentManager({
|
export function openArweaveAttachmentManager({
|
||||||
login,
|
login,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
@@ -196,6 +286,8 @@ export function openArweaveAttachmentManager({
|
|||||||
historyOnly = false,
|
historyOnly = false,
|
||||||
mode = 'attachment',
|
mode = 'attachment',
|
||||||
historyPurpose = '',
|
historyPurpose = '',
|
||||||
|
uploadTransport = 'arweave',
|
||||||
|
turboKeySource = 'client',
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
const cleanStoragePwd = String(storagePwd || '').trim();
|
const cleanStoragePwd = String(storagePwd || '').trim();
|
||||||
@@ -208,9 +300,15 @@ export function openArweaveAttachmentManager({
|
|||||||
|
|
||||||
let closed = false;
|
let closed = false;
|
||||||
let wallets = [];
|
let wallets = [];
|
||||||
|
let turboKeyChoices = [];
|
||||||
let selectedWalletId = readLastWalletId(cleanLogin) || 'derived-client-key';
|
let selectedWalletId = readLastWalletId(cleanLogin) || 'derived-client-key';
|
||||||
|
let selectedTurboKeySource = readLastTurboKeySource(cleanLogin, turboKeySource);
|
||||||
|
let selectedUploadTransport = String(uploadTransport || '').trim().toLowerCase() === 'turbo' ? 'turbo' : 'arweave';
|
||||||
let selectedFile = null;
|
let selectedFile = null;
|
||||||
let selectedSha256 = '';
|
let selectedSha256 = '';
|
||||||
|
let selectedPreviewEnabled = false;
|
||||||
|
let selectedPreviewFile = null;
|
||||||
|
let selectedPreviewSha256 = '';
|
||||||
let priceInfo = null;
|
let priceInfo = null;
|
||||||
let balanceInfo = null;
|
let balanceInfo = null;
|
||||||
let autoOpenedFileDialog = false;
|
let autoOpenedFileDialog = false;
|
||||||
@@ -219,6 +317,10 @@ export function openArweaveAttachmentManager({
|
|||||||
const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment';
|
const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment';
|
||||||
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
|
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
|
||||||
|
|
||||||
|
function isTurboUpload() {
|
||||||
|
return selectedUploadTransport === 'turbo';
|
||||||
|
}
|
||||||
|
|
||||||
function selectedWallet() {
|
function selectedWallet() {
|
||||||
return wallets.find((item) => String(item.id) === String(selectedWalletId)) || wallets[0] || null;
|
return wallets.find((item) => String(item.id) === String(selectedWalletId)) || wallets[0] || null;
|
||||||
}
|
}
|
||||||
@@ -263,22 +365,240 @@ export function openArweaveAttachmentManager({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadTurboKeyChoices = async (errorEl = null) => {
|
||||||
|
try {
|
||||||
|
turboKeyChoices = await getStoredSolanaWalletChoices({
|
||||||
|
login: cleanLogin,
|
||||||
|
storagePwd: cleanStoragePwd,
|
||||||
|
includeRoot: true,
|
||||||
|
});
|
||||||
|
turboKeyChoices.sort((a, b) => {
|
||||||
|
if (a?.keySource === b?.keySource) return 0;
|
||||||
|
if (a?.keySource === 'client') return -1;
|
||||||
|
if (b?.keySource === 'client') return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
if (!turboKeyChoices.some((item) => String(item.keySource) === String(selectedTurboKeySource))) {
|
||||||
|
selectedTurboKeySource = String(
|
||||||
|
turboKeyChoices.find((item) => item.keySource === 'client')?.keySource
|
||||||
|
|| turboKeyChoices[0]?.keySource
|
||||||
|
|| 'client'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeLastTurboKeySource(cleanLogin, selectedTurboKeySource);
|
||||||
|
} catch (error) {
|
||||||
|
setText(errorEl, error?.message || 'Не удалось загрузить Solana-ключи для Turbo.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTurboMeta = (metaEl) => {
|
||||||
|
if (!metaEl) return;
|
||||||
|
const turboAddress = String(balanceInfo?.address || priceInfo?.address || '').trim();
|
||||||
|
const turboCredits = balanceInfo ? formatTurboCredits(balanceInfo.effectiveCredits, 6) : '—';
|
||||||
|
const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '—';
|
||||||
|
const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '—';
|
||||||
|
const fileSize = selectedFile ? formatBytes(selectedFile.size) : '—';
|
||||||
|
const previewSize = selectedPreviewEnabled && selectedPreviewFile ? formatBytes(selectedPreviewFile.size) : '';
|
||||||
|
const freeHint = selectedFile
|
||||||
|
? (combinedUploadSize() <= getFreeTurboUploadBytesLimit() ? 'Да, бесплатно через Turbo' : 'Нет, нужен Turbo balance')
|
||||||
|
: `До ${formatBytes(getFreeTurboUploadBytesLimit())} бесплатно`;
|
||||||
|
const freeBadge = priceInfo?.isFree
|
||||||
|
? ' <span style="color:#15803d;">(пока файлы до 100 KB бесплатно)</span>'
|
||||||
|
: '';
|
||||||
|
metaEl.innerHTML = `
|
||||||
|
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
|
||||||
|
<div>Turbo balance: ${escapeHtml(turboCredits)}</div>
|
||||||
|
<div>SOL на этом ключе: ${solBalance}</div>
|
||||||
|
<div>Размер файла: ${escapeHtml(fileSize)}</div>
|
||||||
|
${previewSize ? `<div>Размер превью: ${escapeHtml(previewSize)}</div>` : ''}
|
||||||
|
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
|
||||||
|
${selectedPreviewEnabled && selectedPreviewSha256 ? `<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256)}</div>` : ''}
|
||||||
|
<div>Цена Turbo: ${escapeHtml(uploadPrice)}${freeBadge}</div>
|
||||||
|
<div>До ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))} бесплатно: ${escapeHtml(freeHint)}</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshTurboBalance = async (metaEl, errorEl) => {
|
||||||
|
try {
|
||||||
|
balanceInfo = await getTurboBalanceForStoredSolanaKey({
|
||||||
|
login: cleanLogin,
|
||||||
|
storagePwd: cleanStoragePwd,
|
||||||
|
keySource: selectedTurboKeySource,
|
||||||
|
});
|
||||||
|
renderTurboMeta(metaEl);
|
||||||
|
if (errorEl && String(errorEl.textContent || '').includes('Turbo balance')) {
|
||||||
|
setText(errorEl, '');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setText(errorEl, error?.message || 'Не удалось получить Turbo balance.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const promptTurboTopUp = async (metaEl, errorEl) => {
|
||||||
|
const value = window.prompt('Сколько SOL перевести в Turbo?', '0.01');
|
||||||
|
if (value == null) return;
|
||||||
|
setText(errorEl, 'Пополняем Turbo...');
|
||||||
|
try {
|
||||||
|
const result = await topUpTurboWithSolana({
|
||||||
|
login: cleanLogin,
|
||||||
|
storagePwd: cleanStoragePwd,
|
||||||
|
keySource: selectedTurboKeySource,
|
||||||
|
amountSol: value,
|
||||||
|
});
|
||||||
|
await refreshTurboBalance(metaEl, errorEl);
|
||||||
|
setText(errorEl, `Turbo пополнен. Tx: ${result.txId || 'отправлено'}`);
|
||||||
|
} catch (error) {
|
||||||
|
setText(errorEl, error?.message || 'Не удалось пополнить Turbo.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetPreviewState = () => {
|
||||||
|
selectedPreviewEnabled = false;
|
||||||
|
selectedPreviewFile = null;
|
||||||
|
selectedPreviewSha256 = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPreviewEligibleForCurrentFile = () => (
|
||||||
|
!isAvatarMode
|
||||||
|
&& selectedFile instanceof File
|
||||||
|
&& isVideoAttachmentName(selectedFile.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
const combinedUploadSize = () => {
|
||||||
|
return Number(selectedFile?.size || 0) + Number(selectedPreviewEnabled ? (selectedPreviewFile?.size || 0) : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderArweaveMeta = (metaEl) => {
|
||||||
|
if (!metaEl) return;
|
||||||
|
metaEl.innerHTML = `
|
||||||
|
${isAvatarMode ? '' : `<div>Имя: ${escapeHtml(selectedFile?.name || 'file')}</div>`}
|
||||||
|
<div>Размер: ${escapeHtml(formatBytes(selectedFile?.size || 0))}</div>
|
||||||
|
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
|
||||||
|
${selectedPreviewEnabled && selectedPreviewFile
|
||||||
|
? `<div>Превью: ${escapeHtml(selectedPreviewFile.name)} · ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>`
|
||||||
|
: ''}
|
||||||
|
${selectedPreviewEnabled && selectedPreviewSha256
|
||||||
|
? `<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256)}</div>`
|
||||||
|
: ''}
|
||||||
|
<div>Цена: ${escapeHtml(Number(priceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
|
||||||
|
<div>Баланс кошелька: ${escapeHtml(Number(balanceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const recalculateArweaveState = async (metaEl, errorEl, uploadBtn) => {
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
priceInfo = null;
|
||||||
|
balanceInfo = null;
|
||||||
|
setText(errorEl, '');
|
||||||
|
if (!selectedFile || !selectedSha256) {
|
||||||
|
setText(metaEl, '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wallet = selectedWallet();
|
||||||
|
if (!wallet?.address) throw new Error('Выберите Arweave-кошелёк.');
|
||||||
|
const mainPrice = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedFile.size });
|
||||||
|
let totalWinston = BigInt(mainPrice.winston);
|
||||||
|
if (selectedPreviewEnabled && selectedPreviewFile) {
|
||||||
|
const previewPrice = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedPreviewFile.size });
|
||||||
|
totalWinston += BigInt(previewPrice.winston);
|
||||||
|
}
|
||||||
|
balanceInfo = await getArweaveBalance({ gateway: cleanGateway, address: wallet.address });
|
||||||
|
priceInfo = {
|
||||||
|
ar: Number(totalWinston) / 1e12,
|
||||||
|
winston: totalWinston.toString(),
|
||||||
|
};
|
||||||
|
renderArweaveMeta(metaEl);
|
||||||
|
const hasFunds = BigInt(balanceInfo.winston) >= totalWinston;
|
||||||
|
if (!hasFunds) {
|
||||||
|
setText(errorEl, 'Недостаточно AR на выбранном кошельке.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshTurboStateForCurrentFile = async (metaEl, errorEl, uploadBtn) => {
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
priceInfo = null;
|
||||||
|
balanceInfo = null;
|
||||||
|
setText(errorEl, '');
|
||||||
|
if (!selectedFile || !selectedSha256) {
|
||||||
|
renderTurboMeta(metaEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const mainPrice = await estimateTurboUploadPrice({
|
||||||
|
login: cleanLogin,
|
||||||
|
storagePwd: cleanStoragePwd,
|
||||||
|
keySource: selectedTurboKeySource,
|
||||||
|
byteLength: selectedFile.size,
|
||||||
|
});
|
||||||
|
let totalWinc = BigInt(mainPrice.winc);
|
||||||
|
if (selectedPreviewEnabled && selectedPreviewFile) {
|
||||||
|
const previewPrice = await estimateTurboUploadPrice({
|
||||||
|
login: cleanLogin,
|
||||||
|
storagePwd: cleanStoragePwd,
|
||||||
|
keySource: selectedTurboKeySource,
|
||||||
|
byteLength: selectedPreviewFile.size,
|
||||||
|
});
|
||||||
|
totalWinc += BigInt(previewPrice.winc);
|
||||||
|
}
|
||||||
|
priceInfo = {
|
||||||
|
...mainPrice,
|
||||||
|
byteLength: combinedUploadSize(),
|
||||||
|
isFree: combinedUploadSize() <= getFreeTurboUploadBytesLimit(),
|
||||||
|
winc: totalWinc.toString(),
|
||||||
|
credits: Number(totalWinc) / 1_000_000_000_000,
|
||||||
|
};
|
||||||
|
balanceInfo = await getTurboBalanceForStoredSolanaKey({
|
||||||
|
login: cleanLogin,
|
||||||
|
storagePwd: cleanStoragePwd,
|
||||||
|
keySource: selectedTurboKeySource,
|
||||||
|
});
|
||||||
|
renderTurboMeta(metaEl);
|
||||||
|
if (!priceInfo.isFree && BigInt(balanceInfo.effectiveWinc) < BigInt(priceInfo.winc)) {
|
||||||
|
setText(errorEl, 'Turbo balance недостаточен. Нажмите «Пополнить Turbo».');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
} catch (error) {
|
||||||
|
setText(errorEl, error?.message || 'Не удалось обновить цену Turbo.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const showUpload = async () => {
|
const showUpload = async () => {
|
||||||
|
const turboMode = isTurboUpload();
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal" data-ar-attach-modal="true">
|
<div class="modal" data-ar-attach-modal="true">
|
||||||
<div class="modal-card stack ar-attachment-manager-card">
|
<div class="modal-card stack ar-attachment-manager-card">
|
||||||
<h3 class="modal-title">${isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')}</h3>
|
<h3 class="modal-title">${turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'))}</h3>
|
||||||
|
<div class="form-actions-grid">
|
||||||
|
<button class="${turboMode ? 'secondary-btn' : 'primary-btn'}" type="button" data-action="switch-arweave">Загрузка используя свой Arweave кошелёк</button>
|
||||||
|
<button class="${turboMode ? 'primary-btn' : 'secondary-btn'}" type="button" data-action="switch-turbo">Загрузить через Turbo</button>
|
||||||
|
${isAvatarMode ? '' : '<button class="secondary-btn" type="button" data-action="history-top">Использовать журнал загрузок</button>'}
|
||||||
|
${isAvatarMode ? '' : '<button class="secondary-btn" type="button" data-action="existing-top">Использовать существующий в Arweave файл</button>'}
|
||||||
|
</div>
|
||||||
|
${turboMode
|
||||||
|
? `
|
||||||
|
<label class="meta-muted" for="turbo-key-source">Подписывать и пополнять через</label>
|
||||||
|
<select class="input ar-attachment-wallet-select" id="turbo-key-source"></select>
|
||||||
|
<p class="meta-muted">Turbo использует Solana-ключ пользователя. Файлы до 100 KB пока бесплатно. Если файл больше, пополните Turbo со своего SOL-кошелька.</p>
|
||||||
|
`
|
||||||
|
: `
|
||||||
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
|
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
|
||||||
<select class="input ar-attachment-wallet-select" id="ar-attach-wallet"></select>
|
<select class="input ar-attachment-wallet-select" id="ar-attach-wallet"></select>
|
||||||
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
|
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
|
||||||
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
|
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
|
||||||
|
`}
|
||||||
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
|
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
|
||||||
<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
|
<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
|
||||||
|
<label class="meta-muted" data-preview-option="true" hidden>
|
||||||
|
<input type="checkbox" data-preview-toggle="true" />
|
||||||
|
Добавить превью
|
||||||
|
</label>
|
||||||
<div class="ar-attachment-meta" data-meta="true"></div>
|
<div class="ar-attachment-meta" data-meta="true"></div>
|
||||||
<p class="meta-muted inline-error" data-error="true"></p>
|
<p class="meta-muted inline-error" data-error="true"></p>
|
||||||
<div class="form-actions-grid">
|
<div class="form-actions-grid">
|
||||||
${historyOnly || isAvatarMode ? '' : '<button class="secondary-btn" type="button" data-action="existing">Ввести txId</button>'}
|
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : ''}
|
||||||
${historyOnly ? '' : `<button class="secondary-btn" type="button" data-action="history">${isAvatarMode ? 'История аватаров' : 'История загрузок'}</button>`}
|
|
||||||
<button class="primary-btn" type="button" data-action="upload" disabled>${historyOnly ? 'Загрузить в журнал' : 'Загрузить'}</button>
|
<button class="primary-btn" type="button" data-action="upload" disabled>${historyOnly ? 'Загрузить в журнал' : 'Загрузить'}</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
||||||
@@ -287,11 +607,52 @@ export function openArweaveAttachmentManager({
|
|||||||
`;
|
`;
|
||||||
bindBackdrop();
|
bindBackdrop();
|
||||||
const walletEl = root.querySelector('#ar-attach-wallet');
|
const walletEl = root.querySelector('#ar-attach-wallet');
|
||||||
|
const turboKeyEl = root.querySelector('#turbo-key-source');
|
||||||
const fileEl = root.querySelector('#ar-attach-file');
|
const fileEl = root.querySelector('#ar-attach-file');
|
||||||
const metaEl = root.querySelector('[data-meta="true"]');
|
const metaEl = root.querySelector('[data-meta="true"]');
|
||||||
const errorEl = root.querySelector('[data-error="true"]');
|
const errorEl = root.querySelector('[data-error="true"]');
|
||||||
const uploadBtn = root.querySelector('[data-action="upload"]');
|
const uploadBtn = root.querySelector('[data-action="upload"]');
|
||||||
|
const previewOptionEl = root.querySelector('[data-preview-option="true"]');
|
||||||
|
const previewToggleEl = root.querySelector('[data-preview-toggle="true"]');
|
||||||
|
|
||||||
|
root.querySelector('[data-action="switch-arweave"]')?.addEventListener('click', async () => {
|
||||||
|
if (selectedUploadTransport === 'arweave') return;
|
||||||
|
selectedUploadTransport = 'arweave';
|
||||||
|
selectedFile = null;
|
||||||
|
selectedSha256 = '';
|
||||||
|
resetPreviewState();
|
||||||
|
priceInfo = null;
|
||||||
|
balanceInfo = null;
|
||||||
|
await showUpload();
|
||||||
|
});
|
||||||
|
root.querySelector('[data-action="switch-turbo"]')?.addEventListener('click', async () => {
|
||||||
|
if (selectedUploadTransport === 'turbo') return;
|
||||||
|
selectedUploadTransport = 'turbo';
|
||||||
|
selectedFile = null;
|
||||||
|
selectedSha256 = '';
|
||||||
|
resetPreviewState();
|
||||||
|
priceInfo = null;
|
||||||
|
balanceInfo = null;
|
||||||
|
await showUpload();
|
||||||
|
});
|
||||||
|
root.querySelector('[data-action="history-top"]')?.addEventListener('click', showHistory);
|
||||||
|
root.querySelector('[data-action="existing-top"]')?.addEventListener('click', showExisting);
|
||||||
|
|
||||||
|
if (turboMode) {
|
||||||
|
await loadTurboKeyChoices(errorEl);
|
||||||
|
if (turboKeyEl) {
|
||||||
|
turboKeyEl.innerHTML = turboKeyChoices.map((item) => (
|
||||||
|
`<option value="${escapeHtml(item.keySource)}">${escapeHtml(item.label)} · ${escapeHtml(shortAddress(item.address))}</option>`
|
||||||
|
)).join('');
|
||||||
|
turboKeyEl.value = selectedTurboKeySource;
|
||||||
|
turboKeyEl.addEventListener('change', async () => {
|
||||||
|
selectedTurboKeySource = String(turboKeyEl.value || 'client');
|
||||||
|
writeLastTurboKeySource(cleanLogin, selectedTurboKeySource);
|
||||||
|
await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn);
|
||||||
|
} else {
|
||||||
await loadWallets(errorEl);
|
await loadWallets(errorEl);
|
||||||
if (walletEl) {
|
if (walletEl) {
|
||||||
walletEl.innerHTML = renderWalletOptions();
|
walletEl.innerHTML = renderWalletOptions();
|
||||||
@@ -302,31 +663,40 @@ export function openArweaveAttachmentManager({
|
|||||||
uploadBtn.disabled = true;
|
uploadBtn.disabled = true;
|
||||||
selectedFile = null;
|
selectedFile = null;
|
||||||
selectedSha256 = '';
|
selectedSha256 = '';
|
||||||
|
resetPreviewState();
|
||||||
priceInfo = null;
|
priceInfo = null;
|
||||||
balanceInfo = null;
|
balanceInfo = null;
|
||||||
if (fileEl) fileEl.value = '';
|
if (fileEl) fileEl.value = '';
|
||||||
setText(metaEl, '');
|
setText(metaEl, '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(resolve, null));
|
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(resolve, null));
|
||||||
root.querySelector('[data-action="existing"]')?.addEventListener('click', showExisting);
|
root.querySelector('[data-action="topup"]')?.addEventListener('click', async () => {
|
||||||
root.querySelector('[data-action="history"]')?.addEventListener('click', showHistory);
|
await promptTurboTopUp(metaEl, errorEl);
|
||||||
|
if (turboMode && selectedFile && selectedSha256) {
|
||||||
|
await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!turboMode) {
|
||||||
root.querySelector('[data-action="add-wallet"]')?.addEventListener('click', showAddWallet);
|
root.querySelector('[data-action="add-wallet"]')?.addEventListener('click', showAddWallet);
|
||||||
|
}
|
||||||
|
|
||||||
fileEl?.addEventListener('change', async () => {
|
fileEl?.addEventListener('change', async () => {
|
||||||
selectedFile = fileEl.files?.[0] || null;
|
selectedFile = fileEl.files?.[0] || null;
|
||||||
selectedSha256 = '';
|
selectedSha256 = '';
|
||||||
|
resetPreviewState();
|
||||||
priceInfo = null;
|
priceInfo = null;
|
||||||
balanceInfo = null;
|
balanceInfo = null;
|
||||||
uploadBtn.disabled = true;
|
uploadBtn.disabled = true;
|
||||||
setText(errorEl, '');
|
setText(errorEl, '');
|
||||||
setText(metaEl, '');
|
setText(metaEl, '');
|
||||||
|
if (previewToggleEl instanceof HTMLInputElement) previewToggleEl.checked = false;
|
||||||
|
if (previewOptionEl) previewOptionEl.hidden = true;
|
||||||
if (!selectedFile) return;
|
if (!selectedFile) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wallet = selectedWallet();
|
|
||||||
if (!wallet?.address) throw new Error('Выберите Arweave-кошелёк.');
|
|
||||||
if (isAvatarMode && !String(selectedFile.type || '').startsWith('image/')) {
|
if (isAvatarMode && !String(selectedFile.type || '').startsWith('image/')) {
|
||||||
throw new Error('Для аватара можно выбрать только изображение.');
|
throw new Error('Для аватара можно выбрать только изображение.');
|
||||||
}
|
}
|
||||||
@@ -335,42 +705,102 @@ export function openArweaveAttachmentManager({
|
|||||||
}
|
}
|
||||||
const buffer = await selectedFile.arrayBuffer();
|
const buffer = await selectedFile.arrayBuffer();
|
||||||
selectedSha256 = await sha256HexFromArrayBuffer(buffer);
|
selectedSha256 = await sha256HexFromArrayBuffer(buffer);
|
||||||
priceInfo = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedFile.size });
|
if (previewOptionEl && isPreviewEligibleForCurrentFile()) {
|
||||||
balanceInfo = await getArweaveBalance({ gateway: cleanGateway, address: wallet.address });
|
previewOptionEl.hidden = false;
|
||||||
const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston);
|
}
|
||||||
metaEl.innerHTML = `
|
if (turboMode) {
|
||||||
${isAvatarMode ? '' : `<div>Имя: ${escapeHtml(selectedFile.name || 'file')}</div>`}
|
await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn);
|
||||||
<div>Размер: ${escapeHtml(formatBytes(selectedFile.size))}</div>
|
} else {
|
||||||
<div>SHA-256: ${escapeHtml(selectedSha256)}</div>
|
await recalculateArweaveState(metaEl, errorEl, uploadBtn);
|
||||||
<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) {
|
} catch (error) {
|
||||||
setText(errorEl, error?.message || 'Не удалось подготовить файл.');
|
setText(errorEl, error?.message || 'Не удалось подготовить файл.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
previewToggleEl?.addEventListener('change', async () => {
|
||||||
|
if (!(previewToggleEl instanceof HTMLInputElement)) return;
|
||||||
|
if (!selectedFile || !isPreviewEligibleForCurrentFile()) {
|
||||||
|
previewToggleEl.checked = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
selectedPreviewEnabled = previewToggleEl.checked;
|
||||||
|
selectedPreviewFile = null;
|
||||||
|
selectedPreviewSha256 = '';
|
||||||
|
setText(errorEl, '');
|
||||||
|
try {
|
||||||
|
if (selectedPreviewEnabled) {
|
||||||
|
selectedPreviewFile = await createVideoPreviewFile(selectedFile);
|
||||||
|
selectedPreviewSha256 = await sha256HexFromArrayBuffer(await selectedPreviewFile.arrayBuffer());
|
||||||
|
}
|
||||||
|
if (turboMode) {
|
||||||
|
await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn);
|
||||||
|
} else {
|
||||||
|
await recalculateArweaveState(metaEl, errorEl, uploadBtn);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
resetPreviewState();
|
||||||
|
previewToggleEl.checked = false;
|
||||||
|
setText(errorEl, error?.message || 'Не удалось подготовить превью.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
uploadBtn?.addEventListener('click', async () => {
|
uploadBtn?.addEventListener('click', async () => {
|
||||||
if (!selectedFile || !selectedSha256) {
|
if (!selectedFile || !selectedSha256) {
|
||||||
setText(errorEl, 'Выберите файл.');
|
setText(errorEl, 'Выберите файл.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!turboMode) {
|
||||||
const wallet = selectedWallet();
|
const wallet = selectedWallet();
|
||||||
if (!wallet?.jwk) {
|
if (!wallet?.jwk) {
|
||||||
setText(errorEl, 'Выберите Arweave-кошелёк.');
|
setText(errorEl, 'Выберите Arweave-кошелёк.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
uploadBtn.disabled = true;
|
uploadBtn.disabled = true;
|
||||||
setText(errorEl, 'Загружаем файл в Arweave...');
|
setText(errorEl, turboMode ? 'Загружаем файл через Turbo...' : 'Загружаем файл в Arweave...');
|
||||||
try {
|
try {
|
||||||
const uploaded = await uploadArweaveFile({
|
let previewUpload = null;
|
||||||
|
if (selectedPreviewEnabled && selectedPreviewFile && selectedPreviewSha256) {
|
||||||
|
previewUpload = turboMode
|
||||||
|
? await uploadFileWithTurbo({
|
||||||
|
login: cleanLogin,
|
||||||
|
storagePwd: cleanStoragePwd,
|
||||||
|
keySource: selectedTurboKeySource,
|
||||||
|
file: selectedPreviewFile,
|
||||||
|
shineType: 'attachment-preview',
|
||||||
|
tags: [
|
||||||
|
{ name: 'SHiNE-Login', value: cleanLogin },
|
||||||
|
{ name: 'SHiNE-Attachment-Preview-For', value: selectedFile.name || 'file' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
: await uploadArweaveFile({
|
||||||
gateway: cleanGateway,
|
gateway: cleanGateway,
|
||||||
jwk: wallet.jwk,
|
jwk: selectedWallet()?.jwk,
|
||||||
|
file: selectedPreviewFile,
|
||||||
|
shineType: 'attachment-preview',
|
||||||
|
tags: [
|
||||||
|
{ name: 'SHiNE-Login', value: cleanLogin },
|
||||||
|
{ name: 'SHiNE-Attachment-Preview-For', value: selectedFile.name || 'file' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const uploaded = turboMode
|
||||||
|
? await uploadFileWithTurbo({
|
||||||
|
login: cleanLogin,
|
||||||
|
storagePwd: cleanStoragePwd,
|
||||||
|
keySource: selectedTurboKeySource,
|
||||||
|
file: selectedFile,
|
||||||
|
shineType: isAvatarMode ? 'avatar' : 'attachment',
|
||||||
|
tags: [
|
||||||
|
{ name: 'SHiNE-Login', value: cleanLogin },
|
||||||
|
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
: await uploadArweaveFile({
|
||||||
|
gateway: cleanGateway,
|
||||||
|
jwk: selectedWallet()?.jwk,
|
||||||
file: selectedFile,
|
file: selectedFile,
|
||||||
shineType: isAvatarMode ? 'avatar' : 'attachment',
|
shineType: isAvatarMode ? 'avatar' : 'attachment',
|
||||||
tags: [
|
tags: [
|
||||||
@@ -383,12 +813,18 @@ export function openArweaveAttachmentManager({
|
|||||||
size: selectedFile.size,
|
size: selectedFile.size,
|
||||||
sha256: selectedSha256,
|
sha256: selectedSha256,
|
||||||
ar: uploaded.id,
|
ar: uploaded.id,
|
||||||
|
preview: previewUpload?.id && selectedPreviewSha256
|
||||||
|
? {
|
||||||
|
ar: previewUpload.id,
|
||||||
|
sha256: selectedPreviewSha256,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
uploadedAtMs: Date.now(),
|
uploadedAtMs: Date.now(),
|
||||||
purpose: isAvatarMode ? 'avatar' : 'attachment',
|
purpose: isAvatarMode ? 'avatar' : 'attachment',
|
||||||
}, { pendingPlacement: historyOnly && !isAvatarMode });
|
}, { pendingPlacement: historyOnly && !isAvatarMode });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
uploadBtn.disabled = false;
|
uploadBtn.disabled = false;
|
||||||
setText(errorEl, error?.message || 'Не удалось загрузить файл в Arweave.');
|
setText(errorEl, error?.message || (turboMode ? 'Не удалось загрузить файл через Turbo.' : 'Не удалось загрузить файл в Arweave.'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -407,6 +843,14 @@ export function openArweaveAttachmentManager({
|
|||||||
<input class="input" id="ar-existing-txid" type="text" maxlength="64" placeholder="43 символа txId" />
|
<input class="input" id="ar-existing-txid" type="text" maxlength="64" placeholder="43 символа txId" />
|
||||||
<label class="meta-muted" for="ar-existing-name">Имя файла в сообщении</label>
|
<label class="meta-muted" for="ar-existing-name">Имя файла в сообщении</label>
|
||||||
<input class="input" id="ar-existing-name" type="text" maxlength="180" placeholder="например report.pdf" />
|
<input class="input" id="ar-existing-name" type="text" maxlength="180" placeholder="например report.pdf" />
|
||||||
|
<label class="meta-muted" data-existing-preview-wrap="true" hidden>
|
||||||
|
<input type="checkbox" data-existing-preview-toggle="true" />
|
||||||
|
Добавить превью
|
||||||
|
</label>
|
||||||
|
<div class="stack" data-existing-preview-fields="true" hidden>
|
||||||
|
<label class="meta-muted" for="ar-existing-preview-txid">Transaction ID превью</label>
|
||||||
|
<input class="input" id="ar-existing-preview-txid" type="text" maxlength="64" placeholder="43 символа txId превью" />
|
||||||
|
</div>
|
||||||
<div class="ar-attachment-meta" data-meta="true"></div>
|
<div class="ar-attachment-meta" data-meta="true"></div>
|
||||||
<p class="meta-muted inline-error" data-error="true"></p>
|
<p class="meta-muted inline-error" data-error="true"></p>
|
||||||
<div class="form-actions-grid">
|
<div class="form-actions-grid">
|
||||||
@@ -419,39 +863,73 @@ export function openArweaveAttachmentManager({
|
|||||||
bindBackdrop();
|
bindBackdrop();
|
||||||
const txEl = root.querySelector('#ar-existing-txid');
|
const txEl = root.querySelector('#ar-existing-txid');
|
||||||
const nameEl = root.querySelector('#ar-existing-name');
|
const nameEl = root.querySelector('#ar-existing-name');
|
||||||
|
const previewWrapEl = root.querySelector('[data-existing-preview-wrap="true"]');
|
||||||
|
const previewToggleEl = root.querySelector('[data-existing-preview-toggle="true"]');
|
||||||
|
const previewFieldsEl = root.querySelector('[data-existing-preview-fields="true"]');
|
||||||
|
const previewTxEl = root.querySelector('#ar-existing-preview-txid');
|
||||||
const metaEl = root.querySelector('[data-meta="true"]');
|
const metaEl = root.querySelector('[data-meta="true"]');
|
||||||
const errorEl = root.querySelector('[data-error="true"]');
|
const errorEl = root.querySelector('[data-error="true"]');
|
||||||
const checkBtn = root.querySelector('[data-action="check"]');
|
const checkBtn = root.querySelector('[data-action="check"]');
|
||||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload);
|
root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload);
|
||||||
|
nameEl?.addEventListener('input', () => {
|
||||||
|
const isVideo = isVideoAttachmentName(String(nameEl.value || '').trim());
|
||||||
|
if (previewWrapEl) previewWrapEl.hidden = !isVideo;
|
||||||
|
if (!isVideo) {
|
||||||
|
if (previewToggleEl instanceof HTMLInputElement) previewToggleEl.checked = false;
|
||||||
|
if (previewFieldsEl) previewFieldsEl.hidden = true;
|
||||||
|
if (previewTxEl) previewTxEl.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
previewToggleEl?.addEventListener('change', () => {
|
||||||
|
if (previewFieldsEl && previewToggleEl instanceof HTMLInputElement) {
|
||||||
|
previewFieldsEl.hidden = !previewToggleEl.checked;
|
||||||
|
}
|
||||||
|
});
|
||||||
checkBtn?.addEventListener('click', async () => {
|
checkBtn?.addEventListener('click', async () => {
|
||||||
const txId = String(txEl?.value || '').trim();
|
const txId = String(txEl?.value || '').trim();
|
||||||
const name = String(nameEl?.value || '').trim() || 'arweave-file';
|
const name = String(nameEl?.value || '').trim() || 'arweave-file';
|
||||||
|
const wantsPreview = previewToggleEl instanceof HTMLInputElement && previewToggleEl.checked;
|
||||||
|
const previewTxId = String(previewTxEl?.value || '').trim();
|
||||||
if (!validateArweaveTxId(txId)) {
|
if (!validateArweaveTxId(txId)) {
|
||||||
setText(errorEl, 'Некорректный Transaction ID Arweave.');
|
setText(errorEl, 'Некорректный Transaction ID Arweave.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (wantsPreview && !validateArweaveTxId(previewTxId)) {
|
||||||
|
setText(errorEl, 'Некорректный Transaction ID превью.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
checkBtn.disabled = true;
|
checkBtn.disabled = true;
|
||||||
setText(errorEl, 'Скачиваем файл и считаем SHA-256...');
|
setText(errorEl, 'Скачиваем файл и считаем SHA-256...');
|
||||||
setText(metaEl, '');
|
setText(metaEl, '');
|
||||||
try {
|
try {
|
||||||
const meta = await loadArweaveFileMetadata({ gateway: cleanGateway, txId });
|
const meta = await loadArweaveFileMetadata({ gateway: cleanGateway, txId });
|
||||||
|
let preview = null;
|
||||||
|
if (wantsPreview) {
|
||||||
|
const previewMeta = await loadArweaveFileMetadata({ gateway: cleanGateway, txId: previewTxId });
|
||||||
|
preview = {
|
||||||
|
ar: previewTxId,
|
||||||
|
sha256: previewMeta.sha256Hex,
|
||||||
|
};
|
||||||
|
}
|
||||||
const item = normalizeAttachment({
|
const item = normalizeAttachment({
|
||||||
name,
|
name,
|
||||||
size: meta.sizeBytes,
|
size: meta.sizeBytes,
|
||||||
sha256: meta.sha256Hex,
|
sha256: meta.sha256Hex,
|
||||||
ar: txId,
|
ar: txId,
|
||||||
|
preview,
|
||||||
uploadedAtMs: Date.now(),
|
uploadedAtMs: Date.now(),
|
||||||
});
|
});
|
||||||
metaEl.innerHTML = `
|
metaEl.innerHTML = `
|
||||||
<div>Размер: ${escapeHtml(formatBytes(item.size))}</div>
|
<div>Размер: ${escapeHtml(formatBytes(item.size))}</div>
|
||||||
<div>SHA-256: ${escapeHtml(item.sha256)}</div>
|
<div>SHA-256: ${escapeHtml(item.sha256)}</div>
|
||||||
|
${item.preview ? '<div>Превью будет добавлено вместе с файлом.</div>' : ''}
|
||||||
`;
|
`;
|
||||||
setText(errorEl, '');
|
setText(errorEl, '');
|
||||||
const addBtn = document.createElement('button');
|
const addBtn = document.createElement('button');
|
||||||
addBtn.className = 'primary-btn';
|
addBtn.className = 'primary-btn';
|
||||||
addBtn.type = 'button';
|
addBtn.type = 'button';
|
||||||
addBtn.textContent = 'Добавить в сообщение';
|
addBtn.textContent = historyOnly ? 'Добавить в журнал' : 'Добавить в сообщение';
|
||||||
addBtn.addEventListener('click', () => finish(resolve, item));
|
addBtn.addEventListener('click', () => finish(resolve, item, { pendingPlacement: historyOnly && !isAvatarMode }));
|
||||||
metaEl.append(addBtn);
|
metaEl.append(addBtn);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setText(errorEl, error?.message || 'Не удалось проверить файл Arweave.');
|
setText(errorEl, error?.message || 'Не удалось проверить файл Arweave.');
|
||||||
@@ -475,11 +953,13 @@ export function openArweaveAttachmentManager({
|
|||||||
${rows.length ? rows.map((item, index) => {
|
${rows.length ? rows.map((item, index) => {
|
||||||
const isSelected = selectedTxIdSet.has(item.ar);
|
const isSelected = selectedTxIdSet.has(item.ar);
|
||||||
return `
|
return `
|
||||||
<article class="ar-attachment-history-tile${isSelected ? ' is-selected' : ''}" data-history-tile="${index}">
|
<article class="ar-attachment-history-tile${hasAttachmentPreview(item) ? ' ar-attachment-history-tile--with-preview' : ''}${isSelected ? ' is-selected' : ''}" data-history-tile="${index}">
|
||||||
|
${buildHistoryPreviewMarkup(item, cleanGateway)}
|
||||||
<strong class="ar-attachment-history-name">${escapeHtml(item.name)}${item.purpose === 'avatar' ? ' · аватар' : ''}</strong>
|
<strong class="ar-attachment-history-name">${escapeHtml(item.name)}${item.purpose === 'avatar' ? ' · аватар' : ''}</strong>
|
||||||
<div class="ar-attachment-history-meta-row">
|
<div class="ar-attachment-history-meta-row">
|
||||||
<span>${escapeHtml(formatBytes(item.size))}</span>
|
<span>${escapeHtml(formatBytes(item.size))}</span>
|
||||||
<span>${escapeHtml(formatArweaveHistoryTime(item.uploadedAtMs))}</span>
|
<span>${escapeHtml(formatArweaveHistoryTime(item.uploadedAtMs))}</span>
|
||||||
|
${hasAttachmentPreview(item) ? '<span class="ar-attachment-placement-flag">С превью</span>' : ''}
|
||||||
<span class="ar-attachment-status ar-attachment-status--pending" data-status="${index}">Проверяем...</span>
|
<span class="ar-attachment-status ar-attachment-status--pending" data-status="${index}">Проверяем...</span>
|
||||||
${item.pendingPlacement ? '<span class="ar-attachment-placement-flag">Не добавлен в SHiNE</span>' : ''}
|
${item.pendingPlacement ? '<span class="ar-attachment-placement-flag">Не добавлен в SHiNE</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,56 +1,7 @@
|
|||||||
import { authService } from '../state.js';
|
import { openArweaveAttachmentManager } from './arweave-attachment-manager.js';
|
||||||
import { getArweaveBalance, getArweaveWalletFromStoredClientKey } from '../services/arweave-wallet-service.js';
|
|
||||||
import {
|
|
||||||
buildArweaveDataUrl,
|
|
||||||
getArweaveUploadPrice,
|
|
||||||
prepareAvatarImageFile,
|
|
||||||
sha256HexFromArrayBuffer,
|
|
||||||
uploadArweaveFile,
|
|
||||||
validateArweaveTxId,
|
|
||||||
validateSha256Hex,
|
|
||||||
validateAvatarSourceFile,
|
|
||||||
} from '../services/arweave-file-service.js';
|
|
||||||
import { bytesToBase64 } from '../services/crypto-utils.js';
|
|
||||||
import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
|
import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
|
||||||
|
|
||||||
const DEFAULT_FREE_AVATAR_MAX_BYTES = 128 * 1024;
|
export async function openAvatarWizard({
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
return String(text || '')
|
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
.replaceAll('"', '"')
|
|
||||||
.replaceAll("'", ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
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 formatAr(ar) {
|
|
||||||
const n = Number(ar);
|
|
||||||
if (!Number.isFinite(n)) return '0';
|
|
||||||
return n.toLocaleString('ru-RU', { maximumFractionDigits: 6 });
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearJwk(ctx) {
|
|
||||||
if (!ctx?.jwk || typeof ctx.jwk !== 'object') return;
|
|
||||||
Object.keys(ctx.jwk).forEach((key) => {
|
|
||||||
ctx.jwk[key] = '';
|
|
||||||
});
|
|
||||||
ctx.jwk = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setNodeText(node, text) {
|
|
||||||
if (node) node.textContent = String(text || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openAvatarWizard({
|
|
||||||
login,
|
login,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
gateway,
|
gateway,
|
||||||
@@ -58,568 +9,37 @@ export function openAvatarWizard({
|
|||||||
onAvatarSaved,
|
onAvatarSaved,
|
||||||
onStatus,
|
onStatus,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const root = document.getElementById('modal-root');
|
|
||||||
if (!root) return Promise.resolve(false);
|
|
||||||
|
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
const cleanStoragePwd = String(storagePwd || '').trim();
|
const cleanStoragePwd = String(storagePwd || '').trim();
|
||||||
const cleanGateway = String(gateway || '').trim();
|
const cleanGateway = String(gateway || '').trim();
|
||||||
if (!cleanLogin || !cleanStoragePwd) {
|
if (!cleanLogin || !cleanStoragePwd) {
|
||||||
return Promise.reject(new Error('Нет активной сессии.'));
|
throw new Error('Нет активной сессии.');
|
||||||
}
|
}
|
||||||
|
|
||||||
let closed = false;
|
const item = await openArweaveAttachmentManager({
|
||||||
let lastPreviewUrl = '';
|
|
||||||
let walletCtx = null;
|
|
||||||
let balanceInfo = null;
|
|
||||||
let optimized = null;
|
|
||||||
let priceInfo = null;
|
|
||||||
let uploadedTxId = '';
|
|
||||||
let uploadedSha256Hex = '';
|
|
||||||
let uploadedInfoText = '';
|
|
||||||
let freeQuotaInfo = null;
|
|
||||||
|
|
||||||
function revokePreviewUrl() {
|
|
||||||
if (!lastPreviewUrl) return;
|
|
||||||
URL.revokeObjectURL(lastPreviewUrl);
|
|
||||||
lastPreviewUrl = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function close(result = false, resolve) {
|
|
||||||
if (closed) return;
|
|
||||||
closed = true;
|
|
||||||
revokePreviewUrl();
|
|
||||||
clearJwk(walletCtx);
|
|
||||||
walletCtx = null;
|
|
||||||
root.innerHTML = '';
|
|
||||||
resolve(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensurePreviewImage(url, imageEl) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = imageEl;
|
|
||||||
img.onload = () => resolve(true);
|
|
||||||
img.onerror = () => reject(new Error('Не удалось загрузить изображение по этому Transaction ID'));
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const showStepChoice = () => {
|
|
||||||
if (closed) return;
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" data-avatar-wizard-modal="true">
|
|
||||||
<div class="modal-card stack avatar-wizard-card">
|
|
||||||
<h3 class="modal-title">Сменить аватар</h3>
|
|
||||||
<p class="meta-muted">Вы можете использовать уже загруженный файл Arweave, загрузить новый файл со своего кошелька или попробовать временную бесплатную загрузку через сервер.</p>
|
|
||||||
<div class="avatar-wizard-choice-grid">
|
|
||||||
<button class="primary-btn" type="button" data-action="use-existing">Использовать существующий файл в Arweave</button>
|
|
||||||
<button class="primary-btn" type="button" data-action="upload-new">Загрузить новый файл в Arweave</button>
|
|
||||||
<button class="primary-btn" type="button" data-action="upload-free">Залить аватар бесплатно</button>
|
|
||||||
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
|
|
||||||
modal?.addEventListener('click', (event) => {
|
|
||||||
if (event.target === modal) close(false, resolve);
|
|
||||||
});
|
|
||||||
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
|
|
||||||
root.querySelector('[data-action="use-existing"]')?.addEventListener('click', showStepExistingInput);
|
|
||||||
root.querySelector('[data-action="upload-new"]')?.addEventListener('click', () => { void showStepUpload(); });
|
|
||||||
root.querySelector('[data-action="upload-free"]')?.addEventListener('click', () => { void showStepFreeUpload(); });
|
|
||||||
};
|
|
||||||
|
|
||||||
const showStepExistingInput = () => {
|
|
||||||
if (closed) return;
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" data-avatar-wizard-modal="true">
|
|
||||||
<div class="modal-card stack avatar-wizard-card">
|
|
||||||
<h3 class="modal-title">Использовать существующий файл в Arweave</h3>
|
|
||||||
<label class="meta-muted" for="avatar-existing-txid">Transaction ID Arweave</label>
|
|
||||||
<input class="input" id="avatar-existing-txid" type="text" maxlength="64" placeholder="Введите Transaction ID" />
|
|
||||||
<p class="avatar-wizard-error" data-error="true"></p>
|
|
||||||
<div class="avatar-wizard-actions">
|
|
||||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
|
||||||
<button class="primary-btn" type="button" data-action="next">Далее</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
|
|
||||||
const inputEl = root.querySelector('#avatar-existing-txid');
|
|
||||||
const errorEl = root.querySelector('[data-error="true"]');
|
|
||||||
modal?.addEventListener('click', (event) => {
|
|
||||||
if (event.target === modal) close(false, resolve);
|
|
||||||
});
|
|
||||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
|
|
||||||
root.querySelector('[data-action="next"]')?.addEventListener('click', () => {
|
|
||||||
const txId = String(inputEl?.value || '').trim();
|
|
||||||
if (!validateArweaveTxId(txId)) {
|
|
||||||
setNodeText(errorEl, 'Некорректный Transaction ID Arweave.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setNodeText(errorEl, '');
|
|
||||||
void showStepExistingPreview(txId);
|
|
||||||
});
|
|
||||||
window.setTimeout(() => inputEl?.focus(), 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const showStepExistingPreview = async (txId) => {
|
|
||||||
if (closed) return;
|
|
||||||
const previewUrl = buildArweaveDataUrl({ gateway: cleanGateway, txId });
|
|
||||||
let existingSha256Hex = '';
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" data-avatar-wizard-modal="true">
|
|
||||||
<div class="modal-card stack avatar-wizard-card">
|
|
||||||
<h3 class="modal-title">Предпросмотр аватара</h3>
|
|
||||||
<div class="avatar-preview-circle avatar-wizard-preview">
|
|
||||||
<img alt="Предпросмотр аватара" data-preview-image="true" />
|
|
||||||
</div>
|
|
||||||
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p>
|
|
||||||
<p class="avatar-wizard-error" data-error="true"></p>
|
|
||||||
<div class="avatar-wizard-actions">
|
|
||||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
|
||||||
<button class="primary-btn" type="button" data-action="save">Сохранить аватар</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
|
|
||||||
const imageEl = root.querySelector('[data-preview-image="true"]');
|
|
||||||
const errorEl = root.querySelector('[data-error="true"]');
|
|
||||||
const saveBtn = root.querySelector('[data-action="save"]');
|
|
||||||
modal?.addEventListener('click', (event) => {
|
|
||||||
if (event.target === modal) close(false, resolve);
|
|
||||||
});
|
|
||||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepExistingInput);
|
|
||||||
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ensurePreviewImage(previewUrl, imageEl);
|
|
||||||
const response = await fetch(previewUrl, { method: 'GET', cache: 'no-store' });
|
|
||||||
if (!response.ok) throw new Error('BAD_AVATAR_FETCH');
|
|
||||||
existingSha256Hex = await sha256HexFromArrayBuffer(await response.arrayBuffer());
|
|
||||||
if (!validateSha256Hex(existingSha256Hex)) throw new Error('BAD_AVATAR_HASH');
|
|
||||||
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = false;
|
|
||||||
} catch (error) {
|
|
||||||
setNodeText(errorEl, 'Не удалось проверить файл по этому Transaction ID.');
|
|
||||||
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveBtn?.addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await saveProfileAvatarArweave(cleanLogin, txId, existingSha256Hex);
|
|
||||||
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
|
|
||||||
close(true, resolve);
|
|
||||||
} catch {
|
|
||||||
setNodeText(errorEl, 'Не удалось сохранить аватар в профиль.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const showStepZeroBalance = () => {
|
|
||||||
if (closed) return;
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" data-avatar-wizard-modal="true">
|
|
||||||
<div class="modal-card stack avatar-wizard-card">
|
|
||||||
<h3 class="modal-title">Недостаточно средств</h3>
|
|
||||||
<p class="meta-muted">У вас нулевой баланс Arweave. Для загрузки аватара нужен AR.</p>
|
|
||||||
<div class="avatar-wizard-actions">
|
|
||||||
<button class="primary-btn" type="button" data-action="to-wallet">Перейти в кошелёк</button>
|
|
||||||
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
root.querySelector('[data-action="to-wallet"]')?.addEventListener('click', () => {
|
|
||||||
close(false, resolve);
|
|
||||||
if (typeof navigate === 'function') navigate('wallet-view');
|
|
||||||
});
|
|
||||||
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
|
|
||||||
};
|
|
||||||
|
|
||||||
const showStepUploadForm = () => {
|
|
||||||
if (closed) return;
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" data-avatar-wizard-modal="true">
|
|
||||||
<div class="modal-card stack avatar-wizard-card">
|
|
||||||
<h3 class="modal-title">Загрузить новый файл в Arweave</h3>
|
|
||||||
<p class="meta-muted">Баланс: ${formatAr(balanceInfo?.ar)} AR</p>
|
|
||||||
<label class="meta-muted" for="avatar-file-input">Выберите изображение</label>
|
|
||||||
<input class="input" id="avatar-file-input" type="file" accept="image/jpeg,image/png,image/webp" />
|
|
||||||
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Максимальный исходный файл — 10 MB. Перед загрузкой изображение будет уменьшено для аватарки.</p>
|
|
||||||
<div class="avatar-preview-circle avatar-wizard-preview" hidden data-preview-wrap="true">
|
|
||||||
<img alt="Предпросмотр аватара" data-preview-image="true" />
|
|
||||||
</div>
|
|
||||||
<div class="avatar-wizard-meta" data-meta="true"></div>
|
|
||||||
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p>
|
|
||||||
<p class="avatar-wizard-error" data-error="true"></p>
|
|
||||||
<div class="avatar-wizard-actions">
|
|
||||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
|
||||||
<button class="primary-btn" type="button" data-action="upload" disabled>Загрузить в Arweave</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
|
|
||||||
const fileInput = root.querySelector('#avatar-file-input');
|
|
||||||
const errorEl = root.querySelector('[data-error="true"]');
|
|
||||||
const metaEl = root.querySelector('[data-meta="true"]');
|
|
||||||
const previewWrap = root.querySelector('[data-preview-wrap="true"]');
|
|
||||||
const previewImage = root.querySelector('[data-preview-image="true"]');
|
|
||||||
const uploadBtn = root.querySelector('[data-action="upload"]');
|
|
||||||
|
|
||||||
let selectedFile = null;
|
|
||||||
optimized = null;
|
|
||||||
priceInfo = null;
|
|
||||||
uploadedSha256Hex = '';
|
|
||||||
|
|
||||||
modal?.addEventListener('click', (event) => {
|
|
||||||
if (event.target === modal) close(false, resolve);
|
|
||||||
});
|
|
||||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
|
|
||||||
|
|
||||||
fileInput?.addEventListener('change', async () => {
|
|
||||||
setNodeText(errorEl, '');
|
|
||||||
setNodeText(metaEl, '');
|
|
||||||
uploadBtn.disabled = true;
|
|
||||||
revokePreviewUrl();
|
|
||||||
|
|
||||||
selectedFile = fileInput.files?.[0] || null;
|
|
||||||
if (!selectedFile) {
|
|
||||||
setNodeText(errorEl, 'Выберите файл изображения.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
validateAvatarSourceFile(selectedFile);
|
|
||||||
optimized = await prepareAvatarImageFile(selectedFile);
|
|
||||||
priceInfo = await getArweaveUploadPrice({
|
|
||||||
gateway: cleanGateway,
|
|
||||||
byteLength: optimized.file.size,
|
|
||||||
});
|
|
||||||
|
|
||||||
lastPreviewUrl = URL.createObjectURL(optimized.file);
|
|
||||||
previewImage.src = lastPreviewUrl;
|
|
||||||
previewWrap.hidden = false;
|
|
||||||
|
|
||||||
const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston);
|
|
||||||
metaEl.innerHTML = `
|
|
||||||
<div>Исходный размер: ${escapeHtml(formatBytes(optimized.originalSizeBytes))}</div>
|
|
||||||
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
|
|
||||||
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
|
|
||||||
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
|
|
||||||
<div>SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}</div>
|
|
||||||
<div>Примерная цена загрузки: ${escapeHtml(formatAr(priceInfo.ar))} AR</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (!hasFunds) {
|
|
||||||
setNodeText(errorEl, 'Недостаточно средств для загрузки.');
|
|
||||||
uploadBtn.disabled = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uploadBtn.disabled = false;
|
|
||||||
} catch (error) {
|
|
||||||
const message = String(error?.message || '');
|
|
||||||
if (message === 'Выберите файл изображения.' || message === 'Поддерживаются только JPEG, PNG или WebP.' || message === 'Файл слишком большой. Максимум 10 MB.') {
|
|
||||||
setNodeText(errorEl, message);
|
|
||||||
} else if (message.includes('цену загрузки')) {
|
|
||||||
setNodeText(errorEl, 'Не удалось получить баланс Arweave.');
|
|
||||||
} else {
|
|
||||||
setNodeText(errorEl, 'Не удалось подготовить изображение.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
uploadBtn?.addEventListener('click', async () => {
|
|
||||||
setNodeText(errorEl, '');
|
|
||||||
if (!optimized?.file) {
|
|
||||||
setNodeText(errorEl, 'Выберите файл изображения.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!priceInfo || BigInt(balanceInfo.winston) < BigInt(priceInfo.winston)) {
|
|
||||||
setNodeText(errorEl, 'Недостаточно средств для загрузки.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadBtn.disabled = true;
|
|
||||||
try {
|
|
||||||
uploadedInfoText = '';
|
|
||||||
const uploaded = await uploadArweaveFile({
|
|
||||||
gateway: cleanGateway,
|
|
||||||
jwk: walletCtx?.jwk,
|
|
||||||
file: optimized.file,
|
|
||||||
tags: [
|
|
||||||
{ name: 'SHiNE-Profile-Login', value: cleanLogin },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
uploadedTxId = String(uploaded.id || '').trim();
|
|
||||||
uploadedSha256Hex = String(optimized?.sha256Hex || '').trim().toLowerCase();
|
|
||||||
if (!uploadedTxId) {
|
|
||||||
throw new Error('Пустой Transaction ID');
|
|
||||||
}
|
|
||||||
if (!validateSha256Hex(uploadedSha256Hex)) {
|
|
||||||
throw new Error('Некорректный SHA256');
|
|
||||||
}
|
|
||||||
showStepUploaded();
|
|
||||||
} catch {
|
|
||||||
setNodeText(errorEl, 'Не удалось загрузить файл в Arweave.');
|
|
||||||
uploadBtn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const showStepFreeLimitExhausted = () => {
|
|
||||||
if (closed) return;
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" data-avatar-wizard-modal="true">
|
|
||||||
<div class="modal-card stack avatar-wizard-card">
|
|
||||||
<h3 class="modal-title">Лимит исчерпан</h3>
|
|
||||||
<p class="meta-muted">Вы исчерпали бесплатный лимит аватарок.</p>
|
|
||||||
<div class="avatar-wizard-actions">
|
|
||||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
|
||||||
<button class="secondary-btn" type="button" data-action="close">Закрыть</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
|
|
||||||
root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve));
|
|
||||||
};
|
|
||||||
|
|
||||||
const showStepFreeUploadForm = () => {
|
|
||||||
if (closed) return;
|
|
||||||
const remaining = Number(freeQuotaInfo?.remainingCount || 0);
|
|
||||||
const limit = Number(freeQuotaInfo?.limit || 3);
|
|
||||||
const maxBytes = Number(freeQuotaInfo?.maxBytes || DEFAULT_FREE_AVATAR_MAX_BYTES);
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" data-avatar-wizard-modal="true">
|
|
||||||
<div class="modal-card stack avatar-wizard-card">
|
|
||||||
<h3 class="modal-title">Залить аватар бесплатно</h3>
|
|
||||||
<p class="meta-muted">Осталось бесплатных загрузок: ${remaining} из ${limit}.</p>
|
|
||||||
<label class="meta-muted" for="avatar-free-file-input">Выберите изображение</label>
|
|
||||||
<input class="input" id="avatar-free-file-input" type="file" accept="image/jpeg,image/png,image/webp" />
|
|
||||||
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Перед отправкой изображение уменьшается для аватарки. Итоговый файл должен быть не больше ${formatBytes(maxBytes)}.</p>
|
|
||||||
<div class="avatar-preview-circle avatar-wizard-preview" hidden data-preview-wrap="true">
|
|
||||||
<img alt="Предпросмотр аватара" data-preview-image="true" />
|
|
||||||
</div>
|
|
||||||
<div class="avatar-wizard-meta" data-meta="true"></div>
|
|
||||||
<p class="meta-muted">Это временная тестовая бесплатная загрузка через серверный кошелёк Arweave.</p>
|
|
||||||
<p class="avatar-wizard-error" data-error="true"></p>
|
|
||||||
<div class="avatar-wizard-actions">
|
|
||||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
|
||||||
<button class="primary-btn" type="button" data-action="upload" disabled>Залить бесплатно</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
|
|
||||||
const fileInput = root.querySelector('#avatar-free-file-input');
|
|
||||||
const errorEl = root.querySelector('[data-error="true"]');
|
|
||||||
const metaEl = root.querySelector('[data-meta="true"]');
|
|
||||||
const previewWrap = root.querySelector('[data-preview-wrap="true"]');
|
|
||||||
const previewImage = root.querySelector('[data-preview-image="true"]');
|
|
||||||
const uploadBtn = root.querySelector('[data-action="upload"]');
|
|
||||||
|
|
||||||
optimized = null;
|
|
||||||
uploadedSha256Hex = '';
|
|
||||||
|
|
||||||
modal?.addEventListener('click', (event) => {
|
|
||||||
if (event.target === modal) close(false, resolve);
|
|
||||||
});
|
|
||||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
|
|
||||||
|
|
||||||
fileInput?.addEventListener('change', async () => {
|
|
||||||
setNodeText(errorEl, '');
|
|
||||||
setNodeText(metaEl, '');
|
|
||||||
uploadBtn.disabled = true;
|
|
||||||
revokePreviewUrl();
|
|
||||||
|
|
||||||
const selectedFile = fileInput.files?.[0] || null;
|
|
||||||
if (!selectedFile) {
|
|
||||||
setNodeText(errorEl, 'Выберите файл изображения.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
validateAvatarSourceFile(selectedFile);
|
|
||||||
optimized = await prepareAvatarImageFile(selectedFile);
|
|
||||||
if (Number(optimized?.file?.size || 0) > maxBytes) {
|
|
||||||
throw new Error(`После уменьшения файл всё ещё больше ${formatBytes(maxBytes)}. Возьмите более простое изображение.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastPreviewUrl = URL.createObjectURL(optimized.file);
|
|
||||||
previewImage.src = lastPreviewUrl;
|
|
||||||
previewWrap.hidden = false;
|
|
||||||
metaEl.innerHTML = `
|
|
||||||
<div>Исходный размер: ${escapeHtml(formatBytes(optimized.originalSizeBytes))}</div>
|
|
||||||
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
|
|
||||||
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
|
|
||||||
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
|
|
||||||
<div>SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}</div>
|
|
||||||
`;
|
|
||||||
uploadBtn.disabled = false;
|
|
||||||
} catch (error) {
|
|
||||||
setNodeText(errorEl, String(error?.message || 'Не удалось подготовить изображение.'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
uploadBtn?.addEventListener('click', async () => {
|
|
||||||
setNodeText(errorEl, '');
|
|
||||||
if (!optimized?.file) {
|
|
||||||
setNodeText(errorEl, 'Выберите файл изображения.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadBtn.disabled = true;
|
|
||||||
try {
|
|
||||||
const fileBytes = new Uint8Array(await optimized.file.arrayBuffer());
|
|
||||||
const uploaded = await authService.uploadTestFreeAvatar({
|
|
||||||
contentType: optimized.contentType,
|
|
||||||
fileBytesBase64: bytesToBase64(fileBytes),
|
|
||||||
sha256Hex: String(optimized.sha256Hex || '').trim().toLowerCase(),
|
|
||||||
});
|
|
||||||
uploadedTxId = String(uploaded.txId || '').trim();
|
|
||||||
uploadedSha256Hex = String(uploaded.sha256Hex || optimized.sha256Hex || '').trim().toLowerCase();
|
|
||||||
uploadedInfoText = `Осталось бесплатных загрузок: ${Number(uploaded.remainingCount || 0)} из ${Number(uploaded.limit || limit)}.`;
|
|
||||||
if (!uploadedTxId) {
|
|
||||||
throw new Error('Сервер не вернул Transaction ID.');
|
|
||||||
}
|
|
||||||
showStepUploaded();
|
|
||||||
} catch (error) {
|
|
||||||
setNodeText(errorEl, String(error?.message || 'Не удалось бесплатно загрузить аватар.'));
|
|
||||||
uploadBtn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const showStepFreeUpload = async () => {
|
|
||||||
if (closed) return;
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" data-avatar-wizard-modal="true">
|
|
||||||
<div class="modal-card stack avatar-wizard-card">
|
|
||||||
<h3 class="modal-title">Подготовка бесплатной загрузки</h3>
|
|
||||||
<p class="meta-muted" data-loading="true">Проверяем остаток бесплатных загрузок...</p>
|
|
||||||
<p class="avatar-wizard-error" data-error="true"></p>
|
|
||||||
<div class="avatar-wizard-actions">
|
|
||||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const loadingEl = root.querySelector('[data-loading="true"]');
|
|
||||||
const errorEl = root.querySelector('[data-error="true"]');
|
|
||||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
|
|
||||||
|
|
||||||
try {
|
|
||||||
freeQuotaInfo = await authService.getTestFreeAvatarQuota();
|
|
||||||
} catch (error) {
|
|
||||||
setNodeText(errorEl, error?.message || 'Не удалось получить остаток бесплатных загрузок.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!freeQuotaInfo?.enabled) {
|
|
||||||
setNodeText(loadingEl, '');
|
|
||||||
setNodeText(errorEl, 'Временная бесплатная загрузка аватаров сейчас отключена на сервере.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Number(freeQuotaInfo?.remainingCount || 0) <= 0) {
|
|
||||||
showStepFreeLimitExhausted();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showStepFreeUploadForm();
|
|
||||||
};
|
|
||||||
|
|
||||||
const showStepUploaded = () => {
|
|
||||||
if (closed) return;
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" data-avatar-wizard-modal="true">
|
|
||||||
<div class="modal-card stack avatar-wizard-card">
|
|
||||||
<h3 class="modal-title">Файл загружен в Arweave</h3>
|
|
||||||
<p class="meta-muted">Transaction ID:</p>
|
|
||||||
<p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p>
|
|
||||||
<p class="meta-muted">SHA-256:</p>
|
|
||||||
<p class="avatar-wizard-meta">${escapeHtml(uploadedSha256Hex)}</p>
|
|
||||||
${uploadedInfoText ? `<p class="meta-muted">${escapeHtml(uploadedInfoText)}</p>` : ''}
|
|
||||||
<p class="avatar-wizard-error" data-error="true"></p>
|
|
||||||
<div class="avatar-wizard-actions">
|
|
||||||
<button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button>
|
|
||||||
<button class="primary-btn" type="button" data-action="set-avatar">Сделать аватаром</button>
|
|
||||||
</div>
|
|
||||||
<button class="secondary-btn" type="button" data-action="close">Закрыть</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const errorEl = root.querySelector('[data-error="true"]');
|
|
||||||
root.querySelector('[data-action="copy-id"]')?.addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(uploadedTxId);
|
|
||||||
setNodeText(errorEl, '');
|
|
||||||
} catch {
|
|
||||||
setNodeText(errorEl, 'Не удалось скопировать Transaction ID.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
root.querySelector('[data-action="set-avatar"]')?.addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await saveProfileAvatarArweave(cleanLogin, uploadedTxId, uploadedSha256Hex);
|
|
||||||
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
|
|
||||||
close(true, resolve);
|
|
||||||
} catch {
|
|
||||||
setNodeText(errorEl, 'Не удалось сохранить аватар в профиль.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve));
|
|
||||||
};
|
|
||||||
|
|
||||||
const showStepUpload = async () => {
|
|
||||||
if (closed) return;
|
|
||||||
root.innerHTML = `
|
|
||||||
<div class="modal" data-avatar-wizard-modal="true">
|
|
||||||
<div class="modal-card stack avatar-wizard-card">
|
|
||||||
<h3 class="modal-title">Подготовка загрузки</h3>
|
|
||||||
<p class="meta-muted" data-loading="true">Загружаем Arweave-кошелёк...</p>
|
|
||||||
<p class="avatar-wizard-error" data-error="true"></p>
|
|
||||||
<div class="avatar-wizard-actions">
|
|
||||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const loadingEl = root.querySelector('[data-loading="true"]');
|
|
||||||
const errorEl = root.querySelector('[data-error="true"]');
|
|
||||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
|
|
||||||
try {
|
|
||||||
walletCtx = await getArweaveWalletFromStoredClientKey({
|
|
||||||
login: cleanLogin,
|
login: cleanLogin,
|
||||||
storagePwd: cleanStoragePwd,
|
storagePwd: cleanStoragePwd,
|
||||||
onStatus: (message) => {
|
|
||||||
setNodeText(loadingEl, message);
|
|
||||||
if (typeof onStatus === 'function') onStatus(message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
balanceInfo = await getArweaveBalance({
|
|
||||||
gateway: cleanGateway,
|
gateway: cleanGateway,
|
||||||
address: walletCtx.address,
|
navigate,
|
||||||
|
mode: 'avatar',
|
||||||
|
historyPurpose: 'avatar',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
setNodeText(errorEl, 'Не удалось получить баланс Arweave.');
|
if (!item?.ar || !item?.sha256) return false;
|
||||||
return;
|
|
||||||
|
if (typeof onStatus === 'function') {
|
||||||
|
onStatus('Сохраняем аватар в профиль...');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (BigInt(balanceInfo.winston) <= 0n) {
|
await saveProfileAvatarArweave(cleanLogin, item.ar, item.sha256);
|
||||||
showStepZeroBalance();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showStepUploadForm();
|
|
||||||
};
|
|
||||||
|
|
||||||
showStepChoice();
|
if (typeof onAvatarSaved === 'function') {
|
||||||
});
|
await onAvatarSaved(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof onStatus === 'function') {
|
||||||
|
onStatus('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function setStatusBadge(node, status, label) {
|
|||||||
|
|
||||||
function renderTile(item, index) {
|
function renderTile(item, index) {
|
||||||
const tile = document.createElement('article');
|
const tile = document.createElement('article');
|
||||||
tile.className = 'ar-attachment-history-tile ar-attachment-history-tile--page';
|
tile.className = `ar-attachment-history-tile ar-attachment-history-tile--page${item?.preview?.ar ? ' ar-attachment-history-tile--with-preview' : ''}`;
|
||||||
|
|
||||||
const name = document.createElement('strong');
|
const name = document.createElement('strong');
|
||||||
name.className = 'ar-attachment-history-name';
|
name.className = 'ar-attachment-history-name';
|
||||||
@@ -42,6 +42,12 @@ function renderTile(item, index) {
|
|||||||
const time = document.createElement('span');
|
const time = document.createElement('span');
|
||||||
time.textContent = formatArweaveHistoryTime(item.uploadedAtMs);
|
time.textContent = formatArweaveHistoryTime(item.uploadedAtMs);
|
||||||
meta.append(status, size, time);
|
meta.append(status, size, time);
|
||||||
|
if (item?.preview?.ar) {
|
||||||
|
const previewFlag = document.createElement('span');
|
||||||
|
previewFlag.className = 'ar-attachment-placement-flag';
|
||||||
|
previewFlag.textContent = 'С превью';
|
||||||
|
meta.append(previewFlag);
|
||||||
|
}
|
||||||
if (item.pendingPlacement) {
|
if (item.pendingPlacement) {
|
||||||
const flag = document.createElement('span');
|
const flag = document.createElement('span');
|
||||||
flag.className = 'ar-attachment-placement-flag';
|
flag.className = 'ar-attachment-placement-flag';
|
||||||
@@ -126,8 +132,12 @@ export function render({ navigate }) {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
controls.querySelector('[data-action="upload"]')?.addEventListener('click', uploadFile);
|
controls.querySelector('[data-action="upload"]')?.addEventListener('click', () => {
|
||||||
controls.querySelector('[data-action="upload-menu"]')?.addEventListener('click', uploadFile);
|
void uploadFile();
|
||||||
|
});
|
||||||
|
controls.querySelector('[data-action="upload-menu"]')?.addEventListener('click', () => {
|
||||||
|
void uploadFile();
|
||||||
|
});
|
||||||
|
|
||||||
controls.querySelector('[data-action="back"]')?.addEventListener('click', () => navigate('settings-view'));
|
controls.querySelector('[data-action="back"]')?.addEventListener('click', () => navigate('settings-view'));
|
||||||
controls.querySelector('[data-action="menu"]')?.addEventListener('click', (event) => {
|
controls.querySelector('[data-action="menu"]')?.addEventListener('click', (event) => {
|
||||||
@@ -147,7 +157,7 @@ export function render({ navigate }) {
|
|||||||
const menu = controls.querySelector('[data-menu="true"]');
|
const menu = controls.querySelector('[data-menu="true"]');
|
||||||
if (menu) menu.hidden = true;
|
if (menu) menu.hidden = true;
|
||||||
window.alert(
|
window.alert(
|
||||||
'Здесь вы можете заранее добавить файл в Arweave. Файл загрузится в блокчейн-хранилище и сохранится в журнале только этой браузерной сессии.\n\n'
|
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. Кнопка «Загрузить через Turbo» находится внутри стандартного окна загрузки.\n\n'
|
||||||
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
|
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
|
||||||
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
|
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,15 +32,31 @@ function normalizeName(name) {
|
|||||||
return clean || 'file';
|
return clean || 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePreview(input = {}) {
|
||||||
|
const previewTxId = String(input.previewAr || input.ar || input.txId || '').trim();
|
||||||
|
const previewSha256Hex = String(input.previewSha256 || input.sha256 || input.sha256Hex || '').trim().toLowerCase();
|
||||||
|
if (!previewTxId || !previewSha256Hex) return null;
|
||||||
|
if (!validateArweaveTxId(previewTxId)) return null;
|
||||||
|
if (!validateSha256Hex(previewSha256Hex)) return null;
|
||||||
|
return {
|
||||||
|
ar: previewTxId,
|
||||||
|
sha256: previewSha256Hex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeAttachment(input = {}) {
|
export function normalizeAttachment(input = {}) {
|
||||||
const txId = String(input.ar || input.txId || '').trim();
|
const txId = String(input.ar || input.txId || '').trim();
|
||||||
const sha256Hex = String(input.sha256 || input.sha256Hex || '').trim().toLowerCase();
|
const sha256Hex = String(input.sha256 || input.sha256Hex || '').trim().toLowerCase();
|
||||||
const size = Number(input.size || input.sizeBytes || 0);
|
const size = Number(input.size || input.sizeBytes || 0);
|
||||||
const name = normalizeName(input.name || input.fileName || 'file');
|
const name = normalizeName(input.name || input.fileName || 'file');
|
||||||
|
const preview = normalizePreview(input.preview || {
|
||||||
|
previewAr: input.previewAr,
|
||||||
|
previewSha256: input.previewSha256,
|
||||||
|
});
|
||||||
if (!validateArweaveTxId(txId)) throw new Error('Некорректный Transaction ID Arweave.');
|
if (!validateArweaveTxId(txId)) throw new Error('Некорректный Transaction ID Arweave.');
|
||||||
if (!validateSha256Hex(sha256Hex)) throw new Error('Некорректный SHA-256 файла.');
|
if (!validateSha256Hex(sha256Hex)) throw new Error('Некорректный SHA-256 файла.');
|
||||||
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер файла.');
|
if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер файла.');
|
||||||
return {
|
const out = {
|
||||||
v: 1,
|
v: 1,
|
||||||
name,
|
name,
|
||||||
size,
|
size,
|
||||||
@@ -48,12 +64,20 @@ export function normalizeAttachment(input = {}) {
|
|||||||
ar: txId,
|
ar: txId,
|
||||||
uploadedAtMs: Number(input.uploadedAtMs || 0) || Date.now(),
|
uploadedAtMs: Number(input.uploadedAtMs || 0) || Date.now(),
|
||||||
};
|
};
|
||||||
|
if (preview) {
|
||||||
|
out.v = 2;
|
||||||
|
out.preview = preview;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAttachmentBlock(attachment) {
|
export function buildAttachmentBlock(attachment) {
|
||||||
const item = normalizeAttachment(attachment);
|
const item = normalizeAttachment(attachment);
|
||||||
const encodedName = encodeURIComponent(item.name);
|
const encodedName = encodeURIComponent(item.name);
|
||||||
return `<SHiNE:attach;v=1;name=${encodedName};size=${item.size};sha256=${item.sha256};ar=${item.ar}>`;
|
const previewFields = item.preview
|
||||||
|
? `;previewAr=${item.preview.ar};previewSha256=${item.preview.sha256}`
|
||||||
|
: '';
|
||||||
|
return `<SHiNE:attach;v=${item.preview ? 2 : 1};name=${encodedName};size=${item.size};sha256=${item.sha256};ar=${item.ar}${previewFields}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function composeMessageWithAttachments(text, attachments = []) {
|
export function composeMessageWithAttachments(text, attachments = []) {
|
||||||
@@ -91,6 +115,8 @@ export function parseMessageAttachments(rawText) {
|
|||||||
size: fields.size,
|
size: fields.size,
|
||||||
sha256: fields.sha256,
|
sha256: fields.sha256,
|
||||||
ar: fields.ar,
|
ar: fields.ar,
|
||||||
|
previewAr: fields.previewAr,
|
||||||
|
previewSha256: fields.previewSha256,
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
// Битый attach-блок скрываем из UI, но не ломаем отображение текста.
|
// Битый attach-блок скрываем из UI, но не ломаем отображение текста.
|
||||||
@@ -236,7 +262,7 @@ function createFileCard({ item, url, messageTimestampMs, slide }) {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMediaSlide({ item, url, kind, messageTimestampMs, slide }) {
|
function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl = '' }) {
|
||||||
const frame = document.createElement('div');
|
const frame = document.createElement('div');
|
||||||
frame.className = `message-attachment-media-frame message-attachment-media-frame--${kind}`;
|
frame.className = `message-attachment-media-frame message-attachment-media-frame--${kind}`;
|
||||||
frame.role = 'button';
|
frame.role = 'button';
|
||||||
@@ -264,6 +290,7 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide }) {
|
|||||||
const video = document.createElement('video');
|
const video = document.createElement('video');
|
||||||
video.className = 'message-attachment-media';
|
video.className = 'message-attachment-media';
|
||||||
video.src = url;
|
video.src = url;
|
||||||
|
if (previewUrl) video.poster = previewUrl;
|
||||||
video.preload = 'metadata';
|
video.preload = 'metadata';
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
video.playsInline = true;
|
video.playsInline = true;
|
||||||
@@ -320,11 +347,12 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
|
|||||||
const item = items[index];
|
const item = items[index];
|
||||||
const kind = getAttachmentKind(item);
|
const kind = getAttachmentKind(item);
|
||||||
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
|
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
|
||||||
|
const previewUrl = item.preview?.ar ? buildArweaveDataUrl({ gateway, txId: item.preview.ar }) : '';
|
||||||
slide.dataset.unavailable = '0';
|
slide.dataset.unavailable = '0';
|
||||||
slide.replaceChildren();
|
slide.replaceChildren();
|
||||||
slide.append(kind === 'file'
|
slide.append(kind === 'file'
|
||||||
? createFileCard({ item, url, messageTimestampMs, slide })
|
? createFileCard({ item, url, messageTimestampMs, slide })
|
||||||
: createMediaSlide({ item, url, kind, messageTimestampMs, slide }));
|
: createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl }));
|
||||||
prev.hidden = items.length <= 1 || index <= 0;
|
prev.hidden = items.length <= 1 || index <= 0;
|
||||||
next.hidden = items.length <= 1 || index >= items.length - 1;
|
next.hidden = items.length <= 1 || index >= items.length - 1;
|
||||||
counter.textContent = `${index + 1} из ${items.length}`;
|
counter.textContent = `${index + 1} из ${items.length}`;
|
||||||
|
|||||||
@@ -17,6 +17,34 @@ const BASE58_MAP = (() => {
|
|||||||
return out;
|
return out;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
function encodeBase58(bytesLike) {
|
||||||
|
const bytes = bytesLike instanceof Uint8Array ? bytesLike : Uint8Array.from(bytesLike || []);
|
||||||
|
if (!bytes.length) return '';
|
||||||
|
|
||||||
|
const digits = [0];
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
let carry = bytes[i];
|
||||||
|
for (let j = 0; j < digits.length; j += 1) {
|
||||||
|
const value = (digits[j] << 8) + carry;
|
||||||
|
digits[j] = value % 58;
|
||||||
|
carry = (value / 58) | 0;
|
||||||
|
}
|
||||||
|
while (carry > 0) {
|
||||||
|
digits.push(carry % 58);
|
||||||
|
carry = (carry / 58) | 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < bytes.length && bytes[i] === 0; i += 1) {
|
||||||
|
out += BASE58_ALPHABET[0];
|
||||||
|
}
|
||||||
|
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
||||||
|
out += BASE58_ALPHABET[digits[i]];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeEndpoint(url) {
|
function normalizeEndpoint(url) {
|
||||||
const raw = String(url || '').trim();
|
const raw = String(url || '').trim();
|
||||||
if (!raw) return DEFAULT_SOLANA_ENDPOINT;
|
if (!raw) return DEFAULT_SOLANA_ENDPOINT;
|
||||||
@@ -65,13 +93,27 @@ function decodeBase58(input) {
|
|||||||
async function keypairFromPkcs8(pkcs8B64) {
|
async function keypairFromPkcs8(pkcs8B64) {
|
||||||
const solana = await loadSolanaLib();
|
const solana = await loadSolanaLib();
|
||||||
const seed32 = extractClientKey32FromStoredValue(pkcs8B64);
|
const seed32 = extractClientKey32FromStoredValue(pkcs8B64);
|
||||||
|
try {
|
||||||
return solana.Keypair.fromSeed(seed32);
|
return solana.Keypair.fromSeed(seed32);
|
||||||
|
} finally {
|
||||||
|
seed32.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function keypairFromStoredSecret(storedSecret) {
|
||||||
|
const solana = await loadSolanaLib();
|
||||||
|
const seed32 = extractClientKey32FromStoredValue(storedSecret);
|
||||||
|
try {
|
||||||
|
return solana.Keypair.fromSeed(seed32);
|
||||||
|
} finally {
|
||||||
|
seed32.fill(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRandomSolanaWallet() {
|
export async function createRandomSolanaWallet() {
|
||||||
const solana = await loadSolanaLib();
|
const solana = await loadSolanaLib();
|
||||||
const keypair = solana.Keypair.generate();
|
const keypair = solana.Keypair.generate();
|
||||||
const privateKey32Base58 = solana.bs58.encode(keypair.secretKey.slice(0, 32));
|
const privateKey32Base58 = encodeBase58(keypair.secretKey.slice(0, 32));
|
||||||
return {
|
return {
|
||||||
address: keypair.publicKey.toBase58(),
|
address: keypair.publicKey.toBase58(),
|
||||||
privateKey32Base58,
|
privateKey32Base58,
|
||||||
@@ -107,7 +149,7 @@ export async function getWalletFromStoredClientKey({ login, storagePwd }) {
|
|||||||
if (!clientPrivate) {
|
if (!clientPrivate) {
|
||||||
throw new Error('На устройстве не найден client.key');
|
throw new Error('На устройстве не найден client.key');
|
||||||
}
|
}
|
||||||
const keypair = await keypairFromPkcs8(clientPrivate);
|
const keypair = await keypairFromStoredSecret(clientPrivate);
|
||||||
return {
|
return {
|
||||||
address: keypair.publicKey.toBase58(),
|
address: keypair.publicKey.toBase58(),
|
||||||
keypair,
|
keypair,
|
||||||
@@ -115,6 +157,66 @@ export async function getWalletFromStoredClientKey({ login, storagePwd }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWalletFromStoredRootKey({ login, storagePwd }) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
const cleanPwd = String(storagePwd || '').trim();
|
||||||
|
if (!cleanLogin || !cleanPwd) {
|
||||||
|
throw new Error('Нет активной сессии для доступа к root key');
|
||||||
|
}
|
||||||
|
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
|
||||||
|
const rootPrivate = String(secrets?.rootKey || '').trim();
|
||||||
|
if (!rootPrivate) {
|
||||||
|
throw new Error('На устройстве не найден root key');
|
||||||
|
}
|
||||||
|
const keypair = await keypairFromStoredSecret(rootPrivate);
|
||||||
|
return {
|
||||||
|
address: keypair.publicKey.toBase58(),
|
||||||
|
keypair,
|
||||||
|
rootPrivatePkcs8B64: rootPrivate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStoredSolanaWalletChoices({ login, storagePwd, includeRoot = true } = {}) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
const cleanPwd = String(storagePwd || '').trim();
|
||||||
|
if (!cleanLogin || !cleanPwd) {
|
||||||
|
throw new Error('Нет активной сессии для доступа к ключам');
|
||||||
|
}
|
||||||
|
|
||||||
|
const choices = [];
|
||||||
|
const clientWallet = await getWalletFromStoredClientKey({ login: cleanLogin, storagePwd: cleanPwd });
|
||||||
|
choices.push({
|
||||||
|
id: 'client-key',
|
||||||
|
keySource: 'client',
|
||||||
|
label: 'client key',
|
||||||
|
address: clientWallet.address,
|
||||||
|
keypair: clientWallet.keypair,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (includeRoot) {
|
||||||
|
try {
|
||||||
|
const rootWallet = await getWalletFromStoredRootKey({ login: cleanLogin, storagePwd: cleanPwd });
|
||||||
|
choices.push({
|
||||||
|
id: 'root-key',
|
||||||
|
keySource: 'root',
|
||||||
|
label: 'root key',
|
||||||
|
address: rootWallet.address,
|
||||||
|
keypair: rootWallet.keypair,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// root key на устройстве может отсутствовать, это допустимо
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return choices;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encodeSolanaSecretKeyBase58(secretKey) {
|
||||||
|
const bytes = secretKey instanceof Uint8Array ? secretKey : Uint8Array.from(secretKey || []);
|
||||||
|
if (!bytes.length) throw new Error('Пустой Solana secret key');
|
||||||
|
return encodeBase58(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBalanceSol({ endpoint, address }) {
|
export async function getBalanceSol({ endpoint, address }) {
|
||||||
const solana = await loadSolanaLib();
|
const solana = await loadSolanaLib();
|
||||||
const rpc = normalizeEndpoint(endpoint);
|
const rpc = normalizeEndpoint(endpoint);
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { getBalanceSol, getStoredSolanaWalletChoices, encodeSolanaSecretKeyBase58, formatSol } from './solana-wallet-service.js';
|
||||||
|
import { state } from '../state.js';
|
||||||
|
|
||||||
|
const FREE_TURBO_UPLOAD_BYTES = 100 * 1024;
|
||||||
|
const WINC_PER_CREDIT = 1_000_000_000_000n;
|
||||||
|
|
||||||
|
let turboLibPromise = null;
|
||||||
|
|
||||||
|
function parseSolToLamports(amountSol) {
|
||||||
|
const raw = String(amountSol ?? '').trim().replace(',', '.');
|
||||||
|
const match = raw.match(/^(\d+)(?:\.(\d+))?$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Сумма пополнения должна быть числом');
|
||||||
|
}
|
||||||
|
const intPart = BigInt(match[1] || '0');
|
||||||
|
const frac = String(match[2] || '');
|
||||||
|
if (frac.length > 9) {
|
||||||
|
throw new Error('Слишком много знаков после запятой для SOL');
|
||||||
|
}
|
||||||
|
const fracPadded = `${frac}${'0'.repeat(9 - frac.length)}`;
|
||||||
|
const lamports = (intPart * 1_000_000_000n) + BigInt(fracPadded || '0');
|
||||||
|
if (lamports <= 0n) {
|
||||||
|
throw new Error('Сумма пополнения должна быть больше 0');
|
||||||
|
}
|
||||||
|
return lamports.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKeySource(value) {
|
||||||
|
return String(value || '').trim().toLowerCase() === 'root' ? 'root' : 'client';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTurboLib() {
|
||||||
|
if (!turboLibPromise) {
|
||||||
|
turboLibPromise = import('../vendor/turbo-sdk.web.bundle.js');
|
||||||
|
}
|
||||||
|
return turboLibPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTurboWalletChoice({ login, storagePwd, keySource = 'client' } = {}) {
|
||||||
|
const normalizedKeySource = normalizeKeySource(keySource);
|
||||||
|
const choices = await getStoredSolanaWalletChoices({ login, storagePwd, includeRoot: true });
|
||||||
|
const selected = choices.find((item) => item.keySource === normalizedKeySource) || choices[0] || null;
|
||||||
|
if (!selected?.keypair) {
|
||||||
|
throw new Error(normalizedKeySource === 'root'
|
||||||
|
? 'На устройстве не найден root key для Turbo.'
|
||||||
|
: 'На устройстве не найден client key для Turbo.');
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTurboClientFromWalletChoice(choice) {
|
||||||
|
const moduleRef = await loadTurboLib();
|
||||||
|
const TurboFactory = moduleRef?.TurboFactory;
|
||||||
|
if (!TurboFactory?.authenticated) {
|
||||||
|
throw new Error('Turbo SDK не удалось инициализировать.');
|
||||||
|
}
|
||||||
|
const privateKey = await encodeSolanaSecretKeyBase58(choice.keypair.secretKey);
|
||||||
|
return TurboFactory.authenticated({
|
||||||
|
privateKey,
|
||||||
|
token: 'solana',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function creditsFromWinc(winc) {
|
||||||
|
const value = BigInt(String(winc || '0'));
|
||||||
|
return Number(value) / Number(WINC_PER_CREDIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortAddress(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (raw.length <= 16) return raw;
|
||||||
|
return `${raw.slice(0, 8)}...${raw.slice(-6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFreeTurboUploadSize(byteLength) {
|
||||||
|
const bytes = Number(byteLength || 0);
|
||||||
|
return Number.isFinite(bytes) && bytes > 0 && bytes <= FREE_TURBO_UPLOAD_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFreeTurboUploadBytesLimit() {
|
||||||
|
return FREE_TURBO_UPLOAD_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTurboCredits(value, digits = 6) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return '0';
|
||||||
|
return n.toLocaleString('ru-RU', {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTurboContextForStoredSolanaKey({ login, storagePwd, keySource = 'client' } = {}) {
|
||||||
|
const choice = await getTurboWalletChoice({ login, storagePwd, keySource });
|
||||||
|
const turbo = await createTurboClientFromWalletChoice(choice);
|
||||||
|
return {
|
||||||
|
turbo,
|
||||||
|
address: choice.address,
|
||||||
|
keySource: choice.keySource,
|
||||||
|
label: choice.label,
|
||||||
|
keypair: choice.keypair,
|
||||||
|
shortAddress: shortAddress(choice.address),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTurboBalanceForStoredSolanaKey({ login, storagePwd, keySource = 'client' } = {}) {
|
||||||
|
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
|
||||||
|
const balance = await ctx.turbo.getBalance(ctx.address);
|
||||||
|
const solanaBalance = await getBalanceSol({
|
||||||
|
endpoint: state.registrationPayment?.endpoint || state.entrySettings?.solanaRpc || undefined,
|
||||||
|
address: ctx.address,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...ctx,
|
||||||
|
controlledWinc: String(balance.controlledWinc || '0'),
|
||||||
|
spendableWinc: String(balance.winc || '0'),
|
||||||
|
effectiveWinc: String(balance.effectiveBalance || '0'),
|
||||||
|
controlledCredits: creditsFromWinc(balance.controlledWinc || '0'),
|
||||||
|
spendableCredits: creditsFromWinc(balance.winc || '0'),
|
||||||
|
effectiveCredits: creditsFromWinc(balance.effectiveBalance || '0'),
|
||||||
|
solBalance: solanaBalance.sol,
|
||||||
|
solLamports: solanaBalance.lamports,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function estimateTurboUploadPrice({ login, storagePwd, keySource = 'client', byteLength } = {}) {
|
||||||
|
const bytes = Number(byteLength);
|
||||||
|
if (!Number.isInteger(bytes) || bytes <= 0) {
|
||||||
|
throw new Error('Некорректный размер файла для Turbo.');
|
||||||
|
}
|
||||||
|
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
|
||||||
|
const rows = await ctx.turbo.getUploadCosts({ bytes: [bytes] });
|
||||||
|
const price = Array.isArray(rows) ? rows[0] : null;
|
||||||
|
if (!price) {
|
||||||
|
throw new Error('Turbo не вернул цену загрузки.');
|
||||||
|
}
|
||||||
|
const winc = String(price.winc || '0');
|
||||||
|
return {
|
||||||
|
...ctx,
|
||||||
|
byteLength: bytes,
|
||||||
|
isFree: isFreeTurboUploadSize(bytes),
|
||||||
|
winc,
|
||||||
|
credits: creditsFromWinc(winc),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFileWithTurbo({
|
||||||
|
login,
|
||||||
|
storagePwd,
|
||||||
|
keySource = 'client',
|
||||||
|
file,
|
||||||
|
tags = [],
|
||||||
|
shineType = 'attachment',
|
||||||
|
onProgress,
|
||||||
|
} = {}) {
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
throw new Error('Выберите файл для загрузки через Turbo.');
|
||||||
|
}
|
||||||
|
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
|
||||||
|
const result = await ctx.turbo.uploadFile({
|
||||||
|
file,
|
||||||
|
dataItemOpts: {
|
||||||
|
tags: [
|
||||||
|
{ name: 'Content-Type', value: String(file.type || 'application/octet-stream') },
|
||||||
|
{ name: 'App-Name', value: 'SHiNE' },
|
||||||
|
{ name: 'SHiNE-Type', value: String(shineType || 'attachment') },
|
||||||
|
...((Array.isArray(tags) ? tags : []).filter((item) => item?.name && item?.value)),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
events: typeof onProgress === 'function'
|
||||||
|
? {
|
||||||
|
onProgress: ({ totalBytes, processedBytes, step }) => {
|
||||||
|
onProgress({
|
||||||
|
totalBytes,
|
||||||
|
processedBytes,
|
||||||
|
step,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...ctx,
|
||||||
|
id: String(result?.id || '').trim(),
|
||||||
|
owner: String(result?.owner || '').trim(),
|
||||||
|
winc: String(result?.winc || '0'),
|
||||||
|
credits: creditsFromWinc(result?.winc || '0'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function topUpTurboWithSolana({
|
||||||
|
login,
|
||||||
|
storagePwd,
|
||||||
|
keySource = 'client',
|
||||||
|
amountSol,
|
||||||
|
turboCreditDestinationAddress = '',
|
||||||
|
} = {}) {
|
||||||
|
const ctx = await getTurboContextForStoredSolanaKey({ login, storagePwd, keySource });
|
||||||
|
const tokenAmount = parseSolToLamports(amountSol);
|
||||||
|
const result = await ctx.turbo.topUpWithTokens({
|
||||||
|
tokenAmount,
|
||||||
|
turboCreditDestinationAddress: String(turboCreditDestinationAddress || '').trim() || undefined,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...ctx,
|
||||||
|
amountSol: formatSol(amountSol, 9),
|
||||||
|
lamports: tokenAmount,
|
||||||
|
txId: String(result?.id || '').trim(),
|
||||||
|
target: String(result?.target || '').trim(),
|
||||||
|
rewardWinc: String(result?.reward || '0'),
|
||||||
|
rewardCredits: creditsFromWinc(result?.reward || '0'),
|
||||||
|
};
|
||||||
|
}
|
||||||
+423
File diff suppressed because one or more lines are too long
@@ -2575,6 +2575,41 @@ textarea.input {
|
|||||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
|
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ar-attachment-history-tile--with-preview {
|
||||||
|
gap: 0.32rem;
|
||||||
|
padding: 0.42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-attachment-history-preview {
|
||||||
|
align-items: flex-end;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 0.42rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 7rem;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-attachment-history-preview-image {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ar-attachment-history-preview-badge {
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 999px;
|
||||||
|
bottom: 0.42rem;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
left: 0.42rem;
|
||||||
|
padding: 0.2rem 0.46rem;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
.ar-attachment-history-tile-head {
|
.ar-attachment-history-tile-head {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user