From c530627078eeb4aade785c64a7a334d9ea42554796ba4a7a2b568ada588baa91 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 31 Jul 2026 12:49:24 +0400 Subject: [PATCH] =?UTF-8?q?UI:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=B0=D1=80=D1=83=D1=81=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- docs/Blockchain/15_TEXT_Attachments.md | 18 +- docs/Blockchain/CHANGELOG.md | 6 + shine-UI/js/pages/channel-thread-view.js | 12 +- shine-UI/js/pages/channel-view.js | 16 +- shine-UI/js/services/attachment-format.js | 289 ++++++++++++++++++++-- shine-UI/styles/components.css | 233 +++++++++++++++++ 7 files changed, 541 insertions(+), 35 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index 3c0a9804..3bc40f27 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.4.1 +client.version=1.4.2 server.version=1.4.0 diff --git a/docs/Blockchain/15_TEXT_Attachments.md b/docs/Blockchain/15_TEXT_Attachments.md index 526e6d82..4cd0ca14 100644 --- a/docs/Blockchain/15_TEXT_Attachments.md +++ b/docs/Blockchain/15_TEXT_Attachments.md @@ -31,6 +31,8 @@ После последнего attach-блока идёт обычный пользовательский текст. Пользовательский текст может быть пустым, если есть хотя бы одно валидное вложение. +Клиент SHiNE ограничивает одно сообщение максимум 10 вложениями и сохраняет порядок attach-блоков в том порядке, в котором пользователь прикрепил файлы. + ## Поля Обязательные поля: @@ -67,8 +69,18 @@ UI поддерживает три сценария: 1. читает валидные attach-блоки только из начала текста; 2. скрывает технические attach-строки; 3. отображает вложения над пользовательским текстом; -4. каждое вложение показывает как карточку/ссылку на Arweave gateway; -5. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение. +4. показывает вложения горизонтальной каруселью: одно вложение на экране, переключение свайпом или стрелками, счётчик вида `1 из 3`; +5. определяет тип вложения по расширению имени файла: + - изображение показывает как ограниченное по размеру превью; + - видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию; + - обычный файл показывает как карточку с именем, расширением, размером и скачиванием; +6. при перелистывании останавливает воспроизводящееся видео; +7. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение. + +Если файл по ссылке Arweave gateway недоступен, клиент показывает диагностический блок: + +- если сообщение создано менее 20 минут назад: файл, скорее всего, ещё не распространился в Arweave, нужно повторить через несколько минут; +- если сообщение старше 20 минут: файл считается недоступным. Старые клиенты без поддержки формата могут показывать attach-блоки как обычный текст. @@ -79,6 +91,6 @@ UI поддерживает три сценария: ## Ограничения v1 - `ar` допускает только короткий Arweave `txId`, полный URL не используется; -- превью типа image/video/audio не входит в формат v1; +- MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла; - MIME type, width, height, duration и thumbnail не записываются в блокчейн; - проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки. diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index cdd13b60..a3a1c89a 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -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 - Базовый коммит-ориентир: `e9e6628`. - Добавлен отдельный документ `docs/Blockchain/15_TEXT_Attachments.md` с полной спецификацией `SHiNE:attach v=1`. diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index f6f0885b..1f5fc1c3 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -15,7 +15,8 @@ import { renderUserAvatar } from '../components/avatar-image.js'; import { openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js'; import { composeMessageWithAttachments, - createAttachmentListElement, + createAttachmentCarouselElement, + MAX_MESSAGE_ATTACHMENTS, parseMessageAttachments, } from '../services/attachment-format.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 () => { try { + if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) { + errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`; + return; + } const item = await openArweaveAttachmentManager({ login: state.session.login, storagePwd: state.session.storagePwdInMemory, @@ -670,7 +675,10 @@ function renderNodeCard(node, heading, handlers, localNumber) { card.append(authorTile); 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); diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 47e93c0a..73f2af72 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -21,7 +21,8 @@ import { renderUserAvatar } from '../components/avatar-image.js'; import { openArweaveAttachmentManager } from '../components/arweave-attachment-manager.js'; import { composeMessageWithAttachments, - createAttachmentListElement, + createAttachmentCarouselElement, + MAX_MESSAGE_ATTACHMENTS, parseMessageAttachments, } from '../services/attachment-format.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; @@ -392,6 +393,10 @@ function openReplyModal({ onSubmit, navigate }) { root.querySelector('#reply-attach')?.addEventListener('click', async () => { try { + if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) { + errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`; + return; + } const item = await openArweaveAttachmentManager({ login: state.session.login, storagePwd: state.session.storagePwdInMemory, @@ -546,6 +551,10 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) { root.querySelector('#channel-message-attach')?.addEventListener('click', async () => { try { + if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) { + errorEl.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`; + return; + } const item = await openArweaveAttachmentManager({ login: state.session.login, storagePwd: state.session.storagePwdInMemory, @@ -1004,7 +1013,10 @@ function renderPostCard(post, { card.append(authorTile); 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); diff --git a/shine-UI/js/services/attachment-format.js b/shine-UI/js/services/attachment-format.js index d93b2baa..bc712681 100644 --- a/shine-UI/js/services/attachment-format.js +++ b/shine-UI/js/services/attachment-format.js @@ -2,6 +2,10 @@ import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './a 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 || '') @@ -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 = ` - - - ${escapeHtml(item.name)} - ${escapeHtml(formatBytes(item.size))} · ${escapeHtml(item.ar)} - - `; - 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 = ` + + `; + 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); +} diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index d4b4dc55..47c990c6 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -2182,6 +2182,165 @@ textarea.input { 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 { align-items: center; 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; } +.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 { max-width: min(92vw, 34rem); }