|
|
|
|
| "use client"; |
|
|
| import { useState, useEffect, useRef, useMemo } from 'react'; |
| import { Group, GroupMember, User, GroupSendingMode } from '@/lib/types'; |
| import { Button } from './ui/button'; |
| import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; |
| import { useAuth } from '@/contexts/auth-context'; |
| import { useFirebase } from '@/contexts/firebase-context'; |
| import { useGroups } from '@/contexts/groups-context'; |
| import { useSettings } from '@/contexts/settings-context'; |
| import { ArrowLeft, Edit, ShieldCheck, User as UserIcon, Users, X, Check, Loader2, MoreVertical, UserPlus, MessageSquare, Settings, MicOff, Crown } from 'lucide-react'; |
| import { ScrollArea } from './ui/scroll-area'; |
| import { |
| DropdownMenu, |
| DropdownMenuContent, |
| DropdownMenuItem, |
| DropdownMenuTrigger, |
| } from "@/components/ui/dropdown-menu" |
| import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog'; |
| import { Badge } from './ui/badge'; |
| import { doc, getDoc } from 'firebase/firestore'; |
| import { Input } from './ui/input'; |
| import { Textarea } from './ui/textarea'; |
| import { Label } from './ui/label'; |
| import { RadioGroup, RadioGroupItem } from './ui/radio-group'; |
| import { Capacitor } from '@capacitor/core'; |
|
|
|
|
| interface GroupInfoPanelProps { |
| isOpen: boolean; |
| onClose: () => void; |
| group: Group; |
| onAddMembers: () => void; |
| } |
|
|
| export function GroupInfoPanel({ isOpen, onClose, group, onAddMembers }: GroupInfoPanelProps) { |
| const { currentUser } = useAuth(); |
| const { db } = useFirebase(); |
| const { leaveGroup, removeMemberFromGroup, toggleGroupAdmin, updateGroupInfo, updateGroupSendingMode, toggleMuteMember } = useGroups(); |
| const { playSound, t, addToast } = useSettings(); |
| const [confirmAction, setConfirmAction] = useState<{ type: 'leave' | 'remove', member?: GroupMember } | null>(null); |
| const [members, setMembers] = useState<GroupMember[]>([]); |
| |
| const [isEditing, setIsEditing] = useState(false); |
| const [isSaving, setIsSaving] = useState(false); |
| const [isUploading, setIsUploading] = useState(false); |
| const [editedName, setEditedName] = useState(group.info.name); |
| const [editedPhoto, setEditedPhoto] = useState(group.info.photoURL); |
| const [editedDesc, setEditedDesc] = useState(group.info.description || ''); |
| |
| const initialGroupState = useRef(group.info); |
| const fileInputRef = useRef<HTMLInputElement | null>(null); |
|
|
| useEffect(() => { |
| if (!group) return; |
|
|
| const fetchMembersData = async () => { |
| const memberUids = Object.keys(group.members); |
| const memberPromises = memberUids.map(uid => getDoc(doc(db, 'users', uid))); |
| const memberDocs = await Promise.all(memberPromises); |
| |
| const memberDetails = memberDocs |
| .filter(doc => doc.exists()) |
| .map(doc => { |
| const userData = doc.data() as User; |
| return { |
| ...userData, |
| uid: doc.id, |
| isAdmin: !!group.admins[doc.id], |
| isMuted: !!group.info.mutedMembers?.[doc.id] |
| } as GroupMember; |
| }) |
| .sort((a, b) => { |
| if (a.isAdmin !== b.isAdmin) return a.isAdmin ? -1 : 1; |
| return a.displayName.localeCompare(b.displayName); |
| }); |
|
|
| setMembers(memberDetails); |
| }; |
|
|
| fetchMembersData(); |
| }, [group, db]); |
| |
| useEffect(() => { |
| |
| setEditedName(group.info.name); |
| setEditedPhoto(group.info.photoURL); |
| setEditedDesc(group.info.description || ''); |
| setIsEditing(false); |
| initialGroupState.current = group.info; |
| }, [isOpen, group]); |
|
|
|
|
| const isCurrentUserAdmin = useMemo(() => { |
| return !!(currentUser && group.admins[currentUser.uid]); |
| }, [currentUser, group]); |
|
|
| const isCurrentUserOwner = useMemo(() => { |
| return currentUser?.uid === group.info.createdBy; |
| }, [currentUser, group]); |
| |
| const handleEditToggle = () => { |
| playSound('touch'); |
| if (isEditing) { |
| |
| setEditedName(initialGroupState.current.name); |
| setEditedPhoto(initialGroupState.current.photoURL); |
| setEditedDesc(initialGroupState.current.description || ''); |
| } |
| setIsEditing(!isEditing); |
| }; |
| |
| const handleSaveChanges = async () => { |
| playSound('touch'); |
| if (!isEditing) return; |
| setIsSaving(true); |
| try { |
| await updateGroupInfo(group.id, { |
| name: editedName, |
| photoURL: editedPhoto, |
| description: editedDesc, |
| }); |
| setIsEditing(false); |
| } catch (error) { |
| console.error("Failed to save group info:", error); |
| } finally { |
| setIsSaving(false); |
| } |
| }; |
|
|
| const handleLeaveGroup = async () => { |
| if (!currentUser) return; |
| playSound('touch'); |
| |
| await leaveGroup(group.id); |
| onClose(); |
| setConfirmAction(null); |
| }; |
| |
| const handleRemoveMember = async () => { |
| if (confirmAction?.type !== 'remove' || !confirmAction.member) return; |
| playSound('touch'); |
| await removeMemberFromGroup(group.id, confirmAction.member.uid, confirmAction.member.displayName); |
| setConfirmAction(null); |
| }; |
|
|
| const handleToggleAdmin = async (member: GroupMember) => { |
| playSound('touch'); |
| await toggleGroupAdmin(group.id, member.uid, member.isAdmin); |
| |
| setMembers(prev => prev.map(m => m.uid === member.uid ? { ...m, isAdmin: !m.isAdmin } : m)); |
| } |
| |
| const handleToggleMute = async (member: GroupMember) => { |
| playSound('touch'); |
| await toggleMuteMember(group.id, member.uid, member.displayName, !!member.isMuted); |
| |
| setMembers(prev => prev.map(m => m.uid === member.uid ? { ...m, isMuted: !m.isMuted } : m)); |
| }; |
|
|
| const handleSendingModeChange = (mode: GroupSendingMode) => { |
| playSound('touch'); |
| updateGroupSendingMode(group.id, mode); |
| } |
| |
| const handleAvatarClick = () => { |
| if (isEditing) { |
| fileInputRef.current?.click(); |
| } |
| }; |
|
|
| const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => { |
| const file = event.target.files?.[0]; |
| if (!file) return; |
|
|
| if (file.size > 5 * 1024 * 1024) { |
| addToast("File is too large. Please select an image under 5MB.", { variant: 'destructive' }); |
| return; |
| } |
| |
| setIsUploading(true); |
|
|
| try { |
| const compressedFile = await new Promise<File>((resolve, reject) => { |
| const reader = new FileReader(); |
| reader.readAsDataURL(file); |
| reader.onload = (e) => { |
| const img = new Image(); |
| img.src = e.target?.result as string; |
| img.onload = () => { |
| const canvas = document.createElement('canvas'); |
| const MAX_WIDTH = 256; |
| const MAX_HEIGHT = 256; |
| 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) => { |
| if (blob) { |
| resolve(new File([blob], file.name, { type: 'image/jpeg', lastModified: Date.now() })); |
| } else { |
| reject(new Error('Canvas to Blob conversion failed')); |
| } |
| }, 'image/jpeg', 0.8); |
| }; |
| img.onerror = reject; |
| }; |
| reader.onerror = reject; |
| }); |
|
|
| const formData = new FormData(); |
| formData.append('file', compressedFile); |
|
|
| const isNative = Capacitor.isNativePlatform(); |
| const baseUrl = isNative ? process.env.NEXT_PUBLIC_API_BASE_URL : ''; |
| const uploadUrl = `${baseUrl}/api/media`; |
|
|
| const uploadResponse = await fetch(uploadUrl, { |
| method: 'POST', |
| body: formData, |
| }); |
|
|
| if (!uploadResponse.ok) { |
| throw new Error('Upload to storage failed.'); |
| } |
| |
| const { fileKey } = await uploadResponse.json(); |
| const publicUrl = `${baseUrl}/api/media?fileKey=${fileKey}`; |
|
|
| setEditedPhoto(publicUrl); |
| addToast("Group picture updated!"); |
|
|
| } catch (error) { |
| console.error("Failed to upload group picture:", error); |
| addToast("Failed to upload image. Please try again.", { variant: 'destructive' }); |
| } finally { |
| setIsUploading(false); |
| } |
| }; |
| |
| if (!isOpen || !currentUser) return null; |
|
|
| const actionToConfirm = () => { |
| if (!confirmAction) return; |
| if (confirmAction.type === 'leave') handleLeaveGroup(); |
| if (confirmAction.type === 'remove') handleRemoveMember(); |
| } |
| |
| const sendingMode = group.info.settings?.sendingMode || 'everyone'; |
| const groupType = group.info.type || 'general'; |
|
|
| return ( |
| <aside className="absolute top-0 right-0 h-full w-full bg-card border-l z-50 flex flex-col group-info-anim md:w-80 lg:w-96"> |
| <input |
| type="file" |
| ref={fileInputRef} |
| className="hidden" |
| accept="image/png, image/jpeg, image/gif" |
| onChange={handleFileSelect} |
| /> |
| <header className="flex-shrink-0 flex items-center p-4 bg-card border-b"> |
| <Button onClick={onClose} variant="ghost" size="icon" className="mr-4"> |
| <X /> |
| </Button> |
| <h2 className="text-xl font-bold">{t('groupInfoTitle')}</h2> |
| </header> |
| |
| <ScrollArea className="flex-1"> |
| <div className="flex flex-col items-center text-center p-6 border-b"> |
| <div className="relative"> |
| <Avatar className="w-24 h-24 mb-4 cursor-pointer" onClick={handleAvatarClick}> |
| <AvatarImage src={isEditing ? editedPhoto : group.info.photoURL} alt={group.info.name} /> |
| <AvatarFallback>{(isEditing ? editedName : group.info.name).charAt(0)}</AvatarFallback> |
| </Avatar> |
| {isUploading && ( |
| <div className="absolute inset-0 mb-4 bg-black/60 rounded-full flex items-center justify-center"> |
| <Loader2 className="w-8 w-8 animate-spin text-white" /> |
| </div> |
| )} |
| </div> |
| <div className="relative w-full"> |
| {isEditing ? ( |
| <Input value={editedName} onChange={(e) => setEditedName(e.target.value)} className="text-2xl font-bold text-center h-12 mb-2" /> |
| ) : ( |
| <h3 className="text-2xl font-bold">{group.info.name}</h3> |
| )} |
| {isCurrentUserAdmin && ( |
| <Button variant="ghost" size="icon" onClick={handleEditToggle} className="absolute -right-1 top-1 h-8 w-8"> |
| {isEditing ? <X className="h-4 w-4" /> : <Edit className="h-4 w-4" />} |
| </Button> |
| )} |
| </div> |
| {isEditing ? ( |
| <Textarea value={editedDesc} onChange={(e) => setEditedDesc(e.target.value)} placeholder={t('groupDescriptionPlaceholder')} className="text-center mt-1" /> |
| ) : ( |
| <p className="text-sm text-muted-foreground mt-1">{group.info.description || t('noDescription')}</p> |
| )} |
| <Badge variant="outline" className="mt-3 capitalize">{t(groupType)}</Badge> |
| {isEditing && ( |
| <> |
| <Button onClick={handleSaveChanges} disabled={isSaving || isUploading} className="mt-4 w-full"> |
| {isSaving ? <Loader2 className="animate-spin" /> : t('saveChanges')} |
| </Button> |
| </> |
| )} |
| </div> |
| |
| {isCurrentUserAdmin && ( |
| <div className="p-4 border-b"> |
| <h4 className="font-bold flex items-center gap-2 text-muted-foreground mb-3"> |
| <Settings className="h-5 w-5"/> {t('adminSettings')} |
| </h4> |
| <RadioGroup value={sendingMode} onValueChange={handleSendingModeChange} className="space-y-2"> |
| <Label className="font-medium flex items-center gap-2"><MessageSquare className="h-4 w-4" /> {t('whoCanSend')}</Label> |
| <div className="flex items-center space-x-2 p-2 bg-muted rounded-md"> |
| <RadioGroupItem value="everyone" id="r-everyone" /> |
| <Label htmlFor="r-everyone" className="flex-1 cursor-pointer">{t('everyone')}</Label> |
| </div> |
| <div className="flex items-center space-x-2 p-2 bg-muted rounded-md"> |
| <RadioGroupItem value="admins" id="r-admins" /> |
| <Label htmlFor="r-admins" className="flex-1 cursor-pointer">{t('adminsOnlyOption')}</Label> |
| </div> |
| {isCurrentUserOwner && ( |
| <div className="flex items-center space-x-2 p-2 bg-muted rounded-md"> |
| <RadioGroupItem value="owner" id="r-owner" /> |
| <Label htmlFor="r-owner" className="flex-1 cursor-pointer">{t('ownerOnlyOption')}</Label> |
| </div> |
| )} |
| </RadioGroup> |
| </div> |
| )} |
| |
| <div className="p-4"> |
| <div className="flex items-center justify-between mb-2"> |
| <h4 className="font-bold flex items-center gap-2 text-muted-foreground"> |
| <Users className="h-5 w-5"/> {t('members', { count: members.length })} |
| </h4> |
| {isCurrentUserAdmin && ( |
| <Button variant="outline" size="sm" onClick={onAddMembers}> |
| <UserPlus className="mr-2 h-4 w-4" /> |
| {t('add')} |
| </Button> |
| )} |
| </div> |
| <div className="space-y-2"> |
| {members.map(member => ( |
| <div key={member.uid} className="flex items-center justify-between p-2 rounded-lg hover:bg-muted group"> |
| <div className="flex items-center gap-3"> |
| <Avatar> |
| <AvatarImage src={member.photoURL} alt={member.displayName} /> |
| <AvatarFallback>{member.displayName.charAt(0)}</AvatarFallback> |
| </Avatar> |
| <div> |
| <p className="font-bold flex items-center gap-1.5"> |
| {member.displayName} |
| {member.uid === currentUser.uid && <span className="text-xs text-muted-foreground">{t('you')}</span>} |
| </p> |
| <div className="flex items-center gap-1.5"> |
| {member.uid === group.info.createdBy && <Badge variant="secondary" className="px-1.5 text-xs h-5 flex items-center gap-1"><Crown className="h-3 w-3 text-amber-500" /> {t('owner')}</Badge>} |
| {member.isAdmin && member.uid !== group.info.createdBy && <Badge variant="secondary" className="px-1.5 text-xs h-5">{t('admin')}</Badge>} |
| {member.isMuted && <Badge variant="destructive" className="px-1.5 text-xs h-5 flex items-center gap-1"><MicOff className="h-3 w-3" /> {t('muted')}</Badge>} |
| </div> |
| </div> |
| </div> |
| {isCurrentUserAdmin && member.uid !== currentUser.uid && ( |
| <DropdownMenu onOpenChange={() => playSound('touch')}> |
| <DropdownMenuTrigger asChild> |
| <Button variant="ghost" size="icon" className="h-8 w-8 opacity-0 group-hover:opacity-100"> |
| <MoreVertical /> |
| </Button> |
| </DropdownMenuTrigger> |
| <DropdownMenuContent> |
| {isCurrentUserOwner && member.uid !== group.info.createdBy && ( |
| <DropdownMenuItem onSelect={() => handleToggleAdmin(member)}> |
| {member.isAdmin ? t('demoteToMember') : t('promoteToAdmin')} |
| </DropdownMenuItem> |
| )} |
| <DropdownMenuItem onSelect={() => handleToggleMute(member)}> |
| {member.isMuted ? t('unmuteMember') : t('muteMember')} |
| </DropdownMenuItem> |
| <DropdownMenuItem onSelect={() => setConfirmAction({ type: 'remove', member })} className="text-destructive"> |
| {t('removeFromGroup')} |
| </DropdownMenuItem> |
| </DropdownMenuContent> |
| </DropdownMenu> |
| )} |
| </div> |
| ))} |
| </div> |
| </div> |
| |
| </ScrollArea> |
| <footer className="p-4 border-t"> |
| <Button variant="destructive" className="w-full" onClick={() => setConfirmAction({ type: 'leave' })}> |
| {t('leaveGroup')} |
| </Button> |
| </footer> |
|
|
| <AlertDialog open={!!confirmAction} onOpenChange={(isOpen) => !isOpen && setConfirmAction(null)}> |
| <AlertDialogContent> |
| <AlertDialogHeader> |
| <AlertDialogTitle>{t('areYouSure')}</AlertDialogTitle> |
| <AlertDialogDescription> |
| {confirmAction?.type === 'leave' && t('leaveGroupConfirm')} |
| {confirmAction?.type === 'remove' && t('removeMemberConfirm', { name: confirmAction.member?.displayName || '' })} |
| </AlertDialogDescription> |
| </AlertDialogHeader> |
| <AlertDialogFooter> |
| <AlertDialogCancel onClick={() => playSound('touch')}>{t('cancel')}</AlertDialogCancel> |
| <AlertDialogAction onClick={actionToConfirm} className="bg-destructive hover:bg-destructive/90"> |
| {t('confirm')} |
| </AlertDialogAction> |
| </AlertDialogFooter> |
| </AlertDialogContent> |
| </AlertDialog> |
| </aside> |
| ); |
| } |
|
|