DEV Community

v
v

Posted on • Edited on

chat app

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);
}

Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

});

// 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);
}
Enter fullscreen mode Exit fullscreen mode

});

// 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);
});
Enter fullscreen mode Exit fullscreen mode

});

const PORT = process.env.PORT || 3001;

server.listen(PORT, () => {
console.log(SERVER RUNNING ON PORT ${PORT});
});


Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Top comments (0)