diff --git a/web/src/components/ChatInfoPanel.tsx b/web/src/components/ChatInfoPanel.tsx
index 0a54b10..ac4aab8 100644
--- a/web/src/components/ChatInfoPanel.tsx
+++ b/web/src/components/ChatInfoPanel.tsx
@@ -19,6 +19,7 @@ import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, Message, UserSea
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { useUiStore } from "../store/uiStore";
+import { MediaViewer } from "./MediaViewer";
interface Props {
chatId: number | null;
@@ -745,43 +746,15 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
) : null}
{mediaViewer ? (
-
setMediaViewer(null)}>
-
event.stopPropagation()}>
-
- {mediaViewer.items.length > 1 ? (
- <>
-
-
- >
- ) : null}
-
- {mediaViewer.items[mediaViewer.index].type === "image" ? (
-

- ) : (
-
- )}
-
-
+ setMediaViewer(null)}
+ onIndexChange={(nextIndex) => setMediaViewer((prev) => (prev ? { ...prev, index: nextIndex } : prev))}
+ onJumpToMessage={(messageId) => jumpToMessage(messageId)}
+ onToast={showToast}
+ open
+ />
) : null}
,
document.body
diff --git a/web/src/components/MediaViewer.tsx b/web/src/components/MediaViewer.tsx
new file mode 100644
index 0000000..b894967
--- /dev/null
+++ b/web/src/components/MediaViewer.tsx
@@ -0,0 +1,305 @@
+import { useEffect, useMemo, useRef, useState, type PointerEvent, type WheelEvent } from "react";
+import { createPortal } from "react-dom";
+
+export type MediaViewerItem = {
+ url: string;
+ type: "image" | "video";
+ messageId?: number;
+};
+
+interface MediaViewerProps {
+ open: boolean;
+ items: MediaViewerItem[];
+ index: number;
+ onIndexChange: (index: number) => void;
+ onClose: () => void;
+ onToast?: (message: string) => void;
+ onJumpToMessage?: (messageId: number) => void;
+}
+
+export function MediaViewer(props: MediaViewerProps) {
+ const { open, items, index, onIndexChange, onClose, onToast, onJumpToMessage } = props;
+ const [zoom, setZoom] = useState(1);
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
+ const [dragging, setDragging] = useState(false);
+ const dragStartRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);
+ const swipeStartRef = useRef<{ x: number; y: number } | null>(null);
+
+ const current = useMemo(() => items[index] ?? null, [items, index]);
+ const canPrev = items.length > 1;
+ const canNext = items.length > 1;
+
+ useEffect(() => {
+ setZoom(1);
+ setOffset({ x: 0, y: 0 });
+ setDragging(false);
+ dragStartRef.current = null;
+ swipeStartRef.current = null;
+ }, [index, open]);
+
+ useEffect(() => {
+ if (!open) return;
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ onClose();
+ return;
+ }
+ if (event.key === "ArrowLeft" && canPrev) {
+ onIndexChange(index <= 0 ? items.length - 1 : index - 1);
+ return;
+ }
+ if (event.key === "ArrowRight" && canNext) {
+ onIndexChange(index >= items.length - 1 ? 0 : index + 1);
+ return;
+ }
+ if ((event.key === "+" || event.key === "=") && current?.type === "image") {
+ setZoom((prev) => Math.min(5, prev + 0.25));
+ return;
+ }
+ if (event.key === "-" && current?.type === "image") {
+ setZoom((prev) => Math.max(1, prev - 0.25));
+ return;
+ }
+ if (event.key === "0" && current?.type === "image") {
+ setZoom(1);
+ setOffset({ x: 0, y: 0 });
+ }
+ }
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [canNext, canPrev, current?.type, index, items.length, onClose, onIndexChange, open]);
+
+ if (!open || !current || !items.length) {
+ return null;
+ }
+
+ async function handleDownload() {
+ try {
+ await downloadFileFromUrl(current.url);
+ onToast?.("File downloaded");
+ } catch {
+ onToast?.("Download failed");
+ }
+ }
+
+ async function handleCopyLink() {
+ try {
+ await navigator.clipboard.writeText(current.url);
+ onToast?.("Link copied");
+ } catch {
+ onToast?.("Failed to copy link");
+ }
+ }
+
+ function handleWheel(event: WheelEvent) {
+ if (current.type !== "image") return;
+ event.preventDefault();
+ const delta = event.deltaY > 0 ? -0.15 : 0.15;
+ setZoom((prev) => {
+ const next = Math.min(5, Math.max(1, prev + delta));
+ if (next === 1) {
+ setOffset({ x: 0, y: 0 });
+ }
+ return next;
+ });
+ }
+
+ function handlePointerDown(event: PointerEvent) {
+ if (current.type !== "image") return;
+ swipeStartRef.current = { x: event.clientX, y: event.clientY };
+ if (zoom <= 1) return;
+ setDragging(true);
+ dragStartRef.current = { x: event.clientX, y: event.clientY, ox: offset.x, oy: offset.y };
+ }
+
+ function handlePointerMove(event: PointerEvent) {
+ if (!dragging || !dragStartRef.current || zoom <= 1) return;
+ const dx = event.clientX - dragStartRef.current.x;
+ const dy = event.clientY - dragStartRef.current.y;
+ setOffset({ x: dragStartRef.current.ox + dx, y: dragStartRef.current.oy + dy });
+ }
+
+ function handlePointerUp(event: PointerEvent) {
+ const swipeStart = swipeStartRef.current;
+ swipeStartRef.current = null;
+ if (dragging) {
+ setDragging(false);
+ dragStartRef.current = null;
+ return;
+ }
+ if (!swipeStart || items.length < 2 || zoom > 1) {
+ return;
+ }
+ const dx = event.clientX - swipeStart.x;
+ const dy = event.clientY - swipeStart.y;
+ if (Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy)) {
+ if (dx < 0) {
+ onIndexChange(index >= items.length - 1 ? 0 : index + 1);
+ } else {
+ onIndexChange(index <= 0 ? items.length - 1 : index - 1);
+ }
+ }
+ }
+
+ return createPortal(
+
+
event.stopPropagation()}>
+
+
+
+
{extractFileName(current.url)}
+
+ {index + 1} / {items.length}
+
+
+
+ {current.messageId && onJumpToMessage ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
{
+ setDragging(false);
+ dragStartRef.current = null;
+ swipeStartRef.current = null;
+ }}
+ onDoubleClick={() => {
+ if (current.type !== "image") return;
+ if (zoom > 1) {
+ setZoom(1);
+ setOffset({ x: 0, y: 0 });
+ } else {
+ setZoom(2);
+ }
+ }}
+ >
+ {canPrev ? (
+
+ ) : null}
+ {canNext ? (
+
+ ) : null}
+
+ {current.type === "image" ? (
+

1 ? "grab" : "zoom-in" }}
+ />
+ ) : (
+
+ )}
+
+
+ {items.length > 1 ? (
+
+
+ {items.map((item, itemIndex) => (
+
+ ))}
+
+
+ ) : null}
+
+
+
,
+ document.body
+ );
+}
+
+function extractFileName(url: string): string {
+ try {
+ const parsed = new URL(url);
+ const value = parsed.pathname.split("/").pop();
+ return decodeURIComponent(value || "file");
+ } catch {
+ const value = url.split("/").pop();
+ return value ? decodeURIComponent(value) : "file";
+ }
+}
+
+async function downloadFileFromUrl(url: string): Promise {
+ const response = await fetch(url, { mode: "cors" });
+ if (!response.ok) {
+ throw new Error("Download failed");
+ }
+ const blob = await response.blob();
+ const filename = extractFileName(url);
+ const blobUrl = window.URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = blobUrl;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.URL.revokeObjectURL(blobUrl);
+}
diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx
index ad2edba..f7223ee 100644
--- a/web/src/components/MessageList.tsx
+++ b/web/src/components/MessageList.tsx
@@ -15,6 +15,7 @@ import { useAudioPlayerStore } from "../store/audioPlayerStore";
import { useUiStore } from "../store/uiStore";
import { formatTime } from "../utils/format";
import { formatMessageHtml } from "../utils/formatMessage";
+import { MediaViewer } from "./MediaViewer";
type ContextMenuState = {
x: number;
@@ -724,72 +725,14 @@ export function MessageList() {
) : null}
{mediaViewer ? (
- setMediaViewer(null)}>
-
event.stopPropagation()}>
-
-
- {mediaViewer.items.length > 1 ? (
- <>
-
-
-
- >
- ) : null}
-
-
-
- {mediaViewer.items[mediaViewer.index]?.type === "image" ? (
-

- ) : (
-
- )}
-
-
+ setMediaViewer(null)}
+ onIndexChange={(nextIndex) => setMediaViewer((prev) => (prev ? { ...prev, index: nextIndex } : prev))}
+ onToast={showToast}
+ open
+ />
) : null}
{deleteMessageId ? (