+ {/* Input row */}
+
+
- {/* Emoji trigger */}
+
+
setShowOptions(!showOptions)}
+ aria-label="Share"
+ aria-expanded={showOptions}
+ className={`absolute left-2 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full transition-colors ${
+ showOptions
+ ? "bg-brand-green text-white dark:bg-brand-gold dark:text-darkBg-main"
+ : "text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-darkBg-card dark:hover:text-gray-300"
+ }`}
+ >
+
+
+
+
- {/* Send */}
-
+
-
{/* Hint shown while typing */}
{messageText.length > 0 && (
diff --git a/components/chat/message-item.tsx b/components/chat/message-item.tsx
index de898fb..28bc6d3 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 { 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,15 @@ 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 shouldShowIncomingAvatar = Boolean(activeConversation?.isGroup && !isMe)
+ const reactionsAllowed = true
// Aggregate raw reaction rows into display format
const aggregatedReactions: Reaction[] = useMemo(() => {
@@ -168,6 +174,34 @@ 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 messageStatus = !isLegacy ? (message as Message).status : undefined
+ const hasReadProof = !isLegacy && Boolean(
+ (message as Message).readBy?.some((receipt) => receipt.userId && receipt.userId !== currentUserId)
+ )
+ const hasDeliveryProof = !isLegacy && Boolean((message as any).deliveryConfirmed || hasReadProof)
+ const visualStatus = isTempMessage
+ ? "pending"
+ : hasReadProof
+ ? "read"
+ : hasDeliveryProof
+ ? "delivered"
+ : "sent"
+ const StatusIcon = (() => {
+ if (!isMe || isLegacy) return null
+ if (visualStatus === "pending") return Clock
+ if (visualStatus === "read") return CheckCheck
+ if (visualStatus === "delivered") return CheckCheck
+ return Check
+ })()
+ const statusTone = visualStatus === "read"
+ ? "text-[#34b7f1]"
+ : "text-gray-500 dark:text-gray-300"
const handleTouchStart = (e: React.TouchEvent) => {
if (!canSwipeReply) return
@@ -218,6 +252,60 @@ export default function MessageItem({ message, onReply }: MessageItemProps) {
setSwipeOffset(0)
}
+ const messageActions = (!isLegacy && (onReply || reactionsAllowed)) && !isTempMessage ? (
+
+ {reactionsAllowed && (
+
+ setShowReactionPicker((v) => !v)}
+ className={cn(
+ "h-5 w-5 p-0",
+ isMe
+ ? "text-gray-500 hover:text-gray-700 hover:bg-emerald-100/80 dark:text-gray-200 dark:hover:text-white dark:hover:bg-white/10"
+ : "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
+ )}
+ aria-label="Add reaction"
+ >
+
+
+ {showReactionPicker && (
+ setShowReactionPicker(false)}
+ />
+ )}
+
+ )}
+
+ {onReply && (
+
+
+
+ )}
+
+ ) : null
// Render group contribution card
if (isMoneyMessage && groupContributionData) {
return (
@@ -237,15 +325,17 @@ export default function MessageItem({ message, onReply }: MessageItemProps) {
}
return (
-
- {!isMe && (
-
-
- {senderDisplayName.charAt(0).toUpperCase()}
+
+ {shouldShowIncomingAvatar && (
+
+ {avatar && }
+
+ {getInitials(senderDisplayName)}
+
)}
-
+
{!isLegacy && onReply && (
)}
+ {isMe && messageActions}
+
- {!isMe &&
{senderDisplayName}
}
+ {shouldShowSenderName &&
{senderDisplayName}
}
{replyTo && (
{replyTo.senderName}
{replyTo.content || "(no text)"}
@@ -321,6 +413,9 @@ export default function MessageItem({ message, onReply }: MessageItemProps) {
fileSize={message.fileSize}
duration={message.duration}
mimeType={message.mimeType}
+ secureMediaKey={message.secureMediaKey}
+ secureMediaIv={message.secureMediaIv}
+ isSecureMedia={message.isSecureMedia}
/>
) : (
<>
@@ -335,57 +430,15 @@ export default function MessageItem({ message, onReply }: MessageItemProps) {
>
)}
-
- {timestamp}
-
-
- {(!isLegacy && (onReply || true)) && !isTempMessage && (
-
- {/* Reaction trigger */}
-
- setShowReactionPicker((v) => !v)}
- className={cn(
- "h-6 w-6 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 && (
- setShowReactionPicker(false)}
- />
- )}
-
-
- {onReply && (
-
-
- Reply
-
- )}
-
- )}
+
+ {timestamp}
+ {StatusIcon && (
+
+ )}
+
{/* Aggregated reaction bubbles */}
{aggregatedReactions.length > 0 && (
@@ -432,14 +485,10 @@ export default function MessageItem({ message, onReply }: MessageItemProps) {
)} */}
+
+ {!isMe && messageActions}
- {isMe && (
-
-
- Y
-
- )}
)
-}
\ No newline at end of file
+}
diff --git a/components/chat/options-dropdown.tsx b/components/chat/options-dropdown.tsx
index cba758f..082e41f 100644
--- a/components/chat/options-dropdown.tsx
+++ b/components/chat/options-dropdown.tsx
@@ -1,65 +1,179 @@
-"use client"
+'use client';
-import { FileText, DollarSign, MapPin, Calendar } from 'lucide-react'
+import {
+ BarChart3,
+ CalendarDays,
+ DollarSign,
+ FileText,
+ HandCoins,
+ ImageIcon,
+ MapPin,
+ MonitorUp,
+ NotebookText,
+ PiggyBank,
+ Ticket,
+ Users,
+ WalletCards,
+ Workflow,
+} from 'lucide-react';
interface OptionsDropdownProps {
- isOpen: boolean;
- onOptionSelect: (option: string) => void;
+ isOpen: boolean;
+ onOptionSelect: (option: string) => void;
}
interface DropdownOption {
- icon: React.ReactNode;
- label: string;
- color: string;
- action: () => void;
+ icon: React.ReactNode;
+ label: string;
+ color: string;
+ action: () => void;
}
-export default function OptionsDropdown({ isOpen, onOptionSelect }: OptionsDropdownProps) {
- const options: DropdownOption[] = [
+interface DropdownSection {
+ title: string;
+ options: DropdownOption[];
+}
+
+export default function OptionsDropdown({
+ isOpen,
+ onOptionSelect,
+}: OptionsDropdownProps) {
+ const sections: DropdownSection[] = [
+ {
+ title: 'Send',
+ options: [
+ {
+ icon: ,
+ label: 'Photo / video',
+ color: 'text-sky-600 dark:text-sky-300',
+ action: () => onOptionSelect('Media'),
+ },
+ {
+ icon: ,
+ label: 'Document',
+ color: 'text-gray-600 dark:text-gray-300',
+ action: () => onOptionSelect('Document'),
+ },
+ {
+ icon: ,
+ label: 'Send money',
+ color: 'text-brand-green dark:text-brand-gold',
+ action: () => onOptionSelect('Send Money'),
+ },
+ {
+ icon: ,
+ label: 'Request money',
+ color: 'text-amber-600 dark:text-amber-300',
+ action: () => onOptionSelect('Request Money'),
+ },
+ {
+ icon: ,
+ label: 'Ticket',
+ color: 'text-violet-600 dark:text-violet-300',
+ action: () => onOptionSelect('Ticket'),
+ },
+ ],
+ },
+ {
+ title: 'Share',
+ options: [
{
- icon: ,
- label: "Document",
- color: "text-gray-600 dark:text-gray-400",
- action: () => onOptionSelect("Document"),
+ icon: ,
+ label: 'Event',
+ color: 'text-indigo-600 dark:text-indigo-300',
+ action: () => onOptionSelect('Event'),
},
{
- icon: ,
- label: "Send Money",
- color: "text-brand-green dark:text-brand-gold",
- action: () => onOptionSelect("Send Money"),
+ icon: ,
+ label: 'Group',
+ color: 'text-cyan-600 dark:text-cyan-300',
+ action: () => onOptionSelect('Group'),
},
{
- icon: ,
- label: "Location",
- color: "text-gray-600 dark:text-gray-400",
- action: () => onOptionSelect("Location"),
+ icon: ,
+ label: 'Contribution',
+ color: 'text-emerald-600 dark:text-emerald-300',
+ action: () => onOptionSelect('Contribution'),
},
{
- icon: ,
- label: "Schedule",
- color: "text-gray-600 dark:text-gray-400",
- action: () => onOptionSelect("Schedule"),
+ icon: ,
+ label: 'Location',
+ color: 'text-rose-600 dark:text-rose-300',
+ action: () => onOptionSelect('Location'),
},
- ]
+ ],
+ },
+ {
+ title: 'Create together',
+ options: [
+ {
+ icon: ,
+ label: 'Shared wallet',
+ color: 'text-emerald-600 dark:text-emerald-300',
+ action: () => onOptionSelect('Shared Wallet'),
+ },
+ {
+ icon: ,
+ label: 'Shared note',
+ color: 'text-yellow-600 dark:text-yellow-300',
+ action: () => onOptionSelect('Shared Note'),
+ },
+ {
+ icon: ,
+ label: 'Whiteboard',
+ color: 'text-fuchsia-600 dark:text-fuchsia-300',
+ action: () => onOptionSelect('Whiteboard'),
+ },
+ {
+ icon: ,
+ label: 'Screen share',
+ color: 'text-blue-600 dark:text-blue-300',
+ action: () => onOptionSelect('Screen Share'),
+ },
+
+ {
+ icon: ,
+ label: 'Poll',
+ color: 'text-pink-600 dark:text-pink-300',
+ action: () => onOptionSelect('Poll'),
+ },
+ ],
+ },
+ ];
+
+ if (!isOpen) return null;
- if (!isOpen) return null
+ return (
+
+
+ {sections.map(section => (
+
+
+ {section.title}
+
+
+ {section.options.map(option => (
+
+
+ {option.icon}
+
+
+ {option.label}
+
+
+ ))}
- return (
-
-
- {options.map((option, index) => (
-
-
{option.icon}
-
{option.label}
-
- ))}
-
- )
+
+ ))}
+
+
+ );
}
diff --git a/components/chat/quick-actions.tsx b/components/chat/quick-actions.tsx
index ee9759e..1607916 100644
--- a/components/chat/quick-actions.tsx
+++ b/components/chat/quick-actions.tsx
@@ -15,10 +15,9 @@ import {
PlusCircle,
Link,
MoreVertical,
+ ArrowLeft,
} from 'lucide-react';
-import {
- useGetPendingInvitationsUnifiedQuery,
-} from '@/states/contactSlice';
+import { useGetPendingInvitationsUnifiedQuery } from '@/states/contactSlice';
import { useAuthToken } from '@/hooks/use-auth-token';
interface QuickActionsProps {
@@ -53,7 +52,21 @@ export default function QuickActions({
return (
-
Messages
+
+
window.history.back()}
+ className='h-7 w-7 rounded-full text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-darkBg-interactive'
+ aria-label='Go back'
+ >
+
+
+
+ Messages
+
+
-
+
@@ -97,4 +113,4 @@ export default function QuickActions({
);
-}
\ No newline at end of file
+}
diff --git a/components/chat/secure-identity-dialog.tsx b/components/chat/secure-identity-dialog.tsx
new file mode 100644
index 0000000..1e8ac20
--- /dev/null
+++ b/components/chat/secure-identity-dialog.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { Copy, Loader2, ShieldCheck, ShieldAlert } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { useToast } from "@/hooks/use-toast";
+import { fetchSecureDeviceIdentitySummaries } from "@/services/secureChatService";
+
+type IdentitySummary = Awaited
>[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 && (
+
+
+ Copy fingerprints
+
+ )}
+
+
+
+ );
+}
diff --git a/components/chat/start-chart-modal.tsx b/components/chat/start-chart-modal.tsx
index 7f5d436..8cfc783 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);
}}
>
@@ -189,7 +184,7 @@ export default function StartChatModal({ isOpen, onClose, onStartChat, existingC
- No contacts found matching "{searchTerm}"
+ No contacts found matching "{searchTerm}"
) : availableContacts.length === 0 ? (
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"
/>
{
id="new-password"
type="password"
value={securityFormData.newPassword}
- onChange={(e) => updateSecurityFormData({ newPassword: e.target.value })}
+ onChange={(event) => updateSecurityFormData({ newPassword: 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"
/>
{
pattern="[0-9]*"
maxLength={4}
value={securityFormData.newPin}
- onChange={(e) => handleNumericInput(e.target.value, 'newPin')}
- placeholder="••••"
+ onChange={(event) => handleNumericInput(event.target.value, "newPin")}
+ placeholder="****"
className="dark:bg-darkBg-main dark:text-white dark:border-darkBorder-light"
/>
{
pattern="[0-9]*"
maxLength={4}
value={securityFormData.confirmNewPin}
- onChange={(e) => handleNumericInput(e.target.value, 'confirmNewPin')}
- placeholder="••••"
+ onChange={(event) => handleNumericInput(event.target.value, "confirmNewPin")}
+ 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
+
+
+
+ Refresh
+
+
-
-
-
-
-
-
Current device
-
- iPhone 13 • San Francisco, CA • Last active: Just now
-
-
-
-
- Current
-
-
-
-
-
-
-
-
Chrome on Windows
-
New York, NY • Last active: 2 days ago
-
-
-
- Sign out
-
+ {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
-
-
-
- Sign out
-
+ 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}
+
+
+
+
handleRevokeSecureDevice(device.deviceId)}
+ className="self-start text-red-500 hover:text-red-600 hover:bg-red-50 disabled:text-gray-400 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-900/20 sm:self-center"
+ >
+
+ {isRevoking ? "Revoking..." : "Revoke"}
+
+
+ );
+ })}
-
+ )}
-
-
- Sign out of all devices
-
+
+ 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/context/ChatContext.tsx b/context/ChatContext.tsx
index 6eb74d7..55d265a 100644
--- a/context/ChatContext.tsx
+++ b/context/ChatContext.tsx
@@ -11,11 +11,19 @@ import {
TypingUser,
OnlineUser,
ChatParticipantStatus,
+ MessageType,
Participant,
ReactionRow,
} from "@/types/chat.types";
import { toast } from "@/hooks/use-toast";
import { notificationService } from "@/services/notificationService";
+import { getChatPreviewText } from "@/utils/chatPreview";
+import {
+ fetchSecureChatMessages,
+ markSecureChatAsRead,
+ sendSecureReactionMessage,
+ sendSecureTextMessage,
+} from "@/services/secureChatService";
interface ChatContextType {
isConnected: boolean;
@@ -50,6 +58,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);
@@ -58,6 +67,13 @@ interface ChatProviderProps {
children: ReactNode;
}
+type NotificationMessageType = "text" | "image" | "video" | "audio" | "file" | "voice" | "money" | "secure";
+
+const toNotificationMessageType = (messageType: MessageType): NotificationMessageType => {
+ if (messageType === "document") return "file";
+ return messageType;
+};
+
export const ChatProvider = ({ children }: ChatProviderProps) => {
const { getToken, getUserId } = useAuthToken();
// Use state to track token and userId changes dynamically
@@ -71,6 +87,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;
@@ -90,6 +112,49 @@ export const ChatProvider = ({ children }: ChatProviderProps) => {
return new Date(bTime).getTime() - new Date(aTime).getTime();
}, [isSupportConversation]);
+ const updateSecureConversationPreview = useCallback(async (chatId: string) => {
+ if (!token || !userId) return null;
+
+ const secureMessages = await fetchSecureChatMessages({
+ token,
+ userId,
+ chatId,
+ page: 1,
+ limit: 1,
+ });
+ const latestMessage = secureMessages[secureMessages.length - 1] || null;
+
+ if (!latestMessage || latestMessage.content.startsWith("[Unable to decrypt")) {
+ return latestMessage;
+ }
+
+ setConversations((prev: Conversation[]) => {
+ const updatedConversations = prev.map((conv: Conversation) =>
+ conv.id === chatId
+ ? {
+ ...conv,
+ lastMessage: {
+ content: getChatPreviewText(latestMessage),
+ messageType: latestMessage.messageType,
+ createdAt: latestMessage.createdAt,
+ 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,
+ }
+ : conv
+ );
+
+ return updatedConversations.sort(sortConversations);
+ });
+
+ return latestMessage;
+ }, [sortConversations, token, userId]);
+
// Monitor token and userId changes
useEffect(() => {
const currentToken = getToken();
@@ -97,7 +162,6 @@ export const ChatProvider = ({ children }: ChatProviderProps) => {
// If userId changes (different user logged in), clear all state
if (userId && currentUserId && userId !== currentUserId) {
- console.log('Different user detected, clearing chat state');
setConversations([]);
setActiveChat(null);
setMessages({});
@@ -120,7 +184,6 @@ export const ChatProvider = ({ children }: ChatProviderProps) => {
// If user changed, clear state
if (userId && newUserId && userId !== newUserId) {
- console.log('User changed via token event, clearing chat state');
setConversations([]);
setActiveChat(null);
setMessages({});
@@ -147,7 +210,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,15 +327,101 @@ 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.senderId === userId) {
+ if (data.chatId === activeChat) {
+ setSecureMessagesRefreshKey((current) => current + 1);
+ }
+ 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" as const,
+ createdAt: data.createdAt,
+ sender: data.sender.name,
+ },
+ timestamp: data.createdAt,
+ unreadCount: shouldIncrementUnread
+ ? (conv.unreadCount || 0) + 1
+ : (conv.unreadCount || 0),
+ };
+ });
+
+ return updatedConversations.sort(sortConversations);
+ });
+
+ void updateSecureConversationPreview(data.chatId)
+ .then((latestMessage) => {
+ if (data.senderId !== userId) {
+ notificationService.notifyNewMessage({
+ chatId: data.chatId,
+ senderId: data.senderId,
+ senderName: data.sender.name,
+ content: latestMessage?.content || "",
+ messageType: latestMessage
+ ? toNotificationMessageType(latestMessage.messageType)
+ : "secure" as const,
+ });
+ }
+ })
+ .catch((error) => {
+ console.error("Failed to decrypt secure notification preview", error);
+ if (data.senderId !== userId) {
+ notificationService.notifyNewMessage({
+ chatId: data.chatId,
+ senderId: data.senderId,
+ senderName: "",
+ content: "",
+ messageType: "secure" as const,
+ });
+ }
+ });
+
+ refetchChats();
+ };
+
const handleMessageDelivered = (data: { chatId: string; messageId: string; deliveredAt: Date }) => {
setMessages((prev: Record) => ({
...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
) || []
}));
+ void updateSecureConversationPreview(data.chatId).catch((error) => {
+ console.error("Failed to update delivered secure preview", error);
+ });
};
const handleMessagesRead = (data: { chatId: string; readBy: string; readAt: Date }) => {
@@ -280,8 +429,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
) || []
}));
@@ -295,6 +452,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) => {
@@ -384,6 +544,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" as const,
+ 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,
@@ -451,6 +636,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => {
};
socketService.onNewMessage(handleNewMessage);
+ socketService.onSecureMessageAvailable(handleSecureMessageAvailable);
socketService.onMessageDelivered(handleMessageDelivered);
socketService.onMessagesRead(handleMessagesRead);
socketService.onUserTyping(handleUserTyping);
@@ -469,6 +655,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => {
return () => {
socketService.offNewMessage(handleNewMessage);
+ socketService.offSecureMessageAvailable(handleSecureMessageAvailable);
socketService.offMessageDelivered(handleMessageDelivered);
socketService.offMessagesRead(handleMessagesRead);
socketService.offUserTyping(handleUserTyping);
@@ -484,22 +671,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, updateSecureConversationPreview]);
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 +707,11 @@ export const ChatProvider = ({ children }: ChatProviderProps) => {
}
};
}
- }, [activeChat, isConnected]);
+
+ return () => {
+ notificationService.setActiveChat(null);
+ };
+ }, [activeChat, isConnected, isActiveSecureChat, token, userId]);
useEffect(() => {
if (chatsData?.data) {
@@ -519,6 +722,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,17 +737,156 @@ 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),
+ })),
+ }));
+
+ const latestMessage = secureMessages[secureMessages.length - 1] || null;
+ if (latestMessage && !latestMessage.content.startsWith("[Unable to decrypt")) {
+ setConversations((prev: Conversation[]) => {
+ const updatedConversations = prev.map((conv: Conversation) =>
+ conv.id === activeChat
+ ? {
+ ...conv,
+ lastMessage: {
+ content: getChatPreviewText(latestMessage),
+ messageType: latestMessage.messageType,
+ createdAt: latestMessage.createdAt,
+ 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,
+ }
+ : conv
+ );
+
+ return updatedConversations.sort(sortConversations);
+ });
+ }
+
+ await markSecureChatAsRead({ token, userId, chatId: activeChat });
+ } catch (error: any) {
+ console.error("Failed to load secure chat messages", error);
+ if (
+ error?.name === "SecureIdentityChangedError" ||
+ error?.message?.includes("Secure device identity changed")
+ ) {
+ toast({
+ title: "Security warning",
+ description: "A secure device identity changed. Verify this contact before continuing.",
+ variant: "destructive",
+ });
+ }
+ }
+ };
+
+ void loadSecureMessages();
+ const interval = window.setInterval(() => {
+ void loadSecureMessages();
+ }, 5000);
+
+ return () => {
+ cancelled = true;
+ window.clearInterval(interval);
+ };
+ }, [activeChat, token, userId, isActiveSecureChat, secureMessagesRefreshKey, sortConversations]);
+
+ useEffect(() => {
+ if (!token || !userId) {
+ 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",
+ );
+
+ if (!hasSecureConversations) {
+ return;
+ }
+
+ const interval = window.setInterval(() => {
+ refetchChats();
+ }, 10000);
+
+ return () => {
+ window.clearInterval(interval);
+ };
+ }, [token, userId, conversations, refetchChats, updateSecureConversationPreview]);
const refreshConversations = useCallback(() => {
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") {
+ setSecureMessagesRefreshKey((current) => current + 1);
+ return;
+ }
refetchMessages();
}
- }, [activeChat, refetchMessages]);
+ }, [activeChat, activeConversation?.securityMode, refetchMessages]);
const sendMessage = useCallback((
chatId: string,
@@ -550,13 +896,114 @@ 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: trimmedContent,
+ 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)
+ }));
+ const identityChanged =
+ error?.name === "SecureIdentityChangedError" ||
+ error?.message?.includes("Secure device identity changed");
+ toast({
+ title: identityChanged ? "Security warning" : "Secure message failed",
+ description: identityChanged
+ ? "A secure device identity changed. Verify this contact before sending."
+ : 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 +1032,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 +1046,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 +1103,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) => {
@@ -677,16 +1143,106 @@ export const ChatProvider = ({ children }: ChatProviderProps) => {
}, []);
const addReaction = useCallback((chatId: string, messageId: string, emoji: string) => {
+ const conversation = conversations.find((item) => item.id === chatId);
+ if (conversation?.securityMode === "secure_dm_v1") {
+ if (!token || !userId) {
+ toast({
+ title: "Secure reaction failed",
+ description: "Your secure session is not ready yet.",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ setMessages((prev: Record) => ({
+ ...prev,
+ [chatId]: (prev[chatId] || []).map((message) =>
+ message.id === messageId
+ ? {
+ ...message,
+ reactions: [
+ ...(message.reactions || []).filter(
+ (reaction) => reaction.userId !== userId,
+ ),
+ { userId, emoji },
+ ],
+ }
+ : message
+ ),
+ }));
+
+ void sendSecureReactionMessage({
+ token,
+ userId,
+ chatId,
+ conversation,
+ targetMessageId: messageId,
+ emoji,
+ action: "set",
+ }).catch((error) => {
+ toast({
+ title: "Secure reaction failed",
+ description: error?.message || "Failed to send encrypted reaction",
+ variant: "destructive",
+ });
+ setSecureMessagesRefreshKey((current) => current + 1);
+ });
+ return;
+ }
+
if (isConnected) {
socketService.addReaction(chatId, messageId, emoji);
}
- }, [isConnected]);
+ }, [conversations, isConnected, token, userId]);
const removeReaction = useCallback((chatId: string, messageId: string) => {
+ const conversation = conversations.find((item) => item.id === chatId);
+ if (conversation?.securityMode === "secure_dm_v1") {
+ if (!token || !userId) {
+ toast({
+ title: "Secure reaction failed",
+ description: "Your secure session is not ready yet.",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ setMessages((prev: Record) => ({
+ ...prev,
+ [chatId]: (prev[chatId] || []).map((message) =>
+ message.id === messageId
+ ? {
+ ...message,
+ reactions: (message.reactions || []).filter(
+ (reaction) => reaction.userId !== userId,
+ ),
+ }
+ : message
+ ),
+ }));
+
+ void sendSecureReactionMessage({
+ token,
+ userId,
+ chatId,
+ conversation,
+ targetMessageId: messageId,
+ action: "remove",
+ }).catch((error) => {
+ toast({
+ title: "Secure reaction failed",
+ description: error?.message || "Failed to remove encrypted reaction",
+ variant: "destructive",
+ });
+ setSecureMessagesRefreshKey((current) => current + 1);
+ });
+ return;
+ }
+
if (isConnected) {
socketService.removeReaction(chatId, messageId);
}
- }, [isConnected]);
+ }, [conversations, isConnected, token, userId]);
const clearChatState = useCallback(() => {
// Disconnect socket properly
@@ -730,6 +1286,7 @@ export const ChatProvider = ({ children }: ChatProviderProps) => {
clearChatState,
addReaction,
removeReaction,
+ upsertConversation,
};
return (
@@ -748,4 +1305,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-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 ad15f4b..b417a4b 100644
--- a/hooks/use-chat-operations.ts
+++ b/hooks/use-chat-operations.ts
@@ -1,11 +1,10 @@
'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 {
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,
@@ -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 {
@@ -64,8 +149,6 @@ export function useChatOperations(): UseChatOperationsReturn {
skip: !token,
});
- const [createOrGetDMChat, { isLoading: isCreatingDMChat }] =
- useCreateOrGetDMChatMutation();
const [sendMessage, { isLoading: isSendingMessage }] =
useSendMessageMutation();
const [markAsRead] = useMarkMessagesAsReadMutation();
@@ -75,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) {
@@ -98,12 +175,14 @@ 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,
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 +206,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),
@@ -146,30 +227,92 @@ 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) => {
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,
+ });
+
+ upsertConversation({
+ id: result.chatId,
+ name: `${contact.otherUser.firstName} ${contact.otherUser.lastName}`.trim() || 'Unknown Contact',
+ isGroup: false,
+ type: 'dm',
+ 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: "Chat Started",
- description: `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?.()
} 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, upsertConversation, userId])
const handleJoinGroup = useCallback(async (group: any) => {
try {
@@ -253,7 +396,7 @@ export function useChatOperations(): UseChatOperationsReturn {
conversations,
activeChat,
messages,
- isLoading: chatsLoading || isCreatingDMChat || isSendingMessage || isCreatingGroup || isJoiningGroup,
+ isLoading: chatsLoading || isSendingMessage || isCreatingGroup || isJoiningGroup,
isConnected,
typingUsers,
onlineUsers,
@@ -263,6 +406,7 @@ export function useChatOperations(): UseChatOperationsReturn {
handleCreateGroupChat,
handleDeleteChat,
handleMarkAsRead,
- refreshConversations
+ refreshConversations,
+ upsertConversation,
}
}
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/lib/e2ee/deviceStore.ts b/lib/e2ee/deviceStore.ts
new file mode 100644
index 0000000..17f653a
--- /dev/null
+++ b/lib/e2ee/deviceStore.ts
@@ -0,0 +1,128 @@
+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 null;
+ }
+
+ try {
+ const state = await withStore("readonly", (store) =>
+ store.get("active"),
+ );
+
+ 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 null;
+ }
+};
+
+export const saveStoredSecureDeviceState = async (state: StoredSecureDeviceState) => {
+ if (typeof window === "undefined") return;
+
+ if (!canUseIndexedDb()) {
+ throw new Error("Secure device storage requires IndexedDB");
+ }
+
+ try {
+ await withStore("readwrite", (store) => store.put(state, "active"));
+ writeFallbackState(null);
+ } catch {
+ throw new Error("Failed to store secure device 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/identityTrustStore.ts b/lib/e2ee/identityTrustStore.ts
new file mode 100644
index 0000000..7a64935
--- /dev/null
+++ b/lib/e2ee/identityTrustStore.ts
@@ -0,0 +1,88 @@
+"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) {
+ const error = new Error("Secure device identity changed. Verify this contact before continuing.");
+ error.name = "SecureIdentityChangedError";
+ throw error;
+ }
+
+ if (!existing) {
+ pins[pinKey] = fingerprint;
+ writePins(pins);
+ }
+
+ return fingerprint;
+};
diff --git a/lib/e2ee/secureMediaCrypto.ts b/lib/e2ee/secureMediaCrypto.ts
new file mode 100644
index 0000000..3067caf
--- /dev/null
+++ b/lib/e2ee/secureMediaCrypto.ts
@@ -0,0 +1,90 @@
+"use client";
+
+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;
+};
+
+export interface SecureMediaEncryptionResult {
+ encryptedFile: File;
+ key: string;
+ iv: string;
+ originalName: string;
+ originalType: string;
+ originalSize: number;
+}
+
+export const encryptSecureMediaFile = async (file: File): Promise => {
+ const rawKey = window.crypto.getRandomValues(new Uint8Array(32));
+ const iv = window.crypto.getRandomValues(new Uint8Array(12));
+ const key = await window.crypto.subtle.importKey("raw", rawKey, "AES-GCM", false, ["encrypt"]);
+ const ciphertext = await window.crypto.subtle.encrypt(
+ {
+ name: "AES-GCM",
+ iv,
+ },
+ key,
+ await file.arrayBuffer(),
+ );
+ const encryptedFile = new File([ciphertext], `${file.name}.qcenc`, {
+ type: "application/octet-stream",
+ });
+
+ return {
+ encryptedFile,
+ key: toBase64Url(rawKey),
+ iv: toBase64Url(iv),
+ originalName: file.name,
+ originalType: file.type || "application/octet-stream",
+ originalSize: file.size,
+ };
+};
+
+export const decryptSecureMediaBlob = async ({
+ encryptedBlob,
+ key,
+ iv,
+ originalType,
+}: {
+ encryptedBlob: Blob;
+ key: string;
+ iv: string;
+ originalType: string;
+}) => {
+ const cryptoKey = await window.crypto.subtle.importKey(
+ "raw",
+ fromBase64Url(key),
+ "AES-GCM",
+ false,
+ ["decrypt"],
+ );
+ const plaintext = await window.crypto.subtle.decrypt(
+ {
+ name: "AES-GCM",
+ iv: fromBase64Url(iv),
+ },
+ cryptoKey,
+ await encryptedBlob.arrayBuffer(),
+ );
+
+ return new Blob([plaintext], { type: originalType });
+};
diff --git a/lib/e2ee/secureMessageCrypto.ts b/lib/e2ee/secureMessageCrypto.ts
new file mode 100644
index 0000000..7c42247
--- /dev/null
+++ b/lib/e2ee/secureMessageCrypto.ts
@@ -0,0 +1,385 @@
+"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)),
+ );
+};
+
+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,
+ 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 selectedOneTimePreKey =
+ recipientDevice.userId === senderUserId
+ ? null
+ : pickOneTimePreKey(recipientDevice.oneTimePreKeys);
+ const recipientPublicKey = await importExchangePublicKey(
+ selectedOneTimePreKey?.publicKey || 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,
+ recipientOneTimePreKeyId: selectedOneTimePreKey?.keyId || null,
+ 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 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: recipientPreKeyPrivate,
+ 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/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/package.json b/package.json
index b6849e1..e2e3bab 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");
diff --git a/services/e2eeDeviceService.ts b/services/e2eeDeviceService.ts
new file mode 100644
index 0000000..65f5879
--- /dev/null
+++ b/services/e2eeDeviceService.ts
@@ -0,0 +1,541 @@
+"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 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;
+
+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);
+ }
+
+ 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 = () =>
+ 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 }> };
+ };
+
+ 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 (
+ count = ONE_TIME_PREKEY_COUNT,
+ existingIds = new Set(),
+): Promise => {
+ const keys: StoredOneTimePreKey[] = [];
+
+ for (let index = 0; index < count; index += 1) {
+ const keyPair = await generateExchangeKeyPair();
+ const exported = await exportPrivatePublicPair(keyPair);
+
+ keys.push({
+ keyId: getUniqueRandomId(existingIds),
+ publicKey: exported.publicKey,
+ privateKey: exported.privateKey,
+ });
+ }
+
+ 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 exportedIdentity = await exportPrivatePublicPair(identityKeys);
+ const usedKeyIds = new Set();
+ const signedPreKey = await createSignedPreKey(identityKeys.privateKey, usedKeyIds);
+
+ 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,
+ oneTimePreKeys: await generateOneTimePreKeys(ONE_TIME_PREKEY_COUNT, usedKeyIds),
+ lastServerSyncAt: null,
+ };
+};
+
+const hasValidPreKeyIds = (state: StoredSecureDeviceState) =>
+ 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 hasStaleSyncMarker = (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 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;
+ }
+};
+
+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 => ({
+ 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 });
+ }
+};
+
+const rotateServerSignedPreKey = async (token: string, state: StoredSecureDeviceState) => {
+ if (!baseUrl) {
+ throw new Error("NEXT_PUBLIC_API_URL is not configured");
+ }
+
+ 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,
+ });
+ }
+};
+
+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);
+ throw Object.assign(new Error(payload?.message || "Failed to upload one-time pre-keys"), {
+ statusCode: response.status,
+ });
+ }
+};
+
+const importIdentityPrivateKey = (privateKey: JsonWebKey) =>
+ window.crypto.subtle.importKey(
+ "jwk",
+ privateKey,
+ {
+ name: "ECDSA",
+ namedCurve: "P-256",
+ },
+ false,
+ ["sign"],
+ );
+
+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 ({
+ 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 (!hasValidPreKeyIds(state)) {
+ state = await createFreshDeviceState(userId);
+ await saveStoredSecureDeviceState(state);
+ }
+
+ const registeredDevice = await getRegisteredDeviceSummary(token, state.deviceId);
+
+ const needsServerSync =
+ !registeredDevice ||
+ !registeredDevice.bundle?.algorithm ||
+ Number(registeredDevice.availableOneTimePreKeys || 0) <= 0;
+
+ if (needsServerSync) {
+ 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;
+ }
+ }
+
+ try {
+ state = await refreshDeviceKeyMaterial(
+ token,
+ state,
+ Number(registeredDevice.availableOneTimePreKeys || 0),
+ );
+ } catch (error) {
+ console.error("Secure device key refresh failed:", error);
+ }
+
+ if (hasStaleSyncMarker(state)) {
+ const nextState = {
+ ...state,
+ lastServerSyncAt: new Date().toISOString(),
+ };
+ await saveStoredSecureDeviceState(nextState);
+ return nextState;
+ }
+
+ return state;
+};
diff --git a/services/notificationService.ts b/services/notificationService.ts
index 2092c52..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,11 +93,25 @@ class NotificationService {
senderId: string;
senderName: string;
content: string;
- messageType: 'text' | 'image' | 'file' | 'voice' | 'money';
+ messageType: 'text' | 'image' | 'video' | 'audio' | 'file' | 'voice' | 'money' | 'secure';
}) {
+ if (data.messageType === 'secure') {
+ await this.notify({
+ type: NotificationType.MESSAGE,
+ title: 'New secure message',
+ message: 'Open QueCode to view this encrypted message.',
+ url: `${window.location.origin}/chat`,
+ chatId: data.chatId,
+ senderId: data.senderId,
+ });
+ return;
+ }
+
const typeMap: Record = {
text: NotificationType.MESSAGE,
image: NotificationType.MEDIA,
+ video: NotificationType.MEDIA,
+ audio: NotificationType.VOICE,
file: NotificationType.DOCUMENT,
voice: NotificationType.VOICE,
money: NotificationType.MONEY,
@@ -105,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`,
@@ -113,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
new file mode 100644
index 0000000..d4fccca
--- /dev/null
+++ b/services/secureChatService.ts
@@ -0,0 +1,727 @@
+"use client";
+
+import baseUrl from "@/helpers/baseUrl";
+import { assertTrustedDeviceIdentity, getIdentityFingerprint } from "@/lib/e2ee/identityTrustStore";
+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 { getChatPreviewText } from "@/utils/chatPreview";
+import type {
+ PublicSecureDeviceBundle,
+ SecureEncryptedEnvelope,
+ StoredSecureDeviceState,
+} from "@/types/e2ee.types";
+
+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;
+
+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");
+ }
+
+ 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 parseSecureControlMessage = (content: string) => {
+ try {
+ const payload = JSON.parse(content);
+ if (payload?.kind === "reaction" && payload?.targetMessageId) {
+ return payload as {
+ kind: "reaction";
+ version?: number;
+ targetMessageId: string;
+ action: "set" | "remove";
+ emoji?: string;
+ };
+ }
+ } catch {
+ return null;
+ }
+
+ return null;
+};
+
+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;
+};
+
+export const getSecureConversationRecipientId = (conversation: Conversation, userId: string) =>
+ getConversationRecipient(conversation, userId);
+
+const fetchPublicDeviceBundles = async (
+ token: string,
+ userId: string,
+ options?: { forceRefresh?: boolean },
+) => {
+ const cacheKey = `bundles:${userId}`;
+ const cached = bundleCache.get(cacheKey);
+ if (!options?.forceRefresh && 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 rawDevices = (payload?.data || []).map((device: any) => ({
+ userId,
+ deviceId: device.deviceId,
+ deviceName: device.deviceName,
+ platform: device.platform,
+ bundle: device.bundle,
+ oneTimePreKeys: device.oneTimePreKeys || [],
+ })) as PublicSecureDeviceBundle[];
+
+ const usableDevices = rawDevices.filter((device) => {
+ const valid = hasUsablePublicBundle(device);
+ if (!valid) {
+ console.warn("Skipping invalid secure device bundle", {
+ userId,
+ deviceId: device.deviceId,
+ });
+ }
+ 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(),
+ devices,
+ });
+
+ return devices;
+};
+
+const isUnavailableOneTimePreKeyError = (error: unknown) =>
+ error instanceof Error &&
+ (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,
+}: {
+ 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,
+ 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 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 ||
+ getChatPreviewText({ messageType: mediaPayload.mediaType || rawMessage.messageType });
+ 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]";
+ }
+ }
+ return {
+ id: rawMessage.id,
+ chatId: rawMessage.chatId,
+ content,
+ ...mediaFields,
+ 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: rawMessage.readBy || [],
+ deliveryConfirmed: Boolean(rawMessage.deliveredAt),
+ } 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: rawMessage.readBy || [],
+ deliveryConfirmed: Boolean(rawMessage.deliveredAt),
+ } satisfies Message;
+ }
+ }),
+ );
+
+ const chronologicalMessages = decryptedMessages.reverse();
+ const visibleMessages: Message[] = [];
+
+ for (const message of chronologicalMessages) {
+ const controlMessage = parseSecureControlMessage(message.content);
+
+ if (controlMessage?.kind === "reaction") {
+ const target = visibleMessages.find((item) => item.id === controlMessage.targetMessageId);
+ if (target) {
+ const existingReactions = target.reactions || [];
+ const withoutSender = existingReactions.filter(
+ (reaction) => reaction.userId !== message.sender.id,
+ );
+
+ target.reactions =
+ controlMessage.action === "set" && controlMessage.emoji
+ ? [...withoutSender, { userId: message.sender.id, emoji: controlMessage.emoji }]
+ : withoutSender;
+ }
+ continue;
+ }
+
+ visibleMessages.push(message);
+ }
+
+ return visibleMessages;
+};
+
+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;
+}) => {
+ const secureChat = await createOrGetSecureDmChat({
+ token,
+ participantId,
+ });
+
+ return {
+ ...secureChat,
+ usedSecure: true,
+ };
+};
+
+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 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: targetDevices,
+ });
+
+ return 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,
+ }),
+ });
+ };
+
+ const payload = await sendWithStalePreKeyRetry({
+ attempt: sendAttempt,
+ cacheKeys: [`bundles:${userId}`, `bundles:${recipientUserId}`],
+ });
+
+ bundleCache.delete(`bundles:${userId}`);
+ bundleCache.delete(`bundles:${recipientUserId}`);
+
+ 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 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 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 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: targetDevices,
+ });
+
+ return 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,
+ }),
+ });
+ };
+
+ const messagePayload = await sendWithStalePreKeyRetry({
+ attempt: sendAttempt,
+ cacheKeys: [`bundles:${userId}`, `bundles:${recipientUserId}`],
+ });
+
+ bundleCache.delete(`bundles:${userId}`);
+ bundleCache.delete(`bundles:${recipientUserId}`);
+
+ return {
+ id: messagePayload.data.id,
+ chatId,
+ content: caption || getChatPreviewText({ messageType: mediaType }),
+ 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 sendSecureReactionMessage = async ({
+ token,
+ userId,
+ chatId,
+ conversation,
+ targetMessageId,
+ emoji,
+ action,
+}: {
+ token: string;
+ userId: string;
+ chatId: string;
+ conversation: Conversation;
+ targetMessageId: string;
+ emoji?: string;
+ action: "set" | "remove";
+}) => {
+ const state = await getSecureDeviceState(token, userId);
+ const recipientUserId = getConversationRecipient(conversation, userId);
+
+ 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",
+ version: 1,
+ targetMessageId,
+ action,
+ emoji: action === "set" ? emoji : null,
+ }),
+ senderUserId: userId,
+ senderState: state,
+ recipientDevices: targetDevices,
+ });
+
+ return 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: targetMessageId,
+ recipientPayloads,
+ }),
+ });
+ };
+
+ await sendWithStalePreKeyRetry({
+ attempt: sendAttempt,
+ cacheKeys: [`bundles:${userId}`, `bundles:${recipientUserId}`],
+ });
+
+ bundleCache.delete(`bundles:${userId}`);
+ bundleCache.delete(`bundles:${recipientUserId}`);
+};
+
+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 a475d6c..c430aa7 100644
--- a/services/socketService.ts
+++ b/services/socketService.ts
@@ -28,12 +28,10 @@ class SocketService {
this.connectionCount++
if (this.socket?.connected) {
- console.log(`Socket already connected. Connection count: ${this.connectionCount}`)
return this.socket
}
this.userId = userId
- console.log(`Creating new socket connection. Connection count: ${this.connectionCount}`)
const finalToken = token || getValidToken();
@@ -46,16 +44,13 @@ class SocketService {
})
this.socket.on("connect", () => {
- console.log("Connected to chat server")
// User is automatically joined to their chats on connection
})
this.socket.on("disconnect", () => {
- console.log("Disconnected from socket server")
})
this.socket.on("connect_error", async (error) => {
- console.error("Socket connection error:", error)
if (error.message.includes('Authentication error') && !this.refreshInProgress) {
this.refreshInProgress = true
@@ -81,11 +76,9 @@ class SocketService {
disconnect() {
this.connectionCount = Math.max(0, this.connectionCount - 1)
- console.log(`Disconnect requested. Connection count: ${this.connectionCount}`)
// Only actually disconnect when no contexts are using the socket
if (this.connectionCount === 0 && this.socket) {
- console.log("Actually disconnecting socket")
this.socket.disconnect()
this.socket = null
this.userId = null
@@ -94,7 +87,6 @@ class SocketService {
// Force disconnect (for logout)
forceDisconnect() {
- console.log("Force disconnecting socket")
if (this.socket) {
this.socket.removeAllListeners()
this.socket.disconnect()
@@ -182,19 +174,15 @@ class SocketService {
if (this.socket.connected) {
this.socket.emit("send_message", payload)
} else {
- console.error('Socket exists but is not connected. Attempting to reconnect...');
this.socket.connect();
setTimeout(() => {
if (this.socket?.connected) {
- console.log('Reconnected, sending message');
this.socket.emit("send_message", payload)
} else {
- console.error('Failed to reconnect socket');
}
}, 1000);
}
} else {
- console.error('No socket available to send message');
}
}
@@ -223,6 +211,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 +227,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/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/types/chat.types.ts b/types/chat.types.ts
index 900e41d..030aad8 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;
@@ -24,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
| {
@@ -38,6 +44,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 +63,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[];
@@ -83,6 +93,9 @@ export interface MediaData {
thumbnailUrl?: string;
fileName?: string;
mimeType?: string;
+ secureMediaKey?: string;
+ secureMediaIv?: string;
+ isSecureMedia?: boolean;
duration?: number;
width?: number;
height?: number;
@@ -126,6 +139,7 @@ export interface Message extends MediaData {
sender: MessageSender;
readBy?: ReadReceipt[];
isMe?: boolean;
+ deliveryConfirmed?: boolean;
mentions?: MentionData[];
reactions?: ReactionRow[];
}
diff --git a/types/e2ee.types.ts b/types/e2ee.types.ts
new file mode 100644
index 0000000..1d4f56a
--- /dev/null
+++ b/types/e2ee.types.ts
@@ -0,0 +1,86 @@
+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;
+ createdAt?: string | null;
+ };
+ 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;
+ recipientOneTimePreKeyId?: number | null;
+ ephemeralPublicKey: JsonWebKey;
+ wrappedMessageKey: string;
+ wrappedMessageKeyIv: string;
+ ciphertext: string;
+ ciphertextIv: string;
+ createdAt: string;
+ signature: string;
+}
+
+export interface SecureRecipientPayload {
+ recipientUserId: string;
+ recipientDeviceId: string;
+ encryptedEnvelope: SecureEncryptedEnvelope;
+}
diff --git a/utils/avatar.ts b/utils/avatar.ts
new file mode 100644
index 0000000..99c0cb6
--- /dev/null
+++ b/utils/avatar.ts
@@ -0,0 +1,9 @@
+export const isPlaceholderAvatar = (src?: string | null) => {
+ 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";
+};
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 || "";
+};
diff --git a/utils/tokenUtils.ts b/utils/tokenUtils.ts
index 57566fb..d1f4179 100644
--- a/utils/tokenUtils.ts
+++ b/utils/tokenUtils.ts
@@ -143,13 +143,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,
}),
);
}