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
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.4.1 client.version=1.4.2
server.version=1.4.0 server.version=1.4.0
+15 -3
View File
@@ -31,6 +31,8 @@
После последнего attach-блока идёт обычный пользовательский текст. Пользовательский текст может быть пустым, если есть хотя бы одно валидное вложение. После последнего attach-блока идёт обычный пользовательский текст. Пользовательский текст может быть пустым, если есть хотя бы одно валидное вложение.
Клиент SHiNE ограничивает одно сообщение максимум 10 вложениями и сохраняет порядок attach-блоков в том порядке, в котором пользователь прикрепил файлы.
## Поля ## Поля
Обязательные поля: Обязательные поля:
@@ -67,8 +69,18 @@ UI поддерживает три сценария:
1. читает валидные attach-блоки только из начала текста; 1. читает валидные attach-блоки только из начала текста;
2. скрывает технические attach-строки; 2. скрывает технические attach-строки;
3. отображает вложения над пользовательским текстом; 3. отображает вложения над пользовательским текстом;
4. каждое вложение показывает как карточку/ссылку на Arweave gateway; 4. показывает вложения горизонтальной каруселью: одно вложение на экране, переключение свайпом или стрелками, счётчик вида `1 из 3`;
5. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение. 5. определяет тип вложения по расширению имени файла:
- изображение показывает как ограниченное по размеру превью;
- видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию;
- обычный файл показывает как карточку с именем, расширением, размером и скачиванием;
6. при перелистывании останавливает воспроизводящееся видео;
7. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение.
Если файл по ссылке Arweave gateway недоступен, клиент показывает диагностический блок:
- если сообщение создано менее 20 минут назад: файл, скорее всего, ещё не распространился в Arweave, нужно повторить через несколько минут;
- если сообщение старше 20 минут: файл считается недоступным.
Старые клиенты без поддержки формата могут показывать attach-блоки как обычный текст. Старые клиенты без поддержки формата могут показывать attach-блоки как обычный текст.
@@ -79,6 +91,6 @@ UI поддерживает три сценария:
## Ограничения v1 ## Ограничения v1
- `ar` допускает только короткий Arweave `txId`, полный URL не используется; - `ar` допускает только короткий Arweave `txId`, полный URL не используется;
- превью типа image/video/audio не входит в формат v1; - MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла;
- MIME type, width, height, duration и thumbnail не записываются в блокчейн; - MIME type, width, height, duration и thumbnail не записываются в блокчейн;
- проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки. - проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки.
+6
View File
@@ -1,5 +1,11 @@
# История изменений документации блокчейна # История изменений документации блокчейна
## 2026-07-31 12:47:41 +0400
- Базовый коммит-ориентир: `f961947`.
- Для `SHiNE:attach v=1` уточнено клиентское отображение вложений: горизонтальная карусель над текстом сообщения, счётчик позиции, просмотр image/video в модальном окне и скачивание файлов.
- Зафиксирован клиентский лимит: максимум 10 вложений в одном сообщении с сохранением порядка attach-блоков.
- Описана диагностика недоступного файла через Arweave gateway: отдельное сообщение для свежих сообщений младше 20 минут и отдельное сообщение для старых недоступных файлов.
## 2026-07-30 11:12:47 +0400 ## 2026-07-30 11:12:47 +0400
- Базовый коммит-ориентир: `e9e6628`. - Базовый коммит-ориентир: `e9e6628`.
- Добавлен отдельный документ `docs/Blockchain/15_TEXT_Attachments.md` с полной спецификацией `SHiNE:attach v=1`. - Добавлен отдельный документ `docs/Blockchain/15_TEXT_Attachments.md` с полной спецификацией `SHiNE:attach v=1`.
+10 -2
View File
@@ -15,7 +15,8 @@ import { renderUserAvatar } from '../components/avatar-image.js';
import { openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js'; import { openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import { import {
composeMessageWithAttachments, composeMessageWithAttachments,
createAttachmentListElement, createAttachmentCarouselElement,
MAX_MESSAGE_ATTACHMENTS,
parseMessageAttachments, parseMessageAttachments,
} from '../services/attachment-format.js'; } from '../services/attachment-format.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js';
@@ -417,6 +418,10 @@ function openReplyModal({ onSubmit, navigate }) {
root.querySelector('#thread-reply-attach')?.addEventListener('click', async () => { root.querySelector('#thread-reply-attach')?.addEventListener('click', async () => {
try { try {
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
const item = await openArweaveAttachmentManager({ const item = await openArweaveAttachmentManager({
login: state.session.login, login: state.session.login,
storagePwd: state.session.storagePwdInMemory, storagePwd: state.session.storagePwdInMemory,
@@ -670,7 +675,10 @@ function renderNodeCard(node, heading, handlers, localNumber) {
card.append(authorTile); card.append(authorTile);
if (!isDeletedMessage && parsedText.attachments.length > 0) { if (!isDeletedMessage && parsedText.attachments.length > 0) {
card.append(createAttachmentListElement(parsedText.attachments, { gateway: state.entrySettings.arweaveServer })); card.append(createAttachmentCarouselElement(parsedText.attachments, {
gateway: state.entrySettings.arweaveServer,
messageTimestampMs: node?.createdAtMs,
}));
} }
card.append(body); card.append(body);
+14 -2
View File
@@ -21,7 +21,8 @@ import { renderUserAvatar } from '../components/avatar-image.js';
import { openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js'; import { openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js';
import { import {
composeMessageWithAttachments, composeMessageWithAttachments,
createAttachmentListElement, createAttachmentCarouselElement,
MAX_MESSAGE_ATTACHMENTS,
parseMessageAttachments, parseMessageAttachments,
} from '../services/attachment-format.js'; } from '../services/attachment-format.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js';
@@ -392,6 +393,10 @@ function openReplyModal({ onSubmit, navigate }) {
root.querySelector('#reply-attach')?.addEventListener('click', async () => { root.querySelector('#reply-attach')?.addEventListener('click', async () => {
try { try {
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
const item = await openArweaveAttachmentManager({ const item = await openArweaveAttachmentManager({
login: state.session.login, login: state.session.login,
storagePwd: state.session.storagePwdInMemory, storagePwd: state.session.storagePwdInMemory,
@@ -546,6 +551,10 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
root.querySelector('#channel-message-attach')?.addEventListener('click', async () => { root.querySelector('#channel-message-attach')?.addEventListener('click', async () => {
try { try {
if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) {
errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`;
return;
}
const item = await openArweaveAttachmentManager({ const item = await openArweaveAttachmentManager({
login: state.session.login, login: state.session.login,
storagePwd: state.session.storagePwdInMemory, storagePwd: state.session.storagePwdInMemory,
@@ -1004,7 +1013,10 @@ function renderPostCard(post, {
card.append(authorTile); card.append(authorTile);
if (!isDeletedMessage && parsedBody.attachments.length > 0) { if (!isDeletedMessage && parsedBody.attachments.length > 0) {
card.append(createAttachmentListElement(parsedBody.attachments, { gateway: state.entrySettings.arweaveServer })); card.append(createAttachmentCarouselElement(parsedBody.attachments, {
gateway: state.entrySettings.arweaveServer,
messageTimestampMs: post.timestampMs,
}));
} }
card.append(body); card.append(body);
+262 -27
View File
@@ -2,6 +2,10 @@ import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './a
const ATTACH_PREFIX = '<SHiNE:attach;'; const ATTACH_PREFIX = '<SHiNE:attach;';
const ATTACH_BLOCK_RE = /^<SHiNE:attach;([^>]*)>\n?/u; 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) { export function escapeHtml(text) {
return String(text || '') return String(text || '')
@@ -54,7 +58,9 @@ export function buildAttachmentBlock(attachment) {
export function composeMessageWithAttachments(text, attachments = []) { export function composeMessageWithAttachments(text, attachments = []) {
const cleanText = String(text || '').trim(); 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; if (!rows.length) return cleanText;
return `${rows.join('\n')}${cleanText ? `\n${cleanText}` : ''}`; return `${rows.join('\n')}${cleanText ? `\n${cleanText}` : ''}`;
} }
@@ -98,35 +104,264 @@ export function parseMessageAttachments(rawText) {
}; };
} }
export function createAttachmentListElement(attachments = [], { gateway = '', compact = false } = {}) { export function getAttachmentExtension(name) {
const items = Array.isArray(attachments) ? attachments : []; const clean = String(name || '').trim().toLowerCase();
const wrap = document.createElement('div'); const dot = clean.lastIndexOf('.');
wrap.className = `message-attachments${compact ? ' message-attachments--compact' : ''}`; if (dot < 0 || dot === clean.length - 1) return '';
items.forEach((raw) => { return clean.slice(dot + 1).replace(/[^a-z0-9]/g, '');
let item; }
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 { try {
item = normalizeAttachment(raw); video.pause();
video.currentTime = 0;
} catch { } 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; return wrap;
} }
export function createAttachmentListElement(attachments = [], options = {}) {
return createAttachmentCarouselElement(attachments, options);
}
+233
View File
@@ -2182,6 +2182,165 @@ textarea.input {
gap: 0.5rem; gap: 0.5rem;
} }
.message-attachment-carousel {
gap: 0.35rem;
margin: 0.5rem 0 0.4rem;
max-width: min(100%, 34rem);
}
.message-attachment-carousel-viewport {
align-items: center;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.05), rgba(20, 184, 166, 0.08));
border: 1px solid rgba(15, 23, 42, 0.1);
border-radius: 1.15rem;
display: grid;
min-height: 12rem;
overflow: hidden;
position: relative;
touch-action: pan-y;
}
.message-attachment-slide {
align-items: stretch;
display: grid;
min-height: 12rem;
}
.message-attachment-arrow {
align-items: center;
background: rgba(15, 23, 42, 0.62);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 999px;
color: #fff;
cursor: pointer;
display: inline-flex;
font-size: 1.85rem;
height: 2.35rem;
justify-content: center;
line-height: 1;
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 2.35rem;
z-index: 2;
}
.message-attachment-arrow:disabled {
cursor: default;
opacity: 0.28;
}
.message-attachment-arrow--prev {
left: 0.55rem;
}
.message-attachment-arrow--next {
right: 0.55rem;
}
.message-attachment-counter {
color: #64748b;
font-size: 0.78rem;
text-align: center;
}
.message-attachment-media-frame {
align-items: center;
background: #0f172a;
cursor: zoom-in;
display: grid;
min-height: 12rem;
overflow: hidden;
position: relative;
}
.message-attachment-media {
display: block;
height: min(52vh, 20rem);
max-height: 20rem;
object-fit: contain;
width: 100%;
}
.message-attachment-play {
align-items: center;
background: rgba(15, 23, 42, 0.72);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 999px;
color: #fff;
display: inline-flex;
font-size: 1.7rem;
height: 4rem;
justify-content: center;
left: 50%;
padding-left: 0.18rem;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 4rem;
}
.message-attachment-download {
background: rgba(20, 184, 166, 0.95);
border-radius: 999px;
color: #fff;
font-size: 0.82rem;
font-weight: 700;
padding: 0.45rem 0.75rem;
position: relative;
text-decoration: none;
width: max-content;
z-index: 3;
}
.message-attachment-media-frame > .message-attachment-download {
bottom: 0.75rem;
position: absolute;
right: 0.75rem;
}
.message-attachment-file-card {
align-items: center;
background: linear-gradient(135deg, rgba(240, 253, 250, 0.95), rgba(239, 246, 255, 0.95));
color: #0f172a;
display: grid;
gap: 0.7rem;
grid-template-columns: auto minmax(0, 1fr) auto;
padding: 1rem;
}
.message-attachment-file-ext {
align-items: center;
background: #0f766e;
border-radius: 0.9rem;
color: #fff;
display: inline-flex;
font-size: 0.8rem;
font-weight: 800;
height: 3rem;
justify-content: center;
max-width: 4rem;
min-width: 3rem;
padding: 0 0.45rem;
}
.message-attachment-file-main {
display: grid;
min-width: 0;
}
.message-attachment-unavailable {
align-items: center;
background: linear-gradient(135deg, rgba(254, 242, 242, 0.96), rgba(255, 247, 237, 0.96));
color: #7f1d1d;
display: flex;
font-weight: 700;
justify-content: center;
min-height: 12rem;
padding: 1.25rem;
text-align: center;
}
.message-attachment-card { .message-attachment-card {
align-items: center; align-items: center;
background: linear-gradient(135deg, rgba(240, 253, 250, 0.95), rgba(239, 246, 255, 0.95)); background: linear-gradient(135deg, rgba(240, 253, 250, 0.95), rgba(239, 246, 255, 0.95));
@@ -2224,6 +2383,80 @@ textarea.input {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.attachment-viewer-modal {
z-index: 80;
}
.attachment-viewer-card {
background: rgba(15, 23, 42, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1.2rem;
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.45);
color: #fff;
display: grid;
gap: 0.8rem;
max-height: 92vh;
max-width: min(94vw, 68rem);
padding: 0.9rem;
width: 100%;
}
.attachment-viewer-head,
.attachment-viewer-actions {
align-items: center;
display: flex;
gap: 0.8rem;
justify-content: space-between;
}
.attachment-viewer-title {
font-weight: 800;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-viewer-body {
align-items: center;
display: grid;
justify-items: center;
min-height: 12rem;
}
.attachment-viewer-media {
background: #020617;
border-radius: 0.8rem;
max-height: 76vh;
max-width: 100%;
object-fit: contain;
}
@media (max-width: 640px) {
.message-attachment-carousel {
max-width: 100%;
}
.message-attachment-carousel-viewport,
.message-attachment-slide,
.message-attachment-media-frame,
.message-attachment-unavailable {
min-height: 10rem;
}
.message-attachment-media {
height: min(42vh, 16rem);
}
.message-attachment-file-card {
grid-template-columns: auto minmax(0, 1fr);
}
.message-attachment-file-card .message-attachment-download {
grid-column: 1 / -1;
}
}
.ar-attachment-manager-card { .ar-attachment-manager-card {
max-width: min(92vw, 34rem); max-width: min(92vw, 34rem);
} }