Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
e91d486
feat(chat): add secure dm frontend foundation
LinzMatt May 18, 2026
ee3ef75
fix(chat): satisfy secure dm type checks
LinzMatt May 18, 2026
1190dba
wip: preserve local secure chat frontend changes
LinzMatt May 27, 2026
b999892
merge dev2 into secure chat hardening
LinzMatt May 27, 2026
a1eee7e
fix secure chat frontend type checks
LinzMatt May 27, 2026
6db1c15
harden secure device state storage
LinzMatt May 27, 2026
dd167b3
ignore local next dev output
LinzMatt May 27, 2026
82745c3
pin secure device identities locally
LinzMatt May 27, 2026
1d7c92e
use one-time prekeys for secure envelopes
LinzMatt May 27, 2026
cec095c
refresh recipient device bundles after secure send
LinzMatt May 27, 2026
431529a
add secure identity verification dialog
LinzMatt May 27, 2026
3406f39
block plaintext media in secure chats
LinzMatt May 27, 2026
451e6c4
send encrypted media in secure chats
LinzMatt May 27, 2026
9e25d10
preview secure media after client decrypt
LinzMatt May 27, 2026
696676a
cover secure media crypto smoke flow
LinzMatt May 27, 2026
9ee3a30
refresh secure device key material
LinzMatt May 27, 2026
ef6bb35
manage secure chat devices in settings
LinzMatt May 27, 2026
bbb87a5
block plaintext reactions in secure chats
LinzMatt May 27, 2026
ac9ec06
sanitize secure chat notifications
LinzMatt May 27, 2026
6133246
retry secure send after stale prekey
LinzMatt May 28, 2026
67c75fd
keep local prekeys for secure history
LinzMatt May 28, 2026
3e0e185
show decrypted secure chat previews
LinzMatt May 28, 2026
e0351b1
preview secure images automatically
LinzMatt May 28, 2026
ee2fc41
auto preview secure playable media
LinzMatt May 28, 2026
be4122f
Send secure reactions as encrypted controls
LinzMatt May 28, 2026
37f735c
Simplify secure media previews
LinzMatt May 28, 2026
9f2b170
Compact chat message bubbles
LinzMatt May 28, 2026
f4c0ad4
Tighten DM message layout
LinzMatt May 29, 2026
e101839
Fix DM spacing and message status indicators
LinzMatt May 29, 2026
40177ec
Use strict chat receipt indicators
LinzMatt May 29, 2026
5193a76
Use secure receipt proofs in chat UI
LinzMatt May 29, 2026
ce74484
Stabilize secure chat receipts UI
LinzMatt May 29, 2026
b43c255
Stabilize secure chat retries and switching
LinzMatt Jul 20, 2026
d0038f0
Refine mobile chat conversation UI
LinzMatt Jul 20, 2026
89261dd
Refine chat UX for mobile messaging
LinzMatt Jul 22, 2026
3a01277
Merge remote-tracking branch 'origin/dev2' into qc-secure-chat-harden…
LinzMatt Jul 22, 2026
d6d449b
Fix secure chat PR lint issues
LinzMatt Jul 22, 2026
b49cd57
chore: rerun PR validation
LinzMatt Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

# next.js
/.next/
/.next-dev-local/
/out/

# production
Expand Down
48 changes: 44 additions & 4 deletions app/contacts/components/ContactsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useAuthToken } from "@/hooks/use-auth-token";
import { useToast } from "@/hooks/use-toast";
import { ManageTagsDialog } from "./ManageTagsDialog";
import { useRouter } from "next/navigation";
import { createOrGetPreferredDmChat } from "@/services/secureChatService";

interface ContactsTableProps {
contacts: Contact[];
Expand Down Expand Up @@ -57,9 +58,41 @@ export function ContactsTable({ contacts, isLoading, onSelect }: ContactsTablePr
}
};

const handleQuickMessage = (e: React.MouseEvent) => {
const handleSendMessage = async (contact: Contact) => {
if (!token) {
toast({
title: "Authentication required",
description: "Please log in again to open a conversation.",
variant: "destructive",
});
return;
}

try {
const result = await createOrGetPreferredDmChat({
token,
participantId: contact.otherUser.id,
});

sessionStorage.setItem("pendingChatId", result.chatId);
router.push("/chat");

toast({
title: "Secure Chat Ready",
description: `Opening a secure conversation with ${contact.otherUser.firstName} ${contact.otherUser.lastName}.`,
});
} catch (error: any) {
toast({
title: "Unable to open chat",
description: error?.data?.message || error?.message || "Please try again.",
variant: "destructive",
});
}
};

const handleQuickMessage = (contact: Contact, e: React.MouseEvent) => {
e.stopPropagation();
router.push("/chat");
void handleSendMessage(contact);
};

const handleQuickSend = (contact: Contact, e: React.MouseEvent) => {
Expand Down Expand Up @@ -189,7 +222,7 @@ export function ContactsTable({ contacts, isLoading, onSelect }: ContactsTablePr
variant="ghost"
size="icon"
className="h-9 w-9 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
onClick={handleQuickMessage}
onClick={(e) => handleQuickMessage(contact, e)}
title="Message"
>
<MessageCircle className="h-4 w-4" />
Expand Down Expand Up @@ -240,6 +273,9 @@ export function ContactsTable({ contacts, isLoading, onSelect }: ContactsTablePr
<DropdownMenuItem onClick={(e) => handleOpenProfile(e, contact.otherUser.id)}>
View Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleSendMessage(contact)}>
Send Message
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setManagingTagsContactId(contact.id)}>
Edit Tags
</DropdownMenuItem>
Expand Down Expand Up @@ -308,6 +344,10 @@ export function ContactsTable({ contacts, isLoading, onSelect }: ContactsTablePr
<DropdownMenuItem onClick={(e) => handleOpenProfile(e, contact.otherUser.id)}>
View Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void handleSendMessage(contact)}>
Send Message
</DropdownMenuItem>
<DropdownMenuItem>Send Money</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setManagingTagsContactId(contact.id)}>
Edit Tags
</DropdownMenuItem>
Expand All @@ -327,7 +367,7 @@ export function ContactsTable({ contacts, isLoading, onSelect }: ContactsTablePr
variant="outline"
size="sm"
className="h-8 flex-1 rounded-full text-xs border-gray-200 text-gray-700 hover:bg-gray-50 dark:border-darkBorder-light/60 dark:text-gray-200 dark:hover:bg-darkBg-interactive"
onClick={handleQuickMessage}
onClick={(e) => handleQuickMessage(contact, e)}
>
<MessageCircle className="h-3.5 w-3.5 mr-1" />
Message
Expand Down
12 changes: 6 additions & 6 deletions app/welcome/[userId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from 'lucide-react';
import axios from 'axios';
import baseUrl from '@/helpers/baseUrl';
import { createOrGetPreferredDmChat } from '@/services/secureChatService';
import Navigation from '@/components/Navigation';
import { Header } from '@/components/Header';
import { useUserInfo } from '@/hooks/use-user-info';
Expand Down Expand Up @@ -1319,13 +1320,12 @@ const WelcomeProfilePage: React.FC = () => {
return;
}

const response = await axios.post(
`${baseUrl}/chats/dm`,
{ participantId: userId },
{ headers: { Authorization: `Bearer ${token}` } }
);
const result = await createOrGetPreferredDmChat({
token,
participantId: userId,
});

const chatId = response?.data?.data?.chatId;
const chatId = result?.chatId;
if (chatId) {
sessionStorage.setItem('pendingChatId', chatId);
}
Expand Down
2 changes: 2 additions & 0 deletions components/ClientProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import BrowserNotificationBadge from "@/components/notifications/BrowserNotifica
import AuthSessionManager from "@/components/AuthSessionManager"
import AppLockGate from "@/components/AppLockGate"
import ChatNotificationSync from "@/components/ChatNotificationSync"
import SecureDeviceBootstrap from "@/components/SecureDeviceBootstrap"

const APP_LOCK_ENABLED = process.env.NEXT_PUBLIC_ENABLE_APP_LOCK === "true"
const APP_LOCK_STORAGE_KEYS = ["qc:appLocked", "qc:lastActivityAt"]
Expand All @@ -36,6 +37,7 @@ const ClientProvider = ({ children }: { children: React.ReactNode }) => {
return (
<Provider store={store}>
<AuthSessionManager />
<SecureDeviceBootstrap />
<ThemeProvider>
{APP_LOCK_ENABLED ? <AppLockGate /> : null}
<NotificationProvider>
Expand Down
71 changes: 71 additions & 0 deletions components/SecureDeviceBootstrap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use client";

import { useEffect } from "react";
import { ensureRegisteredSecureDevice } from "@/services/e2eeDeviceService";
import {
getCurrentUserInfo,
getStoredUserInfo,
getValidToken,
refreshAccessToken,
} from "@/utils/tokenUtils";

const runBootstrap = async () => {
const authUser = getStoredUserInfo();
const tokenUserInfo = getCurrentUserInfo();
const resolvedUserId =
tokenUserInfo.accountType === "user" ? tokenUserInfo.userId : authUser?.id || null;
const resolvedAccountType =
tokenUserInfo.accountType !== "unknown"
? tokenUserInfo.accountType
: authUser?.accountType || null;

if (!resolvedUserId || resolvedAccountType !== "user") {
return;
}

const token = getValidToken() || (await refreshAccessToken());
if (!token) {
return;
}

await ensureRegisteredSecureDevice({
token,
userId: resolvedUserId,
});
};

export default function SecureDeviceBootstrap() {
useEffect(() => {
if (typeof window === "undefined") {
return;
}

void runBootstrap();

const onResume = () => {
void runBootstrap();
};
const onTokenChanged = () => {
void runBootstrap();
};
const onVisibilityChange = () => {
if (!document.hidden) {
void runBootstrap();
}
};

window.addEventListener("online", onResume);
window.addEventListener("focus", onResume);
window.addEventListener("authTokenChanged", onTokenChanged as EventListener);
document.addEventListener("visibilitychange", onVisibilityChange);

return () => {
window.removeEventListener("online", onResume);
window.removeEventListener("focus", onResume);
window.removeEventListener("authTokenChanged", onTokenChanged as EventListener);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, []);

return null;
}
47 changes: 12 additions & 35 deletions components/chat/chat-area.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"use client"

import { useRef, useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { Send, HandCoins, Target } from "lucide-react"
import { Send } from "lucide-react"
import ChatHeader from "./chat-header"
import MessageItem from "./message-item"
import MessageInput from "./message-input"
Expand Down Expand Up @@ -66,6 +65,7 @@ interface ChatAreaProps {
onInviteToGroup: () => void
onGroupSettings?: () => void
onDeleteGroup?: () => void
onVerifySecurity?: () => void
onCreateContribution?: () => void
isGroupAdmin?: boolean
typingUsers?: any[]
Expand All @@ -83,6 +83,7 @@ export default function ChatArea({
onInviteToGroup,
onGroupSettings,
onDeleteGroup,
onVerifySecurity,
onCreateContribution,
isGroupAdmin = false,
typingUsers = [],
Expand Down Expand Up @@ -113,40 +114,13 @@ export default function ChatArea({
onInviteToGroup={onInviteToGroup}
onGroupSettings={onGroupSettings}
onDeleteGroup={onDeleteGroup}
onVerifySecurity={onVerifySecurity}
onSendMoney={onSendMoney}
onRequestMoney={onRequestMoney}
onCreateContribution={onCreateContribution}
isGroupAdmin={isGroupAdmin}
/>
</div>

{/* Action Buttons */}
<div className="flex gap-2 p-3 sm:p-4 border-b border-gray-100 dark:border-darkBorder-light bg-white dark:bg-darkBg-card flex-shrink-0">
<Button
onClick={onSendMoney}
className="bg-brand-green hover:bg-brand-green/90 dark:bg-brand-gold dark:hover:bg-brand-gold/90 text-white dark:text-darkBg-main text-xs sm:text-sm py-2 px-4 rounded-lg shadow-sm transition-all duration-200 flex-1 sm:flex-none"
>
<Send size={14} className="mr-1.5 hidden sm:inline" />
Send Money
</Button>
{!conversation.isGroup && (
<Button
onClick={onRequestMoney}
variant="outline"
className="text-xs sm:text-sm py-2 px-4 rounded-lg shadow-sm transition-all duration-200 flex-1 sm:flex-none"
>
<HandCoins size={14} className="mr-1.5 hidden sm:inline" />
Request Money
</Button>
)}
{conversation.isGroup && isGroupAdmin && onCreateContribution && (
<Button
onClick={onCreateContribution}
variant="outline"
className="text-xs sm:text-sm py-2 px-4 rounded-lg shadow-sm transition-all duration-200 flex-1 sm:flex-none border-brand-green/40 dark:border-brand-gold/40 text-brand-green dark:text-brand-gold hover:bg-brand-green/5 dark:hover:bg-brand-gold/5"
>
<Target size={14} className="mr-1.5 hidden sm:inline" />
Contributions
</Button>
)}
</div>

{/* Messages */}
<div className="flex-1 overflow-y-auto p-3 sm:p-4">
{messages.length > 0 ? (
Expand All @@ -162,7 +136,7 @@ export default function ChatArea({
return (
<div key={message.id}>
{showSep && <DateSeparator date={msgDate} />}
<div className="mb-4">
<div className="mb-0.5">
<MessageItem
message={message}
onReply={(reply) => setReplyToMessage(reply)}
Expand Down Expand Up @@ -216,7 +190,10 @@ export default function ChatArea({
{/* Message Input */}
<div className="flex-shrink-0">
<MessageInput
chatId={conversation.id}
groupId={conversation.isGroup ? conversation.groupId : undefined}
isSecureChat={conversation.securityMode === "secure_dm_v1"}
secureConversation={conversation}
replyToMessage={replyToMessage}
onCancelReply={() => setReplyToMessage(null)}
/>
Expand Down
Loading
Loading