import { createSignal, For, Show, onCleanup, onMount } from 'solid-js'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { toast } from '@/components/ui/Toast'; import { MessagesRealtimeClient, messagesApi, uploadChatFile, } from '@/lib/messages'; import type { ConversationListItem, ConversationMember, Message, MessageReferenceInput, MessageSuggestion, UserFile, } from '@/lib/messages'; import { IconAt, IconBell, IconBellOff, IconBolt, IconChevronLeft, IconCircleCheck, IconFile, IconHeart, IconMessageCircle, IconMicrophone, IconMicrophoneOff, IconPaperclip, IconPhone, IconPhoneOff, IconPlus, IconSearch, IconSend, IconSparkles, IconThumbUp, IconUpload, IconX, } from '@tabler/icons-solidjs'; import { useHaptics } from '@/lib/haptics'; import './Messages.css'; interface MemberOption { id: number; username: string; name: string; } interface TeamOption { id: number; name: string; } interface MentionUserOption { type: 'user'; id: number; username: string; label: string; } interface MentionFileOption { type: 'file'; file: UserFile; label: string; } interface AIShareSessionOption { id: number; title: string; message_count: number; created_at: string; last_message_at?: string; } interface AIShareMessageOption { id: number; session_id: number; content: string; role: 'user' | 'assistant'; created_at: string; } interface ComposerAIReference { entity_type: 'ai_chat_session' | 'ai_chat_message'; entity_id: number; deep_link: string; title: string; subtitle: string; mention_token: string; attachment_title: string; } interface MentionAISessionOption { type: 'ai_chat_session'; session: AIShareSessionOption; label: string; } interface MentionAIMessageOption { type: 'ai_chat_message'; session: AIShareSessionOption; message: AIShareMessageOption; label: string; } type MentionOption = MentionUserOption | MentionFileOption | MentionAISessionOption | MentionAIMessageOption; interface ComposerLibraryFile { id: number; original_name: string; mime_type: string; } interface AIProviderOption { id: string; name: string; } type ReactionKey = 'thumb_up' | 'heart' | 'bolt' | 'check' | 'sparkles'; const REACTION_PRESETS: Array<{ key: ReactionKey; label: string; icon: any }> = [ { key: 'thumb_up', label: 'Thumb up', icon: IconThumbUp }, { key: 'heart', label: 'Heart', icon: IconHeart }, { key: 'bolt', label: 'Bolt', icon: IconBolt }, { key: 'check', label: 'Check', icon: IconCircleCheck }, { key: 'sparkles', label: 'Sparkles', icon: IconSparkles }, ]; 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', 'ai_chat_session', 'ai_chat_message', ]; type TriStateFilter = 'any' | 'yes' | 'no'; type CallStatus = 'idle' | 'starting' | 'calling' | 'in_call' | 'error'; type SpotlightAlertTone = 'error' | 'warning' | 'info'; interface SpotlightAlert { tone: SpotlightAlertTone; title: string; message?: string; } interface MessagingAlertContent { tone: SpotlightAlertTone; title: string; message: string; } export const Messages = () => { const haptics = useHaptics(); const [conversations, setConversations] = createSignal([]); const [messages, setMessages] = createSignal([]); const [selectedConversationId, setSelectedConversationId] = createSignal(null); const [activeScreen, setActiveScreen] = createSignal<'list' | 'conversation'>('list'); 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 [members, setMembers] = createSignal([]); const [teams, setTeams] = 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(null); const [groupUserIds, setGroupUserIds] = createSignal([]); const [teamId, setTeamId] = createSignal(null); 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 [attachedLibraryFiles, setAttachedLibraryFiles] = createSignal([]); const [mentionQuery, setMentionQuery] = createSignal(''); const [mentionOpen, setMentionOpen] = createSignal(false); const [mentionOptions, setMentionOptions] = createSignal([]); const [mentionHighlightedIndex, setMentionHighlightedIndex] = createSignal(0); const [mentionLoading, setMentionLoading] = createSignal(false); const [isDragOverComposer, setIsDragOverComposer] = createSignal(false); const [revealedSensitiveMessages, setRevealedSensitiveMessages] = createSignal>({}); const [uploadProgress, setUploadProgress] = createSignal<{ done: number; total: number } | null>(null); const [aiReferenceEnabled, setAiReferenceEnabled] = createSignal(false); const [aiReferenceModel, setAiReferenceModel] = createSignal('longcat'); const [aiReferenceContext, setAiReferenceContext] = createSignal(''); const [aiProviders, setAiProviders] = createSignal([]); const [aiSettings, setAiSettings] = createSignal>({}); const [showAiSharePicker, setShowAiSharePicker] = createSignal(false); const [aiShareSessions, setAiShareSessions] = createSignal([]); const [aiShareMessagesBySession, setAiShareMessagesBySession] = createSignal>({}); const [aiShareSelectedSessionId, setAiShareSelectedSessionId] = createSignal(null); const [aiShareLoadingSessions, setAiShareLoadingSessions] = createSignal(false); const [aiShareLoadingMessages, setAiShareLoadingMessages] = createSignal(false); const [composerAiReferences, setComposerAiReferences] = createSignal([]); const [spotlightAlert, setSpotlightAlert] = createSignal(null); 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 = ''; let mentionSearchTimer: number | null = null; let mentionSearchToken = 0; let composerTextareaRef: HTMLTextAreaElement | null = null; let hiddenFileInputRef: HTMLInputElement | null = null; const sensitiveRevealTimers = new Map(); let spotlightAlertTimer: number | null = null; 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 openConversation = (conversationId: number) => { setSelectedConversationId(conversationId); setActiveScreen('conversation'); haptics.selection(); // Selection feedback for opening conversation }; const closeConversation = () => { const conversationID = selectedConversationId(); if (conversationID) { markTypingStopped(conversationID); } if (isCallActive()) { endVoiceCall(true); } setActiveScreen('list'); }; const showSpotlightAlert = (tone: SpotlightAlertTone, title: string, message?: string) => { setSpotlightAlert({ tone, title, message }); if (spotlightAlertTimer) { window.clearTimeout(spotlightAlertTimer); } spotlightAlertTimer = window.setTimeout(() => { setSpotlightAlert(null); spotlightAlertTimer = null; }, 7000); }; const dismissSpotlightAlert = () => { if (spotlightAlertTimer) { window.clearTimeout(spotlightAlertTimer); spotlightAlertTimer = null; } setSpotlightAlert(null); }; const shouldUseSensitiveReveal = () => { const type = activeConversation()?.conversation.type; return type === 'dm' || type === 'self'; }; const conversationNameById = (id: number) => conversations().find((item) => item.conversation.id === id)?.conversation.name || 'Conversation'; const formatConversationPreview = (item: ConversationListItem) => { const body = item.last_message?.body?.trim(); if (body) return body; if (item.conversation.topic?.trim()) return item.conversation.topic.trim(); switch (item.conversation.type) { case 'dm': return 'Direct message'; case 'group': return 'Group chat'; case 'team': return 'Team channel'; case 'global': return 'Workspace channel'; case 'self': return 'Notes to self'; case 'password_vault': return 'Password vault'; default: return 'Conversation'; } }; const formatConversationTime = (item: ConversationListItem) => { const raw = item.conversation.last_message_at || item.last_message?.created_at || item.conversation.updated_at; const parsed = new Date(raw); if (Number.isNaN(parsed.getTime())) return ''; const now = new Date(); const sameDay = parsed.toDateString() === now.toDateString(); if (sameDay) { return parsed.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1); if (parsed.toDateString() === yesterday.toDateString()) { return 'Yesterday'; } return parsed.toLocaleDateString([], { month: 'short', day: 'numeric' }); }; const getConversationInitials = (item: ConversationListItem) => { const label = (item.conversation.name || item.conversation.type || 'C').trim(); if (label.startsWith('#')) { return '#'; } const parts = label.split(/\s+/).filter(Boolean).slice(0, 2); if (parts.length === 0) return 'C'; return parts.map((part) => part.charAt(0).toUpperCase()).join(''); }; const getConversationAvatarClass = (item: ConversationListItem) => { switch (item.conversation.type) { case 'dm': return 'conversation-item-avatar-dm'; case 'group': return 'conversation-item-avatar-group'; case 'team': return 'conversation-item-avatar-team'; case 'global': return 'conversation-item-avatar-global'; case 'self': return 'conversation-item-avatar-self'; case 'password_vault': return 'conversation-item-avatar-vault'; default: return 'conversation-item-avatar-default'; } }; const formatMessagingAlert = (error: unknown, fallbackTitle: string): MessagingAlertContent => { const rawMessage = error instanceof Error ? error.message : 'Unknown error'; if (/Failed to initialize messaging defaults/i.test(rawMessage)) { return { tone: 'error', title: 'Messaging is not ready yet', message: 'The backend could not provision messaging defaults. Reload after the migration fix is applied.', }; } if (/User not authenticated/i.test(rawMessage)) { return { tone: 'warning', title: 'Session expired', message: 'Sign in again to keep using messages.', }; } return { tone: 'error', title: fallbackTitle, message: rawMessage, }; }; const normalizeReactionKey = (value: string): ReactionKey => { const raw = value.trim().toLowerCase(); if (!raw) return 'thumb_up'; if (raw === 'thumb_up' || raw === 'šŸ‘' || raw === ':+1:') return 'thumb_up'; if (raw === 'heart' || raw === 'ā¤ļø' || raw === 'ā¤') return 'heart'; if (raw === 'bolt' || raw === 'šŸ”„') return 'bolt'; if (raw === 'check' || raw === 'āœ…') return 'check'; if (raw === 'sparkles' || raw === '✨' || raw === '⭐' || raw === 'star') return 'sparkles'; return 'thumb_up'; }; const reactionIconForKey = (key: ReactionKey) => REACTION_PRESETS.find((preset) => preset.key === key)?.icon || IconThumbUp; const groupedReactions = (message: Message) => { const grouped = new Map(); for (const reaction of message.reactions || []) { const key = normalizeReactionKey(reaction.emoji); const current = grouped.get(key) || { count: 0, mine: false, rawMine: [] }; current.count += 1; if (reaction.user_id === currentUserId()) { current.mine = true; current.rawMine.push(reaction.emoji); } grouped.set(key, current); } return grouped; }; const reactionEntries = (message: Message) => Array.from(groupedReactions(message).entries()); 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 referenceKey = (ref: { entity_type: string; entity_id: number }) => `${ref.entity_type}:${ref.entity_id}`; const sanitizeMentionToken = (value: string) => value .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, '') .slice(0, 40) || 'ai-reference'; const availableAiReferenceModels = () => { const configured = Object.entries(aiSettings()) .filter(([, config]) => config?.enabled) .map(([provider]) => provider); const providerIds = aiProviders().map((provider) => provider.id); const combined = Array.from(new Set([...configured, ...providerIds])); if (combined.length === 0) { return [aiReferenceModel() || 'longcat']; } return combined; }; const buildAiSessionReference = (session: AIShareSessionOption): ComposerAIReference => { const token = `@ai-session:${sanitizeMentionToken(session.title || `session-${session.id}`)}`; return { entity_type: 'ai_chat_session', entity_id: session.id, deep_link: `/app/chat?session=${session.id}`, title: session.title || `AI Session #${session.id}`, subtitle: `${session.message_count} messages`, mention_token: token, attachment_title: `AI session: ${session.title || `#${session.id}`}`, }; }; const buildAiMessageReference = (session: AIShareSessionOption, message: AIShareMessageOption): ComposerAIReference => { const preview = (message.content || '').trim().slice(0, 64) || `Message #${message.id}`; const token = `@ai-msg:${sanitizeMentionToken(preview)}`; return { entity_type: 'ai_chat_message', entity_id: message.id, deep_link: `/app/chat?session=${session.id}&message=${message.id}`, title: `${session.title || `Session #${session.id}`}`, subtitle: preview, mention_token: token, attachment_title: `AI message: ${preview}`, }; }; const addComposerAiReference = (next: ComposerAIReference) => { setComposerAiReferences((prev) => { if (prev.some((entry) => referenceKey(entry) === referenceKey(next))) { return prev; } return [...prev, next]; }); }; const removeComposerAiReference = (entityType: ComposerAIReference['entity_type'], entityId: number) => { setComposerAiReferences((prev) => prev.filter((entry) => !(entry.entity_type === entityType && entry.entity_id === entityId)) ); }; 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 || 'Unknown user'; }); return names; }; const activeMentionToken = (text: string, caret: number) => { const beforeCaret = text.slice(0, caret); const atIndex = beforeCaret.lastIndexOf('@'); if (atIndex < 0) return null; if (atIndex > 0 && /\S/.test(beforeCaret.charAt(atIndex - 1))) { return null; } const token = beforeCaret.slice(atIndex + 1); if (/\s/.test(token)) return null; return { query: token.trim().toLowerCase(), start: atIndex, end: caret, }; }; const refreshMentionOptions = (query: string) => { const normalizedQuery = query.trim().toLowerCase(); const conversationUserOptions: MentionUserOption[] = conversationMembers() .map((member) => { const username = (member.user?.username || '').trim(); const label = member.user?.full_name || username || 'Unknown user'; return { type: 'user' as const, id: member.user_id, username: username || label.toLowerCase().replace(/\s+/g, '.'), label, }; }) .filter((option) => option.id !== currentUserId()) .filter((option, index, arr) => arr.findIndex((entry) => entry.id === option.id) === index) .filter((option) => !normalizedQuery ? true : `${option.label} ${option.username}`.toLowerCase().includes(normalizedQuery) ) .slice(0, 6); if (!aiShareSessions().length && (normalizedQuery.startsWith('ai') || normalizedQuery.startsWith('chat'))) { void loadAIShareSessions(); } const aiSessionOptions: MentionAISessionOption[] = aiShareSessions() .filter((session) => !normalizedQuery ? true : `${session.title} ai session ${session.id}`.toLowerCase().includes(normalizedQuery) ) .slice(0, 3) .map((session) => ({ type: 'ai_chat_session', session, label: `AI Session: ${session.title}`, })); const aiMessageOptions: MentionAIMessageOption[] = aiShareSessions() .slice(0, 4) .flatMap((session) => (aiShareMessagesBySession()[session.id] || []).slice(0, 8).map((message) => ({ type: 'ai_chat_message' as const, session, message, label: `AI Message: ${(message.content || '').slice(0, 48) || `#${message.id}`}`, })) ) .filter((option) => !normalizedQuery ? true : `${option.session.title} ${option.message.content} ai message`.toLowerCase().includes(normalizedQuery) ) .slice(0, 3); setMentionLoading(true); const token = ++mentionSearchToken; void messagesApi .listUserFiles(normalizedQuery, 8) .then((files) => { if (token !== mentionSearchToken) return; const fileOptions: MentionFileOption[] = (files || []).map((file) => ({ type: 'file', file, label: file.original_name || 'Attachment', })); const merged: MentionOption[] = [...conversationUserOptions, ...fileOptions, ...aiSessionOptions, ...aiMessageOptions]; setMentionOptions(merged); setMentionHighlightedIndex(0); }) .catch(() => { if (token !== mentionSearchToken) return; setMentionOptions([...conversationUserOptions, ...aiSessionOptions, ...aiMessageOptions]); }) .finally(() => { if (token === mentionSearchToken) { setMentionLoading(false); } }); }; const requestNotificationPermission = async () => { if (!('Notification' in window)) { toast.warning('Browser notifications unavailable', 'This browser does not support notifications.'); showSpotlightAlert('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'); if (!enabled) { showSpotlightAlert( 'warning', permission === 'denied' ? 'Notifications blocked' : 'Permission dismissed', permission === 'denied' ? 'Browser notifications are blocked. Enable them in your browser settings if you want message alerts.' : 'Notifications stay off until you allow them.' ); } }; 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, options?: { metadata?: Record; references?: MessageReferenceInput[]; } ) => { const conversationID = explicitConversationId || selectedConversationId(); if (!conversationID) return; const trimmedBody = body.trim(); if (!trimmedBody && attachments.length === 0 && (options?.references || []).length === 0) return; markTypingStopped(); setSendingMessage(true); try { const response = await messagesApi.sendMessage(conversationID, { body: trimmedBody, attachments, metadata: options?.metadata, references: options?.references, }); const created = response.message; setMessages((prev) => (prev.some((m) => m.id === created.id) ? prev : [...prev, created])); 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: Array<{ kind: string; file_id?: number; title?: string; url?: string }> = [{ 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'); const references: MessageReferenceInput[] = composerAiReferences().map((reference) => ({ entity_type: reference.entity_type, entity_id: reference.entity_id, deep_link: reference.deep_link, })); for (const reference of composerAiReferences()) { attachments.push({ kind: 'activity', title: reference.attachment_title, url: reference.deep_link, }); } const metadata = aiReferenceEnabled() || references.length > 0 || aiReferenceContext().trim() ? { ai_reference: { enabled: aiReferenceEnabled(), provider: aiReferenceModel(), context: aiReferenceContext().trim(), references, }, } : undefined; await postMessage(composedBody, attachments, conversationID, { metadata, references, }); setInputText(''); setComposerAiReferences([]); 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 || activeScreen() !== 'conversation') { openConversation(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'); showSpotlightAlert('error', 'Failed to answer call', error instanceof Error ? error.message : 'Unknown 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) { showSpotlightAlert('warning', 'No participants', 'Add at least one other user to call.'); 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(); showSpotlightAlert('error', 'Failed to start call', error instanceof Error ? error.message : 'Unknown error'); 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(); const nextConversations = data.conversations || []; setConversations(nextConversations); const selectedID = selectedConversationId(); if (selectedID && !nextConversations.some((item) => item.conversation.id === selectedID)) { setSelectedConversationId(null); setActiveScreen('list'); } if (nextConversations.length === 0) { setSelectedConversationId(null); setActiveScreen('list'); } else if (!selectedID) { const firstConversationID = nextConversations[0].conversation.id; setSelectedConversationId(firstConversationID); if (window.innerWidth >= 768) { setActiveScreen('conversation'); } } } catch (error) { const alert = formatMessagingAlert(error, 'Failed to load conversations'); showSpotlightAlert(alert.tone, alert.title, alert.message); } finally { setLoadingConversations(false); } }; const loadMessages = async (conversationId: number) => { setLoadingMessages(true); try { const data = await messagesApi.getMessages(conversationId, undefined, 50); setMessages(data.messages || []); } catch (error) { const alert = formatMessagingAlert(error, 'Failed to load messages'); showSpotlightAlert(alert.tone, alert.title, alert.message); } 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.username || m.email || 'Member', })); setMembers(mapped); } catch { // ignore } }; const loadTeams = 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/teams?limit=200`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!res.ok) return; const data = await res.json(); const mapped: TeamOption[] = (data.teams || []).map((team: any) => ({ id: Number(team.id), name: team.name || 'Team', })); setTeams(mapped.filter((team) => Number.isFinite(team.id) && team.id > 0)); } catch { // ignore } }; const loadAIProviders = 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/ai/providers`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!res.ok) return; const data = await res.json(); const providers = (data.providers || []) as Array<{ id?: string; name?: string }>; const mapped = providers .map((provider) => ({ id: (provider.id || '').trim(), name: (provider.name || provider.id || 'AI provider').trim(), })) .filter((provider) => provider.id); setAiProviders(mapped); } catch { // ignore provider loading failures } }; const loadAISettings = 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/auth/ai/settings`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!res.ok) return; const data = await res.json(); if (!data || typeof data !== 'object') return; const normalized: Record = {}; for (const [provider, raw] of Object.entries(data as Record)) { if (!raw || typeof raw !== 'object') continue; const cfg = raw as Record; normalized[provider] = { enabled: Boolean(cfg.enabled), model: typeof cfg.model === 'string' ? cfg.model : undefined, model_thinking: typeof cfg.model_thinking === 'string' ? cfg.model_thinking : undefined, }; } setAiSettings(normalized); const firstEnabledProvider = Object.entries(normalized).find(([, cfg]) => cfg.enabled)?.[0]; if (firstEnabledProvider) { setAiReferenceModel(firstEnabledProvider); } } catch { // ignore settings loading failures } }; const loadAIShareSessions = async () => { const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''; setAiShareLoadingSessions(true); try { const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!res.ok) { throw new Error('Failed to load AI sessions'); } const data = (await res.json()) as Array>; const mapped: AIShareSessionOption[] = (Array.isArray(data) ? data : []).map((session) => ({ id: Number(session.id || 0), title: typeof session.title === 'string' && session.title.trim() ? session.title : `Session #${session.id}`, message_count: Number(session.message_count || 0), created_at: typeof session.created_at === 'string' ? session.created_at : new Date().toISOString(), last_message_at: typeof session.last_message_at === 'string' ? session.last_message_at : undefined, })).filter((session) => session.id > 0); setAiShareSessions(mapped); if (mapped.length > 0 && !aiShareSelectedSessionId()) { setAiShareSelectedSessionId(mapped[0].id); void loadAIShareMessages(mapped[0].id); } } catch { toast.error('Failed to load AI sessions'); } finally { setAiShareLoadingSessions(false); } }; const loadAIShareMessages = async (sessionId: number) => { const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''; if (!sessionId) return; if (aiShareMessagesBySession()[sessionId]) return; setAiShareLoadingMessages(true); try { const res = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/chat/sessions/${sessionId}/messages`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!res.ok) { throw new Error('Failed to load AI messages'); } const data = (await res.json()) as Array>; const mapped: AIShareMessageOption[] = (Array.isArray(data) ? data : []).map((message) => ({ id: Number(message.id || 0), session_id: sessionId, content: typeof message.content === 'string' ? message.content : '', role: (message.role === 'assistant' ? 'assistant' : 'user') as 'assistant' | 'user', created_at: typeof message.created_at === 'string' ? message.created_at : new Date().toISOString(), })).filter((message) => message.id > 0); setAiShareMessagesBySession((prev) => ({ ...prev, [sessionId]: mapped, })); } catch { toast.error('Failed to load AI messages'); } finally { setAiShareLoadingMessages(false); } }; 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 || []); if (files.length > 0) { setSelectedFiles((prev) => [...prev, ...files]); } if (target) { target.value = ''; } }; const removeLocalFile = (index: number) => { setSelectedFiles((prev) => prev.filter((_, fileIndex) => fileIndex !== index)); }; const removeAttachedLibraryFile = (id: number) => { setAttachedLibraryFiles((prev) => prev.filter((file) => file.id !== id)); }; const selectMentionOption = (option: MentionOption, range: { start: number; end: number }) => { const currentText = inputText(); let inserted = ''; if (option.type === 'user') { inserted = `@${option.username} `; } else if (option.type === 'file') { const readableFileName = (option.file.original_name || 'file').trim() || 'file'; inserted = `@${readableFileName.replace(/\s+/g, '_')} `; setAttachedLibraryFiles((prev) => prev.some((entry) => entry.id === option.file.id) ? prev : [ ...prev, { id: option.file.id, original_name: option.file.original_name || 'Attachment', mime_type: option.file.mime_type, }, ] ); } else if (option.type === 'ai_chat_session') { const reference = buildAiSessionReference(option.session); inserted = `${reference.mention_token} `; addComposerAiReference(reference); } else { const reference = buildAiMessageReference(option.session, option.message); inserted = `${reference.mention_token} `; addComposerAiReference(reference); } const updated = `${currentText.slice(0, range.start)}${inserted}${currentText.slice(range.end)}`; setInputText(updated); setMentionOpen(false); setMentionOptions([]); setMentionQuery(''); window.requestAnimationFrame(() => { if (!composerTextareaRef) return; const caret = range.start + inserted.length; composerTextareaRef.focus(); composerTextareaRef.setSelectionRange(caret, caret); }); }; const handleComposerInput = (event: InputEvent & { currentTarget: HTMLTextAreaElement }) => { const value = event.currentTarget.value; const caret = event.currentTarget.selectionStart || value.length; setInputText(value); if (value.trim()) { markTypingStarted(); } else { markTypingStopped(); } const mentionToken = activeMentionToken(value, caret); if (!mentionToken) { setMentionOpen(false); setMentionOptions([]); setMentionQuery(''); return; } const nextQuery = mentionToken.query; setMentionOpen(true); setMentionQuery(nextQuery); if (mentionSearchTimer) { window.clearTimeout(mentionSearchTimer); mentionSearchTimer = null; } mentionSearchTimer = window.setTimeout(() => { refreshMentionOptions(nextQuery); }, 120); }; const handleComposerDrop = (event: DragEvent) => { event.preventDefault(); setIsDragOverComposer(false); const droppedFiles = Array.from(event.dataTransfer?.files || []); if (droppedFiles.length > 0) { setSelectedFiles((prev) => [...prev, ...droppedFiles]); } }; const sendMessage = async () => { if (!selectedConversationId()) return; const body = inputText().trim(); if (!body && selectedFiles().length === 0 && attachedLibraryFiles().length === 0 && composerAiReferences().length === 0) return; try { const localFiles = [...selectedFiles()]; if (localFiles.length > 0) { setUploadProgress({ done: 0, total: localFiles.length }); } else { setUploadProgress(null); } const attachments: any[] = []; for (let i = 0; i < localFiles.length; i += 1) { const file = localFiles[i]; 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`, }); setUploadProgress({ done: i + 1, total: localFiles.length }); } for (const file of attachedLibraryFiles()) { attachments.push({ kind: file.mime_type?.startsWith('image/') ? 'image' : 'file', file_id: file.id, title: file.original_name, url: `${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/files/${file.id}/download`, }); } const references: MessageReferenceInput[] = composerAiReferences().map((reference) => ({ entity_type: reference.entity_type, entity_id: reference.entity_id, deep_link: reference.deep_link, })); for (const reference of composerAiReferences()) { attachments.push({ kind: 'activity', title: reference.attachment_title, url: reference.deep_link, }); } const metadata = aiReferenceEnabled() || references.length > 0 || aiReferenceContext().trim() ? { ai_reference: { enabled: aiReferenceEnabled(), provider: aiReferenceModel(), context: aiReferenceContext().trim(), references, }, } : undefined; await postMessage(body, attachments, undefined, { metadata, references, }); setInputText(''); setSelectedFiles([]); setAttachedLibraryFiles([]); setComposerAiReferences([]); setMentionOptions([]); setMentionOpen(false); setMentionQuery(''); haptics.success(); // Success feedback for sending message } catch (error) { toast.error('Failed to send message', error instanceof Error ? error.message : 'Unknown error'); haptics.error(); // Error feedback } finally { setUploadProgress(null); } }; 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()!); } } 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 removeReaction = async (messageId: number, emoji: string) => { try { await messagesApi.removeReaction(messageId, emoji); } catch { // ignore reaction errors in quick UI } }; const toggleReaction = async (message: Message, key: ReactionKey) => { const grouped = groupedReactions(message); const group = grouped.get(key); if (group?.mine) { for (const raw of group.rawMine) { await removeReaction(message.id, raw); } return; } await addReaction(message.id, key); }; const revealSensitiveMessage = async (messageId: number) => { try { const data = await messagesApi.revealSensitiveMessage(messageId); setRevealedSensitiveMessages((prev) => ({ ...prev, [messageId]: data.plaintext, })); const existing = sensitiveRevealTimers.get(messageId); if (existing) { window.clearTimeout(existing); } const timer = window.setTimeout(() => { setRevealedSensitiveMessages((prev) => { const next = { ...prev }; delete next[messageId]; return next; }); sensitiveRevealTimers.delete(messageId); }, 15000); sensitiveRevealTimers.set(messageId, timer); } catch (error) { toast.error('Reveal failed', error instanceof Error ? error.message : 'Unable to reveal message'); } }; 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) => { openConversation(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') { if (!targetUserId()) { toast.warning('Select a member', 'Choose who to message directly.'); return; } payload.user_ids = [targetUserId()]; } else if (type === 'group') { if (groupUserIds().length === 0) { toast.warning('Select members', 'Choose at least one member for the group.'); return; } payload.user_ids = groupUserIds(); } else if (type === 'team') { if (!teamId()) { toast.warning('Select a team', 'Choose a team to create the channel.'); return; } payload.team_id = teamId(); } const data = await messagesApi.createConversation(payload); setShowCreateConversation(false); setNewConversationName(''); setNewConversationTopic(''); setTargetUserId(null); setGroupUserIds([]); setTeamId(null); await loadConversations(); openConversation(data.conversation.id); await loadMessages(data.conversation.id); } catch (error) { toast.error('Failed to create conversation', error instanceof Error ? error.message : 'Unknown error'); } }; onMount(async () => { await Promise.all([loadConversations(), loadMembers(), loadTeams(), loadAIProviders(), loadAISettings()]); 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); }); onMount(() => { const handleEscape = (event: KeyboardEvent) => { if (event.key !== 'Escape') return; if (searchOpen()) { setSearchOpen(false); } if (showCreateConversation()) { setShowCreateConversation(false); } if (showAiSharePicker()) { setShowAiSharePicker(false); } if (mentionOpen()) { setMentionOpen(false); } }; window.addEventListener('keydown', handleEscape); onCleanup(() => { window.removeEventListener('keydown', handleEscape); }); }); onCleanup(() => { markTypingStopped(); discardVoiceRecording = true; stopVoiceRecording(); cleanupCallResources(); if (mentionSearchTimer) { window.clearTimeout(mentionSearchTimer); mentionSearchTimer = null; } if (typingCleanupTimer) { window.clearInterval(typingCleanupTimer); typingCleanupTimer = null; } if (typingStopTimer) { window.clearTimeout(typingStopTimer); typingStopTimer = null; } if (spotlightAlertTimer) { window.clearTimeout(spotlightAlertTimer); spotlightAlertTimer = null; } for (const timer of Array.from(sensitiveRevealTimers.values())) { window.clearTimeout(timer); } sensitiveRevealTimers.clear(); 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 (

Messages

Direct messages, channels, calls, and AI references

{(alert) => (

{alert().title}

{alert().message}

)}

{activeConversation()?.conversation.name || 'Conversation'}

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

Pick a conversation

Jump into your self chat, browse workspace channels, or start a new direct message.

} >
Call status: {callStatus()} 0}> • with {activeCallPeerIds().length} participant{activeCallPeerIds().length > 1 ? 's' : ''}
Call transcript: {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 || 'Unknown user'} {new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} edited

{message.is_sensitive && shouldUseSensitiveReveal() && !revealedSensitiveMessages()[message.id] ? message.body || '[sensitive content hidden]' : revealedSensitiveMessages()[message.id] || message.body}

Sensitive content hidden by default. revealSensitiveMessage(message.id)}> Reveal }> Visible for 15 seconds
0}>
{(att) => ( {att.title || 'Attachment'} } >
{att.title || 'Voice note'}
)}
0}>
{(ref) => ( {ref.entity_type} )}
suggestion.status === 'pending' && suggestion.type !== 'move_to_password_vault' )} >
suggestion.status === 'pending' && suggestion.type !== 'move_to_password_vault' )} > {(suggestion) => (
{suggestion.type.replace(/_/g, ' ')}
)}
{(preset) => { const Icon = preset.icon; return ( ); }}
0}>
{(entry) => { const key = entry[0]; const data = entry[1]; const Icon = reactionIconForKey(key); return ( ); }}
)}
{ event.preventDefault(); setIsDragOverComposer(true); }} onDragLeave={(event) => { event.preventDefault(); setIsDragOverComposer(false); }} onDrop={handleComposerDrop} > 0}>
{activeTypingUserNames().length === 1 ? `${activeTypingUserNames()[0]} is typing` : `${activeTypingUserNames().slice(0, 2).join(', ')}${activeTypingUserNames().length > 2 ? ' and others' : ''} are typing`}
0 || attachedLibraryFiles().length > 0 || composerAiReferences().length > 0}>
{(file, index) => (
{file.name}
)}
{(file) => (
{file.original_name}
)}
{(reference) => (
{reference.title}
)}
Voice transcript: {voiceTranscriptPreview()}
Recording {formatMs(voiceRecordingMs())}
{ hiddenFileInputRef = el; }} onChange={onFileSelect} />