feat(web): fullscreen media preview/viewer and fix media context menu
Some checks failed
CI / test (push) Failing after 26s
Some checks failed
CI / test (push) Failing after 26s
This commit is contained in:
@@ -567,28 +567,33 @@ export function MessageComposer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedFile ? (
|
{selectedFile ? (
|
||||||
<div className="mb-2 rounded-2xl border border-slate-700/80 bg-slate-900/95 p-3 text-sm shadow-xl">
|
<div className="fixed inset-0 z-[160] flex items-center justify-center bg-slate-950/80 p-3" onClick={cancelSelectedFile}>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="flex h-full w-full max-w-2xl flex-col rounded-2xl border border-slate-700/80 bg-slate-900/95 shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
||||||
<div>
|
<div className="flex items-center justify-between border-b border-slate-700/70 px-3 py-2">
|
||||||
<p className="font-semibold">Send {selectedType === "image" || selectedType === "video" ? "Photo" : "File"}</p>
|
<div className="min-w-0">
|
||||||
<p className="text-xs text-slate-400">{selectedFile.name} • {formatBytes(selectedFile.size)}</p>
|
<p className="truncate text-sm font-semibold">Send {selectedType === "image" || selectedType === "video" ? "Photo" : "File"}</p>
|
||||||
|
<p className="truncate text-xs text-slate-400">{selectedFile.name} • {formatBytes(selectedFile.size)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={cancelSelectedFile} type="button">
|
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={cancelSelectedFile} type="button">
|
||||||
✕
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="tg-scrollbar min-h-0 flex-1 overflow-auto p-3">
|
||||||
{previewUrl && selectedType === "image" ? (
|
{previewUrl && selectedType === "image" ? (
|
||||||
<img className="mb-2 max-h-72 w-full rounded-xl object-contain" src={previewUrl} alt={selectedFile.name} />
|
<img className="mx-auto max-h-[70vh] w-full rounded-xl object-contain" src={previewUrl} alt={selectedFile.name} />
|
||||||
) : null}
|
) : null}
|
||||||
{previewUrl && selectedType === "video" ? (
|
{previewUrl && selectedType === "video" ? (
|
||||||
<video className="mb-2 max-h-72 w-full rounded-xl" src={previewUrl} controls muted />
|
<video className="mx-auto max-h-[70vh] w-full rounded-xl" src={previewUrl} controls muted />
|
||||||
) : null}
|
) : null}
|
||||||
{!previewUrl ? (
|
{!previewUrl ? (
|
||||||
<div className="mb-2 rounded-lg border border-slate-700/80 bg-slate-800/60 px-3 py-2 text-xs text-slate-300">
|
<div className="rounded-lg border border-slate-700/80 bg-slate-800/60 px-3 py-2 text-xs text-slate-300">
|
||||||
No preview available
|
No preview available
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-700/70 p-3">
|
||||||
<input
|
<input
|
||||||
className="mb-2 w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
|
className="mb-2 w-full rounded-xl border border-slate-700/80 bg-slate-800/80 px-3 py-2 text-sm outline-none placeholder:text-slate-400 focus:border-sky-500"
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
@@ -618,6 +623,8 @@ export function MessageComposer() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{uploadError ? <div className="mb-2 text-sm text-red-400">{uploadError}</div> : null}
|
{uploadError ? <div className="mb-2 text-sm text-red-400">{uploadError}</div> : null}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ type PendingDeleteState = {
|
|||||||
timerId: number;
|
timerId: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
|
type MediaViewerState = {
|
||||||
|
items: Array<{ url: string; type: "image" | "video" }>;
|
||||||
|
index: number;
|
||||||
|
} | null;
|
||||||
|
|
||||||
const QUICK_REACTIONS = ["👍", "❤️", "🔥", "😂", "😮", "😢"];
|
const QUICK_REACTIONS = ["👍", "❤️", "🔥", "😂", "😮", "😢"];
|
||||||
|
|
||||||
export function MessageList() {
|
export function MessageList() {
|
||||||
@@ -60,6 +65,7 @@ export function MessageList() {
|
|||||||
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
|
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
|
||||||
const [undoTick, setUndoTick] = useState(0);
|
const [undoTick, setUndoTick] = useState(0);
|
||||||
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
|
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
|
||||||
|
const [mediaViewer, setMediaViewer] = useState<MediaViewerState>(null);
|
||||||
|
|
||||||
const messages = useMemo(() => {
|
const messages = useMemo(() => {
|
||||||
if (!activeChatId) {
|
if (!activeChatId) {
|
||||||
@@ -100,6 +106,7 @@ export function MessageList() {
|
|||||||
setForwardSelectedChatIds(new Set());
|
setForwardSelectedChatIds(new Set());
|
||||||
setDeleteMessageId(null);
|
setDeleteMessageId(null);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
|
setMediaViewer(null);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
@@ -415,7 +422,14 @@ export function MessageList() {
|
|||||||
void ensureReactionsLoaded(message.id);
|
void ensureReactionsLoaded(message.id);
|
||||||
const pos = getSafeContextPosition(event.clientX, event.clientY, 220, 280);
|
const pos = getSafeContextPosition(event.clientX, event.clientY, 220, 280);
|
||||||
setCtx({ x: pos.x, y: pos.y, messageId: message.id, attachmentUrl: url });
|
setCtx({ x: pos.x, y: pos.y, messageId: message.id, attachmentUrl: url });
|
||||||
}
|
},
|
||||||
|
onOpenMedia: (url, type) => {
|
||||||
|
const items = messages
|
||||||
|
.filter((m) => (m.type === "image" || m.type === "video" || m.type === "circle_video") && !!m.text)
|
||||||
|
.map((m) => ({ url: m.text as string, type: (m.type === "image" ? "image" : "video") as "image" | "video" }));
|
||||||
|
const idx = items.findIndex((i) => i.url === url && i.type === type);
|
||||||
|
setMediaViewer({ items, index: idx >= 0 ? idx : 0 });
|
||||||
|
},
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{messageReactions.length > 0 ? (
|
{messageReactions.length > 0 ? (
|
||||||
@@ -620,6 +634,75 @@ export function MessageList() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{mediaViewer ? (
|
||||||
|
<div className="fixed inset-0 z-[170] flex items-center justify-center bg-slate-950/90 p-2 md:p-4" onClick={() => setMediaViewer(null)}>
|
||||||
|
<div className="relative flex h-full w-full max-w-5xl items-center justify-center" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
className="absolute left-1 top-1 z-10 rounded-full bg-slate-900/80 px-3 py-2 text-xs text-slate-200 hover:bg-slate-800 md:left-3 md:top-3"
|
||||||
|
onClick={() => setMediaViewer(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{mediaViewer.items.length > 1 ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="absolute left-1/2 top-3 z-10 -translate-x-1/2 rounded-full bg-slate-900/80 px-2 py-1 text-xs text-slate-200 md:top-4"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{mediaViewer.index + 1} / {mediaViewer.items.length}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="absolute left-1 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-3 text-lg text-slate-200 hover:bg-slate-800 md:left-3"
|
||||||
|
onClick={() =>
|
||||||
|
setMediaViewer((prev) =>
|
||||||
|
prev ? { ...prev, index: prev.index <= 0 ? prev.items.length - 1 : prev.index - 1 } : prev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="absolute right-1 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 px-3 py-3 text-lg text-slate-200 hover:bg-slate-800 md:right-3"
|
||||||
|
onClick={() =>
|
||||||
|
setMediaViewer((prev) =>
|
||||||
|
prev ? { ...prev, index: prev.index >= prev.items.length - 1 ? 0 : prev.index + 1 } : prev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="absolute right-1 top-1 z-10 rounded-full bg-slate-900/80 px-3 py-2 text-xs text-slate-200 hover:bg-slate-800 md:right-3 md:top-3"
|
||||||
|
onClick={async () => {
|
||||||
|
const current = mediaViewer.items[mediaViewer.index];
|
||||||
|
try {
|
||||||
|
await downloadFileFromUrl(current.url);
|
||||||
|
showToast("File downloaded");
|
||||||
|
} catch {
|
||||||
|
showToast("Download failed");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{mediaViewer.items[mediaViewer.index]?.type === "image" ? (
|
||||||
|
<img className="max-h-full max-w-full rounded-xl object-contain" src={mediaViewer.items[mediaViewer.index].url} alt="media" />
|
||||||
|
) : (
|
||||||
|
<video className="max-h-full max-w-full rounded-xl" controls src={mediaViewer.items[mediaViewer.index].url} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{deleteMessageId ? (
|
{deleteMessageId ? (
|
||||||
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setDeleteMessageId(null)}>
|
<div className="absolute inset-0 z-50 flex items-end justify-center bg-slate-950/55 p-3" onClick={() => setDeleteMessageId(null)}>
|
||||||
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(event) => event.stopPropagation()}>
|
<div className="w-full rounded-xl border border-slate-700/80 bg-slate-900 p-3" onClick={(event) => event.stopPropagation()}>
|
||||||
@@ -663,29 +746,52 @@ export function MessageList() {
|
|||||||
function renderMessageContent(
|
function renderMessageContent(
|
||||||
messageType: string,
|
messageType: string,
|
||||||
text: string | null,
|
text: string | null,
|
||||||
opts: { onAttachmentContextMenu: (event: MouseEvent<HTMLElement>, url: string) => void }
|
opts: {
|
||||||
|
onAttachmentContextMenu: (event: MouseEvent<HTMLElement>, url: string) => void;
|
||||||
|
onOpenMedia: (url: string, type: "image" | "video") => void;
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
if (!text) return <p className="opacity-80">[empty]</p>;
|
if (!text) return <p className="opacity-80">[empty]</p>;
|
||||||
|
|
||||||
if (messageType === "image") {
|
if (messageType === "image") {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-xl bg-slate-950/30" onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}>
|
<button
|
||||||
|
className="block overflow-hidden rounded-xl bg-slate-950/30"
|
||||||
|
onClick={() => opts.onOpenMedia(text, "image")}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
opts.onAttachmentContextMenu(event, text);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
<img alt="attachment" className="max-h-80 rounded-xl object-cover" draggable={false} src={text} />
|
<img alt="attachment" className="max-h-80 rounded-xl object-cover" draggable={false} src={text} />
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageType === "video" || messageType === "circle_video") {
|
if (messageType === "video" || messageType === "circle_video") {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-xl bg-slate-950/30" onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}>
|
<button
|
||||||
<video className="max-h-80 rounded-xl" controls src={text} />
|
className="relative block overflow-hidden rounded-xl bg-slate-950/30"
|
||||||
</div>
|
onClick={() => opts.onOpenMedia(text, "video")}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
opts.onAttachmentContextMenu(event, text);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<video className="max-h-80 rounded-xl" muted src={text} />
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center text-3xl text-white/85">▶</span>
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageType === "voice") {
|
if (messageType === "voice") {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2" onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}>
|
<div className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2" onContextMenu={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
opts.onAttachmentContextMenu(event, text);
|
||||||
|
}}>
|
||||||
<div className="mb-1 flex items-center gap-2 text-xs text-slate-300">
|
<div className="mb-1 flex items-center gap-2 text-xs text-slate-300">
|
||||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-700/80">🎤</span>
|
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-700/80">🎤</span>
|
||||||
<span className="font-semibold">Voice message</span>
|
<span className="font-semibold">Voice message</span>
|
||||||
@@ -697,7 +803,10 @@ function renderMessageContent(
|
|||||||
|
|
||||||
if (messageType === "audio") {
|
if (messageType === "audio") {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2" onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}>
|
<div className="rounded-xl border border-slate-700/60 bg-slate-950/30 p-2" onContextMenu={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
opts.onAttachmentContextMenu(event, text);
|
||||||
|
}}>
|
||||||
<div className="mb-1 flex items-center gap-2 text-xs text-slate-300">
|
<div className="mb-1 flex items-center gap-2 text-xs text-slate-300">
|
||||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-700/80">🎵</span>
|
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-700/80">🎵</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -714,7 +823,10 @@ function renderMessageContent(
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="w-full rounded-xl border border-slate-600/60 bg-slate-800/70 px-3 py-2 text-left"
|
className="w-full rounded-xl border border-slate-600/60 bg-slate-800/70 px-3 py-2 text-left"
|
||||||
onContextMenu={(event) => opts.onAttachmentContextMenu(event, text)}
|
onContextMenu={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
opts.onAttachmentContextMenu(event, text);
|
||||||
|
}}
|
||||||
onClick={() => window.open(text, "_blank", "noopener,noreferrer")}
|
onClick={() => window.open(text, "_blank", "noopener,noreferrer")}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user