| | 'use client'; |
| |
|
| | import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; |
| | import { ref, onValue, off, push, set, serverTimestamp, runTransaction, update, query, orderByChild, startAt, remove, onChildAdded, onChildChanged, get, onChildRemoved } from "firebase/database"; |
| | import { doc, getDoc, Firestore } from "firebase/firestore"; |
| | import type { Message, ChatRecipient, User, ReplyTo, Group, Language, ActionPayload, EncryptedMessage } from '@/lib/types'; |
| | 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 { useChatUtils } from '@/contexts/chat-utils-context'; |
| | import { cryptoService } from '@/lib/crypto-service'; |
| | import { storageService } from '@/lib/storage-service'; |
| | import { useAppContext } from '@/contexts/app-context'; |
| | import { learnFromUserReply } from '@/lib/suggested-replies'; |
| | import { LocalNotifications } from '@capacitor/local-notifications'; |
| | import { Capacitor } from '@capacitor/core'; |
| |
|
| |
|
| | const PENDING_MESSAGE_TIMEOUT = 10000; |
| |
|
| | const notifyRecipient = async (recipient: ChatRecipient, sender: User, messageText: string, chatId: string, addToast: (msg: string, options?: any) => void) => { |
| | |
| | if (recipient.uid === sender.uid) { |
| | return; |
| | } |
| |
|
| | if (recipient.isGroup || !(recipient as User).fcmToken) { |
| | if(!(recipient as User).fcmToken) console.log(`[FCM Notify] Skipped: Recipient ${recipient.displayName} has no FCM token.`); |
| | return; |
| | } |
| |
|
| | console.log(`[FCM Notify] Attempting to notify recipient: ${recipient.displayName} (UID: ${recipient.uid})`); |
| | |
| | try { |
| | const payload = { |
| | recipientUid: recipient.uid, |
| | senderUid: sender.uid, |
| | title: sender.displayName, |
| | body: messageText, |
| | icon: sender.photoURL, |
| | chatId: chatId, |
| | }; |
| | console.log("[FCM Notify] Sending payload to /api/notify:", payload); |
| | const isNative = Capacitor.isNativePlatform(); |
| | const baseUrl = isNative ? process.env.NEXT_PUBLIC_API_BASE_URL : ''; |
| | const apiUrl = `${baseUrl}/api/notify`; |
| | const response = await fetch(apiUrl, { |
| | method: 'POST', |
| | headers: { 'Content-Type': 'application/json' }, |
| | body: JSON.stringify(payload), |
| | }); |
| |
|
| | const responseData = await response.json(); |
| | if (!response.ok) { |
| | console.error("[FCM Notify] API responded with an error:", responseData); |
| | throw new Error(responseData.details || responseData.error || 'Failed to send notification'); |
| | } |
| | |
| | console.log("[FCM Notify] Notification request sent successfully:", responseData); |
| |
|
| | } catch (error: any) { |
| | const errorMessage = `[FCM] ${error.message || 'An unknown error occurred.'}`; |
| | console.error("[FCM Notify] Failed to send notification request:", error); |
| | |
| | |
| | } |
| | }; |
| |
|
| |
|
| | export const useChat = (recipient: ChatRecipient | null) => { |
| | const { currentUser } = useAuth(); |
| | const { rtdb, db } = useFirebase(); |
| | const { messageCache, privateReplyTo, setPrivateReplyTo } = useAppContext(); |
| | const { groups, resetGroupKeys } = useGroups(); |
| | const { addToast, playSound, stopSound, privacySettings, dataMode, language } = useSettings(); |
| | const { uploadFile, transcribeAndSet } = useChatUtils(); |
| | const [messages, setMessages] = useState<Message[]>([]); |
| | const [loading, setLoading] = useState(true); |
| | const [typingUsers, setTypingUsers] = useState<string[]>([]); |
| | const lastPlayedSoundForMessageId = useRef<string | null>(null); |
| | const previousTypingUsersCount = useRef(0); |
| | const lastMessageTimestamp = useRef(0); |
| | const uploadAbortController = useRef<AbortController | null>(null); |
| |
|
| | |
| | const chatId = useMemo(() => { |
| | if (!currentUser || !recipient) return null; |
| | if (recipient.isGroup) return recipient.uid; |
| | return currentUser.uid < recipient.uid |
| | ? `private_${currentUser.uid}_${recipient.uid}` |
| | : `private_${recipient.uid}_${currentUser.uid}`; |
| | }, [currentUser, recipient]); |
| | |
| | |
| | if (!currentUser) { |
| | return { |
| | messages: [], |
| | setMessages: () => {}, |
| | loading: true, |
| | sendTextMessage: async () => {}, |
| | sendMediaMessage: async () => {}, |
| | sendVoiceMessage: async () => {}, |
| | editMessage: async () => {}, |
| | toggleReaction: async () => {}, |
| | typingUsers: [], |
| | chatId: null, |
| | }; |
| | } |
| |
|
| | const currentGroup = useMemo<Group | undefined>(() => { |
| | if (!recipient?.isGroup) return undefined; |
| | return groups.find(g => g.id === recipient.uid); |
| | }, [recipient, groups]); |
| |
|
| |
|
| | |
| | useEffect(() => { |
| | if (!chatId || !currentUser || messages.length === 0 || !recipient) return; |
| |
|
| | const unreadMessages = messages.filter( |
| | msg => msg.sender !== currentUser.uid && (!msg.readBy || !msg.readBy[currentUser.uid]) |
| | ); |
| |
|
| | if (unreadMessages.length > 0) { |
| | const updates: { [key: string] : any } = {}; |
| | const localUpdates: { messageId: string, updates: Partial<Message> }[] = []; |
| | |
| | unreadMessages.forEach(msg => { |
| | if(msg.id) { |
| | updates[`/chats/${chatId}/messages/${msg.id}/deliveredTo/${currentUser.uid}`] = serverTimestamp(); |
| | localUpdates.push({ messageId: msg.id, updates: { deliveredTo: { ...msg.deliveredTo, [currentUser.uid]: Date.now() } } }); |
| |
|
| | if (privacySettings.readReceipts) { |
| | updates[`/chats/${chatId}/messages/${msg.id}/readBy/${currentUser.uid}`] = serverTimestamp(); |
| | localUpdates.find(u => u.messageId === msg.id)!.updates.readBy = { ...msg.readBy, [currentUser.uid]: Date.now() }; |
| | } |
| | } |
| | }); |
| | if (Object.keys(updates).length > 0) { |
| | update(ref(rtdb), updates).catch(console.error); |
| | Promise.all(localUpdates.map(lu => storageService.updateMessage(chatId, lu.messageId, lu.updates))) |
| | } |
| | } |
| | }, [messages, chatId, currentUser, rtdb, privacySettings.readReceipts, recipient]); |
| |
|
| | useEffect(() => { |
| | if (!chatId || !currentUser || !recipient) { |
| | setLoading(false); |
| | setMessages([]); |
| | setTypingUsers([]); |
| | return; |
| | } |
| |
|
| | let isMounted = true; |
| | setLoading(true); |
| | |
| | const subscriptions: (() => void)[] = []; |
| |
|
| | const handleNewMessage = (snapshot: any) => { |
| | if (!isMounted) return; |
| | const newMessage = { id: snapshot.key, ...snapshot.val() } as Message; |
| | |
| | setMessages(prev => { |
| | const existingIndex = prev.findIndex(m => m.id === newMessage.id || (m.status === 'pending' && m.text === newMessage.text)); |
| | |
| | if (existingIndex !== -1) { |
| | |
| | const updatedMessages = [...prev]; |
| | updatedMessages[existingIndex] = { ...newMessage, status: 'sent' }; |
| | storageService.saveMessages(chatId, [updatedMessages[existingIndex]]); |
| | return updatedMessages.sort((a, b) => a.timestamp - b.timestamp); |
| | } else if (!prev.some(m => m.id === newMessage.id)) { |
| | |
| | storageService.saveMessages(chatId, [newMessage]); |
| | if (newMessage.sender !== currentUser.uid && lastPlayedSoundForMessageId.current !== newMessage.id) { |
| | playSound('receive'); |
| | lastPlayedSoundForMessageId.current = newMessage.id; |
| | } |
| | return [...prev, newMessage].sort((a, b) => a.timestamp - b.timestamp); |
| | } |
| | return prev; |
| | }); |
| | }; |
| | |
| | const handleChangedMessage = (snapshot: any) => { |
| | if(!isMounted) return; |
| | const updatedMessage = { id: snapshot.key, ...snapshot.val() } as Message; |
| | storageService.updateMessage(chatId, updatedMessage.id!, updatedMessage); |
| | setMessages(prev => { |
| | const newMessages = prev.map(m => m.id === updatedMessage.id ? updatedMessage : m); |
| | messageCache.current.set(chatId, newMessages); |
| | return newMessages; |
| | }); |
| | }; |
| |
|
| | const handleRemovedMessage = (snapshot: any) => { |
| | if (!isMounted) return; |
| | const messageId = snapshot.key; |
| | storageService.deleteMessage(chatId, messageId); |
| | setMessages(prev => { |
| | const newMessages = prev.filter(m => m.id !== messageId); |
| | messageCache.current.set(chatId, newMessages); |
| | return newMessages; |
| | }); |
| | }; |
| | |
| | const typingRef = ref(rtdb, `typing/${chatId}`); |
| | const onTyping = onValue(typingRef, (snapshot) => { |
| | if (!isMounted) return; |
| | const typingData = snapshot.val() || {}; |
| | const currentTypingUsers = Object.entries(typingData) |
| | .filter(([uid, _]) => uid !== currentUser.uid) |
| | .map(([, displayName]) => displayName as string); |
| |
|
| | if (privacySettings.showTyping) { |
| | if (currentTypingUsers.length > 0 && previousTypingUsersCount.current === 0) { |
| | playSound('typing'); |
| | } else if (currentTypingUsers.length === 0 && previousTypingUsersCount.current > 0) { |
| | stopSound('typing'); |
| | } |
| | setTypingUsers(currentTypingUsers); |
| | previousTypingUsersCount.current = currentTypingUsers.length; |
| | } else { |
| | setTypingUsers([]); |
| | stopSound('typing'); |
| | } |
| | }); |
| | subscriptions.push(() => off(typingRef, 'value', onTyping)); |
| |
|
| |
|
| | const loadAndSync = async () => { |
| | let localMessages: Message[] = []; |
| | if (messageCache.current.has(chatId)) { |
| | localMessages = messageCache.current.get(chatId)!; |
| | } else { |
| | localMessages = await storageService.getMessages(chatId); |
| | if (isMounted) { |
| | messageCache.current.set(chatId, localMessages); |
| | } |
| | } |
| | |
| | const now = Date.now(); |
| | const validMessages = localMessages.filter(m => !(m.status === 'pending' && (now - m.timestamp) > PENDING_MESSAGE_TIMEOUT * 2)); |
| |
|
| |
|
| | if(isMounted) { |
| | setMessages(validMessages); |
| | setLoading(false); |
| | lastMessageTimestamp.current = validMessages.length > 0 ? validMessages[validMessages.length - 1].timestamp : 0; |
| | } |
| |
|
| | const messagesRef = ref(rtdb, `chats/${chatId}/messages`); |
| | const newMessagesQuery = query(messagesRef, orderByChild('timestamp'), startAt(lastMessageTimestamp.current + 1)); |
| | |
| | subscriptions.push(onChildAdded(newMessagesQuery, handleNewMessage)); |
| | subscriptions.push(onChildChanged(messagesRef, handleChangedMessage)); |
| | subscriptions.push(onChildRemoved(messagesRef, handleRemovedMessage)); |
| | }; |
| |
|
| | loadAndSync(); |
| | |
| | const handleLocalDataChange = async (event: Event) => { |
| | const customEvent = event as CustomEvent; |
| | if (isMounted && customEvent.detail.chatId === chatId) { |
| | const updatedMessages = await storageService.getMessages(chatId); |
| | setMessages(updatedMessages); |
| | messageCache.current.set(chatId, updatedMessages); |
| | } |
| | }; |
| |
|
| | window.addEventListener('localDataChange', handleLocalDataChange); |
| | |
| | return () => { |
| | isMounted = false; |
| | if (uploadAbortController.current) { |
| | uploadAbortController.current.abort(); |
| | } |
| | subscriptions.forEach(unsub => unsub()); |
| | window.removeEventListener('localDataChange', handleLocalDataChange); |
| | stopSound('typing'); |
| | }; |
| | }, [chatId, currentUser, playSound, stopSound, rtdb, privacySettings.showTyping, recipient, messageCache]); |
| |
|
| |
|
| | const processUrlPreview = useCallback(async (text: string, messageId: string) => { |
| | |
| | }, [chatId, rtdb]); |
| |
|
| | const sendTextMessage = useCallback(async (text: string, replyTo?: ReplyTo | null) => { |
| | if (!chatId || !currentUser || !text.trim() || !recipient) return; |
| | |
| | learnFromUserReply(text, language); |
| | |
| | if (recipient.isGroup) { |
| | const group = groups.find(g => g.id === recipient.uid); |
| | if (!group || !group.members[currentUser.uid]) { |
| | addToast("You are no longer a member of this group.", { variant: "destructive" }); |
| | return; |
| | } |
| | } |
| | |
| | let textToSend = text.trim(); |
| | const shouldEncrypt = recipient.uid !== 'public_chat'; |
| | |
| | let encryptedTextPayload: EncryptedMessage | string | null = null; |
| | let keyVersion: number | undefined; |
| | |
| | |
| | const isPrivateReply = !!privateReplyTo; |
| | |
| | if (shouldEncrypt && !isPrivateReply) { |
| | try { |
| | if (recipient.isGroup && currentGroup) { |
| | if (!currentGroup.keyLastRotatedAt) { |
| | throw new Error("Group key version is missing. Cannot send message."); |
| | } |
| | keyVersion = currentGroup.keyLastRotatedAt; |
| | const groupKey = await cryptoService.getGroupKey(currentGroup.id, keyVersion, currentUser.uid, db); |
| | if (!groupKey) { |
| | await resetGroupKeys(currentGroup.id); |
| | throw new Error("Group key was outdated. Please try sending your message again."); |
| | } |
| | const encryptedString = await cryptoService.encryptGroupMessage(textToSend, groupKey); |
| | encryptedTextPayload = encryptedString; |
| | } else if (!recipient.isGroup) { |
| | encryptedTextPayload = await cryptoService.encryptMessage(textToSend, recipient.uid, rtdb); |
| | } |
| | } catch (error: any) { |
| | addToast(error.message || "Encryption failed.", { variant: "destructive" }); |
| | return; |
| | } |
| | } |
| |
|
| | const newMessageRef = push(ref(rtdb, `chats/${chatId}/messages`)); |
| | |
| | const messagePayload: Partial<Message> = { |
| | sender: currentUser.uid, |
| | senderDisplayName: currentUser.displayName, |
| | senderPhotoUrl: currentUser.photoURL, |
| | timestamp: serverTimestamp() as any, |
| | replyTo: replyTo || null, |
| | text: textToSend, |
| | encryptedText: encryptedTextPayload, |
| | deliveredTo: { [currentUser.uid]: serverTimestamp() }, |
| | keyVersion: keyVersion, |
| | role: 'user', |
| | content: [{text: textToSend}], |
| | privateFor: isPrivateReply ? { uid: privateReplyTo!.uid, displayName: privateReplyTo!.displayName } : null, |
| | }; |
| |
|
| | if (isPrivateReply) { |
| | setPrivateReplyTo(null); |
| | } |
| | |
| | const dbMessageData = JSON.parse(JSON.stringify(messagePayload)); |
| |
|
| | const localId = `pending_${Date.now()}`; |
| | const tempMessage: Message = { ...messagePayload, id: localId, timestamp: Date.now(), status: 'pending' } as Message; |
| | |
| | setMessages(prev => [...prev, tempMessage]); |
| |
|
| | try { |
| | await set(newMessageRef, dbMessageData); |
| | |
| | |
| | setMessages(prev => prev.map(m => m.id === localId ? { ...m, status: 'sent', id: newMessageRef.key! } : m)); |
| | storageService.saveMessages(chatId, [{...tempMessage, status: 'sent', id: newMessageRef.key!, timestamp: Date.now() } as Message]); |
| |
|
| | |
| | if (!shouldEncrypt) { |
| | processUrlPreview(text.trim(), newMessageRef.key!); |
| | } |
| | |
| | await notifyRecipient(recipient, currentUser, textToSend, chatId, addToast); |
| |
|
| |
|
| | } catch (err: any) { |
| | console.error('Error sending text message: ', err); |
| | addToast(`Failed to send message: ${err.message}`, { variant: 'destructive' }); |
| | setMessages(prev => prev.map(m => (m.id === localId ? { ...m, status: 'failed' } : m))); |
| | } |
| | |
| | }, [chatId, currentUser, recipient, addToast, rtdb, currentGroup, db, groups, processUrlPreview, language, resetGroupKeys, privateReplyTo, setPrivateReplyTo]); |
| |
|
| |
|
| | const sendMediaMessage = useCallback(async (file: File, text?: string, replyTo?: ReplyTo | null, options?: { deleteAfterDelivery?: boolean }) => { |
| | if (!chatId || !currentUser || !recipient) { |
| | addToast("Cannot send message. Chat or user not available.", { variant: "destructive" }); |
| | return; |
| | } |
| |
|
| | const isNative = Capacitor.isNativePlatform(); |
| | const notificationId = Date.now(); |
| |
|
| | const localId = `pending_${Date.now()}`; |
| | const isImage = file.type.startsWith('image/'); |
| | const isVideo = file.type.startsWith('video/'); |
| | |
| | const tempMessage: Message = { |
| | id: localId, |
| | chatId: chatId, |
| | sender: currentUser.uid, |
| | senderDisplayName: currentUser.displayName, |
| | senderPhotoUrl: currentUser.photoURL, |
| | timestamp: Date.now(), |
| | status: 'pending', |
| | uploadProgress: 0, |
| | imageKey: isImage ? URL.createObjectURL(file) : undefined, |
| | videoKey: isVideo ? URL.createObjectURL(file) : undefined, |
| | fileKey: !isImage && !isVideo ? 'local' : undefined, |
| | fileName: file.name, |
| | fileSize: file.size, |
| | fileType: file.type, |
| | text: text, |
| | replyTo: replyTo, |
| | role: 'user', |
| | content: [{ text: text || 'Media attached' }], |
| | originalSize: file.size, |
| | compressedSize: 0, |
| | deleteAfterDelivery: options?.deleteAfterDelivery, |
| | }; |
| | |
| | setMessages(prev => [...prev, tempMessage]); |
| | |
| | const onProgress = async (progress: number) => { |
| | setMessages(prev => prev.map(m => m.id === localId ? { ...m, uploadProgress: progress } : m)); |
| | if (isNative) { |
| | try { |
| | await LocalNotifications.schedule({ |
| | notifications: [{ |
| | id: notificationId, |
| | title: "Uploading Media", |
| | body: `${file.name} (${Math.round(progress)}%)`, |
| | ongoing: true, |
| | autoCancel: false, |
| | channelId: 'uploads', |
| | progressBar: { value: progress, max: 100, indeterminate: false } |
| | }] |
| | }); |
| | } catch (e) { |
| | console.warn('Failed to update notification progress', e); |
| | } |
| | } |
| | }; |
| | |
| | uploadAbortController.current = new AbortController(); |
| | |
| | try { |
| | if (isNative) { |
| | await LocalNotifications.requestPermissions(); |
| | await LocalNotifications.createChannel({ id: 'uploads', name: 'Uploads', importance: 3, visibility: 1 }); |
| | } |
| | await onProgress(0); |
| |
|
| | const shouldEncrypt = recipient.uid !== 'public_chat'; |
| | let fileToSend = file; |
| | let encryptionKey: { [uid: string]: string } | { group: string } | undefined = undefined; |
| | let keyVersion: number | undefined; |
| | |
| | if (shouldEncrypt) { |
| | if (recipient.isGroup && currentGroup) { |
| | if (!currentGroup.keyLastRotatedAt) throw new Error("Group key version is missing."); |
| | keyVersion = currentGroup.keyLastRotatedAt; |
| | } |
| | const result = await cryptoService.encryptFile(file, recipient, currentUser.uid, rtdb, db, currentGroup); |
| | fileToSend = result.encryptedBlob; |
| | encryptionKey = result.encryptionKey; |
| | } |
| | |
| | const { fileKey } = await uploadFile(fileToSend, onProgress); |
| | |
| | const newMessageRef = push(ref(rtdb, `chats/${chatId}/messages`)); |
| | |
| | const messagePayload: Partial<Message> = { |
| | sender: currentUser.uid, |
| | senderDisplayName: currentUser.displayName, |
| | senderPhotoUrl: currentUser.photoURL, |
| | timestamp: serverTimestamp() as any, |
| | replyTo: replyTo || null, |
| | text: text || null, |
| | deliveredTo: { [currentUser.uid]: serverTimestamp() }, |
| | readBy: {}, |
| | encryptionKey: encryptionKey as any, |
| | keyVersion: keyVersion, |
| | role: 'user', |
| | content: [{text: text || `Attachment: ${file.name}`}], |
| | originalSize: tempMessage.originalSize, |
| | compressedSize: fileToSend.size, |
| | deleteAfterDelivery: options?.deleteAfterDelivery, |
| | }; |
| | |
| | if (isImage) messagePayload.imageKey = fileKey; |
| | else if (isVideo) messagePayload.videoKey = fileKey; |
| | else { |
| | messagePayload.fileKey = fileKey; |
| | messagePayload.fileName = file.name; |
| | messagePayload.fileSize = file.size; |
| | messagePayload.fileType = file.type; |
| | } |
| | |
| | const dbMessageData = JSON.parse(JSON.stringify(messagePayload)); |
| | |
| | await set(newMessageRef, dbMessageData); |
| | |
| | setMessages(prev => prev.map(m => { |
| | if (m.id === localId) { |
| | const finalMessage = { ...m, ...dbMessageData, id: newMessageRef.key!, status: 'sent' as 'sent', uploadProgress: undefined }; |
| | if (finalMessage.imageKey?.startsWith('blob:')) URL.revokeObjectURL(finalMessage.imageKey); |
| | if (finalMessage.videoKey?.startsWith('blob:')) URL.revokeObjectURL(finalMessage.videoKey); |
| |
|
| | if (isImage) finalMessage.imageKey = fileKey; |
| | if (isVideo) finalMessage.videoKey = fileKey; |
| | |
| | storageService.saveMessages(chatId, [finalMessage as Message]); |
| |
|
| | return finalMessage as Message; |
| | } |
| | return m; |
| | })); |
| | |
| | await notifyRecipient(recipient, currentUser, text || `📄 '${file.name}'`, chatId, addToast); |
| | } catch (error: any) { |
| | if (error.name !== 'AbortError') { |
| | console.error('Failed to send media message:', error); |
| | addToast(error.message || "Failed to send media message.", { variant: "destructive" }); |
| | setMessages(prev => prev.map(m => m.id === localId ? { ...m, status: 'failed', uploadProgress: undefined } : m)); |
| | } else { |
| | setMessages(prev => prev.filter(m => m.id !== localId)); |
| | } |
| | } finally { |
| | if (isNative) { |
| | try { |
| | await LocalNotifications.cancel({ notifications: [{ id: notificationId }] }); |
| | } catch (e) { |
| | console.warn('Failed to cancel notification', e); |
| | } |
| | } |
| | uploadAbortController.current = null; |
| | } |
| | }, [chatId, currentUser, recipient, uploadFile, addToast, rtdb, db, currentGroup]); |
| |
|
| |
|
| | const sendVoiceMessage = useCallback(async (file: File, duration: number) => { |
| | if (!chatId || !currentUser || !recipient) { |
| | addToast("Cannot send message. Chat or user not available.", { variant: "destructive" }); |
| | return Promise.reject("Chat or user not available."); |
| | } |
| |
|
| | const isNative = Capacitor.isNativePlatform(); |
| | const notificationId = Date.now(); |
| | |
| | const localId = `pending_${Date.now()}`; |
| | const localUrl = URL.createObjectURL(file); |
| | const tempMessage: Message = { |
| | id: localId, |
| | sender: currentUser.uid, |
| | senderDisplayName: currentUser.displayName, |
| | senderPhotoUrl: currentUser.photoURL, |
| | timestamp: Date.now(), |
| | status: 'pending', |
| | uploadProgress: 0, |
| | audioKey: localUrl, |
| | audioDuration: duration, |
| | role: 'user', |
| | content: [{ text: 'Voice message' }], |
| | }; |
| | |
| | setMessages(prev => [...prev, tempMessage]); |
| |
|
| | const onProgress = async (progress: number) => { |
| | setMessages(prev => prev.map(m => m.id === localId ? { ...m, uploadProgress: progress } : m)); |
| | if (isNative) { |
| | try { |
| | await LocalNotifications.schedule({ |
| | notifications: [{ |
| | id: notificationId, |
| | title: "Uploading Voice Message", |
| | body: `(${Math.round(progress)}%)`, |
| | ongoing: true, |
| | autoCancel: false, |
| | channelId: 'uploads', |
| | progressBar: { value: progress, max: 100, indeterminate: false } |
| | }] |
| | }); |
| | } catch (e) { |
| | console.warn('Failed to update notification progress', e); |
| | } |
| | } |
| | }; |
| | |
| | uploadAbortController.current = new AbortController(); |
| |
|
| | try { |
| | if (isNative) { |
| | await LocalNotifications.requestPermissions(); |
| | await LocalNotifications.createChannel({ id: 'uploads', name: 'Uploads', importance: 3, visibility: 1 }); |
| | } |
| | await onProgress(0); |
| |
|
| | const shouldEncrypt = recipient.uid !== 'public_chat'; |
| | let fileToSend = file; |
| | let encryptionKey: { [uid: string]: string } | { group: string } | undefined = undefined; |
| | let keyVersion: number | undefined; |
| |
|
| | if (shouldEncrypt) { |
| | if (recipient.isGroup && currentGroup) { |
| | if (!currentGroup.keyLastRotatedAt) throw new Error("Group key version is missing."); |
| | keyVersion = currentGroup.keyLastRotatedAt; |
| | } |
| | const result = await cryptoService.encryptFile(file, recipient, currentUser.uid, rtdb, db, currentGroup); |
| | fileToSend = result.encryptedBlob; |
| | encryptionKey = result.encryptionKey; |
| | } |
| |
|
| | const { fileKey } = await uploadFile(fileToSend, onProgress); |
| | |
| | const newMessageRef = push(ref(rtdb, `chats/${chatId}/messages`)); |
| | const messagePayload: Partial<Message> = { |
| | sender: currentUser.uid, |
| | senderDisplayName: currentUser.displayName, |
| | senderPhotoUrl: currentUser.photoURL, |
| | timestamp: serverTimestamp() as any, |
| | deliveredTo: { [currentUser.uid]: serverTimestamp() }, |
| | audioKey: fileKey, |
| | audioDuration: duration, |
| | encryptionKey: encryptionKey as any, |
| | keyVersion: keyVersion, |
| | role: 'user', |
| | content: [{text: 'Voice message'}], |
| | }; |
| | |
| | const dbMessageData = JSON.parse(JSON.stringify(messagePayload)); |
| |
|
| | await set(newMessageRef, dbMessageData); |
| |
|
| | setMessages(prev => prev.map(m => { |
| | if (m.id === localId) { |
| | URL.revokeObjectURL(localUrl); |
| | const finalMessage = { ...m, ...dbMessageData, id: newMessageRef.key!, status: 'sent' as 'sent', uploadProgress: undefined, audioKey: fileKey }; |
| | storageService.saveMessages(chatId, [finalMessage as Message]); |
| | return finalMessage as Message; |
| | } |
| | return m; |
| | })); |
| |
|
| | await notifyRecipient(recipient, currentUser, '🎤 Voice Message', chatId, addToast); |
| | transcribeAndSet(newMessageRef.key!, file, recipient); |
| |
|
| | } catch (error: any) { |
| | if (error.name !== 'AbortError') { |
| | console.error('Failed to send voice message:', error); |
| | addToast(error.message || "Failed to send voice message.", { variant: "destructive" }); |
| | setMessages(prev => prev.map(m => m.id === localId ? { ...m, status: 'failed', uploadProgress: undefined } : m)); |
| | } else { |
| | setMessages(prev => prev.filter(m => m.id !== localId)); |
| | } |
| | throw new Error(error.message || "Failed to upload or send voice message."); |
| | } finally { |
| | if (isNative) { |
| | try { |
| | await LocalNotifications.cancel({ notifications: [{ id: notificationId }] }); |
| | } catch (e) { |
| | console.warn('Failed to cancel notification', e); |
| | } |
| | } |
| | uploadAbortController.current = null; |
| | } |
| | }, [chatId, currentUser, recipient, uploadFile, addToast, rtdb, db, currentGroup, transcribeAndSet]); |
| |
|
| | const toggleReaction = useCallback(async (messageId: string, emoji: string) => { |
| | if (!chatId || !currentUser) return; |
| | |
| | const reactionPath = `chats/${chatId}/messages/${messageId}/reactions/${emoji}/${currentUser.uid}`; |
| | const reactionRef = ref(rtdb, reactionPath); |
| | playSound('touch'); |
| | |
| | try { |
| | await runTransaction(reactionRef, (currentData) => { |
| | return currentData ? null : { displayName: currentUser.displayName, uid: currentUser.uid }; |
| | }); |
| | } catch (error) { |
| | console.error('Error toggling reaction:', error); |
| | addToast("Failed to update reaction.", { variant: "destructive" }); |
| | } |
| | }, [chatId, currentUser, addToast, rtdb, playSound]); |
| |
|
| | const editMessage = useCallback(async (messageId: string, newText: string) => { |
| | if (!chatId || !currentUser || !recipient) return; |
| | try { |
| | const messageRef = ref(rtdb, `chats/${chatId}/messages/${messageId}`); |
| | |
| | const textToSend = newText.trim(); |
| | const updates: Partial<Message> = { |
| | text: textToSend, |
| | editedAt: serverTimestamp() as any, |
| | content: [{text: textToSend}], |
| | }; |
| |
|
| | const shouldEncrypt = recipient.uid !== 'public_chat'; |
| | |
| | if (shouldEncrypt) { |
| | if (recipient.isGroup && currentGroup) { |
| | if (!currentGroup.keyLastRotatedAt) throw new Error("Group key version is missing."); |
| | const keyVersion = currentGroup.keyLastRotatedAt; |
| | const groupKey = await cryptoService.getGroupKey(currentGroup.id, keyVersion, currentUser.uid, db); |
| | if (!groupKey) throw new Error("Could not retrieve group key for encryption."); |
| | const encryptedString = await cryptoService.encryptGroupMessage(textToSend, groupKey); |
| | updates.encryptedText = encryptedString as any; |
| | updates.keyVersion = keyVersion; |
| | } else if (!recipient.isGroup) { |
| | updates.encryptedText = await cryptoService.encryptMessage(textToSend, recipient.uid, rtdb); |
| | } |
| | updates.text = textToSend; |
| | } else { |
| | updates.encryptedText = null; |
| | } |
| |
|
| | const dbUpdates = JSON.parse(JSON.stringify(updates)); |
| |
|
| | await update(messageRef, dbUpdates); |
| | await storageService.updateMessage(chatId, messageId, {text: textToSend, editedAt: Date.now()}); |
| | const newLocalMessages = await storageService.getMessages(chatId); |
| | setMessages(newLocalMessages); |
| |
|
| | } catch (error) { |
| | console.error("Error editing message:", error); |
| | addToast("Failed to edit message.", { variant: "destructive" }); |
| | } |
| | }, [currentUser, addToast, rtdb, currentGroup, db, chatId, recipient]); |
| |
|
| | return { messages, setMessages, loading, sendTextMessage, sendMediaMessage, sendVoiceMessage, editMessage, toggleReaction, typingUsers, chatId }; |
| | } |
| |
|