UI: добавить карусель вложений

This commit is contained in:
AidarKC
2026-07-31 12:49:24 +04:00
parent f961947e1e
commit c530627078
7 changed files with 541 additions and 35 deletions
+262 -27
View File
@@ -2,6 +2,10 @@ import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './a
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 || '')
@@ -54,7 +58,9 @@ export function buildAttachmentBlock(attachment) {
export function composeMessageWithAttachments(text, attachments = []) {
const cleanText = String(text || '').trim();
const rows = (Array.isArray(attachments) ? attachments : []).map(buildAttachmentBlock);
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}` : ''}`;
}
@@ -98,35 +104,264 @@ export function parseMessageAttachments(rawText) {
};
}
export function createAttachmentListElement(attachments = [], { gateway = '', compact = false } = {}) {
const items = Array.isArray(attachments) ? attachments : [];
const wrap = document.createElement('div');
wrap.className = `message-attachments${compact ? ' message-attachments--compact' : ''}`;
items.forEach((raw) => {
let item;
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 {
item = normalizeAttachment(raw);
video.pause();
video.currentTime = 0;
} catch {
return;
// Если браузер не даёт управлять видео, просто продолжаем перелистывание.
}
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
const link = document.createElement('a');
link.className = 'message-attachment-card';
link.href = url;
link.target = '_blank';
link.rel = 'noopener';
link.title = 'Открыть файл в Arweave';
link.addEventListener('click', (event) => {
event.stopPropagation();
});
link.innerHTML = `
<span class="message-attachment-icon" aria-hidden="true">📎</span>
<span class="message-attachment-main">
<span class="message-attachment-name">${escapeHtml(item.name)}</span>
<span class="message-attachment-meta">${escapeHtml(formatBytes(item.size))} · ${escapeHtml(item.ar)}</span>
</span>
`;
wrap.append(link);
});
}
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);
}