Add pwa & stuff #75
3 changed files with 287 additions and 16 deletions
commit
b7c94123fa
|
|
@ -1,21 +1,145 @@
|
||||||
import Text from "@tensamin/markdown/text";
|
import Text from "@tensamin/markdown/text";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@methanium/ui";
|
import {
|
||||||
import { TriangleAlert } from "lucide-react";
|
Avatar,
|
||||||
import { useState, useMemo, useEffect } from "react";
|
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 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 { load } = useStorage();
|
||||||
|
|
||||||
const [trustedDomains, setTrustedDomains] = useState<string[]>([]);
|
const [trustedDomains, setTrustedDomains] = useState<string[]>([]);
|
||||||
const [hidden, setHidden] = useState(true);
|
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 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(() => {
|
useEffect(() => {
|
||||||
load("chat_trusted_domains").then(setTrustedDomains);
|
load("chat_trusted_domains").then(setTrustedDomains);
|
||||||
}, [load]);
|
}, [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) ? (
|
return trustedDomains.includes(hostname) ? (
|
||||||
<div className="group relative inline-block">
|
<div className="group relative inline-block">
|
||||||
{hidden ? (
|
{hidden ? (
|
||||||
|
|
@ -23,12 +147,147 @@ export default function Media({ link }: { link: string }) {
|
||||||
) : (
|
) : (
|
||||||
<MediaSaveButton className="top-2" url={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
|
<img
|
||||||
src={link}
|
src={link}
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
onLoad={() => setHidden(false)}
|
onLoad={() => setHidden(false)}
|
||||||
className="max-h-70 max-w-70 py-1 rounded-lg"
|
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>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-1 items-center">
|
<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 { useStorage } from "@tensamin/storage/context";
|
||||||
import { Save } from "lucide-react";
|
import { Star } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function MediaSaveButton({
|
export default function MediaSaveButton({
|
||||||
|
|
@ -17,6 +17,7 @@ export default function MediaSaveButton({
|
||||||
url: string;
|
url: string;
|
||||||
}) {
|
}) {
|
||||||
const { load, save } = useStorage();
|
const { load, save } = useStorage();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [savedMedia, setSavedMedia] = useState<string[] | null>(null);
|
const [savedMedia, setSavedMedia] = useState<string[] | null>(null);
|
||||||
const isSaved = savedMedia ? savedMedia.includes(url) : defaultSaved;
|
const isSaved = savedMedia ? savedMedia.includes(url) : defaultSaved;
|
||||||
|
|
||||||
|
|
@ -40,17 +41,21 @@ export default function MediaSaveButton({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
variant="secondary"
|
||||||
aria-label={ariaLabel ?? (isSaved ? "Unsave media" : "Save media")}
|
aria-label={ariaLabel ?? (isSaved ? "Unsave media" : "Save media")}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<Save
|
<Star
|
||||||
|
color="var(--primary-foreground-alt)"
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-4.5 stroke-1.5!",
|
"size-4.5 stroke-1.5!",
|
||||||
isSaved && "fill-(--primary) stroke-(--primary-foreground-alt)",
|
isSaved ? "fill-(--primary)" : "",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -431,7 +431,14 @@ function MessageComponent({
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{isValidURL ? (
|
{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} />
|
<Text value={message.Content} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue