| |
|
| |
|
| | 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 |
| | }; |
| | }; |
| |
|