/** * JCHAT Web Application * Main application logic for the JMAP-based chat client */ class JChatApp { constructor() { this.jmapClient = new JMAPClient(JChatConfig.API_BASE_URL); this.conversations = new Map(); this.messages = new Map(); this.currentConversationId = null; this.currentUser = null; // Will be set after authentication this.authToken = this.getStoredToken(); this.states = { conversation: '0', message: '0' }; // UI Elements this.elements = {}; this.bindElements(); this.bindEvents(); // Initialize the application this.init(); } bindElements() { this.elements = { connectionStatus: document.getElementById('connectionStatus'), conversationList: document.getElementById('conversationList'), chatHeader: document.getElementById('chatHeader'), messagesContainer: document.getElementById('messagesContainer'), messageInputArea: document.getElementById('messageInputArea'), messageInput: document.getElementById('messageInput'), sendButton: document.getElementById('sendButton') }; } bindEvents() { // Send message on button click this.elements.sendButton.addEventListener('click', () => this.sendMessage()); // Send message on Enter (but not Shift+Enter) this.elements.messageInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); } }); // Auto-resize textarea this.elements.messageInput.addEventListener('input', () => { this.autoResizeTextarea(this.elements.messageInput); }); // Enable/disable send button based on input this.elements.messageInput.addEventListener('input', () => { const hasText = this.elements.messageInput.value.trim().length > 0; this.elements.sendButton.disabled = !hasText; }); } async init() { try { this.updateConnectionStatus('Connecting...', 'connecting'); // Check if user is authenticated if (!this.authToken || !await this.verifyAuthentication()) { // Show login/registration prompt this.showAuthenticationPrompt(); return; } // Initialize JMAP session await this.jmapClient.init(this.authToken); console.log('JMAP session initialized:', this.jmapClient.session); // Load initial data await this.loadConversations(); this.updateConnectionStatus('Connected', 'connected'); // Start polling for updates (in production, use EventSource/WebSockets) this.startPolling(); } catch (error) { console.error('Failed to initialize application:', error); if (error.message.includes('unauthorized') || error.message.includes('401')) { // Authentication error - show login this.clearStoredToken(); this.showAuthenticationPrompt(); } else { this.updateConnectionStatus('Connection failed', 'disconnected'); this.showError('Failed to connect to chat server. Please refresh the page to try again.'); } } } // Authentication methods getStoredToken() { return localStorage.getItem('jchat_auth_token'); } storeToken(token) { localStorage.setItem('jchat_auth_token', token); this.authToken = token; } clearStoredToken() { localStorage.removeItem('jchat_auth_token'); this.authToken = null; } async verifyAuthentication() { if (!this.authToken) return false; try { const response = await fetch(`${JChatConfig.API_BASE_URL}/auth/me`, { method: 'GET', headers: { 'Authorization': `Bearer ${this.authToken}`, 'Content-Type': 'application/json' } }); if (response.ok) { const data = await response.json(); this.currentUser = data.user; return true; } else { return false; } } catch (error) { console.error('Auth verification failed:', error); return false; } } showAuthenticationPrompt() { // Decide whether to show login or register modal // For simplicity, always show register modal first showModal('registerModal'); } async performLogin() { const email = document.getElementById('loginEmail').value.trim(); const password = document.getElementById('loginPassword').value; if (!email || !password) { this.showLoginError('Please fill in all fields'); return; } try { const response = await fetch(`${JChatConfig.API_BASE_URL}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: password }) }); const data = await response.json(); if (response.ok) { // Store token and user info this.storeToken(data.token); this.currentUser = data.user; // Close modal and initialize app closeModal('loginModal'); this.init(); // Restart initialization } else { this.showLoginError(data.detail || 'Login failed'); } } catch (error) { console.error('Login error:', error); this.showLoginError('Login failed. Please try again.'); } } async performRegistration() { const email = document.getElementById('registerEmail').value.trim(); const displayName = document.getElementById('registerDisplayName').value.trim(); const password = document.getElementById('registerPassword').value; const confirmPassword = document.getElementById('registerConfirmPassword').value; if (!email || !displayName || !password || !confirmPassword) { this.showRegisterError('Please fill in all fields'); return; } if (password !== confirmPassword) { this.showRegisterError('Passwords do not match'); return; } if (password.length < 8) { this.showRegisterError('Password must be at least 8 characters long'); return; } try { const response = await fetch(`${JChatConfig.API_BASE_URL}/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, displayName: displayName, password: password }) }); const data = await response.json(); if (response.ok) { // Store token and user info this.storeToken(data.token); this.currentUser = data.user; // Close modal and initialize app closeModal('registerModal'); this.init(); // Restart initialization } else { this.showRegisterError(data.detail || 'Registration failed'); } } catch (error) { console.error('Registration error:', error); this.showRegisterError('Registration failed. Please try again.'); } } showLoginModal() { closeModal('registerModal'); showModal('loginModal'); // Clear any previous errors document.getElementById('loginError').style.display = 'none'; } showRegisterModal() { closeModal('loginModal'); showModal('registerModal'); // Clear any previous errors document.getElementById('registerError').style.display = 'none'; } showLoginError(message) { const errorDiv = document.getElementById('loginError'); errorDiv.textContent = message; errorDiv.style.display = 'block'; } showRegisterError(message) { const errorDiv = document.getElementById('registerError'); errorDiv.textContent = message; errorDiv.style.display = 'block'; } async logout() { try { await fetch(`${JChatConfig.API_BASE_URL}/auth/logout`, { method: 'POST', headers: { 'Authorization': `Bearer ${this.authToken}`, 'Content-Type': 'application/json' } }); } catch (error) { console.error('Logout error:', error); } finally { // Clear local state regardless of server response this.clearStoredToken(); this.currentUser = null; this.conversations.clear(); this.messages.clear(); this.currentConversationId = null; // Show authentication prompt this.showAuthenticationPrompt(); } } updateConnectionStatus(text, status) { this.elements.connectionStatus.textContent = text; this.elements.connectionStatus.className = `connection-status status-${status}`; } async loadConversations() { try { // Query all conversations const queryResult = await this.jmapClient.queryConversations({}, [{ property: 'lastMessageAt', isAscending: false }]); if (queryResult.ids.length > 0) { // Get conversation details const conversationsResult = await this.jmapClient.getConversations(queryResult.ids); // Update local state conversationsResult.list.forEach(conv => { this.conversations.set(conv.id, conv); }); this.states.conversation = conversationsResult.state; } this.renderConversationList(); } catch (error) { console.error('Failed to load conversations:', error); this.showError('Failed to load conversations.'); } } async createConversation(title, description = '') { try { this.showStatus('Creating conversation...', 'info'); const result = await this.jmapClient.createConversation({ title: title, description: description || null, participantIds: [this.currentUser] // Add current user as participant }); this.showStatus('Conversation created successfully!', 'success'); // Reload conversations to include the new one await this.loadConversations(); // Select the new conversation if possible if (result && result.id) { this.selectConversation(result.id); } } catch (error) { console.error('Failed to create conversation:', error); this.showError('Failed to create conversation: ' + error.message); throw error; } } async loadMessages(conversationId) { try { // Query messages for the conversation const queryResult = await this.jmapClient.queryMessages({ inConversation: conversationId }, [{ property: 'sentAt', isAscending: true }]); if (queryResult.ids.length > 0) { // Get message details const messagesResult = await this.jmapClient.getMessages(queryResult.ids); // Update local state messagesResult.list.forEach(msg => { this.messages.set(msg.id, msg); }); this.states.message = messagesResult.state; } this.renderMessages(conversationId); } catch (error) { console.error('Failed to load messages:', error); this.showError('Failed to load messages.'); } } renderConversationList() { const conversations = Array.from(this.conversations.values()); if (conversations.length === 0) { this.elements.conversationList.innerHTML = `
No conversations yet
`; return; } // Sort by last message time conversations.sort((a, b) => { const aTime = a.lastMessageAt || a.createdAt; const bTime = b.lastMessageAt || b.createdAt; return new Date(bTime) - new Date(aTime); }); const html = conversations.map(conv => { const isActive = conv.id === this.currentConversationId; const title = conv.title || 'Untitled Conversation'; const preview = 'No messages yet'; // In production, would show last message const time = this.formatTime(conv.lastMessageAt || conv.createdAt); return `
${this.escapeHtml(title)}
${this.escapeHtml(preview)}
${conv.messageCount} messages ${time}
`; }).join(''); this.elements.conversationList.innerHTML = html; } async selectConversation(conversationId) { try { this.currentConversationId = conversationId; const conversation = this.conversations.get(conversationId); if (!conversation) { console.error('Conversation not found:', conversationId); return; } // Update UI this.elements.chatHeader.textContent = conversation.title || 'Untitled Conversation'; this.elements.messageInputArea.style.display = 'flex'; // Update conversation list highlighting this.renderConversationList(); // Load messages for this conversation await this.loadMessages(conversationId); } catch (error) { console.error('Failed to select conversation:', error); this.showError('Failed to load conversation.'); } } renderMessages(conversationId) { const conversationMessages = Array.from(this.messages.values()) .filter(msg => msg.conversationId === conversationId) .sort((a, b) => new Date(a.sentAt) - new Date(b.sentAt)); if (conversationMessages.length === 0) { this.elements.messagesContainer.innerHTML = `

No messages yet

Start the conversation by sending a message below

`; return; } const html = conversationMessages.map(msg => { const isOwn = msg.senderId === this.currentUser; const senderName = msg.senderId || 'Unknown User'; // Use the senderId as the display name const avatar = senderName.charAt(0).toUpperCase(); const time = this.formatTime(msg.sentAt); return `
${avatar}
${!isOwn ? `
${this.escapeHtml(senderName)}
` : ''}
${this.formatMessageBody(msg.body, msg.bodyType)}
${time}
`; }).join(''); this.elements.messagesContainer.innerHTML = html; // Scroll to bottom this.scrollToBottom(); } async sendMessage() { const messageText = this.elements.messageInput.value.trim(); if (!messageText || !this.currentConversationId) { return; } try { // Disable input while sending this.elements.messageInput.disabled = true; this.elements.sendButton.disabled = true; // Send the message const message = await this.jmapClient.sendMessage( this.currentConversationId, messageText, 'text/plain', this.currentUser // Pass the current user as sender ); // Add message to local state this.messages.set(message.id, message); // Clear input this.elements.messageInput.value = ''; this.autoResizeTextarea(this.elements.messageInput); // Re-render messages this.renderMessages(this.currentConversationId); // Update conversation in list (message count, last message time, etc.) // In production, this would be handled by change notifications await this.loadConversations(); } catch (error) { console.error('Failed to send message:', error); this.showError('Failed to send message. Please try again.'); } finally { // Re-enable input this.elements.messageInput.disabled = false; this.elements.sendButton.disabled = false; this.elements.messageInput.focus(); } } async createConversation(title, participantIds = []) { try { const conversation = await this.jmapClient.createConversation({ title: title, participantIds: [this.currentUser, ...participantIds] }); // Add to local state this.conversations.set(conversation.id, conversation); // Re-render conversation list this.renderConversationList(); // Select the new conversation await this.selectConversation(conversation.id); return conversation; } catch (error) { console.error('Failed to create conversation:', error); this.showError('Failed to create conversation.'); throw error; } } startPolling() { // Poll for changes every 5 seconds // In production, this should be replaced with EventSource/WebSockets setInterval(async () => { try { await this.checkForUpdates(); } catch (error) { console.error('Failed to poll for updates:', error); } }, 5000); } async checkForUpdates() { try { // Check for conversation changes const convChanges = await this.jmapClient.getConversationChanges(this.states.conversation); if (convChanges.hasMoreChanges || convChanges.created.length > 0 || convChanges.updated.length > 0 || convChanges.destroyed.length > 0 || convChanges.newState !== this.states.conversation) { // Update our stored state this.states.conversation = convChanges.newState; await this.loadConversations(); } // Check for message changes const msgChanges = await this.jmapClient.getMessageChanges(this.states.message); if (msgChanges.hasMoreChanges || msgChanges.created.length > 0 || msgChanges.updated.length > 0 || msgChanges.destroyed.length > 0 || msgChanges.newState !== this.states.message) { // Update our stored state this.states.message = msgChanges.newState; // Reload messages for current conversation if (this.currentConversationId) { await this.loadMessages(this.currentConversationId); } } } catch (error) { // Don't spam console with polling errors if (error.message.includes('changes')) { console.debug('Polling error:', error); } else { console.error('Update check failed:', error); } } } // Utility methods formatTime(isoString) { if (!isoString) return ''; const date = new Date(isoString); const now = new Date(); if (date.toDateString() === now.toDateString()) { return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } else { return date.toLocaleDateString(); } } formatMessageBody(body, bodyType) { if (bodyType === 'text/html') { // In production, would sanitize HTML return body; } else { // Convert plain text to HTML, preserving line breaks return this.escapeHtml(body).replace(/\n/g, '
'); } } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } autoResizeTextarea(textarea) { textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; } scrollToBottom() { this.elements.messagesContainer.scrollTop = this.elements.messagesContainer.scrollHeight; } showError(message) { // Simple error display - in production would use a proper notification system const errorDiv = document.createElement('div'); errorDiv.className = 'error'; errorDiv.textContent = message; document.body.appendChild(errorDiv); setTimeout(() => { document.body.removeChild(errorDiv); }, 5000); } } // Initialize the application when the DOM is loaded let app; document.addEventListener('DOMContentLoaded', () => { // Load user settings const savedUsername = localStorage.getItem('jchat_username'); if (savedUsername) { document.getElementById('currentUser').textContent = savedUsername; } app = new JChatApp(); window.jchatApp = app; // Make available globally for modal functions if (savedUsername) { app.currentUser = savedUsername; } }); // Global functions for UI interactions function showUserSettings() { const modal = document.getElementById('userSettingsModal'); const input = document.getElementById('userDisplayName'); input.value = localStorage.getItem('jchat_username') || ''; modal.style.display = 'flex'; } function showNewConversationDialog() { const modal = document.getElementById('newConversationModal'); document.getElementById('newConversationTitle').value = ''; document.getElementById('newConversationDescription').value = ''; modal.style.display = 'flex'; } function showModal(modalId) { document.getElementById(modalId).style.display = 'flex'; } function closeModal(modalId) { document.getElementById(modalId).style.display = 'none'; } function saveUserSettings() { const username = document.getElementById('userDisplayName').value.trim(); if (username) { localStorage.setItem('jchat_username', username); document.getElementById('currentUser').textContent = username; closeModal('userSettingsModal'); // Update the app's current user if (window.jchatApp) { window.jchatApp.currentUser = username; } } } async function createNewConversation() { const title = document.getElementById('newConversationTitle').value.trim(); if (title) { const description = document.getElementById('newConversationDescription').value.trim(); try { await window.jchatApp.createConversation(title, description); closeModal('newConversationModal'); } catch (error) { console.error('Error creating conversation:', error); alert('Failed to create conversation. Please try again.'); } } } // Authentication global functions function showLoginModal() { if (window.jchatApp) { window.jchatApp.showLoginModal(); } } function showRegisterModal() { if (window.jchatApp) { window.jchatApp.showRegisterModal(); } } function performLogin() { if (window.jchatApp) { window.jchatApp.performLogin(); } } function performRegistration() { if (window.jchatApp) { window.jchatApp.performRegistration(); } } async function createNewConversation() { const title = document.getElementById('newConversationTitle').value.trim(); const description = document.getElementById('newConversationDescription').value.trim(); if (!title) { alert('Please enter a conversation title'); return; } if (window.jchatApp) { try { await window.jchatApp.createConversation(title, description); closeModal('newConversationModal'); } catch (error) { alert('Failed to create conversation: ' + error.message); } } } // Close modals when clicking outside document.addEventListener('click', (e) => { if (e.target.classList.contains('modal')) { e.target.style.display = 'none'; } }); // Close modals with Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { const modals = document.querySelectorAll('.modal'); modals.forEach(modal => { if (modal.style.display === 'flex') { modal.style.display = 'none'; } }); } }); // For debugging in console window.jchat = { get app() { return app; }, get jmapClient() { return app?.jmapClient; } };