import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './arweave-file-service.js'; const ATTACH_PREFIX = ']*)>\n?/u; export const MAX_MESSAGE_ATTACHMENTS = 10; const RECENT_UNAVAILABLE_MS = 20 * 60 * 1000; const IMAGE_EXTENSIONS = new Set(['apng', 'avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp']); const VIDEO_EXTENSIONS = new Set(['avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'ogg', 'ogv', 'webm']); export function escapeHtml(text) { return String(text || '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } export function formatBytes(bytes) { const value = Number(bytes || 0); if (!Number.isFinite(value) || value <= 0) return '0 B'; if (value < 1024) return `${value} B`; if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; return `${(value / (1024 * 1024)).toFixed(2)} MB`; } function normalizeName(name) { const clean = String(name || 'file') .replace(/[\u0000-\u001f\u007f]/g, '') .replace(/[\\/]/g, '_') .trim(); return clean || 'file'; } export function normalizeAttachment(input = {}) { const txId = String(input.ar || input.txId || '').trim(); const sha256Hex = String(input.sha256 || input.sha256Hex || '').trim().toLowerCase(); const size = Number(input.size || input.sizeBytes || 0); const name = normalizeName(input.name || input.fileName || 'file'); if (!validateArweaveTxId(txId)) throw new Error('Некорректный Transaction ID Arweave.'); if (!validateSha256Hex(sha256Hex)) throw new Error('Некорректный SHA-256 файла.'); if (!Number.isInteger(size) || size <= 0) throw new Error('Некорректный размер файла.'); return { v: 1, name, size, sha256: sha256Hex, ar: txId, uploadedAtMs: Number(input.uploadedAtMs || 0) || Date.now(), }; } export function buildAttachmentBlock(attachment) { const item = normalizeAttachment(attachment); const encodedName = encodeURIComponent(item.name); return ``; } export function composeMessageWithAttachments(text, attachments = []) { const cleanText = String(text || '').trim(); const rows = (Array.isArray(attachments) ? attachments : []) .slice(0, MAX_MESSAGE_ATTACHMENTS) .map(buildAttachmentBlock); if (!rows.length) return cleanText; return `${rows.join('\n')}${cleanText ? `\n${cleanText}` : ''}`; } function parseFields(rawFields) { const out = {}; String(rawFields || '').split(';').forEach((part) => { const eq = part.indexOf('='); if (eq <= 0) return; const key = part.slice(0, eq).trim(); const value = part.slice(eq + 1).trim(); if (key) out[key] = value; }); return out; } export function parseMessageAttachments(rawText) { let rest = String(rawText || ''); const attachments = []; while (rest.startsWith(ATTACH_PREFIX)) { const match = rest.match(ATTACH_BLOCK_RE); if (!match) break; const fields = parseFields(match[1]); try { attachments.push(normalizeAttachment({ name: decodeURIComponent(String(fields.name || 'file')), size: fields.size, sha256: fields.sha256, ar: fields.ar, })); } catch { // Битый attach-блок скрываем из UI, но не ломаем отображение текста. } rest = rest.slice(match[0].length); } return { attachments, text: rest.replace(/^\n+/u, ''), }; } export function getAttachmentExtension(name) { const clean = String(name || '').trim().toLowerCase(); const dot = clean.lastIndexOf('.'); if (dot < 0 || dot === clean.length - 1) return ''; return clean.slice(dot + 1).replace(/[^a-z0-9]/g, ''); } export function getAttachmentKind(attachment = {}) { const ext = getAttachmentExtension(attachment.name); if (IMAGE_EXTENSIONS.has(ext)) return 'image'; if (VIDEO_EXTENSIONS.has(ext)) return 'video'; return 'file'; } function isRecentMessage(messageTimestampMs) { const ts = Number(messageTimestampMs || 0); if (!Number.isFinite(ts) || ts <= 0) return false; return Date.now() - ts < RECENT_UNAVAILABLE_MS; } function createUnavailableBlock(messageTimestampMs) { const block = document.createElement('div'); block.className = 'message-attachment-unavailable'; block.textContent = isRecentMessage(messageTimestampMs) ? 'Скорее всего файл недоступен, потому что сообщение было создано недавно и файл ещё не распространился в Arweave. Попробуйте повторить через несколько минут.' : 'Файл недоступен.'; return block; } function stopVideos(root) { root.querySelectorAll('video').forEach((video) => { try { video.pause(); video.currentTime = 0; } catch { // Если браузер не даёт управлять видео, просто продолжаем перелистывание. } }); } function createDownloadLink(url, label = 'Скачать') { const link = document.createElement('a'); link.className = 'message-attachment-download'; link.href = url; link.target = '_blank'; link.rel = 'noopener'; link.download = ''; link.textContent = label; link.addEventListener('click', (event) => { event.stopPropagation(); }); return link; } function openAttachmentViewer({ item, url, kind }) { const root = document.getElementById('modal-root') || document.body; const overlay = document.createElement('div'); overlay.className = 'modal attachment-viewer-modal'; overlay.innerHTML = ` `; const close = () => { stopVideos(overlay); overlay.remove(); }; overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); }); overlay.querySelector('.attachment-viewer-close')?.addEventListener('click', close); const body = overlay.querySelector('.attachment-viewer-body'); if (kind === 'video') { const video = document.createElement('video'); video.className = 'attachment-viewer-media'; video.src = url; video.controls = true; video.autoplay = true; body.append(video); } else { const img = document.createElement('img'); img.className = 'attachment-viewer-media'; img.src = url; img.alt = item.name; body.append(img); } overlay.querySelector('.attachment-viewer-actions')?.append(createDownloadLink(url)); root.append(overlay); } function replaceWithUnavailable(slide, messageTimestampMs) { if (!slide || slide.dataset.unavailable === '1') return; slide.dataset.unavailable = '1'; slide.replaceChildren(createUnavailableBlock(messageTimestampMs)); } function createFileCard({ item, url, messageTimestampMs, slide }) { const ext = getAttachmentExtension(item.name).toUpperCase() || 'FILE'; const card = document.createElement('div'); card.className = 'message-attachment-file-card'; card.role = 'button'; card.tabIndex = 0; card.title = 'Скачать файл'; card.innerHTML = `
${escapeHtml(ext)}
${escapeHtml(item.name)}
${escapeHtml(formatBytes(item.size))} · ${escapeHtml(item.ar)}
`; const openFile = () => window.open(url, '_blank', 'noopener'); card.addEventListener('click', (event) => { event.stopPropagation(); openFile(); }); card.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); openFile(); }); card.append(createDownloadLink(url)); void fetch(url, { method: 'HEAD', cache: 'no-store' }) .then((response) => { if (!response.ok) replaceWithUnavailable(slide, messageTimestampMs); }) .catch(() => replaceWithUnavailable(slide, messageTimestampMs)); return card; } function createMediaSlide({ item, url, kind, messageTimestampMs, slide }) { const frame = document.createElement('div'); frame.className = `message-attachment-media-frame message-attachment-media-frame--${kind}`; frame.role = 'button'; frame.tabIndex = 0; frame.title = kind === 'video' ? 'Открыть видео' : 'Открыть изображение'; frame.addEventListener('click', (event) => { event.stopPropagation(); openAttachmentViewer({ item, url, kind }); }); frame.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); openAttachmentViewer({ item, url, kind }); }); if (kind === 'image') { const img = document.createElement('img'); img.className = 'message-attachment-media'; img.src = url; img.alt = item.name; img.loading = 'lazy'; img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true }); frame.append(img); } else { const video = document.createElement('video'); video.className = 'message-attachment-media'; video.src = url; video.preload = 'metadata'; video.muted = true; video.playsInline = true; video.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true }); const play = document.createElement('span'); play.className = 'message-attachment-play'; play.textContent = '▶'; frame.append(video, play); } frame.append(createDownloadLink(url)); return frame; } export function createAttachmentCarouselElement(attachments = [], { gateway = '', messageTimestampMs = 0 } = {}) { const items = (Array.isArray(attachments) ? attachments : []) .slice(0, MAX_MESSAGE_ATTACHMENTS) .map((raw) => { try { return normalizeAttachment(raw); } catch { return null; } }) .filter(Boolean); const wrap = document.createElement('div'); wrap.className = 'message-attachments message-attachment-carousel'; wrap.addEventListener('click', (event) => { event.stopPropagation(); }); if (!items.length) return wrap; let index = 0; let pointerStartX = 0; const viewport = document.createElement('div'); viewport.className = 'message-attachment-carousel-viewport'; const slide = document.createElement('div'); slide.className = 'message-attachment-slide'; const prev = document.createElement('button'); prev.type = 'button'; prev.className = 'message-attachment-arrow message-attachment-arrow--prev'; prev.textContent = '‹'; prev.setAttribute('aria-label', 'Предыдущее вложение'); const next = document.createElement('button'); next.type = 'button'; next.className = 'message-attachment-arrow message-attachment-arrow--next'; next.textContent = '›'; next.setAttribute('aria-label', 'Следующее вложение'); const counter = document.createElement('div'); counter.className = 'message-attachment-counter'; const render = () => { stopVideos(wrap); const item = items[index]; const kind = getAttachmentKind(item); const url = buildArweaveDataUrl({ gateway, txId: item.ar }); slide.dataset.unavailable = '0'; slide.replaceChildren(); slide.append(kind === 'file' ? createFileCard({ item, url, messageTimestampMs, slide }) : createMediaSlide({ item, url, kind, messageTimestampMs, slide })); prev.disabled = index <= 0; next.disabled = index >= items.length - 1; counter.textContent = `${index + 1} из ${items.length}`; }; const move = (delta) => { const nextIndex = Math.max(0, Math.min(items.length - 1, index + delta)); if (nextIndex === index) return; index = nextIndex; render(); }; prev.addEventListener('click', (event) => { event.stopPropagation(); move(-1); }); next.addEventListener('click', (event) => { event.stopPropagation(); move(1); }); viewport.addEventListener('pointerdown', (event) => { pointerStartX = event.clientX; }); viewport.addEventListener('pointerup', (event) => { const dx = event.clientX - pointerStartX; if (Math.abs(dx) < 40) return; move(dx < 0 ? 1 : -1); }); viewport.append(slide, prev, next); wrap.append(viewport, counter); render(); return wrap; } export function createAttachmentListElement(attachments = [], options = {}) { return createAttachmentCarouselElement(attachments, options); }