diff options
Diffstat (limited to 'client/app.js')
-rw-r--r-- | client/app.js | 791 |
1 files changed, 791 insertions, 0 deletions
diff --git a/client/app.js b/client/app.js new file mode 100644 index 0000000..0303fc6 --- /dev/null +++ b/client/app.js @@ -0,0 +1,791 @@ +/** + * 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 = ` + <div class="loading">No conversations yet</div> + `; + 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 ` + <div class="conversation-item ${isActive ? 'active' : ''}" + onclick="app.selectConversation('${conv.id}')"> + <div class="conversation-title">${this.escapeHtml(title)}</div> + <div class="conversation-preview">${this.escapeHtml(preview)}</div> + <div class="conversation-meta"> + <span>${conv.messageCount} messages</span> + <span>${time}</span> + </div> + </div> + `; + }).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 = ` + <div class="empty-state"> + <h3>No messages yet</h3> + <p>Start the conversation by sending a message below</p> + </div> + `; + 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 ` + <div class="message ${isOwn ? 'own' : ''}"> + <div class="message-avatar">${avatar}</div> + <div class="message-content"> + ${!isOwn ? `<div class="message-sender">${this.escapeHtml(senderName)}</div>` : ''} + <div class="message-text">${this.formatMessageBody(msg.body, msg.bodyType)}</div> + <div class="message-time">${time}</div> + </div> + </div> + `; + }).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, '<br>'); + } + } + + 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; } +}; |