From e91d486d2073c96de5a5e85de48ec21cab82cb1c Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Mon, 18 May 2026 14:54:21 +0200 Subject: [PATCH 01/36] feat(chat): add secure dm frontend foundation --- app/contacts/components/ContactsTable.tsx | 43 ++- app/welcome/[userId]/page.tsx | 12 +- components/ClientProvider.tsx | 2 + components/SecureDeviceBootstrap.tsx | 62 ++++ components/chat/chat-header.tsx | 88 +++++- components/chat/start-chart-modal.tsx | 15 +- context/ChatContext.tsx | 322 +++++++++++++++++-- hooks/use-chat-operations.ts | 33 +- lib/e2ee/deviceStore.ts | 116 +++++++ lib/e2ee/secureMessageCrypto.ts | 360 ++++++++++++++++++++++ services/e2eeDeviceService.ts | 266 ++++++++++++++++ services/secureChatService.ts | 336 ++++++++++++++++++++ services/socketService.ts | 16 + types/chat.types.ts | 5 + types/e2ee.types.ts | 84 +++++ 15 files changed, 1697 insertions(+), 63 deletions(-) create mode 100644 components/SecureDeviceBootstrap.tsx create mode 100644 lib/e2ee/deviceStore.ts create mode 100644 lib/e2ee/secureMessageCrypto.ts create mode 100644 services/e2eeDeviceService.ts create mode 100644 services/secureChatService.ts create mode 100644 types/e2ee.types.ts diff --git a/app/contacts/components/ContactsTable.tsx b/app/contacts/components/ContactsTable.tsx index 6552111..ed6cf2a 100644 --- a/app/contacts/components/ContactsTable.tsx +++ b/app/contacts/components/ContactsTable.tsx @@ -17,6 +17,7 @@ import { useAuthToken } from "@/hooks/use-auth-token"; import { useToast } from "@/hooks/use-toast"; import { ManageTagsDialog } from "./ManageTagsDialog"; import { useRouter } from "next/navigation"; +import { createOrGetPreferredDmChat } from "@/services/secureChatService"; interface ContactsTableProps { contacts: Contact[]; @@ -57,6 +58,40 @@ export function ContactsTable({ contacts, isLoading, onSelect }: ContactsTablePr } }; + const handleSendMessage = async (contact: Contact) => { + if (!token) { + toast({ + title: "Authentication required", + description: "Please log in again to open a conversation.", + variant: "destructive", + }); + return; + } + + try { + const result = await createOrGetPreferredDmChat({ + token, + participantId: contact.otherUser.id, + }); + + sessionStorage.setItem("pendingChatId", result.chatId); + router.push("/chat"); + + toast({ + title: result.usedSecure ? "Secure Chat Ready" : "Chat Ready", + description: result.usedSecure + ? `Opening a secure conversation with ${contact.otherUser.firstName} ${contact.otherUser.lastName}.` + : `Opening your conversation with ${contact.otherUser.firstName} ${contact.otherUser.lastName}.`, + }); + } catch (error: any) { + toast({ + title: "Unable to open chat", + description: error?.data?.message || error?.message || "Please try again.", + variant: "destructive", + }); + } + }; + if (isLoading) { return (
@@ -159,7 +194,9 @@ export function ContactsTable({ contacts, isLoading, onSelect }: ContactsTablePr handleOpenProfile(e, contact.otherUser.id)}> View Profile - Send Message + void handleSendMessage(contact)}> + Send Message + Send Money setManagingTagsContactId(contact.id)}> Edit Tags @@ -216,7 +253,9 @@ export function ContactsTable({ contacts, isLoading, onSelect }: ContactsTablePr handleOpenProfile(e, contact.otherUser.id)}> View Profile - Send Message + void handleSendMessage(contact)}> + Send Message + Send Money setManagingTagsContactId(contact.id)}> Edit Tags diff --git a/app/welcome/[userId]/page.tsx b/app/welcome/[userId]/page.tsx index 5a25d42..8cf5872 100644 --- a/app/welcome/[userId]/page.tsx +++ b/app/welcome/[userId]/page.tsx @@ -29,6 +29,7 @@ import { } from 'lucide-react'; import axios from 'axios'; import baseUrl from '@/helpers/baseUrl'; +import { createOrGetPreferredDmChat } from '@/services/secureChatService'; import Navigation from '@/components/Navigation'; import { Header } from '@/components/Header'; import { useUserInfo } from '@/hooks/use-user-info'; @@ -1247,13 +1248,12 @@ const WelcomeProfilePage: React.FC = () => { return; } - const response = await axios.post( - `${baseUrl}/chats/dm`, - { participantId: userId }, - { headers: { Authorization: `Bearer ${token}` } } - ); + const result = await createOrGetPreferredDmChat({ + token, + participantId: userId, + }); - const chatId = response?.data?.data?.chatId; + const chatId = result?.chatId; if (chatId) { sessionStorage.setItem('pendingChatId', chatId); } diff --git a/components/ClientProvider.tsx b/components/ClientProvider.tsx index c02f5ce..d8f6376 100644 --- a/components/ClientProvider.tsx +++ b/components/ClientProvider.tsx @@ -16,6 +16,7 @@ import BrowserNotificationBadge from "@/components/notifications/BrowserNotifica import AuthSessionManager from "@/components/AuthSessionManager" import AppLockGate from "@/components/AppLockGate" import ChatNotificationSync from "@/components/ChatNotificationSync" +import SecureDeviceBootstrap from "@/components/SecureDeviceBootstrap" const APP_LOCK_ENABLED = process.env.NEXT_PUBLIC_ENABLE_APP_LOCK === "true" const APP_LOCK_STORAGE_KEYS = ["qc:appLocked", "qc:lastActivityAt"] @@ -36,6 +37,7 @@ const ClientProvider = ({ children }: { children: React.ReactNode }) => { return ( + {APP_LOCK_ENABLED ? : null} diff --git a/components/SecureDeviceBootstrap.tsx b/components/SecureDeviceBootstrap.tsx new file mode 100644 index 0000000..a4f958b --- /dev/null +++ b/components/SecureDeviceBootstrap.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect } from "react"; +import { ensureRegisteredSecureDevice } from "@/services/e2eeDeviceService"; +import { + getStoredUserInfo, + getValidToken, + refreshAccessToken, +} from "@/utils/tokenUtils"; + +const runBootstrap = async () => { + const authUser = getStoredUserInfo(); + if (!authUser?.id || authUser.accountType !== "user") { + return; + } + + const token = getValidToken() || (await refreshAccessToken()); + if (!token) { + return; + } + + await ensureRegisteredSecureDevice({ + token, + userId: authUser.id, + }); +}; + +export default function SecureDeviceBootstrap() { + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + void runBootstrap(); + + const onResume = () => { + void runBootstrap(); + }; + const onTokenChanged = () => { + void runBootstrap(); + }; + const onVisibilityChange = () => { + if (!document.hidden) { + void runBootstrap(); + } + }; + + window.addEventListener("online", onResume); + window.addEventListener("focus", onResume); + window.addEventListener("authTokenChanged", onTokenChanged as EventListener); + document.addEventListener("visibilitychange", onVisibilityChange); + + return () => { + window.removeEventListener("online", onResume); + window.removeEventListener("focus", onResume); + window.removeEventListener("authTokenChanged", onTokenChanged as EventListener); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, []); + + return null; +} diff --git a/components/chat/chat-header.tsx b/components/chat/chat-header.tsx index c5f40c4..be21888 100644 --- a/components/chat/chat-header.tsx +++ b/components/chat/chat-header.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; @@ -17,6 +17,8 @@ import { useGetGroupByIdQuery } from '@/states/groupSlice'; import { useAuthToken } from '@/hooks/use-auth-token'; import FundraisingProgressBadge from './fundraising-progress-badge'; import { socketService } from '@/services/socketService'; +import { createOrGetSecureDmChat } from '@/services/secureChatService'; +import { toast } from '@/hooks/use-toast'; import { ArrowLeft, Info, @@ -48,8 +50,10 @@ export default function ChatHeader({ onDeleteGroup, }: ChatHeaderProps) { const chat = useChat(); - const { getToken } = useAuthToken(); + const { getToken, getUserId } = useAuthToken(); const token = getToken(); + const userId = getUserId(); + const [isOpeningSecureChat, setIsOpeningSecureChat] = useState(false); // Fetch group details if it's a group chat const { data: groupData, refetch: refetchGroupData } = useGetGroupByIdQuery( @@ -58,6 +62,15 @@ export default function ChatHeader({ ); const group = groupData?.data; + const isDirectConversation = !conversation.isGroup && conversation.type !== 'support'; + const isSecureConversation = conversation.securityMode === 'secure_dm_v1'; + const secureParticipantId = useMemo( + () => + isDirectConversation + ? conversation.participants.find((participant) => participant.userId !== userId)?.userId || null + : null, + [conversation.participants, isDirectConversation, userId], + ); // Listen for real-time fundraising progress updates useEffect(() => { @@ -94,6 +107,44 @@ export default function ChatHeader({ .toUpperCase(); }; + const handleOpenSecureChat = async () => { + if (!token || !secureParticipantId) { + toast({ + title: 'Secure chat unavailable', + description: 'Unable to resolve the other participant for this conversation.', + variant: 'destructive', + }); + return; + } + + try { + setIsOpeningSecureChat(true); + const result = await createOrGetSecureDmChat({ + token, + participantId: secureParticipantId, + }); + + chat.setActiveChat(result.chatId); + chat.refreshConversations(); + + toast({ + title: result.chatId === conversation.id ? 'Secure chat already active' : 'Secure chat ready', + description: + result.chatId === conversation.id + ? 'This conversation is already using secure messaging.' + : 'You are now in the secure conversation thread.', + }); + } catch (error: any) { + toast({ + title: 'Secure chat unavailable', + description: error?.message || 'Failed to open the secure conversation.', + variant: 'destructive', + }); + } finally { + setIsOpeningSecureChat(false); + } + }; + return (
{/* Back Button - Mobile Only */} @@ -132,6 +183,15 @@ export default function ChatHeader({

{conversation.name || 'Unknown Contact'}

+ {isSecureConversation && ( + + + Secure + + )}
{/* Fundraising Progress for Groups */} @@ -229,13 +289,23 @@ export default function ChatHeader({ Chat Settings
- chat.initializeEncryption()} - className='cursor-pointer' - > - - Initialize Encryption - + {isDirectConversation && !isSecureConversation && ( + + + {isOpeningSecureChat ? 'Opening Secure Chat...' : 'Open Secure Chat'} + + )} + + {isDirectConversation && isSecureConversation && ( + + + Secure Chat Active + + )} {/* Delete Group — only visible to owners and admins */} {conversation.isGroup && diff --git a/components/chat/start-chart-modal.tsx b/components/chat/start-chart-modal.tsx index 8f8dd4f..bec1ab4 100644 --- a/components/chat/start-chart-modal.tsx +++ b/components/chat/start-chart-modal.tsx @@ -6,7 +6,6 @@ import { Button } from "@/components/ui/button" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Input } from "@/components/ui/input" import { Search, MessageCircle, Users, Loader2, AlertCircle } from "lucide-react" -import { toast } from "@/hooks/use-toast" import { useGetAcceptedContactsQuery } from "@/states/contactSlice" import { useAuthToken } from "@/hooks/use-auth-token" import type { Conversation } from "@/types/chat.types" @@ -14,7 +13,7 @@ import type { Conversation } from "@/types/chat.types" interface StartChatModalProps { isOpen: boolean onClose: () => void - onStartChat: (contact: any) => void + onStartChat: (contact: any) => Promise | void existingConversations: Conversation[] } @@ -47,14 +46,10 @@ export default function StartChatModal({ isOpen, onClose, onStartChat, existingC return fullName.includes(search) || email.includes(search) }) - const handleStartChat = (contact: any) => { - onStartChat(contact) + const handleStartChat = async (contact: any) => { + await Promise.resolve(onStartChat(contact)) onClose() setSearchTerm("") - toast({ - title: "Chat Started", - description: `Started a new conversation with ${contact.otherUser.firstName} ${contact.otherUser.lastName}`, - }) } const handleClose = () => { @@ -143,7 +138,7 @@ export default function StartChatModal({ isOpen, onClose, onStartChat, existingC
handleStartChat(contact)} + onClick={() => void handleStartChat(contact)} >
@@ -176,7 +171,7 @@ export default function StartChatModal({ isOpen, onClose, onStartChat, existingC className='opacity-0 group-hover:opacity-100 transition-opacity' onClick={e => { e.stopPropagation(); - handleStartChat(contact); + void handleStartChat(contact); }} > diff --git a/context/ChatContext.tsx b/context/ChatContext.tsx index 6eb74d7..2b01271 100644 --- a/context/ChatContext.tsx +++ b/context/ChatContext.tsx @@ -16,6 +16,11 @@ import { } from "@/types/chat.types"; import { toast } from "@/hooks/use-toast"; import { notificationService } from "@/services/notificationService"; +import { + fetchSecureChatMessages, + markSecureChatAsRead, + sendSecureTextMessage, +} from "@/services/secureChatService"; interface ChatContextType { isConnected: boolean; @@ -71,6 +76,12 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { const [typingUsers, setTypingUsers] = useState([]); const [onlineUsers, setOnlineUsers] = useState([]); const [participantsStatus, setParticipantsStatus] = useState>({}); + const [secureMessagesRefreshKey, setSecureMessagesRefreshKey] = useState(0); + + const activeConversation = activeChat + ? conversations.find((conversation) => conversation.id === activeChat) || null + : null; + const isActiveSecureChat = activeConversation?.securityMode === "secure_dm_v1"; const isSupportConversation = useCallback((conv: Conversation) => { if ((conv as any).type === 'support') return true; @@ -147,7 +158,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { const { data: messagesData, refetch: refetchMessages } = useGetChatMessagesQuery( { chatId: activeChat || '', page: 1, limit: 50 }, - { skip: !activeChat || !token } + { skip: !activeChat || !token || isActiveSecureChat } ); useEffect(() => { @@ -264,6 +275,64 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { }); }; + const handleSecureMessageAvailable = (data: { + chatId: string; + messageId: string; + senderId: string; + sender: { id: string; name: string; firstName?: string; lastName?: string }; + messageType: "text"; + securityMode: "secure_dm_v1"; + createdAt: string; + }) => { + const conversation = conversations.find((item) => item.id === data.chatId); + if (!conversation || conversation.securityMode !== "secure_dm_v1") { + refetchChats(); + return; + } + + if (data.chatId === activeChat) { + setSecureMessagesRefreshKey((current) => current + 1); + } + + setConversations((prev: Conversation[]) => { + const updatedConversations = prev.map((conv: Conversation) => { + if (conv.id !== data.chatId) return conv; + + const shouldIncrementUnread = + data.senderId !== userId && + conv.id !== activeChat; + + return { + ...conv, + lastMessage: { + content: "Secure message", + messageType: "text", + createdAt: data.createdAt, + sender: data.sender.name, + }, + timestamp: data.createdAt, + unreadCount: shouldIncrementUnread + ? (conv.unreadCount || 0) + 1 + : (conv.unreadCount || 0), + }; + }); + + return updatedConversations.sort(sortConversations); + }); + + if (data.senderId !== userId) { + notificationService.notifyNewMessage({ + chatId: data.chatId, + senderId: data.senderId, + senderName: data.sender.lastName || data.sender.name, + content: "Secure message", + messageType: "text", + }); + } + + refetchChats(); + }; + const handleMessageDelivered = (data: { chatId: string; messageId: string; deliveredAt: Date }) => { setMessages((prev: Record) => ({ ...prev, @@ -451,6 +520,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { }; socketService.onNewMessage(handleNewMessage); + socketService.onSecureMessageAvailable(handleSecureMessageAvailable); socketService.onMessageDelivered(handleMessageDelivered); socketService.onMessagesRead(handleMessagesRead); socketService.onUserTyping(handleUserTyping); @@ -469,6 +539,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { return () => { socketService.offNewMessage(handleNewMessage); + socketService.offSecureMessageAvailable(handleSecureMessageAvailable); socketService.offMessageDelivered(handleMessageDelivered); socketService.offMessagesRead(handleMessagesRead); socketService.offUserTyping(handleUserTyping); @@ -484,22 +555,34 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { socketService.offPaymentRequestUpdated(handlePaymentRequestUpdated); socketService.offReactionUpdated(handleReactionUpdated); }; - }, [isConnected, activeChat, userId, refetchMessages, sortConversations]); + }, [isConnected, activeChat, userId, refetchMessages, sortConversations, conversations, token, refetchChats]); useEffect(() => { - if (activeChat && isConnected) { + if (!activeChat) return; + + notificationService.setActiveChat(activeChat); + + setConversations((prev: Conversation[]) => prev.map((conv: Conversation) => + conv.id === activeChat + ? { ...conv, unreadCount: 0 } + : conv + )); + + if (isActiveSecureChat) { + if (token && userId) { + void markSecureChatAsRead({ token, userId, chatId: activeChat }).catch((error) => { + console.error("Failed to mark secure chat as read", error); + }); + } + + return () => { + notificationService.setActiveChat(null); + }; + } + + if (isConnected) { socketService.joinChat(activeChat); socketService.markMessageRead(activeChat, ''); - - // Update notification service with active chat - notificationService.setActiveChat(activeChat); - - // Reset unread count for active chat - setConversations((prev: Conversation[]) => prev.map((conv: Conversation) => - conv.id === activeChat - ? { ...conv, unreadCount: 0 } // ...conv already preserves all fields - : conv - )); return () => { if (activeChat) { @@ -508,7 +591,11 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { } }; } - }, [activeChat, isConnected]); + + return () => { + notificationService.setActiveChat(null); + }; + }, [activeChat, isConnected, isActiveSecureChat, token, userId]); useEffect(() => { if (chatsData?.data) { @@ -519,6 +606,10 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { }, [chatsData, sortConversations]); useEffect(() => { + if (isActiveSecureChat) { + return; + } + if (messagesData?.data?.messages && activeChat) { const messagesWithIsMe = messagesData.data.messages.map((msg: Message) => ({ ...msg, @@ -530,7 +621,71 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { [activeChat]: messagesWithIsMe })); } - }, [messagesData, activeChat, userId]); + }, [messagesData, activeChat, userId, isActiveSecureChat]); + + useEffect(() => { + if (!activeChat || !token || !userId || !isActiveSecureChat) { + return; + } + + let cancelled = false; + + const loadSecureMessages = async () => { + try { + const secureMessages = await fetchSecureChatMessages({ + token, + userId, + chatId: activeChat, + }); + + if (cancelled) return; + + setMessages((prev: Record) => ({ + ...prev, + [activeChat]: secureMessages.map((message) => ({ + ...message, + isMe: String(message.sender.id) === String(userId), + })), + })); + + await markSecureChatAsRead({ token, userId, chatId: activeChat }); + } catch (error) { + console.error("Failed to load secure chat messages", error); + } + }; + + void loadSecureMessages(); + const interval = window.setInterval(() => { + void loadSecureMessages(); + }, 5000); + + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [activeChat, token, userId, isActiveSecureChat, secureMessagesRefreshKey]); + + useEffect(() => { + if (!token || !userId) { + return; + } + + const hasSecureConversations = conversations.some( + (conversation) => conversation.securityMode === "secure_dm_v1", + ); + + if (!hasSecureConversations) { + return; + } + + const interval = window.setInterval(() => { + refetchChats(); + }, 10000); + + return () => { + window.clearInterval(interval); + }; + }, [token, userId, conversations, refetchChats]); const refreshConversations = useCallback(() => { refetchChats(); @@ -538,9 +693,13 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { const refreshMessages = useCallback((chatId: string) => { if (chatId === activeChat) { + if (activeConversation?.securityMode === "secure_dm_v1") { + setSecureMessagesRefreshKey((current) => current + 1); + return; + } refetchMessages(); } - }, [activeChat, refetchMessages]); + }, [activeChat, activeConversation?.securityMode, refetchMessages]); const sendMessage = useCallback(( chatId: string, @@ -550,13 +709,109 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { replyToMessageId?: string, replyTo?: ReplyPreview | null ) => { - if (isConnected && content.trim()) { - socketService.sendMessage(chatId, content.trim(), messageType, undefined, mentions, replyToMessageId); + const trimmedContent = content.trim(); + if (!trimmedContent) { + return; + } + + const conversation = conversations.find((item) => item.id === chatId); + if (conversation?.securityMode === "secure_dm_v1") { + if (messageType !== "text") { + toast({ + title: "Not available yet", + description: "secure_dm_v1 currently supports text messages only.", + variant: "destructive", + }); + return; + } + + if (!token || !userId) { + toast({ + title: "Secure chat unavailable", + description: "Your session is not ready for secure messaging yet.", + variant: "destructive", + }); + return; + } + + const tempMessageId = `temp_secure_${Date.now()}`; + const tempMessage: Message = { + id: tempMessageId, + chatId, + content: trimmedContent, + messageType: "text", + replyToMessageId: replyToMessageId || null, + replyTo: replyTo || null, + reactions: [], + status: 'sent', + createdAt: new Date().toISOString(), + sender: { + id: userId, + name: 'You', + avatar: undefined + }, + isMe: true, + mentions, + }; + + setMessages((prev: Record) => ({ + ...prev, + [chatId]: [...(prev[chatId] || []), tempMessage] + })); + + setConversations((prev: Conversation[]) => { + const updatedConversations = prev.map((conv: Conversation) => + conv.id === chatId + ? { + ...conv, + lastMessage: { + content: "Secure message", + messageType: "text" as const, + createdAt: tempMessage.createdAt, + sender: "You" + }, + timestamp: tempMessage.createdAt + } + : conv + ); + + return updatedConversations.sort(sortConversations); + }); + + void (async () => { + try { + await sendSecureTextMessage({ + token, + userId, + chatId, + conversation, + content: trimmedContent, + replyToMessageId: replyToMessageId || undefined, + }); + setSecureMessagesRefreshKey((current) => current + 1); + refetchChats(); + } catch (error: any) { + setMessages((prev: Record) => ({ + ...prev, + [chatId]: (prev[chatId] || []).filter((message) => message.id !== tempMessageId) + })); + toast({ + title: "Secure message failed", + description: error?.message || "Failed to send secure message", + variant: "destructive", + }); + } + })(); + return; + } + + if (isConnected) { + socketService.sendMessage(chatId, trimmedContent, messageType, undefined, mentions, replyToMessageId); const tempMessage: Message = { id: `temp_${Date.now()}`, chatId, - content: content.trim(), + content: trimmedContent, messageType, replyToMessageId: replyToMessageId || null, replyTo: replyTo || null, @@ -585,7 +840,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { ? { ...conv, // Preserve all fields including groupId lastMessage: { - content: content.trim(), + content: trimmedContent, messageType, createdAt: new Date().toISOString(), sender: 'You' @@ -599,13 +854,23 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { return updatedConversations.sort(sortConversations); }); } - }, [isConnected, userId, sortConversations]); + }, [isConnected, userId, token, conversations, sortConversations, refetchChats]); const markMessagesAsRead = useCallback((chatId: string) => { + const conversation = conversations.find((item) => item.id === chatId); + if (conversation?.securityMode === "secure_dm_v1") { + if (token && userId) { + void markSecureChatAsRead({ token, userId, chatId }).catch((error) => { + console.error("Failed to mark secure chat as read", error); + }); + } + return; + } + if (isConnected) { socketService.markMessageRead(chatId, ''); } - }, [isConnected]); + }, [conversations, isConnected, token, userId]); const startTyping = useCallback((chatId: string) => { if (isConnected) { @@ -646,8 +911,17 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { }, []); const markMessageRead = useCallback((chatId: string, messageId: string) => { + const conversation = conversations.find((item) => item.id === chatId); + if (conversation?.securityMode === "secure_dm_v1") { + if (token && userId) { + void markSecureChatAsRead({ token, userId, chatId }).catch((error) => { + console.error("Failed to mark secure chat as read", error); + }); + } + return; + } socketService.markMessageRead(chatId, messageId); - }, []); + }, [conversations, token, userId]); const addMessage = useCallback((message: Message) => { setMessages((prev: Record) => { @@ -748,4 +1022,4 @@ export const useChat = () => { }; export const useEnhancedChat = useChat; -export const EnhancedChatProvider = ChatProvider; \ No newline at end of file +export const EnhancedChatProvider = ChatProvider; diff --git a/hooks/use-chat-operations.ts b/hooks/use-chat-operations.ts index ad15f4b..5b5a701 100644 --- a/hooks/use-chat-operations.ts +++ b/hooks/use-chat-operations.ts @@ -5,7 +5,6 @@ import { useAuthToken } from '@/hooks/use-auth-token'; import { useChat } from '@/context/ChatContext'; import { useGetUserChatsQuery, - useCreateOrGetDMChatMutation, useSendMessageMutation, useMarkMessagesAsReadMutation, useCreateGroupChatMutation, @@ -14,6 +13,7 @@ import { } from '@/states/chatSlice'; import { toast } from '@/hooks/use-toast'; import { parseMessageContent } from '@/utils/messageUtils'; +import { createOrGetPreferredDmChat } from '@/services/secureChatService'; import type { Chat, Message, @@ -64,8 +64,6 @@ export function useChatOperations(): UseChatOperationsReturn { skip: !token, }); - const [createOrGetDMChat, { isLoading: isCreatingDMChat }] = - useCreateOrGetDMChatMutation(); const [sendMessage, { isLoading: isSendingMessage }] = useSendMessageMutation(); const [markAsRead] = useMarkMessagesAsReadMutation(); @@ -104,6 +102,8 @@ export function useChatOperations(): UseChatOperationsReturn { name: conv.name, isGroup: conv.isGroup, type: conv.type, + securityMode: conv.securityMode, + protocolVersion: conv.protocolVersion, groupId: conv.groupId, // Include groupId lastMessage: conv.lastMessage?.content ? { content: parseMessageContent(conv.lastMessage.content, conv.lastMessage.messageType), @@ -127,6 +127,8 @@ export function useChatOperations(): UseChatOperationsReturn { name: chat.name, isGroup: chat.isGroup, type: chat.type, + securityMode: chat.securityMode, + protocolVersion: chat.protocolVersion, groupId: chat.groupId, // Include groupId lastMessage: chat.lastMessage ? { content: parseMessageContent(chat.lastMessage.content, chat.lastMessage.messageType), @@ -150,26 +152,33 @@ export function useChatOperations(): UseChatOperationsReturn { const handleStartNewChat = useCallback(async (contact: any) => { try { - const result = await createOrGetDMChat({ - participantId: contact.otherUser.id - }).unwrap() + if (!token) { + throw new Error("Authentication required"); + } - setContextActiveChat(result.data.chatId) + const result = await createOrGetPreferredDmChat({ + token, + participantId: contact.otherUser.id, + }); + + setContextActiveChat(result.chatId) toast({ - title: "Chat Started", - description: `Started a new conversation with ${contact.otherUser.firstName} ${contact.otherUser.lastName}`, + title: result.usedSecure ? "Secure Chat Started" : "Chat Started", + description: result.usedSecure + ? `Started a secure conversation with ${contact.otherUser.firstName} ${contact.otherUser.lastName}` + : `Started a new conversation with ${contact.otherUser.firstName} ${contact.otherUser.lastName}`, }) contextRefreshConversations?.() } catch (error: any) { toast({ title: "Error", - description: error.data?.message || "Failed to start chat", + description: error?.data?.message || error?.message || "Failed to start chat", variant: "destructive" }) } - }, [createOrGetDMChat, setContextActiveChat, contextRefreshConversations]) + }, [token, setContextActiveChat, contextRefreshConversations]) const handleJoinGroup = useCallback(async (group: any) => { try { @@ -253,7 +262,7 @@ export function useChatOperations(): UseChatOperationsReturn { conversations, activeChat, messages, - isLoading: chatsLoading || isCreatingDMChat || isSendingMessage || isCreatingGroup || isJoiningGroup, + isLoading: chatsLoading || isSendingMessage || isCreatingGroup || isJoiningGroup, isConnected, typingUsers, onlineUsers, diff --git a/lib/e2ee/deviceStore.ts b/lib/e2ee/deviceStore.ts new file mode 100644 index 0000000..f779d9c --- /dev/null +++ b/lib/e2ee/deviceStore.ts @@ -0,0 +1,116 @@ +import type { StoredSecureDeviceState } from "@/types/e2ee.types"; + +const DB_NAME = "qc-secure-chat"; +const DB_VERSION = 1; +const STORE_NAME = "deviceState"; +const FALLBACK_STORAGE_KEY = "qc:secure-device-state"; + +const canUseIndexedDb = () => + typeof window !== "undefined" && typeof window.indexedDB !== "undefined"; + +const openDb = async (): Promise => + new Promise((resolve, reject) => { + const request = window.indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("Failed to open IndexedDB")); + }); + +const withStore = async ( + mode: IDBTransactionMode, + run: (store: IDBObjectStore) => IDBRequest, +): Promise => { + const db = await openDb(); + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, mode); + const store = transaction.objectStore(STORE_NAME); + const request = run(store); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed")); + transaction.oncomplete = () => db.close(); + transaction.onerror = () => { + db.close(); + reject(transaction.error ?? new Error("IndexedDB transaction failed")); + }; + }); +}; + +const readFallbackState = (): StoredSecureDeviceState | null => { + if (typeof window === "undefined") return null; + + try { + const raw = window.localStorage.getItem(FALLBACK_STORAGE_KEY); + return raw ? (JSON.parse(raw) as StoredSecureDeviceState) : null; + } catch { + return null; + } +}; + +const writeFallbackState = (state: StoredSecureDeviceState | null) => { + if (typeof window === "undefined") return; + + if (!state) { + window.localStorage.removeItem(FALLBACK_STORAGE_KEY); + return; + } + + window.localStorage.setItem(FALLBACK_STORAGE_KEY, JSON.stringify(state)); +}; + +export const getStoredSecureDeviceState = async (): Promise => { + if (typeof window === "undefined") return null; + + if (!canUseIndexedDb()) { + return readFallbackState(); + } + + try { + const state = await withStore("readonly", (store) => + store.get("active"), + ); + return state ?? null; + } catch { + return readFallbackState(); + } +}; + +export const saveStoredSecureDeviceState = async (state: StoredSecureDeviceState) => { + if (typeof window === "undefined") return; + + if (!canUseIndexedDb()) { + writeFallbackState(state); + return; + } + + try { + await withStore("readwrite", (store) => store.put(state, "active")); + writeFallbackState(state); + } catch { + writeFallbackState(state); + } +}; + +export const clearStoredSecureDeviceState = async () => { + if (typeof window === "undefined") return; + + if (!canUseIndexedDb()) { + writeFallbackState(null); + return; + } + + try { + await withStore("readwrite", (store) => store.delete("active")); + writeFallbackState(null); + } catch { + writeFallbackState(null); + } +}; diff --git a/lib/e2ee/secureMessageCrypto.ts b/lib/e2ee/secureMessageCrypto.ts new file mode 100644 index 0000000..8b9f4fd --- /dev/null +++ b/lib/e2ee/secureMessageCrypto.ts @@ -0,0 +1,360 @@ +"use client"; + +import type { + PublicSecureDeviceBundle, + SecureEncryptedEnvelope, + SecureRecipientPayload, + StoredSecureDeviceState, + SupportedE2EEAlgorithm, +} from "@/types/e2ee.types"; + +const SUPPORTED_ALGORITHM: SupportedE2EEAlgorithm = "qc-e2ee-p256-v1"; +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const stableStringify = (value: unknown): string => { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + + const entries = Object.entries(value as Record).sort(([a], [b]) => + a.localeCompare(b), + ); + + return `{${entries + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) + .join(",")}}`; +}; + +const toBase64Url = (value: ArrayBuffer | Uint8Array) => { + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); + let binary = ""; + + for (let index = 0; index < bytes.length; index += 1) { + binary += String.fromCharCode(bytes[index]); + } + + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +}; + +const fromBase64Url = (value: string) => { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return bytes; +}; + +const importIdentityPrivateKey = (jwk: JsonWebKey) => + window.crypto.subtle.importKey( + "jwk", + jwk, + { + name: "ECDSA", + namedCurve: "P-256", + }, + true, + ["sign"], + ); + +const importIdentityPublicKey = (jwk: JsonWebKey) => + window.crypto.subtle.importKey( + "jwk", + jwk, + { + name: "ECDSA", + namedCurve: "P-256", + }, + true, + ["verify"], + ); + +const importExchangePrivateKey = (jwk: JsonWebKey) => + window.crypto.subtle.importKey( + "jwk", + jwk, + { + name: "ECDH", + namedCurve: "P-256", + }, + true, + ["deriveBits"], + ); + +const importExchangePublicKey = (jwk: JsonWebKey) => + window.crypto.subtle.importKey( + "jwk", + jwk, + { + name: "ECDH", + namedCurve: "P-256", + }, + true, + [], + ); + +const deriveWrappingKey = async ({ + privateKey, + publicKey, + senderDeviceId, + recipientDeviceId, +}: { + privateKey: CryptoKey; + publicKey: CryptoKey; + senderDeviceId: string; + recipientDeviceId: string; +}) => { + const sharedSecret = await window.crypto.subtle.deriveBits( + { + name: "ECDH", + public: publicKey, + }, + privateKey, + 256, + ); + + const hkdfKey = await window.crypto.subtle.importKey("raw", sharedSecret, "HKDF", false, [ + "deriveKey", + ]); + + return window.crypto.subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: encoder.encode("qc-secure-dm-v1-salt"), + info: encoder.encode(`${senderDeviceId}:${recipientDeviceId}:message-wrap`), + }, + hkdfKey, + { + name: "AES-GCM", + length: 256, + }, + false, + ["encrypt", "decrypt"], + ); +}; + +const signEnvelopePayload = async ( + identityPrivateKey: CryptoKey, + payload: Omit, +) => { + const signature = await window.crypto.subtle.sign( + { + name: "ECDSA", + hash: "SHA-256", + }, + identityPrivateKey, + encoder.encode(stableStringify(payload)), + ); + + return toBase64Url(signature); +}; + +const verifyEnvelopePayload = async ({ + identityPublicKey, + payload, +}: { + identityPublicKey: JsonWebKey; + payload: SecureEncryptedEnvelope; +}) => { + const cryptoKey = await importIdentityPublicKey(identityPublicKey); + const { signature, ...unsignedPayload } = payload; + return window.crypto.subtle.verify( + { + name: "ECDSA", + hash: "SHA-256", + }, + cryptoKey, + fromBase64Url(signature), + encoder.encode(stableStringify(unsignedPayload)), + ); +}; + +const verifySignedPreKey = async (bundle: PublicSecureDeviceBundle) => { + const identityKey = await importIdentityPublicKey(bundle.bundle.identityPublicKey); + return window.crypto.subtle.verify( + { + name: "ECDSA", + hash: "SHA-256", + }, + identityKey, + fromBase64Url(bundle.bundle.signedPreKeySignature), + encoder.encode(stableStringify(bundle.bundle.signedPreKeyPublic)), + ); +}; + +export const encryptSecureTextForRecipients = async ({ + content, + senderUserId, + senderState, + recipientDevices, +}: { + content: string; + senderUserId: string; + senderState: StoredSecureDeviceState; + recipientDevices: PublicSecureDeviceBundle[]; +}) => { + if (!content.trim()) { + throw new Error("Cannot encrypt an empty secure message"); + } + + const identityPrivateKey = await importIdentityPrivateKey(senderState.identity.privateKey); + const rawMessageKey = window.crypto.getRandomValues(new Uint8Array(32)); + const messageKey = await window.crypto.subtle.importKey( + "raw", + rawMessageKey, + "AES-GCM", + true, + ["encrypt", "decrypt"], + ); + const ciphertextIv = window.crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await window.crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: ciphertextIv, + }, + messageKey, + encoder.encode(content), + ); + + const createdAt = new Date().toISOString(); + const recipientPayloads: SecureRecipientPayload[] = []; + + for (const recipientDevice of recipientDevices) { + if (!(await verifySignedPreKey(recipientDevice))) { + throw new Error(`Secure device bundle verification failed for ${recipientDevice.deviceId}`); + } + + const ephemeralKeyPair = await window.crypto.subtle.generateKey( + { + name: "ECDH", + namedCurve: "P-256", + }, + true, + ["deriveBits"], + ); + const recipientPublicKey = await importExchangePublicKey( + recipientDevice.bundle.signedPreKeyPublic, + ); + const wrappingKey = await deriveWrappingKey({ + privateKey: ephemeralKeyPair.privateKey, + publicKey: recipientPublicKey, + senderDeviceId: senderState.deviceId, + recipientDeviceId: recipientDevice.deviceId, + }); + const wrappingIv = window.crypto.getRandomValues(new Uint8Array(12)); + const wrappedMessageKey = await window.crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: wrappingIv, + }, + wrappingKey, + rawMessageKey, + ); + const ephemeralPublicKey = (await window.crypto.subtle.exportKey( + "jwk", + ephemeralKeyPair.publicKey, + )) as JsonWebKey; + + const unsignedEnvelope: Omit = { + version: 1, + protocolVersion: "secure-dm-v1", + algorithm: SUPPORTED_ALGORITHM, + senderUserId, + senderDeviceId: senderState.deviceId, + recipientUserId: recipientDevice.userId, + recipientDeviceId: recipientDevice.deviceId, + ephemeralPublicKey, + wrappedMessageKey: toBase64Url(wrappedMessageKey), + wrappedMessageKeyIv: toBase64Url(wrappingIv), + ciphertext: toBase64Url(ciphertext), + ciphertextIv: toBase64Url(ciphertextIv), + createdAt, + }; + const signature = await signEnvelopePayload(identityPrivateKey, unsignedEnvelope); + + recipientPayloads.push({ + recipientUserId: recipientDevice.userId, + recipientDeviceId: recipientDevice.deviceId, + encryptedEnvelope: { + ...unsignedEnvelope, + signature, + }, + }); + } + + return { + createdAt, + recipientPayloads, + }; +}; + +export const decryptSecureEnvelope = async ({ + envelope, + senderIdentityPublicKey, + recipientState, +}: { + envelope: SecureEncryptedEnvelope; + senderIdentityPublicKey: JsonWebKey; + recipientState: StoredSecureDeviceState; +}) => { + if (envelope.algorithm !== SUPPORTED_ALGORITHM) { + throw new Error("Unsupported secure message algorithm"); + } + + if (envelope.recipientDeviceId !== recipientState.deviceId) { + throw new Error("Secure message envelope does not target this device"); + } + + const isAuthentic = await verifyEnvelopePayload({ + identityPublicKey: senderIdentityPublicKey, + payload: envelope, + }); + + if (!isAuthentic) { + throw new Error("Secure message signature verification failed"); + } + + const signedPreKeyPrivate = await importExchangePrivateKey(recipientState.signedPreKey.privateKey); + const ephemeralPublicKey = await importExchangePublicKey(envelope.ephemeralPublicKey); + const wrappingKey = await deriveWrappingKey({ + privateKey: signedPreKeyPrivate, + publicKey: ephemeralPublicKey, + senderDeviceId: envelope.senderDeviceId, + recipientDeviceId: envelope.recipientDeviceId, + }); + const rawMessageKey = await window.crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: fromBase64Url(envelope.wrappedMessageKeyIv), + }, + wrappingKey, + fromBase64Url(envelope.wrappedMessageKey), + ); + const messageKey = await window.crypto.subtle.importKey( + "raw", + rawMessageKey, + "AES-GCM", + false, + ["decrypt"], + ); + const plaintext = await window.crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: fromBase64Url(envelope.ciphertextIv), + }, + messageKey, + fromBase64Url(envelope.ciphertext), + ); + + return decoder.decode(plaintext); +}; diff --git a/services/e2eeDeviceService.ts b/services/e2eeDeviceService.ts new file mode 100644 index 0000000..e0bed26 --- /dev/null +++ b/services/e2eeDeviceService.ts @@ -0,0 +1,266 @@ +"use client"; + +import baseUrl from "@/helpers/baseUrl"; +import { + clearStoredSecureDeviceState, + getStoredSecureDeviceState, + saveStoredSecureDeviceState, +} from "@/lib/e2ee/deviceStore"; +import type { + PublicPreKey, + SecureDeviceBundlePayload, + StoredOneTimePreKey, + StoredSecureDeviceState, + SupportedE2EEAlgorithm, +} from "@/types/e2ee.types"; + +const ACTIVE_APP_VERSION = "web-pwa-v1"; +const ONE_TIME_PREKEY_COUNT = 12; +const SYNC_INTERVAL_MS = 12 * 60 * 60 * 1000; +const SUPPORTED_ALGORITHM: SupportedE2EEAlgorithm = "qc-e2ee-p256-v1"; + +const encoder = new TextEncoder(); + +const stableStringify = (value: unknown): string => { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + + const entries = Object.entries(value as Record).sort(([a], [b]) => + a.localeCompare(b), + ); + + return `{${entries + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) + .join(",")}}`; +}; + +const toBase64Url = (buffer: ArrayBuffer) => { + const bytes = new Uint8Array(buffer); + let binary = ""; + + for (let index = 0; index < bytes.length; index += 1) { + binary += String.fromCharCode(bytes[index]); + } + + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +}; + +const getRandomId = () => window.crypto.getRandomValues(new Uint32Array(1))[0] ?? Date.now(); + +const describeCurrentDevice = () => { + const nav = window.navigator as Navigator & { + userAgentData?: { platform?: string; brands?: Array<{ brand: string; version: string }> }; + }; + + const platform = nav.userAgentData?.platform || nav.platform || "web"; + const browser = + nav.userAgentData?.brands?.[0]?.brand || + (/Chrome/i.test(nav.userAgent) + ? "Chrome" + : /Edg/i.test(nav.userAgent) + ? "Edge" + : /Firefox/i.test(nav.userAgent) + ? "Firefox" + : "Browser"); + const mode = window.matchMedia?.("(display-mode: standalone)")?.matches ? "PWA" : "Browser"; + + return { + platform, + deviceName: `${mode} ${browser}`.trim(), + }; +}; + +const generateSigningKeyPair = () => + window.crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256", + }, + true, + ["sign", "verify"], + ); + +const generateExchangeKeyPair = () => + window.crypto.subtle.generateKey( + { + name: "ECDH", + namedCurve: "P-256", + }, + true, + ["deriveBits"], + ); + +const exportPrivatePublicPair = async (keyPair: CryptoKeyPair) => ({ + publicKey: (await window.crypto.subtle.exportKey("jwk", keyPair.publicKey)) as JsonWebKey, + privateKey: (await window.crypto.subtle.exportKey("jwk", keyPair.privateKey)) as JsonWebKey, +}); + +const signSignedPreKey = async ( + identityPrivateKey: CryptoKey, + signedPreKeyPublic: JsonWebKey, +) => { + const signature = await window.crypto.subtle.sign( + { + name: "ECDSA", + hash: "SHA-256", + }, + identityPrivateKey, + encoder.encode(stableStringify(signedPreKeyPublic)), + ); + + return toBase64Url(signature); +}; + +const generateOneTimePreKeys = async (): Promise => { + const keys: StoredOneTimePreKey[] = []; + + for (let index = 0; index < ONE_TIME_PREKEY_COUNT; index += 1) { + const keyPair = await generateExchangeKeyPair(); + const exported = await exportPrivatePublicPair(keyPair); + + keys.push({ + keyId: getRandomId(), + publicKey: exported.publicKey, + privateKey: exported.privateKey, + }); + } + + return keys; +}; + +const createFreshDeviceState = async (userId: string): Promise => { + const { deviceName, platform } = describeCurrentDevice(); + const identityKeys = await generateSigningKeyPair(); + const signedPreKey = await generateExchangeKeyPair(); + const exportedIdentity = await exportPrivatePublicPair(identityKeys); + const exportedSignedPreKey = await exportPrivatePublicPair(signedPreKey); + const signature = await signSignedPreKey(identityKeys.privateKey, exportedSignedPreKey.publicKey); + + return { + version: 1, + ownerUserId: userId, + deviceId: window.crypto.randomUUID(), + deviceName, + platform, + appVersion: ACTIVE_APP_VERSION, + algorithm: SUPPORTED_ALGORITHM, + registrationId: Number(getRandomId() % 16380) + 1, + identity: exportedIdentity, + signedPreKey: { + keyId: getRandomId(), + publicKey: exportedSignedPreKey.publicKey, + privateKey: exportedSignedPreKey.privateKey, + signature, + }, + oneTimePreKeys: await generateOneTimePreKeys(), + lastServerSyncAt: null, + }; +}; + +const shouldResync = (state: StoredSecureDeviceState) => { + if (!state.lastServerSyncAt) return true; + + const lastSyncAt = new Date(state.lastServerSyncAt).getTime(); + if (Number.isNaN(lastSyncAt)) return true; + + return Date.now() - lastSyncAt > SYNC_INTERVAL_MS; +}; + +const buildPublicBundlePayload = ( + state: StoredSecureDeviceState, +): SecureDeviceBundlePayload => ({ + algorithm: state.algorithm, + identityPublicKey: state.identity.publicKey, + signedPreKey: { + keyId: state.signedPreKey.keyId, + publicKey: state.signedPreKey.publicKey, + signature: state.signedPreKey.signature, + }, + registrationId: state.registrationId, + oneTimePreKeys: state.oneTimePreKeys.map((preKey) => ({ + keyId: preKey.keyId, + publicKey: preKey.publicKey, + })), +}); + +const registerBundle = async (token: string, state: StoredSecureDeviceState) => { + if (!baseUrl) { + throw new Error("NEXT_PUBLIC_API_URL is not configured"); + } + + const response = await fetch(`${baseUrl}/e2ee/devices/register`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + deviceId: state.deviceId, + deviceName: state.deviceName, + platform: state.platform, + appVersion: state.appVersion, + bundle: buildPublicBundlePayload(state), + }), + }); + + if (!response.ok) { + const payload = await response.json().catch(() => null); + const message = payload?.message || "Failed to register secure device"; + throw Object.assign(new Error(message), { statusCode: response.status }); + } +}; + +export const ensureRegisteredSecureDevice = async ({ + token, + userId, +}: { + token: string; + userId: string; +}) => { + if (typeof window === "undefined" || !window.crypto?.subtle) { + return null; + } + + let state = await getStoredSecureDeviceState(); + + if (!state || state.ownerUserId !== userId) { + if (state && state.ownerUserId !== userId) { + await clearStoredSecureDeviceState(); + } + state = await createFreshDeviceState(userId); + await saveStoredSecureDeviceState(state); + } + + if (!shouldResync(state)) { + return state; + } + + try { + await registerBundle(token, state); + const nextState: StoredSecureDeviceState = { + ...state, + lastServerSyncAt: new Date().toISOString(), + }; + await saveStoredSecureDeviceState(nextState); + return nextState; + } catch (error: any) { + if (error?.statusCode === 409) { + const regenerated = await createFreshDeviceState(userId); + await registerBundle(token, regenerated); + const nextState: StoredSecureDeviceState = { + ...regenerated, + lastServerSyncAt: new Date().toISOString(), + }; + await saveStoredSecureDeviceState(nextState); + return nextState; + } + + console.error("Secure device bootstrap failed:", error); + return state; + } +}; diff --git a/services/secureChatService.ts b/services/secureChatService.ts new file mode 100644 index 0000000..641e483 --- /dev/null +++ b/services/secureChatService.ts @@ -0,0 +1,336 @@ +"use client"; + +import baseUrl from "@/helpers/baseUrl"; +import { decryptSecureEnvelope, encryptSecureTextForRecipients } from "@/lib/e2ee/secureMessageCrypto"; +import { ensureRegisteredSecureDevice } from "@/services/e2eeDeviceService"; +import type { Conversation, Message, ReplyPreview } from "@/types/chat.types"; +import type { + PublicSecureDeviceBundle, + SecureEncryptedEnvelope, + StoredSecureDeviceState, +} from "@/types/e2ee.types"; + +const bundleCache = new Map(); +const BUNDLE_CACHE_TTL_MS = 60 * 1000; + +const getApiBaseUrl = () => { + if (!baseUrl) { + throw new Error("NEXT_PUBLIC_API_URL is not configured"); + } + + return baseUrl; +}; + +const fetchJson = async (input: RequestInfo | URL, init?: RequestInit) => { + const response = await fetch(input, init); + const payload = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(payload?.message || "Secure chat request failed"); + } + + return payload; +}; + +const getSecureDeviceState = async (token: string, userId: string) => { + const state = await ensureRegisteredSecureDevice({ token, userId }); + if (!state) { + throw new Error("Secure device bootstrap is not available on this browser"); + } + + return state; +}; + +const getConversationRecipient = (conversation: Conversation, userId: string) => { + const recipient = conversation.participants.find((participant) => participant.userId !== userId); + if (!recipient) { + throw new Error("Unable to resolve the secure chat recipient"); + } + + return recipient.userId; +}; + +const fetchPublicDeviceBundles = async (token: string, userId: string) => { + const cacheKey = `bundles:${userId}`; + const cached = bundleCache.get(cacheKey); + if (cached && Date.now() - cached.cachedAt < BUNDLE_CACHE_TTL_MS) { + return cached.devices; + } + + const payload = await fetchJson(`${getApiBaseUrl()}/e2ee/users/${userId}/device-bundles`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const devices = (payload?.data || []).map((device: any) => ({ + userId, + deviceId: device.deviceId, + deviceName: device.deviceName, + platform: device.platform, + bundle: device.bundle, + oneTimePreKeys: device.oneTimePreKeys || [], + })) as PublicSecureDeviceBundle[]; + + bundleCache.set(cacheKey, { + cachedAt: Date.now(), + devices, + }); + + return devices; +}; + +const decryptSecureApiMessage = async ({ + rawMessage, + state, + token, +}: { + rawMessage: any; + state: StoredSecureDeviceState; + token: string; +}) => { + const envelope = rawMessage.encryptedEnvelope as SecureEncryptedEnvelope; + const senderDevices = await fetchPublicDeviceBundles(token, rawMessage.sender.id); + const senderDevice = senderDevices.find((device) => device.deviceId === envelope.senderDeviceId); + + if (!senderDevice?.bundle?.identityPublicKey) { + throw new Error("Unable to resolve the sender secure identity"); + } + + const content = await decryptSecureEnvelope({ + envelope, + senderIdentityPublicKey: senderDevice.bundle.identityPublicKey, + recipientState: state, + }); + + return { + id: rawMessage.id, + chatId: rawMessage.chatId, + content, + messageType: rawMessage.messageType, + replyToMessageId: rawMessage.replyToMessageId || null, + replyTo: null as ReplyPreview | null, + reactions: [], + status: rawMessage.status, + deliveredAt: rawMessage.deliveredAt || undefined, + readAt: rawMessage.readAt || undefined, + createdAt: rawMessage.createdAt, + sender: rawMessage.sender, + readBy: [], + } satisfies Message; +}; + +export const fetchSecureChatMessages = async ({ + token, + userId, + chatId, + page = 1, + limit = 50, +}: { + token: string; + userId: string; + chatId: string; + page?: number; + limit?: number; +}) => { + const state = await getSecureDeviceState(token, userId); + const payload = await fetchJson( + `${getApiBaseUrl()}/e2ee/chats/${chatId}/messages?page=${page}&limit=${limit}`, + { + headers: { + Authorization: `Bearer ${token}`, + "x-qc-device-id": state.deviceId, + }, + }, + ); + + const decryptedMessages = await Promise.all( + ((payload?.data?.messages as any[]) || []).map(async (rawMessage) => { + try { + return await decryptSecureApiMessage({ rawMessage, state, token }); + } catch (error) { + console.error("Failed to decrypt secure message", error); + return { + id: rawMessage.id, + chatId: rawMessage.chatId, + content: "[Unable to decrypt secure message]", + messageType: rawMessage.messageType, + replyToMessageId: rawMessage.replyToMessageId || null, + replyTo: null, + reactions: [], + status: rawMessage.status, + deliveredAt: rawMessage.deliveredAt || undefined, + readAt: rawMessage.readAt || undefined, + createdAt: rawMessage.createdAt, + sender: rawMessage.sender, + readBy: [], + } satisfies Message; + } + }), + ); + + return decryptedMessages.reverse(); +}; + +export const createOrGetSecureDmChat = async ({ + token, + participantId, +}: { + token: string; + participantId: string; +}) => { + const payload = await fetchJson(`${getApiBaseUrl()}/e2ee/dms`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + participantId, + }), + }); + + return payload?.data as { + chatId: string; + securityMode: "secure_dm_v1"; + protocolVersion: string | null; + }; +}; + +export const createOrGetLegacyDmChat = async ({ + token, + participantId, +}: { + token: string; + participantId: string; +}) => { + const payload = await fetchJson(`${getApiBaseUrl()}/chats/dm`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + participantId, + }), + }); + + return payload?.data as { + chatId: string; + securityMode?: "legacy" | "secure_dm_v1"; + protocolVersion?: string | null; + }; +}; + +export const createOrGetPreferredDmChat = async ({ + token, + participantId, +}: { + token: string; + participantId: string; +}) => { + try { + const secureChat = await createOrGetSecureDmChat({ + token, + participantId, + }); + + return { + ...secureChat, + usedSecure: true, + }; + } catch (secureError) { + console.warn("Secure DM unavailable, falling back to legacy DM", secureError); + + const legacyChat = await createOrGetLegacyDmChat({ + token, + participantId, + }); + + return { + ...legacyChat, + securityMode: legacyChat.securityMode || "legacy", + protocolVersion: legacyChat.protocolVersion || null, + usedSecure: legacyChat.securityMode === "secure_dm_v1", + }; + } +}; + +export const sendSecureTextMessage = async ({ + token, + userId, + chatId, + conversation, + content, + replyToMessageId, +}: { + token: string; + userId: string; + chatId: string; + conversation: Conversation; + content: string; + replyToMessageId?: string; +}) => { + const state = await getSecureDeviceState(token, userId); + const recipientUserId = getConversationRecipient(conversation, userId); + const [senderDevices, recipientDevices] = await Promise.all([ + fetchPublicDeviceBundles(token, userId), + fetchPublicDeviceBundles(token, recipientUserId), + ]); + + const { recipientPayloads } = await encryptSecureTextForRecipients({ + content, + senderUserId: userId, + senderState: state, + recipientDevices: [...senderDevices, ...recipientDevices], + }); + + const payload = await fetchJson(`${getApiBaseUrl()}/e2ee/chats/${chatId}/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "x-qc-device-id": state.deviceId, + }, + body: JSON.stringify({ + messageType: "text", + replyToMessageId: replyToMessageId || null, + recipientPayloads, + }), + }); + + bundleCache.delete(`bundles:${userId}`); + + return { + id: payload.data.id, + chatId, + content, + messageType: "text", + replyToMessageId: replyToMessageId || null, + replyTo: null, + reactions: [], + status: payload.data.status || "sent", + createdAt: payload.data.createdAt, + sender: payload.data.sender, + readBy: [], + } satisfies Message; +}; + +export const markSecureChatAsRead = async ({ + token, + userId, + chatId, +}: { + token: string; + userId: string; + chatId: string; +}) => { + const state = await getSecureDeviceState(token, userId); + await fetchJson(`${getApiBaseUrl()}/e2ee/chats/${chatId}/read`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "x-qc-device-id": state.deviceId, + }, + }); +}; diff --git a/services/socketService.ts b/services/socketService.ts index 8b6238d..ee09cb5 100644 --- a/services/socketService.ts +++ b/services/socketService.ts @@ -223,6 +223,12 @@ class SocketService { } } + onSecureMessageAvailable(callback: (message: any) => void) { + if (this.socket) { + this.socket.on("secure_message_available", callback) + } + } + offNewMessage(callback?: (message: any) => void) { if (this.socket) { if (callback) { @@ -233,6 +239,16 @@ class SocketService { } } + offSecureMessageAvailable(callback?: (message: any) => void) { + if (this.socket) { + if (callback) { + this.socket.off("secure_message_available", callback) + } else { + this.socket.off("secure_message_available") + } + } + } + onMessageRead(callback: (data: any) => void) { if (this.socket) { this.socket.on("message_read", callback) diff --git a/types/chat.types.ts b/types/chat.types.ts index 900e41d..5be6741 100644 --- a/types/chat.types.ts +++ b/types/chat.types.ts @@ -1,6 +1,7 @@ export type MessageType = "text" | "image" | "file" | "money" | "audio" | "video" | "document"; export type MessageStatus = "sent" | "delivered" | "read"; export type ChatType = "dm" | "group" | "support"; +export type ChatSecurityMode = "legacy" | "secure_dm_v1" | "secure_group_v1" | "support_plain"; export interface User { id: string; @@ -38,6 +39,8 @@ export interface Conversation { name?: string; // Make optional to match Chat interface isGroup: boolean; type?: ChatType; + securityMode?: ChatSecurityMode; + protocolVersion?: string | null; groupId?: string; // The actual group ID for group chats lastMessage?: LastMessage | null; timestamp?: string; @@ -55,6 +58,8 @@ export interface Chat { name?: string; // Optional since DM chats might not have names isGroup: boolean; type?: ChatType; + securityMode?: ChatSecurityMode; + protocolVersion?: string | null; groupId?: string; // The actual group ID for group chats avatar?: string; participants: Participant[]; diff --git a/types/e2ee.types.ts b/types/e2ee.types.ts new file mode 100644 index 0000000..9cf6675 --- /dev/null +++ b/types/e2ee.types.ts @@ -0,0 +1,84 @@ +export type SupportedE2EEAlgorithm = "qc-e2ee-p256-v1"; + +export interface PublicPreKey { + keyId: number; + publicKey: JsonWebKey; +} + +export interface SecureDeviceBundlePayload { + algorithm: SupportedE2EEAlgorithm; + identityPublicKey: JsonWebKey; + signedPreKey: { + keyId: number; + publicKey: JsonWebKey; + signature: string; + }; + registrationId: number; + oneTimePreKeys: PublicPreKey[]; +} + +export interface StoredOneTimePreKey extends PublicPreKey { + privateKey: JsonWebKey; +} + +export interface StoredSecureDeviceState { + version: 1; + ownerUserId: string; + deviceId: string; + deviceName: string; + platform: string; + appVersion: string; + algorithm: SupportedE2EEAlgorithm; + registrationId: number; + identity: { + publicKey: JsonWebKey; + privateKey: JsonWebKey; + }; + signedPreKey: { + keyId: number; + publicKey: JsonWebKey; + privateKey: JsonWebKey; + signature: string; + }; + oneTimePreKeys: StoredOneTimePreKey[]; + lastServerSyncAt?: string | null; +} + +export interface PublicSecureDeviceBundle { + userId: string; + deviceId: string; + deviceName?: string; + platform?: string; + bundle: { + algorithm: SupportedE2EEAlgorithm; + identityPublicKey: JsonWebKey; + signedPreKeyId: number; + signedPreKeyPublic: JsonWebKey; + signedPreKeySignature: string; + registrationId: number; + }; + oneTimePreKeys: PublicPreKey[]; +} + +export interface SecureEncryptedEnvelope { + version: 1; + protocolVersion: "secure-dm-v1"; + algorithm: SupportedE2EEAlgorithm; + senderUserId: string; + senderDeviceId: string; + recipientUserId: string; + recipientDeviceId: string; + ephemeralPublicKey: JsonWebKey; + wrappedMessageKey: string; + wrappedMessageKeyIv: string; + ciphertext: string; + ciphertextIv: string; + createdAt: string; + signature: string; +} + +export interface SecureRecipientPayload { + recipientUserId: string; + recipientDeviceId: string; + encryptedEnvelope: SecureEncryptedEnvelope; +} From ee3ef7532036e629b61679f9616b53651d4d5121 Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Mon, 18 May 2026 19:44:53 +0200 Subject: [PATCH 02/36] fix(chat): satisfy secure dm type checks --- context/ChatContext.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/context/ChatContext.tsx b/context/ChatContext.tsx index 2b01271..b3907ce 100644 --- a/context/ChatContext.tsx +++ b/context/ChatContext.tsx @@ -306,7 +306,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { ...conv, lastMessage: { content: "Secure message", - messageType: "text", + messageType: "text" as const, createdAt: data.createdAt, sender: data.sender.name, }, @@ -326,7 +326,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { senderId: data.senderId, senderName: data.sender.lastName || data.sender.name, content: "Secure message", - messageType: "text", + messageType: "text" as const, }); } From 1190dbaf7b521088a34171a65862c8b1177b47fb Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Wed, 27 May 2026 11:20:24 +0200 Subject: [PATCH 03/36] wip: preserve local secure chat frontend changes --- app/contacts/components/ContactsTable.tsx | 6 +- components/SecureDeviceBootstrap.tsx | 13 +- components/chat/chat-area.tsx | 1 + components/chat/chat-header.tsx | 66 +-------- components/chat/chat-page-clean.tsx | 106 ++++++++++----- components/chat/message-input.tsx | 23 ++-- context/ChatContext.tsx | 53 ++++++++ hooks/use-auth-token.ts | 63 +++++++-- hooks/use-chat-operations.ts | 155 +++++++++++++++++++++- next.config.mjs | 1 + services/e2eeDeviceService.ts | 50 ++++++- services/secureChatService.ts | 64 +++++---- tsconfig.json | 22 ++- utils/tokenUtils.ts | 7 +- 14 files changed, 468 insertions(+), 162 deletions(-) diff --git a/app/contacts/components/ContactsTable.tsx b/app/contacts/components/ContactsTable.tsx index ed6cf2a..b402471 100644 --- a/app/contacts/components/ContactsTable.tsx +++ b/app/contacts/components/ContactsTable.tsx @@ -78,10 +78,8 @@ export function ContactsTable({ contacts, isLoading, onSelect }: ContactsTablePr router.push("/chat"); toast({ - title: result.usedSecure ? "Secure Chat Ready" : "Chat Ready", - description: result.usedSecure - ? `Opening a secure conversation with ${contact.otherUser.firstName} ${contact.otherUser.lastName}.` - : `Opening your conversation with ${contact.otherUser.firstName} ${contact.otherUser.lastName}.`, + title: "Secure Chat Ready", + description: `Opening a secure conversation with ${contact.otherUser.firstName} ${contact.otherUser.lastName}.`, }); } catch (error: any) { toast({ diff --git a/components/SecureDeviceBootstrap.tsx b/components/SecureDeviceBootstrap.tsx index a4f958b..ce354ca 100644 --- a/components/SecureDeviceBootstrap.tsx +++ b/components/SecureDeviceBootstrap.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { ensureRegisteredSecureDevice } from "@/services/e2eeDeviceService"; import { + getCurrentUserInfo, getStoredUserInfo, getValidToken, refreshAccessToken, @@ -10,7 +11,15 @@ import { const runBootstrap = async () => { const authUser = getStoredUserInfo(); - if (!authUser?.id || authUser.accountType !== "user") { + const tokenUserInfo = getCurrentUserInfo(); + const resolvedUserId = + tokenUserInfo.accountType === "user" ? tokenUserInfo.userId : authUser?.id || null; + const resolvedAccountType = + tokenUserInfo.accountType !== "unknown" + ? tokenUserInfo.accountType + : authUser?.accountType || null; + + if (!resolvedUserId || resolvedAccountType !== "user") { return; } @@ -21,7 +30,7 @@ const runBootstrap = async () => { await ensureRegisteredSecureDevice({ token, - userId: authUser.id, + userId: resolvedUserId, }); }; diff --git a/components/chat/chat-area.tsx b/components/chat/chat-area.tsx index bc40d04..43ad6c2 100644 --- a/components/chat/chat-area.tsx +++ b/components/chat/chat-area.tsx @@ -200,6 +200,7 @@ export default function ChatArea({ {/* Message Input */}
setReplyToMessage(null)} diff --git a/components/chat/chat-header.tsx b/components/chat/chat-header.tsx index be21888..e29ef3c 100644 --- a/components/chat/chat-header.tsx +++ b/components/chat/chat-header.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; @@ -17,8 +17,6 @@ import { useGetGroupByIdQuery } from '@/states/groupSlice'; import { useAuthToken } from '@/hooks/use-auth-token'; import FundraisingProgressBadge from './fundraising-progress-badge'; import { socketService } from '@/services/socketService'; -import { createOrGetSecureDmChat } from '@/services/secureChatService'; -import { toast } from '@/hooks/use-toast'; import { ArrowLeft, Info, @@ -50,10 +48,8 @@ export default function ChatHeader({ onDeleteGroup, }: ChatHeaderProps) { const chat = useChat(); - const { getToken, getUserId } = useAuthToken(); + const { getToken } = useAuthToken(); const token = getToken(); - const userId = getUserId(); - const [isOpeningSecureChat, setIsOpeningSecureChat] = useState(false); // Fetch group details if it's a group chat const { data: groupData, refetch: refetchGroupData } = useGetGroupByIdQuery( @@ -64,13 +60,6 @@ export default function ChatHeader({ const group = groupData?.data; const isDirectConversation = !conversation.isGroup && conversation.type !== 'support'; const isSecureConversation = conversation.securityMode === 'secure_dm_v1'; - const secureParticipantId = useMemo( - () => - isDirectConversation - ? conversation.participants.find((participant) => participant.userId !== userId)?.userId || null - : null, - [conversation.participants, isDirectConversation, userId], - ); // Listen for real-time fundraising progress updates useEffect(() => { @@ -107,44 +96,6 @@ export default function ChatHeader({ .toUpperCase(); }; - const handleOpenSecureChat = async () => { - if (!token || !secureParticipantId) { - toast({ - title: 'Secure chat unavailable', - description: 'Unable to resolve the other participant for this conversation.', - variant: 'destructive', - }); - return; - } - - try { - setIsOpeningSecureChat(true); - const result = await createOrGetSecureDmChat({ - token, - participantId: secureParticipantId, - }); - - chat.setActiveChat(result.chatId); - chat.refreshConversations(); - - toast({ - title: result.chatId === conversation.id ? 'Secure chat already active' : 'Secure chat ready', - description: - result.chatId === conversation.id - ? 'This conversation is already using secure messaging.' - : 'You are now in the secure conversation thread.', - }); - } catch (error: any) { - toast({ - title: 'Secure chat unavailable', - description: error?.message || 'Failed to open the secure conversation.', - variant: 'destructive', - }); - } finally { - setIsOpeningSecureChat(false); - } - }; - return (
{/* Back Button - Mobile Only */} @@ -289,21 +240,10 @@ export default function ChatHeader({ Chat Settings - {isDirectConversation && !isSecureConversation && ( - - - {isOpeningSecureChat ? 'Opening Secure Chat...' : 'Open Secure Chat'} - - )} - {isDirectConversation && isSecureConversation && ( - Secure Chat Active + End-to-end encrypted )} diff --git a/components/chat/chat-page-clean.tsx b/components/chat/chat-page-clean.tsx index 2cdcff2..9aa3320 100644 --- a/components/chat/chat-page-clean.tsx +++ b/components/chat/chat-page-clean.tsx @@ -15,6 +15,7 @@ import { cn } from '@/lib/utils'; import { useDeleteGroupMutation } from '@/states/groupSlice'; import GroupDialogs from '@/components/chat/GroupDialogs'; import { toast } from '@/hooks/use-toast'; +import { createOrGetPreferredDmChat } from '@/services/secureChatService'; import SendMoneyModal from '@/components/chat/send-money-modal'; import RequestMoneyModal from '@/components/chat/request-money-modal'; @@ -29,8 +30,9 @@ import { BackButton } from '@/components/shared/BackButton'; export default function ChatPageClean() { const router = useRouter(); - const { getToken } = useAuthToken(); + const { getToken, getUserId } = useAuthToken(); const token = getToken(); + const currentUserId = getUserId(); const { isExpanded } = useSidebar(); const pathname = usePathname(); const [requestedChatId, setRequestedChatId] = useState(null); @@ -46,6 +48,7 @@ export default function ChatPageClean() { setActiveChat, handleStartNewChat, handleJoinGroup, + upsertConversation, } = useChatOperations(); const { @@ -79,30 +82,42 @@ export default function ChatPageClean() { groupId: null, }); + const buildSelectedConversation = (conversation: any): Conversation => ({ + ...conversation, + id: conversation.id, + name: + conversation.name || + (conversation.otherUser + ? `${conversation.otherUser.firstName} ${conversation.otherUser.lastName}` + : 'Unknown Contact'), + isGroup: conversation.isGroup, + type: conversation.type, + securityMode: conversation.securityMode, + protocolVersion: conversation.protocolVersion, + groupId: conversation.groupId, + avatar: conversation.avatar, + participants: conversation.participants || [], + unreadCount: conversation.unreadCount || 0, + isOnline: conversation.isOnline || false, + memberCount: conversation.memberCount, + lastMessage: conversation.lastMessage || null, + }); + // Determine if chat is active (used to hide bottom nav) const isChatActive = !!selectedChat && !showMobileConversationList; + useEffect(() => { + if (selectedChat?.id && activeChat !== selectedChat.id) { + setActiveChat(selectedChat.id); + } + }, [selectedChat?.id, activeChat, setActiveChat]); + // Auto-select conversation when activeChat changes (e.g., from joining a group) useEffect(() => { if (activeChat && conversations.length > 0) { const conversation = conversations.find(c => c.id === activeChat); if (conversation && (!selectedChat || conversation.id !== selectedChat.id)) { - const conversationData = conversation as any; - setSelectedChat({ - id: conversationData.id, - name: - conversationData.name || - (conversationData.otherUser - ? `${conversationData.otherUser.firstName} ${conversationData.otherUser.lastName}` - : 'Unknown Contact'), - isGroup: conversationData.isGroup, - groupId: conversationData.groupId, - avatar: conversationData.avatar, - participants: conversationData.participants || [], - unreadCount: conversationData.unreadCount || 0, - isOnline: conversationData.isOnline || false, - memberCount: conversationData.memberCount, - }); + setSelectedChat(buildSelectedConversation(conversation)); setShowMobileConversationList(false); } } @@ -168,23 +183,48 @@ export default function ChatPageClean() { setShowMobileConversationList(false); }; - const handleConversationSelect = (conversation: any) => { + const handleConversationSelect = async (conversation: any) => { setSelectedOutsideMessage(null); - setSelectedChat({ - id: conversation.id, - name: - conversation.name || - (conversation.otherUser - ? `${conversation.otherUser.firstName} ${conversation.otherUser.lastName}` - : 'Unknown Contact'), - isGroup: conversation.isGroup, - groupId: conversation.groupId, - avatar: conversation.avatar, - participants: conversation.participants || [], - unreadCount: conversation.unreadCount || 0, - isOnline: conversation.isOnline || false, - memberCount: conversation.memberCount, - }); + + const isDirectConversation = !conversation.isGroup && conversation.type !== 'support'; + if (isDirectConversation && conversation.securityMode !== 'secure_dm_v1') { + const otherParticipantId = + conversation.participants?.find((participant: any) => participant.userId !== currentUserId)?.userId || + null; + + if (token && otherParticipantId) { + try { + const result = await createOrGetPreferredDmChat({ + token, + participantId: otherParticipantId, + }); + + const secureConversation = + conversations.find((item) => item.id === result.chatId) || + buildSelectedConversation({ + ...conversation, + id: result.chatId, + securityMode: 'secure_dm_v1', + protocolVersion: result.protocolVersion, + }); + + upsertConversation(buildSelectedConversation(secureConversation)); + setSelectedChat(buildSelectedConversation(secureConversation)); + setActiveChat(result.chatId); + setShowMobileConversationList(false); + return; + } catch (error: any) { + toast({ + title: 'Secure chat unavailable', + description: error?.message || 'Failed to open the secure conversation.', + variant: 'destructive', + }); + return; + } + } + } + + setSelectedChat(buildSelectedConversation(conversation)); setActiveChat(conversation.id); setShowMobileConversationList(false); }; diff --git a/components/chat/message-input.tsx b/components/chat/message-input.tsx index 6a20419..091e533 100644 --- a/components/chat/message-input.tsx +++ b/components/chat/message-input.tsx @@ -44,6 +44,7 @@ const pickerVars = (isDark: boolean): React.CSSProperties => ({ interface MessageInputProps { onSendMessage?: (message: string) => void + chatId?: string /** Pass the group ID when inside a group chat to enable @mentions */ groupId?: string replyToMessage?: ReplyPreview | null @@ -52,6 +53,7 @@ interface MessageInputProps { export default function MessageInput({ onSendMessage = () => { }, + chatId, groupId, replyToMessage = null, onCancelReply, @@ -90,6 +92,7 @@ export default function MessageInput({ const token = getToken() const isDark = theme === "dark" const { activeChat, sendMessage: contextSendMessage, startTyping, stopTyping, isConnected, addMessage } = chat + const currentChatId = chatId || activeChat // ── Debounce mentionQuery for the RTK search ─────────────────────────────── useEffect(() => { @@ -288,16 +291,16 @@ export default function MessageInput({ const mentions = collectMentions(text) - if (contextSendMessage && activeChat) { + if (contextSendMessage && currentChatId) { contextSendMessage( - activeChat, + currentChatId, text, "text", mentions, replyToMessage?.id, replyToMessage || null ) - if (stopTyping) stopTyping(activeChat) + if (stopTyping) stopTyping(currentChatId) } else { onSendMessage(text) } @@ -313,7 +316,7 @@ export default function MessageInput({ } }, [ messageText, - activeChat, + currentChatId, contextSendMessage, stopTyping, onSendMessage, @@ -329,9 +332,9 @@ export default function MessageInput({ setMessageText(value) setCursorPos(pos) detectMention(value, pos) - if (activeChat && startTyping && stopTyping) { - if (value.trim()) startTyping(activeChat) - else stopTyping(activeChat) + if (currentChatId && startTyping && stopTyping) { + if (value.trim()) startTyping(currentChatId) + else stopTyping(currentChatId) } } @@ -365,7 +368,7 @@ export default function MessageInput({ const handleBlur = () => { saveCursor() - if (activeChat && stopTyping) stopTyping(activeChat) + if (currentChatId && stopTyping) stopTyping(currentChatId) } // ── Attachments / Media ─────────────────────────────────────────────────── @@ -375,13 +378,13 @@ export default function MessageInput({ } const handleMediaUpload = async (file: File, caption: string) => { - if (!activeChat) { + if (!currentChatId) { toast({ title: "Error", description: "No active chat selected", variant: "destructive" }) return } setUploading(true); setUploadProgress(0) try { - const result = await uploadMediaMessage(activeChat, file, caption, (p) => setUploadProgress(p.percentage)) + const result = await uploadMediaMessage(currentChatId, file, caption, (p) => setUploadProgress(p.percentage)) if (result.success && result.data) { if (addMessage) addMessage(result.data as any) toast({ title: "Media sent", description: "Your media has been sent successfully" }) diff --git a/context/ChatContext.tsx b/context/ChatContext.tsx index b3907ce..b3a7986 100644 --- a/context/ChatContext.tsx +++ b/context/ChatContext.tsx @@ -55,6 +55,7 @@ interface ChatContextType { clearChatState: () => void; addReaction: (chatId: string, messageId: string, emoji: string) => void; removeReaction: (chatId: string, messageId: string) => void; + upsertConversation: (conversation: Conversation) => void; } const ChatContext = createContext(undefined); @@ -290,6 +291,14 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { return; } + if (data.senderId === userId) { + if (data.chatId === activeChat) { + setSecureMessagesRefreshKey((current) => current + 1); + } + refetchChats(); + return; + } + if (data.chatId === activeChat) { setSecureMessagesRefreshKey((current) => current + 1); } @@ -453,6 +462,31 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { }; const handleError = (error: { message: string }) => { + if ( + activeChat && + error.message?.includes("requires secure messaging") + ) { + setConversations((prev: Conversation[]) => + prev.map((conversation: Conversation) => + conversation.id === activeChat + ? { + ...conversation, + securityMode: "secure_dm_v1", + protocolVersion: conversation.protocolVersion || "secure-dm-v1", + } + : conversation, + ).sort(sortConversations), + ); + setSecureMessagesRefreshKey((current) => current + 1); + refetchChats(); + toast({ + title: "Secure chat ready", + description: "This thread is secure. Send the message again using the secure flow.", + duration: 5000, + }); + return; + } + toast({ title: "Error", description: error.message, @@ -691,6 +725,24 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { refetchChats(); }, [refetchChats]); + const upsertConversation = useCallback((conversation: Conversation) => { + setConversations((prev: Conversation[]) => { + const existingIndex = prev.findIndex((item) => item.id === conversation.id); + + if (existingIndex === -1) { + return [conversation, ...prev].sort(sortConversations); + } + + const next = [...prev]; + next[existingIndex] = { + ...next[existingIndex], + ...conversation, + }; + + return next.sort(sortConversations); + }); + }, [sortConversations]); + const refreshMessages = useCallback((chatId: string) => { if (chatId === activeChat) { if (activeConversation?.securityMode === "secure_dm_v1") { @@ -1004,6 +1056,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { clearChatState, addReaction, removeReaction, + upsertConversation, }; return ( diff --git a/hooks/use-auth-token.ts b/hooks/use-auth-token.ts index 67fcf71..3550658 100644 --- a/hooks/use-auth-token.ts +++ b/hooks/use-auth-token.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { decodeJWT, isTokenExpired } from "@/utils/jwtUtils"; import { @@ -15,21 +15,31 @@ const TOKEN_KEY = "token"; export const useAuthToken = (enableAutoRedirect: boolean = true) => { const router = useRouter(); const isRedirectingRef = useRef(false); + const [token, setTokenState] = useState(null); - const getToken = useCallback(() => { + const syncTokenState = useCallback(async (allowRefresh: boolean = false) => { if (typeof window === "undefined") return null; const validToken = getValidToken(); - if (validToken) return validToken; + if (validToken) { + setTokenState((current) => (current === validToken ? current : validToken)); + return validToken; + } - if (getRefreshToken()) { - void refreshAccessToken(); - return null; + if (allowRefresh && getRefreshToken()) { + const refreshedToken = await refreshAccessToken(); + setTokenState(refreshedToken ?? null); + return refreshedToken ?? null; } + setTokenState(null); return null; }, []); + const getToken = useCallback(() => { + return token; + }, [token]); + const setToken = useCallback((token: string, expiryDays: number = 1) => { if (typeof window === "undefined") return false; @@ -39,12 +49,14 @@ export const useAuthToken = (enableAutoRedirect: boolean = true) => { } storeAccessToken(token, expiryDays); + setTokenState(token); return true; }, []); const removeToken = useCallback(() => { if (typeof window !== "undefined") { clearAllTokens(); + setTokenState(null); } }, []); @@ -69,13 +81,21 @@ export const useAuthToken = (enableAutoRedirect: boolean = true) => { if (!enableAutoRedirect || typeof window === "undefined") return; const storedToken = getStoredAccessToken(); - if (storedToken && !isTokenExpired(storedToken)) return; + if (storedToken && !isTokenExpired(storedToken)) { + setTokenState((current) => (current === storedToken ? current : storedToken)); + return; + } if (getRefreshToken()) { const refreshedToken = await refreshAccessToken(); - if (refreshedToken) return; + if (refreshedToken) { + setTokenState(refreshedToken); + return; + } } + setTokenState(null); + if (storedToken || localStorage.getItem(TOKEN_KEY)) { redirectToLogin(); } @@ -93,19 +113,42 @@ export const useAuthToken = (enableAutoRedirect: boolean = true) => { if (typeof window === "undefined") return false; const validToken = getValidToken(); - if (validToken) return true; + if (validToken) { + setTokenState((current) => (current === validToken ? current : validToken)); + return true; + } if (getRefreshToken()) { - void refreshAccessToken(); + void refreshAccessToken().then((refreshedToken) => { + setTokenState(refreshedToken ?? null); + }); return false; } + setTokenState(null); + if (enableAutoRedirect) { redirectToLogin(); } return false; }, [enableAutoRedirect, redirectToLogin]); + useEffect(() => { + if (typeof window === "undefined") return; + + void syncTokenState(true); + + const handleAuthTokenChange = () => { + void syncTokenState(); + }; + + window.addEventListener("authTokenChanged", handleAuthTokenChange as EventListener); + + return () => { + window.removeEventListener("authTokenChanged", handleAuthTokenChange as EventListener); + }; + }, [syncTokenState]); + useEffect(() => { if (!enableAutoRedirect || typeof window === "undefined") return; diff --git a/hooks/use-chat-operations.ts b/hooks/use-chat-operations.ts index 5b5a701..2b6d7c7 100644 --- a/hooks/use-chat-operations.ts +++ b/hooks/use-chat-operations.ts @@ -36,6 +36,90 @@ interface UseChatOperationsReturn { handleDeleteChat: (chatId: string) => Promise handleMarkAsRead: (chatId: string) => Promise refreshConversations: () => void + upsertConversation: (conversation: Conversation) => void +} + +const getDirectConversationOtherUserId = (conversation: Conversation, currentUserId?: string | null) => { + if (conversation.isGroup || conversation.type === 'support') { + return null + } + + return conversation.participants?.find((participant: any) => participant.userId !== currentUserId)?.userId || null +} + +const preferSecureDirectConversations = ( + conversations: Conversation[], + currentUserId?: string | null, +) => { + const keptByDirectKey = new Map() + + for (const conversation of conversations) { + const otherUserId = getDirectConversationOtherUserId(conversation, currentUserId) + if (!otherUserId) { + continue + } + + const mapKey = `dm:${otherUserId}` + const existing = keptByDirectKey.get(mapKey) + + if (!existing) { + keptByDirectKey.set(mapKey, conversation) + continue + } + + const existingIsSecure = existing.securityMode === 'secure_dm_v1' + const currentIsSecure = conversation.securityMode === 'secure_dm_v1' + + if (currentIsSecure && !existingIsSecure) { + keptByDirectKey.set(mapKey, conversation) + continue + } + + if (currentIsSecure === existingIsSecure) { + const existingTimestamp = new Date(existing.lastMessage?.createdAt || existing.timestamp || 0).getTime() + const currentTimestamp = new Date(conversation.lastMessage?.createdAt || conversation.timestamp || 0).getTime() + + if (currentTimestamp > existingTimestamp) { + keptByDirectKey.set(mapKey, conversation) + } + } + } + + const seen = new Set() + + return conversations.filter((conversation) => { + const otherUserId = getDirectConversationOtherUserId(conversation, currentUserId) + if (!otherUserId) { + return true + } + + const mapKey = `dm:${otherUserId}` + const preferred = keptByDirectKey.get(mapKey) + if (!preferred || preferred.id !== conversation.id || seen.has(mapKey)) { + return false + } + + seen.add(mapKey) + return true + }) +} + +const getPreferredDirectConversationMap = ( + conversations: Conversation[], + currentUserId?: string | null, +) => { + const map = new Map() + + for (const conversation of preferSecureDirectConversations(conversations, currentUserId)) { + const otherUserId = getDirectConversationOtherUserId(conversation, currentUserId) + if (!otherUserId) { + continue + } + + map.set(otherUserId, conversation) + } + + return map } export function useChatOperations(): UseChatOperationsReturn { @@ -53,6 +137,7 @@ export function useChatOperations(): UseChatOperationsReturn { conversations: enhancedConversations, refreshConversations: contextRefreshConversations, initializeEncryption, + upsertConversation, } = useChat(); const { @@ -96,7 +181,7 @@ export function useChatOperations(): UseChatOperationsReturn { }); } }, [chatsError]); - const conversations: Conversation[] = enhancedConversations?.length + const rawConversations: Conversation[] = enhancedConversations?.length ? enhancedConversations.map((conv: any) => ({ id: conv.id, name: conv.name, @@ -148,6 +233,35 @@ export function useChatOperations(): UseChatOperationsReturn { participants: chat.participants })) || [] + const conversations: Conversation[] = preferSecureDirectConversations(rawConversations, userId) + + useEffect(() => { + if (!contextActiveChat || !rawConversations.length) { + return + } + + const activeConversation = rawConversations.find((conversation) => conversation.id === contextActiveChat) + if (!activeConversation) { + return + } + + const otherUserId = getDirectConversationOtherUserId(activeConversation, userId) + if (!otherUserId) { + return + } + + const preferredMap = getPreferredDirectConversationMap(rawConversations, userId) + const preferredConversation = preferredMap.get(otherUserId) + + if ( + preferredConversation && + preferredConversation.id !== contextActiveChat && + preferredConversation.securityMode === 'secure_dm_v1' + ) { + setContextActiveChat(preferredConversation.id) + } + }, [contextActiveChat, rawConversations, userId, setContextActiveChat]) + const messages: Message[] = activeChat ? (contextMessages[activeChat] || []) : [] const handleStartNewChat = useCallback(async (contact: any) => { @@ -161,13 +275,39 @@ export function useChatOperations(): UseChatOperationsReturn { participantId: contact.otherUser.id, }); + upsertConversation({ + id: result.chatId, + name: `${contact.otherUser.firstName} ${contact.otherUser.lastName}`.trim() || 'Unknown Contact', + isGroup: false, + type: 'direct', + securityMode: 'secure_dm_v1', + protocolVersion: result.protocolVersion, + lastMessage: null, + timestamp: '', + unreadCount: 0, + avatar: contact.otherUser.profileImage || "/placeholder.svg?height=40&width=40", + isOnline: false, + participants: [ + { + userId: userId || '', + role: 'member', + } as any, + { + userId: contact.otherUser.id, + role: 'member', + user: { + id: contact.otherUser.id, + email: contact.otherUser.email, + phone: contact.otherUser.phone, + }, + } as any, + ], + }) setContextActiveChat(result.chatId) toast({ - title: result.usedSecure ? "Secure Chat Started" : "Chat Started", - description: result.usedSecure - ? `Started a secure conversation with ${contact.otherUser.firstName} ${contact.otherUser.lastName}` - : `Started a new conversation with ${contact.otherUser.firstName} ${contact.otherUser.lastName}`, + title: "Secure Chat Started", + description: `Started a secure conversation with ${contact.otherUser.firstName} ${contact.otherUser.lastName}`, }) contextRefreshConversations?.() @@ -178,7 +318,7 @@ export function useChatOperations(): UseChatOperationsReturn { variant: "destructive" }) } - }, [token, setContextActiveChat, contextRefreshConversations]) + }, [token, setContextActiveChat, contextRefreshConversations, upsertConversation, userId]) const handleJoinGroup = useCallback(async (group: any) => { try { @@ -272,6 +412,7 @@ export function useChatOperations(): UseChatOperationsReturn { handleCreateGroupChat, handleDeleteChat, handleMarkAsRead, - refreshConversations + refreshConversations, + upsertConversation, } } diff --git a/next.config.mjs b/next.config.mjs index d8cb476..604c937 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -2,6 +2,7 @@ import withPWA from 'next-pwa'; /** @type {import('next').NextConfig} */ const nextConfig = { + distDir: process.env.NEXT_DIST_DIR || '.next', images: { domains: ['deploy-preview-7--qcode-staging.netlify.app'], diff --git a/services/e2eeDeviceService.ts b/services/e2eeDeviceService.ts index e0bed26..ca80ba8 100644 --- a/services/e2eeDeviceService.ts +++ b/services/e2eeDeviceService.ts @@ -18,6 +18,7 @@ const ACTIVE_APP_VERSION = "web-pwa-v1"; const ONE_TIME_PREKEY_COUNT = 12; const SYNC_INTERVAL_MS = 12 * 60 * 60 * 1000; const SUPPORTED_ALGORITHM: SupportedE2EEAlgorithm = "qc-e2ee-p256-v1"; +const MAX_DB_SAFE_PREKEY_ID = 2_147_483_646; const encoder = new TextEncoder(); @@ -50,7 +51,8 @@ const toBase64Url = (buffer: ArrayBuffer) => { return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); }; -const getRandomId = () => window.crypto.getRandomValues(new Uint32Array(1))[0] ?? Date.now(); +const getRandomId = () => + Number(window.crypto.getRandomValues(new Uint32Array(1))[0] % MAX_DB_SAFE_PREKEY_ID) + 1; const describeCurrentDevice = () => { const nav = window.navigator as Navigator & { @@ -162,6 +164,13 @@ const createFreshDeviceState = async (userId: string): Promise + state.signedPreKey.keyId > 0 && + state.signedPreKey.keyId <= MAX_DB_SAFE_PREKEY_ID && + state.oneTimePreKeys.every( + (preKey) => preKey.keyId > 0 && preKey.keyId <= MAX_DB_SAFE_PREKEY_ID, + ); + const shouldResync = (state: StoredSecureDeviceState) => { if (!state.lastServerSyncAt) return true; @@ -215,6 +224,36 @@ const registerBundle = async (token: string, state: StoredSecureDeviceState) => } }; +const hasRegisteredBundle = async (token: string, deviceId: string) => { + if (!baseUrl) { + return false; + } + + try { + const response = await fetch(`${baseUrl}/e2ee/devices`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + return false; + } + + const payload = await response.json().catch(() => null); + const devices = Array.isArray(payload?.data) ? payload.data : []; + const matchingDevice = devices.find((device: any) => device.deviceId === deviceId); + + if (!matchingDevice) { + return false; + } + + return Boolean(matchingDevice.bundle?.algorithm) && Number(matchingDevice.availableOneTimePreKeys || 0) > 0; + } catch { + return false; + } +}; + export const ensureRegisteredSecureDevice = async ({ token, userId, @@ -236,7 +275,14 @@ export const ensureRegisteredSecureDevice = async ({ await saveStoredSecureDeviceState(state); } - if (!shouldResync(state)) { + if (!hasValidPreKeyIds(state)) { + state = await createFreshDeviceState(userId); + await saveStoredSecureDeviceState(state); + } + + const needsServerSync = shouldResync(state) || !(await hasRegisteredBundle(token, state.deviceId)); + + if (!needsServerSync) { return state; } diff --git a/services/secureChatService.ts b/services/secureChatService.ts index 641e483..63eec6a 100644 --- a/services/secureChatService.ts +++ b/services/secureChatService.ts @@ -13,6 +13,25 @@ import type { const bundleCache = new Map(); const BUNDLE_CACHE_TTL_MS = 60 * 1000; +const isValidBase64UrlCoordinate = (value: unknown) => + typeof value === "string" && value.length >= 43 && value.length <= 44; + +const hasUsablePublicBundle = (device: any) => + Boolean( + device?.bundle && + device.bundle.algorithm === "qc-e2ee-p256-v1" && + device.bundle.identityPublicKey?.kty === "EC" && + device.bundle.identityPublicKey?.crv === "P-256" && + isValidBase64UrlCoordinate(device.bundle.identityPublicKey?.x) && + isValidBase64UrlCoordinate(device.bundle.identityPublicKey?.y) && + device.bundle.signedPreKeyPublic?.kty === "EC" && + device.bundle.signedPreKeyPublic?.crv === "P-256" && + isValidBase64UrlCoordinate(device.bundle.signedPreKeyPublic?.x) && + isValidBase64UrlCoordinate(device.bundle.signedPreKeyPublic?.y) && + typeof device.bundle.signedPreKeySignature === "string" && + device.bundle.signedPreKeySignature.length > 20, + ); + const getApiBaseUrl = () => { if (!baseUrl) { throw new Error("NEXT_PUBLIC_API_URL is not configured"); @@ -63,7 +82,7 @@ const fetchPublicDeviceBundles = async (token: string, userId: string) => { }, }); - const devices = (payload?.data || []).map((device: any) => ({ + const rawDevices = (payload?.data || []).map((device: any) => ({ userId, deviceId: device.deviceId, deviceName: device.deviceName, @@ -72,6 +91,17 @@ const fetchPublicDeviceBundles = async (token: string, userId: string) => { oneTimePreKeys: device.oneTimePreKeys || [], })) as PublicSecureDeviceBundle[]; + const devices = rawDevices.filter((device) => { + const valid = hasUsablePublicBundle(device); + if (!valid) { + console.warn("Skipping invalid secure device bundle", { + userId, + deviceId: device.deviceId, + }); + } + return valid; + }); + bundleCache.set(cacheKey, { cachedAt: Date.now(), devices, @@ -229,31 +259,15 @@ export const createOrGetPreferredDmChat = async ({ token: string; participantId: string; }) => { - try { - const secureChat = await createOrGetSecureDmChat({ - token, - participantId, - }); - - return { - ...secureChat, - usedSecure: true, - }; - } catch (secureError) { - console.warn("Secure DM unavailable, falling back to legacy DM", secureError); + const secureChat = await createOrGetSecureDmChat({ + token, + participantId, + }); - const legacyChat = await createOrGetLegacyDmChat({ - token, - participantId, - }); - - return { - ...legacyChat, - securityMode: legacyChat.securityMode || "legacy", - protocolVersion: legacyChat.protocolVersion || null, - usedSecure: legacyChat.securityMode === "secure_dm_v1", - }; - } + return { + ...secureChat, + usedSecure: true, + }; }; export const sendSecureTextMessage = async ({ diff --git a/tsconfig.json b/tsconfig.json index e7ff90f..68aecc6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,10 @@ { "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -18,9 +22,19 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next-dev-local/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/utils/tokenUtils.ts b/utils/tokenUtils.ts index abda417..e67adc9 100644 --- a/utils/tokenUtils.ts +++ b/utils/tokenUtils.ts @@ -142,13 +142,16 @@ export const storeAccessToken = (token: string, days: number = 1) => { const payload = parseTokenPayload(token); if (payload) { + const resolvedUserInfo = extractUserInfo(payload); localStorage.setItem( USER_INFO_KEY, JSON.stringify({ - id: payload.id || payload.userId || payload.sub || null, + id: resolvedUserInfo.accountType === 'organization' + ? resolvedUserInfo.organizationId + : resolvedUserInfo.userId, name: payload.name || null, email: payload.email || null, - accountType: payload.accountType || null, + accountType: resolvedUserInfo.accountType, }), ); } From a1eee7ef66458e6fa656f94e27c52655be89b083 Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Wed, 27 May 2026 11:26:25 +0200 Subject: [PATCH 04/36] fix secure chat frontend type checks --- context/ChatContext.tsx | 2 +- hooks/use-chat-operations.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/context/ChatContext.tsx b/context/ChatContext.tsx index b3a7986..e1fd5f6 100644 --- a/context/ChatContext.tsx +++ b/context/ChatContext.tsx @@ -471,7 +471,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { conversation.id === activeChat ? { ...conversation, - securityMode: "secure_dm_v1", + securityMode: "secure_dm_v1" as const, protocolVersion: conversation.protocolVersion || "secure-dm-v1", } : conversation, diff --git a/hooks/use-chat-operations.ts b/hooks/use-chat-operations.ts index 2b6d7c7..29a36ae 100644 --- a/hooks/use-chat-operations.ts +++ b/hooks/use-chat-operations.ts @@ -279,7 +279,7 @@ export function useChatOperations(): UseChatOperationsReturn { id: result.chatId, name: `${contact.otherUser.firstName} ${contact.otherUser.lastName}`.trim() || 'Unknown Contact', isGroup: false, - type: 'direct', + type: 'dm', securityMode: 'secure_dm_v1', protocolVersion: result.protocolVersion, lastMessage: null, From 6db1c15d04e6dd2a50ab8a8d660b9b8f1d08de98 Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Wed, 27 May 2026 11:28:45 +0200 Subject: [PATCH 05/36] harden secure device state storage --- lib/e2ee/deviceStore.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/lib/e2ee/deviceStore.ts b/lib/e2ee/deviceStore.ts index f779d9c..17f653a 100644 --- a/lib/e2ee/deviceStore.ts +++ b/lib/e2ee/deviceStore.ts @@ -70,16 +70,29 @@ export const getStoredSecureDeviceState = async (): Promise("readonly", (store) => store.get("active"), ); - return state ?? null; + + if (state) { + writeFallbackState(null); + return state; + } + + const legacyState = readFallbackState(); + if (legacyState) { + await withStore("readwrite", (store) => store.put(legacyState, "active")); + writeFallbackState(null); + return legacyState; + } + + return null; } catch { - return readFallbackState(); + return null; } }; @@ -87,15 +100,14 @@ export const saveStoredSecureDeviceState = async (state: StoredSecureDeviceState if (typeof window === "undefined") return; if (!canUseIndexedDb()) { - writeFallbackState(state); - return; + throw new Error("Secure device storage requires IndexedDB"); } try { await withStore("readwrite", (store) => store.put(state, "active")); - writeFallbackState(state); + writeFallbackState(null); } catch { - writeFallbackState(state); + throw new Error("Failed to store secure device state"); } }; From dd167b3e4310a0b621286a0391d28c0fdb7bd83e Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Wed, 27 May 2026 11:42:50 +0200 Subject: [PATCH 06/36] ignore local next dev output --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c2321cc..1422586 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ # next.js /.next/ +/.next-dev-local/ /out/ # production From 82745c34e39a87ceb22a6c9205d8be9b36996217 Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Wed, 27 May 2026 11:45:06 +0200 Subject: [PATCH 07/36] pin secure device identities locally --- lib/e2ee/identityTrustStore.ts | 86 ++++++++++++++++++++++++++++++++++ services/secureChatService.ts | 14 +++++- 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 lib/e2ee/identityTrustStore.ts diff --git a/lib/e2ee/identityTrustStore.ts b/lib/e2ee/identityTrustStore.ts new file mode 100644 index 0000000..4a8f878 --- /dev/null +++ b/lib/e2ee/identityTrustStore.ts @@ -0,0 +1,86 @@ +"use client"; + +const TRUST_STORAGE_KEY = "qc:secure-device-identity-pins"; +const encoder = new TextEncoder(); + +const stableStringify = (value: unknown): string => { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + + const entries = Object.entries(value as Record).sort(([a], [b]) => + a.localeCompare(b), + ); + + return `{${entries + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) + .join(",")}}`; +}; + +const toBase64Url = (value: ArrayBuffer | Uint8Array) => { + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); + let binary = ""; + + for (let index = 0; index < bytes.length; index += 1) { + binary += String.fromCharCode(bytes[index]); + } + + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +}; + +const readPins = () => { + if (typeof window === "undefined") { + return {} as Record; + } + + try { + const raw = window.localStorage.getItem(TRUST_STORAGE_KEY); + return raw ? (JSON.parse(raw) as Record) : {}; + } catch { + return {}; + } +}; + +const writePins = (pins: Record) => { + if (typeof window === "undefined") return; + window.localStorage.setItem(TRUST_STORAGE_KEY, JSON.stringify(pins)); +}; + +export const getIdentityFingerprint = async (identityPublicKey: JsonWebKey) => { + const digest = await window.crypto.subtle.digest( + "SHA-256", + encoder.encode(stableStringify(identityPublicKey)), + ); + + return toBase64Url(digest); +}; + +export const assertTrustedDeviceIdentity = async ({ + userId, + deviceId, + identityPublicKey, +}: { + userId: string; + deviceId: string; + identityPublicKey: JsonWebKey; +}) => { + const fingerprint = await getIdentityFingerprint(identityPublicKey); + const pinKey = `${userId}:${deviceId}`; + const pins = readPins(); + const existing = pins[pinKey]; + + if (existing && existing !== fingerprint) { + throw new Error("Secure device identity changed. Verify this contact before continuing."); + } + + if (!existing) { + pins[pinKey] = fingerprint; + writePins(pins); + } + + return fingerprint; +}; diff --git a/services/secureChatService.ts b/services/secureChatService.ts index 63eec6a..96eae93 100644 --- a/services/secureChatService.ts +++ b/services/secureChatService.ts @@ -1,6 +1,7 @@ "use client"; import baseUrl from "@/helpers/baseUrl"; +import { assertTrustedDeviceIdentity } from "@/lib/e2ee/identityTrustStore"; import { decryptSecureEnvelope, encryptSecureTextForRecipients } from "@/lib/e2ee/secureMessageCrypto"; import { ensureRegisteredSecureDevice } from "@/services/e2eeDeviceService"; import type { Conversation, Message, ReplyPreview } from "@/types/chat.types"; @@ -91,7 +92,7 @@ const fetchPublicDeviceBundles = async (token: string, userId: string) => { oneTimePreKeys: device.oneTimePreKeys || [], })) as PublicSecureDeviceBundle[]; - const devices = rawDevices.filter((device) => { + const usableDevices = rawDevices.filter((device) => { const valid = hasUsablePublicBundle(device); if (!valid) { console.warn("Skipping invalid secure device bundle", { @@ -101,6 +102,17 @@ const fetchPublicDeviceBundles = async (token: string, userId: string) => { } return valid; }); + const devices = await Promise.all( + usableDevices.map(async (device) => { + await assertTrustedDeviceIdentity({ + userId, + deviceId: device.deviceId, + identityPublicKey: device.bundle.identityPublicKey, + }); + + return device; + }), + ); bundleCache.set(cacheKey, { cachedAt: Date.now(), From 1d7c92e1640e7433cd72ee2ab96c9e01fe1f528a Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Wed, 27 May 2026 11:48:48 +0200 Subject: [PATCH 08/36] use one-time prekeys for secure envelopes --- lib/e2ee/secureMessageCrypto.ts | 19 ++++++++++++++++--- services/secureChatService.ts | 13 +++++++++++++ types/e2ee.types.ts | 1 + 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/e2ee/secureMessageCrypto.ts b/lib/e2ee/secureMessageCrypto.ts index 8b9f4fd..5576339 100644 --- a/lib/e2ee/secureMessageCrypto.ts +++ b/lib/e2ee/secureMessageCrypto.ts @@ -242,8 +242,9 @@ export const encryptSecureTextForRecipients = async ({ true, ["deriveBits"], ); + const selectedOneTimePreKey = recipientDevice.oneTimePreKeys[0] || null; const recipientPublicKey = await importExchangePublicKey( - recipientDevice.bundle.signedPreKeyPublic, + selectedOneTimePreKey?.publicKey || recipientDevice.bundle.signedPreKeyPublic, ); const wrappingKey = await deriveWrappingKey({ privateKey: ephemeralKeyPair.privateKey, @@ -273,6 +274,7 @@ export const encryptSecureTextForRecipients = async ({ senderDeviceId: senderState.deviceId, recipientUserId: recipientDevice.userId, recipientDeviceId: recipientDevice.deviceId, + recipientOneTimePreKeyId: selectedOneTimePreKey?.keyId || null, ephemeralPublicKey, wrappedMessageKey: toBase64Url(wrappedMessageKey), wrappedMessageKeyIv: toBase64Url(wrappingIv), @@ -324,10 +326,21 @@ export const decryptSecureEnvelope = async ({ throw new Error("Secure message signature verification failed"); } - const signedPreKeyPrivate = await importExchangePrivateKey(recipientState.signedPreKey.privateKey); + const recipientOneTimePreKeyId = envelope.recipientOneTimePreKeyId || null; + const oneTimePreKey = recipientOneTimePreKeyId + ? recipientState.oneTimePreKeys.find((preKey) => preKey.keyId === recipientOneTimePreKeyId) + : null; + + if (recipientOneTimePreKeyId && !oneTimePreKey) { + throw new Error("Secure message one-time pre-key is not available on this device"); + } + + const recipientPreKeyPrivate = await importExchangePrivateKey( + oneTimePreKey?.privateKey || recipientState.signedPreKey.privateKey, + ); const ephemeralPublicKey = await importExchangePublicKey(envelope.ephemeralPublicKey); const wrappingKey = await deriveWrappingKey({ - privateKey: signedPreKeyPrivate, + privateKey: recipientPreKeyPrivate, publicKey: ephemeralPublicKey, senderDeviceId: envelope.senderDeviceId, recipientDeviceId: envelope.recipientDeviceId, diff --git a/services/secureChatService.ts b/services/secureChatService.ts index 96eae93..8d5668d 100644 --- a/services/secureChatService.ts +++ b/services/secureChatService.ts @@ -2,6 +2,7 @@ import baseUrl from "@/helpers/baseUrl"; import { assertTrustedDeviceIdentity } from "@/lib/e2ee/identityTrustStore"; +import { saveStoredSecureDeviceState } from "@/lib/e2ee/deviceStore"; import { decryptSecureEnvelope, encryptSecureTextForRecipients } from "@/lib/e2ee/secureMessageCrypto"; import { ensureRegisteredSecureDevice } from "@/services/e2eeDeviceService"; import type { Conversation, Message, ReplyPreview } from "@/types/chat.types"; @@ -144,6 +145,18 @@ const decryptSecureApiMessage = async ({ senderIdentityPublicKey: senderDevice.bundle.identityPublicKey, recipientState: state, }); + const recipientOneTimePreKeyId = envelope.recipientOneTimePreKeyId || null; + + if (recipientOneTimePreKeyId) { + const remainingOneTimePreKeys = state.oneTimePreKeys.filter( + (preKey) => preKey.keyId !== recipientOneTimePreKeyId, + ); + + if (remainingOneTimePreKeys.length !== state.oneTimePreKeys.length) { + state.oneTimePreKeys = remainingOneTimePreKeys; + await saveStoredSecureDeviceState(state); + } + } return { id: rawMessage.id, diff --git a/types/e2ee.types.ts b/types/e2ee.types.ts index 9cf6675..15aa89c 100644 --- a/types/e2ee.types.ts +++ b/types/e2ee.types.ts @@ -68,6 +68,7 @@ export interface SecureEncryptedEnvelope { senderDeviceId: string; recipientUserId: string; recipientDeviceId: string; + recipientOneTimePreKeyId?: number | null; ephemeralPublicKey: JsonWebKey; wrappedMessageKey: string; wrappedMessageKeyIv: string; From cec095cabfc7326053f93d0e73cfcd01dc1363c3 Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Wed, 27 May 2026 11:51:48 +0200 Subject: [PATCH 09/36] refresh recipient device bundles after secure send --- services/secureChatService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/services/secureChatService.ts b/services/secureChatService.ts index 8d5668d..d77cd82 100644 --- a/services/secureChatService.ts +++ b/services/secureChatService.ts @@ -339,6 +339,7 @@ export const sendSecureTextMessage = async ({ }); bundleCache.delete(`bundles:${userId}`); + bundleCache.delete(`bundles:${recipientUserId}`); return { id: payload.data.id, From 431529a8da747b2be711336ea7c787d7165bed02 Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Wed, 27 May 2026 11:58:26 +0200 Subject: [PATCH 10/36] add secure identity verification dialog --- components/chat/chat-area.tsx | 3 + components/chat/chat-header.tsx | 10 ++ components/chat/chat-page-clean.tsx | 21 ++- components/chat/secure-identity-dialog.tsx | 154 +++++++++++++++++++++ services/secureChatService.ts | 26 +++- 5 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 components/chat/secure-identity-dialog.tsx diff --git a/components/chat/chat-area.tsx b/components/chat/chat-area.tsx index f5279f2..2486ebd 100644 --- a/components/chat/chat-area.tsx +++ b/components/chat/chat-area.tsx @@ -66,6 +66,7 @@ interface ChatAreaProps { onInviteToGroup: () => void onGroupSettings?: () => void onDeleteGroup?: () => void + onVerifySecurity?: () => void onCreateContribution?: () => void isGroupAdmin?: boolean typingUsers?: any[] @@ -83,6 +84,7 @@ export default function ChatArea({ onInviteToGroup, onGroupSettings, onDeleteGroup, + onVerifySecurity, onCreateContribution, isGroupAdmin = false, typingUsers = [], @@ -113,6 +115,7 @@ export default function ChatArea({ onInviteToGroup={onInviteToGroup} onGroupSettings={onGroupSettings} onDeleteGroup={onDeleteGroup} + onVerifySecurity={onVerifySecurity} />
diff --git a/components/chat/chat-header.tsx b/components/chat/chat-header.tsx index e29ef3c..27071dd 100644 --- a/components/chat/chat-header.tsx +++ b/components/chat/chat-header.tsx @@ -27,6 +27,7 @@ import { Circle, Wifi, Lock, + ShieldCheck, Trash2, } from 'lucide-react'; @@ -37,6 +38,7 @@ interface ChatHeaderProps { onInviteToGroup?: () => void; onGroupSettings?: () => void; onDeleteGroup?: () => void; + onVerifySecurity?: () => void; } export default function ChatHeader({ @@ -46,6 +48,7 @@ export default function ChatHeader({ onInviteToGroup, onGroupSettings, onDeleteGroup, + onVerifySecurity, }: ChatHeaderProps) { const chat = useChat(); const { getToken } = useAuthToken(); @@ -240,6 +243,13 @@ export default function ChatHeader({ Chat Settings + {isDirectConversation && isSecureConversation && onVerifySecurity && ( + + + Verify Security + + )} + {isDirectConversation && isSecureConversation && ( diff --git a/components/chat/chat-page-clean.tsx b/components/chat/chat-page-clean.tsx index 56d7dde..f723e17 100644 --- a/components/chat/chat-page-clean.tsx +++ b/components/chat/chat-page-clean.tsx @@ -15,7 +15,10 @@ import { cn } from '@/lib/utils'; import { useDeleteGroupMutation } from '@/states/groupSlice'; import GroupDialogs from '@/components/chat/GroupDialogs'; import { toast } from '@/hooks/use-toast'; -import { createOrGetPreferredDmChat } from '@/services/secureChatService'; +import { + createOrGetPreferredDmChat, + getSecureConversationRecipientId, +} from '@/services/secureChatService'; import SendMoneyModal from '@/components/chat/send-money-modal'; import RequestMoneyModal from '@/components/chat/request-money-modal'; @@ -23,6 +26,7 @@ import CreateContributionModal from '@/components/chat/create-contribution-modal import { getGroupById } from '@/helpers/api'; import AddContactModal from '@/components/chat/add-contact-modal'; import UserProfileModal from '@/components/chat/user-profile-modal'; +import SecureIdentityDialog from '@/components/chat/secure-identity-dialog'; import GroupProfileModal from '@/components/chat/group-profile-modal'; import ContactRequestModal from '@/components/chat/contact-request'; import AddMemberModal from '@/components/chat/add-member-modal'; @@ -81,6 +85,7 @@ export default function ChatPageClean() { const [isCreateContributionModalOpen, setIsCreateContributionModalOpen] = useState(false); const [selectedOutsideMessage, setSelectedOutsideMessage] = useState(null); const [isGroupSettingsModalOpen, setIsGroupSettingsModalOpen] = useState(false); + const [isSecureIdentityDialogOpen, setIsSecureIdentityDialogOpen] = useState(false); const [deleteDialog, setDeleteDialog] = useState<{ isOpen: boolean; groupId: string | null }>({ isOpen: false, groupId: null, @@ -271,6 +276,11 @@ export default function ChatPageClean() { } }; + const secureIdentityContactUserId = + selectedChat && currentUserId && selectedChat.securityMode === 'secure_dm_v1' + ? getSecureConversationRecipientId(selectedChat, currentUserId) + : null; + // Opens the delete confirmation dialog for the active group chat const handleDeleteGroup = () => { if (selectedChat?.isGroup && selectedChat.groupId) { @@ -363,6 +373,7 @@ export default function ChatPageClean() { onInviteToGroup={handleInviteToGroup} onGroupSettings={handleGroupSettings} onDeleteGroup={handleDeleteGroup} + onVerifySecurity={() => setIsSecureIdentityDialogOpen(true)} typingUsers={typingUsers} onlineUsers={onlineUsers} /> @@ -451,6 +462,14 @@ export default function ChatPageClean() { groupId={selectedChat?.groupId || null} /> + + {/* Delete Group confirmation dialog (role-gated in ChatHeader) */} >[number]; + +const formatFingerprint = (fingerprint: string) => + fingerprint + .replace(/[^A-Za-z0-9]/g, "") + .slice(0, 48) + .match(/.{1,4}/g) + ?.join(" ") + .toUpperCase() || fingerprint; + +interface SecureIdentityDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + token: string | null; + contactUserId: string | null; + contactName: string; +} + +export default function SecureIdentityDialog({ + open, + onOpenChange, + token, + contactUserId, + contactName, +}: SecureIdentityDialogProps) { + const { toast } = useToast(); + const [devices, setDevices] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open || !token || !contactUserId) return; + + let cancelled = false; + setIsLoading(true); + setError(null); + + fetchSecureDeviceIdentitySummaries({ token, userId: contactUserId }) + .then((result) => { + if (!cancelled) setDevices(result); + }) + .catch((err: any) => { + if (!cancelled) setError(err?.message || "Unable to load secure identities"); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [open, token, contactUserId]); + + const fullFingerprint = useMemo( + () => devices.map((device) => `${device.deviceId}:${device.fingerprint}`).join("\n"), + [devices], + ); + + const copyFingerprints = async () => { + await navigator.clipboard.writeText(fullFingerprint); + toast({ + title: "Copied", + description: "Security fingerprints copied to clipboard.", + }); + }; + + return ( + + + + + + Verify security + + + +
+
+

{contactName}

+

+ Compare these device fingerprints with your contact through another trusted channel. +

+
+ + {isLoading && ( +
+ + Loading secure devices +
+ )} + + {error && ( +
+ + {error} +
+ )} + + {!isLoading && !error && devices.length === 0 && ( +
+ No secure devices are currently available for this contact. +
+ )} + +
+ {devices.map((device) => ( +
+
+
+

+ {device.deviceName} +

+

+ {device.platform} +

+
+ {device.availableOneTimePreKeys} keys +
+ + {formatFingerprint(device.fingerprint)} + +
+ ))} +
+ + {devices.length > 0 && ( + + )} +
+
+
+ ); +} diff --git a/services/secureChatService.ts b/services/secureChatService.ts index d77cd82..36e74f0 100644 --- a/services/secureChatService.ts +++ b/services/secureChatService.ts @@ -1,7 +1,7 @@ "use client"; import baseUrl from "@/helpers/baseUrl"; -import { assertTrustedDeviceIdentity } from "@/lib/e2ee/identityTrustStore"; +import { assertTrustedDeviceIdentity, getIdentityFingerprint } from "@/lib/e2ee/identityTrustStore"; import { saveStoredSecureDeviceState } from "@/lib/e2ee/deviceStore"; import { decryptSecureEnvelope, encryptSecureTextForRecipients } from "@/lib/e2ee/secureMessageCrypto"; import { ensureRegisteredSecureDevice } from "@/services/e2eeDeviceService"; @@ -71,6 +71,9 @@ const getConversationRecipient = (conversation: Conversation, userId: string) => return recipient.userId; }; +export const getSecureConversationRecipientId = (conversation: Conversation, userId: string) => + getConversationRecipient(conversation, userId); + const fetchPublicDeviceBundles = async (token: string, userId: string) => { const cacheKey = `bundles:${userId}`; const cached = bundleCache.get(cacheKey); @@ -123,6 +126,27 @@ const fetchPublicDeviceBundles = async (token: string, userId: string) => { return devices; }; +export const fetchSecureDeviceIdentitySummaries = async ({ + token, + userId, +}: { + token: string; + userId: string; +}) => { + const devices = await fetchPublicDeviceBundles(token, userId); + + return Promise.all( + devices.map(async (device) => ({ + userId, + deviceId: device.deviceId, + deviceName: device.deviceName || "Secure device", + platform: device.platform || "unknown", + fingerprint: await getIdentityFingerprint(device.bundle.identityPublicKey), + availableOneTimePreKeys: device.oneTimePreKeys.length, + })), + ); +}; + const decryptSecureApiMessage = async ({ rawMessage, state, From 3406f39b58467f53a589a0989c4a8b013cfb1f0b Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Wed, 27 May 2026 12:07:08 +0200 Subject: [PATCH 11/36] block plaintext media in secure chats --- components/chat/chat-area.tsx | 1 + components/chat/message-input.tsx | 23 +++++++- lib/e2ee/secureMediaCrypto.ts | 90 +++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 lib/e2ee/secureMediaCrypto.ts diff --git a/components/chat/chat-area.tsx b/components/chat/chat-area.tsx index 2486ebd..0c3d835 100644 --- a/components/chat/chat-area.tsx +++ b/components/chat/chat-area.tsx @@ -221,6 +221,7 @@ export default function ChatArea({ setReplyToMessage(null)} /> diff --git a/components/chat/message-input.tsx b/components/chat/message-input.tsx index 091e533..f998063 100644 --- a/components/chat/message-input.tsx +++ b/components/chat/message-input.tsx @@ -47,6 +47,7 @@ interface MessageInputProps { chatId?: string /** Pass the group ID when inside a group chat to enable @mentions */ groupId?: string + isSecureChat?: boolean replyToMessage?: ReplyPreview | null onCancelReply?: () => void } @@ -55,6 +56,7 @@ export default function MessageInput({ onSendMessage = () => { }, chatId, groupId, + isSecureChat = false, replyToMessage = null, onCancelReply, }: MessageInputProps) { @@ -378,6 +380,15 @@ export default function MessageInput({ } const handleMediaUpload = async (file: File, caption: string) => { + if (isSecureChat) { + toast({ + title: "Secure media unavailable", + description: "Media encryption is not enabled for secure chats yet.", + variant: "destructive", + }) + return + } + if (!currentChatId) { toast({ title: "Error", description: "No active chat selected", variant: "destructive" }) return @@ -488,7 +499,17 @@ export default function MessageInput({ {/* Media */} +
+ {content && content !== `Sent a ${mediaType}` && ( +

{content}

+ )} +
+ ) + } + // Image rendering if (mediaType === 'image' && mediaUrl) { return ( diff --git a/components/chat/message-input.tsx b/components/chat/message-input.tsx index f998063..65039f2 100644 --- a/components/chat/message-input.tsx +++ b/components/chat/message-input.tsx @@ -11,10 +11,11 @@ import { useChat } from "@/context/ChatContext" import { useTheme } from "@/context/ThemeContext" import MediaUploadModal from "./media-upload-modal" import { uploadMediaMessage } from "@/services/mediaService" +import { sendSecureMediaMessage } from "@/services/secureChatService" import MentionDropdown, { MentionMember } from "./mention-dropdown" import { useSearchGroupMembersQuery } from "@/states/groupSlice" import { useAuthToken } from "@/hooks/use-auth-token" -import type { ReplyPreview } from "@/types/chat.types" +import type { Conversation, ReplyPreview } from "@/types/chat.types" const EmojiPicker = dynamic(() => import("emoji-picker-react"), { ssr: false }) const ALL_MENTION_USER_ID = "__all__" @@ -48,6 +49,7 @@ interface MessageInputProps { /** Pass the group ID when inside a group chat to enable @mentions */ groupId?: string isSecureChat?: boolean + secureConversation?: Conversation replyToMessage?: ReplyPreview | null onCancelReply?: () => void } @@ -57,6 +59,7 @@ export default function MessageInput({ chatId, groupId, isSecureChat = false, + secureConversation, replyToMessage = null, onCancelReply, }: MessageInputProps) { @@ -90,8 +93,9 @@ export default function MessageInput({ const chat = useChat() const { theme } = useTheme() - const { getToken } = useAuthToken() + const { getToken, getUserId } = useAuthToken() const token = getToken() + const userId = getUserId() const isDark = theme === "dark" const { activeChat, sendMessage: contextSendMessage, startTyping, stopTyping, isConnected, addMessage } = chat const currentChatId = chatId || activeChat @@ -381,11 +385,37 @@ export default function MessageInput({ const handleMediaUpload = async (file: File, caption: string) => { if (isSecureChat) { - toast({ - title: "Secure media unavailable", - description: "Media encryption is not enabled for secure chats yet.", - variant: "destructive", - }) + if (!currentChatId || !token || !userId || !secureConversation) { + toast({ + title: "Secure media unavailable", + description: "Your secure session is not ready yet.", + variant: "destructive", + }) + return + } + + setUploading(true); setUploadProgress(10) + try { + const message = await sendSecureMediaMessage({ + token, + userId, + chatId: currentChatId, + conversation: secureConversation, + file, + caption, + }) + if (addMessage) addMessage(message as any) + toast({ title: "Secure media sent", description: "Your encrypted media has been sent successfully" }) + setShowMediaModal(false) + } catch (error: any) { + toast({ + title: "Secure media failed", + description: error?.message || "Failed to send encrypted media", + variant: "destructive", + }) + } finally { + setUploading(false); setUploadProgress(0) + } return } @@ -500,14 +530,6 @@ export default function MessageInput({
) -} \ No newline at end of file +} diff --git a/services/secureChatService.ts b/services/secureChatService.ts index 36e74f0..356abcc 100644 --- a/services/secureChatService.ts +++ b/services/secureChatService.ts @@ -4,6 +4,7 @@ import baseUrl from "@/helpers/baseUrl"; import { assertTrustedDeviceIdentity, getIdentityFingerprint } from "@/lib/e2ee/identityTrustStore"; import { saveStoredSecureDeviceState } from "@/lib/e2ee/deviceStore"; import { decryptSecureEnvelope, encryptSecureTextForRecipients } from "@/lib/e2ee/secureMessageCrypto"; +import { encryptSecureMediaFile } from "@/lib/e2ee/secureMediaCrypto"; import { ensureRegisteredSecureDevice } from "@/services/e2eeDeviceService"; import type { Conversation, Message, ReplyPreview } from "@/types/chat.types"; import type { @@ -164,11 +165,32 @@ const decryptSecureApiMessage = async ({ throw new Error("Unable to resolve the sender secure identity"); } - const content = await decryptSecureEnvelope({ + const decryptedContent = await decryptSecureEnvelope({ envelope, senderIdentityPublicKey: senderDevice.bundle.identityPublicKey, recipientState: state, }); + let content = decryptedContent; + let mediaFields: Partial = {}; + + if (rawMessage.messageType !== "text") { + try { + const mediaPayload = JSON.parse(decryptedContent); + content = mediaPayload.caption || mediaPayload.originalName || "Secure media"; + mediaFields = { + mediaUrl: mediaPayload.mediaUrl, + mediaType: mediaPayload.mediaType, + fileSize: mediaPayload.originalSize, + fileName: mediaPayload.originalName, + mimeType: mediaPayload.originalType, + secureMediaKey: mediaPayload.encryptedKey, + secureMediaIv: mediaPayload.encryptedIv, + isSecureMedia: true, + } as Partial; + } catch { + content = "[Unable to decode secure media metadata]"; + } + } const recipientOneTimePreKeyId = envelope.recipientOneTimePreKeyId || null; if (recipientOneTimePreKeyId) { @@ -186,6 +208,7 @@ const decryptSecureApiMessage = async ({ id: rawMessage.id, chatId: rawMessage.chatId, content, + ...mediaFields, messageType: rawMessage.messageType, replyToMessageId: rawMessage.replyToMessageId || null, replyTo: null as ReplyPreview | null, @@ -380,6 +403,107 @@ export const sendSecureTextMessage = async ({ } satisfies Message; }; +export const sendSecureMediaMessage = async ({ + token, + userId, + chatId, + conversation, + file, + caption, +}: { + token: string; + userId: string; + chatId: string; + conversation: Conversation; + file: File; + caption?: string; +}) => { + const state = await getSecureDeviceState(token, userId); + const encryptedMedia = await encryptSecureMediaFile(file); + const formData = new FormData(); + formData.append("file", encryptedMedia.encryptedFile); + + const uploadResponse = await fetch(`${getApiBaseUrl()}/e2ee/chats/${chatId}/media`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "x-qc-device-id": state.deviceId, + }, + body: formData, + }); + const uploadPayload = await uploadResponse.json().catch(() => null); + + if (!uploadResponse.ok || !uploadPayload?.data?.url) { + throw new Error(uploadPayload?.message || "Failed to upload secure media"); + } + + const recipientUserId = getConversationRecipient(conversation, userId); + const [senderDevices, recipientDevices] = await Promise.all([ + fetchPublicDeviceBundles(token, userId), + fetchPublicDeviceBundles(token, recipientUserId), + ]); + const mediaType = file.type.startsWith("image/") + ? "image" + : file.type.startsWith("video/") + ? "video" + : file.type.startsWith("audio/") + ? "audio" + : "document"; + const encryptedContent = JSON.stringify({ + mediaUrl: uploadPayload.data.url, + mediaType, + encryptedKey: encryptedMedia.key, + encryptedIv: encryptedMedia.iv, + originalName: encryptedMedia.originalName, + originalType: encryptedMedia.originalType, + originalSize: encryptedMedia.originalSize, + caption: caption || "", + }); + const { recipientPayloads } = await encryptSecureTextForRecipients({ + content: encryptedContent, + senderUserId: userId, + senderState: state, + recipientDevices: [...senderDevices, ...recipientDevices], + }); + const messagePayload = await fetchJson(`${getApiBaseUrl()}/e2ee/chats/${chatId}/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "x-qc-device-id": state.deviceId, + }, + body: JSON.stringify({ + messageType: mediaType, + recipientPayloads, + }), + }); + + bundleCache.delete(`bundles:${userId}`); + bundleCache.delete(`bundles:${recipientUserId}`); + + return { + id: messagePayload.data.id, + chatId, + content: caption || encryptedMedia.originalName, + messageType: mediaType, + mediaUrl: uploadPayload.data.url, + mediaType, + fileSize: encryptedMedia.originalSize, + fileName: encryptedMedia.originalName, + mimeType: encryptedMedia.originalType, + secureMediaKey: encryptedMedia.key, + secureMediaIv: encryptedMedia.iv, + isSecureMedia: true, + replyToMessageId: null, + replyTo: null, + reactions: [], + status: messagePayload.data.status || "sent", + createdAt: messagePayload.data.createdAt, + sender: messagePayload.data.sender, + readBy: [], + } satisfies Message; +}; + export const markSecureChatAsRead = async ({ token, userId, diff --git a/types/chat.types.ts b/types/chat.types.ts index 5be6741..8dcf9c7 100644 --- a/types/chat.types.ts +++ b/types/chat.types.ts @@ -88,6 +88,9 @@ export interface MediaData { thumbnailUrl?: string; fileName?: string; mimeType?: string; + secureMediaKey?: string; + secureMediaIv?: string; + isSecureMedia?: boolean; duration?: number; width?: number; height?: number; From 9e25d107fbbc9d8306d599295dc831ddc4ca947f Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Wed, 27 May 2026 12:25:47 +0200 Subject: [PATCH 13/36] preview secure media after client decrypt --- components/chat/media-message-content.tsx | 163 ++++++++++++++++++++-- 1 file changed, 150 insertions(+), 13 deletions(-) diff --git a/components/chat/media-message-content.tsx b/components/chat/media-message-content.tsx index 2534cc1..bc6b61a 100644 --- a/components/chat/media-message-content.tsx +++ b/components/chat/media-message-content.tsx @@ -24,21 +24,61 @@ export default function MediaMessageContent({ secureMediaIv, isSecureMedia }: MediaMessageContentProps) { + const [decryptedMediaUrl, setDecryptedMediaUrl] = useState(null) + const [isDecryptingPreview, setIsDecryptingPreview] = useState(false) + const [previewError, setPreviewError] = useState(null) + + useEffect(() => { + return () => { + if (decryptedMediaUrl) { + window.URL.revokeObjectURL(decryptedMediaUrl) + } + } + }, [decryptedMediaUrl]) + + const fetchMediaBlob = async (url: string) => { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Download failed with status ${response.status}`) + } + + return response.blob() + } + + const decryptMediaBlobIfNeeded = async (blob: Blob) => { + if (!isSecureMedia || !secureMediaKey || !secureMediaIv) { + return blob + } + + return decryptSecureMediaBlob({ + encryptedBlob: blob, + key: secureMediaKey, + iv: secureMediaIv, + originalType: mimeType || "application/octet-stream", + }) + } + + const loadSecurePreview = async () => { + if (!mediaUrl || decryptedMediaUrl || isDecryptingPreview) return + + setIsDecryptingPreview(true) + setPreviewError(null) + try { + const encryptedBlob = await fetchMediaBlob(mediaUrl) + const blob = await decryptMediaBlobIfNeeded(encryptedBlob) + setDecryptedMediaUrl(window.URL.createObjectURL(blob)) + } catch (error) { + console.error("Secure media preview failed:", error) + setPreviewError("Preview unavailable") + } finally { + setIsDecryptingPreview(false) + } + } + const handleDownload = async (url: string, filename: string) => { try { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Download failed with status ${response.status}`); - } - const encryptedBlob = await response.blob(); - const blob = isSecureMedia && secureMediaKey && secureMediaIv - ? await decryptSecureMediaBlob({ - encryptedBlob, - key: secureMediaKey, - iv: secureMediaIv, - originalType: mimeType || "application/octet-stream", - }) - : encryptedBlob; + const encryptedBlob = await fetchMediaBlob(url); + const blob = await decryptMediaBlobIfNeeded(encryptedBlob); const blobUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = blobUrl; @@ -56,6 +96,87 @@ export default function MediaMessageContent({ }; if (isSecureMedia && mediaUrl) { + const canPreview = mediaType === "image" || mediaType === "video" || mediaType === "audio" + + if (decryptedMediaUrl && mediaType === "image") { + return ( +
+
+ {fileName window.open(decryptedMediaUrl, "_blank")} + /> +
+ +
+
+ {content && content !== `Sent a ${mediaType}` && ( +

{content}

+ )} +
+ ) + } + + if (decryptedMediaUrl && mediaType === "video") { + return ( +
+
+ +
+ +
+
+ {content && content !== `Sent a ${mediaType}` && ( +

{content}

+ )} +
+ ) + } + + if (decryptedMediaUrl && mediaType === "audio") { + return ( + handleDownload(mediaUrl, fileName || "secure-audio")} + /> + ) + } + return (
@@ -70,6 +191,19 @@ export default function MediaMessageContent({ {fileSize ? formatFileSize(fileSize) : "Encrypted media"}

+ {canPreview && ( + + )}
) } From 696676ad3af2b06d85196c9f74c2c8f6a0554d53 Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Wed, 27 May 2026 12:32:48 +0200 Subject: [PATCH 14/36] cover secure media crypto smoke flow --- package.json | 3 +- scripts/e2ee-media-crypto-smoke.mjs | 60 +++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 scripts/e2ee-media-crypto-smoke.mjs diff --git a/package.json b/package.json index 4ce2c97..89afbe9 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "check-deps": "npx depcheck", - "test": "echo \"No tests specified yet\" && exit 0", + "test": "npm run test:e2ee-media", + "test:e2ee-media": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types scripts/e2ee-media-crypto-smoke.mjs", "test:watch": "echo \"No tests specified yet\" && exit 0", "pre-commit": "npm run lint:check && npm run type-check && npm run format:check" }, diff --git a/scripts/e2ee-media-crypto-smoke.mjs b/scripts/e2ee-media-crypto-smoke.mjs new file mode 100644 index 0000000..f85f477 --- /dev/null +++ b/scripts/e2ee-media-crypto-smoke.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; + +globalThis.window = { + crypto: globalThis.crypto, +}; + +const { decryptSecureMediaBlob, encryptSecureMediaFile } = await import( + "../lib/e2ee/secureMediaCrypto.ts" +); + +const readText = async (blob) => Buffer.from(await blob.arrayBuffer()).toString("utf8"); +const toBase64Url = (bytes) => + Buffer.from(bytes).toString("base64url"); + +const originalText = "QC secure media smoke payload"; +const originalFile = new File([originalText], "proof.txt", { type: "text/plain" }); +const encrypted = await encryptSecureMediaFile(originalFile); + +assert.equal(encrypted.originalName, "proof.txt"); +assert.equal(encrypted.originalType, "text/plain"); +assert.equal(encrypted.originalSize, originalFile.size); +assert.equal(encrypted.encryptedFile.type, "application/octet-stream"); +assert.notEqual(await readText(encrypted.encryptedFile), originalText); + +const decrypted = await decryptSecureMediaBlob({ + encryptedBlob: encrypted.encryptedFile, + key: encrypted.key, + iv: encrypted.iv, + originalType: encrypted.originalType, +}); + +assert.equal(decrypted.type, "text/plain"); +assert.equal(await readText(decrypted), originalText); + +await assert.rejects( + () => + decryptSecureMediaBlob({ + encryptedBlob: encrypted.encryptedFile, + key: toBase64Url(new Uint8Array(32).fill(7)), + iv: encrypted.iv, + originalType: encrypted.originalType, + }), + /decrypt|operation|data|key/i, +); + +const tamperedBytes = new Uint8Array(await encrypted.encryptedFile.arrayBuffer()); +tamperedBytes[tamperedBytes.length - 1] ^= 1; + +await assert.rejects( + () => + decryptSecureMediaBlob({ + encryptedBlob: new Blob([tamperedBytes], { type: "application/octet-stream" }), + key: encrypted.key, + iv: encrypted.iv, + originalType: encrypted.originalType, + }), + /decrypt|operation|data/i, +); + +console.log("E2EE media crypto smoke checks passed"); From 9ee3a30eea7e125a32562cd7b6c3a0948cc31615 Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Wed, 27 May 2026 12:42:41 +0200 Subject: [PATCH 15/36] refresh secure device key material --- services/e2eeDeviceService.ts | 267 +++++++++++++++++++++++++++------- types/e2ee.types.ts | 1 + 2 files changed, 218 insertions(+), 50 deletions(-) diff --git a/services/e2eeDeviceService.ts b/services/e2eeDeviceService.ts index ca80ba8..ce2bbb6 100644 --- a/services/e2eeDeviceService.ts +++ b/services/e2eeDeviceService.ts @@ -16,7 +16,9 @@ import type { const ACTIVE_APP_VERSION = "web-pwa-v1"; const ONE_TIME_PREKEY_COUNT = 12; +const MIN_ONE_TIME_PREKEY_COUNT = 4; const SYNC_INTERVAL_MS = 12 * 60 * 60 * 1000; +const SIGNED_PREKEY_ROTATION_MS = 7 * 24 * 60 * 60 * 1000; const SUPPORTED_ALGORITHM: SupportedE2EEAlgorithm = "qc-e2ee-p256-v1"; const MAX_DB_SAFE_PREKEY_ID = 2_147_483_646; @@ -54,6 +56,18 @@ const toBase64Url = (buffer: ArrayBuffer) => { const getRandomId = () => Number(window.crypto.getRandomValues(new Uint32Array(1))[0] % MAX_DB_SAFE_PREKEY_ID) + 1; +const getUniqueRandomId = (usedIds: Set) => { + for (let attempt = 0; attempt < 20; attempt += 1) { + const keyId = getRandomId(); + if (!usedIds.has(keyId)) { + usedIds.add(keyId); + return keyId; + } + } + + throw new Error("Unable to generate a unique secure pre-key id"); +}; + const describeCurrentDevice = () => { const nav = window.navigator as Navigator & { userAgentData?: { platform?: string; brands?: Array<{ brand: string; version: string }> }; @@ -118,15 +132,18 @@ const signSignedPreKey = async ( return toBase64Url(signature); }; -const generateOneTimePreKeys = async (): Promise => { +const generateOneTimePreKeys = async ( + count = ONE_TIME_PREKEY_COUNT, + existingIds = new Set(), +): Promise => { const keys: StoredOneTimePreKey[] = []; - for (let index = 0; index < ONE_TIME_PREKEY_COUNT; index += 1) { + for (let index = 0; index < count; index += 1) { const keyPair = await generateExchangeKeyPair(); const exported = await exportPrivatePublicPair(keyPair); keys.push({ - keyId: getRandomId(), + keyId: getUniqueRandomId(existingIds), publicKey: exported.publicKey, privateKey: exported.privateKey, }); @@ -135,13 +152,26 @@ const generateOneTimePreKeys = async (): Promise => { return keys; }; +const createSignedPreKey = async (identityPrivateKey: CryptoKey, usedIds = new Set()) => { + const signedPreKey = await generateExchangeKeyPair(); + const exportedSignedPreKey = await exportPrivatePublicPair(signedPreKey); + const signature = await signSignedPreKey(identityPrivateKey, exportedSignedPreKey.publicKey); + + return { + keyId: getUniqueRandomId(usedIds), + publicKey: exportedSignedPreKey.publicKey, + privateKey: exportedSignedPreKey.privateKey, + signature, + createdAt: new Date().toISOString(), + }; +}; + const createFreshDeviceState = async (userId: string): Promise => { const { deviceName, platform } = describeCurrentDevice(); const identityKeys = await generateSigningKeyPair(); - const signedPreKey = await generateExchangeKeyPair(); const exportedIdentity = await exportPrivatePublicPair(identityKeys); - const exportedSignedPreKey = await exportPrivatePublicPair(signedPreKey); - const signature = await signSignedPreKey(identityKeys.privateKey, exportedSignedPreKey.publicKey); + const usedKeyIds = new Set(); + const signedPreKey = await createSignedPreKey(identityKeys.privateKey, usedKeyIds); return { version: 1, @@ -153,13 +183,8 @@ const createFreshDeviceState = async (userId: string): Promise (preKey) => preKey.keyId > 0 && preKey.keyId <= MAX_DB_SAFE_PREKEY_ID, ); -const shouldResync = (state: StoredSecureDeviceState) => { +const hasStaleSyncMarker = (state: StoredSecureDeviceState) => { if (!state.lastServerSyncAt) return true; const lastSyncAt = new Date(state.lastServerSyncAt).getTime(); @@ -180,6 +205,39 @@ const shouldResync = (state: StoredSecureDeviceState) => { return Date.now() - lastSyncAt > SYNC_INTERVAL_MS; }; +const shouldRotateSignedPreKey = (state: StoredSecureDeviceState) => { + if (!state.signedPreKey.createdAt) return true; + + const createdAt = new Date(state.signedPreKey.createdAt).getTime(); + if (Number.isNaN(createdAt)) return true; + + return Date.now() - createdAt > SIGNED_PREKEY_ROTATION_MS; +}; + +const getRegisteredDeviceSummary = async (token: string, deviceId: string) => { + if (!baseUrl) { + return null; + } + + try { + const response = await fetch(`${baseUrl}/e2ee/devices`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + return null; + } + + const payload = await response.json().catch(() => null); + const devices = Array.isArray(payload?.data) ? payload.data : []; + return devices.find((device: any) => device.deviceId === deviceId) || null; + } catch { + return null; + } +}; + const buildPublicBundlePayload = ( state: StoredSecureDeviceState, ): SecureDeviceBundlePayload => ({ @@ -224,34 +282,119 @@ const registerBundle = async (token: string, state: StoredSecureDeviceState) => } }; -const hasRegisteredBundle = async (token: string, deviceId: string) => { +const rotateServerSignedPreKey = async (token: string, state: StoredSecureDeviceState) => { if (!baseUrl) { - return false; + throw new Error("NEXT_PUBLIC_API_URL is not configured"); } - try { - const response = await fetch(`${baseUrl}/e2ee/devices`, { - headers: { - Authorization: `Bearer ${token}`, + const response = await fetch(`${baseUrl}/e2ee/devices/${state.deviceId}/signed-prekey`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + signedPreKey: { + keyId: state.signedPreKey.keyId, + publicKey: state.signedPreKey.publicKey, + signature: state.signedPreKey.signature, }, + }), + }); + + if (!response.ok) { + const payload = await response.json().catch(() => null); + throw Object.assign(new Error(payload?.message || "Failed to rotate signed pre-key"), { + statusCode: response.status, }); + } +}; - if (!response.ok) { - return false; - } +const uploadAdditionalOneTimePreKeys = async ( + token: string, + state: StoredSecureDeviceState, + preKeys: StoredOneTimePreKey[], +) => { + if (!baseUrl) { + throw new Error("NEXT_PUBLIC_API_URL is not configured"); + } + + const response = await fetch(`${baseUrl}/e2ee/devices/${state.deviceId}/one-time-prekeys`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + oneTimePreKeys: preKeys.map((preKey) => ({ + keyId: preKey.keyId, + publicKey: preKey.publicKey, + })), + }), + }); + if (!response.ok) { const payload = await response.json().catch(() => null); - const devices = Array.isArray(payload?.data) ? payload.data : []; - const matchingDevice = devices.find((device: any) => device.deviceId === deviceId); + throw Object.assign(new Error(payload?.message || "Failed to upload one-time pre-keys"), { + statusCode: response.status, + }); + } +}; - if (!matchingDevice) { - return false; - } +const importIdentityPrivateKey = (privateKey: JsonWebKey) => + window.crypto.subtle.importKey( + "jwk", + privateKey, + { + name: "ECDSA", + namedCurve: "P-256", + }, + false, + ["sign"], + ); - return Boolean(matchingDevice.bundle?.algorithm) && Number(matchingDevice.availableOneTimePreKeys || 0) > 0; - } catch { - return false; +const refreshDeviceKeyMaterial = async ( + token: string, + state: StoredSecureDeviceState, + serverAvailableOneTimePreKeys: number | null, +) => { + let nextState = state; + const usedKeyIds = new Set([ + state.signedPreKey.keyId, + ...state.oneTimePreKeys.map((preKey) => preKey.keyId), + ]); + + if (shouldRotateSignedPreKey(state)) { + const identityPrivateKey = await importIdentityPrivateKey(state.identity.privateKey); + nextState = { + ...nextState, + signedPreKey: await createSignedPreKey(identityPrivateKey, usedKeyIds), + lastServerSyncAt: null, + }; + await rotateServerSignedPreKey(token, nextState); + nextState = { + ...nextState, + lastServerSyncAt: new Date().toISOString(), + }; + await saveStoredSecureDeviceState(nextState); + } + + const availableOneTimePreKeys = + serverAvailableOneTimePreKeys ?? nextState.oneTimePreKeys.length; + + if (availableOneTimePreKeys < MIN_ONE_TIME_PREKEY_COUNT) { + const missingCount = ONE_TIME_PREKEY_COUNT - availableOneTimePreKeys; + const freshPreKeys = await generateOneTimePreKeys(missingCount, usedKeyIds); + nextState = { + ...nextState, + oneTimePreKeys: [...nextState.oneTimePreKeys, ...freshPreKeys], + lastServerSyncAt: new Date().toISOString(), + }; + await uploadAdditionalOneTimePreKeys(token, nextState, freshPreKeys); + await saveStoredSecureDeviceState(nextState); } + + return nextState; }; export const ensureRegisteredSecureDevice = async ({ @@ -280,33 +423,57 @@ export const ensureRegisteredSecureDevice = async ({ await saveStoredSecureDeviceState(state); } - const needsServerSync = shouldResync(state) || !(await hasRegisteredBundle(token, state.deviceId)); + const registeredDevice = await getRegisteredDeviceSummary(token, state.deviceId); - if (!needsServerSync) { - return state; - } + const needsServerSync = + !registeredDevice || + !registeredDevice.bundle?.algorithm || + Number(registeredDevice.availableOneTimePreKeys || 0) <= 0; - try { - await registerBundle(token, state); - const nextState: StoredSecureDeviceState = { - ...state, - lastServerSyncAt: new Date().toISOString(), - }; - await saveStoredSecureDeviceState(nextState); - return nextState; - } catch (error: any) { - if (error?.statusCode === 409) { - const regenerated = await createFreshDeviceState(userId); - await registerBundle(token, regenerated); + if (needsServerSync) { + try { + await registerBundle(token, state); const nextState: StoredSecureDeviceState = { - ...regenerated, + ...state, lastServerSyncAt: new Date().toISOString(), }; await saveStoredSecureDeviceState(nextState); return nextState; + } catch (error: any) { + if (error?.statusCode === 409) { + const regenerated = await createFreshDeviceState(userId); + await registerBundle(token, regenerated); + const nextState: StoredSecureDeviceState = { + ...regenerated, + lastServerSyncAt: new Date().toISOString(), + }; + await saveStoredSecureDeviceState(nextState); + return nextState; + } + + console.error("Secure device bootstrap failed:", error); + return state; } + } + + try { + state = await refreshDeviceKeyMaterial( + token, + state, + Number(registeredDevice.availableOneTimePreKeys || 0), + ); + } catch (error) { + console.error("Secure device key refresh failed:", error); + } - console.error("Secure device bootstrap failed:", error); - return state; + if (hasStaleSyncMarker(state)) { + const nextState = { + ...state, + lastServerSyncAt: new Date().toISOString(), + }; + await saveStoredSecureDeviceState(nextState); + return nextState; } + + return state; }; diff --git a/types/e2ee.types.ts b/types/e2ee.types.ts index 15aa89c..1d4f56a 100644 --- a/types/e2ee.types.ts +++ b/types/e2ee.types.ts @@ -39,6 +39,7 @@ export interface StoredSecureDeviceState { publicKey: JsonWebKey; privateKey: JsonWebKey; signature: string; + createdAt?: string | null; }; oneTimePreKeys: StoredOneTimePreKey[]; lastServerSyncAt?: string | null; From ef6bb358beb2a0c61e7b4a4eccd23979ed0e37af Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Wed, 27 May 2026 16:42:13 +0200 Subject: [PATCH 16/36] manage secure chat devices in settings --- components/settings/SecurityTab.tsx | 182 +++++++++++++++++++--------- hooks/use-security-settings.ts | 99 ++++++++++++++- services/e2eeDeviceService.ts | 62 ++++++++++ 3 files changed, 281 insertions(+), 62 deletions(-) diff --git a/components/settings/SecurityTab.tsx b/components/settings/SecurityTab.tsx index b8fea4a..2429761 100644 --- a/components/settings/SecurityTab.tsx +++ b/components/settings/SecurityTab.tsx @@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Separator } from "@/components/ui/separator"; import { Badge } from "@/components/ui/badge"; -import { Eye, EyeOff, CheckCircle, AlertCircle, Smartphone, Globe, LogOut } from 'lucide-react'; +import { Eye, EyeOff, CheckCircle, AlertCircle, Smartphone, Globe, RotateCw, ShieldOff } from "lucide-react"; import Input from "@/components/ui/Input-ant"; import { Label } from "@/components/ui/label"; import { useSecuritySettings } from "@/hooks/use-security-settings"; @@ -15,10 +15,16 @@ export const SecurityTab: React.FC = () => { pinStatus, loadingPinStatus, changingPin, + secureDevices, + loadingSecureDevices, + revokingSecureDeviceId, + currentSecureDeviceId, securityFormData, updateSecurityFormData, handleChangePassword, handleChangePin, + fetchSecureDevices, + handleRevokeSecureDevice, togglePasswordVisibility, toggleCurrentPinVisibility, toggleNewPinVisibility, @@ -32,11 +38,23 @@ export const SecurityTab: React.FC = () => { await handleChangePin(); }; - const handleNumericInput = (value: string, field: 'currentPin' | 'newPin' | 'confirmNewPin') => { + const handleNumericInput = (value: string, field: "currentPin" | "newPin" | "confirmNewPin") => { const numericValue = value.replace(/\D/g, ""); updateSecurityFormData({ [field]: numericValue }); }; + const formatDate = (value?: string | null) => { + if (!value) return "Never synced"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Unknown"; + return date.toLocaleString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + return (
@@ -79,8 +97,8 @@ export const SecurityTab: React.FC = () => {
Attempts Left: {pinStatus.attemptsLeft}/5 @@ -116,7 +134,7 @@ export const SecurityTab: React.FC = () => { id="current-password" type={securityFormData.showPassword ? "text" : "password"} value={securityFormData.currentPassword} - onChange={(e) => updateSecurityFormData({ currentPassword: e.target.value })} + onChange={(event) => updateSecurityFormData({ currentPassword: event.target.value })} className="dark:bg-darkBg-main dark:text-white dark:border-darkBorder-light" />
@@ -146,7 +164,7 @@ export const SecurityTab: React.FC = () => { id="confirm-password" type="password" value={securityFormData.confirmPassword} - onChange={(e) => updateSecurityFormData({ confirmPassword: e.target.value })} + onChange={(event) => updateSecurityFormData({ confirmPassword: event.target.value })} className="dark:bg-darkBg-main dark:text-white dark:border-darkBorder-light" />
@@ -178,8 +196,8 @@ export const SecurityTab: React.FC = () => { pattern="[0-9]*" maxLength={4} value={securityFormData.currentPin} - onChange={(e) => handleNumericInput(e.target.value, 'currentPin')} - placeholder="••••" + onChange={(event) => handleNumericInput(event.target.value, "currentPin")} + placeholder="****" className="dark:bg-darkBg-main dark:text-white dark:border-darkBorder-light" />
@@ -281,63 +299,107 @@ export const SecurityTab: React.FC = () => { - {/* Login Sessions Card - Full Width */} - Login Sessions - Manage your active sessions across devices +
+
+ Secure Chat Devices + Manage devices allowed to receive end-to-end encrypted messages +
+ +
-
-
-
- -
-

Current device

-

- iPhone 13 • San Francisco, CA • Last active: Just now -

-
-
- - Current - -
- -
-
- -
-

Chrome on Windows

-

New York, NY • Last active: 2 days ago

-
-
- + {loadingSecureDevices ? ( + + ) : secureDevices.length === 0 ? ( +
+ No secure chat device is registered for this account yet.
+ ) : ( +
+ {secureDevices.map((device) => { + const isCurrent = device.deviceId === currentSecureDeviceId; + const isRevoking = revokingSecureDeviceId === device.deviceId; + const isBrowserLike = /web|win|mac|linux|browser|chrome|edge|firefox/i.test( + `${device.platform || ""} ${device.deviceName || ""}`, + ); -
-
- -
-

Android App

-

Chicago, IL • Last active: 5 days ago

-
-
- + return ( +
+
+ {isBrowserLike ? ( + + ) : ( + + )} +
+
+

+ {device.deviceName || "Secure device"} +

+ {isCurrent && ( + + Current + + )} + {!device.isActive && ( + + Revoked + + )} +
+

+ {device.platform || "unknown"} - Last active: {formatDate(device.lastSeenAt)} +

+

+ Signed prekey #{device.bundle?.signedPreKeyId || "n/a"} - {device.availableOneTimePreKeys} one-time keys +

+

+ {device.deviceId} +

+
+
+ +
+ ); + })}
-
+ )} - +

+ Revoked devices cannot receive new secure chat envelopes. Existing local message history on that device is not remotely erased. +

); -}; \ No newline at end of file +}; diff --git a/hooks/use-security-settings.ts b/hooks/use-security-settings.ts index 0a309fa..5916997 100644 --- a/hooks/use-security-settings.ts +++ b/hooks/use-security-settings.ts @@ -2,11 +2,23 @@ import { useState, useEffect, useCallback } from "react"; import { toast } from "@/hooks/use-toast"; import { getPinStatus, changePin } from "@/helpers/api"; import { PinStatus, SecurityFormData } from "@/types/settings.types"; +import { useAuthToken } from "@/hooks/use-auth-token"; +import { + getCurrentSecureDeviceId, + listMySecureDevices, + revokeMySecureDevice, + type SecureDeviceSummary, +} from "@/services/e2eeDeviceService"; export const useSecuritySettings = () => { + const { getToken } = useAuthToken(); const [pinStatus, setPinStatus] = useState(null); const [loadingPinStatus, setLoadingPinStatus] = useState(false); const [changingPin, setChangingPin] = useState(false); + const [secureDevices, setSecureDevices] = useState([]); + const [loadingSecureDevices, setLoadingSecureDevices] = useState(false); + const [revokingSecureDeviceId, setRevokingSecureDeviceId] = useState(null); + const [currentSecureDeviceId, setCurrentSecureDeviceId] = useState(null); const [securityFormData, setSecurityFormData] = useState({ currentPassword: "", @@ -24,6 +36,82 @@ export const useSecuritySettings = () => { setSecurityFormData(prev => ({ ...prev, ...updates })); }; + const fetchSecureDevices = useCallback(async () => { + const token = getToken(); + if (!token) { + setSecureDevices([]); + setCurrentSecureDeviceId(null); + return; + } + + setLoadingSecureDevices(true); + try { + const [devices, currentDeviceId] = await Promise.all([ + listMySecureDevices(token), + getCurrentSecureDeviceId(), + ]); + setSecureDevices(devices); + setCurrentSecureDeviceId(currentDeviceId); + } catch (error: any) { + setSecureDevices([]); + toast({ + title: "Secure devices unavailable", + description: error?.message || "Unable to load secure chat devices.", + variant: "destructive", + }); + } finally { + setLoadingSecureDevices(false); + } + }, [getToken]); + + const handleRevokeSecureDevice = useCallback(async (deviceId: string) => { + const token = getToken(); + if (!token) { + toast({ + title: "Session expired", + description: "Please sign in again before managing secure devices.", + variant: "destructive", + }); + return false; + } + + if (deviceId === currentSecureDeviceId) { + toast({ + title: "Current device protected", + description: "Use another device to revoke this secure chat device.", + variant: "destructive", + }); + return false; + } + + if ( + typeof window !== "undefined" && + !window.confirm("Revoke this secure chat device? It will stop receiving new encrypted messages.") + ) { + return false; + } + + setRevokingSecureDeviceId(deviceId); + try { + await revokeMySecureDevice(token, deviceId); + toast({ + title: "Secure device revoked", + description: "That device can no longer receive new secure chat messages.", + }); + await fetchSecureDevices(); + return true; + } catch (error: any) { + toast({ + title: "Failed to revoke device", + description: error?.message || "Unable to revoke this secure device.", + variant: "destructive", + }); + return false; + } finally { + setRevokingSecureDeviceId(null); + } + }, [currentSecureDeviceId, fetchSecureDevices, getToken]); + const fetchPinStatus = useCallback(async () => { setLoadingPinStatus(true); try { @@ -154,19 +242,26 @@ export const useSecuritySettings = () => { // Fetch PIN status on mount useEffect(() => { fetchPinStatus(); - }, [fetchPinStatus]); + fetchSecureDevices(); + }, [fetchPinStatus, fetchSecureDevices]); return { pinStatus, loadingPinStatus, changingPin, + secureDevices, + loadingSecureDevices, + revokingSecureDeviceId, + currentSecureDeviceId, securityFormData, updateSecurityFormData, fetchPinStatus, + fetchSecureDevices, + handleRevokeSecureDevice, handleChangePassword, handleChangePin, togglePasswordVisibility, toggleCurrentPinVisibility, toggleNewPinVisibility, }; -}; \ No newline at end of file +}; diff --git a/services/e2eeDeviceService.ts b/services/e2eeDeviceService.ts index ce2bbb6..65f5879 100644 --- a/services/e2eeDeviceService.ts +++ b/services/e2eeDeviceService.ts @@ -24,6 +24,24 @@ const MAX_DB_SAFE_PREKEY_ID = 2_147_483_646; const encoder = new TextEncoder(); +export interface SecureDeviceSummary { + id: string; + deviceId: string; + deviceName?: string | null; + platform?: string | null; + appVersion?: string | null; + isActive: boolean; + lastSeenAt?: string | null; + revokedAt?: string | null; + bundle?: { + algorithm: SupportedE2EEAlgorithm; + registrationId: number; + signedPreKeyId: number; + uploadedAt?: string | null; + } | null; + availableOneTimePreKeys: number; +} + const stableStringify = (value: unknown): string => { if (value === null || typeof value !== "object") { return JSON.stringify(value); @@ -238,6 +256,50 @@ const getRegisteredDeviceSummary = async (token: string, deviceId: string) => { } }; +export const listMySecureDevices = async (token: string): Promise => { + if (!baseUrl) { + throw new Error("NEXT_PUBLIC_API_URL is not configured"); + } + + const response = await fetch(`${baseUrl}/e2ee/devices`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const payload = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(payload?.message || "Failed to load secure devices"); + } + + return Array.isArray(payload?.data) ? payload.data : []; +}; + +export const revokeMySecureDevice = async (token: string, deviceId: string) => { + if (!baseUrl) { + throw new Error("NEXT_PUBLIC_API_URL is not configured"); + } + + const response = await fetch(`${baseUrl}/e2ee/devices/${encodeURIComponent(deviceId)}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const payload = await response.json().catch(() => null); + + if (!response.ok) { + throw new Error(payload?.message || "Failed to revoke secure device"); + } + + return payload?.data; +}; + +export const getCurrentSecureDeviceId = async () => { + const state = await getStoredSecureDeviceState(); + return state?.deviceId || null; +}; + const buildPublicBundlePayload = ( state: StoredSecureDeviceState, ): SecureDeviceBundlePayload => ({ From bbb87a557833c61381040b78555d37db850cf51c Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Wed, 27 May 2026 16:45:53 +0200 Subject: [PATCH 17/36] block plaintext reactions in secure chats --- components/chat/message-item.tsx | 10 ++++++++-- context/ChatContext.tsx | 24 ++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/components/chat/message-item.tsx b/components/chat/message-item.tsx index c0b700e..e201f7c 100644 --- a/components/chat/message-item.tsx +++ b/components/chat/message-item.tsx @@ -120,9 +120,13 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { const horizontalLockRef = useRef(false) const gestureActiveRef = useRef(false) - const { addReaction, removeReaction, activeChat } = useChat() + const { addReaction, removeReaction, activeChat, conversations } = useChat() const { getUserId } = useAuthToken() const currentUserId = getUserId() + const activeConversation = activeChat + ? conversations.find((conversation) => conversation.id === activeChat) + : null + const reactionsAllowed = activeConversation?.securityMode !== "secure_dm_v1" // Aggregate raw reaction rows into display format const aggregatedReactions: Reaction[] = useMemo(() => { @@ -342,9 +346,10 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { {timestamp}

- {(!isLegacy && (onReply || true)) && !isTempMessage && ( + {(!isLegacy && (onReply || reactionsAllowed)) && !isTempMessage && (
{/* Reaction trigger */} + {reactionsAllowed && (
+ )} {onReply && (
diff --git a/components/chat/media-message-content.tsx b/components/chat/media-message-content.tsx index 07a5b70..e386c3a 100644 --- a/components/chat/media-message-content.tsx +++ b/components/chat/media-message-content.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button" import { decryptSecureMediaBlob } from "@/lib/e2ee/secureMediaCrypto" import { formatDuration, formatFileSize, getFileIcon } from "@/services/mediaService" import type { MediaData } from "@/types/chat.types" +import { getChatPreviewText } from "@/utils/chatPreview" interface MediaMessageContentProps extends MediaData { content: string @@ -27,6 +28,12 @@ export default function MediaMessageContent({ const [decryptedMediaUrl, setDecryptedMediaUrl] = useState(null) const [isDecryptingPreview, setIsDecryptingPreview] = useState(false) const [previewError, setPreviewError] = useState(null) + const mediaPreviewText = getChatPreviewText({ messageType: mediaType }) + const showCaption = Boolean( + content && + content !== `Sent a ${mediaType}` && + content !== mediaPreviewText + ) useEffect(() => { return () => { @@ -134,7 +141,7 @@ export default function MediaMessageContent({
- {content && content !== `Sent a ${mediaType}` && ( + {showCaption && (

{content}

)} @@ -168,7 +175,7 @@ export default function MediaMessageContent({ - {content && content !== `Sent a ${mediaType}` && ( + {showCaption && (

{content}

)} @@ -196,7 +203,7 @@ export default function MediaMessageContent({

- {fileName || "Secure file"} + {mediaPreviewText || "Secure file"}

{fileSize ? formatFileSize(fileSize) : "Encrypted media"} @@ -226,7 +233,7 @@ export default function MediaMessageContent({

- {content && content !== `Sent a ${mediaType}` && ( + {showCaption && (

{content}

)} {previewError && ( @@ -261,7 +268,7 @@ export default function MediaMessageContent({ - {content && content !== `Sent a ${mediaType}` && ( + {showCaption && (

{content}

)} @@ -296,7 +303,7 @@ export default function MediaMessageContent({ - {content && content !== `Sent a ${mediaType}` && ( + {showCaption && (

{content}

)} {duration && ( @@ -350,7 +357,7 @@ export default function MediaMessageContent({ - {content && content !== `Sent a ${mediaType}` && ( + {showCaption && (

{content}

)} @@ -377,7 +384,7 @@ export default function MediaMessageContent({ - {content && ( + {showCaption && (

{content}

)} @@ -409,6 +416,12 @@ function AudioPlayer({ const [currentTime, setCurrentTime] = useState(0) const [totalDuration, setTotalDuration] = useState(duration || 0) const [isMuted, setIsMuted] = useState(false) + const audioPreviewText = getChatPreviewText({ messageType: mediaType }) + const showAudioCaption = Boolean( + content && + content !== `Sent a ${mediaType}` && + content !== audioPreviewText + ) useEffect(() => { const audio = audioRef.current @@ -519,9 +532,9 @@ function AudioPlayer({ )} - {content && content !== `Sent a ${mediaType}` && ( -

{content}

- )} + {showAudioCaption && ( +

{content}

+ )} ) } diff --git a/context/ChatContext.tsx b/context/ChatContext.tsx index 7c62b0a..1e53ae9 100644 --- a/context/ChatContext.tsx +++ b/context/ChatContext.tsx @@ -17,6 +17,7 @@ import { } from "@/types/chat.types"; import { toast } from "@/hooks/use-toast"; import { notificationService } from "@/services/notificationService"; +import { getChatPreviewText } from "@/utils/chatPreview"; import { fetchSecureChatMessages, markSecureChatAsRead, @@ -66,11 +67,10 @@ interface ChatProviderProps { children: ReactNode; } -type NotificationMessageType = "text" | "image" | "file" | "voice" | "money" | "secure"; +type NotificationMessageType = "text" | "image" | "video" | "audio" | "file" | "voice" | "money" | "secure"; const toNotificationMessageType = (messageType: MessageType): NotificationMessageType => { - if (messageType === "audio") return "voice"; - if (messageType === "video" || messageType === "document") return "file"; + if (messageType === "document") return "file"; return messageType; }; @@ -134,7 +134,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { ? { ...conv, lastMessage: { - content: latestMessage.content, + content: getChatPreviewText(latestMessage), messageType: latestMessage.messageType, createdAt: latestMessage.createdAt, sender: latestMessage.sender.name, @@ -755,7 +755,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { ? { ...conv, lastMessage: { - content: latestMessage.content, + content: getChatPreviewText(latestMessage), messageType: latestMessage.messageType, createdAt: latestMessage.createdAt, sender: latestMessage.sender.name, diff --git a/services/notificationService.ts b/services/notificationService.ts index 850358e..21d0a7f 100644 --- a/services/notificationService.ts +++ b/services/notificationService.ts @@ -1,6 +1,7 @@ import { NotificationType, NotificationPayload, NotificationConfig } from '@/types/notification.types'; import { toast } from '@/hooks/use-toast'; import { soundService, getPrefs } from './soundService'; +import { getChatPreviewText } from '@/utils/chatPreview'; class NotificationService { private config: NotificationConfig = { @@ -92,7 +93,7 @@ class NotificationService { senderId: string; senderName: string; content: string; - messageType: 'text' | 'image' | 'file' | 'voice' | 'money' | 'secure'; + messageType: 'text' | 'image' | 'video' | 'audio' | 'file' | 'voice' | 'money' | 'secure'; }) { if (data.messageType === 'secure') { await this.notify({ @@ -109,6 +110,8 @@ class NotificationService { const typeMap: Record = { text: NotificationType.MESSAGE, image: NotificationType.MEDIA, + video: NotificationType.MEDIA, + audio: NotificationType.VOICE, file: NotificationType.DOCUMENT, voice: NotificationType.VOICE, money: NotificationType.MONEY, @@ -117,6 +120,8 @@ class NotificationService { const titleMap: Record = { text: `New message from ${data.senderName}`, image: `${data.senderName} sent a photo`, + video: `${data.senderName} sent a video`, + audio: `${data.senderName} sent an audio`, file: `${data.senderName} sent a file`, voice: `${data.senderName} sent a voice note`, money: `💰 ${data.senderName} sent you money`, @@ -125,7 +130,7 @@ class NotificationService { await this.notify({ type: typeMap[data.messageType] || NotificationType.MESSAGE, title: titleMap[data.messageType] || `New message from ${data.senderName}`, - message: data.content.substring(0, 100), + message: getChatPreviewText(data).substring(0, 100), url: `${window.location.origin}/chat`, chatId: data.chatId, senderId: data.senderId, diff --git a/services/secureChatService.ts b/services/secureChatService.ts index ad0aad4..1d0ff73 100644 --- a/services/secureChatService.ts +++ b/services/secureChatService.ts @@ -6,6 +6,7 @@ import { decryptSecureEnvelope, encryptSecureTextForRecipients } from "@/lib/e2e import { encryptSecureMediaFile } from "@/lib/e2ee/secureMediaCrypto"; import { ensureRegisteredSecureDevice } from "@/services/e2eeDeviceService"; import type { Conversation, Message, ReplyPreview } from "@/types/chat.types"; +import { getChatPreviewText } from "@/utils/chatPreview"; import type { PublicSecureDeviceBundle, SecureEncryptedEnvelope, @@ -203,7 +204,9 @@ const decryptSecureApiMessage = async ({ if (rawMessage.messageType !== "text") { try { const mediaPayload = JSON.parse(decryptedContent); - content = mediaPayload.caption || mediaPayload.originalName || "Secure media"; + content = + mediaPayload.caption || + getChatPreviewText({ messageType: mediaPayload.mediaType || rawMessage.messageType }); mediaFields = { mediaUrl: mediaPayload.mediaUrl, mediaType: mediaPayload.mediaType, @@ -555,7 +558,7 @@ export const sendSecureMediaMessage = async ({ return { id: messagePayload.data.id, chatId, - content: caption || encryptedMedia.originalName, + content: caption || getChatPreviewText({ messageType: mediaType }), messageType: mediaType, mediaUrl: uploadPayload.data.url, mediaType, diff --git a/utils/chatPreview.ts b/utils/chatPreview.ts new file mode 100644 index 0000000..2b98338 --- /dev/null +++ b/utils/chatPreview.ts @@ -0,0 +1,30 @@ +import type { MessageType } from "@/types/chat.types"; + +const mediaPreviewLabels: Partial> = { + image: "Image", + video: "Video", + audio: "Audio", + document: "File", + file: "File", +}; + +export const isMediaPreviewType = (messageType?: string | null) => + messageType === "image" || + messageType === "video" || + messageType === "audio" || + messageType === "document" || + messageType === "file"; + +export const getChatPreviewText = ({ + content, + messageType, +}: { + content?: string | null; + messageType?: MessageType | string | null; +}) => { + if (isMediaPreviewType(messageType)) { + return mediaPreviewLabels[messageType as MessageType] || "File"; + } + + return content || ""; +}; From 9f2b1705430eb1ac99adbdbc291dee9e80365208 Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Thu, 28 May 2026 14:43:58 +0200 Subject: [PATCH 26/36] Compact chat message bubbles --- components/chat/message-item.tsx | 78 +++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 21 deletions(-) diff --git a/components/chat/message-item.tsx b/components/chat/message-item.tsx index 1cd09b3..4b9427d 100644 --- a/components/chat/message-item.tsx +++ b/components/chat/message-item.tsx @@ -8,7 +8,7 @@ import { GroupContributionCard } from "./group-contribution-card" import MessageText from "./message-text" import LinkPreviewCard from "./link-preview-card" import { extractUrls } from "@/utils/url-utils" -import { Reply, Smile } from "lucide-react" +import { AlertCircle, Check, CheckCheck, Clock, Reply, Smile } from "lucide-react" import { Button } from "@/components/ui/button" import ReactionPicker from "./reaction-picker" import { useChat } from "@/context/ChatContext" @@ -120,9 +120,13 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { const horizontalLockRef = useRef(false) const gestureActiveRef = useRef(false) - const { addReaction, removeReaction, activeChat } = useChat() + const { addReaction, removeReaction, activeChat, conversations } = useChat() const { getUserId } = useAuthToken() const currentUserId = getUserId() + const activeConversation = activeChat + ? conversations.find((conversation) => conversation.id === activeChat) + : null + const shouldShowSenderName = Boolean(activeConversation?.isGroup && !isMe) const reactionsAllowed = true // Aggregate raw reaction rows into display format @@ -169,6 +173,21 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { const isTempMessage = !isLegacy && (message as Message).id.startsWith('temp_') const canSwipeReply = !isLegacy && !!onReply && !isTempMessage + const getInitials = (name: string) => { + const parts = name.trim().split(/\s+/).filter(Boolean) + const initials = parts.slice(0, 2).map((part) => part[0]?.toUpperCase()).join("") + return initials || "U" + } + + const StatusIcon = (() => { + if (!isMe || isLegacy) return null + const msg = message as Message + if (isTempMessage) return Clock + if (msg.status === "read") return CheckCheck + if (msg.status === "delivered") return CheckCheck + if (msg.status === "sent") return Check + return AlertCircle + })() const handleTouchStart = (e: React.TouchEvent) => { if (!canSwipeReply) return @@ -238,15 +257,17 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { } return ( -
+
{!isMe && ( - - - {senderDisplayName.charAt(0).toUpperCase()} + + {avatar && } + + {getInitials(senderDisplayName)} + )} -
+
{!isLegacy && onReply && (
- {!isMe &&

{senderDisplayName}

} + {shouldShowSenderName &&

{senderDisplayName}

} {replyTo && (
)} -

- {timestamp} -

+
+ {timestamp} + {StatusIcon && ( + + )} +
{(!isLegacy && (onReply || reactionsAllowed)) && !isTempMessage && ( -
+
{/* Reaction trigger */} {reactionsAllowed && (
@@ -354,14 +388,14 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { size="sm" onClick={() => setShowReactionPicker((v) => !v)} className={cn( - "h-6 w-6 p-0", + "h-5 w-5 p-0", isMe ? "text-white/70 hover:text-white hover:bg-white/15" : "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300" )} aria-label="Add reaction" > - + {showReactionPicker && ( - - Reply + )}
@@ -441,9 +475,11 @@ export default function MessageItem({ message, onReply }: MessageItemProps) {
{isMe && ( - - - Y + + {avatar && } + + {getInitials(senderDisplayName || "You")} + )}
From f4c0ad4906e5e0c850575ae629b164f47e1d0b01 Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Fri, 29 May 2026 09:46:26 +0200 Subject: [PATCH 27/36] Tighten DM message layout --- components/chat/message-item.tsx | 39 +++++++++++++++----------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/components/chat/message-item.tsx b/components/chat/message-item.tsx index 4b9427d..7a192a8 100644 --- a/components/chat/message-item.tsx +++ b/components/chat/message-item.tsx @@ -8,7 +8,7 @@ import { GroupContributionCard } from "./group-contribution-card" import MessageText from "./message-text" import LinkPreviewCard from "./link-preview-card" import { extractUrls } from "@/utils/url-utils" -import { AlertCircle, Check, CheckCheck, Clock, Reply, Smile } from "lucide-react" +import { Check, CheckCheck, Clock, Reply, Smile } from "lucide-react" import { Button } from "@/components/ui/button" import ReactionPicker from "./reaction-picker" import { useChat } from "@/context/ChatContext" @@ -127,6 +127,7 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { ? conversations.find((conversation) => conversation.id === activeChat) : null const shouldShowSenderName = Boolean(activeConversation?.isGroup && !isMe) + const shouldShowIncomingAvatar = Boolean(activeConversation?.isGroup && !isMe) const reactionsAllowed = true // Aggregate raw reaction rows into display format @@ -179,15 +180,23 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { return initials || "U" } + const messageStatus = !isLegacy ? (message as Message).status : undefined const StatusIcon = (() => { if (!isMe || isLegacy) return null - const msg = message as Message if (isTempMessage) return Clock - if (msg.status === "read") return CheckCheck - if (msg.status === "delivered") return CheckCheck - if (msg.status === "sent") return Check - return AlertCircle + if (messageStatus === "read") return CheckCheck + if (messageStatus === "delivered") return CheckCheck + return Check })() + const statusTone = isTempMessage + ? "text-white/70 dark:text-darkBg-main/70" + : messageStatus === "read" + ? "text-emerald-300 dark:text-emerald-700" + : messageStatus === "delivered" + ? "text-white/75 dark:text-darkBg-main/70" + : messageStatus === "sent" + ? "text-white/75 dark:text-darkBg-main/70" + : "text-white/75 dark:text-darkBg-main/70" const handleTouchStart = (e: React.TouchEvent) => { if (!canSwipeReply) return @@ -257,8 +266,8 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { } return ( -
- {!isMe && ( +
+ {shouldShowIncomingAvatar && ( {avatar && } @@ -365,11 +374,7 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { {StatusIcon && ( )}
@@ -474,14 +479,6 @@ export default function MessageItem({ message, onReply }: MessageItemProps) {
- {isMe && ( - - {avatar && } - - {getInitials(senderDisplayName || "You")} - - - )}
) } From e10183997aefb9dc2d6bbf8084a9e3745572fd86 Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Fri, 29 May 2026 10:01:08 +0200 Subject: [PATCH 28/36] Fix DM spacing and message status indicators --- components/chat/chat-area.tsx | 2 +- components/chat/conversation-item.tsx | 18 ++++++++++----- components/chat/message-item.tsx | 33 +++++++++++++++++---------- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/components/chat/chat-area.tsx b/components/chat/chat-area.tsx index 4bdf4ee..4352f26 100644 --- a/components/chat/chat-area.tsx +++ b/components/chat/chat-area.tsx @@ -165,7 +165,7 @@ export default function ChatArea({ return (
{showSep && } -
+
setReplyToMessage(reply)} diff --git a/components/chat/conversation-item.tsx b/components/chat/conversation-item.tsx index 03015d5..ea91b25 100644 --- a/components/chat/conversation-item.tsx +++ b/components/chat/conversation-item.tsx @@ -21,6 +21,10 @@ export default function ConversationItem({ }: ConversationItemProps) { const hasUnread = (conversation.unreadCount || 0) > 0; const isSupport = conversation.type === 'support'; + const getInitials = (name?: string) => { + const parts = (name || 'User').trim().split(/\s+/).filter(Boolean); + return parts.slice(0, 2).map((part) => part[0]?.toUpperCase()).join('') || 'U'; + }; const lastMessageType = conversation.lastMessage?.messageType; const lastMessagePreview = getChatPreviewText({ content: conversation.lastMessage?.content, @@ -64,12 +68,14 @@ export default function ConversationItem({ ) : (
- - - {(conversation.name || 'U').charAt(0).toUpperCase()} + {conversation.avatar && ( + + )} + + {getInitials(conversation.name)} {conversation.isOnline && ( diff --git a/components/chat/message-item.tsx b/components/chat/message-item.tsx index 7a192a8..cd02b54 100644 --- a/components/chat/message-item.tsx +++ b/components/chat/message-item.tsx @@ -181,22 +181,31 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { } const messageStatus = !isLegacy ? (message as Message).status : undefined + const recipientIds = activeConversation?.participants + ?.map((participant) => participant.userId) + .filter((participantId) => participantId && participantId !== currentUserId) || [] + const hasReadProof = !isLegacy && Boolean( + (message as Message).readAt || + (message as Message).readBy?.some((receipt) => recipientIds.includes(receipt.userId)) + ) + const hasDeliveryProof = !isLegacy && Boolean((message as Message).deliveredAt || messageStatus === "delivered") + const visualStatus = isTempMessage + ? "pending" + : hasReadProof + ? "read" + : hasDeliveryProof + ? "delivered" + : "sent" const StatusIcon = (() => { if (!isMe || isLegacy) return null - if (isTempMessage) return Clock - if (messageStatus === "read") return CheckCheck - if (messageStatus === "delivered") return CheckCheck + if (visualStatus === "pending") return Clock + if (visualStatus === "read") return CheckCheck + if (visualStatus === "delivered") return CheckCheck return Check })() - const statusTone = isTempMessage - ? "text-white/70 dark:text-darkBg-main/70" - : messageStatus === "read" - ? "text-emerald-300 dark:text-emerald-700" - : messageStatus === "delivered" - ? "text-white/75 dark:text-darkBg-main/70" - : messageStatus === "sent" - ? "text-white/75 dark:text-darkBg-main/70" - : "text-white/75 dark:text-darkBg-main/70" + const statusTone = visualStatus === "read" + ? "text-emerald-600 dark:text-emerald-700" + : "text-gray-500 dark:text-darkBg-main/70" const handleTouchStart = (e: React.TouchEvent) => { if (!canSwipeReply) return From 40177ecd1c860890136e568b17b32f67bd61e1a7 Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Fri, 29 May 2026 10:20:50 +0200 Subject: [PATCH 29/36] Use strict chat receipt indicators --- components/chat/chat-header.tsx | 10 ++++++---- components/chat/message-item.tsx | 3 +-- context/ChatContext.tsx | 14 +++++++++++--- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/components/chat/chat-header.tsx b/components/chat/chat-header.tsx index 27071dd..fd27862 100644 --- a/components/chat/chat-header.tsx +++ b/components/chat/chat-header.tsx @@ -116,10 +116,12 @@ export default function ChatHeader({
- + {conversation.avatar && ( + + )} {getInitials(conversation.name)} diff --git a/components/chat/message-item.tsx b/components/chat/message-item.tsx index cd02b54..33035e1 100644 --- a/components/chat/message-item.tsx +++ b/components/chat/message-item.tsx @@ -185,10 +185,9 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { ?.map((participant) => participant.userId) .filter((participantId) => participantId && participantId !== currentUserId) || [] const hasReadProof = !isLegacy && Boolean( - (message as Message).readAt || (message as Message).readBy?.some((receipt) => recipientIds.includes(receipt.userId)) ) - const hasDeliveryProof = !isLegacy && Boolean((message as Message).deliveredAt || messageStatus === "delivered") + const hasDeliveryProof = !isLegacy && Boolean((message as any).deliveryConfirmed || hasReadProof) const visualStatus = isTempMessage ? "pending" : hasReadProof diff --git a/context/ChatContext.tsx b/context/ChatContext.tsx index 1e53ae9..5b2d558 100644 --- a/context/ChatContext.tsx +++ b/context/ChatContext.tsx @@ -412,7 +412,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { ...prev, [data.chatId]: prev[data.chatId]?.map((msg: Message) => msg.id === data.messageId - ? { ...msg, status: 'delivered', deliveredAt: data.deliveredAt } + ? { ...msg, status: 'delivered', deliveredAt: data.deliveredAt, deliveryConfirmed: true } : msg ) || [] })); @@ -423,8 +423,16 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { setMessages((prev: Record) => ({ ...prev, [data.chatId]: prev[data.chatId]?.map((msg: Message) => - msg.status === 'delivered' && msg.sender.id !== userId - ? { ...msg, status: 'read', readAt: data.readAt } + msg.sender.id === userId && data.readBy !== userId + ? { + ...msg, + status: 'read', + readAt: data.readAt, + readBy: [ + ...(msg.readBy || []).filter((receipt: any) => receipt.userId !== data.readBy), + { userId: data.readBy, name: '', readAt: data.readAt }, + ], + } : msg ) || [] })); From 5193a76bf96029ae87b9ef3d32e656f5b99ff97c Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Fri, 29 May 2026 10:56:07 +0200 Subject: [PATCH 30/36] Use secure receipt proofs in chat UI --- components/chat/chat-header.tsx | 12 ++---------- components/chat/conversation-item.tsx | 7 ++----- services/secureChatService.ts | 6 ++++-- types/chat.types.ts | 1 + utils/avatar.ts | 9 +++++++++ 5 files changed, 18 insertions(+), 17 deletions(-) create mode 100644 utils/avatar.ts diff --git a/components/chat/chat-header.tsx b/components/chat/chat-header.tsx index fd27862..a41b882 100644 --- a/components/chat/chat-header.tsx +++ b/components/chat/chat-header.tsx @@ -17,6 +17,7 @@ import { useGetGroupByIdQuery } from '@/states/groupSlice'; import { useAuthToken } from '@/hooks/use-auth-token'; import FundraisingProgressBadge from './fundraising-progress-badge'; import { socketService } from '@/services/socketService'; +import { getInitials, isPlaceholderAvatar } from '@/utils/avatar'; import { ArrowLeft, Info, @@ -90,15 +91,6 @@ export default function ChatHeader({ return conversation.isOnline ? 'Online' : 'Last seen recently'; }; - const getInitials = (name: string | undefined) => { - if (!name) return 'U'; - return name - .split(' ') - .map(word => word[0]) - .join('') - .toUpperCase(); - }; - return (
{/* Back Button - Mobile Only */} @@ -116,7 +108,7 @@ export default function ChatHeader({
- {conversation.avatar && ( + {!isPlaceholderAvatar(conversation.avatar) && ( 0; const isSupport = conversation.type === 'support'; - const getInitials = (name?: string) => { - const parts = (name || 'User').trim().split(/\s+/).filter(Boolean); - return parts.slice(0, 2).map((part) => part[0]?.toUpperCase()).join('') || 'U'; - }; const lastMessageType = conversation.lastMessage?.messageType; const lastMessagePreview = getChatPreviewText({ content: conversation.lastMessage?.content, @@ -68,7 +65,7 @@ export default function ConversationItem({ ) : (
- {conversation.avatar && ( + {!isPlaceholderAvatar(conversation.avatar) && ( { + if (!src) return true; + return src.includes("/placeholder.svg") || src.includes("placeholder.svg"); +}; + +export const getInitials = (name?: string | null) => { + const parts = (name || "User").trim().split(/\s+/).filter(Boolean); + return parts.slice(0, 2).map((part) => part[0]?.toUpperCase()).join("") || "U"; +}; From ce7448438682eaa561fde0b734dcaba1598f558d Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Fri, 29 May 2026 11:36:41 +0200 Subject: [PATCH 31/36] Stabilize secure chat receipts UI --- components/chat/conversation-item.tsx | 37 +++++++++++++++++++++----- components/chat/message-item.tsx | 5 +--- context/ChatContext.tsx | 38 ++++++++++++++++++++++++--- types/chat.types.ts | 5 ++++ 4 files changed, 71 insertions(+), 14 deletions(-) diff --git a/components/chat/conversation-item.tsx b/components/chat/conversation-item.tsx index 26a58b7..a202e62 100644 --- a/components/chat/conversation-item.tsx +++ b/components/chat/conversation-item.tsx @@ -2,12 +2,13 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; -import { FileText, Headphones, Image as ImageIcon, Music, Users, Video } from 'lucide-react'; +import { Check, CheckCheck, FileText, Headphones, Image as ImageIcon, Music, Users, Video } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { Conversation } from '@/types/chat.types'; import { formatTimestampWithoutSeconds } from '@/utils/timeUtils'; import { getChatPreviewText } from '@/utils/chatPreview'; import { getInitials, isPlaceholderAvatar } from '@/utils/avatar'; +import { useAuthToken } from '@/hooks/use-auth-token'; interface ConversationItemProps { conversation: Conversation; @@ -20,6 +21,8 @@ export default function ConversationItem({ isActive, onClick, }: ConversationItemProps) { + const { getUserId } = useAuthToken(); + const currentUserId = getUserId(); const hasUnread = (conversation.unreadCount || 0) > 0; const isSupport = conversation.type === 'support'; const lastMessageType = conversation.lastMessage?.messageType; @@ -37,11 +40,23 @@ export default function ConversationItem({ : lastMessageType === 'document' || lastMessageType === 'file' ? FileText : null; + const isOutgoingPreview = conversation.lastMessage?.sender === 'You'; + const isReadPreview = Boolean( + conversation.lastMessage?.readBy?.some((receipt) => receipt.userId && receipt.userId !== currentUserId) + ); + const isDeliveredPreview = Boolean( + conversation.lastMessage?.deliveryConfirmed || conversation.lastMessage?.deliveredAt || isReadPreview + ); + const PreviewStatusIcon = isOutgoingPreview + ? isReadPreview || isDeliveredPreview + ? CheckCheck + : Check + : null; return (
) : (
- + {!isPlaceholderAvatar(conversation.avatar) && (

{conversation.name || 'Unknown Contact'} @@ -101,17 +116,25 @@ export default function ConversationItem({ {conversation.unreadCount} )} - + {(formatTimestampWithoutSeconds(conversation.timestamp)) || ''}

-
+

+ {PreviewStatusIcon && ( + + )} {conversation.isGroup && conversation.memberCount && ( {conversation.isOnline ? 1 : 0}/{conversation.memberCount} diff --git a/components/chat/message-item.tsx b/components/chat/message-item.tsx index 33035e1..196cf6d 100644 --- a/components/chat/message-item.tsx +++ b/components/chat/message-item.tsx @@ -181,11 +181,8 @@ export default function MessageItem({ message, onReply }: MessageItemProps) { } const messageStatus = !isLegacy ? (message as Message).status : undefined - const recipientIds = activeConversation?.participants - ?.map((participant) => participant.userId) - .filter((participantId) => participantId && participantId !== currentUserId) || [] const hasReadProof = !isLegacy && Boolean( - (message as Message).readBy?.some((receipt) => recipientIds.includes(receipt.userId)) + (message as Message).readBy?.some((receipt) => receipt.userId && receipt.userId !== currentUserId) ) const hasDeliveryProof = !isLegacy && Boolean((message as any).deliveryConfirmed || hasReadProof) const visualStatus = isTempMessage diff --git a/context/ChatContext.tsx b/context/ChatContext.tsx index 5b2d558..47d088a 100644 --- a/context/ChatContext.tsx +++ b/context/ChatContext.tsx @@ -137,7 +137,12 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { content: getChatPreviewText(latestMessage), messageType: latestMessage.messageType, createdAt: latestMessage.createdAt, - sender: latestMessage.sender.name, + sender: latestMessage.sender.id === userId ? "You" : latestMessage.sender.name, + status: latestMessage.status, + deliveredAt: latestMessage.deliveredAt, + readAt: latestMessage.readAt, + readBy: latestMessage.readBy, + deliveryConfirmed: latestMessage.deliveryConfirmed, }, timestamp: latestMessage.createdAt, } @@ -416,6 +421,9 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { : msg ) || [] })); + void updateSecureConversationPreview(data.chatId).catch((error) => { + console.error("Failed to update delivered secure preview", error); + }); }; const handleMessagesRead = (data: { chatId: string; readBy: string; readAt: Date }) => { @@ -446,6 +454,9 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { : conv )); } + void updateSecureConversationPreview(data.chatId).catch((error) => { + console.error("Failed to update read secure preview", error); + }); }; const handleUserTyping = (data: TypingUser) => { @@ -766,7 +777,12 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { content: getChatPreviewText(latestMessage), messageType: latestMessage.messageType, createdAt: latestMessage.createdAt, - sender: latestMessage.sender.name, + sender: latestMessage.sender.id === userId ? "You" : latestMessage.sender.name, + status: latestMessage.status, + deliveredAt: latestMessage.deliveredAt, + readAt: latestMessage.readAt, + readBy: latestMessage.readBy, + deliveryConfirmed: latestMessage.deliveryConfirmed, }, timestamp: latestMessage.createdAt, } @@ -809,6 +825,22 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { return; } + const securePreviewTargets = conversations.filter( + (conversation) => + conversation.securityMode === "secure_dm_v1" && + ( + !conversation.lastMessage || + conversation.lastMessage.content === "Secure message" || + conversation.lastMessage.content.startsWith("[Unable to decrypt") + ), + ); + + securePreviewTargets.forEach((conversation) => { + void updateSecureConversationPreview(conversation.id).catch((error) => { + console.error("Failed to update secure conversation preview", error); + }); + }); + const hasSecureConversations = conversations.some( (conversation) => conversation.securityMode === "secure_dm_v1", ); @@ -824,7 +856,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => { return () => { window.clearInterval(interval); }; - }, [token, userId, conversations, refetchChats]); + }, [token, userId, conversations, refetchChats, updateSecureConversationPreview]); const refreshConversations = useCallback(() => { refetchChats(); diff --git a/types/chat.types.ts b/types/chat.types.ts index 7c9b126..030aad8 100644 --- a/types/chat.types.ts +++ b/types/chat.types.ts @@ -25,6 +25,11 @@ export interface LastMessage { content: string; messageType: MessageType; createdAt: string; + status?: MessageStatus; + deliveredAt?: Date | string | null; + readAt?: Date | string | null; + readBy?: ReadReceipt[]; + deliveryConfirmed?: boolean; sender: | string | { From b43c2552f89ce0599d8a8fa059b5b52f74ca9275 Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Mon, 20 Jul 2026 20:27:47 +0200 Subject: [PATCH 32/36] Stabilize secure chat retries and switching --- hooks/use-chat-operations.ts | 10 +-- lib/e2ee/secureMessageCrypto.ts | 13 ++- services/secureChatService.ts | 140 ++++++++++++++++++++++---------- 3 files changed, 113 insertions(+), 50 deletions(-) diff --git a/hooks/use-chat-operations.ts b/hooks/use-chat-operations.ts index 29a36ae..b417a4b 100644 --- a/hooks/use-chat-operations.ts +++ b/hooks/use-chat-operations.ts @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback, useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useAuthToken } from '@/hooks/use-auth-token'; import { useChat } from '@/context/ChatContext'; import { @@ -158,13 +158,7 @@ export function useChatOperations(): UseChatOperationsReturn { useJoinGroupChatMutation(); const [deleteChat] = useDeleteChatMutation(); - const [activeChat, setActiveChat] = useState( - contextActiveChat - ); - - useEffect(() => { - setActiveChat(contextActiveChat); - }, [contextActiveChat]); + const activeChat = contextActiveChat; useEffect(() => { if (isConnected && initializeEncryption) { diff --git a/lib/e2ee/secureMessageCrypto.ts b/lib/e2ee/secureMessageCrypto.ts index 0468f4b..7c42247 100644 --- a/lib/e2ee/secureMessageCrypto.ts +++ b/lib/e2ee/secureMessageCrypto.ts @@ -192,6 +192,15 @@ const verifySignedPreKey = async (bundle: PublicSecureDeviceBundle) => { ); }; +const pickOneTimePreKey = (preKeys: PublicSecureDeviceBundle["oneTimePreKeys"]) => { + if (!preKeys.length) { + return null; + } + + const randomIndex = window.crypto.getRandomValues(new Uint32Array(1))[0] % preKeys.length; + return preKeys[randomIndex]; +}; + export const encryptSecureTextForRecipients = async ({ content, senderUserId, @@ -243,7 +252,9 @@ export const encryptSecureTextForRecipients = async ({ ["deriveBits"], ); const selectedOneTimePreKey = - recipientDevice.userId === senderUserId ? null : recipientDevice.oneTimePreKeys[0] || null; + recipientDevice.userId === senderUserId + ? null + : pickOneTimePreKey(recipientDevice.oneTimePreKeys); const recipientPublicKey = await importExchangePublicKey( selectedOneTimePreKey?.publicKey || recipientDevice.bundle.signedPreKeyPublic, ); diff --git a/services/secureChatService.ts b/services/secureChatService.ts index 05494c0..d4fccca 100644 --- a/services/secureChatService.ts +++ b/services/secureChatService.ts @@ -15,6 +15,11 @@ import type { const bundleCache = new Map(); const BUNDLE_CACHE_TTL_MS = 60 * 1000; +const STALE_PREKEY_RETRY_STEPS = [ + { delayMs: 0, forceRefresh: false, useOneTimePreKeys: true }, + { delayMs: 250, forceRefresh: true, useOneTimePreKeys: true }, + { delayMs: 750, forceRefresh: true, useOneTimePreKeys: false }, +]; const isValidBase64UrlCoordinate = (value: unknown) => typeof value === "string" && value.length >= 43 && value.length <= 44; @@ -155,6 +160,55 @@ const isUnavailableOneTimePreKeyError = (error: unknown) => (error.message.includes("unavailable one-time pre-key") || error.message.includes("already consumed")); +const wait = (delayMs: number) => + new Promise((resolve) => { + window.setTimeout(resolve, delayMs); + }); + +const withoutOneTimePreKeys = (devices: PublicSecureDeviceBundle[]) => + devices.map((device) => ({ + ...device, + oneTimePreKeys: [], + })); + +const sendWithStalePreKeyRetry = async ({ + attempt, + cacheKeys, +}: { + attempt: (options: { forceRefresh: boolean; useOneTimePreKeys: boolean }) => Promise; + cacheKeys: string[]; +}) => { + let lastError: unknown; + + for (const step of STALE_PREKEY_RETRY_STEPS) { + if (step.forceRefresh) { + for (const cacheKey of cacheKeys) { + bundleCache.delete(cacheKey); + } + } + + if (step.delayMs > 0) { + await wait(step.delayMs); + } + + try { + return await attempt({ + forceRefresh: step.forceRefresh, + useOneTimePreKeys: step.useOneTimePreKeys, + }); + } catch (error) { + if (!isUnavailableOneTimePreKeyError(error)) { + throw error; + } + lastError = error; + } + } + + throw lastError instanceof Error + ? lastError + : new Error("Secure chat request failed after refreshing device bundles"); +}; + export const fetchSecureDeviceIdentitySummaries = async ({ token, userId, @@ -404,17 +458,26 @@ export const sendSecureTextMessage = async ({ const state = await getSecureDeviceState(token, userId); const recipientUserId = getConversationRecipient(conversation, userId); - const sendAttempt = async (forceRefresh: boolean) => { + const sendAttempt = async ({ + forceRefresh, + useOneTimePreKeys, + }: { + forceRefresh: boolean; + useOneTimePreKeys: boolean; + }) => { const [senderDevices, recipientDevices] = await Promise.all([ fetchPublicDeviceBundles(token, userId, { forceRefresh }), fetchPublicDeviceBundles(token, recipientUserId, { forceRefresh }), ]); + const targetDevices = useOneTimePreKeys + ? [...senderDevices, ...recipientDevices] + : withoutOneTimePreKeys([...senderDevices, ...recipientDevices]); const { recipientPayloads } = await encryptSecureTextForRecipients({ content, senderUserId: userId, senderState: state, - recipientDevices: [...senderDevices, ...recipientDevices], + recipientDevices: targetDevices, }); return fetchJson(`${getApiBaseUrl()}/e2ee/chats/${chatId}/messages`, { @@ -432,18 +495,10 @@ export const sendSecureTextMessage = async ({ }); }; - let payload: any; - try { - payload = await sendAttempt(false); - } catch (error) { - if (!isUnavailableOneTimePreKeyError(error)) { - throw error; - } - - bundleCache.delete(`bundles:${userId}`); - bundleCache.delete(`bundles:${recipientUserId}`); - payload = await sendAttempt(true); - } + const payload = await sendWithStalePreKeyRetry({ + attempt: sendAttempt, + cacheKeys: [`bundles:${userId}`, `bundles:${recipientUserId}`], + }); bundleCache.delete(`bundles:${userId}`); bundleCache.delete(`bundles:${recipientUserId}`); @@ -515,16 +570,25 @@ export const sendSecureMediaMessage = async ({ originalSize: encryptedMedia.originalSize, caption: caption || "", }); - const sendAttempt = async (forceRefresh: boolean) => { + const sendAttempt = async ({ + forceRefresh, + useOneTimePreKeys, + }: { + forceRefresh: boolean; + useOneTimePreKeys: boolean; + }) => { const [senderDevices, recipientDevices] = await Promise.all([ fetchPublicDeviceBundles(token, userId, { forceRefresh }), fetchPublicDeviceBundles(token, recipientUserId, { forceRefresh }), ]); + const targetDevices = useOneTimePreKeys + ? [...senderDevices, ...recipientDevices] + : withoutOneTimePreKeys([...senderDevices, ...recipientDevices]); const { recipientPayloads } = await encryptSecureTextForRecipients({ content: encryptedContent, senderUserId: userId, senderState: state, - recipientDevices: [...senderDevices, ...recipientDevices], + recipientDevices: targetDevices, }); return fetchJson(`${getApiBaseUrl()}/e2ee/chats/${chatId}/messages`, { @@ -541,18 +605,10 @@ export const sendSecureMediaMessage = async ({ }); }; - let messagePayload: any; - try { - messagePayload = await sendAttempt(false); - } catch (error) { - if (!isUnavailableOneTimePreKeyError(error)) { - throw error; - } - - bundleCache.delete(`bundles:${userId}`); - bundleCache.delete(`bundles:${recipientUserId}`); - messagePayload = await sendAttempt(true); - } + const messagePayload = await sendWithStalePreKeyRetry({ + attempt: sendAttempt, + cacheKeys: [`bundles:${userId}`, `bundles:${recipientUserId}`], + }); bundleCache.delete(`bundles:${userId}`); bundleCache.delete(`bundles:${recipientUserId}`); @@ -600,11 +656,20 @@ export const sendSecureReactionMessage = async ({ const state = await getSecureDeviceState(token, userId); const recipientUserId = getConversationRecipient(conversation, userId); - const sendAttempt = async (forceRefresh: boolean) => { + const sendAttempt = async ({ + forceRefresh, + useOneTimePreKeys, + }: { + forceRefresh: boolean; + useOneTimePreKeys: boolean; + }) => { const [senderDevices, recipientDevices] = await Promise.all([ fetchPublicDeviceBundles(token, userId, { forceRefresh }), fetchPublicDeviceBundles(token, recipientUserId, { forceRefresh }), ]); + const targetDevices = useOneTimePreKeys + ? [...senderDevices, ...recipientDevices] + : withoutOneTimePreKeys([...senderDevices, ...recipientDevices]); const { recipientPayloads } = await encryptSecureTextForRecipients({ content: JSON.stringify({ kind: "reaction", @@ -615,7 +680,7 @@ export const sendSecureReactionMessage = async ({ }), senderUserId: userId, senderState: state, - recipientDevices: [...senderDevices, ...recipientDevices], + recipientDevices: targetDevices, }); return fetchJson(`${getApiBaseUrl()}/e2ee/chats/${chatId}/messages`, { @@ -633,17 +698,10 @@ export const sendSecureReactionMessage = async ({ }); }; - try { - await sendAttempt(false); - } catch (error) { - if (!isUnavailableOneTimePreKeyError(error)) { - throw error; - } - - bundleCache.delete(`bundles:${userId}`); - bundleCache.delete(`bundles:${recipientUserId}`); - await sendAttempt(true); - } + await sendWithStalePreKeyRetry({ + attempt: sendAttempt, + cacheKeys: [`bundles:${userId}`, `bundles:${recipientUserId}`], + }); bundleCache.delete(`bundles:${userId}`); bundleCache.delete(`bundles:${recipientUserId}`); From d0038f0c75b729ab0ee2f64f4f601b9e7acaab01 Mon Sep 17 00:00:00 2001 From: LinzMatt Date: Mon, 20 Jul 2026 21:03:41 +0200 Subject: [PATCH 33/36] Refine mobile chat conversation UI --- components/chat/chat-area.tsx | 39 ++-------------- components/chat/chat-header.tsx | 72 ++++++++++++++++++++++------- components/chat/chat-page-clean.tsx | 5 +- components/chat/message-item.tsx | 22 ++++----- 4 files changed, 75 insertions(+), 63 deletions(-) diff --git a/components/chat/chat-area.tsx b/components/chat/chat-area.tsx index 4352f26..3a86e2f 100644 --- a/components/chat/chat-area.tsx +++ b/components/chat/chat-area.tsx @@ -1,8 +1,7 @@ "use client" import { useRef, useEffect, useState } from "react" -import { Button } from "@/components/ui/button" -import { Send, HandCoins, Target } from "lucide-react" +import { Send } from "lucide-react" import ChatHeader from "./chat-header" import MessageItem from "./message-item" import MessageInput from "./message-input" @@ -116,40 +115,12 @@ export default function ChatArea({ onGroupSettings={onGroupSettings} onDeleteGroup={onDeleteGroup} onVerifySecurity={onVerifySecurity} + onSendMoney={onSendMoney} + onRequestMoney={onRequestMoney} + onCreateContribution={onCreateContribution} + isGroupAdmin={isGroupAdmin} />

- - {/* Action Buttons */} -
- - {!conversation.isGroup && ( - - )} - {conversation.isGroup && isGroupAdmin && onCreateContribution && ( - - )} -
- {/* Messages */}
{messages.length > 0 ? ( diff --git a/components/chat/chat-header.tsx b/components/chat/chat-header.tsx index a41b882..b6a3f93 100644 --- a/components/chat/chat-header.tsx +++ b/components/chat/chat-header.tsx @@ -30,6 +30,9 @@ import { Lock, ShieldCheck, Trash2, + Send, + HandCoins, + Target, } from 'lucide-react'; interface ChatHeaderProps { @@ -40,6 +43,10 @@ interface ChatHeaderProps { onGroupSettings?: () => void; onDeleteGroup?: () => void; onVerifySecurity?: () => void; + onSendMoney?: () => void; + onRequestMoney?: () => void; + onCreateContribution?: () => void; + isGroupAdmin?: boolean; } export default function ChatHeader({ @@ -50,6 +57,10 @@ export default function ChatHeader({ onGroupSettings, onDeleteGroup, onVerifySecurity, + onSendMoney, + onRequestMoney, + onCreateContribution, + isGroupAdmin = false, }: ChatHeaderProps) { const chat = useChat(); const { getToken } = useAuthToken(); @@ -92,22 +103,22 @@ export default function ChatHeader({ }; return ( -
+
{/* Back Button - Mobile Only */} {/* Conversation Info */} -
+
- + {!isPlaceholderAvatar(conversation.avatar) && ( Secure @@ -170,24 +181,51 @@ export default function ChatHeader({ variant='ghost' size='icon' onClick={onInviteToGroup} - className='h-9 w-9 hover:bg-gray-100 transition-colors' + className='h-9 w-9 hover:bg-gray-100 dark:hover:bg-darkBg-interactive transition-colors' aria-label='Add members' > )}
+ {onSendMoney && ( + + )} - {/* Profile/Info Button */} - + {!conversation.isGroup && onRequestMoney && ( + + )} + + {conversation.isGroup && isGroupAdmin && onCreateContribution && ( + + )} {/* More Actions Dropdown */} @@ -195,7 +233,7 @@ export default function ChatHeader({ + ); + })} +
+ ); +} diff --git a/components/chat/conversation-item.tsx b/components/chat/conversation-item.tsx index a202e62..8244172 100644 --- a/components/chat/conversation-item.tsx +++ b/components/chat/conversation-item.tsx @@ -1,8 +1,19 @@ 'use client'; +import { useEffect, useState } from 'react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; -import { Check, CheckCheck, FileText, Headphones, Image as ImageIcon, Music, Users, Video } from 'lucide-react'; +import { + BellOff, + Check, + CheckCheck, + FileText, + Headphones, + Image as ImageIcon, + Music, + Users, + Video, +} from 'lucide-react'; import { cn } from '@/lib/utils'; import type { Conversation } from '@/types/chat.types'; import { formatTimestampWithoutSeconds } from '@/utils/timeUtils'; @@ -25,6 +36,26 @@ export default function ConversationItem({ const currentUserId = getUserId(); const hasUnread = (conversation.unreadCount || 0) > 0; const isSupport = conversation.type === 'support'; + const [isMuted, setIsMuted] = useState(false); + + useEffect(() => { + const readMutedState = () => { + if (typeof window === 'undefined') return; + const muted = JSON.parse( + localStorage.getItem('qc-muted-conversations') || '{}' + ) as Record; + setIsMuted(Boolean(muted[conversation.id])); + }; + + readMutedState(); + window.addEventListener('qc-muted-conversations-changed', readMutedState); + return () => + window.removeEventListener( + 'qc-muted-conversations-changed', + readMutedState + ); + }, [conversation.id]); + const lastMessageType = conversation.lastMessage?.messageType; const lastMessagePreview = getChatPreviewText({ content: conversation.lastMessage?.content, @@ -42,10 +73,14 @@ export default function ConversationItem({ : null; const isOutgoingPreview = conversation.lastMessage?.sender === 'You'; const isReadPreview = Boolean( - conversation.lastMessage?.readBy?.some((receipt) => receipt.userId && receipt.userId !== currentUserId) + conversation.lastMessage?.readBy?.some( + receipt => receipt.userId && receipt.userId !== currentUserId + ) ); const isDeliveredPreview = Boolean( - conversation.lastMessage?.deliveryConfirmed || conversation.lastMessage?.deliveredAt || isReadPreview + conversation.lastMessage?.deliveryConfirmed || + conversation.lastMessage?.deliveredAt || + isReadPreview ); const PreviewStatusIcon = isOutgoingPreview ? isReadPreview || isDeliveredPreview @@ -56,98 +91,106 @@ export default function ConversationItem({ return (
-
-
+
+
{isSupport ? ( -
+
) : conversation.isGroup ? ( -
+
) : ( -
- + <> + {!isPlaceholderAvatar(conversation.avatar) && ( )} - + {getInitials(conversation.name)} {conversation.isOnline && ( - + )} -
+ )}
-
-
-

+

- {conversation.name || 'Unknown Contact'} -

-
- {/* {isSupport && ( - - Support - - )} */} - {hasUnread && ( - - {conversation.unreadCount} - - )} - - {(formatTimestampWithoutSeconds(conversation.timestamp)) || ''} + )} + > + {conversation.name || 'Unknown Contact'} +

+

+ {PreviewStatusIcon && ( + + )} + {conversation.isGroup && conversation.memberCount && ( + + {conversation.isOnline ? 1 : 0}/{conversation.memberCount} -

-
+ )} + {MediaPreviewIcon && ( + + )} + {lastMessagePreview} +

+
-
-

- {PreviewStatusIcon && ( - - )} - {conversation.isGroup && conversation.memberCount && ( - - {conversation.isOnline ? 1 : 0}/{conversation.memberCount} - - )} - {MediaPreviewIcon && ( - - )} - {lastMessagePreview} -

-
+
+ + {formatTimestampWithoutSeconds(conversation.timestamp) || ''} + + {hasUnread && ( + + {conversation.unreadCount} + + )} + {isMuted && !hasUnread && ( + + )}
); -} +} \ No newline at end of file diff --git a/components/chat/conversation-list-layout.tsx b/components/chat/conversation-list-layout.tsx index 34c2f7b..c60754f 100644 --- a/components/chat/conversation-list-layout.tsx +++ b/components/chat/conversation-list-layout.tsx @@ -13,8 +13,8 @@ import JoinGroupByLinkModal from './join-group-by-link-modal'; import CreateGroupModalUpdated from './create-group-modal'; import OutsideMessagesTab from './outside-messages-tab'; import type { Conversation, OutsideMessage } from '@/types/chat.types'; -import { Send, Link } from 'lucide-react' -import { useAuthToken } from '@/hooks/use-auth-token' +import { Send, Link } from 'lucide-react'; +import { useAuthToken } from '@/hooks/use-auth-token'; import { useGetGroupsQuery } from '@/states/groupSlice'; type ChatTabType = 'chats' | 'outside-messages'; @@ -98,12 +98,12 @@ export default function ConversationListLayout({ return (
{/* Header - Fixed */} -
- +
setIsStartChatModalOpen(true)} @@ -111,13 +111,14 @@ export default function ConversationListLayout({ onJoinGroupByLink={() => setIsJoinGroupByLinkModalOpen(true)} onViewContactRequests={onViewContactRequests} /> +
{/* Tab Navigation */}
+
)}
- ) : ( -
-

No groups found

- -
- )} -
- ) - ) : // Regular conversations rendering - isLoading ? ( -
-
- - Loading conversations... - -
- ) : filteredConversations.length > 0 ? ( -
- {/* Section Header */} - {searchTerm === '' && ( -
-

- {activeFilter === 'all' && 'All Conversations'} - {activeFilter === 'users' && 'Direct Messages'} -

-
- )} + ) + ) : // Regular conversations rendering + isLoading ? ( +
+
+ + Loading conversations... + +
+ ) : filteredConversations.length > 0 ? ( +
+ {filteredConversations.map(conversation => ( +
+ onConversationSelect(conversation)} + /> - {filteredConversations.map(conversation => ( -
- onConversationSelect(conversation)} - /> - - {/* Quick Send Money Button - Only for users */} - {!conversation.isGroup && ( - - )} -
- ))} -
- ) : ( - setIsStartChatModalOpen(true)} - onViewMyGroups={() => { }} - onJoinGroupByLink={() => setIsJoinGroupByLinkModalOpen(true)} - onAddContact={onAddContact} - /> - )} -
+ {/* Quick Send Money Button - Only for users */} + {!conversation.isGroup && ( + + )} +
+ ))} +
+ ) : ( + setIsStartChatModalOpen(true)} + onViewMyGroups={() => {}} + onJoinGroupByLink={() => setIsJoinGroupByLinkModalOpen(true)} + onAddContact={onAddContact} + /> + )} +
)} diff --git a/components/chat/message-input.tsx b/components/chat/message-input.tsx index 65039f2..a896e22 100644 --- a/components/chat/message-input.tsx +++ b/components/chat/message-input.tsx @@ -4,7 +4,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react" import dynamic from "next/dynamic" import type { EmojiClickData } from "emoji-picker-react" import { Button } from "@/components/ui/button" -import { Send, Paperclip, Smile, ImageIcon, X } from "lucide-react" +import { Plus, Send, Smile, X } from "lucide-react" import OptionsDropdown from "./options-dropdown" import { toast } from "@/hooks/use-toast" import { useChat } from "@/context/ChatContext" @@ -380,7 +380,11 @@ export default function MessageInput({ // ── Attachments / Media ─────────────────────────────────────────────────── const handleOptionSelect = (option: string) => { setShowOptions(false) - toast({ title: "Selected option", description: option }) + if (option === "Media") { + setShowMediaModal(true) + return + } + toast({ title: "Coming soon", description: option }) } const handleMediaUpload = async (file: File, caption: string) => { @@ -448,7 +452,7 @@ export default function MessageInput({ ref={wrapperRef} className="relative bg-white dark:bg-darkBg-card px-3 py-2 sm:px-4 sm:py-3 border-t border-gray-100 dark:border-darkBorder-light" > - {/* ── @Mention dropdown ──────────────────────────────────────────────── */} + {/* @Mention dropdown */} {showMentionDropdown && ( )} - {/* ── Emoji picker popover ──────────────────────────────────────────── */} + {/* Emoji picker popover */} {showEmojiPicker && (
)} - - {/* ── Input row ─────────────────────────────────────────────────────── */} -
- - {/* Attachment */} -
- - -
- - {/* Media */} - - - {/* Textarea */} -
+ {/* Input row */} +
+