feat(web): inline previews in chat and per-upload download limit choice
This commit is contained in:
1 parent
1a4069576b
commit
96f92c2ba9
6 files changed
+245
-33
No files matched your search
@@ -87,7 +87,8 @@ async function runJob(job: Job): Promise<void> {
|
||||
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<void> {
|
||||
...(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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<T extends HTMLElement>(): [React.RefObject<T | null>, boolean] {
|
||||
const ref = useRef<T>(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<string, number>)[item.preview]!;
|
||||
const [ref, near] = useNearViewport<HTMLDivElement>();
|
||||
const [asked, setAsked] = useState(false);
|
||||
const free = isFree(item);
|
||||
return <div ref={ref} className={styles.slot}>
|
||||
{free && auto ? (near ? children() : <div className={styles.placeholder} />)
|
||||
: asked ? children()
|
||||
: <button type="button" className={styles.poster} onClick={() => setAsked(true)}><Icon name="eye" size={20} /><span>{label} · {formatSize(item.size)}{free ? '' : ` · 消耗 1 次${typeof item.remaining === 'number' ? `(剩 ${item.remaining} 次)` : ''}`}</span></button>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Picture({ item, onOpen }: { item: FileItem; onOpen: () => void }) {
|
||||
const blob = useBlobUrl(item.id);
|
||||
const [broken, setBroken] = useState(false);
|
||||
if (blob.status === 'loading') return <div className={styles.placeholder}>加载中…</div>;
|
||||
if (blob.status === 'error' || broken) return null;
|
||||
return <button type="button" className={styles.picture} aria-label={`查看大图 ${item.name}`} onClick={onOpen}>
|
||||
<img src={blob.url} alt={item.name} onError={() => setBroken(true)} />
|
||||
</button>;
|
||||
}
|
||||
|
||||
function Clip({ item }: { item: FileItem }) {
|
||||
const blob = useBlobUrl(item.id);
|
||||
if (blob.status === 'loading') return <div className={styles.placeholder}>加载中…</div>;
|
||||
if (blob.status === 'error') return null;
|
||||
return <video className={styles.video} src={blob.url} controls preload="metadata" playsInline />;
|
||||
}
|
||||
|
||||
function Sound({ item }: { item: FileItem }) {
|
||||
const blob = useBlobUrl(item.id);
|
||||
return blob.status === 'ready' ? <audio className={styles.audio} src={blob.url} controls /> : null;
|
||||
}
|
||||
|
||||
const LINES = 12;
|
||||
function Snippet({ item, onOpen }: { item: FileItem; onOpen: () => void }) {
|
||||
const blob = useBlobUrl(item.id);
|
||||
const [text, setText] = useState<string>();
|
||||
useEffect(() => {
|
||||
if (blob.status !== 'ready') return;
|
||||
let active = true;
|
||||
void blob.blob.text().then((value) => { if (active) setText(value); }).catch(() => undefined);
|
||||
return () => { active = false; };
|
||||
}, [blob]);
|
||||
if (text === undefined) return null;
|
||||
const lines = text.split('\n');
|
||||
return <pre className={styles.snippet} onClick={onOpen}>{lines.slice(0, LINES).join('\n')}{lines.length > LINES ? '\n…' : ''}</pre>;
|
||||
}
|
||||
|
||||
/** 这个文件能不能在聊天里直接展开(决定下面用简洁的一行还是完整的文件卡片) */
|
||||
export function hasInlinePreview(item: FileItem): boolean {
|
||||
if (item.state.kind !== 'complete') return false;
|
||||
const max = (MAX as Record<string, number>)[item.preview];
|
||||
return max !== undefined && item.size <= max;
|
||||
}
|
||||
|
||||
/** 聊天消息里的附件直接展开:图片(含 GIF 动图)、视频、音频、压缩包目录、小文本。文件卡片仍在下面,用来下载和打开预览 */
|
||||
export function ChatAttachment({ item, onOpen }: { item: FileItem; onOpen: () => void }) {
|
||||
if (item.state.kind !== 'complete') return null;
|
||||
switch (item.preview) {
|
||||
case 'image': return item.size <= MAX.image ? <Gate item={item} label="查看图片">{() => <Picture item={item} onOpen={onOpen} />}</Gate> : null;
|
||||
case 'video': return item.size <= MAX.video ? <Gate item={item} label="播放视频">{() => <Clip item={item} />}</Gate> : null;
|
||||
case 'audio': return item.size <= MAX.audio ? <Gate item={item} label="播放音频">{() => <Sound item={item} />}</Gate> : null;
|
||||
case 'archive': return item.size <= MAX.archive ? <Gate item={item} label="查看压缩包目录">{() => <div className={styles.archive}><ArchivePreview id={item.id} /></div>}</Gate> : null;
|
||||
case 'text': return item.size <= MAX.text ? <Gate item={item} label="查看内容">{() => <Snippet item={item} onOpen={onOpen} />}</Gate> : null;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { TransferProgress } from '@/features/item/TransferProgress';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { ContextMenu, type MenuAction } from '@/ui/ContextMenu';
|
||||
import { ChatAttachment, hasInlinePreview } from './ChatAttachment';
|
||||
import { NameWithBadges } from './RoleBadges';
|
||||
import styles from './ChatPane.module.css';
|
||||
|
||||
@@ -54,14 +55,20 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
|
||||
<div className={styles.body}>
|
||||
{!compact ? <header><NameWithBadges username={sender?.username}><strong className={styles.senderName} title={sender?.username}>{name}</strong></NameWithBadges><time>{formatTime(item.ts)}</time></header> : null}
|
||||
{item.state.kind === 'tampered' ? <p className={styles.integrity}>内容校验失败,已拒绝显示。</p> : item.kind === 'text' ? <p className={styles.messageText}>{item.text}</p>
|
||||
: <div className={styles.fileCard}>
|
||||
<button type="button" className={styles.fileOpen} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
|
||||
<span className={styles.fileIcon}><Icon name={iconOf(item)} size={28} /></span>
|
||||
<span className={styles.fileInfo}><strong>{item.name}</strong><span>{formatSize(item.size)} · {item.mime}</span></span>
|
||||
</button>
|
||||
<Button variant="ghost" icon="eye" label="预览" disabled={!ready} onClick={() => onOpenFile(id)} />
|
||||
<Button variant="ghost" icon="download" label={downloading ? '下载中…' : '下载'} disabled={!ready || downloading} onClick={() => { void save(); }} />
|
||||
</div>}
|
||||
: <>
|
||||
{ready ? <ChatAttachment item={item} onOpen={() => onOpenFile(id)} /> : null}
|
||||
{ready && hasInlinePreview(item) ? <div className={styles.fileCaption}>
|
||||
<span className={styles.captionName}>{item.name}</span><span className={styles.captionSize}>{formatSize(item.size)}</span>
|
||||
{item.preview === 'archive' || item.preview === 'text' ? <Button variant="ghost" icon="eye" label="预览" onClick={() => onOpenFile(id)} /> : null}
|
||||
<Button variant="ghost" icon="download" label={downloading ? '下载中…' : '下载'} disabled={downloading} onClick={() => { void save(); }} />
|
||||
</div> : <div className={styles.fileCard}>
|
||||
<button type="button" className={styles.fileOpen} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
|
||||
<span className={styles.fileIcon}><Icon name={iconOf(item)} size={28} /></span>
|
||||
<span className={styles.fileInfo}><strong>{item.name}</strong><span>{formatSize(item.size)} · {item.mime}</span></span>
|
||||
</button>
|
||||
<Button variant="ghost" icon="eye" label="预览" disabled={!ready} onClick={() => onOpenFile(id)} />
|
||||
<Button variant="ghost" icon="download" label={downloading ? '下载中…' : '下载'} disabled={!ready || downloading} onClick={() => { void save(); }} />
|
||||
</div>}</>}
|
||||
{item.state.kind === 'receiving' && item.kind === 'file' ? <TransferProgress state={item.state} {...(self && item.localId ? { onCancel: () => { void cancelUpload(item.localId!, item.id); } } : {})} />
|
||||
: failed ? <span className={styles.failure} role="alert">发送失败。<button type="button" onClick={() => { void retry(id); }}>重试</button></span>
|
||||
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
|
||||
|
||||
@@ -309,3 +309,26 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 已经在上面展开预览的文件:只留一行文件名、大小和下载 */
|
||||
.fileCaption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.captionName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.captionSize {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -17,35 +17,37 @@ interface Props {
|
||||
|
||||
export function UploadOptionsSheet({ files, defaultMaxDownloads, onClose, onUpload, encrypted = true }: Props) {
|
||||
const { info } = useServerSettings();
|
||||
const [directPreview, setDirectPreview] = useState(true);
|
||||
const [maxDownloads, setMaxDownloads] = useState(defaultMaxDownloads);
|
||||
const [limited, setLimited] = useState(false);
|
||||
const choices = withCurrent(info?.choices.file_max_downloads ?? [], defaultMaxDownloads).filter((n) => n > 0);
|
||||
const [times, setTimes] = useState(defaultMaxDownloads > 0 ? defaultMaxDownloads : 10);
|
||||
|
||||
// 不限制:聊天里直接预览,不消耗次数。限制:预览、下载每次都消耗一次
|
||||
const options: FileUploadOptions = limited ? { directPreview: false, maxDownloads: times } : { directPreview: true, maxDownloads: 0 };
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
title={`上传 ${files.length} 个文件`}
|
||||
onClose={onClose}
|
||||
actions={<Button variant="primary" disabled={!info} onClick={() => onUpload({ directPreview, maxDownloads })}>继续上传</Button>}
|
||||
actions={<Button variant="primary" disabled={!info} onClick={() => onUpload(options)}>继续上传</Button>}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.form} role="radiogroup" aria-label="下载次数">
|
||||
<label className={styles.check}>
|
||||
<input type="checkbox" checked={directPreview} onChange={(e) => setDirectPreview(e.target.checked)} />
|
||||
<span>单帧文件加入空间后可直接预览</span>
|
||||
<input type="radio" name="limit" checked={!limited} onChange={() => setLimited(false)} />
|
||||
<span>不限制次数 · 在聊天里直接预览</span>
|
||||
</label>
|
||||
{directPreview ? (
|
||||
<p className={styles.note}>图片、文本等单帧文件会随空间同步,加入者可直接预览且不计下载次数。大文件必须分帧,仍会受空间下载上限限制。</p>
|
||||
) : (
|
||||
<>
|
||||
<label className={styles.field}>
|
||||
<span>每个文件最多下载次数</span>
|
||||
<select value={maxDownloads} onChange={(e) => setMaxDownloads(Number(e.target.value))}>
|
||||
{withCurrent(info?.choices.file_max_downloads ?? [], defaultMaxDownloads).map((n) => (
|
||||
<option key={n} value={n}>{n === 0 ? '不限' : `${n} 次`}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className={styles.note}>预览和下载内容会消耗次数。{encrypted ? '文件名与类型通过加密目录同步,不消耗次数。' : '此空间未启用端到端加密,服务端能看到文件名与类型。'}</p>
|
||||
</>
|
||||
)}
|
||||
<label className={styles.check}>
|
||||
<input type="radio" name="limit" checked={limited} onChange={() => setLimited(true)} />
|
||||
<span>限制次数 · 点击预览或下载才消耗一次</span>
|
||||
</label>
|
||||
{limited ? (
|
||||
<label className={styles.field}>
|
||||
<span>每个文件最多</span>
|
||||
<select value={times} onChange={(e) => setTimes(Number(e.target.value))}>
|
||||
{choices.map((n) => <option key={n} value={n}>{n} 次</option>)}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<p className={styles.note}>{encrypted ? '文件名与类型通过加密目录同步,不消耗次数。' : '此空间未启用端到端加密,服务端能看到文件名与类型。'}</p>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user