diff --git a/VERSION.properties b/VERSION.properties index b86ab23c..2ff0780b 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.4.21 +client.version=1.4.22 server.version=1.4.5 diff --git a/shine-UI/js/components/arweave-attachment-manager.js b/shine-UI/js/components/arweave-attachment-manager.js index 24e8beaf..244601c2 100644 --- a/shine-UI/js/components/arweave-attachment-manager.js +++ b/shine-UI/js/components/arweave-attachment-manager.js @@ -309,6 +309,7 @@ export function openArweaveAttachmentManager({ let selectedPreviewEnabled = false; let selectedPreviewFile = null; let selectedPreviewSha256 = ''; + let selectedPreviewPriceInfo = null; let priceInfo = null; let balanceInfo = null; let autoOpenedFileDialog = false; @@ -391,41 +392,48 @@ export function openArweaveAttachmentManager({ } }; - const renderTurboMeta = (metaEl) => { - if (!metaEl) return; + const renderTurboMeta = (mainMetaEl, previewMetaEl) => { + if (!mainMetaEl || !previewMetaEl) 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 ? ' (пока файлы до 100 KB бесплатно)' : ''; - metaEl.innerHTML = ` + mainMetaEl.innerHTML = `
Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}
Turbo balance: ${escapeHtml(turboCredits)}
SOL на этом ключе: ${solBalance}
Размер файла: ${escapeHtml(fileSize)}
- ${previewSize ? `
Размер превью: ${escapeHtml(previewSize)}
` : ''}
SHA-256: ${escapeHtml(selectedSha256 || '—')}
- ${selectedPreviewEnabled && selectedPreviewSha256 ? `
SHA-256 превью: ${escapeHtml(selectedPreviewSha256)}
` : ''}
Цена Turbo: ${escapeHtml(uploadPrice)}${freeBadge}
До ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))} бесплатно: ${escapeHtml(freeHint)}
`; + previewMetaEl.innerHTML = ''; + if (selectedPreviewEnabled && selectedPreviewFile) { + previewMetaEl.innerHTML = ` +
Файл превью
+
Имя: ${escapeHtml(selectedPreviewFile.name)}
+
Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}
+
SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}
+
Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${formatTurboCredits(selectedPreviewPriceInfo.credits, 6)} credits` : '—')}
+ `; + } }; - const refreshTurboBalance = async (metaEl, errorEl) => { + const refreshTurboBalance = async (mainMetaEl, previewMetaEl, errorEl) => { try { balanceInfo = await getTurboBalanceForStoredSolanaKey({ login: cleanLogin, storagePwd: cleanStoragePwd, keySource: selectedTurboKeySource, }); - renderTurboMeta(metaEl); + renderTurboMeta(mainMetaEl, previewMetaEl); if (errorEl && String(errorEl.textContent || '').includes('Turbo balance')) { setText(errorEl, ''); } @@ -434,7 +442,7 @@ export function openArweaveAttachmentManager({ } }; - const promptTurboTopUp = async (metaEl, errorEl) => { + const promptTurboTopUp = async (mainMetaEl, previewMetaEl, errorEl) => { const value = window.prompt('Сколько SOL перевести в Turbo?', '0.01'); if (value == null) return; setText(errorEl, 'Пополняем Turbo...'); @@ -445,7 +453,7 @@ export function openArweaveAttachmentManager({ keySource: selectedTurboKeySource, amountSol: value, }); - await refreshTurboBalance(metaEl, errorEl); + await refreshTurboBalance(mainMetaEl, previewMetaEl, errorEl); setText(errorEl, `Turbo пополнен. Tx: ${result.txId || 'отправлено'}`); } catch (error) { setText(errorEl, error?.message || 'Не удалось пополнить Turbo.'); @@ -456,6 +464,7 @@ export function openArweaveAttachmentManager({ selectedPreviewEnabled = false; selectedPreviewFile = null; selectedPreviewSha256 = ''; + selectedPreviewPriceInfo = null; }; const isPreviewEligibleForCurrentFile = () => ( @@ -468,30 +477,36 @@ export function openArweaveAttachmentManager({ return Number(selectedFile?.size || 0) + Number(selectedPreviewEnabled ? (selectedPreviewFile?.size || 0) : 0); }; - const renderArweaveMeta = (metaEl) => { - if (!metaEl) return; - metaEl.innerHTML = ` + const renderArweaveMeta = (mainMetaEl, previewMetaEl) => { + if (!mainMetaEl || !previewMetaEl) return; + mainMetaEl.innerHTML = ` ${isAvatarMode ? '' : `
Имя: ${escapeHtml(selectedFile?.name || 'file')}
`}
Размер: ${escapeHtml(formatBytes(selectedFile?.size || 0))}
SHA-256: ${escapeHtml(selectedSha256 || '—')}
- ${selectedPreviewEnabled && selectedPreviewFile - ? `
Превью: ${escapeHtml(selectedPreviewFile.name)} · ${escapeHtml(formatBytes(selectedPreviewFile.size))}
` - : ''} - ${selectedPreviewEnabled && selectedPreviewSha256 - ? `
SHA-256 превью: ${escapeHtml(selectedPreviewSha256)}
` - : ''}
Цена: ${escapeHtml(Number(priceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR
Баланс кошелька: ${escapeHtml(Number(balanceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR
`; + previewMetaEl.innerHTML = ''; + if (selectedPreviewEnabled && selectedPreviewFile) { + previewMetaEl.innerHTML = ` +
Файл превью
+
Имя: ${escapeHtml(selectedPreviewFile.name)}
+
Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}
+
SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}
+
Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${Number(selectedPreviewPriceInfo.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 })} AR` : '—')}
+ `; + } }; - const recalculateArweaveState = async (metaEl, errorEl, uploadBtn) => { + const recalculateArweaveState = async (mainMetaEl, previewMetaEl, errorEl, uploadBtn) => { uploadBtn.disabled = true; priceInfo = null; balanceInfo = null; + selectedPreviewPriceInfo = null; setText(errorEl, ''); if (!selectedFile || !selectedSha256) { - setText(metaEl, ''); + setText(mainMetaEl, ''); + setText(previewMetaEl, ''); return; } const wallet = selectedWallet(); @@ -500,6 +515,7 @@ export function openArweaveAttachmentManager({ let totalWinston = BigInt(mainPrice.winston); if (selectedPreviewEnabled && selectedPreviewFile) { const previewPrice = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedPreviewFile.size }); + selectedPreviewPriceInfo = previewPrice; totalWinston += BigInt(previewPrice.winston); } balanceInfo = await getArweaveBalance({ gateway: cleanGateway, address: wallet.address }); @@ -507,7 +523,7 @@ export function openArweaveAttachmentManager({ ar: Number(totalWinston) / 1e12, winston: totalWinston.toString(), }; - renderArweaveMeta(metaEl); + renderArweaveMeta(mainMetaEl, previewMetaEl); const hasFunds = BigInt(balanceInfo.winston) >= totalWinston; if (!hasFunds) { setText(errorEl, 'Недостаточно AR на выбранном кошельке.'); @@ -516,13 +532,14 @@ export function openArweaveAttachmentManager({ uploadBtn.disabled = false; }; - const refreshTurboStateForCurrentFile = async (metaEl, errorEl, uploadBtn) => { + const refreshTurboStateForCurrentFile = async (mainMetaEl, previewMetaEl, errorEl, uploadBtn) => { uploadBtn.disabled = true; priceInfo = null; balanceInfo = null; + selectedPreviewPriceInfo = null; setText(errorEl, ''); if (!selectedFile || !selectedSha256) { - renderTurboMeta(metaEl); + renderTurboMeta(mainMetaEl, previewMetaEl); return; } try { @@ -540,6 +557,7 @@ export function openArweaveAttachmentManager({ keySource: selectedTurboKeySource, byteLength: selectedPreviewFile.size, }); + selectedPreviewPriceInfo = previewPrice; totalWinc += BigInt(previewPrice.winc); } priceInfo = { @@ -554,7 +572,7 @@ export function openArweaveAttachmentManager({ storagePwd: cleanStoragePwd, keySource: selectedTurboKeySource, }); - renderTurboMeta(metaEl); + renderTurboMeta(mainMetaEl, previewMetaEl); if (!priceInfo.isFree && BigInt(balanceInfo.effectiveWinc) < BigInt(priceInfo.winc)) { setText(errorEl, 'Turbo balance недостаточен. Нажмите «Пополнить Turbo».'); return; @@ -591,11 +609,12 @@ export function openArweaveAttachmentManager({ `} +
-
+

${turboMode ? '' : ''} @@ -609,7 +628,8 @@ export function openArweaveAttachmentManager({ const walletEl = root.querySelector('#ar-attach-wallet'); const turboKeyEl = root.querySelector('#turbo-key-source'); const fileEl = root.querySelector('#ar-attach-file'); - const metaEl = root.querySelector('[data-meta="true"]'); + const mainMetaEl = root.querySelector('[data-meta-main="true"]'); + const previewMetaEl = root.querySelector('[data-meta-preview="true"]'); const errorEl = root.querySelector('[data-error="true"]'); const uploadBtn = root.querySelector('[data-action="upload"]'); const previewOptionEl = root.querySelector('[data-preview-option="true"]'); @@ -648,10 +668,10 @@ export function openArweaveAttachmentManager({ turboKeyEl.addEventListener('change', async () => { selectedTurboKeySource = String(turboKeyEl.value || 'client'); writeLastTurboKeySource(cleanLogin, selectedTurboKeySource); - await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn); + await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn); }); } - await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn); + await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn); } else { await loadWallets(errorEl); if (walletEl) { @@ -667,16 +687,17 @@ export function openArweaveAttachmentManager({ priceInfo = null; balanceInfo = null; if (fileEl) fileEl.value = ''; - setText(metaEl, ''); + setText(mainMetaEl, ''); + setText(previewMetaEl, ''); }); } } root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(resolve, null)); root.querySelector('[data-action="topup"]')?.addEventListener('click', async () => { - await promptTurboTopUp(metaEl, errorEl); + await promptTurboTopUp(mainMetaEl, previewMetaEl, errorEl); if (turboMode && selectedFile && selectedSha256) { - await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn); + await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn); } }); if (!turboMode) { @@ -691,7 +712,8 @@ export function openArweaveAttachmentManager({ balanceInfo = null; uploadBtn.disabled = true; setText(errorEl, ''); - setText(metaEl, ''); + setText(mainMetaEl, ''); + setText(previewMetaEl, ''); if (previewToggleEl instanceof HTMLInputElement) previewToggleEl.checked = false; if (previewOptionEl) previewOptionEl.hidden = true; if (!selectedFile) return; @@ -709,9 +731,9 @@ export function openArweaveAttachmentManager({ previewOptionEl.hidden = false; } if (turboMode) { - await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn); + await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn); } else { - await recalculateArweaveState(metaEl, errorEl, uploadBtn); + await recalculateArweaveState(mainMetaEl, previewMetaEl, errorEl, uploadBtn); } } catch (error) { setText(errorEl, error?.message || 'Не удалось подготовить файл.'); @@ -735,9 +757,9 @@ export function openArweaveAttachmentManager({ selectedPreviewSha256 = await sha256HexFromArrayBuffer(await selectedPreviewFile.arrayBuffer()); } if (turboMode) { - await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn); + await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, errorEl, uploadBtn); } else { - await recalculateArweaveState(metaEl, errorEl, uploadBtn); + await recalculateArweaveState(mainMetaEl, previewMetaEl, errorEl, uploadBtn); } } catch (error) { resetPreviewState(); diff --git a/shine-UI/js/services/attachment-format.js b/shine-UI/js/services/attachment-format.js index 57172cbd..4167e332 100644 --- a/shine-UI/js/services/attachment-format.js +++ b/shine-UI/js/services/attachment-format.js @@ -286,11 +286,21 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU img.loading = 'lazy'; img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true }); frame.append(img); + } else if (previewUrl) { + const img = document.createElement('img'); + img.className = 'message-attachment-media'; + img.src = previewUrl; + img.alt = `${item.name} preview`; + img.loading = 'lazy'; + img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true }); + const play = document.createElement('span'); + play.className = 'message-attachment-play'; + play.textContent = '▶'; + frame.append(img, play); } else { const video = document.createElement('video'); video.className = 'message-attachment-media'; video.src = url; - if (previewUrl) video.poster = previewUrl; video.preload = 'metadata'; video.muted = true; video.playsInline = true; diff --git a/Черновик_TEXT_CHANNEL_META_для_каналов.md b/Черновик_TEXT_CHANNEL_META_для_каналов.md new file mode 100644 index 00000000..081d0ef9 --- /dev/null +++ b/Черновик_TEXT_CHANNEL_META_для_каналов.md @@ -0,0 +1,297 @@ +# Черновик `TEXT_CHANNEL_META` для каналов SHiNE + +## Статус документа + +Этот файл является рабочим черновиком и не считается финальной спецификацией. + +Важно: + +- это не окончательный формат; +- точная версия должна быть зафиксирована отдельно при начале реализации; +- до реализации допустимы изменения синтаксиса, ограничений и полей БД; +- документ временно лежит в корне проекта как согласованный draft. + +## Назначение + +Нужен специальный скрытый тип текстового сообщения канала, который: + +- технически хранится в блокчейне как `TEXT`-блок; +- не показывается пользователям как обычное сообщение ленты; +- может писать только владелец канала; +- не редактируется; +- задаёт полное текущее состояние метаданных канала. + +Через этот блок должны обновляться: + +- человекочитаемое заглавие канала; +- аватар канала; +- описание канала. + +## Место в формате блокчейна + +- `msg_type = 1` +- `subType = 70` +- имя подтипа: `TEXT_CHANNEL_META` + +Этот подтип относится к текстовым блокам, но трактуется как скрытое техническое сообщение канала. + +## Общая модель применения + +`TEXT_CHANNEL_META`: + +- пишется в линию конкретного канала; +- задаёт полный снимок метаданных; +- при чтении канала сервер берёт последний валидный `TEXT_CHANNEL_META` в линии; +- более старые `TEXT_CHANNEL_META` остаются в истории блокчейна, но не участвуют в вычислении текущего состояния; +- если meta-блок не найден, используются базовые данные канала без override-полей. + +## Общий формат текста + +Внутри `TEXT_CHANNEL_META` текст строится так: + +1. в начале идут технические теги ``; +2. после тегов идёт обычный UTF-8 текст; +3. этот хвостовой текст считается описанием канала. + +Пример: + +```text + + +Тут описание канала. +``` + +## Поддерживаемые теги + +### 1. Тег заглавия + +Формат: + +```text + +``` + +Смысл: + +- задаёт красивое отображаемое имя канала; +- хранится как обычный UTF-8 текст; +- это не технический slug и не системное имя. + +### 2. Тег аватара + +Формат: + +```text + +``` + +Смысл: + +- задаёт аватар канала; +- `ar` хранит только `txId`, без полного URL; +- конкретный gateway/URL уже выбирает клиент или сервер. + +## Правила порядка + +- теги должны идти только в начале текста; +- после начала обычного описания новые теги больше не допускаются; +- рекомендуемый порядок: + 1. `title` + 2. `avatar` + 3. текст описания +- каждый тег допустим не более одного раза. + +## Правила отсутствующих полей + +- если `title` отсутствует, используется обычное техническое имя канала; +- если `avatar` отсутствует, аватар у канала отсутствует; +- если текста после тегов нет, описание считается пустым; +- если тегов нет вообще, но текст есть, это означает пустые `title` и `avatar`, а текст используется как описание. + +## Полный снимок состояния + +Каждый новый `TEXT_CHANNEL_META` задаёт полное состояние канала целиком. + +Это значит: + +- новый блок не дозаполняет старые поля; +- новый блок не наследует значения из прошлого meta-блока; +- если в новом блоке нет `title`, то красивое имя сбрасывается к обычному имени канала; +- если в новом блоке нет `avatar`, аватар сбрасывается; +- если в новом блоке нет текста описания, описание становится пустым. + +## Ограничения для тега `title` + +`title` хранится как UTF-8 строка, но для упрощения формата запрещаются следующие символы: + +- `<` +- `>` +- `;` +- табуляция +- перевод строки +- `\0` + +Дополнительно: + +- максимальная длина `title` — `50` символов; +- допускаются обычные Unicode-символы, включая кириллицу, латиницу, эмодзи и декоративные символы, если они не нарушают запреты выше. + +## Ограничения для описания + +- описание хранится как обычный UTF-8 текст; +- максимальная длина — `250` символов. + +Если описание длиннее лимита, meta-блок должен считаться невалидным. + +## Ограничения для тега `avatar` + +Обязательные поля тега: + +- `v` +- `size` +- `sha256` +- `ar` + +Правила: + +- `size` — размер файла в байтах; +- `sha256` — SHA-256 файла в hex; +- `sha256` должен иметь длину ровно `64` hex-символа; +- `ar` — только `txId` Arweave; +- длина `ar` должна соответствовать длине идентификатора Arweave-транзакции; +- имя файла в meta-теге аватара не хранится. + +## Валидация блока + +Блок `TEXT_CHANNEL_META` считается валидным только если одновременно соблюдены все условия: + +- теги находятся строго в начале текста; +- каждый тип тега встречается не более одного раза; +- `title` удовлетворяет ограничениям; +- `avatar`-тег имеет все обязательные поля и корректные значения; +- длина описания не превышает лимит. + +Если блок невалиден: + +- весь meta-блок не применяется целиком; +- сервер не должен частично брать из него только отдельные поля. + +## Что именно хранится в текущем состоянии канала + +Текущее вычисленное состояние канала должно включать: + +- техническое имя канала; +- красивое отображаемое имя; +- описание; +- `ava_ar`; +- `ava_sha256`; +- `ava_size`; +- время последнего meta-обновления. + +## База данных + +Базовую таблицу каналов предлагается не менять концептуально, а расширить существующую модель хранения каналов. + +Текущая основа уже есть в таблице: + +- `channel_names_state` + +В неё предлагается добавить поля: + +- `ava_ar` +- `ava_sha256` +- `ava_size` +- `meta_updated_at_ms` + +При этом: + +- техническое имя канала продолжает храниться отдельно; +- красивое имя хранится в `display_name`; +- описание хранится в `channel_description`. + +## Поведение API и чтения каналов + +Обычным пользователям `TEXT_CHANNEL_META` не должен приходить как обычное сообщение ленты. + +Это означает: + +- в `GetChannelMessages` такие блоки не показываются как сообщения; +- в обычных списках каналов клиент получает уже вычисленное текущее состояние канала; +- в debug/raw-чтении сам блок может оставаться доступным как часть блокчейна. + +## UI-заметки + +### 1. В обычной ленте + +`TEXT_CHANNEL_META` не показывается как обычное текстовое сообщение. + +### 2. Системная карточка + +UI может показывать специальную системную карточку: + +- для текущего состояния канала; +- для стартового состояния при создании канала. + +### 3. Что показывать по нажатию + +По нажатию на системную карточку нужно показывать полную информацию: + +- дата и время; +- заглавие; +- аватар; +- описание. + +### 4. История UI + +На первом этапе UI показывает только текущую системную карточку состояния. + +Полная история всех прошлых meta-обновлений в UI на этом этапе не требуется. + +## Поведение при создании канала + +Для UI полезно также иметь отдельный визуальный системный блок в начале канала: + +- «канал создан»; +- по нажатию можно показать стартовые данные канала; +- если на момент создания уже есть заглавие/ава/описание, UI показывает именно их. + +Это UI-заметка и не требует отдельного нового блокчейн-формата поверх `TEXT_CHANNEL_META`. + +## Краткое резюме согласованной схемы + +- `msg_type=1` +- `subType=70` +- имя: `TEXT_CHANNEL_META` +- это скрытый технический текстовый блок канала +- порядок: теги в начале, потом описание +- теги: + - `` + - `` +- `title`: + - UTF-8 + - максимум `50` символов + - нельзя `<`, `>`, `;`, таб, перевод строки, `\0` +- описание: + - UTF-8 + - максимум `250` символов +- `avatar`: + - `ar` только `txId` + - хранится без имени файла +- новый meta-блок задаёт полный снимок состояния +- сервер применяет только последний валидный meta-блок +- невалидный meta-блок не применяется целиком +- БД расширяется в той же модели каналов +- в UI обычным сообщением блок не показывается + +## Что ещё потребуется зафиксировать позже + +При переводе черновика в официальную спецификацию нужно будет дополнительно утвердить: + +- точный номер версии формата; +- точный способ подсчёта длины `title` и описания: + - по Unicode code points; + - по Java `String.length()`; + - или по UTF-8 байтам; +- точную проверку длины `ar`; +- список API-ответов, куда должны быть добавлены новые поля; +- правила миграции старых каналов без meta-блоков. diff --git a/Черновик_формата_вложений_в_сообщениях_блокчейна.md b/Черновик_формата_вложений_в_сообщениях_блокчейна.md new file mode 100644 index 00000000..5c5c86db --- /dev/null +++ b/Черновик_формата_вложений_в_сообщениях_блокчейна.md @@ -0,0 +1,173 @@ +# Черновик формата вложений в сообщениях блокчейна SHiNE + +## Статус документа + +Этот файл пока является черновиком и рабочим описанием идеи. + +Важно: + +- это не финальная спецификация; +- точная версия формата будет зафиксирована после начала реальной реализации; +- до момента реализации допустимы изменения полей, синтаксиса и правил отображения; +- документ специально лежит в корне проекта как временная рабочая заметка. + +## Цель + +Нужен минимальный формат, позволяющий прикладывать файлы к текстовым сообщениям в каналах и тредах, не превращая само сообщение в отдельный файловый контейнер. + +Основная идея: + +- само сообщение остаётся обычным текстом; +- вложения описываются специальными техническими вставками в начале текста; +- сами файлы хранятся отдельно в архиве/Arweave; +- в сообщении хранятся только метаданные, достаточные для отображения и скачивания. + +## Общий принцип + +Если в начале текста сообщения стоит один или несколько блоков: + +```text + +``` + +клиент трактует их как описания вложений. + +После этих блоков идёт обычный пользовательский текст сообщения. + +Если технических блоков нет, сообщение считается обычным текстовым сообщением без вложений. + +## Формат одного блока вложения + +Общий вид: + +```text + +``` + +Где: + +- `attach` — тип технической вставки; +- `v=1` — текущая рабочая версия чернового формата; +- `name` — имя файла; +- `size` — размер файла в байтах; +- `sha256` — SHA-256 файла в hex; +- `ar` — идентификатор файла в архиве или адрес файла в Arweave. + +## Несколько вложений + +Если к одному сообщению приложено несколько файлов, блоки просто идут подряд в начале текста: + +```text + + +Текст сообщения +``` + +## Обязательные поля + +Для чернового варианта обязательными считаются: + +- `v` +- `name` +- `size` +- `sha256` +- `ar` + +Если какого-то из этих полей нет, клиент может: + +- игнорировать конкретный битый блок; +- не считать его валидным вложением; +- при этом продолжать обрабатывать остальные корректные блоки. + +## Почему без `mime` и без `kind` + +В минимальном варианте тип файла отдельно не хранится. + +Причины: + +- это экономит место; +- тип всё равно обычно определяется клиентом; +- для первого варианта достаточно имени файла, адреса, хэша и размера; +- превью и способ отображения клиент может решать сам по расширению имени файла или по попытке открыть файл. + +То есть в сообщении не хранится: + +- `mime`; +- `kind`; +- `width`; +- `height`; +- `duration`; +- `thumbnail`. + +Все эти поля можно добавить позже отдельной версией, если они реально понадобятся. + +## Логика клиента + +Клиент получает: + +- имя файла; +- размер; +- хэш; +- адрес файла. + +После этого клиент сам решает, как рендерить вложение: + +- если расширение похоже на изображение, можно пробовать показать preview; +- если расширение похоже на видео, можно пробовать встроенный видеоплеер; +- если расширение похоже на аудио, можно пробовать аудиоплеер; +- иначе показывать обычную кнопку скачивания. + +Если браузер или клиент не смог показать preview, должен быть fallback на скачивание файла. + +## Правила совместимости + +- Сообщение без `` остаётся обычным текстом. +- Старые клиенты, которые не умеют распознавать этот формат, могут показывать сообщение как обычный текст целиком. +- Новые клиенты могут скрывать технические блоки и показывать вместо них UI вложений. + +## Ограничения чернового варианта + +В текущем виде значения полей не предполагают сложного экранирования. + +Значит нужно отдельно договориться, как безопасно хранить `name`, если там встретятся символы: + +- `;` +- `=` +- `>` + +На этапе реальной реализации нужно выбрать один из вариантов: + +- жёстко ограничить допустимые символы в `name`; +- хранить имя файла в безопасно закодированном виде; +- добавить отдельное правило escape/encoding. + +Пока этот вопрос считается открытым. + +## Примеры + +### Одно вложение + +```text + +Привет, вот фото +``` + +### Два вложения + +```text + + +Смотри, прикрепил фото и документ +``` + +## Что надо зафиксировать позже + +Перед переводом этого черновика в официальную спецификацию нужно отдельно утвердить: + +- точное место этого формата в общей документации блокчейна; +- где именно используются такие вложения: каналы, треды, комментарии или иные текстовые блоки; +- финальные правила кодирования `name`; +- допустим ли полный URL в `ar` или только короткий `txId`; +- нужно ли ограничение на количество вложений в одном сообщении; +- нужна ли отдельная серверная или клиентская проверка расширения файла; +- как именно это будет отображаться в web UI и других клиентах.