feat(chat): add media overlay
This commit is contained in:
parent
32671f32fd
commit
b7c94123fa
3 changed files with 287 additions and 16 deletions
|
|
@ -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<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]);
|
||||
|
||||
// Get user's
|
||||
const { get } = useUser();
|
||||
const [user, setUser] = useState<undefined | User>(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<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 ? (
|
||||
|
|
@ -23,12 +147,147 @@ export default function Media({ link }: { link: string }) {
|
|||
) : (
|
||||
<MediaSaveButton className="top-2" url={link} />
|
||||
)}
|
||||
<img
|
||||
src={link}
|
||||
hidden={hidden}
|
||||
onLoad={() => setHidden(false)}
|
||||
className="max-h-70 max-w-70 py-1 rounded-lg"
|
||||
/>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -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<string[] | null>(null);
|
||||
const isSaved = savedMedia ? savedMedia.includes(url) : defaultSaved;
|
||||
|
||||
|
|
@ -40,17 +41,21 @@ export default function MediaSaveButton({
|
|||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
aria-label={ariaLabel ?? (isSaved ? "Unsave media" : "Save media")}
|
||||
className={cn(
|
||||
"rounded-sm border-0! p-0 absolute right-1 top-1 z-10 h-9 w-9 -translate-y-2 opacity-0 transition-all group-hover:translate-y-0 group-hover:opacity-100 bg-card",
|
||||
"rounded-sm border-0! p-0 absolute right-1 top-1 z-10 h-9 w-9 transition-all",
|
||||
!isMobile &&
|
||||
"-translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Save
|
||||
<Star
|
||||
color="var(--primary-foreground-alt)"
|
||||
className={cn(
|
||||
"size-4.5 stroke-1.5!",
|
||||
isSaved && "fill-(--primary) stroke-(--primary-foreground-alt)",
|
||||
isSaved ? "fill-(--primary)" : "",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -431,7 +431,14 @@ function MessageComponent({
|
|||
) : (
|
||||
<div className="flex gap-1">
|
||||
{isValidURL ? (
|
||||
<Media link={message.Content} />
|
||||
<Media
|
||||
link={message.Content}
|
||||
senderId={message.SenderId}
|
||||
time={new Date(message.SendTime).toLocaleString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Text value={message.Content} />
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue