/**
* 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; }
};