Files
Synapsis/src/app/chat/page.tsx
T
cyph3rasi 97869daed0 Replace Chat compose dead-end spinners with bounded network requests and stable retry/back error recovery
Hop-State: A_06FPC4H813DQ7CC2FH8EW90
Hop-Proposal: R_06FPC4GJZVS16ZX6G5Y1N08
Hop-Task: T_06FPC3561KZXPYN0FRX2B70
Hop-Attempt: AT_06FPC3561JKBTYMC4AQVS1R
2026-07-15 06:58:13 -07:00

1331 lines
65 KiB
TypeScript

'use client';
import { Fragment, useCallback, useState, useEffect, useRef } from 'react';
import { useAuth } from '@/lib/contexts/AuthContext';
import { signedAPI } from '@/lib/api/signed-fetch';
import { ArrowLeft, Send, Loader2, LockKeyhole, MessageCircle, Search, Trash2 } from 'lucide-react';
import Link from 'next/link';
import { getProfilePath, useFormattedHandle } from '@/lib/utils/handle';
import { useRouter, useSearchParams } from 'next/navigation';
import { AvatarImage } from '@/components/AvatarImage';
import { E2EEChatGate } from '@/components/E2EEChatGate';
import { IdentityUnlockPrompt } from '@/components/IdentityUnlockPrompt';
import {
decryptStoredChatMessage,
E2EEClientError,
resolveE2EEPublicBundle,
type StoredChatMessage,
} from '@/lib/e2ee/client';
import { encryptE2EEMessage } from '@/lib/e2ee/client-crypto';
import type { E2EEKeyBundle, E2EEMessageEnvelope } from '@/lib/e2ee/protocol';
import { useE2EEIdentity } from '@/lib/e2ee/use-e2ee-identity';
interface Conversation {
id: string;
participant2: {
handle: string;
displayName: string;
avatarUrl: string | null;
did?: string; // Add DID support
};
lastMessageAt: string;
lastMessagePreview: string;
lastMessage?: StoredChatMessage | null;
unreadCount: number;
encryptionMode?: 'legacy' | 'e2ee';
e2eeActivatedAt?: string | null;
}
interface ChatMessagePayload extends StoredChatMessage {
id: string;
senderHandle: string;
senderDisplayName?: string;
senderAvatarUrl?: string;
senderDid?: string;
isSentByMe: boolean;
deliveredAt?: string;
readAt?: string;
createdAt: string;
}
type Message = Omit<ChatMessagePayload, 'content'> & {
content: string;
legacy: boolean;
decryptionError: boolean;
};
type ConversationEncryptionState =
| { status: 'idle' }
| { status: 'resolving'; conversationKey: string }
| {
status: 'ready';
conversationKey: string;
recipientDid: string;
senderBundle: E2EEKeyBundle;
recipientBundle: E2EEKeyBundle;
}
| { status: 'error'; conversationKey: string; code: string; message: string };
interface ComposeIntentError {
handle: string;
message: string;
}
const CHAT_REQUEST_TIMEOUT_MS = 15_000;
interface PreparedSend {
accountDid: string;
conversationKey: string;
draft: string;
senderKeyId: string;
recipientKeyId: string;
envelope: E2EEMessageEnvelope;
}
function encryptionConversationKey(conversation: Conversation): string {
return `${conversation.id}:${conversation.participant2.did || conversation.participant2.handle}`;
}
function accountConversationKey(accountDid: string | null, conversationKey: string): string {
return JSON.stringify([accountDid, conversationKey]);
}
export default function ChatPage() {
const { user, loading: authLoading, isIdentityUnlocked, isRestoring: isIdentityRestoring } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const composeHandle = searchParams.get('compose');
const sharedPostUrl = searchParams.get('share');
const e2eeIdentity = useE2EEIdentity(user?.did, user?.handle);
const activeE2EEKeyId = e2eeIdentity.state.status === 'ready'
? e2eeIdentity.state.material.keyId
: null;
// Chat Data State
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
const selectedHandle = useFormattedHandle(selectedConversation?.participant2.handle || '');
const [messages, setMessages] = useState<Message[]>([]);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [conversationsError, setConversationsError] = useState<string | null>(null);
const [messagesError, setMessagesError] = useState<string | null>(null);
const [composeIntentError, setComposeIntentError] = useState<ComposeIntentError | null>(null);
const [dismissedComposeHandle, setDismissedComposeHandle] = useState<string | null>(null);
const [composeRetryVersion, setComposeRetryVersion] = useState(0);
const [conversationEncryption, setConversationEncryption] = useState<ConversationEncryptionState>({ status: 'idle' });
const [searchQuery, setSearchQuery] = useState('');
const [loadingMessages, setLoadingMessages] = useState(false);
// Legacy / V2 Hybrid State
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [conversationToDelete, setConversationToDelete] = useState<Conversation | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const appliedSharedPostRef = useRef<string | null>(null);
const messagesRequestRef = useRef(0);
const conversationsRequestRef = useRef(0);
const peerResolutionRef = useRef(0);
const composeRequestRef = useRef(0);
const sendRequestRef = useRef(0);
const accountDidRef = useRef<string | null>(null);
const renderedAccountDidRef = useRef<string | null>(user?.did ?? null);
const selectedConversationRef = useRef<Conversation | null>(selectedConversation);
const selectedConversationKeyRef = useRef<string | null>(null);
const preparedSendsRef = useRef(new Map<string, PreparedSend>());
const activeSendKeysRef = useRef(new Set<string>());
renderedAccountDidRef.current = user?.did ?? null;
selectedConversationRef.current = selectedConversation;
selectedConversationKeyRef.current = selectedConversation
? encryptionConversationKey(selectedConversation)
: null;
const selectedConversationKey = selectedConversationKeyRef.current;
const newMessage = selectedConversationKey ? drafts[selectedConversationKey] || '' : '';
const updateSelectedDraft = useCallback((value: string) => {
const key = selectedConversationKeyRef.current;
if (!key) return;
const cacheKey = accountConversationKey(renderedAccountDidRef.current, key);
const prepared = preparedSendsRef.current.get(cacheKey);
if (prepared && prepared.draft !== value) preparedSendsRef.current.delete(cacheKey);
setDrafts((current) => current[key] === value ? current : { ...current, [key]: value });
setSendError(null);
}, []);
const selectConversation = useCallback((conversation: Conversation | null) => {
messagesRequestRef.current += 1;
peerResolutionRef.current += 1;
sendRequestRef.current += 1;
selectedConversationRef.current = conversation;
selectedConversationKeyRef.current = conversation
? encryptionConversationKey(conversation)
: null;
setMessages([]);
setMessagesError(null);
setSendError(null);
setSending(conversation
? activeSendKeysRef.current.has(accountConversationKey(
renderedAccountDidRef.current,
encryptionConversationKey(conversation),
))
: false);
setIsAtBottom(true);
setSelectedConversation(conversation);
}, []);
// ============================================
// HELPER FUNCTIONS (Defined before useEffects)
// ============================================
// Check if user is scrolled to bottom
const checkIfAtBottom = () => {
if (!messagesContainerRef.current) return true;
const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current;
const threshold = 100; // pixels from bottom
return scrollHeight - scrollTop - clientHeight < threshold;
};
// Handle scroll to track if user is at bottom
const handleScroll = () => {
setIsAtBottom(checkIfAtBottom());
};
const loadConversations = useCallback(async (isInitialLoad = true) => {
const requestId = ++conversationsRequestRef.current;
const requestAccountDid = user?.did ?? null;
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), CHAT_REQUEST_TIMEOUT_MS);
try {
if (isInitialLoad) {
setLoading(true);
setConversationsError(null);
}
const res = await fetch('/api/swarm/chat/conversations', { signal: controller.signal });
if (!res.ok) throw new Error('Conversations could not be loaded');
const data = await res.json();
let nextConversations = (Array.isArray(data.conversations) ? data.conversations : []) as Conversation[];
// Render the server's generic encrypted previews as soon as the list
// arrives; local preview decryption can finish without holding the
// whole screen on a spinner.
if (isInitialLoad
&& requestId === conversationsRequestRef.current
&& renderedAccountDidRef.current === requestAccountDid) {
setConversations(nextConversations);
setConversationsError(null);
setLoading(false);
}
if (user?.did && e2eeIdentity.state.status === 'ready') {
const material = e2eeIdentity.state.material;
nextConversations = await Promise.all(nextConversations.map(async (conversation) => {
if (!conversation.lastMessage) return conversation;
try {
const { content } = await decryptStoredChatMessage(
conversation.lastMessage,
user.did!,
material,
);
const normalized = content.replace(/\s+/g, ' ').trim();
const characters = Array.from(normalized);
return {
...conversation,
lastMessagePreview: characters.length > 96
? `${characters.slice(0, 96).join('')}…`
: normalized,
};
} catch {
return { ...conversation, lastMessagePreview: 'Encrypted message' };
}
}));
}
if (requestId === conversationsRequestRef.current
&& renderedAccountDidRef.current === requestAccountDid) {
setConversations(nextConversations);
setConversationsError(null);
}
} catch (e) {
console.error("Failed to load conversations", e);
if (requestId === conversationsRequestRef.current
&& renderedAccountDidRef.current === requestAccountDid) {
setConversationsError(controller.signal.aborted
? 'The node took too long to load conversations. Try again.'
: e instanceof TypeError
? 'The connection to the node was interrupted. Try again.'
: e instanceof Error ? e.message : 'Conversations could not be loaded');
}
} finally {
window.clearTimeout(timeout);
if (requestId === conversationsRequestRef.current
&& renderedAccountDidRef.current === requestAccountDid) {
setLoading(false);
}
}
}, [user?.did, e2eeIdentity.state]);
const markAsRead = useCallback(async (conversationId: string) => {
const requestAccountDid = renderedAccountDidRef.current;
try {
const res = await fetch('/api/swarm/chat/messages', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversationId })
});
if (!res.ok || renderedAccountDidRef.current !== requestAccountDid) return;
setConversations(prev => prev.map(c => c.id === conversationId ? { ...c, unreadCount: 0 } : c));
window.dispatchEvent(new Event('synapsis:chat-updated'));
} catch { }
}, []);
const loadMessages = useCallback(async (conversationId: string) => {
const requestId = ++messagesRequestRef.current;
const requestAccountDid = user?.did ?? null;
if (!user?.did || e2eeIdentity.state.status !== 'ready') {
if (requestId === messagesRequestRef.current) setLoadingMessages(false);
return;
}
const material = e2eeIdentity.state.material;
try {
setMessagesError(null);
const res = await fetch(`/api/swarm/chat/messages?conversationId=${conversationId}`);
if (!res.ok) throw new Error('Messages could not be loaded');
const data = await res.json();
const storedMessages = (Array.isArray(data.messages) ? data.messages : []) as ChatMessagePayload[];
const decryptedMessages = await Promise.all(storedMessages.map(async (msg): Promise<Message> => {
if (msg.protocolVersion !== 0 && msg.protocolVersion !== 1) {
return {
...msg,
content: 'Encrypted message unavailable',
legacy: false,
decryptionError: true,
};
}
try {
const decrypted = await decryptStoredChatMessage(msg, user.did!, material);
return {
...msg,
content: decrypted.content,
legacy: decrypted.legacy,
decryptionError: false,
};
} catch (error) {
console.error(`[E2EE Chat] Message ${msg.id} could not be decrypted:`, error);
return {
...msg,
content: 'Encrypted message unavailable',
legacy: false,
decryptionError: true,
};
}
}));
if (requestId !== messagesRequestRef.current
|| renderedAccountDidRef.current !== requestAccountDid
|| selectedConversationRef.current?.id !== conversationId) return;
// Only update if different
setMessages(prev => {
const unchanged = prev.length === decryptedMessages.length
&& prev.every((message, index) => {
const next = decryptedMessages[index];
return message.id === next.id
&& message.content === next.content
&& message.decryptionError === next.decryptionError
&& message.readAt === next.readAt
&& message.deliveredAt === next.deliveredAt;
});
return unchanged ? prev : decryptedMessages;
});
// Mark as read
void markAsRead(conversationId);
} catch (e) {
console.error("Failed to load messages", e);
if (requestId === messagesRequestRef.current
&& renderedAccountDidRef.current === requestAccountDid
&& selectedConversationRef.current?.id === conversationId) {
setMessagesError(e instanceof Error ? e.message : 'Messages could not be loaded');
}
} finally {
if (requestId === messagesRequestRef.current
&& renderedAccountDidRef.current === requestAccountDid
&& selectedConversationRef.current?.id === conversationId) {
setLoadingMessages(false);
}
}
}, [user?.did, e2eeIdentity.state, markAsRead]);
const resolveConversationEncryption = useCallback(async (conversation: Conversation) => {
const requestId = ++peerResolutionRef.current;
const conversationKey = encryptionConversationKey(conversation);
const requestAccountDid = user?.did ?? null;
setConversationEncryption({ status: 'resolving', conversationKey });
try {
if (!user?.did || !user.handle || e2eeIdentity.state.status !== 'ready') {
throw new E2EEClientError('Your encrypted message key is not ready', 'E2EE_IDENTITY_NOT_READY');
}
let recipientDid = conversation.participant2.did;
if (!recipientDid) {
const response = await fetch(`/api/users/${encodeURIComponent(conversation.participant2.handle)}`, {
cache: 'no-store',
});
const body = await response.json().catch(() => null);
recipientDid = body?.user?.did;
if (!response.ok || !recipientDid) {
throw new E2EEClientError('Recipient identity could not be loaded', 'E2EE_RECIPIENT_NOT_FOUND');
}
}
const [sender, recipient] = await Promise.all([
resolveE2EEPublicBundle(user.did, user.handle),
resolveE2EEPublicBundle(recipientDid, conversation.participant2.handle),
]);
if (sender.bundle.keyId !== e2eeIdentity.state.material.keyId
|| sender.bundle.publicKey !== e2eeIdentity.state.material.publicKey) {
throw new E2EEClientError(
'Your local encryption key does not match your signed public key',
'E2EE_IDENTITY_KEY_MISMATCH',
);
}
if (requestId !== peerResolutionRef.current
|| renderedAccountDidRef.current !== requestAccountDid
|| selectedConversationKeyRef.current !== conversationKey) return;
setConversationEncryption({
status: 'ready',
conversationKey,
recipientDid,
senderBundle: sender.bundle,
recipientBundle: recipient.bundle,
});
} catch (error) {
if (requestId !== peerResolutionRef.current
|| renderedAccountDidRef.current !== requestAccountDid
|| selectedConversationKeyRef.current !== conversationKey) return;
const clientError = error instanceof E2EEClientError ? error : null;
setConversationEncryption({
status: 'error',
conversationKey,
code: clientError?.code || 'E2EE_KEY_LOOKUP_FAILED',
message: error instanceof Error ? error.message : 'Encryption keys could not be verified',
});
}
}, [user?.did, user?.handle, e2eeIdentity.state]);
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
const conversation = selectedConversationRef.current;
const conversationKey = selectedConversationKeyRef.current;
const draftToSend = conversationKey ? drafts[conversationKey] || '' : '';
if (!draftToSend.trim() || !conversation || !conversationKey) return;
const cacheKey = accountConversationKey(renderedAccountDidRef.current, conversationKey);
if (activeSendKeysRef.current.has(cacheKey)) {
setSendError('This encrypted message is still being confirmed.');
return;
}
if (conversationEncryption.status !== 'ready'
|| conversationEncryption.conversationKey !== conversationKey) {
setSendError('Encryption keys are still being verified. Your draft has not been sent.');
return;
}
if (!user?.did || e2eeIdentity.state.status !== 'ready') {
setSendError('Encrypted messages are locked. Your draft has not been sent.');
return;
}
const accountDid = user.did;
const requestId = ++sendRequestRef.current;
if (new TextEncoder().encode(draftToSend).length > 8_000) {
setSendError('Encrypted messages can be up to 8,000 bytes. Your draft has not been sent.');
return;
}
activeSendKeysRef.current.add(cacheKey);
setSending(true);
setSendError(null);
try {
const priorPrepared = preparedSendsRef.current.get(cacheKey);
let envelope: E2EEMessageEnvelope;
if (priorPrepared
&& priorPrepared.accountDid === accountDid
&& priorPrepared.draft === draftToSend
&& priorPrepared.senderKeyId === conversationEncryption.senderBundle.keyId
&& priorPrepared.recipientKeyId === conversationEncryption.recipientBundle.keyId) {
envelope = priorPrepared.envelope;
} else {
envelope = await encryptE2EEMessage({
plaintext: draftToSend,
senderDid: accountDid,
senderHandle: user.handle,
senderBundle: conversationEncryption.senderBundle,
recipientDid: conversationEncryption.recipientDid,
recipientHandle: conversation.participant2.handle,
recipientBundle: conversationEncryption.recipientBundle,
});
preparedSendsRef.current.set(cacheKey, {
accountDid,
conversationKey,
draft: draftToSend,
senderKeyId: conversationEncryption.senderBundle.keyId,
recipientKeyId: conversationEncryption.recipientBundle.keyId,
envelope,
});
}
let response: Response;
try {
response = await signedAPI.sendChat(
envelope,
accountDid,
user.handle
);
} catch (error) {
throw new E2EEClientError(
error instanceof Error ? error.message : 'Delivery could not be confirmed',
'E2EE_SEND_AMBIGUOUS',
);
}
if (!response.ok) {
const body = await response.json().catch(() => null);
if (response.status < 500) preparedSendsRef.current.delete(cacheKey);
throw new E2EEClientError(
body?.error || 'Encrypted message could not be sent',
body?.code || (response.status >= 500 ? 'E2EE_SEND_AMBIGUOUS' : 'E2EE_SEND_FAILED'),
body || undefined,
);
}
preparedSendsRef.current.delete(cacheKey);
setDrafts((current) => {
if (current[conversationKey] !== draftToSend) return current;
const next = { ...current };
delete next[conversationKey];
return next;
});
if (requestId !== sendRequestRef.current
|| renderedAccountDidRef.current !== accountDid
|| selectedConversationKeyRef.current !== conversationKey) {
void loadConversations(false);
return;
}
// Refresh failures after a successful send must not be reported as send
// failures or restore a draft that the server already accepted.
try {
if (conversation.id === 'new') {
const res = await fetch('/api/swarm/chat/conversations');
if (!res.ok) throw new Error('Conversation list could not be refreshed');
const data = await res.json();
const updatedConversations = (data.conversations || []) as Conversation[];
if (requestId !== sendRequestRef.current
|| renderedAccountDidRef.current !== accountDid
|| selectedConversationKeyRef.current !== conversationKey) return;
setConversations(updatedConversations);
const realConv = updatedConversations.find((c: Conversation) =>
c.participant2.handle === conversation.participant2.handle
);
if (realConv) selectConversation(realConv);
} else {
await loadMessages(conversation.id);
void loadConversations(false);
}
} catch (refreshError) {
console.error('[Send] Message sent, but Chat could not refresh:', refreshError);
void loadConversations(false);
}
} catch (err) {
console.error('[Send] Error:', err);
if (requestId !== sendRequestRef.current
|| renderedAccountDidRef.current !== accountDid
|| selectedConversationKeyRef.current !== conversationKey) return;
if (err instanceof E2EEClientError) {
if (err.code === 'E2EE_SENDER_KEY_STALE') {
preparedSendsRef.current.delete(cacheKey);
void e2eeIdentity.retry();
} else if (err.code === 'E2EE_RECIPIENT_KEY_STALE' || err.code === 'E2EE_NOT_CONFIGURED') {
preparedSendsRef.current.delete(cacheKey);
void resolveConversationEncryption(conversation);
}
}
setSendError(err instanceof E2EEClientError && err.code === 'E2EE_SEND_AMBIGUOUS'
? 'Delivery could not be confirmed. Retry will safely reuse the same encrypted message.'
: err instanceof Error
? `${err.message} Your draft has not been sent.`
: 'Encrypted message could not be sent. Your draft has not been sent.');
} finally {
activeSendKeysRef.current.delete(cacheKey);
if (renderedAccountDidRef.current === accountDid
&& selectedConversationKeyRef.current === conversationKey) {
setSending(activeSendKeysRef.current.has(cacheKey));
}
}
};
const handleDeleteConversation = async (deleteFor: 'self' | 'both') => {
if (!conversationToDelete) return;
setIsDeleting(true);
setDeleteError(null);
try {
const res = await fetch(`/api/swarm/chat/conversations/${conversationToDelete.id}?deleteFor=${deleteFor}`, {
method: 'DELETE',
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error || 'Conversation could not be deleted');
}
setConversations(prev => prev.filter(c => c.id !== conversationToDelete.id));
if (selectedConversation?.id === conversationToDelete.id) {
selectConversation(null);
}
setShowDeleteModal(false);
setConversationToDelete(null);
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Conversation could not be deleted');
} finally {
setIsDeleting(false);
}
};
// ============================================
// EFFECTS (Now that functions are defined)
// ============================================
// Load conversations
useEffect(() => {
const did = user?.did ?? null;
if (accountDidRef.current === did) return;
accountDidRef.current = did;
messagesRequestRef.current += 1;
conversationsRequestRef.current += 1;
peerResolutionRef.current += 1;
composeRequestRef.current += 1;
sendRequestRef.current += 1;
preparedSendsRef.current.clear();
activeSendKeysRef.current.clear();
selectedConversationRef.current = null;
selectedConversationKeyRef.current = null;
setConversations([]);
setSelectedConversation(null);
setMessages([]);
setDrafts({});
setSendError(null);
setConversationsError(null);
setMessagesError(null);
setComposeIntentError(null);
setDismissedComposeHandle(null);
setComposeRetryVersion(0);
setSending(false);
setIsAtBottom(true);
setConversationEncryption({ status: 'idle' });
setLoading(Boolean(did));
}, [user?.did]);
// Load conversations
useEffect(() => {
if (!user) return;
void loadConversations(true);
const pollInterval = setInterval(() => {
void loadConversations(false);
}, 5000);
return () => {
clearInterval(pollInterval);
conversationsRequestRef.current += 1;
};
}, [user, activeE2EEKeyId, loadConversations]);
// Handle Compose Intent
useEffect(() => {
if (!composeHandle || selectedConversation || loading || dismissedComposeHandle === composeHandle) return;
// Check if we already have a conversation with this user.
const existing = conversations.find(c =>
c.participant2.handle.toLowerCase() === composeHandle.toLowerCase()
);
if (existing) {
setComposeIntentError(null);
selectConversation(existing);
// Clear the query param so refresh doesn't keep resetting state.
router.replace('/chat', { scroll: false });
return;
}
// Keep a failed intent stable until the user retries. Conversation
// polling replaces the array every few seconds and must not turn the
// visible error back into a spinner on each successful poll.
if (composeIntentError?.handle === composeHandle) return;
const requestId = ++composeRequestRef.current;
const requestAccountDid = renderedAccountDidRef.current;
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), CHAT_REQUEST_TIMEOUT_MS);
setComposeIntentError(null);
// Fetch user details to create a draft conversation. A transient node
// restart must surface a retry action instead of leaving the compose
// route behind an unconditional full-page spinner.
const fetchUserAndInitDraft = async () => {
try {
const res = await fetch(`/api/users/${encodeURIComponent(composeHandle)}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`Account lookup failed (${res.status})`);
const data = await res.json();
if (requestId !== composeRequestRef.current
|| renderedAccountDidRef.current !== requestAccountDid) return;
if (data.user) {
if (data.user.isBot || data.user.canReceiveDms === false) {
setComposeIntentError({
handle: composeHandle,
message: 'This account cannot receive direct messages.',
});
return;
}
const draftConv: Conversation = {
id: 'new',
participant2: {
handle: data.user.handle,
displayName: data.user.displayName || data.user.handle,
avatarUrl: data.user.avatarUrl,
did: data.user.did
},
lastMessageAt: new Date().toISOString(),
lastMessagePreview: 'New Conversation',
unreadCount: 0
};
selectConversation(draftConv);
router.replace('/chat', { scroll: false });
} else {
setComposeIntentError({
handle: composeHandle,
message: 'That account could not be found.',
});
}
} catch (error) {
if (requestId !== composeRequestRef.current
|| renderedAccountDidRef.current !== requestAccountDid) return;
console.error('Failed to load user for compose', error);
setComposeIntentError({
handle: composeHandle,
message: controller.signal.aborted
? 'The node took too long to respond. Try again.'
: 'The connection to the node was interrupted. Try again.',
});
} finally {
window.clearTimeout(timeout);
}
};
void fetchUserAndInitDraft();
return () => {
composeRequestRef.current += 1;
controller.abort();
window.clearTimeout(timeout);
};
}, [composeHandle, selectedConversation, conversations, loading, router, selectConversation, composeIntentError, dismissedComposeHandle, composeRetryVersion]);
// Redirect if not logged in
useEffect(() => {
if (!authLoading && user === null) {
router.push('/login');
}
}, [authLoading, user, router]);
// A thread is sendable only after both participants' public bundles have
// been resolved and verified. A failed lookup never falls back to plaintext.
useEffect(() => {
setSendError(null);
if (!selectedConversation || !activeE2EEKeyId) {
peerResolutionRef.current += 1;
setConversationEncryption({ status: 'idle' });
return;
}
void resolveConversationEncryption(selectedConversation);
return () => {
peerResolutionRef.current += 1;
};
}, [selectedConversation, activeE2EEKeyId, resolveConversationEncryption]);
// Load messages when conversation is selected
useEffect(() => {
messagesRequestRef.current += 1;
setMessages([]);
if (!selectedConversation || !activeE2EEKeyId) {
setLoadingMessages(false);
return;
}
if (selectedConversation.id === 'new') {
setLoadingMessages(false);
return;
}
setLoadingMessages(true);
void loadMessages(selectedConversation.id);
// Polling only retrieves ciphertext; decryption remains local.
const pollInterval = setInterval(() => {
void loadMessages(selectedConversation.id);
}, 3000);
return () => {
clearInterval(pollInterval);
messagesRequestRef.current += 1;
};
}, [selectedConversation, activeE2EEKeyId, loadMessages]);
// A post shared from the timeline waits for the user to choose a conversation,
// then appears in the composer so they remain in control of sending it.
useEffect(() => {
if (!selectedConversation || !sharedPostUrl || appliedSharedPostRef.current === sharedPostUrl) return;
updateSelectedDraft(sharedPostUrl);
appliedSharedPostRef.current = sharedPostUrl;
router.replace('/chat', { scroll: false });
}, [selectedConversation, sharedPostUrl, router, updateSelectedDraft]);
// Auto-scroll to bottom of messages only if user was already at bottom
useEffect(() => {
if (messagesEndRef.current && isAtBottom) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, isAtBottom]);
useEffect(() => {
setIsAtBottom(true);
const frame = window.requestAnimationFrame(() => {
const container = messagesContainerRef.current;
if (container) container.scrollTop = container.scrollHeight;
});
return () => window.cancelAnimationFrame(frame);
}, [selectedConversationKey]);
useEffect(() => {
if (!showDeleteModal) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || isDeleting) return;
setDeleteError(null);
setShowDeleteModal(false);
};
window.addEventListener('keydown', closeOnEscape);
return () => window.removeEventListener('keydown', closeOnEscape);
}, [showDeleteModal, isDeleting]);
// ============================================
// RENDER LOGIC
// ============================================
const filteredConversations = conversations.filter((conv) =>
conv.participant2.displayName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
conv.participant2.handle.toLowerCase().includes(searchQuery.toLowerCase())
);
const selectedEncryptionReady = conversationEncryption.status === 'ready'
&& conversationEncryption.conversationKey === selectedConversationKey;
const selectedEncryptionError = conversationEncryption.status === 'error'
&& conversationEncryption.conversationKey === selectedConversationKey
? conversationEncryption
: null;
if (authLoading || isIdentityRestoring) {
return (
<main aria-busy="true" style={{ minHeight: '100vh', display: 'grid', placeItems: 'center' }}>
<Loader2 className="animate-spin" size={28} aria-label="Loading account" />
</main>
);
}
if (user === null) return null;
if (!isIdentityUnlocked) {
return <IdentityUnlockPrompt onCancel={() => router.push('/')} />;
}
if (e2eeIdentity.state.status !== 'ready') {
return (
<E2EEChatGate
state={e2eeIdentity.state}
busy={e2eeIdentity.busy}
error={e2eeIdentity.actionError}
identityUnlocked={isIdentityUnlocked}
onSetup={e2eeIdentity.setup}
onUnlock={e2eeIdentity.unlock}
onReset={e2eeIdentity.reset}
onRetry={e2eeIdentity.retry}
onCancel={() => router.push('/')}
/>
);
}
// Prevent a flash of the list view while processing a compose intent, but
// never hide a failed request behind an endless spinner.
if (composeHandle && !selectedConversation && dismissedComposeHandle !== composeHandle) {
const currentComposeError = composeIntentError?.handle === composeHandle
? composeIntentError.message
: null;
if (currentComposeError) {
return (
<main style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', padding: 24 }}>
<section role="alert" style={{ width: '100%', maxWidth: 420, textAlign: 'center' }}>
<h1 style={{ fontSize: 20, marginBottom: 10 }}>Couldn&apos;t open this conversation</h1>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.5 }}>
{currentComposeError}
</p>
<div style={{ display: 'flex', justifyContent: 'center', gap: 10, marginTop: 18 }}>
<button
type="button"
className="btn btn-primary"
onClick={() => {
setComposeIntentError(null);
setDismissedComposeHandle(null);
setComposeRetryVersion((version) => version + 1);
}}
>
Try again
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => {
setComposeIntentError(null);
setDismissedComposeHandle(composeHandle);
router.replace('/chat', { scroll: false });
}}
>
Back to messages
</button>
</div>
</section>
</main>
);
}
return (
<div aria-busy="true" style={{ display: 'flex', flexDirection: 'column', gap: 12, height: '100vh', alignItems: 'center', justifyContent: 'center', color: 'var(--foreground-secondary)' }}>
<Loader2 className="animate-spin" size={32} aria-label="Opening conversation" />
<p>Opening conversation</p>
</div>
);
}
// Thread View
if (selectedConversation) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', maxWidth: '600px', margin: '0 auto' }}>
{/* Header */}
<header style={{
position: 'sticky',
top: 0,
zIndex: 20,
background: 'rgba(10, 10, 10, 0.8)',
backdropFilter: 'blur(12px)',
borderBottom: '1px solid var(--border)',
padding: '12px 16px',
flexShrink: 0
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<button
onClick={() => selectConversation(null)}
aria-label="Back to conversations"
style={{ background: 'none', border: 'none', padding: '4px', cursor: 'pointer', color: 'var(--foreground)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<ArrowLeft size={20} />
</button>
<div className="avatar" style={{ width: '32px', height: '32px', fontSize: '14px' }}>
<AvatarImage
avatarUrl={selectedConversation.participant2.avatarUrl}
seed={selectedConversation.participant2.handle}
alt={selectedConversation.participant2.displayName || selectedConversation.participant2.handle}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Link
href={getProfilePath(selectedConversation.participant2.handle)}
style={{
display: 'block',
color: 'var(--foreground)',
textDecoration: 'none',
minWidth: 0,
}}
>
<div style={{ fontWeight: 600, fontSize: '15px' }}>{selectedConversation.participant2.displayName}</div>
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{selectedHandle}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2, fontSize: '11px', color: 'var(--foreground-secondary)' }}>
<LockKeyhole size={11} aria-hidden="true" />
<span>{selectedEncryptionReady
? 'End-to-end encryption ready'
: selectedEncryptionError
? 'Encryption unavailable'
: 'Verifying encryption…'}</span>
</div>
</Link>
</div>
<button
aria-label="Delete conversation"
onClick={() => {
setDeleteError(null);
setConversationToDelete(selectedConversation);
setShowDeleteModal(true);
}}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--foreground-tertiary)', padding: '4px' }}
>
<Trash2 size={18} />
</button>
</div>
</header>
{/* Messages */}
<div
ref={messagesContainerRef}
onScroll={handleScroll}
role="log"
aria-live="polite"
aria-relevant="additions text"
aria-busy={loadingMessages}
aria-label={`Messages with ${selectedConversation.participant2.displayName || selectedHandle}`}
style={{
padding: '16px',
flex: 1,
overflowY: 'auto',
paddingBottom: '16px',
position: 'relative'
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{loadingMessages ? (
<div aria-busy="true" style={{ display: 'flex', justifyContent: 'center', padding: '40px 0', color: 'var(--foreground-secondary)' }}>
<Loader2 className="animate-spin" size={24} aria-label="Decrypting messages" />
</div>
) : messagesError ? (
<div role="alert" style={{ textAlign: 'center', padding: '40px 16px', color: 'var(--foreground-secondary)', fontSize: 14 }}>
<p style={{ marginTop: 0 }}>{messagesError}</p>
<button type="button" className="btn btn-ghost" onClick={() => void loadMessages(selectedConversation.id)}>
Try again
</button>
</div>
) : messages.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px 16px', color: 'var(--foreground-tertiary)', fontSize: 14 }}>
{selectedEncryptionReady
? 'New messages in this conversation will be end-to-end encrypted.'
: 'No messages yet.'}
</div>
) : messages.map((msg, i) => {
const startsEncryptedSection = msg.protocolVersion === 1
&& i > 0
&& messages[i - 1].legacy;
return (
<Fragment key={msg.id || i}>
{startsEncryptedSection && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, color: 'var(--foreground-tertiary)', fontSize: 11 }}>
<span style={{ height: 1, flex: 1, background: 'var(--border)' }} />
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<LockKeyhole size={11} aria-hidden="true" />
End-to-end encryption starts here
</span>
<span style={{ height: 1, flex: 1, background: 'var(--border)' }} />
</div>
)}
<div style={{
display: 'flex',
gap: '12px',
maxWidth: '70%',
marginLeft: msg.isSentByMe ? 'auto' : '0',
flexDirection: msg.isSentByMe ? 'row-reverse' : 'row'
}}>
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
<AvatarImage
avatarUrl={msg.isSentByMe ? user.avatarUrl : msg.senderAvatarUrl}
seed={msg.isSentByMe ? user.handle : msg.senderHandle}
alt={msg.isSentByMe ? user.displayName : msg.senderDisplayName || msg.senderHandle}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: msg.isSentByMe ? 'flex-end' : 'flex-start' }}>
<div
role={msg.decryptionError ? 'status' : undefined}
style={{
padding: '10px 14px',
borderRadius: '16px',
background: msg.decryptionError
? 'var(--background-secondary)'
: msg.isSentByMe ? 'var(--accent)' : 'var(--background-secondary)',
color: msg.decryptionError
? 'var(--foreground-secondary)'
: msg.isSentByMe ? '#000' : 'var(--foreground)',
border: msg.decryptionError || !msg.isSentByMe ? '1px solid var(--border)' : 'none',
wordBreak: 'break-word',
whiteSpace: 'pre-wrap'
}}
>
{msg.decryptionError ? (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 600 }}>
<LockKeyhole size={14} aria-hidden="true" />
Encrypted message unavailable
</div>
<div style={{ marginTop: 4, fontSize: 12 }}>
This message could not be decrypted on this device.
</div>
</div>
) : msg.content}
</div>
{msg.legacy && (
<div style={{ fontSize: '10px', color: 'var(--foreground-tertiary)', marginTop: 4 }}>
Sent before end-to-end encryption
</div>
)}
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
</div>
</Fragment>
);
})}
<div ref={messagesEndRef} />
</div>
</div>
{/* Input */}
<div className="compose" style={{ borderTop: '1px solid var(--border)', background: 'var(--background)', flexShrink: 0 }}>
{selectedEncryptionReady ? (
<>
<form onSubmit={handleSendMessage} style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="text"
className="input"
style={{ flex: 1 }}
placeholder="Type an encrypted message..."
value={newMessage}
onChange={e => updateSelectedDraft(e.target.value)}
aria-label={`Encrypted message to ${selectedHandle}`}
aria-describedby={sendError ? 'encrypted-send-error' : undefined}
/>
<button type="submit" disabled={!newMessage.trim() || sending} className="btn btn-primary" aria-label="Send encrypted message">
{sending ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
</form>
{sendError && (
<p id="encrypted-send-error" role="alert" style={{ color: 'var(--destructive)', fontSize: 13, margin: '10px 0 0' }}>
{sendError}
</p>
)}
</>
) : selectedEncryptionError ? (
<div role="alert" style={{ border: '1px solid var(--border)', borderRadius: 12, padding: 14, background: 'var(--background-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, fontWeight: 600, fontSize: 14 }}>
<LockKeyhole size={15} aria-hidden="true" />
{selectedEncryptionError.code === 'E2EE_NOT_CONFIGURED'
? `Encrypted messaging isn't available with ${selectedHandle} yet.`
: `We couldn't verify ${selectedHandle}'s encryption key.`}
</div>
<p style={{ color: 'var(--foreground-secondary)', fontSize: 13, lineHeight: 1.45, margin: '7px 0 10px' }}>
{selectedEncryptionError.code === 'E2EE_NOT_CONFIGURED'
? 'They need to set up encrypted messages before you can send them a DM. '
: `${selectedEncryptionError.message}. `}
Synapsis will not send this conversation unencrypted.
</p>
<button
type="button"
className="btn btn-ghost"
onClick={() => void resolveConversationEncryption(selectedConversation)}
>
Check again
</button>
</div>
) : (
<div aria-busy="true" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, minHeight: 44, color: 'var(--foreground-secondary)', fontSize: 13 }}>
<Loader2 size={16} className="animate-spin" aria-hidden="true" />
Verifying encryption keys
</div>
)}
</div>
{/* Delete Modal */}
{showDeleteModal && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-conversation-title"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 50
}}>
<div style={{
background: 'var(--background)',
padding: '24px',
borderRadius: '16px',
width: '100%',
maxWidth: '320px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)'
}}>
<h3 id="delete-conversation-title" style={{ marginTop: 0, fontSize: '18px', fontWeight: 600 }}>Delete Conversation</h3>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', marginBottom: '24px' }}>
This action cannot be undone.
</p>
{deleteError && (
<p role="alert" style={{ color: 'var(--destructive)', fontSize: 13, margin: '0 0 14px' }}>
{deleteError}
</p>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<button
disabled={isDeleting}
onClick={() => handleDeleteConversation('self')}
className="btn"
style={{ justifyContent: 'center', width: '100%' }}
>
Delete for me
</button>
{!selectedConversation.participant2.handle.includes('@') && (
<button
disabled={isDeleting}
onClick={() => handleDeleteConversation('both')}
className="btn btn-danger"
style={{ justifyContent: 'center', width: '100%', color: 'var(--destructive)', background: 'var(--destructive-10)' }}
>
Delete for everyone
</button>
)}
{selectedConversation.participant2.handle.includes('@') && (
<p style={{ color: 'var(--foreground-tertiary)', fontSize: 12, margin: '2px 0 0', textAlign: 'center' }}>
Across nodes, deleting removes only your copy.
</p>
)}
<button
disabled={isDeleting}
onClick={() => {
setDeleteError(null);
setShowDeleteModal(false);
}}
className="btn btn-ghost"
style={{ justifyContent: 'center', width: '100%' }}
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
}
// LIST VIEW
return (
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', maxWidth: '600px', margin: '0 auto', background: 'var(--background)' }}>
<div style={{ position: 'sticky', top: 0, zIndex: 20, background: 'var(--background)' }}>
<header style={{
padding: '16px',
borderBottom: '1px solid var(--border)',
background: 'rgba(10, 10, 10, 0.8)',
backdropFilter: 'blur(12px)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h1 style={{ fontSize: '18px', fontWeight: 600, margin: 0 }}>Chat</h1>
</div>
</header>
{sharedPostUrl && (
<div className="chat-share-intent">
Choose a conversation to share this post.
</div>
)}
<div style={{
padding: '16px', // Reverted from 20px to 16px as requested
borderBottom: '1px solid var(--border)',
background: 'rgba(10, 10, 10, 0.8)',
backdropFilter: 'blur(12px)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', background: 'var(--background-secondary)', borderRadius: 'var(--radius-full)', padding: '8px 16px', border: '1px solid var(--border)' }}>
<Search size={16} style={{ color: 'var(--foreground-tertiary)' }} aria-hidden="true" />
<input
type="text"
placeholder="Search..."
aria-label="Search conversations"
style={{ background: 'transparent', border: 'none', outline: 'none', flex: 1, color: 'var(--foreground)' }}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{loading ? (
<div aria-busy="true" style={{ display: 'flex', justifyContent: 'center', padding: '48px' }}>
<Loader2 className="animate-spin" size={32} aria-label="Loading conversations" />
</div>
) : conversationsError ? (
<div role="alert" style={{ textAlign: 'center', padding: '48px 16px', color: 'var(--foreground-secondary)' }}>
<p>{conversationsError}</p>
<button type="button" className="btn btn-ghost" onClick={() => void loadConversations(true)}>
Try again
</button>
</div>
) : filteredConversations.length === 0 ? (
<div style={{ textAlign: 'center', padding: '48px 16px', color: 'var(--foreground-tertiary)' }}>
<MessageCircle size={48} style={{ margin: '0 auto 16px', opacity: 0.5 }} />
<p>No conversations yet</p>
</div>
) : (
filteredConversations.map(conv => (
<button
type="button"
key={conv.id}
className="post"
onClick={() => selectConversation(conv)}
style={{ cursor: 'pointer', display: 'flex', alignItems: 'flex-start', gap: '12px', width: '100%', textAlign: 'left', color: 'inherit', border: 0 }}
>
<div className="avatar">
<AvatarImage
avatarUrl={conv.participant2.avatarUrl}
seed={conv.participant2.handle}
alt={conv.participant2.displayName || conv.participant2.handle}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontWeight: 600, fontSize: '15px' }}>{conv.participant2.displayName || conv.participant2.handle}</span>
{conv.unreadCount > 0 && <span className="badge" style={{ background: 'var(--accent)', color: '#000', borderRadius: '10px', padding: '2px 8px', fontSize: '11px', fontWeight: 600 }}>{conv.unreadCount}</span>}
</div>
<div style={{ fontSize: '13px', color: 'var(--foreground-secondary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', marginTop: '2px' }}>
{conv.lastMessagePreview}
</div>
</div>
</button>
))
)}
</div>
</div>
);
}