From b7c94123faa3b97e59652d42d80cce2f6e883b91 Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 9 Aug 2026 16:46:37 +0200 Subject: [PATCH] feat(chat): add media overlay --- packages/chat/src/components/media.tsx | 279 +++++++++++++++++- .../chat/src/components/mediaSaveButton.tsx | 15 +- packages/chat/src/components/message.tsx | 9 +- 3 files changed, 287 insertions(+), 16 deletions(-) diff --git a/packages/chat/src/components/media.tsx b/packages/chat/src/components/media.tsx index 6eac9de..55781d6 100644 --- a/packages/chat/src/components/media.tsx +++ b/packages/chat/src/components/media.tsx @@ -1,21 +1,145 @@ import Text from "@tensamin/markdown/text"; import { useStorage } from "@tensamin/storage/context"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@methanium/ui"; -import { TriangleAlert } from "lucide-react"; -import { useState, useMemo, useEffect } from "react"; +import { + Avatar, + AvatarFallback, + AvatarImage, + Button, + Dialog, + DialogClose, + DialogContent, + DialogTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@methanium/ui"; +import { toast } from "@tensamin/shared/log"; +import { + Check, + Copy, + ExternalLink, + TriangleAlert, + X, + ZoomIn, +} from "lucide-react"; +import { useState, useMemo, useEffect, useRef, WheelEvent } from "react"; import MediaSaveButton from "./mediaSaveButton"; +import { User, useUser } from "@tensamin/user/context"; -export default function Media({ link }: { link: string }) { +const zoomLevels = [1, 1.5, 2, 3]; + +export default function Media({ + link, + senderId, + time, +}: { + link: string; + senderId: number; + time: string; +}) { const { load } = useStorage(); const [trustedDomains, setTrustedDomains] = useState([]); const [hidden, setHidden] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [zoomLevel, setZoomLevel] = useState(0); + const wheelDelta = useRef(0); const hostname = useMemo(() => new URL(link).hostname, [link]); + // Get user's + const { get } = useUser(); + const [user, setUser] = useState(undefined); + useEffect(() => { + get(senderId).then(setUser); + }, [senderId, get]); + const avatar = user?.Avatar + ? `data:image/webp;base64,${user.Avatar}` + : undefined; + useEffect(() => { load("chat_trusted_domains").then(setTrustedDomains); }, [load]); + const changeZoom = (change: number) => { + setZoomLevel((level) => + Math.max(0, Math.min(zoomLevels.length - 1, level + change)), + ); + }; + + const handleWheel = (event: WheelEvent) => { + wheelDelta.current += event.deltaY; + + if (Math.abs(wheelDelta.current) < 50) return; + + changeZoom(wheelDelta.current < 0 ? 1 : -1); + wheelDelta.current = 0; + }; + + const [copied, setCopied] = useState(false); + useEffect(() => { + let cancelled = false; + + if (copied) { + setTimeout(() => { + if (cancelled) return; + setCopied(false); + }, 1000); + } + + return () => { + cancelled = true; + }; + }, [copied]); + const copyImage = async () => { + try { + if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") { + throw new Error("Image clipboard access is not supported."); + } + + const image = (async () => { + const response = await fetch(link); + if (!response.ok) { + throw new Error(`Failed to download image (${response.status}).`); + } + + const blob = await response.blob(); + if (blob.type === "image/png") return blob; + + const bitmap = await createImageBitmap(blob); + try { + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d"); + + if (!context) { + throw new Error("Failed to prepare the image for copying."); + } + + context.drawImage(bitmap, 0, 0); + return await new Promise((resolve, reject) => { + canvas.toBlob( + (convertedBlob) => + convertedBlob + ? resolve(convertedBlob) + : reject(new Error("Failed to convert the image to PNG.")), + "image/png", + ); + }); + } finally { + bitmap.close(); + } + })(); + + await navigator.clipboard.write([ + new ClipboardItem({ "image/png": image }), + ]); + setCopied(true); + } catch (error) { + toast("error", "Failed to copy image", String(error)); + } + }; + return trustedDomains.includes(hostname) ? (
{hidden ? ( @@ -23,12 +147,147 @@ export default function Media({ link }: { link: string }) { ) : ( )} - setHidden(false)} - className="max-h-70 max-w-70 py-1 rounded-lg" - /> + { + setDialogOpen(open); + if (!open) { + setZoomLevel(0); + wheelDelta.current = 0; + } + }} + > + ( + setHidden(false)} + className="max-h-70 max-w-70 py-1 rounded-lg cursor-pointer" + /> + )} + /> + +
+
+ + + + {user?.Display.slice(0, 2).toUpperCase()} + + +
+

{user?.Display}

+

{time}

+
+
+
+
+ + ( + + )} + /> + Zoom {zoomLevel + 1}/4 + +
+ + ( + + )} + /> + + {copied ? "Copied!" : "Copy image"} + + + + ( + + + + )} + /> + Open in browser + +
+
+
+ ( + + ( + + )} + /> + Close + + )} + /> +
+
+
+ setHidden(false)} + style={{ + transform: `translate(-50%, -50%) scale(${zoomLevels[zoomLevel]})`, + }} + className="h-[80vh] rounded-lg object-cover absolute top-1/2 left-1/2 transition-transform duration-200" + /> +
+
) : (
diff --git a/packages/chat/src/components/mediaSaveButton.tsx b/packages/chat/src/components/mediaSaveButton.tsx index bfdf6a9..99fbcec 100644 --- a/packages/chat/src/components/mediaSaveButton.tsx +++ b/packages/chat/src/components/mediaSaveButton.tsx @@ -1,6 +1,6 @@ -import { Button, cn } from "@methanium/ui"; +import { Button, cn, useIsMobile } from "@methanium/ui"; import { useStorage } from "@tensamin/storage/context"; -import { Save } from "lucide-react"; +import { Star } from "lucide-react"; import { useEffect, useState } from "react"; export default function MediaSaveButton({ @@ -17,6 +17,7 @@ export default function MediaSaveButton({ url: string; }) { const { load, save } = useStorage(); + const isMobile = useIsMobile(); const [savedMedia, setSavedMedia] = useState(null); const isSaved = savedMedia ? savedMedia.includes(url) : defaultSaved; @@ -40,17 +41,21 @@ export default function MediaSaveButton({ return ( diff --git a/packages/chat/src/components/message.tsx b/packages/chat/src/components/message.tsx index 6dc8dc1..d6b6ea2 100644 --- a/packages/chat/src/components/message.tsx +++ b/packages/chat/src/components/message.tsx @@ -431,7 +431,14 @@ function MessageComponent({ ) : (
{isValidURL ? ( - + ) : ( )}