|
|
|
|
| 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<Group[]>([]); |
| const [groupInvitations, setGroupInvitations] = useState<GroupInvitation[]>([]); |
| const previousGroupsRef = useRef<Group[]>([]); |
|
|
|
|
| useEffect(() => { |
| if (!currentUser) { |
| setGroups([]); |
| setGroupInvitations([]); |
| return; |
| } |
|
|
| let isMounted = true; |
| |
| |
| 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<Group, 'id'> = { |
| 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 |
| }; |
| }; |
|
|