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 diff --git a/app/contacts/components/ContactsTable.tsx b/app/contacts/components/ContactsTable.tsx index b574cb0..e3d78a3 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,9 +58,41 @@ export function ContactsTable({ contacts, isLoading, onSelect }: ContactsTablePr } }; - const handleQuickMessage = (e: React.MouseEvent) => { + 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: "Secure Chat Ready", + description: `Opening a secure 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", + }); + } + }; + + const handleQuickMessage = (contact: Contact, e: React.MouseEvent) => { e.stopPropagation(); - router.push("/chat"); + void handleSendMessage(contact); }; const handleQuickSend = (contact: Contact, e: React.MouseEvent) => { @@ -189,7 +222,7 @@ export function ContactsTable({ contacts, isLoading, onSelect }: ContactsTablePr variant="ghost" size="icon" className="h-9 w-9 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" - onClick={handleQuickMessage} + onClick={(e) => handleQuickMessage(contact, e)} title="Message" > @@ -240,6 +273,9 @@ export function ContactsTable({ contacts, isLoading, onSelect }: ContactsTablePr handleOpenProfile(e, contact.otherUser.id)}> View Profile + void handleSendMessage(contact)}> + Send Message + setManagingTagsContactId(contact.id)}> Edit Tags @@ -308,6 +344,10 @@ export function ContactsTable({ contacts, isLoading, onSelect }: ContactsTablePr handleOpenProfile(e, contact.otherUser.id)}> View Profile + void handleSendMessage(contact)}> + Send Message + + Send Money setManagingTagsContactId(contact.id)}> Edit Tags @@ -327,7 +367,7 @@ export function ContactsTable({ contacts, isLoading, onSelect }: ContactsTablePr variant="outline" size="sm" className="h-8 flex-1 rounded-full text-xs border-gray-200 text-gray-700 hover:bg-gray-50 dark:border-darkBorder-light/60 dark:text-gray-200 dark:hover:bg-darkBg-interactive" - onClick={handleQuickMessage} + onClick={(e) => handleQuickMessage(contact, e)} > Message diff --git a/app/welcome/[userId]/page.tsx b/app/welcome/[userId]/page.tsx index 64fda67..df65622 100644 --- a/app/welcome/[userId]/page.tsx +++ b/app/welcome/[userId]/page.tsx @@ -30,6 +30,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'; @@ -1319,13 +1320,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..ce354ca --- /dev/null +++ b/components/SecureDeviceBootstrap.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useEffect } from "react"; +import { ensureRegisteredSecureDevice } from "@/services/e2eeDeviceService"; +import { + getCurrentUserInfo, + getStoredUserInfo, + getValidToken, + refreshAccessToken, +} from "@/utils/tokenUtils"; + +const runBootstrap = async () => { + const authUser = getStoredUserInfo(); + 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; + } + + const token = getValidToken() || (await refreshAccessToken()); + if (!token) { + return; + } + + await ensureRegisteredSecureDevice({ + token, + userId: resolvedUserId, + }); +}; + +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-area.tsx b/components/chat/chat-area.tsx index 2768800..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" @@ -66,6 +65,7 @@ interface ChatAreaProps { onInviteToGroup: () => void onGroupSettings?: () => void onDeleteGroup?: () => void + onVerifySecurity?: () => void onCreateContribution?: () => void isGroupAdmin?: boolean typingUsers?: any[] @@ -83,6 +83,7 @@ export default function ChatArea({ onInviteToGroup, onGroupSettings, onDeleteGroup, + onVerifySecurity, onCreateContribution, isGroupAdmin = false, typingUsers = [], @@ -113,40 +114,13 @@ export default function ChatArea({ onInviteToGroup={onInviteToGroup} 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 ? ( @@ -162,7 +136,7 @@ export default function ChatArea({ return (
{showSep && } -
+
setReplyToMessage(reply)} @@ -216,7 +190,10 @@ export default function ChatArea({ {/* Message Input */}
setReplyToMessage(null)} /> diff --git a/components/chat/chat-header.tsx b/components/chat/chat-header.tsx index d25aff7..b73c2bb 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, 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,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, @@ -27,7 +28,13 @@ import { Circle, Wifi, Lock, + ShieldCheck, Trash2, + Send, + HandCoins, + Target, + BellOff, + Bell, } from 'lucide-react'; interface ChatHeaderProps { @@ -37,6 +44,11 @@ interface ChatHeaderProps { onInviteToGroup?: () => void; onGroupSettings?: () => void; onDeleteGroup?: () => void; + onVerifySecurity?: () => void; + onSendMoney?: () => void; + onRequestMoney?: () => void; + onCreateContribution?: () => void; + isGroupAdmin?: boolean; } export default function ChatHeader({ @@ -46,6 +58,11 @@ export default function ChatHeader({ onInviteToGroup, onGroupSettings, onDeleteGroup, + onVerifySecurity, + onSendMoney, + onRequestMoney, + onCreateContribution, + isGroupAdmin = false, }: ChatHeaderProps) { const chat = useChat(); const { getToken } = useAuthToken(); @@ -58,6 +75,34 @@ export default function ChatHeader({ ); const group = groupData?.data; + const isDirectConversation = + !conversation.isGroup && conversation.type !== 'support'; + const isSecureConversation = conversation.securityMode === 'secure_dm_v1'; + const [isMuted, setIsMuted] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') return; + const muted = JSON.parse( + localStorage.getItem('qc-muted-conversations') || '{}' + ) as Record; + setIsMuted(Boolean(muted[conversation.id])); + }, [conversation.id]); + + const toggleMuteConversation = () => { + if (typeof window === 'undefined') return; + const muted = JSON.parse( + localStorage.getItem('qc-muted-conversations') || '{}' + ) as Record; + if (muted[conversation.id]) { + delete muted[conversation.id]; + setIsMuted(false); + } else { + muted[conversation.id] = true; + setIsMuted(true); + } + localStorage.setItem('qc-muted-conversations', JSON.stringify(muted)); + window.dispatchEvent(new Event('qc-muted-conversations-changed')); + }; // Listen for real-time fundraising progress updates useEffect(() => { @@ -85,36 +130,29 @@ 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 */} {/* Conversation Info */} -
+
- - + + {!isPlaceholderAvatar(conversation.avatar) && ( + + )} {getInitials(conversation.name)} @@ -132,17 +170,30 @@ export default function ChatHeader({

{conversation.name || 'Unknown Contact'}

+ {isSecureConversation && ( + + + Secure + + )}
{/* Fundraising Progress for Groups */} - {conversation.isGroup && group?.hasFundraising && group.fundraisingTarget && ( -
- -
- )} + {conversation.isGroup && + group?.hasFundraising && + group.fundraisingTarget && ( +
+ +
+ )} {/* Online Status (only show if not fundraising) */} {!(conversation.isGroup && group?.hasFundraising) && ( @@ -169,7 +220,44 @@ export default function ChatHeader({ )}
+ {onSendMoney && ( + + )} + + {!conversation.isGroup && onRequestMoney && ( + + )} + {conversation.isGroup && isGroupAdmin && onCreateContribution && ( + + )} {/* Profile/Info Button */} + {/* More Actions Dropdown */} @@ -230,14 +319,37 @@ export default function ChatHeader({ chat.initializeEncryption()} + onClick={toggleMuteConversation} className='cursor-pointer' > - - Initialize Encryption + {isMuted ? ( + + ) : ( + + )} + {isMuted ? 'Unmute Conversation' : 'Mute Conversation'} - {/* Delete Group — only visible to owners and admins */} + {isDirectConversation && + isSecureConversation && + onVerifySecurity && ( + + + Verify Security + + )} + + {isDirectConversation && isSecureConversation && ( + + + End-to-end encrypted + + )} + + {/* Delete Group — only visible to owners and admins */} {conversation.isGroup && (group?.userRole === 'owner' || group?.userRole === 'admin') && onDeleteGroup && ( @@ -250,7 +362,7 @@ export default function ChatHeader({ )} - {/* Delete Chat — only for direct (non-group) conversations */} + {/* Delete Chat — only for direct (non-group) conversations */} {!conversation.isGroup && ( diff --git a/components/chat/chat-page-clean.tsx b/components/chat/chat-page-clean.tsx index 6d3eb35..9047604 100644 --- a/components/chat/chat-page-clean.tsx +++ b/components/chat/chat-page-clean.tsx @@ -15,6 +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, + getSecureConversationRecipientId, +} from '@/services/secureChatService'; import SendMoneyModal from '@/components/chat/send-money-modal'; import RequestMoneyModal from '@/components/chat/request-money-modal'; @@ -22,17 +26,18 @@ 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'; import GroupSettingsModal from '@/components/chat/group-settings-modal'; import { Header } from '@/components/Header'; -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); @@ -48,6 +53,7 @@ export default function ChatPageClean() { setActiveChat, handleStartNewChat, handleJoinGroup, + upsertConversation, } = useChatOperations(); const { @@ -69,23 +75,59 @@ export default function ChatPageClean() { setIsInviteToGroupModalOpen, } = useChatModals(); - const [deleteGroupMutation, { isLoading: isDeleting }] = useDeleteGroupMutation(); + const [deleteGroupMutation, { isLoading: isDeleting }] = + useDeleteGroupMutation(); const [showMobileConversationList, setShowMobileConversationList] = useState(true); const [selectedChat, setSelectedChat] = useState(null); const [isGroupAdmin, setIsGroupAdmin] = useState(false); - const [isCreateContributionModalOpen, setIsCreateContributionModalOpen] = useState(false); - const [selectedOutsideMessage, setSelectedOutsideMessage] = useState(null); - const [isGroupSettingsModalOpen, setIsGroupSettingsModalOpen] = useState(false); - const [deleteDialog, setDeleteDialog] = useState<{ isOpen: boolean; groupId: string | null }>({ + 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, }); + 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]); + // Check if the current user is a group admin whenever the selected chat changes useEffect(() => { if (!selectedChat?.isGroup || !selectedChat?.groupId) { @@ -94,37 +136,29 @@ export default function ChatPageClean() { } let cancelled = false; getGroupById(selectedChat.groupId) - .then((res) => { + .then(res => { if (cancelled) return; const group = res?.data?.data || res?.data; const role = group?.userRole || group?.currentUserRole; setIsGroupAdmin(role === 'admin' || role === 'owner'); }) - .catch(() => { if (!cancelled) setIsGroupAdmin(false); }); - return () => { cancelled = true; }; + .catch(() => { + if (!cancelled) setIsGroupAdmin(false); + }); + return () => { + cancelled = true; + }; }, [selectedChat?.isGroup, selectedChat?.groupId]); // 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, - }); + if ( + conversation && + (!selectedChat || conversation.id !== selectedChat.id) + ) { + setSelectedChat(buildSelectedConversation(conversation)); setShowMobileConversationList(false); } } @@ -149,10 +183,19 @@ export default function ChatPageClean() { }, []); useEffect(() => { - if (!requestedChatId || conversations.length === 0) return; + if (!requestedChatId || conversations.length === 0) { + return; + } + + if ( + selectedChat?.id === requestedChatId && + activeChat === requestedChatId + ) { + return; + } const requestedConversation = conversations.find( - (conversation) => conversation.id === requestedChatId, + conversation => conversation.id === requestedChatId ); if (!requestedConversation) return; @@ -183,23 +226,53 @@ 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: 'Opening this conversation in legacy mode for now.', + variant: 'default', + }); + setSelectedChat(buildSelectedConversation(conversation)); + setActiveChat(conversation.id); + setShowMobileConversationList(false); + return; + } + } + } + + setSelectedChat(buildSelectedConversation(conversation)); setActiveChat(conversation.id); setShowMobileConversationList(false); }; @@ -224,6 +297,13 @@ 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) { @@ -242,13 +322,20 @@ export default function ChatPageClean() { if (!token) return; try { await deleteGroupMutation({ groupId, token }).unwrap(); - toast({ title: 'Group deleted', description: 'The group has been permanently deleted.' }); + toast({ + title: 'Group deleted', + description: 'The group has been permanently deleted.', + }); setDeleteDialog({ isOpen: false, groupId: null }); setSelectedChat(null); setActiveChat(null); setShowMobileConversationList(true); } catch { - toast({ title: 'Delete failed', description: 'Could not delete the group. Please try again.', variant: 'destructive' }); + toast({ + title: 'Delete failed', + description: 'Could not delete the group. Please try again.', + variant: 'destructive', + }); } }; @@ -264,17 +351,21 @@ export default function ChatPageClean() { {/* Main Content */} -
+
{/* Fixed Header */} -
+
-
- -
{!isConnected && ( @@ -310,12 +401,15 @@ export default function ChatPageClean() { onBackClick={handleHideChat} onSendMoney={() => openSendMoneyModal(selectedChat.name)} onRequestMoney={() => setIsRequestMoneyModalOpen(true)} - onCreateContribution={() => setIsCreateContributionModalOpen(true)} + onCreateContribution={() => + setIsCreateContributionModalOpen(true) + } isGroupAdmin={isGroupAdmin} onViewProfile={handleViewProfile} onInviteToGroup={handleInviteToGroup} onGroupSettings={handleGroupSettings} onDeleteGroup={handleDeleteGroup} + onVerifySecurity={() => setIsSecureIdentityDialogOpen(true)} typingUsers={typingUsers} onlineUsers={onlineUsers} /> @@ -327,7 +421,7 @@ export default function ChatPageClean() { setShowMobileConversationList(true); setSelectedOutsideMessage(null); }} - onDelete={(id) => { + onDelete={id => { setSelectedOutsideMessage(null); setShowMobileConversationList(true); }} @@ -404,6 +498,14 @@ export default function ChatPageClean() { groupId={selectedChat?.groupId || null} /> + + {/* Delete Group confirmation dialog (role-gated in ChatHeader) */} {}} - onDeleteGroup={(groupId) => handleConfirmDeleteGroup(groupId)} + onDeleteGroup={groupId => handleConfirmDeleteGroup(groupId)} onCloseLeaveDialog={() => {}} - onCloseDeleteDialog={() => setDeleteDialog({ isOpen: false, groupId: null })} + onCloseDeleteDialog={() => + setDeleteDialog({ isOpen: false, groupId: null }) + } />
); diff --git a/components/chat/conversation-filters.tsx b/components/chat/conversation-filters.tsx index 5aee142..2c3d800 100644 --- a/components/chat/conversation-filters.tsx +++ b/components/chat/conversation-filters.tsx @@ -1,52 +1,53 @@ -'use client' +'use client'; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { cn } from '@/lib/utils'; -export type FilterType = "all" | "users" | "groups" +export type FilterType = 'all' | 'users' | 'groups'; interface ConversationFiltersProps { - activeFilter: FilterType - onFilterChange: (filter: FilterType) => void - totalCount: number - userCount: number - groupCount: number + activeFilter: FilterType; + onFilterChange: (filter: FilterType) => void; + totalCount: number; + userCount: number; + groupCount: number; } export default function ConversationFilters({ - activeFilter, - onFilterChange, - totalCount, - userCount, - groupCount, + activeFilter, + onFilterChange, + totalCount, + userCount, + groupCount, }: ConversationFiltersProps) { - return ( -
+ const filters: Array<{ value: FilterType; label: string; count: number }> = [ + { value: 'all', label: 'All', count: totalCount }, + { value: 'users', label: 'Users', count: userCount }, + { value: 'groups', label: 'Groups', count: groupCount }, + ]; - onFilterChange(value as FilterType)} - > - - - All ({totalCount}) - - - Users ({userCount}) - - - Groups ({groupCount}) - - - -
- ) -} \ No newline at end of file + return ( +
+ {filters.map(filter => { + const isActive = activeFilter === filter.value; + return ( + + ); + })} +
+ ); +} diff --git a/components/chat/conversation-item.tsx b/components/chat/conversation-item.tsx index d224eea..8244172 100644 --- a/components/chat/conversation-item.tsx +++ b/components/chat/conversation-item.tsx @@ -1,11 +1,25 @@ 'use client'; +import { useEffect, useState } from 'react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; -import { Users, Headphones } 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'; +import { getChatPreviewText } from '@/utils/chatPreview'; +import { getInitials, isPlaceholderAvatar } from '@/utils/avatar'; +import { useAuthToken } from '@/hooks/use-auth-token'; interface ConversationItemProps { conversation: Conversation; @@ -18,91 +32,165 @@ 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 [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, + messageType: lastMessageType, + }); + const MediaPreviewIcon = + lastMessageType === 'image' + ? ImageIcon + : lastMessageType === 'video' + ? Video + : lastMessageType === 'audio' + ? Music + : 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 (
-
-
+
+
{isSupport ? ( -
+
) : conversation.isGroup ? ( -
+
) : ( -
+ <> - - - {(conversation.name || 'U').charAt(0).toUpperCase()} + {!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} +

+
-
-

- {conversation.isGroup && conversation.memberCount && ( - - {conversation.isOnline ? 1 : 0}/{conversation.memberCount} - - )} - {conversation.lastMessage?.content || ''} -

-
+
+ + {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 0846e3b..4b0d140 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/media-message-content.tsx b/components/chat/media-message-content.tsx index 358fda7..e386c3a 100644 --- a/components/chat/media-message-content.tsx +++ b/components/chat/media-message-content.tsx @@ -1,10 +1,12 @@ "use client" import { useState, useRef, useEffect } from "react" -import { Download, Play, Pause, Volume2, VolumeX } from "lucide-react" +import { Download, Lock, Play, Pause, Volume2, VolumeX } from "lucide-react" 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 @@ -18,12 +20,83 @@ export default function MediaMessageContent({ fileName, fileSize, duration, - mimeType + mimeType, + secureMediaKey, + secureMediaIv, + isSecureMedia }: MediaMessageContentProps) { + 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 () => { + 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) + } + } + + useEffect(() => { + const canAutoPreview = + mediaType === "image" || mediaType === "video" || mediaType === "audio" + + if (!isSecureMedia || !canAutoPreview || !mediaUrl || decryptedMediaUrl || isDecryptingPreview) { + return + } + + void loadSecurePreview() + }, [isSecureMedia, mediaType, mediaUrl, decryptedMediaUrl, isDecryptingPreview]) + const handleDownload = async (url: string, filename: string) => { try { - const response = await fetch(url); - const blob = await response.blob(); + const encryptedBlob = await fetchMediaBlob(url); + const blob = await decryptMediaBlobIfNeeded(encryptedBlob); const blobUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = blobUrl; @@ -34,10 +107,142 @@ export default function MediaMessageContent({ window.URL.revokeObjectURL(blobUrl); } catch (error) { console.error('Download failed:', error); - window.open(url, '_blank'); + if (!isSecureMedia) { + window.open(url, '_blank'); + } } }; + if (isSecureMedia && mediaUrl) { + const canPreview = mediaType === "image" || mediaType === "video" || mediaType === "audio" + + if (decryptedMediaUrl && mediaType === "image") { + return ( +
+
+ {fileName window.open(decryptedMediaUrl, "_blank")} + /> +
+ +
+
+ {showCaption && ( +

{content}

+ )} +
+ ) + } + + if (decryptedMediaUrl && mediaType === "video") { + return ( +
+
+ +
+ +
+
+ {showCaption && ( +

{content}

+ )} +
+ ) + } + + if (decryptedMediaUrl && mediaType === "audio") { + return ( + handleDownload(mediaUrl, fileName || "secure-audio")} + /> + ) + } + + return ( +
+
+
+ +
+
+

+ {mediaPreviewText || "Secure file"} +

+

+ {fileSize ? formatFileSize(fileSize) : "Encrypted media"} +

+
+ {canPreview && ( + + )} + +
+ {showCaption && ( +

{content}

+ )} + {previewError && ( +

{previewError}

+ )} +
+ ) + } + // Image rendering if (mediaType === 'image' && mediaUrl) { return ( @@ -63,7 +268,7 @@ export default function MediaMessageContent({
- {content && content !== `Sent a ${mediaType}` && ( + {showCaption && (

{content}

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

{content}

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

{content}

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

{content}

)}
@@ -211,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 @@ -321,9 +532,9 @@ function AudioPlayer({ )}
- {content && content !== `Sent a ${mediaType}` && ( -

{content}

- )} + {showAudioCaption && ( +

{content}

+ )} ) } diff --git a/components/chat/message-input.tsx b/components/chat/message-input.tsx index 6a20419..a896e22 100644 --- a/components/chat/message-input.tsx +++ b/components/chat/message-input.tsx @@ -4,17 +4,18 @@ 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" 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__" @@ -44,15 +45,21 @@ 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 + isSecureChat?: boolean + secureConversation?: Conversation replyToMessage?: ReplyPreview | null onCancelReply?: () => void } export default function MessageInput({ onSendMessage = () => { }, + chatId, groupId, + isSecureChat = false, + secureConversation, replyToMessage = null, onCancelReply, }: MessageInputProps) { @@ -86,10 +93,12 @@ 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 // ── Debounce mentionQuery for the RTK search ─────────────────────────────── useEffect(() => { @@ -288,16 +297,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 +322,7 @@ export default function MessageInput({ } }, [ messageText, - activeChat, + currentChatId, contextSendMessage, stopTyping, onSendMessage, @@ -329,9 +338,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,23 +374,62 @@ export default function MessageInput({ const handleBlur = () => { saveCursor() - if (activeChat && stopTyping) stopTyping(activeChat) + if (currentChatId && stopTyping) stopTyping(currentChatId) } // ── 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) => { - if (!activeChat) { + if (isSecureChat) { + 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 + } + + 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" }) @@ -404,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 */} +
+