From fe79f55c179f7faaeaf7a6c5a880f174c597cc81 Mon Sep 17 00:00:00 2001 From: KishoreB25 Date: Wed, 15 Jul 2026 22:49:14 +0530 Subject: [PATCH 1/5] chore: add .dockerignore files to backend and frontend directories --- backend/.dockerignore | 4 ++++ frontend/.dockerignore | 5 +++++ 2 files changed, 9 insertions(+) create mode 100644 backend/.dockerignore create mode 100644 frontend/.dockerignore diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..cab67ef --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +npm-debug.log +.env +.git diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..8eeb8c0 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +npm-debug.log +dist +.env +.git From 823c6cca0d93dbb6bc0576e9ba44574af8aab987 Mon Sep 17 00:00:00 2001 From: KishoreB25 Date: Wed, 15 Jul 2026 22:53:17 +0530 Subject: [PATCH 2/5] feat: implement real-time messaging and user status tracking using Socket.io --- backend/server.js | 11 +-- backend/sockets/chatSocket.js | 100 +++++++++++++++++++++-- frontend/src/components/ChatScreen.tsx | 105 ++++++++++++++----------- frontend/src/hooks/useSocket.ts | 89 +++++++++++++++++++++ frontend/src/services/socket.ts | 16 ++++ 5 files changed, 260 insertions(+), 61 deletions(-) create mode 100644 frontend/src/hooks/useSocket.ts create mode 100644 frontend/src/services/socket.ts diff --git a/backend/server.js b/backend/server.js index bc0fe9f..bdc60f5 100644 --- a/backend/server.js +++ b/backend/server.js @@ -30,14 +30,9 @@ const messageRoutes = require('./routes/messageRoutes'); app.use('/api/users', userRoutes); app.use('/api/messages', messageRoutes); -// TODO: Import and use socket logic from sockets/chatSocket.js -io.on('connection', (socket) => { - console.log(`A user connected: ${socket.id}`); - - socket.on('disconnect', () => { - console.log(`User disconnected: ${socket.id}`); - }); -}); +// Initialize all Socket.io event listeners from the chatSocket module +const chatSocket = require('./sockets/chatSocket'); +chatSocket(io); // Use 0.0.0.0 to listen on all local network interfaces (required for mesh setup) const PORT = process.env.PORT || 5000; diff --git a/backend/sockets/chatSocket.js b/backend/sockets/chatSocket.js index 6edecef..5782740 100644 --- a/backend/sockets/chatSocket.js +++ b/backend/sockets/chatSocket.js @@ -1,7 +1,97 @@ // chatSocket.js - Socket.io event listeners for real-time messaging -// module.exports = (io, socket) => { -// socket.on('sendMessage', (message) => { -// // save to db, then emit to everyone -// }); -// }; +/** + * In-memory map tracking currently connected sockets. + * Key: socket.id (assigned by socket.io on each connection) + * Value: { deviceId: string, username: string } + */ +const onlineUsers = new Map(); + +/** + * Builds a serializable array of active users from the in-memory map, + * suitable for broadcasting to all clients. + */ +const getActiveUserList = () => { + return Array.from(onlineUsers.values()).map(({ deviceId, username }) => ({ + _id: deviceId, + username, + status: 'online', + joinedAt: new Date().toISOString(), + })); +}; + +/** + * Initializes all socket.io event listeners. + * @param {import('socket.io').Server} io - The socket.io Server instance + */ +module.exports = (io) => { + io.on('connection', (socket) => { + console.log(`[Socket] Client connected: ${socket.id}`); + + // ------------------------------------------------------------------ + // register_device + // Sent by the frontend after login. Links this socket to the user + // and broadcasts the updated active user list to ALL clients. + // Payload: { deviceId: string, username: string } + // ------------------------------------------------------------------ + socket.on('register_device', ({ deviceId, username }) => { + if (!deviceId || !username) { + console.warn(`[Socket] register_device missing fields from ${socket.id}`); + return; + } + + // Store the mapping + onlineUsers.set(socket.id, { deviceId, username }); + console.log(`[Socket] Device registered: ${username} (${deviceId}) via socket ${socket.id}`); + + // Push updated user list to every connected client + io.emit('active_users_update', getActiveUserList()); + }); + + // ------------------------------------------------------------------ + // send_message + // Receives a message payload from the sender and broadcasts it to + // all OTHER connected clients via receive_message. + // Payload: { _id, senderId, senderName, text, timestamp } + // ------------------------------------------------------------------ + socket.on('send_message', (message) => { + const { senderId, senderName, text, timestamp } = message; + + // Basic validation + if (!senderId || !senderName || !text) { + console.warn(`[Socket] send_message received invalid payload from ${socket.id}:`, message); + return; + } + + const outgoing = { + _id: message._id || `${timestamp}-${senderId}`, + senderId, + senderName, + text, + timestamp: timestamp || new Date().toISOString(), + }; + + console.log(`[Socket] Relaying message from ${senderName}: "${text.slice(0, 40)}"`); + + // Broadcast to everyone EXCEPT the sender (sender already added + // the message to their own state via optimistic update) + socket.broadcast.emit('receive_message', outgoing); + }); + + // ------------------------------------------------------------------ + // disconnect + // Fired when a client closes the tab / loses connection. + // Removes from the map and notifies all remaining clients. + // ------------------------------------------------------------------ + socket.on('disconnect', (reason) => { + const user = onlineUsers.get(socket.id); + if (user) { + console.log(`[Socket] Device disconnected: ${user.username} — reason: ${reason}`); + onlineUsers.delete(socket.id); + io.emit('active_users_update', getActiveUserList()); + } else { + console.log(`[Socket] Unknown socket disconnected: ${socket.id}`); + } + }); + }); +}; diff --git a/frontend/src/components/ChatScreen.tsx b/frontend/src/components/ChatScreen.tsx index 163a4f1..eb51adf 100644 --- a/frontend/src/components/ChatScreen.tsx +++ b/frontend/src/components/ChatScreen.tsx @@ -1,10 +1,12 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { getUsername, getOrCreateDeviceId } from '../services/user'; import { Sidebar } from './Sidebar'; import { ChatArea } from './ChatArea'; import { DetailsPanel } from './DetailsPanel'; import { getLocalMessages, saveMessageLocally, initMessageSync, initUserSync } from '../services/db'; +import { useSocket } from '../hooks/useSocket'; +import { socket } from '../services/socket'; interface Message { _id?: string; @@ -24,10 +26,9 @@ interface ActiveUser { export const ChatScreen: React.FC = () => { const [messages, setMessages] = useState([]); - const [isOnline, setIsOnline] = useState(true); // Track database replication status + const [dbSyncing, setDbSyncing] = useState(true); // Track database replication status const [, setSyncing] = useState(false); const [onlineUsers, setOnlineUsers] = useState([]); - const [usersLoading, setUsersLoading] = useState(false); const navigate = useNavigate(); const currentUsername = getUsername() || 'UNKNOWN'; @@ -40,30 +41,37 @@ export const ChatScreen: React.FC = () => { } }, [navigate]); - // Fetch active users list from backend Express API - const fetchActiveUsers = async () => { - setUsersLoading(true); - const backendHost = window.location.hostname === 'localhost' - ? 'http://localhost:5000' - : `http://${window.location.hostname}:5000`; + // Called by useSocket when the server pushes an updated active user list + const handleUsersUpdated = useCallback((users: ActiveUser[]) => { + // Exclude self from the sidebar list; inject a simulated latency for UI display + const others = users + .filter((u) => u._id !== currentDeviceId) + .map((u) => ({ + ...u, + latency: u.latency || `${Math.floor(Math.random() * 15) + 5}ms`, + })); + setOnlineUsers(others); + }, [currentDeviceId]); + + // Called by useSocket when a new message arrives from another peer + const handleIncomingMessage = useCallback((msg: Message) => { + setMessages((prev) => { + // Guard against duplicates (e.g. if PouchDB sync also fires) + if (prev.some((m) => m._id === msg._id)) return prev; + return [...prev, msg]; + }); + }, []); - try { - const response = await fetch(`${backendHost}/api/users`); - if (response.ok) { - const data = await response.json(); - // Remove self from the list to display others, inject simulated latencies for realism - const mapped = data.map((u: ActiveUser) => ({ - ...u, - latency: u.latency || `${Math.floor(Math.random() * 15) + 5}ms` - })); - setOnlineUsers(mapped.filter((u: ActiveUser) => u._id !== currentDeviceId && u.status === 'online')); - } - } catch (error) { - console.error('Failed to fetch active users:', error); - } finally { - setUsersLoading(false); - } - }; + // Open the socket connection and register this device for the duration of the session + const { isConnected } = useSocket( + currentDeviceId, + currentUsername, + handleIncomingMessage, + handleUsersUpdated + ); + + // Derive overall "online" status from WebSocket connectivity + const isOnline = isConnected || dbSyncing; // Fetch initial local messages const loadMessages = async () => { @@ -88,17 +96,13 @@ export const ChatScreen: React.FC = () => { } }; - // Set up user polling and database sync + // Load initial message history and keep PouchDB syncing for persistence useEffect(() => { if (!getUsername()) return; loadMessages(); - fetchActiveUsers(); - - // Poll active user discovery every 5 seconds - const userPollInterval = setInterval(fetchActiveUsers, 5000); - // Initialize bidirectional replication for messages + // Initialize bidirectional replication for messages (persistence layer) const messageSync = initMessageSync(() => { loadMessages(); }); @@ -106,46 +110,51 @@ export const ChatScreen: React.FC = () => { // Initialize bidirectional replication for users const userSync = initUserSync(() => {}); - // Listen to message replication changes to update network status + // Track CouchDB replication health for the db-sync part of isOnline messageSync .on('active', () => { - setIsOnline(true); + setDbSyncing(true); setSyncing(true); }) .on('paused', (err: any) => { - if (err) { - setIsOnline(false); - } else { - setIsOnline(true); - } + setDbSyncing(!err); setSyncing(false); }) .on('error', () => { - setIsOnline(false); + setDbSyncing(false); setSyncing(false); }); return () => { - clearInterval(userPollInterval); messageSync.cancel(); userSync.cancel(); }; }, []); const handleSendMessage = async (text: string) => { + const timestamp = new Date().toISOString(); + const _id = `${timestamp}-${currentDeviceId}`; + const messagePayload = { + _id, senderId: currentDeviceId, senderName: currentUsername, - text + text, + timestamp, }; + // 1. Optimistic update — add to local state instantly so the sender + // sees their message without waiting for any round-trip + setMessages((prev) => [...prev, messagePayload]); + + // 2. Broadcast to all other connected peers via Socket.io + socket.emit('send_message', messagePayload); + + // 3. Persist to PouchDB/CouchDB in the background (history layer) try { - // Save message to client database (will auto-sync to backend in background) await saveMessageLocally(messagePayload); - // Immediately reload UI to show sent message - await loadMessages(); } catch (error) { - console.error('Failed to dispatch message locally:', error); + console.error('Failed to persist message locally:', error); } }; @@ -156,8 +165,8 @@ export const ChatScreen: React.FC = () => { {/* 1. Sidebar - Left Panel (20% width equivalent) */} {}} /> {/* 2. Conversation - Center Panel (60% width equivalent) */} diff --git a/frontend/src/hooks/useSocket.ts b/frontend/src/hooks/useSocket.ts new file mode 100644 index 0000000..ec592ed --- /dev/null +++ b/frontend/src/hooks/useSocket.ts @@ -0,0 +1,89 @@ +// useSocket.ts - React hook that manages the socket lifecycle for ChatScreen +import { useEffect, useState } from 'react'; +import { socket } from '../services/socket'; + +interface Message { + _id?: string; + senderId: string; + senderName: string; + text: string; + timestamp: string; +} + +interface ActiveUser { + _id: string; + username: string; + joinedAt: string; + status: string; + latency?: string; +} + +/** + * Manages the Socket.io connection for the duration of the chat session. + * + * @param deviceId - The current user's persistent device ID + * @param username - The current user's display name + * @param onMessageReceived - Called when a new message arrives from another peer + * @param onUsersUpdated - Called when the server pushes an updated user list + * @returns { isConnected } - Whether the socket is currently connected + */ +export const useSocket = ( + deviceId: string, + username: string, + onMessageReceived: (msg: Message) => void, + onUsersUpdated: (users: ActiveUser[]) => void +): { isConnected: boolean } => { + const [isConnected, setIsConnected] = useState(socket.connected); + + useEffect(() => { + // --- Connection lifecycle handlers --- + const handleConnect = () => { + console.log('[Socket] Connected:', socket.id); + setIsConnected(true); + // Register this device with the server immediately after connecting + socket.emit('register_device', { deviceId, username }); + }; + + const handleDisconnect = (reason: string) => { + console.log('[Socket] Disconnected:', reason); + setIsConnected(false); + }; + + const handleConnectError = (err: Error) => { + console.error('[Socket] Connection error:', err.message); + setIsConnected(false); + }; + + // --- Domain event handlers --- + const handleReceiveMessage = (msg: Message) => { + onMessageReceived(msg); + }; + + const handleActiveUsersUpdate = (users: ActiveUser[]) => { + onUsersUpdated(users); + }; + + // Register all listeners + socket.on('connect', handleConnect); + socket.on('disconnect', handleDisconnect); + socket.on('connect_error', handleConnectError); + socket.on('receive_message', handleReceiveMessage); + socket.on('active_users_update', handleActiveUsersUpdate); + + // Open the connection (autoConnect is false on the singleton) + socket.connect(); + + // Cleanup: remove listeners and close connection when ChatScreen unmounts + return () => { + socket.off('connect', handleConnect); + socket.off('disconnect', handleDisconnect); + socket.off('connect_error', handleConnectError); + socket.off('receive_message', handleReceiveMessage); + socket.off('active_users_update', handleActiveUsersUpdate); + socket.disconnect(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [deviceId, username]); + + return { isConnected }; +}; diff --git a/frontend/src/services/socket.ts b/frontend/src/services/socket.ts new file mode 100644 index 0000000..dfe54c1 --- /dev/null +++ b/frontend/src/services/socket.ts @@ -0,0 +1,16 @@ +// socket.ts - Singleton socket.io-client instance +// autoConnect: false so the connection only opens after the user has +// logged in and ChatScreen mounts (controlled by useSocket hook). + +import { io } from 'socket.io-client'; + +const BACKEND_URL = + window.location.hostname === 'localhost' + ? 'http://localhost:5000' + : `http://${window.location.hostname}:5000`; + +export const socket = io(BACKEND_URL, { + autoConnect: false, + // Prefer WebSocket transport; fall back to polling only if needed + transports: ['websocket', 'polling'], +}); From 1c35e1859b50f915f1fc832890b53bfc2fc0bc9c Mon Sep 17 00:00:00 2001 From: KishoreB25 Date: Wed, 15 Jul 2026 23:54:35 +0530 Subject: [PATCH 3/5] feat: implement real-time chat interface with socket.io messaging, offline-first PouchDB persistence, and active user tracking --- frontend/src/components/ChatScreen.tsx | 32 ++++++++---- frontend/src/hooks/useSocket.ts | 43 ++++++++-------- frontend/src/services/db.ts | 71 +++++++++++++++++--------- 3 files changed, 92 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/ChatScreen.tsx b/frontend/src/components/ChatScreen.tsx index eb51adf..38f8000 100644 --- a/frontend/src/components/ChatScreen.tsx +++ b/frontend/src/components/ChatScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { getUsername, getOrCreateDeviceId } from '../services/user'; import { Sidebar } from './Sidebar'; @@ -26,10 +26,13 @@ interface ActiveUser { export const ChatScreen: React.FC = () => { const [messages, setMessages] = useState([]); - const [dbSyncing, setDbSyncing] = useState(true); // Track database replication status + const [dbSyncing, setDbSyncing] = useState(true); const [, setSyncing] = useState(false); const [onlineUsers, setOnlineUsers] = useState([]); const navigate = useNavigate(); + // Accumulates messages received via socket that haven't been persisted to PouchDB yet. + // This prevents loadMessages() from wiping them when PouchDB sync fires. + const socketMessagesRef = useRef([]); const currentUsername = getUsername() || 'UNKNOWN'; const currentDeviceId = getOrCreateDeviceId(); @@ -55,10 +58,16 @@ export const ChatScreen: React.FC = () => { // Called by useSocket when a new message arrives from another peer const handleIncomingMessage = useCallback((msg: Message) => { + // Keep a ref copy so loadMessages() can merge these back in + socketMessagesRef.current = [ + ...socketMessagesRef.current.filter(m => m._id !== msg._id), + msg + ]; setMessages((prev) => { - // Guard against duplicates (e.g. if PouchDB sync also fires) if (prev.some((m) => m._id === msg._id)) return prev; - return [...prev, msg]; + return [...prev, msg].sort( + (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); }); }, []); @@ -73,11 +82,10 @@ export const ChatScreen: React.FC = () => { // Derive overall "online" status from WebSocket connectivity const isOnline = isConnected || dbSyncing; - // Fetch initial local messages + // Fetch messages from PouchDB and merge with any live socket messages not yet persisted const loadMessages = async () => { try { const localDocs = await getLocalMessages(); - // Format PouchDB docs into Message array const formatted = localDocs.map((doc: any) => ({ _id: doc._id, senderId: doc.senderId, @@ -85,12 +93,15 @@ export const ChatScreen: React.FC = () => { text: doc.text, timestamp: doc.timestamp })); - - // Sort messages chronologically by timestamp - const sorted = formatted.sort( + + // Merge socket-received messages that aren't in PouchDB yet + const pouchIds = new Set(formatted.map((m: Message) => m._id)); + const socketOnly = socketMessagesRef.current.filter(m => !pouchIds.has(m._id)); + + const merged = [...formatted, ...socketOnly].sort( (a: Message, b: Message) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() ); - setMessages(sorted); + setMessages(merged); } catch (error) { console.error('Error loading messages from PouchDB:', error); } @@ -148,6 +159,7 @@ export const ChatScreen: React.FC = () => { setMessages((prev) => [...prev, messagePayload]); // 2. Broadcast to all other connected peers via Socket.io + console.log('[Socket] Emitting send_message — connected:', socket.connected); socket.emit('send_message', messagePayload); // 3. Persist to PouchDB/CouchDB in the background (history layer) diff --git a/frontend/src/hooks/useSocket.ts b/frontend/src/hooks/useSocket.ts index ec592ed..921f8e2 100644 --- a/frontend/src/hooks/useSocket.ts +++ b/frontend/src/hooks/useSocket.ts @@ -1,5 +1,5 @@ // useSocket.ts - React hook that manages the socket lifecycle for ChatScreen -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { socket } from '../services/socket'; interface Message { @@ -18,15 +18,6 @@ interface ActiveUser { latency?: string; } -/** - * Manages the Socket.io connection for the duration of the chat session. - * - * @param deviceId - The current user's persistent device ID - * @param username - The current user's display name - * @param onMessageReceived - Called when a new message arrives from another peer - * @param onUsersUpdated - Called when the server pushes an updated user list - * @returns { isConnected } - Whether the socket is currently connected - */ export const useSocket = ( deviceId: string, username: string, @@ -35,12 +26,16 @@ export const useSocket = ( ): { isConnected: boolean } => { const [isConnected, setIsConnected] = useState(socket.connected); + // Keep latest callbacks in refs so the socket listeners never go stale + const onMessageRef = useRef(onMessageReceived); + const onUsersRef = useRef(onUsersUpdated); + useEffect(() => { onMessageRef.current = onMessageReceived; }, [onMessageReceived]); + useEffect(() => { onUsersRef.current = onUsersUpdated; }, [onUsersUpdated]); + useEffect(() => { - // --- Connection lifecycle handlers --- const handleConnect = () => { console.log('[Socket] Connected:', socket.id); setIsConnected(true); - // Register this device with the server immediately after connecting socket.emit('register_device', { deviceId, username }); }; @@ -51,16 +46,16 @@ export const useSocket = ( const handleConnectError = (err: Error) => { console.error('[Socket] Connection error:', err.message); - setIsConnected(false); }; - // --- Domain event handlers --- const handleReceiveMessage = (msg: Message) => { - onMessageReceived(msg); + console.log('[Socket] receive_message from', msg.senderName, ':', msg.text); + onMessageRef.current(msg); }; const handleActiveUsersUpdate = (users: ActiveUser[]) => { - onUsersUpdated(users); + console.log('[Socket] active_users_update:', users.map(u => u.username)); + onUsersRef.current(users); }; // Register all listeners @@ -70,19 +65,25 @@ export const useSocket = ( socket.on('receive_message', handleReceiveMessage); socket.on('active_users_update', handleActiveUsersUpdate); - // Open the connection (autoConnect is false on the singleton) - socket.connect(); + // Connect if not already connected + if (socket.connected) { + // Already connected (e.g. React strict-mode double-mount) — re-register + setIsConnected(true); + socket.emit('register_device', { deviceId, username }); + } else { + socket.connect(); + } - // Cleanup: remove listeners and close connection when ChatScreen unmounts + // On cleanup: only remove listeners. Do NOT disconnect — + // the singleton should stay alive for the session. return () => { socket.off('connect', handleConnect); socket.off('disconnect', handleDisconnect); socket.off('connect_error', handleConnectError); socket.off('receive_message', handleReceiveMessage); socket.off('active_users_update', handleActiveUsersUpdate); - socket.disconnect(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [deviceId, username]); return { isConnected }; diff --git a/frontend/src/services/db.ts b/frontend/src/services/db.ts index 2bcaa26..802de85 100644 --- a/frontend/src/services/db.ts +++ b/frontend/src/services/db.ts @@ -1,60 +1,85 @@ import PouchDB from 'pouchdb-browser'; -// Initialize local PouchDB databases +// Initialize local PouchDB databases (always available, offline-first) const localMessagesDb = new PouchDB('local_messages'); const localUsersDb = new PouchDB('local_users'); -// Initialize remote CouchDB connections -// Use environment variables for credentials and port +// ------------------------------------------------------------------ +// Remote CouchDB connection (optional — only used for persistence sync) +// If env vars are missing or CouchDB is unreachable, the app continues +// to work offline via socket.io for real-time and local PouchDB for history. +// ------------------------------------------------------------------ const user = import.meta.env.VITE_COUCHDB_USER; const pass = import.meta.env.VITE_COUCHDB_PASSWORD; const port = import.meta.env.VITE_COUCHDB_PORT; -// Dynamic IP resolution for mesh network functionality -const REMOTE_DB_URL = window.location.hostname === 'localhost' - ? `http://${user}:${pass}@localhost:${port}` - : `http://${user}:${pass}@${window.location.hostname}:${port}`; +const couchDBAvailable = user && pass && port && + user !== 'undefined' && pass !== 'undefined' && port !== 'undefined'; -const remoteMessagesDb = new PouchDB(`${REMOTE_DB_URL}/messages`); -const remoteUsersDb = new PouchDB(`${REMOTE_DB_URL}/users`); +let remoteMessagesDb: PouchDB.Database | null = null; +let remoteUsersDb: PouchDB.Database | null = null; -// Setup two-way sync for messages +if (couchDBAvailable) { + const host = window.location.hostname === 'localhost' + ? 'localhost' + : window.location.hostname; + const REMOTE_DB_URL = `http://${user}:${pass}@${host}:${port}`; + remoteMessagesDb = new PouchDB(`${REMOTE_DB_URL}/messages`); + remoteUsersDb = new PouchDB(`${REMOTE_DB_URL}/users`); + console.log('[DB] Remote CouchDB configured at', `${host}:${port}`); +} else { + console.warn('[DB] CouchDB env vars missing — running in local-only mode. Messages persist locally only.'); +} + +// Minimal no-op sync handler so callers can always call .cancel() +const noOpSync = () => { + const handler = { cancel: () => {}, on: () => handler } as any; + return handler; +}; + +// Setup two-way sync for messages (skipped silently if CouchDB is unavailable) export const initMessageSync = (onChangeCallback: () => void) => { + if (!remoteMessagesDb) return noOpSync(); + return localMessagesDb.sync(remoteMessagesDb, { live: true, - retry: true + retry: false // Don't retry — avoids flooding console with repeated ERR_CONNECTION_REFUSED }).on('change', (info: any) => { - console.log('Message sync change:', info); if (onChangeCallback) onChangeCallback(); }).on('error', (err: any) => { - console.error('Message sync error:', err); + // Log only once, not a stream of retried errors + console.warn('[DB] Message sync error (CouchDB may be unavailable):', err?.message || err); }); }; -// Setup two-way sync for users +// Setup two-way sync for users (skipped silently if CouchDB is unavailable) export const initUserSync = (onChangeCallback: () => void) => { + if (!remoteUsersDb) return noOpSync(); + return localUsersDb.sync(remoteUsersDb, { live: true, - retry: true + retry: false }).on('change', (info: any) => { - console.log('User sync change:', info); if (onChangeCallback) onChangeCallback(); }).on('error', (err: any) => { - console.error('User sync error:', err); + console.warn('[DB] User sync error (CouchDB may be unavailable):', err?.message || err); }); }; -// Helper function to save a message locally (will auto-sync to remote) +// Helper function to save a message locally export const saveMessageLocally = async (message: any) => { try { const response = await localMessagesDb.put({ - _id: new Date().toISOString() + '-' + message.senderId, + // Use the pre-built _id if provided, otherwise construct one + _id: message._id || (new Date().toISOString() + '-' + message.senderId), ...message, - timestamp: new Date().toISOString() + timestamp: message.timestamp || new Date().toISOString() }); return response; - } catch (error) { - console.error('Error saving message locally:', error); + } catch (error: any) { + // 409 = document conflict (already saved) — safe to ignore + if (error?.status === 409) return; + console.error('[DB] Error saving message locally:', error); throw error; } }; @@ -68,7 +93,7 @@ export const getLocalMessages = async () => { }); return result.rows.map((row: any) => row.doc); } catch (error) { - console.error('Error fetching local messages:', error); + console.error('[DB] Error fetching local messages:', error); return []; } }; From eced4a74b017fb5c3ab0cd88507aeb88dbc983c6 Mon Sep 17 00:00:00 2001 From: KishoreB25 Date: Thu, 16 Jul 2026 00:17:49 +0530 Subject: [PATCH 4/5] feat: implement PouchDB service for local storage and optional remote CouchDB synchronization --- frontend/package-lock.json | 215 ++++++++++++++++++++++++++++++++++++ frontend/package.json | 1 + frontend/src/pouchdb.d.ts | 3 +- frontend/src/services/db.ts | 4 +- 4 files changed, 219 insertions(+), 4 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 874f0db..9c2ef17 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^24.13.2", + "@types/pouchdb": "^6.4.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", @@ -1161,6 +1162,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1182,6 +1193,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -1192,6 +1210,203 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pouchdb": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@types/pouchdb/-/pouchdb-6.4.2.tgz", + "integrity": "sha512-YsI47rASdtzR+3V3JE2UKY58snhm0AglHBpyckQBkRYoCbTvGagXHtV0x5n8nzN04jQmvTG+Sm85cIzKT3KXBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-adapter-cordova-sqlite": "*", + "@types/pouchdb-adapter-fruitdown": "*", + "@types/pouchdb-adapter-http": "*", + "@types/pouchdb-adapter-idb": "*", + "@types/pouchdb-adapter-leveldb": "*", + "@types/pouchdb-adapter-localstorage": "*", + "@types/pouchdb-adapter-memory": "*", + "@types/pouchdb-adapter-node-websql": "*", + "@types/pouchdb-adapter-websql": "*", + "@types/pouchdb-browser": "*", + "@types/pouchdb-core": "*", + "@types/pouchdb-http": "*", + "@types/pouchdb-mapreduce": "*", + "@types/pouchdb-node": "*", + "@types/pouchdb-replication": "*" + } + }, + "node_modules/@types/pouchdb-adapter-cordova-sqlite": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/pouchdb-adapter-cordova-sqlite/-/pouchdb-adapter-cordova-sqlite-1.0.4.tgz", + "integrity": "sha512-1MGjmAMux3OIyJ+iXfhJ5hNIzS+KjGJ05O3bF5Gen5TiJUFNK1bOp3VVV9SxXgz+hGwnBruBAWdAqhbB6ZHhSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-core": "*" + } + }, + "node_modules/@types/pouchdb-adapter-fruitdown": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@types/pouchdb-adapter-fruitdown/-/pouchdb-adapter-fruitdown-6.1.6.tgz", + "integrity": "sha512-KaFB29hUI97eTtJI6pjv7EQcqhZ63qHWovKgyiE+HZF5fVmdrBbTmnIrbR87AJXcXKy47+oQFJ7rzxY8TalpLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-core": "*" + } + }, + "node_modules/@types/pouchdb-adapter-http": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@types/pouchdb-adapter-http/-/pouchdb-adapter-http-6.1.6.tgz", + "integrity": "sha512-DJur1mt07GJXwGb5K+MOILoCOSgoQpsi7hybcTzRLeR3IO8Y8eq7TnhTkftAJdx9VHJGOiOXFjO+8BYM69j5yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-core": "*" + } + }, + "node_modules/@types/pouchdb-adapter-idb": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/@types/pouchdb-adapter-idb/-/pouchdb-adapter-idb-6.1.7.tgz", + "integrity": "sha512-KwjkJ4fTNz5wPXYu20bUoWud7ty0t7tgdo4oc0AJvG+fcURAH7mI7uFmpE4dZIT+hUq5G61xu96AVq9b2q4T3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-core": "*" + } + }, + "node_modules/@types/pouchdb-adapter-leveldb": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@types/pouchdb-adapter-leveldb/-/pouchdb-adapter-leveldb-6.1.6.tgz", + "integrity": "sha512-mqeTpA2Ni2U4FA5ISRESy4WwhfUahXViUa3jQpXGdSpruaeHlhTLzZJPyz7/mGlvdAfAFv9Vd5d6ys3ASmMujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-core": "*" + } + }, + "node_modules/@types/pouchdb-adapter-localstorage": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@types/pouchdb-adapter-localstorage/-/pouchdb-adapter-localstorage-6.1.6.tgz", + "integrity": "sha512-+HQBCpD80XkKJE64r7uLwzkNRgkvMnhDI5rIFLx3USxdrRph/R3awcEubRFndcgtxzcUaL9iYw9KetgFMUqPrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-core": "*" + } + }, + "node_modules/@types/pouchdb-adapter-memory": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@types/pouchdb-adapter-memory/-/pouchdb-adapter-memory-6.1.6.tgz", + "integrity": "sha512-QCCtW561XuwFACzP/4zYySzs/a4em0EeuQdszen0YOaGV1/fRqJE0dOlmzh8do4sNJomLO6+MFtEzguGljnkgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-core": "*" + } + }, + "node_modules/@types/pouchdb-adapter-node-websql": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/@types/pouchdb-adapter-node-websql/-/pouchdb-adapter-node-websql-6.1.5.tgz", + "integrity": "sha512-yi68syUvHs4OM3mzKlh4zfpov64KITIAnxi387zgdby6SEfAJzWPC0dfH77iEVRDGCrKb3cKTNkl/UGHnphaow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-adapter-websql": "*", + "@types/pouchdb-core": "*" + } + }, + "node_modules/@types/pouchdb-adapter-websql": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/@types/pouchdb-adapter-websql/-/pouchdb-adapter-websql-6.1.7.tgz", + "integrity": "sha512-9oNkP5ZCGMkQALO9KmtbHXlkBq8i2hoCEE6/gWzRicAvL1y+WIKjEQiIIEamMhj5u5tARvW3n2/r+JXwLCyYgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-core": "*" + } + }, + "node_modules/@types/pouchdb-browser": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/@types/pouchdb-browser/-/pouchdb-browser-6.1.5.tgz", + "integrity": "sha512-f+HjxEjYFpgoYWXnMI9AQZZ+SIG8dBiBPrpfWWGsCl+48rumsP5BuBWHq/aXoB8SRKYO0XdP4TNvMBWM3UATCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-adapter-http": "*", + "@types/pouchdb-adapter-idb": "*", + "@types/pouchdb-adapter-websql": "*", + "@types/pouchdb-core": "*", + "@types/pouchdb-mapreduce": "*", + "@types/pouchdb-replication": "*" + } + }, + "node_modules/@types/pouchdb-core": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/pouchdb-core/-/pouchdb-core-7.0.15.tgz", + "integrity": "sha512-gq1Qbqn9nCaAKRRv6fRHZ4/ER+QYEwSXBZlDQcxwdbPrtZO8EhIn2Bct0AlguaSEdFcABfbaxxyQwFINkNQ9dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "*", + "@types/pouchdb-find": "*" + } + }, + "node_modules/@types/pouchdb-find": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@types/pouchdb-find/-/pouchdb-find-7.3.3.tgz", + "integrity": "sha512-U7zXk67s9Ar+9Pwj5kSbuMnn8zif0AOOIPy4KRFeJ/S/Tk+mNS90soj+3OV21H8xyB7WTxjvS1JLablZC6C6ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-core": "*" + } + }, + "node_modules/@types/pouchdb-http": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/@types/pouchdb-http/-/pouchdb-http-6.1.5.tgz", + "integrity": "sha512-9jGCAl6DUsXIl1vjuPu8tzGykAr84549P4IS0zYdrOKq5eXzQRUb/tb2hEVTmmTcYKXu2P1N55ABsdDNZvzGGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-adapter-http": "*", + "@types/pouchdb-core": "*" + } + }, + "node_modules/@types/pouchdb-mapreduce": { + "version": "6.1.10", + "resolved": "https://registry.npmjs.org/@types/pouchdb-mapreduce/-/pouchdb-mapreduce-6.1.10.tgz", + "integrity": "sha512-AgYVqCnaA5D7cWkWyzZVuk0137N4yZsmIQTD/i3DmuMxYYoFrtWUoQu0tbA52SpTRGdL8ubQ7JFQXzA13fA6IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-core": "*" + } + }, + "node_modules/@types/pouchdb-node": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/@types/pouchdb-node/-/pouchdb-node-6.1.7.tgz", + "integrity": "sha512-hryc2eCtNB3GbLcHSwU8glLaY66gDMus1AYkcIYAAxufdnK2BAy1oxaRLmnwRn1A1vG41P/t0htFD161LUnfQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-adapter-http": "*", + "@types/pouchdb-adapter-leveldb": "*", + "@types/pouchdb-core": "*", + "@types/pouchdb-mapreduce": "*", + "@types/pouchdb-replication": "*" + } + }, + "node_modules/@types/pouchdb-replication": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/@types/pouchdb-replication/-/pouchdb-replication-6.4.7.tgz", + "integrity": "sha512-slB4zOwri3SAVHioFx/FWC/KqOzzb7nDFtV+qzaKzxkf+U5zTwCbK3uRHaj0d/XQk0DwVeajf1ni3Wiyq3j2OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/pouchdb-core": "*", + "@types/pouchdb-find": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", diff --git a/frontend/package.json b/frontend/package.json index 19919de..8a2d347 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^24.13.2", + "@types/pouchdb": "^6.4.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", diff --git a/frontend/src/pouchdb.d.ts b/frontend/src/pouchdb.d.ts index d6d3d9c..539f08a 100644 --- a/frontend/src/pouchdb.d.ts +++ b/frontend/src/pouchdb.d.ts @@ -1,2 +1 @@ -declare module 'pouchdb'; -declare module 'pouchdb-browser'; +// PouchDB typings are handled by @types/pouchdb. diff --git a/frontend/src/services/db.ts b/frontend/src/services/db.ts index 802de85..0819085 100644 --- a/frontend/src/services/db.ts +++ b/frontend/src/services/db.ts @@ -44,7 +44,7 @@ export const initMessageSync = (onChangeCallback: () => void) => { return localMessagesDb.sync(remoteMessagesDb, { live: true, retry: false // Don't retry — avoids flooding console with repeated ERR_CONNECTION_REFUSED - }).on('change', (info: any) => { + }).on('change', () => { if (onChangeCallback) onChangeCallback(); }).on('error', (err: any) => { // Log only once, not a stream of retried errors @@ -59,7 +59,7 @@ export const initUserSync = (onChangeCallback: () => void) => { return localUsersDb.sync(remoteUsersDb, { live: true, retry: false - }).on('change', (info: any) => { + }).on('change', () => { if (onChangeCallback) onChangeCallback(); }).on('error', (err: any) => { console.warn('[DB] User sync error (CouchDB may be unavailable):', err?.message || err); From 9b4e2dc8cf6e364c09dab0c79429747e4c847a48 Mon Sep 17 00:00:00 2001 From: KishoreB25 Date: Thu, 16 Jul 2026 00:30:50 +0530 Subject: [PATCH 5/5] docs: add README.md for backend socket implementation --- backend/README.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 backend/README.md diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..ed3cfcf --- /dev/null +++ b/backend/README.md @@ -0,0 +1,75 @@ +# First Contact - Backend Service + +The backend service is a lightweight Node.js, Express, and Socket.io server that acts as a real-time message relay and interface to Apache CouchDB. It operates as part of the offline-first mesh communications system. + +--- + +## ⚡ How Phase 3 (Real-time WebSockets) Works + +Phase 3 implements instant, bidirectional messaging between devices on the local network using **Socket.io**. This allows peers to communicate instantly without poll-latency or internet dependencies. + +### Architecture Flow +1. **Connection**: When a client loads the frontend, it establishes a WebSocket connection to the backend. +2. **Device Registration (`register_device`)**: + * The client emits this event sending its unique `deviceId` (stored in localStorage) and `username`. + * The backend maps the connection (`socket.id`) to the device details in an in-memory `onlineUsers` Map. + * The backend immediately broadcasts the updated active user list (`active_users_update`) to **all** connected clients. +3. **Message Relaying (`send_message`)**: + * A client sends a message payload: `{ _id, senderId, senderName, text, timestamp }`. + * The backend validates the payload and broadcasts it to all **other** sockets via the `receive_message` event. + * *Note: The sender adds the message to their own UI state immediately (optimistic UI), so the server uses `socket.broadcast.emit` to avoid sending it back to the initiator.* +4. **Disconnection (`disconnect`)**: + * When a browser tab is closed or a network drop occurs, the backend detects the disconnect. + * The corresponding user is removed from the `onlineUsers` Map. + * The server broadcasts the updated online user list to all remaining active clients. + +--- + +## 🚀 Running the Server + +### Option 1: Running Locally (Development Mode) + +Ensure you have **Node.js** (v18+) and **npm** installed. + +1. **Install dependencies**: + ```bash + npm install + ``` + +2. **Start the development server (with hot-reloading via Nodemon)**: + ```bash + npm run dev + ``` + The backend will be available at `http://localhost:5000`. + +--- + +### Option 2: Running with Docker (Individually) + +You can containerize the backend on its own. + +1. **Build the Docker Image**: + ```bash + docker build -t first-contact-backend . + ``` + +2. **Run the Container**: + ```bash + docker run -d -p 5000:5000 --name first_contact_backend_container --env-file .env first-contact-backend + ``` + +--- + +### Option 3: Running the Full Stack (Recommended) + +To run the CouchDB instance, the Backend, and the Frontend together, use Docker Compose from the root workspace directory: + +```bash +# Run from the project root +docker compose up --build +``` + +This starts: +* **CouchDB**: `http://localhost:5984` +* **Backend**: `http://localhost:5000` +* **Frontend**: `http://localhost:5173`