client/packages/chat/src/components/media/media.tsx
Alois 6f4cc62530
Some checks failed
/ build-web (push) Successful in 8m40s
/ build-mobile (push) Failing after 11m17s
/ build-desktop (linux) (push) Successful in 14m8s
/ release (push) Has been skipped
feat(gif-picker): add gif groups
feat(gif-picker): increase default size
2026-08-11 13:01:09 +02:00

301 lines
9.3 KiB
TypeScript

import { Text } from "@methanium/ui/markdown";
import { useStorage } from "@tensamin/storage/context";
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, type WheelEvent } from "react";
import MediaSaveButton from "./mediaSaveButton";
import { useUserFields } from "@tensamin/user/context";
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<string[]>([]);
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]);
const { data: user } = useUserFields(senderId, ["Avatar", "Display"]);
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<Blob>((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) ? (
<div className="group relative inline-block">
{hidden ? (
<Text value={link} />
) : (
<MediaSaveButton className="top-2" url={link} />
)}
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
setZoomLevel(0);
wheelDelta.current = 0;
}
}}
>
<DialogTrigger
render={({ onClick }) => (
<img
onClick={onClick}
src={link}
hidden={hidden}
onLoad={() => setHidden(false)}
className="max-h-70 max-w-70 py-1 rounded-lg cursor-pointer"
/>
)}
/>
<DialogContent
showCloseButton={false}
onWheel={handleWheel}
className="bg-black/50 border-0! w-screen! h-screen! max-w-screen! rounded-none! p-0!"
>
<div className="relative z-10 flex w-full h-full justify-between p-7 items-start pointer-events-none">
<div
className={`flex gap-2 items-center transition-opacity duration-200 ${zoomLevel === 0 ? "opacity-100" : "pointer-events-none opacity-0"}`}
inert={zoomLevel !== 0}
>
<Avatar className="size-10">
<AvatarImage src={avatar} />
<AvatarFallback className="text-lg">
{user?.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<p className="text-lg font-semibold">{user?.Display}</p>
<p className="text-sm text-muted-foreground">{time}</p>
</div>
</div>
<div className="flex gap-3 pointer-events-auto">
<div
className={`flex ${zoomLevel === 0 ? "gap-1" : ""} bg-card rounded-lg p-1`}
>
<Tooltip>
<TooltipTrigger
render={({ ref }) => (
<Button
ref={ref}
onClick={() =>
setZoomLevel(
(level) => (level + 1) % zoomLevels.length,
)
}
aria-label={`Zoom ${zoomLevel + 1}/4`}
className="w-9! h-9!"
variant="ghost"
>
<ZoomIn />
</Button>
)}
/>
<TooltipContent>Zoom {zoomLevel + 1}/4</TooltipContent>
</Tooltip>
<div
className={`flex overflow-hidden transition-[width,opacity] duration-200 ${zoomLevel === 0 ? "w-18 opacity-100" : "pointer-events-none w-0 opacity-0"}`}
inert={zoomLevel !== 0}
>
<Tooltip>
<TooltipTrigger
render={({ ref }) => (
<Button
ref={ref}
onClick={copyImage}
aria-label="Copy image"
className="w-9! h-9!"
variant="ghost"
>
{copied ? <Check /> : <Copy />}
</Button>
)}
/>
<TooltipContent>
{copied ? "Copied!" : "Copy image"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={({ ref }) => (
<a
ref={ref}
href={link}
target="_blank"
className="w-9! h-9!"
>
<Button className="w-9! h-9!" variant="ghost">
<ExternalLink />
</Button>
</a>
)}
/>
<TooltipContent>Open in browser</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex bg-card rounded-lg p-1">
<DialogClose
render={({ onClick }) => (
<Tooltip>
<TooltipTrigger
render={({ ref }) => (
<Button
ref={ref}
onClick={onClick}
className="w-9! h-9!"
variant="ghost"
>
<X />
</Button>
)}
/>
<TooltipContent>Close</TooltipContent>
</Tooltip>
)}
/>
</div>
</div>
</div>
<img
src={link}
hidden={hidden}
onLoad={() => 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"
/>
</DialogContent>
</Dialog>
</div>
) : (
<div className="flex gap-1 items-center">
<Tooltip>
<TooltipTrigger
render={<TriangleAlert className="size-4 stroke-yellow-400" />}
/>
<TooltipContent>
Link embeds can get your IP-Address! You can configure trusted domains
in the settings.
</TooltipContent>
</Tooltip>
<Text value={link} />
</div>
);
}