SHA256
UI: добавить карусель вложений
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user