import { useState, useEffect, useCallback, useRef } from 'react'; import { collection, query, where, onSnapshot, doc, getDoc, writeBatch, arrayUnion, deleteDoc, updateDoc, arrayRemove, deleteField } from 'firebase/firestore'; import { ref, set, update, push, serverTimestamp, remove } from 'firebase/database'; import { cryptoService } from '@/lib/crypto-service'; import type { User, Group, GroupInvitation, GroupType, Contact, GroupSendingMode, ChatRecipient } from '@/lib/types'; import { useAuth } from '@/contexts/auth-context'; import { useFirebase } from '@/contexts/firebase-context'; import { useSettings } from '@/contexts/settings-context'; import { useContacts } from '@/contexts/contacts-context'; import { storageService } from '@/lib/storage-service'; interface UseGroupsProps { setRecipient: (recipient: ChatRecipient | null | ((prev: ChatRecipient | null) => ChatRecipient | null)) => void; } export const useGroupsCore = ({ setRecipient }: UseGroupsProps) => { const { currentUser } = useAuth(); const { db, rtdb } = useFirebase(); const { contacts } = useContacts(); const { addToast, playSound, t } = useSettings(); const [groups, setGroups] = useState([]); const [groupInvitations, setGroupInvitations] = useState([]); const previousGroupsRef = useRef([]); useEffect(() => { if (!currentUser) { setGroups([]); setGroupInvitations([]); return; } let isMounted = true; // Load cached groups first storageService.getGroups().then(cachedGroups => { if (isMounted && cachedGroups.length > 0) { const sortedGroups = cachedGroups.sort((a, b) => a.info.name.localeCompare(b.info.name)); setGroups(sortedGroups); previousGroupsRef.current = sortedGroups; } }); const groupsQuery = query(collection(db, 'groups'), where(`members.${currentUser.uid}`, '==', true)); const groupsUnsub = onSnapshot(groupsQuery, (snapshot) => { const fetchedGroups = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as Group)); const sortedGroups = fetchedGroups.sort((a, b) => a.info.name.localeCompare(b.info.name)); const prevGroupIds = new Set(previousGroupsRef.current.map(g => g.id)); const currentGroupIds = new Set(sortedGroups.map(g => g.id)); prevGroupIds.forEach(id => { if (!currentGroupIds.has(id)) { setRecipient(prevRecipient => { if (prevRecipient && prevRecipient.uid === id) { addToast(t('You have been removed from the group.')); return null; } return prevRecipient; }); } }); if (isMounted) { setGroups(sortedGroups); storageService.saveGroups(sortedGroups); } previousGroupsRef.current = sortedGroups; }); const invitationsUnsub = onSnapshot(collection(db, 'users', currentUser.uid, 'groupInvitations'), (snapshot) => { if (isMounted) { setGroupInvitations(snapshot.docs.map(doc => doc.data() as GroupInvitation)); } }); return () => { isMounted = false; groupsUnsub(); invitationsUnsub(); }; }, [currentUser, db, setRecipient, addToast, t]); const resetGroupKeys = useCallback(async (groupId: string, newMemberUids?: string[]) => { if (!currentUser) throw new Error("Current user not found for key reset."); try { cryptoService.clearGroupKeyCache(groupId); const groupRef = doc(db, 'groups', groupId); const groupSnap = await getDoc(groupRef); if (!groupSnap.exists()) throw new Error("Group not found for key reset."); const groupData = groupSnap.data() as Group; const finalMemberUids = newMemberUids || Object.keys(groupData.members); if (finalMemberUids.length === 0) { await updateDoc(groupRef, { encryptedKeys: {}, keyLastRotatedAt: Date.now() }); return; } const { encryptedKeys, groupKeyString } = await cryptoService.createEncryptedGroupKey(finalMemberUids, rtdb); const newKeyVersion = Date.now(); if (finalMemberUids.includes(currentUser.uid)) { cryptoService.storeGroupKey(groupId, newKeyVersion, groupKeyString); } await updateDoc(groupRef, { encryptedKeys: encryptedKeys, keyLastRotatedAt: newKeyVersion }); await push(ref(rtdb, `chats/${groupId}/messages`), { sender: 'system', text: t('systemKeyReset'), timestamp: serverTimestamp(), isSystemMessage: true, }); addToast(t('groupKeyResetSuccess'), { variant: "default" }); } catch (error) { console.error("Failed to reset group keys:", error); addToast(t('groupKeyResetError'), { variant: "destructive" }); throw error; } }, [currentUser, rtdb, db, addToast, t]); const createGroup = useCallback(async (name: string, photoURL: string, memberUids: string[], groupType: GroupType) => { if (!currentUser) return; try { const newGroupRef = doc(collection(db, 'groups')); const groupId = newGroupRef.id; const defaultPhoto = `https://api.dicebear.com/8.x/identicon/svg?seed=${name}`; const allMemberUids = [currentUser.uid, ...memberUids]; const { encryptedKeys, groupKeyString } = await cryptoService.createEncryptedGroupKey(allMemberUids, rtdb); const keyVersion = Date.now(); cryptoService.storeGroupKey(groupId, keyVersion, groupKeyString); const groupData: Omit = { info: { name, photoURL: photoURL || defaultPhoto, createdBy: currentUser.uid, createdAt: Date.now(), type: groupType, settings: { sendingMode: 'everyone' } }, members: { [currentUser.uid]: true, ...Object.fromEntries(memberUids.map(uid => [uid, true])) }, admins: { [currentUser.uid]: true }, encryptedKeys: encryptedKeys, keyLastRotatedAt: keyVersion, }; const batch = writeBatch(db); batch.set(newGroupRef, groupData); allMemberUids.forEach(uid => { batch.update(doc(db, 'users', uid), { groups: arrayUnion(groupId) }); }) await batch.commit(); const participantsForRtdb = Object.fromEntries(allMemberUids.map(uid => [uid, true])); await set(ref(rtdb, `chats/${groupId}/participants`), participantsForRtdb); addToast(t('groupCreated', { name })); } catch (error: any) { addToast(t('groupCreateError', { error: error.message }), { variant: 'destructive' }); console.error(error); } }, [currentUser, addToast, db, rtdb, t]); const acceptGroupInvitation = async (invitation: GroupInvitation) => { if (!currentUser) return; try { const groupDoc = await getDoc(doc(db, 'groups', invitation.groupId)); if (!groupDoc.exists()) throw new Error("Group does not exist."); const batch = writeBatch(db); batch.update(doc(db, 'groups', invitation.groupId), { [`members.${currentUser.uid}`]: true }); batch.update(doc(db, 'users', currentUser.uid), { groups: arrayUnion(invitation.groupId) }); batch.delete(doc(db, 'users', currentUser.uid, 'groupInvitations', invitation.groupId)); await batch.commit(); await set(ref(rtdb, `chats/${invitation.groupId}/participants/${currentUser.uid}`), true); await push(ref(rtdb, `chats/${invitation.groupId}/messages`), { sender: 'system', text: t('systemUserJoined', { name: currentUser.displayName }), timestamp: serverTimestamp(), isSystemMessage: true, }); addToast(t('joinedGroup', { name: invitation.groupName })); } catch (error) { console.error("Error accepting group invitation:", error); addToast(t('joinGroupError'), { variant: 'destructive' }); } }; const declineGroupInvitation = async (invitation: GroupInvitation) => { if (!currentUser) return; try { await deleteDoc(doc(db, 'users', currentUser.uid, 'groupInvitations', invitation.groupId)); addToast(t('invitationDeclined')); } catch(error) { console.error("Error declining group invitation:", error); addToast(t('declineInvitationError'), { variant: "destructive" }); } }; const updateGroupInfo = useCallback(async (groupId: string, newInfo: { name?: string; photoURL?: string; description?: string; }) => { const groupRef = doc(db, 'groups', groupId); const updates: { [key: string]: any } = {}; if (newInfo.name) updates['info.name'] = newInfo.name; if (newInfo.photoURL) updates['info.photoURL'] = newInfo.photoURL; if (newInfo.description !== undefined) updates['info.description'] = newInfo.description; await updateDoc(groupRef, updates); addToast(t('groupInfoUpdated')); }, [addToast, db, t]); const leaveGroup = useCallback(async (groupId: string) => { if (!currentUser) return; try { const groupRef = doc(db, 'groups', groupId); const groupSnap = await getDoc(groupRef); if (!groupSnap.exists()) return; const groupData = groupSnap.data() as Group; const remainingMembers = Object.keys(groupData.members).filter(uid => uid !== currentUser.uid); const batch = writeBatch(db); batch.update(groupRef, { [`members.${currentUser.uid}`]: deleteField(), [`admins.${currentUser.uid}`]: deleteField(), [`encryptedKeys.${currentUser.uid}`]: deleteField(), }); batch.update(doc(db, 'users', currentUser.uid), { groups: arrayRemove(groupId) }); await batch.commit(); await remove(ref(rtdb, `chats/${groupId}/participants/${currentUser.uid}`)); await push(ref(rtdb, `chats/${groupId}/messages`), { sender: 'system', text: t('systemUserLeft', { name: currentUser.displayName }), timestamp: serverTimestamp(), isSystemMessage: true, }); await resetGroupKeys(groupId, remainingMembers); addToast(t('leftGroup')); setRecipient(null); } catch (error) { console.error("Error leaving group:", error); addToast(t('leaveGroupError'), { variant: 'destructive' }); } }, [currentUser, addToast, db, rtdb, resetGroupKeys, t, setRecipient]); const removeMemberFromGroup = useCallback(async (groupId: string, memberUid: string, memberName: string) => { if (!currentUser) return; try { const groupRef = doc(db, 'groups', groupId); const groupSnap = await getDoc(groupRef); if (!groupSnap.exists()) return; const groupData = groupSnap.data() as Group; const remainingMembers = Object.keys(groupData.members).filter(uid => uid !== memberUid); const batch = writeBatch(db); batch.update(groupRef, { [`members.${memberUid}`]: deleteField(), [`admins.${memberUid}`]: deleteField(), [`encryptedKeys.${memberUid}`]: deleteField(), }); batch.update(doc(db, 'users', memberUid), { groups: arrayRemove(groupId) }); await batch.commit(); await remove(ref(rtdb, `chats/${groupId}/participants/${memberUid}`)); await push(ref(rtdb, `chats/${groupId}/messages`), { sender: 'system', text: t('systemUserRemoved', { user: memberName, admin: currentUser.displayName }), timestamp: serverTimestamp(), isSystemMessage: true, }); await resetGroupKeys(groupId, remainingMembers); addToast(t('memberRemoved', { name: memberName })); } catch (error) { console.error("Error removing member:", error); addToast(t('removeMemberError'), { variant: 'destructive' }); } }, [currentUser, addToast, db, rtdb, resetGroupKeys, t]); const addMembersToGroup = useCallback(async (groupId: string, newMemberUids: string[]) => { if (!currentUser) return; try { const groupRef = doc(db, 'groups', groupId); const groupSnap = await getDoc(groupRef); if (!groupSnap.exists()) throw new Error("Group not found."); const groupData = groupSnap.data() as Group; if (!groupData.admins[currentUser.uid]) { addToast(t('adminsOnlyAction'), { variant: 'destructive' }); return; } const membersToAdd = newMemberUids.filter(uid => !groupData.members[uid]); if (membersToAdd.length === 0) { addToast(t('allMembersAlreadyInGroup'), { variant: "default" }); return; } const batch = writeBatch(db); const memberUpdates: { [key: string]: any } = {}; const participantUpdates: { [key: string]: true } = {}; membersToAdd.forEach(uid => { memberUpdates[`members.${uid}`] = true; participantUpdates[uid] = true; batch.update(doc(db, 'users', uid), { groups: arrayUnion(groupId) }); }); batch.update(groupRef, memberUpdates); await batch.commit(); await update(ref(rtdb, `chats/${groupId}/participants`), participantUpdates); const updatedGroupSnap = await getDoc(groupRef); if (!updatedGroupSnap.exists()) throw new Error("Group disappeared after member update."); const updatedGroupData = updatedGroupSnap.data() as Group; const finalMemberUids = Object.keys(updatedGroupData.members); await resetGroupKeys(groupId, finalMemberUids); const addedContacts = contacts.filter(c => membersToAdd.includes(c.uid)).map(c => c.name).join(', '); await push(ref(rtdb, `chats/${groupId}/messages`), { sender: 'system', text: t('systemUserAdded', { admin: currentUser.displayName, users: addedContacts }), timestamp: serverTimestamp(), isSystemMessage: true, }); addToast(t('membersAdded', { count: membersToAdd.length })); } catch (error) { console.error("Failed to add members:", error); addToast(t('addMembersError'), { variant: 'destructive' }); } }, [currentUser, contacts, addToast, db, rtdb, resetGroupKeys, t]); const toggleGroupAdmin = useCallback(async (groupId: string, memberUid: string, isCurrentlyAdmin: boolean) => { const groupRef = doc(db, 'groups', groupId); if (isCurrentlyAdmin) { await updateDoc(groupRef, { [`admins.${memberUid}`]: deleteField() }); addToast(t('demotedToMember')); } else { await updateDoc(groupRef, { [`admins.${memberUid}`]: true }); addToast(t('promotedToAdmin')); } }, [addToast, db, t]); const updateGroupSendingMode = useCallback(async (groupId: string, mode: GroupSendingMode) => { const groupRef = doc(db, 'groups', groupId); await updateDoc(groupRef, { 'info.settings.sendingMode': mode }); addToast(t('groupSettingsUpdated')); }, [addToast, db, t]); const toggleMuteMember = useCallback(async (groupId: string, memberUid: string, memberName: string, isCurrentlyMuted: boolean) => { if (!currentUser) return; const groupRef = doc(db, 'groups', groupId); const updatePath = `info.mutedMembers.${memberUid}`; const systemMessageText = isCurrentlyMuted ? t('systemUnmuted', { user: memberName, admin: currentUser.displayName }) : t('systemMuted', { user: memberName, admin: currentUser.displayName }); if (isCurrentlyMuted) { await updateDoc(groupRef, { [updatePath]: deleteField() }); } else { await updateDoc(groupRef, { [updatePath]: true }); } await push(ref(rtdb, `chats/${groupId}/messages`), { sender: 'system', text: systemMessageText, timestamp: serverTimestamp(), isSystemMessage: true, }); addToast(isCurrentlyMuted ? t('memberUnmuted', { name: memberName }) : t('memberMuted', { name: memberName })); }, [addToast, db, currentUser, rtdb, t]); return { groups, groupInvitations, createGroup, acceptGroupInvitation, declineGroupInvitation, updateGroupInfo, addMembersToGroup, removeMemberFromGroup, leaveGroup, toggleGroupAdmin, updateGroupSendingMode, toggleMuteMember, resetGroupKeys }; };