import { createSignal, For, Show, onCleanup, onMount } from 'solid-js'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Card } from '@/components/ui/Card'; import { toast } from '@/components/ui/Toast'; import { MessagesRealtimeClient, messagesApi, uploadChatFile, } from '@/lib/messages'; import type { ConversationListItem, ConversationMember, Message, MessageSuggestion, VaultItem, } from '@/lib/messages'; import { IconBell, IconBellOff, IconLock, IconMessageCircle, IconPlus, IconSearch, IconSend, IconUsers, IconX, } from '@tabler/icons-solidjs'; interface MemberOption { id: number; username: string; name: string; } const ATTACHMENT_KIND_OPTIONS = [ 'file', 'image', 'voice_note', 'youtube', 'github', 'website', 'bookmark', 'task', 'event', 'calendar', 'activity', 'learning_path', 'saved_search', ]; const REFERENCE_TYPE_OPTIONS = [ 'task', 'bookmark', 'calendar_event', 'youtube_video', 'learning_path', 'saved_search', 'github', 'password_vault_item', ]; type TriStateFilter = 'any' | 'yes' | 'no'; type CallStatus = 'idle' | 'starting' | 'calling' | 'in_call' | 'error'; export const Messages = () => { const [conversations, setConversations] = createSignal([]); const [messages, setMessages] = createSignal([]); const [selectedConversationId, setSelectedConversationId] = createSignal(null); const [loadingConversations, setLoadingConversations] = createSignal(false); const [loadingMessages, setLoadingMessages] = createSignal(false); const [inputText, setInputText] = createSignal(''); const [selectedFiles, setSelectedFiles] = createSignal([]); const [sendingMessage, setSendingMessage] = createSignal(false); const [wsStatus, setWsStatus] = createSignal<'connected' | 'disconnected' | 'error'>('disconnected'); const [searchOpen, setSearchOpen] = createSignal(false); const [searchQuery, setSearchQuery] = createSignal(''); const [searchResults, setSearchResults] = createSignal([]); const [searching, setSearching] = createSignal(false); const [showVault, setShowVault] = createSignal(false); const [vaultItems, setVaultItems] = createSignal([]); const [revealedSecrets, setRevealedSecrets] = createSignal>({}); const [shareTargets, setShareTargets] = createSignal>({}); const [members, setMembers] = createSignal([]); const [conversationMembers, setConversationMembers] = createSignal([]); const [typingByConversation, setTypingByConversation] = createSignal>>({}); const [newConversationType, setNewConversationType] = createSignal<'dm' | 'group' | 'team'>('dm'); const [newConversationName, setNewConversationName] = createSignal(''); const [newConversationTopic, setNewConversationTopic] = createSignal(''); const [targetUserId, setTargetUserId] = createSignal(''); const [groupUserIds, setGroupUserIds] = createSignal(''); const [teamId, setTeamId] = createSignal(''); const [showCreateConversation, setShowCreateConversation] = createSignal(false); const [browserNotificationsEnabled, setBrowserNotificationsEnabled] = createSignal( localStorage.getItem('messages_browser_notifications') === 'true' ); const [searchConversationIds, setSearchConversationIds] = createSignal([]); const [searchSenderId, setSearchSenderId] = createSignal(''); const [searchDateFrom, setSearchDateFrom] = createSignal(''); const [searchDateTo, setSearchDateTo] = createSignal(''); const [searchAttachmentKinds, setSearchAttachmentKinds] = createSignal([]); const [searchReferenceTypes, setSearchReferenceTypes] = createSignal([]); const [searchHasLinks, setSearchHasLinks] = createSignal('any'); const [searchHasAttachments, setSearchHasAttachments] = createSignal('any'); const [searchHasSuggestions, setSearchHasSuggestions] = createSignal('any'); const [searchMentionOnly, setSearchMentionOnly] = createSignal(false); const [voiceTranscriptEnabled, setVoiceTranscriptEnabled] = createSignal( localStorage.getItem('messages_voice_transcript') !== 'false' ); const [isRecordingVoice, setIsRecordingVoice] = createSignal(false); const [voiceRecordingMs, setVoiceRecordingMs] = createSignal(0); const [voiceTranscriptPreview, setVoiceTranscriptPreview] = createSignal(''); const [callStatus, setCallStatus] = createSignal('idle'); const [callMuted, setCallMuted] = createSignal(false); const [activeCallConversationId, setActiveCallConversationId] = createSignal(null); const [activeCallPeerIds, setActiveCallPeerIds] = createSignal([]); const [callTranscriptEnabled, setCallTranscriptEnabled] = createSignal( localStorage.getItem('messages_call_transcript') !== 'false' ); const [callTranscriptPreview, setCallTranscriptPreview] = createSignal(''); const getCurrentUserId = () => { const raw = localStorage.getItem('trackeep_user') || localStorage.getItem('user'); if (!raw) return 0; try { const parsed = JSON.parse(raw); return parsed.id || 0; } catch { return 0; } }; const currentUserId = () => getCurrentUserId(); let realtime: MessagesRealtimeClient | null = null; let pollInterval: number | null = null; let typingStopTimer: number | null = null; let typingCleanupTimer: number | null = null; let lastTypingStartedAt = 0; let voiceRecorder: MediaRecorder | null = null; let voiceRecordingStream: MediaStream | null = null; let voiceRecordingChunks: BlobPart[] = []; let voiceRecordingTimer: number | null = null; let discardVoiceRecording = false; let voiceRecognition: any = null; let voiceFinalTranscript = ''; let voiceInterimTranscript = ''; let callLocalStream: MediaStream | null = null; let callPeers = new Map(); let remoteAudioElements = new Map(); let callRecognition: any = null; let callFinalTranscript = ''; let callInterimTranscript = ''; const sortedConversations = () => [...conversations()].sort((a, b) => { const aDate = a.conversation.last_message_at || a.conversation.updated_at; const bDate = b.conversation.last_message_at || b.conversation.updated_at; return new Date(bDate).getTime() - new Date(aDate).getTime(); }); const activeConversation = () => conversations().find((item) => item.conversation.id === selectedConversationId()) || null; const getSpeechRecognitionCtor = () => { if (typeof window === 'undefined') return null; const w = window as any; return w.SpeechRecognition || w.webkitSpeechRecognition || null; }; const isCallActive = () => callStatus() === 'starting' || callStatus() === 'calling' || callStatus() === 'in_call'; const triStateToBool = (value: TriStateFilter): boolean | undefined => { if (value === 'yes') return true; if (value === 'no') return false; return undefined; }; const toggleNumberFilter = (current: number[], value: number) => { if (current.includes(value)) { return current.filter((v) => v !== value); } return [...current, value]; }; const toggleStringFilter = (current: string[], value: string) => { if (current.includes(value)) { return current.filter((v) => v !== value); } return [...current, value]; }; const toISOOrUndefined = (value: string): string | undefined => { const trimmed = value.trim(); if (!trimmed) return undefined; const parsed = new Date(trimmed); if (Number.isNaN(parsed.getTime())) return undefined; return parsed.toISOString(); }; const clearSearchFilters = () => { setSearchConversationIds([]); setSearchSenderId(''); setSearchDateFrom(''); setSearchDateTo(''); setSearchAttachmentKinds([]); setSearchReferenceTypes([]); setSearchHasLinks('any'); setSearchHasAttachments('any'); setSearchHasSuggestions('any'); setSearchMentionOnly(false); }; const activeTypingUserNames = () => { const conversationID = selectedConversationId(); if (!conversationID) return [] as string[]; const usersMap = typingByConversation()[conversationID] || {}; const names = Object.keys(usersMap) .map((id) => Number(id)) .filter((id) => id !== currentUserId()) .map((id) => { const member = conversationMembers().find((m) => m.user_id === id); return member?.user?.full_name || member?.user?.username || `User ${id}`; }); return names; }; const requestNotificationPermission = async () => { if (!('Notification' in window)) { toast.warning('Browser notifications unavailable', 'This browser does not support notifications.'); return; } if (Notification.permission === 'granted') { setBrowserNotificationsEnabled(true); localStorage.setItem('messages_browser_notifications', 'true'); return; } const permission = await Notification.requestPermission(); const enabled = permission === 'granted'; setBrowserNotificationsEnabled(enabled); localStorage.setItem('messages_browser_notifications', enabled ? 'true' : 'false'); }; const maybeNotify = (title: string, body: string) => { if (!browserNotificationsEnabled()) return; if (!('Notification' in window)) return; if (Notification.permission !== 'granted') return; new Notification(title, { body }); }; const sendTypingEvent = (type: 'typing.started' | 'typing.stopped', explicitConversationId?: number) => { const conversationID = explicitConversationId || selectedConversationId(); if (!conversationID) return; if (!realtime || wsStatus() !== 'connected') return; realtime.send({ type, conversation_id: conversationID, }); }; const markTypingStopped = (explicitConversationId?: number) => { sendTypingEvent('typing.stopped', explicitConversationId); if (typingStopTimer) { window.clearTimeout(typingStopTimer); typingStopTimer = null; } }; const markTypingStarted = () => { const conversationID = selectedConversationId(); if (!conversationID) return; const now = Date.now(); if (now-lastTypingStartedAt > 1200) { sendTypingEvent('typing.started', conversationID); lastTypingStartedAt = now; } if (typingStopTimer) { window.clearTimeout(typingStopTimer); } typingStopTimer = window.setTimeout(() => { markTypingStopped(conversationID); }, 2200); }; const setVoiceTranscriptPreference = (enabled: boolean) => { setVoiceTranscriptEnabled(enabled); localStorage.setItem('messages_voice_transcript', enabled ? 'true' : 'false'); }; const setCallTranscriptPreference = (enabled: boolean) => { setCallTranscriptEnabled(enabled); localStorage.setItem('messages_call_transcript', enabled ? 'true' : 'false'); }; const formatMs = (ms: number) => { const totalSeconds = Math.floor(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; }; const stopVoiceRecognition = () => { if (voiceRecognition) { try { voiceRecognition.onresult = null; voiceRecognition.onerror = null; voiceRecognition.onend = null; voiceRecognition.stop(); } catch { // ignore } voiceRecognition = null; } }; const startVoiceRecognition = () => { if (!voiceTranscriptEnabled()) return; const Ctor = getSpeechRecognitionCtor(); if (!Ctor) return; voiceFinalTranscript = ''; voiceInterimTranscript = ''; setVoiceTranscriptPreview(''); const recognition = new Ctor(); recognition.lang = navigator.language || 'en-US'; recognition.continuous = true; recognition.interimResults = true; recognition.onresult = (event: any) => { for (let i = event.resultIndex; i < event.results.length; i += 1) { const transcript = event.results[i][0]?.transcript || ''; if (event.results[i].isFinal) { voiceFinalTranscript += `${transcript} `; } else { voiceInterimTranscript = transcript; } } const merged = `${voiceFinalTranscript} ${voiceInterimTranscript}`.trim(); setVoiceTranscriptPreview(merged); }; recognition.onerror = () => { // Keep recording even if browser transcript fails. }; recognition.onend = () => { voiceRecognition = null; }; try { recognition.start(); voiceRecognition = recognition; } catch { voiceRecognition = null; } }; const stopCallRecognition = () => { if (callRecognition) { try { callRecognition.onresult = null; callRecognition.onerror = null; callRecognition.onend = null; callRecognition.stop(); } catch { // ignore } callRecognition = null; } setCallTranscriptPreview(''); callFinalTranscript = ''; callInterimTranscript = ''; }; const startCallRecognition = () => { if (!callTranscriptEnabled()) return; const Ctor = getSpeechRecognitionCtor(); if (!Ctor) return; callFinalTranscript = ''; callInterimTranscript = ''; setCallTranscriptPreview(''); const recognition = new Ctor(); recognition.lang = navigator.language || 'en-US'; recognition.continuous = true; recognition.interimResults = true; recognition.onresult = (event: any) => { for (let i = event.resultIndex; i < event.results.length; i += 1) { const transcript = event.results[i][0]?.transcript || ''; if (event.results[i].isFinal) { callFinalTranscript += `${transcript} `; } else { callInterimTranscript = transcript; } } const merged = `${callFinalTranscript} ${callInterimTranscript}`.trim(); setCallTranscriptPreview(merged); }; recognition.onerror = () => { // keep call running when transcript fails }; recognition.onend = () => { callRecognition = null; }; try { recognition.start(); callRecognition = recognition; } catch { callRecognition = null; } }; const postMessage = async (body: string, attachments: any[], explicitConversationId?: number) => { const conversationID = explicitConversationId || selectedConversationId(); if (!conversationID) return; const trimmedBody = body.trim(); if (!trimmedBody && attachments.length === 0) return; markTypingStopped(); setSendingMessage(true); try { const response = await messagesApi.sendMessage(conversationID, { body: trimmedBody, attachments, }); const created = response.message; setMessages((prev) => (prev.some((m) => m.id === created.id) ? prev : [...prev, created])); if (response.warning) { toast.warning('Sensitive Content Warning', response.warning); } loadConversations(); } catch (error) { throw error; } finally { setSendingMessage(false); } }; const startVoiceRecording = async () => { const conversationID = selectedConversationId(); if (!conversationID) { toast.warning('Conversation required', 'Select a conversation before recording.'); return; } if (isRecordingVoice()) return; if (!navigator.mediaDevices?.getUserMedia) { toast.error('Microphone unavailable', 'This browser does not support microphone capture.'); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const recorder = new MediaRecorder(stream); voiceRecordingStream = stream; voiceRecorder = recorder; voiceRecordingChunks = []; setVoiceRecordingMs(0); setIsRecordingVoice(true); discardVoiceRecording = false; recorder.ondataavailable = (event) => { if (event.data && event.data.size > 0) { voiceRecordingChunks.push(event.data); } }; recorder.onstop = async () => { try { if (discardVoiceRecording) { return; } const blob = new Blob(voiceRecordingChunks, { type: recorder.mimeType || 'audio/webm' }); if (blob.size === 0) { toast.warning('Empty recording', 'No audio was captured.'); return; } const extension = recorder.mimeType.includes('ogg') ? 'ogg' : recorder.mimeType.includes('mp4') ? 'm4a' : 'webm'; const file = new File([blob], `voice-note-${Date.now()}.${extension}`, { type: recorder.mimeType || 'audio/webm' }); const uploaded = await uploadChatFile(file); const attachments = [{ kind: 'voice_note', file_id: uploaded.id, title: uploaded.original_name || 'Voice note', url: `${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/files/${uploaded.id}/download`, }]; const transcript = `${voiceFinalTranscript} ${voiceInterimTranscript}`.trim(); const transcriptBody = transcript ? `Transcript (local): ${transcript}` : ''; const composedBody = [inputText().trim(), transcriptBody].filter(Boolean).join('\n\n'); await postMessage(composedBody, attachments, conversationID); setInputText(''); if (transcriptBody) { toast.success('Voice note sent', 'Local transcript attached.'); } else { toast.success('Voice note sent'); } } catch (error) { toast.error('Failed to send voice note', error instanceof Error ? error.message : 'Unknown error'); } finally { stopVoiceRecognition(); voiceRecordingChunks = []; setVoiceTranscriptPreview(''); voiceFinalTranscript = ''; voiceInterimTranscript = ''; } }; recorder.start(250); startVoiceRecognition(); voiceRecordingTimer = window.setInterval(() => { setVoiceRecordingMs((prev) => prev + 250); }, 250); } catch (error) { setIsRecordingVoice(false); toast.error('Microphone access failed', error instanceof Error ? error.message : 'Unknown error'); } }; const stopVoiceRecording = () => { if (!isRecordingVoice()) return; setIsRecordingVoice(false); if (voiceRecordingTimer) { window.clearInterval(voiceRecordingTimer); voiceRecordingTimer = null; } stopVoiceRecognition(); if (voiceRecorder && voiceRecorder.state !== 'inactive') { try { voiceRecorder.stop(); } catch { // ignore } } if (voiceRecordingStream) { voiceRecordingStream.getTracks().forEach((track) => track.stop()); voiceRecordingStream = null; } voiceRecorder = null; }; const ensureCallLocalStream = async () => { if (callLocalStream) return callLocalStream; if (!navigator.mediaDevices?.getUserMedia) { throw new Error('Microphone capture is not supported in this browser'); } const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); callLocalStream = stream; return stream; }; const attachRemoteAudio = (userID: number, stream: MediaStream) => { let element = remoteAudioElements.get(userID); if (!element) { element = document.createElement('audio'); element.autoplay = true; element.setAttribute('playsinline', 'true'); element.style.display = 'none'; document.body.appendChild(element); remoteAudioElements.set(userID, element); } element.srcObject = stream; setActiveCallPeerIds((prev) => (prev.includes(userID) ? prev : [...prev, userID])); }; const removeRemoteAudio = (userID: number) => { const element = remoteAudioElements.get(userID); if (element) { element.srcObject = null; element.remove(); remoteAudioElements.delete(userID); } setActiveCallPeerIds((prev) => prev.filter((id) => id !== userID)); }; const closePeerConnection = (userID: number) => { const pc = callPeers.get(userID); if (pc) { try { pc.onicecandidate = null; pc.ontrack = null; pc.close(); } catch { // ignore } callPeers.delete(userID); } removeRemoteAudio(userID); }; const buildPeerConnection = (conversationID: number, peerUserID: number) => { let pc = callPeers.get(peerUserID); if (pc) return pc; pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }); callPeers.set(peerUserID, pc); if (callLocalStream) { callLocalStream.getTracks().forEach((track) => { try { pc!.addTrack(track, callLocalStream!); } catch { // ignore duplicate track errors } }); } pc.onicecandidate = (event) => { if (!event.candidate || !realtime || wsStatus() !== 'connected') return; realtime.send({ type: 'call.ice', conversation_id: conversationID, target_user_id: peerUserID, candidate: event.candidate, }); }; pc.ontrack = (event) => { const stream = event.streams[0]; if (stream) { attachRemoteAudio(peerUserID, stream); } }; pc.onconnectionstatechange = () => { if (pc?.connectionState === 'failed' || pc?.connectionState === 'disconnected' || pc?.connectionState === 'closed') { closePeerConnection(peerUserID); if (callPeers.size === 0 && callStatus() === 'in_call') { setCallStatus('idle'); setActiveCallConversationId(null); stopCallRecognition(); } } }; return pc; }; const handleIncomingCallOffer = async (payload: any) => { const senderID = Number(payload?.sender_id || 0); const targetID = Number(payload?.target_user_id || 0); const conversationID = Number(payload?.conversation_id || payload?.conversationID || 0); if (!senderID || !conversationID || !payload?.sdp) return; if (targetID && targetID !== currentUserId()) return; try { await ensureCallLocalStream(); if (activeCallConversationId() && activeCallConversationId() !== conversationID) { toast.warning('Incoming call ignored', 'You already have an active call in another conversation.'); return; } setActiveCallConversationId(conversationID); if (selectedConversationId() !== conversationID) { setSelectedConversationId(conversationID); } const pc = buildPeerConnection(conversationID, senderID); await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); realtime?.send({ type: 'call.answer', conversation_id: conversationID, target_user_id: senderID, sdp: answer, }); setCallStatus('in_call'); startCallRecognition(); toast.info('Voice call', 'Call connected.'); } catch (error) { setCallStatus('error'); toast.error('Failed to answer call', error instanceof Error ? error.message : 'Unknown error'); } }; const handleIncomingCallAnswer = async (payload: any) => { const senderID = Number(payload?.sender_id || 0); const targetID = Number(payload?.target_user_id || 0); if (!senderID || !payload?.sdp) return; if (targetID && targetID !== currentUserId()) return; const pc = callPeers.get(senderID); if (!pc) return; try { await pc.setRemoteDescription(new RTCSessionDescription(payload.sdp)); setCallStatus('in_call'); startCallRecognition(); } catch { // ignore bad/late answers } }; const handleIncomingCallICE = async (payload: any) => { const senderID = Number(payload?.sender_id || 0); const targetID = Number(payload?.target_user_id || 0); if (!senderID || !payload?.candidate) return; if (targetID && targetID !== currentUserId()) return; const pc = callPeers.get(senderID); if (!pc) return; try { await pc.addIceCandidate(new RTCIceCandidate(payload.candidate)); } catch { // ignore stale ice candidates } }; const cleanupCallResources = () => { for (const userID of Array.from(callPeers.keys())) { closePeerConnection(userID); } callPeers.clear(); if (callLocalStream) { callLocalStream.getTracks().forEach((track) => track.stop()); callLocalStream = null; } setCallMuted(false); setActiveCallConversationId(null); setActiveCallPeerIds([]); setCallStatus('idle'); stopCallRecognition(); }; const endVoiceCall = (notifyRemote: boolean = true) => { const conversationID = activeCallConversationId(); if (notifyRemote && conversationID && realtime && wsStatus() === 'connected') { realtime.send({ type: 'call.hangup', conversation_id: conversationID, }); } cleanupCallResources(); }; const startVoiceCall = async () => { const conversationID = selectedConversationId(); if (!conversationID) { toast.warning('Conversation required', 'Select a conversation first.'); return; } if (isCallActive()) return; const peers = conversationMembers() .map((member) => member.user_id) .filter((id) => id !== currentUserId()); if (peers.length === 0) { toast.warning('No participants', 'Add at least one other user to call.'); return; } try { setCallStatus('starting'); await ensureCallLocalStream(); setActiveCallConversationId(conversationID); setCallStatus('calling'); for (const peerID of peers) { const pc = buildPeerConnection(conversationID, peerID); const offer = await pc.createOffer({ offerToReceiveAudio: true, }); await pc.setLocalDescription(offer); realtime?.send({ type: 'call.offer', conversation_id: conversationID, target_user_id: peerID, sdp: offer, }); } startCallRecognition(); } catch (error) { setCallStatus('error'); cleanupCallResources(); toast.error('Failed to start call', error instanceof Error ? error.message : 'Unknown error'); } }; const toggleCallMute = () => { if (!callLocalStream) return; const nextMuted = !callMuted(); callLocalStream.getAudioTracks().forEach((track) => { track.enabled = !nextMuted; }); setCallMuted(nextMuted); }; const loadConversations = async () => { setLoadingConversations(true); try { const data = await messagesApi.listConversations(); setConversations(data.conversations || []); if (!selectedConversationId() && data.conversations?.length) { setSelectedConversationId(data.conversations[0].conversation.id); } } catch (error) { toast.error('Failed to load conversations', error instanceof Error ? error.message : 'Unknown error'); } finally { setLoadingConversations(false); } }; const loadMessages = async (conversationId: number) => { setLoadingMessages(true); try { const data = await messagesApi.getMessages(conversationId, undefined, 50); setMessages(data.messages || []); } catch (error) { toast.error('Failed to load messages', error instanceof Error ? error.message : 'Unknown error'); } finally { setLoadingMessages(false); } }; const loadConversationDetails = async (conversationId: number) => { try { const data = await messagesApi.getConversation(conversationId); setConversationMembers(data.members || []); } catch { setConversationMembers([]); } }; const loadMembers = async () => { const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''; try { const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/members?limit=200`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!res.ok) return; const data = await res.json(); const mapped: MemberOption[] = (data.members || []).map((m: any) => ({ id: Number(m.id), username: m.username || '', name: m.name || m.full_name || m.email || `User ${m.id}`, })); setMembers(mapped); } catch { // ignore } }; const loadVaultItems = async () => { try { const data = await messagesApi.listVaultItems(); setVaultItems(data.items || []); } catch (error) { toast.error('Failed to load vault items', error instanceof Error ? error.message : 'Unknown error'); } }; const handleWsEvent = (event: any) => { const eventType = event?.type || ''; const eventConversationId = Number(event?.conversation_id || event?.data?.conversation_id || 0); if (eventType === 'message.created' && event?.data) { const incoming: Message = event.data; if (incoming.conversation_id === selectedConversationId()) { setMessages((prev) => (prev.some((m) => m.id === incoming.id) ? prev : [...prev, incoming])); } setTypingByConversation((prev) => { const convMap = { ...(prev[incoming.conversation_id] || {}) }; delete convMap[incoming.sender_id]; return { ...prev, [incoming.conversation_id]: convMap, }; }); if (incoming.sender_id !== currentUserId()) { const conv = conversations().find((c) => c.conversation.id === incoming.conversation_id); const convName = conv?.conversation.name || 'New message'; toast.info(convName, incoming.body || 'New message received'); maybeNotify(convName, incoming.body || 'New message received'); } loadConversations(); return; } if (eventType === 'message.updated' && event?.data) { const updated: Message = event.data; setMessages((prev) => prev.map((m) => (m.id === updated.id ? { ...m, ...updated } : m))); return; } if (eventType === 'message.deleted' && event?.data) { const deletedId = Number(event.data.message_id); setMessages((prev) => prev.map((m) => (m.id === deletedId ? { ...m, body: '[deleted]', deleted_at: new Date().toISOString() } : m))); return; } if (eventType === 'reaction.added' && event?.data) { const reaction = event.data; setMessages((prev) => prev.map((m) => m.id === reaction.message_id ? { ...m, reactions: [...(m.reactions || []), reaction] } : m ) ); return; } if (eventType === 'reaction.removed' && event?.data) { const payload = event.data; setMessages((prev) => prev.map((m) => m.id === payload.message_id ? { ...m, reactions: (m.reactions || []).filter( (r) => !(r.user_id === payload.user_id && r.emoji === payload.emoji) ), } : m ) ); return; } if ((eventType === 'typing.started' || eventType === 'typing.stopped') && event?.data) { const payload = event.data; const conversationID = Number(payload.conversation_id || eventConversationId); const userID = Number(payload.user_id || 0); if (!conversationID || !userID || userID === currentUserId()) return; if (eventType === 'typing.started') { setTypingByConversation((prev) => { const convMap = { ...(prev[conversationID] || {}) }; convMap[userID] = Date.now(); return { ...prev, [conversationID]: convMap, }; }); } else { setTypingByConversation((prev) => { const convMap = { ...(prev[conversationID] || {}) }; delete convMap[userID]; return { ...prev, [conversationID]: convMap, }; }); } return; } if (eventType === 'call.offer' && event?.data) { void handleIncomingCallOffer(event.data); return; } if (eventType === 'call.answer' && event?.data) { void handleIncomingCallAnswer(event.data); return; } if (eventType === 'call.ice' && event?.data) { void handleIncomingCallICE(event.data); return; } if (eventType === 'call.hangup' && event?.data) { const conversationID = Number(event.data.conversation_id || eventConversationId); if (conversationID && activeCallConversationId() === conversationID) { cleanupCallResources(); toast.info('Voice call', 'Call ended.'); } return; } if (eventType === 'conversation.updated' || eventConversationId > 0) { loadConversations(); } }; const startRealtime = () => { realtime = new MessagesRealtimeClient(handleWsEvent, (status) => { setWsStatus(status); }); realtime.connect(); }; const startPollingFallback = () => { if (pollInterval) return; pollInterval = window.setInterval(() => { loadConversations(); if (selectedConversationId()) { loadMessages(selectedConversationId()!); } }, 10000); }; const stopPollingFallback = () => { if (!pollInterval) return; window.clearInterval(pollInterval); pollInterval = null; }; const onFileSelect = (event: Event) => { const target = event.currentTarget as HTMLInputElement; const files = Array.from(target.files || []); setSelectedFiles(files); }; const sendMessage = async () => { if (!selectedConversationId()) return; const body = inputText().trim(); if (!body && selectedFiles().length === 0) return; try { const attachments: any[] = []; for (const file of selectedFiles()) { const uploaded = await uploadChatFile(file); attachments.push({ kind: uploaded.mime_type?.startsWith('image/') ? 'image' : 'file', file_id: uploaded.id, title: uploaded.original_name, url: `${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/files/${uploaded.id}/download`, }); } await postMessage(body, attachments); setInputText(''); setSelectedFiles([]); } catch (error) { toast.error('Failed to send message', error instanceof Error ? error.message : 'Unknown error'); } }; const handleSuggestionAction = async (messageId: number, suggestion: MessageSuggestion, action: 'accept' | 'dismiss') => { try { if (action === 'accept') { await messagesApi.acceptSuggestion(messageId, suggestion.id, { redact_original: suggestion.type === 'move_to_password_vault', }); toast.success('Suggestion applied'); } else { await messagesApi.dismissSuggestion(messageId, suggestion.id); } if (selectedConversationId()) { await loadMessages(selectedConversationId()!); } if (suggestion.type === 'move_to_password_vault') { await loadVaultItems(); } } catch (error) { toast.error('Failed to apply suggestion', error instanceof Error ? error.message : 'Unknown error'); } }; const addReaction = async (messageId: number, emoji: string) => { try { await messagesApi.addReaction(messageId, emoji); } catch { // ignore reaction errors in quick UI } }; const performSearch = async () => { setSearching(true); try { const data = await messagesApi.searchMessages({ query: searchQuery(), conversation_ids: searchConversationIds().length ? searchConversationIds() : undefined, sender_id: searchSenderId().trim() ? Number(searchSenderId()) : undefined, date_from: toISOOrUndefined(searchDateFrom()), date_to: toISOOrUndefined(searchDateTo()), attachment_kinds: searchAttachmentKinds().length ? searchAttachmentKinds() : undefined, reference_types: searchReferenceTypes().length ? searchReferenceTypes() : undefined, has_links: triStateToBool(searchHasLinks()), has_attachments: triStateToBool(searchHasAttachments()), has_suggestions: triStateToBool(searchHasSuggestions()), mention_only: searchMentionOnly(), limit: 50, offset: 0, }); setSearchResults(data.results || []); } catch (error) { toast.error('Search failed', error instanceof Error ? error.message : 'Unknown error'); } finally { setSearching(false); } }; const openSearchResult = async (result: Message) => { setSelectedConversationId(result.conversation_id); setSearchOpen(false); await loadMessages(result.conversation_id); }; const createConversation = async () => { try { const type = newConversationType(); let payload: any = { type, name: newConversationName().trim(), topic: newConversationTopic().trim(), }; if (type === 'dm') { payload.user_ids = [Number(targetUserId())]; } else if (type === 'group') { payload.user_ids = groupUserIds() .split(',') .map((part) => Number(part.trim())) .filter((id) => Number.isFinite(id) && id > 0); } else if (type === 'team') { payload.team_id = Number(teamId()); } const data = await messagesApi.createConversation(payload); setShowCreateConversation(false); setNewConversationName(''); setNewConversationTopic(''); setTargetUserId(''); setGroupUserIds(''); setTeamId(''); await loadConversations(); setSelectedConversationId(data.conversation.id); await loadMessages(data.conversation.id); } catch (error) { toast.error('Failed to create conversation', error instanceof Error ? error.message : 'Unknown error'); } }; const revealVaultItem = async (item: VaultItem) => { try { const revealed = await messagesApi.revealVaultItem(item.id); setRevealedSecrets((prev) => ({ ...prev, [item.id]: { secret: revealed.secret, notes: revealed.notes || '' }, })); toast.warning('Password Safety', revealed.warning || 'Handle revealed secrets with care.'); } catch (error) { toast.error('Failed to reveal vault item', error instanceof Error ? error.message : 'Unknown error'); } }; const shareVaultItem = async (item: VaultItem) => { const targetRaw = shareTargets()[item.id]; const targetConversationID = Number(targetRaw); if (!targetConversationID) { toast.warning('Target required', 'Enter a valid conversation ID.'); return; } try { await messagesApi.shareVaultItem(item.id, { target_conversation_id: targetConversationID, allow_reveal: true, }); toast.success('Vault item shared'); await loadVaultItems(); } catch (error) { toast.error('Failed to share vault item', error instanceof Error ? error.message : 'Unknown error'); } }; onMount(async () => { await Promise.all([loadConversations(), loadMembers(), loadVaultItems()]); startRealtime(); typingCleanupTimer = window.setInterval(() => { const cutoff = Date.now() - 6000; setTypingByConversation((prev) => { let changed = false; const next: Record> = {}; for (const [convIDRaw, users] of Object.entries(prev)) { const convID = Number(convIDRaw); const filtered: Record = {}; for (const [userIDRaw, startedAt] of Object.entries(users)) { if (Number(startedAt) >= cutoff) { filtered[Number(userIDRaw)] = Number(startedAt); } else { changed = true; } } next[convID] = filtered; } return changed ? next : prev; }); }, 1500); }); onCleanup(() => { markTypingStopped(); discardVoiceRecording = true; stopVoiceRecording(); cleanupCallResources(); if (typingCleanupTimer) { window.clearInterval(typingCleanupTimer); typingCleanupTimer = null; } if (typingStopTimer) { window.clearTimeout(typingStopTimer); typingStopTimer = null; } stopPollingFallback(); realtime?.disconnect(); }); // Keep message list in sync with selected conversation const syncSelectedConversation = async () => { if (!selectedConversationId()) return; await Promise.all([ loadMessages(selectedConversationId()!), loadConversationDetails(selectedConversationId()!), ]); }; // Re-run loading whenever selected conversation changes let lastConversationId: number | null = null; const selectionWatcher = setInterval(() => { const current = selectedConversationId(); if (current && current !== lastConversationId) { if (lastConversationId) { markTypingStopped(lastConversationId); } lastConversationId = current; syncSelectedConversation(); } }, 250); onCleanup(() => clearInterval(selectionWatcher)); // Fallback polling when websocket is disconnected. const wsWatcher = setInterval(() => { if (wsStatus() === 'connected') { stopPollingFallback(); } else { startPollingFallback(); } }, 1500); onCleanup(() => clearInterval(wsWatcher)); return (
{/* Left rail */}

Messages

Status: {wsStatus()} Refreshing…
{(item) => ( )}
{/* Center timeline */}

{activeConversation()?.conversation.name || 'Select a conversation'}

{activeConversation()?.conversation.topic || activeConversation()?.conversation.type || ''}

Call status: {callStatus()} 0}> • with {activeCallPeerIds().length} peer{activeCallPeerIds().length > 1 ? 's' : ''}
Call transcript preview: {callTranscriptPreview()}

Loading messages…

{(message) => (
{(message.sender?.full_name || message.sender?.username || 'U').charAt(0).toUpperCase()} } > {message.sender?.username
{message.sender?.full_name || message.sender?.username || `User ${message.sender_id}`} (edited)

{message.body}

Sensitive content detected. We recommend Proton Pass (not affiliated).
0}>
{(att) => ( {att.kind} {att.title || att.url} } >
{att.title || 'Voice note'}
)}
0}>
{(ref) => ( {ref.entity_type} )}
s.status === 'pending')}>
s.status === 'pending')}> {(suggestion) => (
{suggestion.type}
We do not recommend storing passwords in chat. If needed, move it to encrypted vault storage.
)}
0}>
{(message.reactions || []).map((r) => r.emoji).join(' ')}
)}
0}>
{activeTypingUserNames().length === 1 ? `${activeTypingUserNames()[0]} is typing...` : `${activeTypingUserNames().slice(0, 2).join(', ')}${activeTypingUserNames().length > 2 ? ' and others' : ''} are typing...`}
0}> {selectedFiles().length} file{selectedFiles().length > 1 ? 's' : ''} selected Recording {formatMs(voiceRecordingMs())}
Voice transcript preview: {voiceTranscriptPreview()}
{ const value = (e.currentTarget as HTMLInputElement).value; setInputText(value); if (value.trim()) { markTypingStarted(); } else { markTypingStopped(); } }} placeholder='Message (try links, tasks, events, or "@username" mentions)' onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }} />
{/* Right rail */}

Details

Members, vault items, and quick actions

Conversation Info

ID: {selectedConversationId() || '-'}

Type: {activeConversation()?.conversation.type || '-'}

Members

0} fallback={

No members loaded.

} >
{(member) => (
{(member.user?.full_name || member.user?.username || 'U').charAt(0).toUpperCase()} } > {member.user?.username
{member.user?.full_name || member.user?.username || `User ${member.user_id}`}
@{member.user?.username || member.user_id}
{member.role}
)}

Password Vault

{(item) => (
{item.label}
Owner: {item.owner_user_id} {item.shared ? '• Shared' : ''}
Secret: {revealedSecrets()[item.id].secret}
Notes: {revealedSecrets()[item.id].notes}
setShareTargets((prev) => ({ ...prev, [item.id]: (e.currentTarget as HTMLInputElement).value, })) } placeholder="Conversation ID" />
)}

Quick Create Conversation

{/* Create conversation modal */}

Create Conversation

setNewConversationName((e.currentTarget as HTMLInputElement).value)} placeholder="Conversation name" /> setNewConversationTopic((e.currentTarget as HTMLInputElement).value)} placeholder="Topic (optional)" /> setGroupUserIds((e.currentTarget as HTMLInputElement).value)} placeholder="Member user IDs, comma-separated (e.g. 2,3,7)" /> setTeamId((e.currentTarget as HTMLInputElement).value)} placeholder="Team ID" />
{/* Search modal */}

Search Messages

setSearchQuery((e.currentTarget as HTMLInputElement).value)} placeholder="Search text, file type, URLs, mentions..." onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Enter') performSearch(); }} />
setSearchDateFrom((e.currentTarget as HTMLInputElement).value)} />
setSearchDateTo((e.currentTarget as HTMLInputElement).value)} />
{(item) => ( )}
{(kind) => ( )}
{(refType) => ( )}
{(result) => ( )}
No results yet.
); }; export default Messages;