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