WhatsApp Web Client — Complete Project Breakdown
This document describes the entire WhatsApp Web replacement application in sufficient detail to recreate it from scratch. Every file, every configuration choice, every architectural decision is documented here.
Table of Contents
- Architecture Overview
- Technology Stack
- Project Structure
- Infrastructure & Docker
- Database Schema
- Backend — Express + whatsapp-web.js
- Frontend — React + Vite + Tailwind
- Real-Time Communication
- Media Archival System
- Feature List & Implementation Details
- Complete File Reference
Architecture Overview
Phone (linked device)
↕
WhatsApp Servers
↕
Headless Chromium (inside Docker container)
↕ whatsapp-web.js
Express Backend (port 3101)
↕ REST API + Socket.io
React Frontend (port 3100)
↕ Vite dev proxy → backend
Browser
The system replaces the official WhatsApp Web interface. A headless Chromium browser runs inside a Docker container, maintaining a persistent WhatsApp Web session via whatsapp-web.js. The Express backend wraps this in a REST API and pushes real-time events via Socket.io. The React frontend provides the UI.
Key design decisions:
- Session persists across container restarts via a Docker volume (
wa_session_data) - Messages are archived to PostgreSQL as they flow through (offline access, search)
- Media files are saved to disk organized by contact in categorized subfolders
- All communication between frontend and backend uses relative
/apiURLs (no hardcoded hosts) - Frontend proxies API requests through Vite dev server to the backend container
Technology Stack
Backend
| Technology | Version | Purpose |
|---|---|---|
| Node.js | 20 (Alpine) | Runtime |
| TypeScript | ^5.3.3 | Language |
| Express | ^4.18.2 | HTTP framework |
| whatsapp-web.js | ^1.26.1-alpha.3 | WhatsApp Web automation |
| Socket.io | ^4.8.1 | Real-time events |
| PostgreSQL | 15 (Alpine) | Message/chat archive |
| pg | ^8.11.3 | PostgreSQL client |
| Sharp | ^0.33.5 | Image thumbnail generation |
| qrcode | ^1.5.4 | QR code generation (data URI) |
| multer | ^1.4.5-lts.1 | File upload handling |
| helmet | ^7.1.0 | Security headers |
| morgan | ^1.10.0 | HTTP request logging |
| cors | ^2.8.5 | CORS configuration |
| nodemon | ^3.0.2 | Dev auto-restart |
Frontend
| Technology | Version | Purpose |
|---|---|---|
| React | ^18.2.0 | UI library |
| TypeScript | ^5.2.2 | Language |
| Vite | ^5.0.0 | Build tool + dev server |
| Tailwind CSS | ^3.3.6 | Utility-first CSS |
| React Router | ^6.20.1 | Client-side routing |
| Axios | ^1.6.2 | HTTP client |
| socket.io-client | ^4.8.1 | WebSocket client |
| lucide-react | ^0.294.0 | Icon library |
| PostCSS | ^8.4.32 | CSS processing |
| autoprefixer | ^10.4.16 | CSS vendor prefixes |
Infrastructure
| Technology | Version | Purpose |
|---|---|---|
| Docker Compose | v2 | Container orchestration |
| Chromium | Alpine package | Headless browser for Puppeteer |
Project Structure
WhatsApp/
├── docker-compose.yml # 3 services: db, backend, frontend
├── .env # Environment variables
├── start.sh # docker compose up -d
├── stop.sh # docker compose down
├── media/ # Persistent media storage (volume-mounted)
│ └── +PHONE_ContactName/ # Per-contact folders
│ ├── photos/ # .jpg, .png, .gif, .webp
│ ├── videos/ # .mp4, .3gp
│ ├── docs/ # .pdf, .docx, .xlsx, etc.
│ ├── audio/ # .ogg, .mp3
│ ├── thumbs/ # 200px JPEG thumbnails
│ ├── transcript.txt # Chronological message log
│ └── links.json # Extracted URLs
│
├── database/
│ └── init/
│ └── 01-schema.sql # PostgreSQL schema (auto-runs on first start)
│
├── backend/
│ ├── Dockerfile # Multi-stage: dev (nodemon) + prod (compiled JS)
│ ├── package.json
│ ├── tsconfig.json
│ └── src/
│ ├── index.ts # Express app + server setup
│ ├── config/
│ │ └── database.ts # PostgreSQL connection pool
│ ├── services/
│ │ ├── WhatsAppService.ts # Singleton — wraps whatsapp-web.js Client
│ │ ├── WebSocketService.ts # Singleton — Socket.io server
│ │ └── MediaService.ts # Singleton — file organization + thumbnails
│ ├── controllers/
│ │ ├── statusController.ts # Connection state, initialize, logout
│ │ ├── chatController.ts # Chat list, contact details
│ │ ├── messageController.ts# Message history, send text/media
│ │ └── mediaController.ts # Media browser, sync, delete, thumbnails
│ └── routes/
│ ├── statusRoutes.ts
│ ├── chatRoutes.ts
│ ├── messageRoutes.ts
│ └── mediaRoutes.ts
│
└── frontend/
├── Dockerfile # Multi-stage: dev (Vite) + prod (nginx)
├── package.json
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts # Proxy /api → backend, port 3100
├── tailwind.config.js # WhatsApp color palette
├── postcss.config.js
├── index.html
└── src/
├── main.tsx # React entry point
├── App.tsx # Router + layout (TopBar + BackfillBanner + Routes)
├── index.css # Tailwind + IBM Plex font + scrollbar styles (14px base)
├── vite-env.d.ts
├── types/
│ └── index.ts # All TypeScript interfaces
├── services/
│ ├── api.ts # Axios API layer (status, chat, message, media, contact, personality)
│ ├── actionsAPI.ts # Actions/reminders API
│ └── reminderAPI.ts # Reminder CRUD API
├── contexts/
│ └── WhatsAppContext.tsx # Global state + socket event handling
├── hooks/
│ ├── useSSE.ts # SSE (Server-Sent Events) client hook
│ └── useIdleBackfill.ts # Idle-triggered backfill hook
├── pages/
│ ├── LoginPage.tsx # QR code display + connection UI
│ ├── ChatPage.tsx # 3-column grid: ChatList | Conversation | CrmPanel
│ ├── SettingsPage.tsx # Connection info + logout + storage
│ ├── MediaBrowserPage.tsx # Full media browser with tabs/lightbox/delete
│ ├── ActionListPage.tsx # Global action/reminder list
│ ├── PersonalitiesPage.tsx# AI personality management
│ └── AISettingsPage.tsx # AI provider configuration
└── components/
├── TopBar.tsx # Brand label + status + nav (redesigned)
├── ActionBar.tsx # DEPRECATED — no longer imported
├── BackfillBanner.tsx # Thin backfill progress bar
├── ChatList.tsx # Filter chips + searchable contact list (redesigned)
├── ChatListItem.tsx # Edge-to-edge avatar + tags + AI icon (redesigned)
├── ConversationView.tsx# Header + action banner + messages (redesigned)
├── CrmPanel.tsx # NEW — Persistent CRM sidebar with sections
├── MessageBubble.tsx # Bubbles with inline images + context menu
├── MessageComposer.tsx # Text + file input + reply preview + send
├── MessageContextMenu.tsx # Right-click/dropdown menu for messages
├── ContactProfile.tsx # DEPRECATED — replaced by CrmPanel
├── ContactEditForm.tsx # Reusable contact edit form (used in CrmPanel)
├── PersonalityPicker.tsx # AI personality assignment (used in CrmPanel)
├── PersonalityBadge.tsx# AI personality badge display
├── AvatarPicker.tsx # Avatar selection modal
├── ForwardModal.tsx # Message forwarding modal
├── WhereWereWeModal.tsx# AI conversation summary
├── AnalysePanel.tsx # Contact analysis
├── DuplicatesPanel.tsx # Duplicate contact detection
├── ContactImportExport.tsx # VCF import/export
├── MediaStorageSettings.tsx # Storage configuration
├── CollapsibleSection.tsx # Reusable collapsible UI
├── Tip.tsx # Tooltip component
└── QRCode.tsx # QR code image display
Infrastructure & Docker
docker-compose.yml
Three services on a shared bridge network (wa-network):
1. wa-db (PostgreSQL 15)
- Container name:
whatsapp-db - Port:
5436:5432(external:internal) - Volume:
wa_db_datafor persistent data - Init script:
./database/init/01-schema.sqlruns on first creation - Credentials:
wa_user/wa_password/ databasewhatsapp
2. wa-backend (Node.js 20 + Chromium)
- Container name:
whatsapp-backend - Port:
3101:3101 shm_size: '1gb'— critical for Chromium shared memory (prevents crashes)- Volumes:
./backend:/app— live code editing with nodemon/app/node_modules— anonymous volume (container’s own modules)wa_session_data:/app/.wwebjs_auth— persistent WhatsApp session (survives restarts, no re-scan)./media:/app/media— media file storage
- Command:
npm run dev(nodemon watches TypeScript files) - Environment:
DATABASE_URL=postgresql://wa_user:wa_password@wa-db:5432/whatsapp - Depends on:
wa-db
3. wa-frontend (Node.js 20 + Vite)
- Container name:
whatsapp-frontend - Port:
3100:3100 - Volumes:
./frontend:/app— live code editing with HMR/app/node_modules— anonymous volume
- Command:
npm run dev - Depends on:
wa-backend
Backend Dockerfile (Multi-stage)
Development stage:
FROM node:20-alpine AS development
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont dbus
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
- Installs Chromium and font dependencies for Puppeteer
PUPPETEER_EXECUTABLE_PATHtells whatsapp-web.js to use the system ChromiumPUPPETEER_SKIP_CHROMIUM_DOWNLOAD=trueprevents downloading a second Chromium
Production stage:
- Compiles TypeScript to
dist/ - Runs as non-root user
nodeuser - Production-only npm install
Frontend Dockerfile (Multi-stage)
Development stage: Plain Node.js with Vite dev server
Production stage: nginx serving the built dist/ directory
.env
NODE_ENV=development
POSTGRES_DB=whatsapp
POSTGRES_USER=wa_user
POSTGRES_PASSWORD=wa_password
DATABASE_URL=postgresql://wa_user:wa_password@localhost:5436/whatsapp
BACKEND_PORT=3101
FRONTEND_PORT=3100
start.sh / stop.sh
Simple wrappers around docker compose up -d and docker compose down.
Database Schema
Four tables in PostgreSQL:
contacts
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PK | |
| wa_id | VARCHAR(64) UNIQUE | WhatsApp ID (e.g., 123456789@c.us) |
| phone_number | VARCHAR(32) | |
| push_name | VARCHAR(255) | Name set by user in WhatsApp |
| saved_name | VARCHAR(255) | Name from your phone contacts |
| profile_pic_url | TEXT | |
| is_group | BOOLEAN | |
| is_business | BOOLEAN | |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ |
chats
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PK | |
| wa_id | VARCHAR(64) UNIQUE | |
| contact_id | INTEGER FK | References contacts(id) |
| name | VARCHAR(255) | |
| is_group | BOOLEAN | |
| unread_count | INTEGER | |
| last_message_text | TEXT | |
| last_message_at | TIMESTAMPTZ | |
| is_pinned | BOOLEAN | |
| is_archived | BOOLEAN | |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ |
Index: idx_chats_last_message on last_message_at DESC
messages
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PK | |
| wa_id | VARCHAR(128) UNIQUE | Serialized message ID |
| chat_wa_id | VARCHAR(64) | |
| from_wa_id | VARCHAR(64) | |
| from_me | BOOLEAN | |
| body | TEXT | |
| message_type | VARCHAR(32) | Default ‘chat’ |
| has_media | BOOLEAN | |
| media_mime_type | VARCHAR(128) | |
| media_url | TEXT | |
| media_filename | VARCHAR(255) | |
| ack | INTEGER | 0=pending, 1=sent, 2=delivered, 3=read |
| timestamp | TIMESTAMPTZ | |
| created_at | TIMESTAMPTZ |
Index: idx_messages_chat on (chat_wa_id, timestamp DESC)
connection_state (single-row)
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | Always 1 (CHECK constraint) |
| state | VARCHAR(32) | ‘disconnected’, ‘qr’, ‘connecting’, ‘ready’ |
| phone_number | VARCHAR(32) | |
| last_connected_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ |
Pre-seeded with one row: INSERT INTO connection_state (state) VALUES ('disconnected')
Backend
Entry Point (src/index.ts)
- Creates Express app with middleware:
helmet(CSP disabled),cors(origin: localhost:3100),morgan(dev logging),express.json() - Health check at
GET /api/health - Mounts route groups:
/api/status,/api/chats,/api/messages,/api/media - Initializes WebSocket service on the HTTP server
- Auto-initializes WhatsApp client 2 seconds after server start — if a saved session exists, it reconnects silently; if not, it waits for the user to click Connect
Database Config (src/config/database.ts)
PostgreSQL connection pool using pg:
- Max 20 connections
- 30s idle timeout, 2s connection timeout
- Supports
DATABASE_URLconnection string or individual env vars - Exits process on unexpected pool errors
WhatsAppService (src/services/WhatsAppService.ts)
Singleton pattern — WhatsAppService.getInstance()
State machine: disconnected → connecting → qr → ready (or back to disconnected)
Key methods:
initialize()— Creates a new whatsapp-web.jsClientwithLocalAuthstrategy and Puppeteer config. Cleans stale Chromium lock files first (from ungraceful shutdowns). Registers event handlers for:qr,ready,disconnected,authenticated,auth_failure,loading_screen,message,message_create,message_ack.destroy()— Destroys the client instancelogout()— Logs out (clears session) then destroysgetClient()— Returns raw whatsapp-web.js Client for controllersgetState()/getPhoneNumber()— Current state accessors
Puppeteer configuration:
puppeteer: {
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--disable-gpu',
],
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
}
Message handling (handleMessage):
- Skips
status@broadcastmessages - Resolves contact info (phone + display name)
- Serializes message for DB and socket
- Archives to PostgreSQL (upsert by
wa_id, updatesackon conflict) - If media: downloads it, saves via MediaService, logs to transcript
- If text: appends to transcript, extracts links
- Emits socket event:
wa:message(incoming) orwa:message_create(outgoing)
Contact resolution (resolveContact):
- Gets
pushname(WhatsApp display name),name(saved contact name),shortName - Falls back to phone number if no name available
Chromium lock cleanup (cleanChromiumLocks):
- Walks
.wwebjs_authdirectory - Removes
SingletonLock,SingletonCookie,SingletonSocketfiles - Prevents “already running” errors after ungraceful shutdown
WebSocketService (src/services/WebSocketService.ts)
Singleton pattern — wraps Socket.io server (used internally within each slice).
initialize(server)— Creates Socket.io server with CORS for localhost:3100emit(event, data)— Broadcasts to all connected clients- Logs connect/disconnect events
Note: In the SaaS deployment, the browser does NOT connect to the slice’s Socket.io server directly. Instead, the gateway connects as a socket.io-client and re-emits events to the browser via SSE (Server-Sent Events). See Real-Time Communication.
MediaService (src/services/MediaService.ts)
Singleton pattern — handles all file organization and storage.
Media root: /app/media (mapped to ./media on host)
Contact directory naming: +PHONE_ContactName (e.g., +639625840831_Lanie)
- Phone number with leading
+ - Name sanitized: only
[a-zA-Z0-9 _-], spaces become underscores
File naming: +PHONE_ContactName_YYYYMMDD_HHMMSS.ext
MIME type mapping:
image/jpeg → .jpg image/png → .png image/gif → .gif
image/webp → .webp video/mp4 → .mp4 video/3gpp → .3gp
audio/ogg → .ogg audio/mpeg → .mp3 application/pdf → .pdf
text/plain → .txt text/csv → .csv (and more)
Category classification:
image/*→photos/video/*→videos/audio/*→audio/- Everything else →
docs/
Thumbnail generation:
- Photos: Sharp resizes to 200x200px, cover fit, 60% JPEG quality
- Videos: Uses WhatsApp’s embedded preview image (
msg._data.mediaData?.preview?._b64), resizes to same spec - Stored in
thumbs/subfolder with.jpgextension generateMissingThumbnails()backfills thumbnails for existing photos
Link extraction:
- URL regex:
https?:\/\/[^\s<>"{}|\\^[]]+` - Saved to
links.jsonper contact with deduplication (url + timestamp) - Each entry:
{ url, timestamp, sender, context }
Transcript:
transcript.txtper contact- Format:
[YYYY-MM-DD HH:MM:SS] SenderName: message text - Media entries:
[time] Sender: [Media: filename.jpg] optional caption
Key methods:
saveMedia(contact, timestamp, mimetype, base64Data, previewB64?)→ filenamesaveLinks(contact, timestamp, sender, text)→ count addedappendTranscript(contact, timestamp, senderName, text)logMediaInTranscript(contact, timestamp, senderName, filename, caption?)getCategorizedFiles(contactDir)→{ photos, videos, docs, audio }each with{ name, size, modified }getLinks(contactDir)→LinkEntry[]getAllFilenames(contactDir)→Set<string>(for dedup during sync)generateMissingThumbnails(contactDir)→ number generated
Controllers
statusController (src/controllers/statusController.ts)
| Endpoint | Method | Action |
|---|---|---|
/api/status/state |
GET | Returns { state, phoneNumber, lastConnectedAt } from service + DB |
/api/status/initialize |
POST | Starts WhatsApp client (non-blocking, returns immediately) |
/api/status/logout |
POST | Logs out and destroys client |
chatController (src/controllers/chatController.ts)
| Endpoint | Method | Action |
|---|---|---|
/api/chats |
GET | Lists all chats from WhatsApp client (falls back to DB if disconnected). Archives chats to DB in background. Returns { id, name, isGroup, unreadCount, lastMessage, timestamp, pinned, archived }[] |
/api/chats/contact/:chatId |
GET | Full contact details including profile pic URL, about text, business/enterprise flags, blocked status |
Chat archiving: On each getChats call, upserts contacts and chats to PostgreSQL for offline access.
messageController (src/controllers/messageController.ts)
| Endpoint | Method | Action |
|---|---|---|
/api/messages/:chatId |
GET | Fetch message history. ?limit=50. Timestamps converted from PostgreSQL TIMESTAMPTZ to Unix seconds. Media URLs enriched from media_files table or on-demand endpoint. Supplemented with DOM messages if chat is open. |
/api/messages/:chatId/text |
POST | Send text message. Body: { text } |
/api/messages/:chatId/media |
POST | Send media file. Multipart form: file + optional caption. Uses multer memory storage. |
/api/messages/:chatId/react |
POST | React to message. Body: { messageId, emoji } |
/api/messages/:chatId/reply |
POST | Reply to message. Body: { messageId, text } |
/api/messages/:chatId/forward |
POST | Forward message. Body: { messageId, targetChatId } |
/api/messages/:chatId/star |
POST | Star/unstar message. Body: { messageId } |
/api/messages/:chatId/pin |
POST | Pin message (7 days). Body: { messageId } |
/api/messages/:chatId/delete |
POST | Delete message from WhatsApp AND local DB. Body: { messageId, forEveryone } |
/api/messages/:chatId/download-media |
POST | Download media as JSON base64. Body: { messageId } |
/api/messages/:chatId/media/:messageId |
GET | Serve media inline as binary — fetches from WhatsApp on-demand, cached 24h |
Media URL enrichment: When loading messages, the controller:
- Queries
media_filestable for synced thumbnails/originals - Extracts folder + filename from absolute paths to build
/api/media/thumb/URLs - For media messages without synced files, sets URL to the on-demand endpoint (
/api/messages/:chatId/media/:messageId)
Delete cleanup: When a message is deleted, the controller also removes the row from the messages table and any associated media_files entries, preventing ghost messages from reappearing.
mediaController (src/controllers/mediaController.ts)
| Endpoint | Method | Action |
|---|---|---|
/api/media/contacts |
GET | Lists all contact folders with categorized counts, total size, transcript line count |
/api/media/contacts/:folder |
GET | Full categorized file listing for one contact: photos, videos, docs, audio, links, transcript |
/api/media/sync/:chatId |
POST | Scans WhatsApp chat history, downloads missing media, extracts links. ?limit=100. Returns stats. |
/api/media/file/:folder/:filename |
GET | Serves a media file (checks subfolders then root) |
/api/media/thumb/:folder/:filename |
GET | Serves thumbnail (falls back to full file) |
/api/media/file/:folder/:filename |
DELETE | Deletes file + its thumbnail |
/api/media/thumbs/:folder |
POST | Generates missing thumbnails for a contact |
Sync algorithm:
- Fetches N messages from WhatsApp chat
- For each message with media: checks if file already exists (by timestamp prefix)
- Downloads missing media via
msg.downloadMedia() - Saves to categorized subfolder, generates thumbnail
- Extracts links from all text messages
- Returns:
{ downloaded, skipped, failed, linksFound, byCategory, totalFilesInFolder }
Routes
Each route file creates an Express Router and maps endpoints to controller functions:
statusRoutes.ts: GET/state, POST/initialize, POST/logoutchatRoutes.ts: GET/, GET/contact/:chatIdmessageRoutes.ts: GET/:chatId, POST/:chatId/text, POST/:chatId/media(with multer)mediaRoutes.ts: GET/contacts, GET/contacts/:folder, POST/sync/:chatId, GET/file/:folder/:filename, GET/thumb/:folder/:filename, DELETE/file/:folder/:filename, POST/thumbs/:folder
Frontend
Build Configuration
vite.config.ts:
- React plugin
- Dev server on
0.0.0.0:3100(accessible from any interface) - API proxy:
/api→http://wa-backend:3101(Docker service name) - Path alias:
@→./src - Source maps enabled in build
tailwind.config.js — CRM Color Palette (redesigned 2026-02-19):
colors: {
wa: {
green: '#00c896', // Primary accent (buttons, badges, active states)
'green-dark': '#00a87e', // Hover green
'teal-green': '#005040', // Deep green accents
'light-green': '#dcf8c6', // Light mode outgoing (unused in dark theme)
bg: '#efeae2', // Light mode background (unused)
'bg-deep': '#0e0f11', // Page background
panel: '#1c1f24', // Raised panel/card
'panel-header': '#15171a', // Panel headers (same as sidebar)
incoming: '#1c1f24', // Incoming message bubble
outgoing: '#003d2d', // Outgoing message bubble
'outgoing-border': '#005040', // Outgoing bubble border
sidebar: '#15171a', // Sidebar background
hover: '#22262d', // Hover state
border: '#2a2f38', // Border color
'border-subtle': '#1e2228', // Subtle borders
text: '#e8ecf0', // Primary text (bright white)
'text-secondary': '#b0b8c4', // Secondary text (readable grey)
'text-muted': '#8891a0', // Muted text (timestamps, labels)
warn: '#f5a623', // Warnings, action badges
'warn-dim': 'rgba(245,166,35,0.12)', // Warning backgrounds
danger: '#e05252', // Errors, overdue, delete
'accent-dim': 'rgba(0,200,150,0.12)', // Accent backgrounds
'accent-border': 'rgba(0,200,150,0.3)', // Accent borders
},
}
fontFamily: {
sans: ['IBM Plex Sans', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['IBM Plex Mono', 'ui-monospace', 'monospace'],
}
postcss.config.js: Tailwind CSS + autoprefixer
index.css: Tailwind directives + IBM Plex font import + scrollbar styling (8px general, 10px green-tinted for contact list via .contact-list-scroll class). Base font-size: 14px.
index.html: Dark background, Google Fonts preconnect for IBM Plex Sans + Mono, title “OMELAS CRM”
TypeScript Types (src/types/index.ts)
Chat { id, name, isGroup, unreadCount, lastMessage: { body, timestamp, fromMe } | null, timestamp, pinned, archived }
Message { id, chatId, from, fromMe, body, type, hasMedia, timestamp, ack }
ConnectionState = 'disconnected' | 'qr' | 'connecting' | 'ready'
StatusInfo { state, phoneNumber, lastConnectedAt }
ContactDetail { id, number, name, shortName, pushname, isGroup, isBusiness, isEnterprise, isMyContact, isBlocked, profilePicUrl, about }
MediaFile { name, size, modified }
LinkEntry { url, timestamp, sender, context }
MediaContact { folder, counts: { photos, videos, docs, audio, links }, totalFiles, transcriptLines, totalSize }
MediaFolderDetail { folder, photos, videos, docs, audio, links, transcript }
SyncResult { chatId, contact, folder, totalMessages, mediaMessages, downloaded, skipped, failed, linksFound, newFiles, byCategory, totalFilesInFolder }
API Service Layer (src/services/api.ts)
Axios instance with base URL /api:
statusAPI:
getState()→ GET/api/status/stateinitialize()→ POST/api/status/initializelogout()→ POST/api/status/logout
chatAPI:
getChats()→ GET/api/chatsgetContact(chatId)→ GET/api/chats/contact/:chatId
messageAPI:
getMessages(chatId, limit=50)→ GET/api/messages/:chatIdsendText(chatId, text)→ POST/api/messages/:chatId/textsendMedia(chatId, file, caption?)→ POST/api/messages/:chatId/media(multipart)
mediaAPI:
listContacts()→ GET/api/media/contactsgetContactMedia(folder)→ GET/api/media/contacts/:foldersyncMedia(chatId, limit=100)→ POST/api/media/sync/:chatIdwith{}bodygetFileUrl(folder, filename)→ URL string (no request)getThumbUrl(folder, filename)→ URL string (no request)deleteFile(folder, filename)→ DELETE/api/media/file/:folder/:filenamegenerateThumbs(folder)→ POST/api/media/thumbs/:folder
Important: syncMedia sends {} as the body (not null). Express strict JSON mode rejects null.
SSE Hook (src/hooks/useSSE.ts)
- Connects to
GET /api/eventsvia the nativeEventSourceAPI - Plain HTTP (no WebSocket upgrade needed) — works through any reverse proxy
- EventSource handles auto-reconnect natively with exponential backoff
- Parses SSE
datafield as{ event: string, payload: any } - Maintains listener map:
Map<string, Set<Function>> - Returns
{ on, off, connected }— event emitter API used by WhatsAppContext
Replaced: The previous
useWebSocket.tshook connected directly to the slice’s Socket.io server. This was replaced with SSE because proxying socket.io through the gateway caused persistent 502 errors on WebSocket upgrades.
App Component (src/App.tsx)
Layout structure:
<BrowserRouter>
<WhatsAppProvider>
<div className="h-screen flex flex-col bg-wa-bg-deep">
<TopBar /> {/* 44px — brand + status + nav */}
<BackfillBanner /> {/* Thin progress bar during backfill */}
<AppRoutes /> {/* flex-1 — page content */}
</div>
</WhatsAppProvider>
</BrowserRouter>
Note: ActionBar was removed in the 2026-02-19 redesign. Sync media moved to ConversationView header.
Routing:
- When disconnected: only
/settingsand*→ LoginPage - When connected:
/→ ChatPage,/media→ MediaBrowserPage,/actions→ ActionListPage,/personalities→ PersonalitiesPage,/ai-settings→ AISettingsPage,/settings→ SettingsPage,*→ redirect to/
WhatsAppContext (src/contexts/WhatsAppContext.tsx)
Global state provider — wraps entire app.
State:
connectionState— current WA connection statusphoneNumber— connected phone numberqrCode— base64 data URI for QR codeloadingProgress—{ percent, message }during initializationchats— full chat listactiveChat— currently selected chatmessages— messages for active chat
SSE event handlers (registered via on() from useSSE, bound once):
wa:catchup→ hydrates full state on SSE connect (connectionState, qr, phoneNumber, loadingProgress, authenticated)wa:qr→ sets QR code, updates state to ‘qr’wa:ready→ clears QR, sets state to ‘ready’, fetches chatswa:disconnected→ clears everythingwa:loading→ sets loading progresswa:message→ appends to messages (if active chat matches), bumps chat to top with unread countwa:message_create→ appends to messages (dedupes by ID)wa:message_ack→ updates ack value on matching message
Actions:
initialize()— sets state to ‘connecting’, calls APIlogout()— calls API, clears all stateselectChat(chat)— fetches messages, clears unreadsendMessage(text)/sendMedia(file)— sends via APIcheckState()— polls API for current stateresetUI()— cancels connecting state (UI reset only)refreshChats()— re-fetches chat list
Pages
LoginPage
- Shows when not connected
- Three states: disconnected (green Connect button), connecting (spinner with progress bar), QR (QR code image)
- Progress bar shows WhatsApp loading percentage from backend events
ChatPage (redesigned 2026-02-19)
Three-column CSS grid layout (300px | 1fr | 440px):
- Left (300px):
ChatList— contact list with filter chips, search, avatars - Center (1fr):
ConversationView— messages + composer (or empty state) - Right (440px):
CrmPanel— persistent CRM sidebar (or empty state) AvatarPickermodal triggered by clicking avatars in contact list or CRM panel- Old
ContactProfileoverlay removed — replaced by persistentCrmPanel
SettingsPage
- Connection status card
- Phone number display
- Logout button with confirmation
MediaBrowserPage (835 lines — largest component)
Left sidebar (288px):
- Search bar filtering contacts by name or phone
- Contact list showing: name, phone, categorized counts (photos/videos/docs/audio/links), total size
- Selected state highlighted
Main content area:
- Folder header with contact name and Sync Media button (spins while syncing)
- Sync result banner showing: new downloads, skipped, failed, links found, breakdown by category
- Tab bar: Photos | Videos | Docs | Audio | Links — each with count badge
- Selection toolbar: Select/Cancel toggle, All/None, selected count + total size, red Delete N button
Tab content:
- PhotoGrid: Responsive grid (3-8 columns by breakpoint), thumbnail images, hover overlay with file type/size/date, delete button on hover. Click opens lightbox. Select mode adds checkbox overlay with green ring.
- VideoGrid: Same grid layout, uses video thumbnail (from WhatsApp preview), play button overlay, links to full video. Select mode same as photos.
- FileList: List rows with extension badge, filename, type/size/date, download + delete on hover. Select mode adds checkbox column.
- AudioList: Cards with
<audio>player, file metadata, delete on hover. Select mode adds checkbox. - LinkList: Clickable URLs with sender, date, and surrounding context text.
Lightbox:
- Full-screen black overlay
- Top bar: filename, file type, size, date, download button, delete button, close button
- Left/right navigation arrows
- Counter: “N of M”
Multi-select:
- Toggle with Select/Cancel button
- Checkboxes appear on all items
- Select All / Select None shortcuts
- Bulk delete with confirmation dialog
- Sequential API calls (one per file)
- Selection clears on tab switch and folder switch
Helper functions:
parseFolder(folder)— extracts{ name, phone }from+PHONE_NameformatdisplayFolder(folder)— returns"Name (+phone)"for headersformatSize(bytes)— human-readable file sizesformatDate(iso|timestamp)— locale-formatted date+timegetFileExt(name)— uppercase extensiongetFileType(name)— human-readable type name (e.g., “JPEG Image”)
Components
TopBar (redesigned 2026-02-19)
44px fixed header with:
- Left: “OMELAS CRM” brand label (mono, green), status pill (READY/CONNECTING/SCAN QR/OFFLINE with color coding), phone number, chat count, AWAY mode indicator
- Right: Navigation buttons — Chats, Media, Actions (with count badge), Personalities, AI Settings, Away mode toggle, Settings, Logout (with confirmation modal)
All buttons use NavButton sub-component with: icon, label, accent/danger/active border styling. Labels hidden on small screens (hidden lg:inline).
Removed in redesign: ActionBar, Check State, Refresh Chats, Connect, Cancel buttons.
ChatList (redesigned 2026-02-19)
- Header: “CONTACTS” label + total count
- Search input
- Filter chips: All, Warm, Action, New — filters by contact tags or pending reminders
- Archived chats toggle (shows count, button to switch view)
- Scrollable list with
.contact-list-scrollclass (10px green scrollbar) - List of
ChatListItemcomponents - Loads tags and reminders for all contacts on mount
- Empty states for no chats / no matches / no filter results
ChatListItem (redesigned 2026-02-19)
- Edge-to-edge square avatar (60px wide, no border/radius/padding, fills full row height). Click opens AvatarPicker.
- Active indicator: 2px green bar between avatar and body
- Body: Name (text-sm medium) + last message preview (text-xs) + tag chips (Warm/New/Cold/Action with color coding)
- Right column: Timestamp (mono text-[11px]) + red robot icon (when AI active) + unread dot (green) + ACT badge (amber, when has pending actions)
- Right-click context menu: pin/unpin
- Props:
chat, isActive, onClick, onTogglePin, onAvatarClick, avatarRetry, personalityEmoji, personalityName, personalityPaused, tags, hasActions
ConversationView (redesigned 2026-02-19)
- Header: Name (text-sm semibold) + pushname/phone subline (mono text-[11px]) + personality badge + action count button + sync media button
- Action banner: Amber bar below header when contact has pending reminders — shows first action text + “DONE” button
- Scrollable message area with subtle dot pattern background
- Auto-scrolls to bottom on new messages
MessageComposerat bottomForwardModalfor message forwarding
CrmPanel (NEW — 2026-02-19)
Persistent right sidebar replacing old ContactProfile overlay. Sections:
- Header: “CRM Profile” label
- Hero: Large avatar (w-24 h-24, clickable for AvatarPicker) + name + pushname + phone + tag chips
- Quick action buttons: Add Action, Note, Tag (pinned below hero)
- Actions & Reminders (collapsible): List of pending reminders with complete/delete, inline add form
- Photos (collapsible): 4-column thumbnail grid from synced media + “Browse All Media” button
- Contact Details (collapsible): Wraps
ContactEditFormcomponent - AI Auto-Reply (collapsible): Wraps
PersonalityPickercomponent - Activity (collapsible): “Where were we?” button + recent action timeline
- Section headers: large emoji icon + uppercase mono title (text-sm font-semibold) + count badge + chevron toggle
- Props:
chatId, chatName, onAvatarPickerClick
MessageBubble
- Left-aligned (incoming,
bg-wa-incoming) or right-aligned (outgoing,bg-wa-outgoing) - Rounded corners with “tail” (tl-none for incoming, tr-none for outgoing)
- Inline images:
MediaPlaceholdershows images/videos inline whenmediaUrlis available (from synced thumbnails or on-demand WhatsApp fetch). Falls back to icon + type label. - AI reply indicator: blue Bot icon + “AI” label
- Hover dropdown arrow for context menu (reply, react, star, forward, copy, delete)
- Timestamp in bottom-right (text-[11px])
- Ack icons for outgoing: clock (0), single check (1), double check (2), blue double check (3+)
- Star icon shown for starred messages
MessageComposer
- Paperclip button → hidden file input
- Textarea (auto-height up to 120px, Enter sends, Shift+Enter newline)
- Reply preview bar (when replying to a message) with cancel button
- Send button (disabled when empty)
ContactProfile (DEPRECATED)
File still exists but no longer used in the UI. Replaced by persistent CrmPanel in the right column.
QRCode
- White background padding
- 288px QR code image from data URI
- Instruction text below
Real-Time Communication
Architecture (SSE + socket.io-client bridge)
Browser ──── GET /api/events (SSE) ────→ Gateway ──── socket.io-client ────→ Slice (localhost:500X)
plain HTTP, text/event-stream │ socket.io on localhost
auto-reconnect built in │ always works (no proxy)
│
SliceConnector
(per-slice bridge)
The browser connects to the gateway via SSE (Server-Sent Events) — plain HTTP, no WebSocket upgrade needed. The gateway maintains a SliceConnector per active slice that connects to the slice’s Socket.io server as a client on localhost (always reliable). Events from the slice are re-emitted to all connected SSE browser streams.
Key components:
gateway/src/sse.ts— SliceConnector class + SSE endpoint handlerfrontend/src/hooks/useSSE.ts— EventSource hook (replaces useWebSocket)- Slice backends keep their Socket.io servers unchanged
Events (Slice → Gateway → Browser)
| Event | Data | Purpose |
|---|---|---|
wa:catchup |
Full cached state | Sent on SSE connect — immediate state hydration |
wa:qr |
{ qr: string } |
QR code as base64 data URI |
wa:ready |
{ phoneNumber: string } |
Connection established |
wa:disconnected |
{ reason: string } |
Connection lost |
wa:loading |
{ percent: number, message: string } |
WhatsApp loading progress |
wa:auth_failure |
{ message: string } |
Authentication failed |
wa:phone_mismatch |
{ previousPhone, newPhone } |
Different phone detected |
wa:message |
Message object |
Incoming message from others |
wa:message_create |
Message object |
Outgoing message confirmed |
wa:message_ack |
{ messageId: string, ack: number } |
Delivery status update |
wa:message_reaction |
{ messageId, chatId, reactions } |
Reaction added/removed |
Connection Flow
- Browser opens
GET /api/events→ SSE stream established - Gateway creates/reuses SliceConnector → connects to slice via socket.io-client
- SliceConnector fetches real state from slice HTTP API → sends
wa:catchupto browser - If saved session exists → authenticates silently →
wa:readyemitted - If no session → LoginPage auto-initializes →
wa:qremitted → user scans →wa:ready - Frontend receives
wa:ready→ fetches chat list → shows ChatPage - Real-time messages flow through SSE events while connected
- 15-second heartbeat comments (
:keepalive) prevent proxy timeouts - If SSE drops, EventSource auto-reconnects and gets fresh
wa:catchup
Media Archival System
How Media Gets Saved
Real-time (via WhatsAppService message handler):
- Every incoming/outgoing message triggers
handleMessage() - If
msg.hasMedia, downloads viamsg.downloadMedia() - Grabs WhatsApp’s video preview:
msg._data.mediaData?.preview?._b64 - Saves to
MEDIA_ROOT/+PHONE_Name/CATEGORY/filename.ext - Generates thumbnail for photos (Sharp) or saves video preview
- Logs to transcript.txt
- Extracts links from text body
Manual sync (via mediaController.syncMedia):
- User clicks Sync Media → API fetches last N messages from WhatsApp
- Checks existing filenames to skip duplicates
- Downloads missing media
- Returns statistics
File Organization
/app/media/
+639625840831_Lanie/
photos/
+639625840831_Lanie_20260215_143022.jpg
+639625840831_Lanie_20260215_150101.png
videos/
+639625840831_Lanie_20260216_091533.mp4
audio/
+639625840831_Lanie_20260216_120045.ogg
docs/
+639625840831_Lanie_20260217_083012.pdf
thumbs/
+639625840831_Lanie_20260215_143022.jpg ← 200px thumbnail
+639625840831_Lanie_20260216_091533.jpg ← video preview thumbnail
transcript.txt
links.json
Backwards Compatibility
The system supports both:
- New layout: Files in categorized subfolders (
photos/,videos/, etc.) - Old flat layout: Files directly in the contact root directory
All file lookup operations check subfolders first, then fall back to root.
Feature List & Implementation Details
1. WhatsApp Connection Management
- QR code scanning via browser UI
- Session persistence across container restarts (Docker volume)
- Auto-reconnect on startup
- Clean Chromium lock file cleanup
- Connection state machine: disconnected → connecting → qr → ready
- Loading progress display with percentage
2. Chat Interface
- Full chat list with search filtering
- Archive/unarchive toggle
- Unread message badges
- Last message preview with smart timestamps
- Chat selection with message history loading
- Real-time message delivery (no polling)
3. Messaging
- Send and receive text messages
- Send files via multipart upload
- Message bubbles with left/right alignment
- Delivery status ticks (clock → check → double check → blue double check)
- Auto-scroll to newest message
- Shift+Enter for newlines
4. Contact Profiles
- Slide-over panel with full contact details
- Profile picture display (with fallback icon)
- About text
- Business/Enterprise/Blocked flags
- Accessible by clicking any avatar
5. Media Browser
- Contact sidebar with search
- Five tabs: Photos, Videos, Docs, Audio, Links
- Photo grid with thumbnails (responsive columns)
- Video grid with preview thumbnails (from WhatsApp)
- Audio player with native
<audio>controls - Document list with extension badges
- Link list with context and sender info
- File metadata on all items: type, size, date
6. Photo Lightbox
- Full-screen image viewer
- Left/right navigation
- Image counter
- Download button
- Delete button
- Keyboard-friendly (click overlay to close)
7. Media Sync
- Sync from Chat view (ActionBar) or Media Browser
- Downloads missing media from WhatsApp message history
- Extracts URLs from text messages
- Skips already-downloaded files
- Shows detailed statistics: new, skipped, failed, by category
8. File Management
- Single file delete with confirmation
- Multi-select mode with checkboxes
- Select All / Select None
- Bulk delete with progress
- Automatic thumbnail cleanup on delete
- Sidebar counts refresh after changes
9. Transcript & Links
- Automatic message logging to
transcript.txt - URL extraction from all text messages
links.jsonwith deduplication, sender info, context- Searchable link listing in Media Browser
10. Database Archiving
- Messages archived in real-time as they flow through
- Chat list cached on every fetch
- Contacts upserted with latest names
- Connection state persisted
- Falls back to DB data when WhatsApp is disconnected
Complete File Reference
Infrastructure Files
| File | Lines | Purpose |
|---|---|---|
docker-compose.yml |
69 | Three services, two volumes, bridge network |
.env |
13 | Environment variables |
start.sh |
11 | Start all containers |
stop.sh |
5 | Stop all containers |
database/init/01-schema.sql |
68 | PostgreSQL schema with 4 tables + indexes |
Backend Files
| File | Lines | Purpose |
|---|---|---|
backend/Dockerfile |
75 | Multi-stage: dev + build + prod with Chromium |
backend/package.json |
39 | 12 dependencies + 11 devDependencies |
backend/tsconfig.json |
23 | ES2020, strict mode, CommonJS output |
backend/src/index.ts |
51 | Express setup, middleware, route mounting, WA auto-init |
backend/src/config/database.ts |
41 | PostgreSQL pool with connection string support |
backend/src/services/WhatsAppService.ts |
341 | WA client singleton, message handling, media saving |
backend/src/services/WebSocketService.ts |
47 | Socket.io singleton |
backend/src/services/MediaService.ts |
455 | File org, thumbnails, transcripts, links |
backend/src/controllers/statusController.ts |
43 | Connection state management |
backend/src/controllers/chatController.ts |
127 | Chat list + contact details + DB archiving |
backend/src/controllers/messageController.ts |
124 | Message history + text/media sending |
backend/src/controllers/mediaController.ts |
347 | Media listing, sync, serve, delete, thumbnails |
backend/src/routes/statusRoutes.ts |
11 | 3 endpoints |
backend/src/routes/chatRoutes.ts |
10 | 2 endpoints |
backend/src/routes/messageRoutes.ts |
13 | 3 endpoints (1 with multer) |
backend/src/routes/mediaRoutes.ts |
14 | 7 endpoints |
Frontend Files
| File | Lines | Purpose |
|---|---|---|
frontend/Dockerfile |
39 | Multi-stage: dev (Vite) + build + prod (nginx) |
frontend/package.json |
31 | 6 dependencies + 8 devDependencies |
frontend/tsconfig.json |
~24 | Standard Vite React config |
frontend/tsconfig.node.json |
10 | Vite config compilation |
frontend/vite.config.ts |
26 | Proxy, aliases, source maps |
frontend/tailwind.config.js |
31 | WhatsApp dark mode color palette |
frontend/postcss.config.js |
6 | Tailwind + autoprefixer |
frontend/index.html |
13 | Entry HTML with emoji favicon |
frontend/src/main.tsx |
10 | React 18 createRoot |
frontend/src/App.tsx |
46 | Router + layout structure |
frontend/src/index.css |
33 | Tailwind + scrollbar styles |
frontend/src/types/index.ts |
105 | 10 TypeScript interfaces |
frontend/src/services/api.ts |
84 | 4 API modules, 14 methods |
frontend/src/contexts/WhatsAppContext.tsx |
233 | Global state, 7 socket handlers, 8 actions |
frontend/src/hooks/useSSE.ts |
~40 | SSE (EventSource) connection hook |
frontend/src/pages/LoginPage.tsx |
69 | QR code + connection UI |
frontend/src/pages/ChatPage.tsx |
64 | Chat sidebar + conversation layout |
frontend/src/pages/SettingsPage.tsx |
62 | Settings cards |
frontend/src/pages/MediaBrowserPage.tsx |
835 | Full media browser with all sub-components |
frontend/src/components/TopBar.tsx |
190 | Status bar + navigation |
frontend/src/components/ActionBar.tsx |
209 | Per-chat actions + sync result panel |
frontend/src/components/ChatList.tsx |
84 | Searchable list with archive toggle |
frontend/src/components/ChatListItem.tsx |
76 | Chat row with avatar, name, message, badge |
frontend/src/components/ConversationView.tsx |
55 | Message area + header + composer |
frontend/src/components/MessageBubble.tsx |
48 | Message display with ack ticks |
frontend/src/components/MessageComposer.tsx |
77 | Text + file input + send |
frontend/src/components/ContactProfile.tsx |
163 | Contact detail slide-over |
frontend/src/components/QRCode.tsx |
21 | QR code display |
Ports Summary
| Service | Port | Protocol |
|---|---|---|
| Frontend (Vite) | 3100 | HTTP |
| Backend (Express) | 3101 | HTTP + WebSocket |
| PostgreSQL | 5436 | TCP |
Key Lessons & Gotchas
- Express strict JSON mode rejects
null— Always send{}notnullas POST body withContent-Type: application/json - lucide-react icon availability varies by version — Always verify an icon export exists before using it. Missing icon = entire React app crashes (blank page)
- Chromium needs
shm_size: '1gb'in docker-compose — without this, Chromium crashes with shared memory errors wa_session_dataDocker volume is critical — this persists the WhatsApp session. Without it, you need to re-scan QR on every container restart- Stale Chromium lock files — after ungraceful shutdown, Puppeteer fails with “already running”. WhatsAppService cleans
SingletonLockfiles on init - WhatsApp video previews — available at
msg._data.mediaData?.preview?._b64, not officially documented. Used as video thumbnails - Vite proxy uses Docker service name —
http://wa-backend:3101because frontend container resolves Docker DNS, not localhost - useRef pattern for socket handlers —
activeChatRefavoids stale closure issue where socket handlers capture old state