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" ? ( - media - ) : ( -
-
+ 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" ? ( + media 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" ? ( - media - ) : ( -
-
+ setMediaViewer(null)} + onIndexChange={(nextIndex) => setMediaViewer((prev) => (prev ? { ...prev, index: nextIndex } : prev))} + onToast={showToast} + open + /> ) : null} {deleteMessageId ? (