From 49fa5aa2a127bdf8924d02bf77e5086b39c7a447 Mon Sep 17 00:00:00 2001 From: Calvin Morrison Date: Wed, 3 Sep 2025 21:15:36 -0400 Subject: i vibe coded it --- client/README.md | 57 ++++ client/app.js | 791 +++++++++++++++++++++++++++++++++++++++++++++++++ client/config.js | 54 ++++ client/index.html | 563 +++++++++++++++++++++++++++++++++++ client/jchat-client.js | 352 ++++++++++++++++++++++ client/jmap-client.js | 302 +++++++++++++++++++ client/package.json | 12 + client/server.js | 59 ++++ 8 files changed, 2190 insertions(+) create mode 100644 client/README.md create mode 100644 client/app.js create mode 100644 client/config.js create mode 100644 client/index.html create mode 100644 client/jchat-client.js create mode 100644 client/jmap-client.js create mode 100644 client/package.json create mode 100644 client/server.js (limited to 'client') diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..ec92fc2 --- /dev/null +++ b/client/README.md @@ -0,0 +1,57 @@ +# JCHAT Web Client + +A pure HTML/JavaScript client for the JCHAT protocol. No build process required - just serve the static files. + +## Quick Start + +### Option 1: Using shttpd (recommended) +```bash +shttpd . --port 3000 +``` + +### Option 2: Using Python +```bash +python3 -m http.server 3000 +``` + +### Option 3: Using any web server +Point your web server (nginx, Apache, etc.) to serve files from this directory. + +## Files + +- `index.html` - Main client interface +- `jmap-client.js` - JMAP protocol client library +- `app.js` - Chat application logic +- `package.json` - Project metadata (no dependencies) + +## Usage + +1. Make sure the JCHAT server is running on `localhost:8080` +2. Serve these files on any port (e.g., 3000) +3. Open `http://localhost:3000` in your browser +4. The client will automatically connect to the JCHAT server + +## Configuration + +The client connects to `http://localhost:8080` by default. To change this, edit the `serverUrl` in `app.js`: + +```javascript +class JChatApp { + constructor() { + this.jmapClient = new JMAPClient('http://your-server:8080'); + // ... + } +} +``` + +## Browser Support + +Works in any modern browser that supports: +- ES6 Classes +- Fetch API +- Arrow functions +- Template literals + +## No Build Required + +This is intentionally a simple, dependency-free client. No webpack, no npm install, no build process. Just HTML, CSS, and vanilla JavaScript. 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 = ` +
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; } +}; diff --git a/client/config.js b/client/config.js new file mode 100644 index 0000000..d86da9e --- /dev/null +++ b/client/config.js @@ -0,0 +1,54 @@ +// JChat Client Configuration +window.JChatConfig = { + // Server configuration + API_BASE_URL: 'http://api.jchat.localhost', + WEB_BASE_URL: 'http://web.jchat.localhost', + + // Feature flags + FEATURES: { + REGISTRATION_ENABLED: true, + GUEST_ACCESS: false, + FILE_UPLOADS: true, + REAL_TIME_UPDATES: true + }, + + // UI configuration + UI: { + THEME: 'light', + AUTO_FOCUS_MESSAGE_INPUT: true, + SHOW_TYPING_INDICATORS: true, + MESSAGE_PAGE_SIZE: 50, + CONVERSATION_PAGE_SIZE: 20 + }, + + // Polling configuration + POLLING: { + INTERVAL_MS: 2000, + MAX_RETRIES: 3, + BACKOFF_MULTIPLIER: 2 + }, + + // Authentication + AUTH: { + TOKEN_STORAGE_KEY: 'jchat_auth_token', + USER_STORAGE_KEY: 'jchat_user_data', + AUTO_LOGOUT_ON_TOKEN_EXPIRE: true + }, + + // Development/Debug + DEBUG: { + LOG_LEVEL: 'info', // 'debug', 'info', 'warn', 'error' + SHOW_NETWORK_REQUESTS: false, + MOCK_SLOW_NETWORK: false + } +}; + +// Environment-specific overrides +if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + // Development overrides + window.JChatConfig.DEBUG.LOG_LEVEL = 'debug'; + window.JChatConfig.DEBUG.SHOW_NETWORK_REQUESTS = true; +} + +// Make config immutable +Object.freeze(window.JChatConfig); diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..941394f --- /dev/null +++ b/client/index.html @@ -0,0 +1,563 @@ + + + + + + JCHAT - JMAP Chat Client + + + +
+

JCHAT Client

+
+ Disconnected +
+
+ +
+ + +
+
+ Select a conversation +
+
+
+

Welcome to JCHAT

+

Select a conversation to start chatting

+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + diff --git a/client/jchat-client.js b/client/jchat-client.js new file mode 100644 index 0000000..4ab5428 --- /dev/null +++ b/client/jchat-client.js @@ -0,0 +1,352 @@ +/** + * JCHAT Web Client - JMAP-based Chat Application + */ + +class JChatClient { + constructor() { + this.serverUrl = JChatConfig.API_BASE_URL; + this.session = null; + this.conversations = new Map(); + this.messages = new Map(); + this.currentConversationId = null; + this.userId = 'user1'; // Demo user + + this.init(); + } + + async init() { + try { + await this.loadSession(); + await this.loadConversations(); + this.updateConnectionStatus('Connected', 'success'); + + // Set up polling for new messages (in production, use EventSource/WebSockets) + this.startPolling(); + } catch (error) { + console.error('Failed to initialize client:', error); + this.updateConnectionStatus('Connection failed', 'error'); + } + } + + async loadSession() { + const response = await fetch(`${this.serverUrl}/jmap/session`); + if (!response.ok) { + throw new Error('Failed to load session'); + } + this.session = await response.json(); + console.log('Session loaded:', this.session); + } + + async makeJMAPRequest(methodCalls) { + const request = { + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:chat'], + methodCalls: methodCalls + }; + + const response = await fetch(`${this.serverUrl}/jmap/api`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request) + }); + + if (!response.ok) { + throw new Error(`JMAP request failed: ${response.status}`); + } + + return await response.json(); + } + + async loadConversations() { + try { + // For demo, create a sample conversation if none exist + const queryResponse = await this.makeJMAPRequest([ + ['Conversation/query', { accountId: 'default' }, 'q1'] + ]); + + const queryResult = queryResponse.methodResponses[0][1]; + + if (queryResult.ids.length === 0) { + // Create a demo conversation + await this.createDemoConversation(); + return this.loadConversations(); + } + + // Load conversation details + const getResponse = await this.makeJMAPRequest([ + ['Conversation/get', { + accountId: 'default', + ids: queryResult.ids + }, 'g1'] + ]); + + const conversations = getResponse.methodResponses[0][1].list; + + this.conversations.clear(); + conversations.forEach(conv => { + this.conversations.set(conv.id, conv); + }); + + this.renderConversations(); + } catch (error) { + console.error('Failed to load conversations:', error); + this.showStatus('Failed to load conversations', 'error'); + } + } + + async createDemoConversation() { + try { + const response = await this.makeJMAPRequest([ + ['Conversation/set', { + accountId: 'default', + create: { + 'demo1': { + title: 'Demo Conversation', + participantIds: [this.userId, 'user2'] + } + } + }, 'c1'] + ]); + + console.log('Demo conversation created:', response); + } catch (error) { + console.error('Failed to create demo conversation:', error); + } + } + + async loadMessages(conversationId) { + try { + // Query messages for the conversation + const queryResponse = await this.makeJMAPRequest([ + ['Message/query', { + accountId: 'default', + filter: { inConversation: conversationId }, + sort: [{ property: 'sentAt', isAscending: true }] + }, 'mq1'] + ]); + + const messageIds = queryResponse.methodResponses[0][1].ids; + + if (messageIds.length === 0) { + this.messages.set(conversationId, []); + this.renderMessages(); + return; + } + + // Get message details + const getResponse = await this.makeJMAPRequest([ + ['Message/get', { + accountId: 'default', + ids: messageIds + }, 'mg1'] + ]); + + const messages = getResponse.methodResponses[0][1].list; + this.messages.set(conversationId, messages); + this.renderMessages(); + } catch (error) { + console.error('Failed to load messages:', error); + this.showStatus('Failed to load messages', 'error'); + } + } + + renderConversations() { + const container = document.getElementById('conversations'); + container.innerHTML = ''; + + this.conversations.forEach(conv => { + const div = document.createElement('div'); + div.className = 'conversation'; + div.onclick = () => this.selectConversation(conv.id); + + if (conv.id === this.currentConversationId) { + div.classList.add('active'); + } + + div.innerHTML = ` +
${conv.title || 'Untitled Conversation'}
+
+ ${conv.lastMessageAt ? `Last: ${new Date(conv.lastMessageAt).toLocaleTimeString()}` : 'No messages'} +
+ `; + + container.appendChild(div); + }); + } + + renderMessages() { + const container = document.getElementById('messages'); + const messages = this.messages.get(this.currentConversationId) || []; + + if (messages.length === 0) { + container.innerHTML = ` +
+

No messages yet

+

Start the conversation by sending a message

+
+ `; + return; + } + + container.innerHTML = ''; + + messages.forEach(msg => { + const div = document.createElement('div'); + div.className = 'message'; + + if (msg.senderId === this.userId) { + div.classList.add('sent'); + } + + const sentTime = new Date(msg.sentAt).toLocaleTimeString(); + + div.innerHTML = ` +
${sentTime}
+
${this.escapeHtml(msg.body)}
+ `; + + container.appendChild(div); + }); + + // Scroll to bottom + container.scrollTop = container.scrollHeight; + } + + async selectConversation(conversationId) { + this.currentConversationId = conversationId; + const conversation = this.conversations.get(conversationId); + + // Update UI + document.getElementById('conversationTitle').textContent = conversation.title || 'Untitled Conversation'; + document.getElementById('compose').style.display = 'flex'; + + // Re-render conversations to show selection + this.renderConversations(); + + // Load and render messages + await this.loadMessages(conversationId); + } + + async sendMessage() { + const input = document.getElementById('messageInput'); + const message = input.value.trim(); + + if (!message || !this.currentConversationId) { + return; + } + + try { + const response = await this.makeJMAPRequest([ + ['Message/set', { + accountId: 'default', + create: { + 'temp1': { + conversationId: this.currentConversationId, + body: message, + senderId: this.userId + } + } + }, 'm1'] + ]); + + console.log('Message sent:', response); + + // Clear input + input.value = ''; + + // Reload messages + await this.loadMessages(this.currentConversationId); + await this.loadConversations(); // Update conversation preview + + } catch (error) { + console.error('Failed to send message:', error); + this.showStatus('Failed to send message', 'error'); + } + } + + async createNewConversation() { + const title = prompt('Enter conversation title:'); + if (!title) return; + + try { + const response = await this.makeJMAPRequest([ + ['Conversation/set', { + accountId: 'default', + create: { + 'new1': { + title: title, + participantIds: [this.userId] + } + } + }, 'nc1'] + ]); + + console.log('New conversation created:', response); + await this.loadConversations(); + + } catch (error) { + console.error('Failed to create conversation:', error); + this.showStatus('Failed to create conversation', 'error'); + } + } + + startPolling() { + // Simple polling for demo - in production use EventSource or WebSockets + setInterval(async () => { + if (this.currentConversationId) { + await this.loadMessages(this.currentConversationId); + } + }, 5000); // Poll every 5 seconds + } + + updateConnectionStatus(text, type) { + const statusElement = document.getElementById('connectionStatus'); + statusElement.textContent = text; + statusElement.className = type; + } + + showStatus(message, type) { + // Remove existing status + const existing = document.querySelector('.status'); + if (existing) { + existing.remove(); + } + + // Create new status + const status = document.createElement('div'); + status.className = `status ${type}`; + status.textContent = message; + document.body.appendChild(status); + + // Auto-remove after 3 seconds + setTimeout(() => { + if (status.parentNode) { + status.remove(); + } + }, 3000); + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +} + +// Global functions for HTML event handlers +window.jchatClient = new JChatClient(); + +window.sendMessage = () => { + window.jchatClient.sendMessage(); +}; + +window.createNewConversation = () => { + window.jchatClient.createNewConversation(); +}; + +window.handleKeyPress = (event) => { + if (event.key === 'Enter') { + window.jchatClient.sendMessage(); + } +}; diff --git a/client/jmap-client.js b/client/jmap-client.js new file mode 100644 index 0000000..a7d5a37 --- /dev/null +++ b/client/jmap-client.js @@ -0,0 +1,302 @@ +/** + * JMAP Client Library for JCHAT + * Provides a clean interface for interacting with JMAP-based chat servers + */ + +class JMAPClient { + constructor(serverUrl) { + this.serverUrl = serverUrl; + this.session = null; + this.accountId = 'default'; + this.capabilities = []; + this.authToken = null; + } + + /** + * Initialize the JMAP session with optional auth token + */ + async init(authToken = null) { + this.authToken = authToken; + + const headers = { + 'Accept': 'application/json' + }; + + if (this.authToken) { + headers['Authorization'] = `Bearer ${this.authToken}`; + } + + const response = await fetch(`${this.serverUrl}/jmap/session`, { + method: 'GET', + headers: headers + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Authentication required'); + } + throw new Error(`Failed to load session: ${response.status} ${response.statusText}`); + } + + this.session = await response.json(); + this.accountId = Object.keys(this.session.accounts)[0] || 'default'; + this.capabilities = Object.keys(this.session.capabilities); + + return this.session; + } + + /** + * Make a JMAP API request + */ + async request(methodCalls, using = null) { + if (!this.session) { + throw new Error('Session not initialized. Call init() first.'); + } + + const defaultUsing = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:chat']; + const requestBody = { + using: using || defaultUsing, + methodCalls: methodCalls + }; + + const headers = { + 'Content-Type': 'application/json; charset=utf-8' + }; + + if (this.authToken) { + headers['Authorization'] = `Bearer ${this.authToken}`; + } + + const response = await fetch(`${this.serverUrl}/jmap/api`, { + method: 'POST', + headers: headers, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`JMAP request failed: ${response.status} ${response.statusText}\n${errorText}`); + } + + const result = await response.json(); + + if (result.methodResponses) { + return result; + } else { + throw new Error('Invalid JMAP response format'); + } + } + + /** + * Get conversations + */ + async getConversations(ids = null, properties = null) { + const methodCall = ['Conversation/get', { + accountId: this.accountId, + ids: ids, + properties: properties + }, 'c1']; + + const response = await this.request([methodCall]); + const [method, result, callId] = response.methodResponses[0]; + + if (method === 'error') { + throw new Error(`Conversation/get error: ${result.type} - ${result.description || ''}`); + } + + return result; + } + + /** + * Create a new conversation + */ + async createConversation(data) { + const methodCall = ['Conversation/set', { + accountId: this.accountId, + create: { + 'new-conv': data + } + }, 'c1']; + + const response = await this.request([methodCall]); + const [method, result, callId] = response.methodResponses[0]; + + if (method === 'error') { + throw new Error(`Conversation/set error: ${result.type} - ${result.description || ''}`); + } + + if (result.notCreated && result.notCreated['new-conv']) { + throw new Error(`Failed to create conversation: ${result.notCreated['new-conv'].type}`); + } + + return result.created['new-conv']; + } + + /** + * Update a conversation + */ + async updateConversation(id, updates) { + const methodCall = ['Conversation/set', { + accountId: this.accountId, + update: { + [id]: updates + } + }, 'c1']; + + const response = await this.request([methodCall]); + const [method, result, callId] = response.methodResponses[0]; + + if (method === 'error') { + throw new Error(`Conversation/set error: ${result.type} - ${result.description || ''}`); + } + + if (result.notUpdated && result.notUpdated[id]) { + throw new Error(`Failed to update conversation: ${result.notUpdated[id].type}`); + } + + return result.updated.find(conv => conv.id === id); + } + + /** + * Query conversations + */ + async queryConversations(filter = {}, sort = null) { + const methodCall = ['Conversation/query', { + accountId: this.accountId, + filter: filter, + sort: sort + }, 'c1']; + + const response = await this.request([methodCall]); + const [method, result, callId] = response.methodResponses[0]; + + if (method === 'error') { + throw new Error(`Conversation/query error: ${result.type} - ${result.description || ''}`); + } + + return result; + } + + /** + * Get messages + */ + async getMessages(ids = null, properties = null) { + const methodCall = ['Message/get', { + accountId: this.accountId, + ids: ids, + properties: properties + }, 'c1']; + + const response = await this.request([methodCall]); + const [method, result, callId] = response.methodResponses[0]; + + if (method === 'error') { + throw new Error(`Message/get error: ${result.type} - ${result.description || ''}`); + } + + return result; + } + + /** + * Send a message + */ + async sendMessage(conversationId, body, bodyType = 'text/plain', senderId = null) { + const methodCall = ['Message/set', { + accountId: this.accountId, + create: { + 'new-message': { + conversationId: conversationId, + body: body, + bodyType: bodyType, + senderId: senderId || this.accountId // Use provided senderId or default to accountId + } + } + }, 'c1']; + + const response = await this.request([methodCall]); + const [method, result, callId] = response.methodResponses[0]; + + if (method === 'error') { + throw new Error(`Message/set error: ${result.type} - ${result.description || ''}`); + } + + if (result.notCreated && result.notCreated['new-message']) { + throw new Error(`Failed to send message: ${result.notCreated['new-message'].type}`); + } + + return result.created['new-message']; + } + + /** + * Query messages + */ + async queryMessages(filter = {}, sort = null) { + const methodCall = ['Message/query', { + accountId: this.accountId, + filter: filter, + sort: sort + }, 'c1']; + + const response = await this.request([methodCall]); + const [method, result, callId] = response.methodResponses[0]; + + if (method === 'error') { + throw new Error(`Message/query error: ${result.type} - ${result.description || ''}`); + } + + return result; + } + + /** + * Get conversation changes + */ + async getConversationChanges(sinceState) { + const methodCall = ['Conversation/changes', { + accountId: this.accountId, + sinceState: sinceState + }, 'c1']; + + const response = await this.request([methodCall]); + const [method, result, callId] = response.methodResponses[0]; + + if (method === 'error') { + throw new Error(`Conversation/changes error: ${result.type} - ${result.description || ''}`); + } + + return result; + } + + /** + * Get message changes + */ + async getMessageChanges(sinceState) { + const methodCall = ['Message/changes', { + accountId: this.accountId, + sinceState: sinceState + }, 'c1']; + + const response = await this.request([methodCall]); + const [method, result, callId] = response.methodResponses[0]; + + if (method === 'error') { + throw new Error(`Message/changes error: ${result.type} - ${result.description || ''}`); + } + + return result; + } + + /** + * Echo test method + */ + async echo(data) { + const methodCall = ['Core/echo', data, 'c1']; + const response = await this.request([methodCall]); + const [method, result, callId] = response.methodResponses[0]; + + if (method === 'error') { + throw new Error(`Core/echo error: ${result.type} - ${result.description || ''}`); + } + + return result; + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..2a3f3cf --- /dev/null +++ b/client/package.json @@ -0,0 +1,12 @@ +{ + "name": "jchat-client", + "version": "1.0.0", + "description": "JCHAT Web Client - Static HTML/JS files for JMAP chat client", + "scripts": { + "serve": "echo 'Use: shttpd . --port 3000' && shttpd . --port 3000", + "serve-alt": "python3 -m http.server 3000" + }, + "keywords": ["jmap", "chat", "messaging", "static"], + "author": "JCHAT Team", + "license": "MIT" +} diff --git a/client/server.js b/client/server.js new file mode 100644 index 0000000..56a8995 --- /dev/null +++ b/client/server.js @@ -0,0 +1,59 @@ +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const url = require('url'); + +const port = process.env.PORT || 3000; + +const mimeTypes = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon' +}; + +const server = http.createServer((req, res) => { + console.log(`${req.method} ${req.url}`); + + let filePath = '.' + url.parse(req.url).pathname; + + // Default to index.html + if (filePath === './') { + filePath = './index.html'; + } + + const extname = String(path.extname(filePath)).toLowerCase(); + const mimeType = mimeTypes[extname] || 'application/octet-stream'; + + fs.readFile(filePath, (error, content) => { + if (error) { + if (error.code === 'ENOENT') { + // File not found + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('404 Not Found\n'); + } else { + // Server error + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('500 Internal Server Error\n'); + } + } else { + // Success + res.writeHead(200, { + 'Content-Type': mimeType, + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + }); + res.end(content, 'utf-8'); + } + }); +}); + +server.listen(port, () => { + console.log(`JCHAT Client Server running at http://localhost:${port}/`); +}); -- cgit v1.2.3