Add pwa & stuff #75

Merged
alois merged 48 commits from dev into main 2026-08-18 22:32:16 +03:00
3 changed files with 287 additions and 16 deletions
Showing only changes of commit b7c94123fa - Show all commits

feat(chat): add media overlay
Some checks failed
/ build-desktop (linux) (push) Failing after 4m45s
/ build-web (push) Failing after 5m12s
/ build-mobile (push) Failing after 7m34s
/ release (push) Has been skipped

Alois 2026-08-09 16:46:37 +02:00
No known key found for this signature in database

View file

@ -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} />
)}
<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)}
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 className="flex gap-1 items-center">

View file

@ -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>

View file

@ -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} />
)}