aboutsummaryrefslogtreecommitdiff
path: root/client
diff options
context:
space:
mode:
authorCalvin Morrison <calvin@pobox.com>2025-09-03 21:15:36 -0400
committerCalvin Morrison <calvin@pobox.com>2025-09-03 21:15:36 -0400
commit49fa5aa2a127bdf8924d02bf77e5086b39c7a447 (patch)
tree61d86a7705dacc9fddccc29fa79d075d83ab8059 /client
i vibe coded itHEADmaster
Diffstat (limited to 'client')
-rw-r--r--client/README.md57
-rw-r--r--client/app.js791
-rw-r--r--client/config.js54
-rw-r--r--client/index.html563
-rw-r--r--client/jchat-client.js352
-rw-r--r--client/jmap-client.js302
-rw-r--r--client/package.json12
-rw-r--r--client/server.js59
8 files changed, 2190 insertions, 0 deletions
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 = `
+ <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; }
+};
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 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>JCHAT - JMAP Chat Client</title>
+ <style>
+ * {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ }
+
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background-color: #f5f5f5;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .header {
+ background-color: #2c3e50;
+ color: white;
+ padding: 1rem 2rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+ }
+
+ .header h1 {
+ font-size: 1.5rem;
+ font-weight: 600;
+ }
+
+ .connection-status {
+ padding: 0.5rem 1rem;
+ border-radius: 20px;
+ font-size: 0.9rem;
+ }
+
+ .status-connected {
+ background-color: #27ae60;
+ }
+
+ .status-disconnected {
+ background-color: #e74c3c;
+ }
+
+ .status-connecting {
+ background-color: #f39c12;
+ }
+
+ .main-container {
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+ }
+
+ .sidebar {
+ width: 300px;
+ background-color: white;
+ border-right: 1px solid #ddd;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .sidebar-header {
+ padding: 1rem;
+ background-color: #34495e;
+ color: white;
+ font-weight: 600;
+ }
+
+ .user-info {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+ font-size: 0.9rem;
+ }
+
+ .user-settings-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0.25rem;
+ border-radius: 4px;
+ font-size: 1rem;
+ color: white;
+ }
+
+ .user-settings-btn:hover {
+ background: rgba(255,255,255,0.2);
+ }
+
+ .new-conversation-btn {
+ width: 100%;
+ padding: 0.75rem;
+ background: #3498db;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ font-weight: 500;
+ transition: background-color 0.2s;
+ }
+
+ .new-conversation-btn:hover {
+ background: #2980b9;
+ }
+
+ /* Modal Styles */
+ .modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0,0,0,0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ }
+
+ .modal-content {
+ background: white;
+ padding: 2rem;
+ border-radius: 12px;
+ min-width: 400px;
+ max-width: 500px;
+ }
+
+ .modal-content h3 {
+ margin-top: 0;
+ margin-bottom: 1.5rem;
+ color: #333;
+ }
+
+ .modal-content label {
+ display: block;
+ margin-bottom: 1rem;
+ font-weight: 500;
+ color: #555;
+ }
+
+ .modal-content input,
+ .modal-content textarea {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ margin-top: 0.25rem;
+ font-size: 0.9rem;
+ }
+
+ .modal-content textarea {
+ resize: vertical;
+ min-height: 80px;
+ }
+
+ .modal-buttons {
+ display: flex;
+ gap: 1rem;
+ justify-content: flex-end;
+ margin-top: 1.5rem;
+ }
+
+ .modal-buttons button {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ font-weight: 500;
+ }
+
+ .modal-buttons button:first-child {
+ background: #4f46e5;
+ color: white;
+ }
+
+ .modal-buttons button:first-child:hover {
+ background: #3730a3;
+ }
+
+ .modal-buttons button:last-child {
+ background: #f3f4f6;
+ color: #374151;
+ }
+
+ .modal-buttons button:last-child:hover {
+ background: #e5e7eb;
+ }
+
+ .conversation-list {
+ flex: 1;
+ overflow-y: auto;
+ }
+
+ .conversation-item {
+ padding: 1rem;
+ border-bottom: 1px solid #eee;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ }
+
+ .conversation-item:hover {
+ background-color: #f8f9fa;
+ }
+
+ .conversation-item.active {
+ background-color: #3498db;
+ color: white;
+ }
+
+ .conversation-title {
+ font-weight: 600;
+ margin-bottom: 0.25rem;
+ }
+
+ .conversation-preview {
+ font-size: 0.9rem;
+ color: #666;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .conversation-item.active .conversation-preview {
+ color: #ecf0f1;
+ }
+
+ .conversation-meta {
+ font-size: 0.8rem;
+ color: #999;
+ margin-top: 0.25rem;
+ display: flex;
+ justify-content: space-between;
+ }
+
+ .conversation-item.active .conversation-meta {
+ color: #bdc3c7;
+ }
+
+ .chat-area {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ background-color: white;
+ }
+
+ .chat-header {
+ padding: 1rem 2rem;
+ background-color: white;
+ border-bottom: 1px solid #ddd;
+ font-weight: 600;
+ }
+
+ .messages-container {
+ flex: 1;
+ overflow-y: auto;
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ .message {
+ display: flex;
+ gap: 0.75rem;
+ max-width: 70%;
+ }
+
+ .message.own {
+ align-self: flex-end;
+ flex-direction: row-reverse;
+ }
+
+ .message-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background-color: #3498db;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-weight: 600;
+ font-size: 0.9rem;
+ flex-shrink: 0;
+ }
+
+ .message-content {
+ background-color: #f8f9fa;
+ padding: 0.75rem 1rem;
+ border-radius: 18px;
+ position: relative;
+ }
+
+ .message.own .message-content {
+ background-color: #3498db;
+ color: white;
+ }
+
+ .message-sender {
+ font-weight: 600;
+ font-size: 0.9rem;
+ margin-bottom: 0.25rem;
+ }
+
+ .message.own .message-sender {
+ display: none;
+ }
+
+ .message-text {
+ line-height: 1.4;
+ }
+
+ .message-time {
+ font-size: 0.8rem;
+ color: #999;
+ margin-top: 0.25rem;
+ }
+
+ .message.own .message-time {
+ color: rgba(255,255,255,0.8);
+ }
+
+ .message-input-area {
+ padding: 1rem 2rem;
+ background-color: white;
+ border-top: 1px solid #ddd;
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ }
+
+ .message-input {
+ flex: 1;
+ padding: 0.75rem 1rem;
+ border: 1px solid #ddd;
+ border-radius: 24px;
+ font-size: 1rem;
+ outline: none;
+ resize: none;
+ max-height: 120px;
+ min-height: 44px;
+ }
+
+ .message-input:focus {
+ border-color: #3498db;
+ }
+
+ .send-button {
+ background-color: #3498db;
+ color: white;
+ border: none;
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.2s;
+ }
+
+ .send-button:hover:not(:disabled) {
+ background-color: #2980b9;
+ }
+
+ .send-button:disabled {
+ background-color: #bdc3c7;
+ cursor: not-allowed;
+ }
+
+ .empty-state {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ color: #666;
+ }
+
+ .empty-state h3 {
+ margin-bottom: 0.5rem;
+ }
+
+ .loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem;
+ color: #666;
+ }
+
+ .error {
+ background-color: #e74c3c;
+ color: white;
+ padding: 1rem;
+ margin: 1rem;
+ border-radius: 6px;
+ }
+
+ /* Mobile responsive */
+ @media (max-width: 768px) {
+ .main-container {
+ flex-direction: column;
+ }
+
+ .sidebar {
+ width: 100%;
+ height: 200px;
+ }
+
+ .message {
+ max-width: 85%;
+ }
+ }
+ </style>
+</head>
+<body>
+ <div class="header">
+ <h1>JCHAT Client</h1>
+ <div class="connection-status status-disconnected" id="connectionStatus">
+ Disconnected
+ </div>
+ </div>
+
+ <div class="main-container">
+ <div class="sidebar">
+ <div class="sidebar-header">
+ <div class="user-info">
+ <span id="currentUser">Anonymous User</span>
+ <button class="user-settings-btn" onclick="showUserSettings()" title="User Settings">⚙️</button>
+ </div>
+ <button class="new-conversation-btn" onclick="showNewConversationDialog()">+ New Conversation</button>
+ </div>
+ <div class="conversation-list" id="conversationList">
+ <div class="loading">Loading conversations...</div>
+ </div>
+ </div>
+
+ <div class="chat-area">
+ <div class="chat-header" id="chatHeader">
+ Select a conversation
+ </div>
+ <div class="messages-container" id="messagesContainer">
+ <div class="empty-state">
+ <h3>Welcome to JCHAT</h3>
+ <p>Select a conversation to start chatting</p>
+ </div>
+ </div>
+ <div class="message-input-area" id="messageInputArea" style="display: none;">
+ <textarea
+ class="message-input"
+ id="messageInput"
+ placeholder="Type a message..."
+ rows="1"></textarea>
+ <button class="send-button" id="sendButton" disabled>
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
+ <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
+ </svg>
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Authentication Modals -->
+ <!-- Login Modal -->
+ <div class="modal" id="loginModal" style="display: none;">
+ <div class="modal-content">
+ <h3>Log In</h3>
+ <div id="loginError" style="color: #e74c3c; margin-bottom: 1rem; display: none;"></div>
+ <label>
+ Email:
+ <input type="email" id="loginEmail" placeholder="Enter your email" required>
+ </label>
+ <label>
+ Password:
+ <input type="password" id="loginPassword" placeholder="Enter your password" required>
+ </label>
+ <div class="modal-buttons">
+ <button onclick="performLogin()">Log In</button>
+ <button onclick="showRegisterModal()">Register Instead</button>
+ <button onclick="closeModal('loginModal')">Cancel</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Register Modal -->
+ <div class="modal" id="registerModal" style="display: none;">
+ <div class="modal-content">
+ <h3>Create Account</h3>
+ <div id="registerError" style="color: #e74c3c; margin-bottom: 1rem; display: none;"></div>
+ <label>
+ Email:
+ <input type="email" id="registerEmail" placeholder="Enter your email" required>
+ </label>
+ <label>
+ Display Name:
+ <input type="text" id="registerDisplayName" placeholder="Enter your display name" required>
+ </label>
+ <label>
+ Password:
+ <input type="password" id="registerPassword" placeholder="Create a password (min 8 characters)" required>
+ </label>
+ <label>
+ Confirm Password:
+ <input type="password" id="registerConfirmPassword" placeholder="Confirm your password" required>
+ </label>
+ <div class="modal-buttons">
+ <button onclick="performRegistration()">Create Account</button>
+ <button onclick="showLoginModal()">Log In Instead</button>
+ <button onclick="closeModal('registerModal')">Cancel</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- User Settings Modal -->
+ <div class="modal" id="userSettingsModal" style="display: none;">
+ <div class="modal-content">
+ <h3>User Settings</h3>
+ <label>
+ Display Name:
+ <input type="text" id="userDisplayName" placeholder="Enter your name">
+ </label>
+ <div class="modal-buttons">
+ <button onclick="saveUserSettings()">Save</button>
+ <button onclick="closeModal('userSettingsModal')">Cancel</button>
+ </div>
+ </div>
+ </div>
+
+ <!-- New Conversation Modal -->
+ <div class="modal" id="newConversationModal" style="display: none;">
+ <div class="modal-content">
+ <h3>New Conversation</h3>
+ <label>
+ Conversation Title:
+ <input type="text" id="newConversationTitle" placeholder="Enter conversation title">
+ </label>
+ <label>
+ Description (optional):
+ <textarea id="newConversationDescription" placeholder="Conversation description"></textarea>
+ </label>
+ <div class="modal-buttons">
+ <button onclick="createNewConversation()">Create</button>
+ <button onclick="closeModal('newConversationModal')">Cancel</button>
+ </div>
+ </div>
+ </div>
+
+ <script src="config.js"></script>
+ <script src="jmap-client.js"></script>
+ <script src="app.js"></script>
+</body>
+</html>
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 = `
+ <div class="conversation-title">${conv.title || 'Untitled Conversation'}</div>
+ <div class="conversation-preview">
+ ${conv.lastMessageAt ? `Last: ${new Date(conv.lastMessageAt).toLocaleTimeString()}` : 'No messages'}
+ </div>
+ `;
+
+ container.appendChild(div);
+ });
+ }
+
+ renderMessages() {
+ const container = document.getElementById('messages');
+ const messages = this.messages.get(this.currentConversationId) || [];
+
+ if (messages.length === 0) {
+ container.innerHTML = `
+ <div class="empty-state">
+ <h3>No messages yet</h3>
+ <p>Start the conversation by sending a message</p>
+ </div>
+ `;
+ 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 = `
+ <div class="message-header">${sentTime}</div>
+ <div class="message-body">${this.escapeHtml(msg.body)}</div>
+ `;
+
+ 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}/`);
+});