From 96f92c2ba9d1c32162da1403a7d38cb439ff7961 Mon Sep 17 00:00:00 2001 From: JustMe Date: Sat, 17 Oct 2026 13:44:16 +0800 Subject: [PATCH] feat(web): inline previews in chat and per-upload download limit choice --- web/src/core-bridge/worker/uploadQueue.ts | 7 +- .../channel/ChatAttachment.module.css | 80 ++++++++++++++ web/src/features/channel/ChatAttachment.tsx | 101 ++++++++++++++++++ web/src/features/channel/ChatMessage.tsx | 23 ++-- web/src/features/channel/ChatPane.module.css | 23 ++++ .../features/channel/UploadOptionsSheet.tsx | 44 ++++---- 6 files changed, 245 insertions(+), 33 deletions(-) create mode 100644 web/src/features/channel/ChatAttachment.module.css create mode 100644 web/src/features/channel/ChatAttachment.tsx diff --git a/web/src/core-bridge/worker/uploadQueue.ts b/web/src/core-bridge/worker/uploadQueue.ts index b13df0d..aaad71d 100644 --- a/web/src/core-bridge/worker/uploadQueue.ts +++ b/web/src/core-bridge/worker/uploadQueue.ts @@ -87,7 +87,8 @@ async function runJob(job: Job): Promise { const prepared = await prepareObject(resource, session, threadId, 'files'); const plan = session.encryptFile(file, {}, { itemId: prepared.itemIdBytes, - ...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }), + // 小文件(一帧放得下)且不限制次数:不设上限,加入者直接预览。其余都带上每个文件的下载次数(0 = 不限) + ...(options.directPreview && file.size < maxFrameData - 1024 ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }), ts, maxFrameData, threadId, folderId, }); item = { @@ -95,9 +96,7 @@ async function runJob(job: Job): Promise { ...(author ? { author } : {}), ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), ...(localId ? { localId } : {}), name: file.name, mime: file.type || 'application/octet-stream', size: file.size, - remaining: plan.gated - ? (options.directPreview ? resource.maxDownloads ?? 0 : options.maxDownloads) || null - : null, + remaining: plan.gated ? options.maxDownloads || null : null, preview: previewKindOf(file.type, file.name), gated: plan.gated, }; job.itemId = item.id; diff --git a/web/src/features/channel/ChatAttachment.module.css b/web/src/features/channel/ChatAttachment.module.css new file mode 100644 index 0000000..ac29cb0 --- /dev/null +++ b/web/src/features/channel/ChatAttachment.module.css @@ -0,0 +1,80 @@ +.slot { + width: 360px; + max-width: 100%; + margin-top: var(--space-1); +} + +.placeholder, +.poster { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + width: 100%; + min-height: 96px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-md); + background: var(--surface-2); + color: var(--text-dim); + font: inherit; + font-size: 12px; +} + +.poster { + cursor: pointer; +} + +.picture { + display: block; + padding: 0; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--surface-2); + cursor: zoom-in; + overflow: hidden; +} + +.picture img { + display: block; + width: auto; + max-width: 100%; + height: auto; + max-height: 320px; +} + +.video { + display: block; + width: 100%; + max-height: 320px; + border-radius: var(--radius-md); + background: var(--surface-2); +} + +.audio { + width: 100%; +} + +.snippet { + max-height: 220px; + margin: 0; + padding: var(--space-2) var(--space-3); + overflow: auto; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--surface-2); + color: var(--text); + font-size: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; + cursor: pointer; +} + +.archive { + max-height: 220px; + padding: var(--space-2) var(--space-3); + overflow: auto; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--surface-2); + font-size: 12px; +} diff --git a/web/src/features/channel/ChatAttachment.tsx b/web/src/features/channel/ChatAttachment.tsx new file mode 100644 index 0000000..4c81c82 --- /dev/null +++ b/web/src/features/channel/ChatAttachment.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from 'react'; +import type { FileItem } from '@/core-bridge/types'; +import { formatSize } from '@/lib/format'; +import { Icon } from '@/ui/Icon'; +import { ArchivePreview } from '../item/previews/ArchivePreview'; +import { useBlobUrl } from '../item/useBlobUrl'; +import styles from './ChatAttachment.module.css'; + +/** 聊天里自动展开的大小上限:再大的只显示文件卡片,点开预览 */ +const MB = 1024 * 1024; +/** 不限制次数、不超过这个大小的,滚动到附近就自动加载;更大的显示占位,点一下才加载(整个文件要先解密到内存) */ +const AUTO = { image: 30 * MB, video: 25 * MB, audio: 25 * MB, archive: 50 * MB, text: 128 * 1024 } as const; +/** 再大就不在聊天里展开了,只留文件卡片 */ +const MAX = { image: 100 * MB, video: 500 * MB, audio: 500 * MB, archive: 200 * MB, text: 128 * 1024 } as const; + +/** 滚动到附近才加载,一屏几十张图时不会一下子全部解密 */ +function useNearViewport(): [React.RefObject, boolean] { + const ref = useRef(null); + const [near, setNear] = useState(false); + useEffect(() => { + const element = ref.current; + if (!element || near) return; + if (typeof IntersectionObserver === 'undefined') { setNear(true); return; } + const observer = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) { setNear(true); observer.disconnect(); } }, { rootMargin: '300px' }); + observer.observe(element); + return () => observer.disconnect(); + }, [near]); + return [ref, near]; +} + +/** 不限制下载次数的文件直接预览;有上限的,读内容会消耗一次:不自动加载,点一次预览一次 */ +const isFree = (item: FileItem) => item.remaining === null; + +function Gate({ item, label, children }: { item: FileItem; label: string; children: () => React.ReactNode }) { + const auto = item.size <= (AUTO as Record)[item.preview]!; + const [ref, near] = useNearViewport(); + const [asked, setAsked] = useState(false); + const free = isFree(item); + return
+ {free && auto ? (near ? children() :
) + : asked ? children() + : } +
; +} + +function Picture({ item, onOpen }: { item: FileItem; onOpen: () => void }) { + const blob = useBlobUrl(item.id); + const [broken, setBroken] = useState(false); + if (blob.status === 'loading') return
加载中…
; + if (blob.status === 'error' || broken) return null; + return ; +} + +function Clip({ item }: { item: FileItem }) { + const blob = useBlobUrl(item.id); + if (blob.status === 'loading') return
加载中…
; + if (blob.status === 'error') return null; + return