aboutsummaryrefslogtreecommitdiff
path: root/client/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'client/app.js')
-rw-r--r--client/app.js791
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; }
+};