looood / src /hooks /use-groups.ts
looda3131's picture
Clean push without any binary history
cc276cc
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;
// 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<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
};
};