| "use client"; |
|
|
| import { useState, useEffect, useRef } from 'react'; |
| import { Button } from '@/components/ui/button'; |
| import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; |
| import { Textarea } from '@/components/ui/textarea'; |
| import { Loader2, Send, X, Image as ImageIcon, Eye } from 'lucide-react'; |
| import { useSettings } from '@/contexts/settings-context'; |
| import type { ReplyTo } from '@/lib/types'; |
| import { RadioGroup, RadioGroupItem } from './ui/radio-group'; |
| import { Label } from './ui/label'; |
| import { cn } from '@/lib/utils'; |
| import Image from 'next/image'; |
| import { Switch } from './ui/switch'; |
|
|
| interface MediaPreviewModalProps { |
| isOpen: boolean; |
| onClose: () => void; |
| file: File; |
| originalSize: number; |
| onSend: (file: File, text?: string, replyTo?: ReplyTo, options?: { deleteAfterDelivery: boolean }) => Promise<void>; |
| } |
|
|
| function formatBytes(bytes: number, decimals = 2) { |
| if (bytes === 0) return '0 Bytes'; |
| const k = 1024; |
| const dm = decimals < 0 ? 0 : decimals; |
| const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; |
| } |
|
|
| |
| async function compressImage(file: File): Promise<Blob | null> { |
| return new Promise((resolve) => { |
| const reader = new FileReader(); |
| reader.readAsDataURL(file); |
| reader.onload = (e) => { |
| const img = document.createElement('img'); |
| img.src = e.target?.result as string; |
| img.onload = () => { |
| const canvas = document.createElement('canvas'); |
| const MAX_WIDTH = 1024; |
| const MAX_HEIGHT = 1024; |
| let width = img.width; |
| let height = img.height; |
|
|
| if (width > height) { |
| if (width > MAX_WIDTH) { |
| height *= MAX_WIDTH / width; |
| width = MAX_WIDTH; |
| } |
| } else { |
| if (height > MAX_HEIGHT) { |
| width *= MAX_HEIGHT / height; |
| height = MAX_HEIGHT; |
| } |
| } |
| canvas.width = width; |
| canvas.height = height; |
| const ctx = canvas.getContext('2d'); |
| ctx?.drawImage(img, 0, 0, width, height); |
| canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.65); |
| }; |
| }; |
| }); |
| } |
|
|
|
|
| export function MediaPreviewModal({ isOpen, onClose, file, originalSize, onSend }: MediaPreviewModalProps) { |
| const { addToast, t } = useSettings(); |
| const [caption, setCaption] = useState(''); |
| const [isLoading, setIsLoading] = useState(false); |
| const [previewUrl, setPreviewUrl] = useState<string | null>(null); |
| const [quality, setQuality] = useState<'standard' | 'hd'>('standard'); |
| const [isViewOnce, setIsViewOnce] = useState(false); |
| const [compressedBlob, setCompressedBlob] = useState<Blob | null>(null); |
|
|
| const isImage = file.type.startsWith('image/'); |
| const isVideo = file.type.startsWith('video/'); |
|
|
| useEffect(() => { |
| if (file && isOpen) { |
| const url = URL.createObjectURL(file); |
| setPreviewUrl(url); |
|
|
| if (isImage) { |
| compressImage(file).then(blob => { |
| setCompressedBlob(blob); |
| }); |
| } |
|
|
| return () => URL.revokeObjectURL(url); |
| } |
| }, [file, isOpen, isImage]); |
|
|
|
|
| const handleSend = async () => { |
| if (!file) return; |
|
|
| setIsLoading(true); |
| |
| let fileToSend = file; |
| |
| if (isImage && quality === 'standard' && compressedBlob) { |
| fileToSend = new File([compressedBlob], file.name, { type: 'image/jpeg' }); |
| } |
|
|
| try { |
| await onSend(fileToSend, caption.trim(), undefined, { deleteAfterDelivery: isViewOnce }); |
| addToast('Media sent!'); |
| handleClose(); |
| } catch (error) { |
| console.error("Failed to send media:", error); |
| addToast('Could not send the media. Please try again.', { variant: 'destructive' }); |
| } finally { |
| setIsLoading(false); |
| } |
| }; |
|
|
| const handleClose = () => { |
| setCaption(''); |
| setIsLoading(false); |
| setCompressedBlob(null); |
| setQuality('standard'); |
| setIsViewOnce(false); |
| onClose(); |
| }; |
|
|
| if (!isOpen || !file) return null; |
| |
| const standardSize = isImage && compressedBlob ? compressedBlob.size : originalSize; |
|
|
| return ( |
| <Dialog open={isOpen} onOpenChange={handleClose}> |
| <DialogContent |
| className={cn( |
| "p-0 border-0 bg-black/80 max-w-none w-screen h-screen rounded-none flex flex-col items-center justify-center", |
| "data-[state=open]:animate-in data-[state=open]:fade-in-0", |
| "data-[state=closed]:animate-out data-[state=closed]:fade-out-0" |
| )} |
| > |
| <DialogHeader className="sr-only"> |
| <DialogTitle>{t('sendMediaTitle')}</DialogTitle> |
| <DialogDescription>Preview your media before sending.</DialogDescription> |
| </DialogHeader> |
| |
| <Button onClick={handleClose} variant="ghost" size="icon" className="absolute top-4 right-4 z-50 h-10 w-10 bg-black/30 hover:bg-black/50 text-white rounded-full"> |
| <X className="h-6 w-6" /> |
| </Button> |
| |
| <div className="relative w-full h-full flex flex-col items-center justify-center scaleIn"> |
| <div className="relative w-full h-full flex items-center justify-center p-8"> |
| {previewUrl && ( |
| isImage ? ( |
| <Image |
| src={previewUrl} |
| alt={file.name} |
| fill |
| style={{objectFit:"contain"}} |
| className="rounded-lg shadow-2xl" |
| /> |
| ) : ( |
| <video src={previewUrl} controls autoPlay className="max-w-full max-h-full rounded-lg shadow-2xl" /> |
| ) |
| )} |
| </div> |
| </div> |
| |
| <div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/80 via-black/50 to-transparent"> |
| <div className="max-w-md mx-auto mb-2 p-1 rounded-lg bg-black/40 backdrop-blur-sm flex flex-col gap-2"> |
| {isImage && ( |
| <RadioGroup value={quality} onValueChange={(val) => setQuality(val as 'standard' | 'hd')} className="grid grid-cols-2 gap-1"> |
| <Label htmlFor="q-standard" className={cn("text-center p-2 rounded-md cursor-pointer text-sm text-white", quality === 'standard' && 'bg-primary/80')}> |
| <RadioGroupItem value="standard" id="q-standard" className="sr-only"/> |
| Standard quality <span className="text-xs opacity-70">({formatBytes(standardSize)})</span> |
| </Label> |
| <Label htmlFor="q-hd" className={cn("text-center p-2 rounded-md cursor-pointer text-sm text-white", quality === 'hd' && 'bg-primary/80')}> |
| <RadioGroupItem value="hd" id="q-hd" className="sr-only"/> |
| HD quality <span className="text-xs opacity-70">({formatBytes(originalSize)})</span> |
| </Label> |
| </RadioGroup> |
| )} |
| {isVideo && ( |
| <div className="text-center p-2 rounded-md text-sm text-white/80"> |
| <p>Videos are sent in their original quality.</p> |
| <p className="text-xs opacity-70">({formatBytes(originalSize)})</p> |
| </div> |
| )} |
| <div className="flex items-center justify-center p-2 rounded-md"> |
| <Label htmlFor="view-once-switch" className="text-white text-sm flex items-center gap-2 cursor-pointer"> |
| <Eye className="h-4 w-4" /> |
| <span>View Once</span> |
| </Label> |
| <Switch id="view-once-switch" checked={isViewOnce} onCheckedChange={setIsViewOnce} className="ml-auto" /> |
| </div> |
| </div> |
| <div className="flex items-center gap-2 max-w-2xl mx-auto"> |
| <Textarea |
| placeholder={t('addCaption')} |
| value={caption} |
| onChange={(e) => setCaption(e.target.value)} |
| className="bg-black/40 border-white/20 text-white placeholder:text-gray-300 focus-visible:ring-1 focus-visible:ring-primary ring-offset-0 resize-none" |
| rows={1} |
| /> |
| <Button onClick={handleSend} disabled={isLoading} size="icon" className="w-12 h-12 rounded-full bg-primary flex-shrink-0"> |
| {isLoading ? <Loader2 className="animate-spin" /> : <Send />} |
| </Button> |
| </div> |
| </div> |
| </DialogContent> |
| </Dialog> |
| ); |
| } |
|
|