full-stack-chat-app/
├── chat-app-backend/ # Node.js/Express Backend
│ ├── node_modules/ # Backend dependencies
│ ├── firebaseConfig.js # Firebase Client SDK Configuration
│ ├── package.json # Backend scripts and dependencies
│ ├── seed.js # Script to seed initial users and conversations
│ ├── server.js # Main Express server with Socket.io
│ └── serviceAccountKey.json # (Optional) Admin SDK key (deprecated in favor of Client SDK)
│
├── chat-app-frontend/ # React Frontend (Vite)
│ ├── node_modules/ # Frontend dependencies
│ ├── public/ # Static assets
│ ├── src/ # Source code
│ │ ├── assets/ # Images and local assets
│ │ ├── App.css # Component styles
│ │ ├── App.jsx # Main Chat Component
│ │ ├── index.css # Global styles
│ │ └── main.jsx # Entry point
│ ├── index.html # HTML template
│ ├── package.json # Frontend scripts and dependencies
│ └── vite.config.js # Vite configuration
│
└── firebase_schema.md # Database schema documentation
*firebaseConfig.js
*
const firebaseConfig = {
apiKey: "REPLACE_WITH_API_KEY",
authDomain: "REPLACE_WITH_AUTH_DOMAIN",
projectId: "REPLACE_WITH_PROJECT_ID",
storageBucket: "REPLACE_WITH_STORAGE_BUCKET",
messagingSenderId: "REPLACE_WITH_MESSAGING_SENDER_ID",
appId: "REPLACE_WITH_APP_ID",
measurementId: "REPLACE_WITH_MEASUREMENT_ID"
};
module.exports = firebaseConfig;
*seed.js
*
const { initializeApp } = require('firebase/app');
const { getFirestore, collection, doc, setDoc } = require('firebase/firestore');
const firebaseConfig = require('./firebaseConfig');
try {
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
const seedData = async () => {
console.log("Starting seed with Client SDK...");
// Seed Users
const users = [
{
uid: 'user1',
username: '3nvky',
profile_image: 'https://via.placeholder.com/150',
},
{
uid: 'user2',
username: 'GA7IU',
profile_image: 'https://via.placeholder.com/150',
}
];
for (const user of users) {
// setDoc with merge: true is slightly different in Web SDK
// setDoc(doc(db, "cities", "LA"), data, { merge: true });
await setDoc(doc(db, 'users', user.uid), user, { merge: true });
console.log(`User ${user.username} created/updated.`);
}
// Seed a Conversation
const conversationId = 'conv_user1_user2';
const conversation = {
conversationId: conversationId,
user1: 'user1',
user2: 'user2'
};
await setDoc(doc(db, 'conversations', conversationId), conversation, { merge: true });
console.log(`Conversation ${conversationId} created.`);
};
seedData().then(() => {
console.log("Seeding complete.");
process.exit(0);
}).catch(err => {
console.error("Seeding failed:", err.message);
if (err.message.includes("insufficient permissions")) {
console.warn("WARNING: You are using Client SDK. Ensure Firestore Rules are open (test mode) or allow writes.");
}
process.exit(1);
});
} catch (error) {
console.error("Error initializing:", error);
}
server.js
const express = require('express');
const http = require('http');
const { Server } = require("socket.io");
const cors = require('cors');
const dotenv = require('dotenv');
const { initializeApp } = require('firebase/app');
// // const { auth } = require('./firebaseConfig') // Removed unused auth import // Removed unused auth import
const { getFirestore, collection, addDoc, getDocs, query, where, orderBy, serverTimestamp, setDoc, doc } = require('firebase/firestore');
const firebaseConfig = require('./firebaseConfig');
dotenv.config();
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
});
app.use(cors());
// Increase limit for Base64 uploads
app.use(express.json({ limit: '10mb' }));
// --- Firebase Setup ---
let db;
try {
const appFirebase = initializeApp(firebaseConfig);
db = getFirestore(appFirebase);
console.log("Firebase initialized ");
} catch (error) {
console.warn("Error initializing Firebase.");
console.error(error.message);
}
// --- Seed Users ---
const seedUsers = async () => {
if (!db) return;
try {
const usersRef = collection(db, 'users');
const snapshot = await getDocs(usersRef);
if (snapshot.empty) {
console.log("Seeding users...");
const users = [
{ uid: 'user1', username: 'vivek', profile_image: 'https://via.placeholder.com/150?text=V' },
{ uid: 'user2', username: 'mihir', profile_image: 'https://via.placeholder.com/150?text=M' }
];
for (const user of users) {
// Use uid as doc ID for easy lookup if needed
await setDoc(doc(db, 'users', user.uid), user);
}
console.log("Users seeded.");
}
} catch (e) {
console.error("Error seeding users:", e);
}
}
seedUsers();
// --- API Endpoints ---
// 1. Get Users
app.get('/api/users', async (req, res, next) => {
if (!db) return res.json([]);
try {
const snapshot = await getDocs(collection(db, 'users'));
const users = [];
snapshot.forEach(doc => users.push(doc.data()));
res.json(users);
} catch (e) {
next(e);
}
});
const multer = require('multer');
// Configure Multer (Memory Storage)
const upload = multer({ storage: multer.memoryStorage() });
// 2. Create Message (Handling Text + File from FormData)
app.post('/api/messages', upload.single('file'), async (req, res, next) => {
try {
const { conversationId, content, senderId } = req.body;
// Handle file if present
let fileBase64 = null;
let fileName = null;
let fileType = null;
if (req.file) {
fileBase64 = `data:${req.file.mimetype};base64,${req.file.buffer.toString('base64')}`;
fileName = req.file.originalname;
fileType = req.file.mimetype;
}
if (!conversationId || !senderId) {
return res.status(400).json({ error: "Missing required fields" });
}
const messageData = {
conversationId,
content: content || "", // Content can be empty if file IS present
senderId,
message_at: serverTimestamp()
};
if (fileBase64) {
messageData.fileBase64 = fileBase64;
messageData.fileName = fileName;
messageData.fileType = fileType;
}
let savedMessage = { ...messageData, message_at: new Date() };
if (db) {
const docRef = await addDoc(collection(db, 'messages'), messageData);
savedMessage.messageId = docRef.id;
} else {
savedMessage.messageId = 'mock_id_' + Date.now();
}
// Emit via Socket.io for real-time
io.to(conversationId).emit('receive_message', savedMessage);
res.status(201).json(savedMessage);
} catch (e) {
next(e);
}
});
// 3. Message List
app.get('/api/messages/:conversationId', async (req, res, next) => {
try {
const { conversationId } = req.params;
if (!db) {
return res.json([]);
}
// Query
const q = query(
collection(db, 'messages'),
where('conversationId', '==', conversationId),
orderBy('message_at', 'asc')
);
const snapshot = await getDocs(q);
const messages = [];
snapshot.forEach(doc => {
messages.push({
messageId: doc.id,
...doc.data()
});
});
res.json(messages);
} catch (e) {
next(e);
}
});
// Global Error Handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: "Internal Server Error" });
});
// --- Socket.io ---
io.on('connection', (socket) => {
socket.on('join_conversation', (conversationId) => {
socket.join(conversationId);
console.log(User ${socket.id} joined conversation ${conversationId});
});
socket.on('disconnect', () => {
// console.log('user disconnected', socket.id);
});
});
const PORT = process.env.PORT || 3001;
server.listen(PORT, () => {
console.log(SERVER RUNNING ON PORT ${PORT});
});
app.jsx
import React, { useState, useEffect, useRef } from 'react';
import io from 'socket.io-client';
import axios from 'axios';
import { motion, AnimatePresence } from 'framer-motion';
import './index.css';
// Configuration
const SOCKET_URL = 'http://localhost:3001';
const API_URL = 'http://localhost:3001/api';
const CONVERSATION_ID = 'conv_user1_user2';
const MAX_FILE_SIZE_BYTES = 700 * 1024; // 700 KB
const socket = io(SOCKET_URL);
function App() {
const [messages, setMessages] = useState([]);
const [inputText, setInputText] = useState("");
const [users, setUsers] = useState([]); // Dynamic users
const [currentUser, setCurrentUser] = useState(null);
const [selectedFile, setSelectedFile] = useState(null); // { base64, name, type }
const messagesEndRef = useRef(null);
const fileInputRef = useRef(null);
useEffect(() => {
// Join Room
socket.emit('join_conversation', CONVERSATION_ID);
// Initial Load: Users & Messages
fetchUsers();
fetchMessages();
// Socket Listener
socket.on('receive_message', (newMessage) => {
setMessages((prev) => {
// Avoid duplicates if my message
if (prev.find(m => m.messageId === newMessage.messageId)) return prev;
return [...prev, newMessage];
});
scrollToBottom();
});
return () => {
socket.off('receive_message');
};
}, []);
const fetchUsers = async () => {
try {
const res = await axios.get(`${API_URL}/users`);
const userList = res.data;
if (userList.length > 0) {
setUsers(userList);
// Default to first user if not set, or maintain current if switching back/refresh logic existed
// For simple demo, just pick first as default
setCurrentUser(userList[0]);
}
} catch (err) {
console.error("Failed to load users", err);
}
}
const fetchMessages = async () => {
try {
const res = await axios.get(`${API_URL}/messages/${CONVERSATION_ID}`);
setMessages(res.data);
scrollToBottom();
} catch (err) {
console.error("Failed to load messages", err);
}
};
const scrollToBottom = () => {
minutesTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, 100);
};
// Custom timeout wrapper to avoid "variable not found" issues if any
const minutesTimeout = (fn, delay) => setTimeout(fn, delay);
const handleFileSelect = (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.size > MAX_FILE_SIZE_BYTES) {
alert("File size exceeds 700KB limit.");
e.target.value = null; // Reset input
return;
}
// Store raw file
setSelectedFile(file);
};
const removeFile = () => {
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const sendMessage = async (e) => {
e.preventDefault();
if (!inputText.trim() && !selectedFile) return;
if (!currentUser) return;
const formData = new FormData();
formData.append('conversationId', CONVERSATION_ID);
formData.append('senderId', currentUser.uid);
formData.append('content', inputText);
if (selectedFile) {
formData.append('file', selectedFile);
}
try {
await axios.post(`${API_URL}/messages`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
setInputText("");
removeFile();
} catch (err) {
console.error("Failed to send", err);
alert("Failed to send message. Potentially too large or server error.");
}
};
// Helper to format time
const formatTime = (timestamp) => {
if (!timestamp) return '';
// Handle Firestore Timestamp or JS Date string/obj
const date = new Date(timestamp.seconds ? timestamp.seconds * 1000 : timestamp);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
// Helper to Switch User
const switchUser = () => {
if (users.length < 2) return;
const currentIndex = users.findIndex(u => u.uid === currentUser.uid);
const nextIndex = (currentIndex + 1) % users.length;
setCurrentUser(users[nextIndex]);
}
// Get user details for rendering
const getUserParams = (uid) => {
return users.find(u => u.uid === uid) || { username: 'Unknown' };
}
if (!currentUser) return <div style={{ padding: 20 }}>Loading Chat...</div>;
return (
<div className="app-container" style={{ margin: 'auto', width: '100%', maxWidth: '800px', height: '90vh', backgroundColor: '#fff', borderRadius: '16px', boxShadow: '0 10px 30px rgba(0,0,0,0.1)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Header / Config for Demo */}
<div style={{ padding: '10px 20px', borderBottom: '1px solid #eee', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ fontWeight: 600 }}>Chat</h3>
<div>
<span style={{ fontSize: '12px', color: '#888', marginRight: '10px' }}>Current User: <b>{currentUser.username}</b></span>
{users.length > 1 && (
<button onClick={switchUser} style={{ fontSize: '12px', padding: '4px 8px', cursor: 'pointer' }}>
Switch User
</button>
)}
</div>
</div>
{/* Chat Area */}
<div className="chat-window" style={{ flex: 1, padding: '20px', overflowY: 'auto', backgroundColor: '#FAFAFA', display: 'flex', flexDirection: 'column', gap: '15px' }}>
<AnimatePresence>
{messages.map((msg, index) => {
const isMe = msg.senderId === currentUser.uid;
const senderUser = getUserParams(msg.senderId);
return (
<motion.div
key={msg.messageId || index}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
style={{
alignSelf: isMe ? 'flex-end' : 'flex-start',
maxWidth: '70%',
display: 'flex',
flexDirection: 'column',
alignItems: isMe ? 'flex-end' : 'flex-start'
}}
>
<div style={{
backgroundColor: isMe ? '#E8F5E9' : '#F1F1F1',
color: '#000',
padding: '12px 18px',
borderRadius: isMe ? '18px 18px 0 18px' : '18px 18px 18px 0',
boxShadow: '0 2px 5px rgba(0,0,0,0.02)',
position: 'relative',
minWidth: '120px'
}}>
{/* File Display */}
{msg.fileBase64 && (
<div style={{ marginBottom: '10px' }}>
{msg.fileType?.startsWith('image/') ? (
<img src={msg.fileBase64} alt="uploaded" style={{ maxWidth: '100%', borderRadius: '8px', border: '1px solid #ddd' }} />
) : (
<div style={{ backgroundColor: 'rgba(0,0,0,0.05)', padding: '10px', borderRadius: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '20px' }}>📄</span>
<a
href={msg.fileBase64}
download={msg.fileName || 'download'}
style={{ color: '#2196F3', textDecoration: 'none', fontSize: '14px', fontWeight: '500' }}
>
{msg.fileName || 'Download File'}
</a>
</div>
)}
</div>
)}
{msg.content && <div style={{ fontSize: '15px', lineHeight: '1.4', marginBottom: '4px' }}>{msg.content}</div>}
<div style={{ fontSize: '11px', color: '#888', textAlign: isMe ? 'right' : 'left' }}>
{senderUser.username} • {formatTime(msg.message_at)}
</div>
</div>
</motion.div>
);
})}
</AnimatePresence>
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<form onSubmit={sendMessage} style={{ padding: '20px', backgroundColor: '#fff', borderTop: '1px solid #eee', display: 'flex', flexDirection: 'column', gap: '10px' }}>
{selectedFile && (
<div style={{ fontSize: '12px', color: '#666', display: 'flex', justifyContent: 'space-between', alignItems: 'center', backgroundColor: '#f9f9f9', padding: '8px 12px', borderRadius: '6px' }}>
<span>Selected: <b>{selectedFile.name}</b> ({(selectedFile.size / 1024).toFixed(1)} KB)</span>
<button type="button" onClick={removeFile} style={{ border: 'none', background: 'transparent', color: 'red', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
</div>
)}
<div style={{ display: 'flex', gap: '10px' }}>
{/* File Input */}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileSelect}
accept="image/*,application/pdf"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
style={{
border: '1px solid #ddd',
background: '#f9f9f9',
padding: '0 15px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '18px'
}}
title="Upload File"
>
📎
</button>
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Type your message..."
style={{
flex: 1,
padding: '12px 15px',
borderRadius: '8px',
border: '1px solid #ddd',
outline: 'none',
fontSize: '15px'
}}
/>
<button
type="submit"
disabled={!inputText.trim() && !selectedFile}
style={{
backgroundColor: '#4CAF50',
color: 'white',
border: 'none',
borderRadius: '8px',
padding: '0 20px',
fontSize: '15px',
fontWeight: '600',
cursor: 'pointer',
opacity: (!inputText.trim() && !selectedFile) ? 0.7 : 1
}}
>
Send
</button>
</div>
</form>
</div>
);
}
export default App;
export default App;
Top comments (0)