Build a Private Family Chat App

Build a real-time family chat PWA with AI-powered document search.

Introduction

30 Second Summary

Every family has that moment where someone asks "Can you send me mom's Aadhaar number again?" and everyone scrambles through months of old messages and photos. What if your family had its own private chat app that could instantly find information buried in old document photos?

In this project, you will build a private family chat app with AI-powered search. Your family members will message each other in real-time, share documents and photos, and use an AI assistant to extract information like ID numbers straight from uploaded images.

What You'll Build

A Progressive Web App (PWA) built with Firebase and Google Gemini that works as a private family messaging platform with real-time chat, image sharing, and AI-powered document search.

By the end of this project, you'll have:

  • A real-time chat interface powered by Firestore where messages appear instantly across devices.
  • Image and document sharing stored securely in Firebase Storage with email/password authentication so only your family can access the app.
  • An AI-powered search bar that uses Google Gemini to read text from photos and answer questions about your chat history.
  • A live deployed app on Firebase Hosting that your family can install on their phones from a single URL.
  • Secret Mission: A Document Vault where family members can tag, categorize, and instantly retrieve important documents like IDs and bills.

Are there any prerequisites?

You should have basic HTML, CSS, and JavaScript knowledge. You also need a Google account to set up Firebase and get your Gemini API key. Everything else is covered in the project steps.

Set Up Firebase and Get Your API Keys

Your family chat app needs a backend to store messages, handle user logins, and host the app online. Firebase gives you all of this in one platform for free.

You also need an AI service that can read text from document photos and answer questions about your chat history. Google Gemini handles this. In this step, you'll create a Firebase project, turn on the services your app needs, and grab a Gemini API key.

In this step, get ready to:
  • Install the Firebase CLI and create a Firebase project.
  • Enable Authentication, Firestore, and Storage in your project.
  • Get a Gemini API key for AI-powered image search.
Install the Firebase CLI and create a project

The Firebase CLI is a command-line tool that lets you manage Firebase projects, run a local development server, and deploy your app. It requires Node.js to run.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Confirm that Node.js is installed by running this command:
node --version

I see a version number

You're all set. Any version 16.0 or higher works for this project.

Command not found

  • Go to nodejs.org and download the LTS version.
  • Run the installer and accept all default options.
  • Close and reopen your terminal.
  • Run node --version again to confirm the installation.
  • Install the Firebase CLI globally by running this command:
npm install -g firebase-tools

What does this command do?

  • npm install downloads a package from the Node.js package registry.
  • -g installs it globally so you can use the firebase command from any folder on your computer.
  • Verify the installation by running:
firebase --version

You should see a version number printed in your terminal.

Seeing 'command not found'?

  • Close and reopen your terminal, then try the command again.
  • On macOS, you may need to run sudo npm install -g firebase-tools and enter your computer password.
  • help me fix 'firebase command not found' after installing firebase-tools
  • Log in to Firebase by running this command:
firebase login
  • Select your Google account in the browser window that opens.
  • Click Allow to grant the Firebase CLI access to your account.

You should see a Success! Logged in as message followed by your email address in the terminal.

Browser didn't open?

  • Copy the URL shown in your terminal and paste it into your browser manually.
  • If you're behind a firewall or VPN, try running firebase login --no-localhost instead.
  • help me log in to the Firebase CLI

Now you'll create a Firebase project in the browser. This project is the container for all the backend services your app will use.

  • Open console.firebase.google.com in your browser.
  • Click Create a project (or Add project if you already have existing projects).
  • Enter family-chat as the project name.
  • Toggle Enable Google Analytics for this project off. You don't need analytics for this project.
  • Click Create project.
  • Wait for the project to finish creating, then click Continue.

You land on your project's overview page. Next, you'll register a web app so Firebase generates the configuration keys your code needs to connect.

  • Click the gear icon next to Project Overview in the sidebar to open Project settings.
  • Scroll down to the Your apps section.
  • Click the web icon (</>) to register a new web app.
  • Enter family-chat-web as the app nickname.
  • Check the Also set up Firebase Hosting for this app checkbox.
  • Click Register app.

Firebase displays a code snippet containing your firebaseConfig object. This object has your project's unique keys and IDs.

  • Copy the entire firebaseConfig object shown on screen.
  • Paste it into a text file or notes app and save it. You'll use this in a later step.
  • Click Continue to console.

Pro tip: keep a setup notes file

Create a file called setup-notes.txt on your Desktop and paste your firebaseConfig there. You'll add your Gemini API key to this file shortly. Having both in one place makes the next steps faster.

Enable Firebase services

Your app needs three Firebase services. Firebase Authentication handles user logins. Firestore stores your chat messages. Firebase Storage holds uploaded images.

You'll turn on each service one at a time in the Firebase Console.

  • In the Firebase Console sidebar, click Build to expand the menu.
  • Click Authentication.
  • Click Get started.
  • In the Sign-in method tab, click Email/Password.
  • Toggle the Enable switch on.
  • Click Save.

Email/Password now appears as Enabled in the providers list. Next, set up Firestore.

  • In the sidebar under Build, click Firestore Database.
  • Click Create database.
  • Select Start in test mode.
  • Click Next.
  • Choose a Cloud Firestore location close to you (for example, us-central1 if you're in the US).
  • Click Enable.

Firestore takes a moment to provision. Once it's ready, you see an empty database dashboard. Now set up Storage.

  • In the sidebar under Build, click Storage.
  • Click Get started.
  • Select Start in test mode.
  • Click Next.
  • Click Done.

What is test mode?

Test mode lets anyone read and write to your database and storage for 30 days. This makes development easy because you don't need to set up security rules right away. You'll add proper security rules in a later step to lock your data down before sharing the app.

Can't find the Build section?

  • Make sure you're in your family-chat project, not the Firebase home screen. The project name appears at the top of the sidebar.
  • If the sidebar is collapsed, click the hamburger menu icon (three horizontal lines) at the top left to expand it.
  • help me find the Build section in the Firebase Console
Get a Gemini API key

Gemini is Google's AI model that can understand both text and images. Your app will send document photos to Gemini and ask it to read the text inside them.

To use Gemini, you need an API key from Google AI Studio.

  • Open aistudio.google.com in your browser.
  • Sign in with your Google account.
  • Click Get API Key in the top navigation bar.
  • Click Create API Key.
  • Select your Google Cloud project (or let it create a new one for you).
  • Copy the API key that appears on screen.
  • Paste the key into the same setup-notes.txt file where you saved your Firebase config.

Keep your API key private

Your Gemini API key is linked to your Google account. Never share it publicly or commit it to a public GitHub repository. For this private family app, storing the key in your client-side code is fine. The free tier rate limits (10 requests per minute, 250 per day) prevent misuse.

Why Gemini instead of a traditional OCR library?

Traditional OCR libraries like Tesseract.js struggle with complex document photos, achieving 47-85% accuracy on things like ID cards. Gemini's vision capabilities score 84-98% on similar documents because it understands context, not just individual characters.

Can't create an API key?

  • Make sure you're signed into the same Google account you used for Firebase.
  • If you see a billing prompt, select the free tier option. The Gemini free tier does not require a credit card.
  • help me get a Gemini API key from Google AI Studio

Your backend is ready and your keys are saved. Next up, you'll build the chat interface and start writing the code that brings it all to life.

Build the Chat Interface

Your Firebase project is configured with authentication, database, and storage services ready to go. Now it's time to build the interface your family will see and use every day.

You'll create four files in this step. An HTML page with the login and chat layouts, a CSS stylesheet for mobile-friendly message bubbles, a PWA manifest for phone installation, and a service worker for offline caching.

In this step, get ready to:
  • Set up the project folder with Firebase Hosting.
  • Build and style a chat interface with login, messages, and search sections.
  • Add PWA configuration files so the app can be installed on phones.
Initialize your project and create the HTML structure

Firebase Hosting serves your app's files from a public folder. You'll create your project folder, initialize Firebase Hosting in it, and then build the HTML layout with a login screen, chat area, and AI search bar.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir family-chat
cd family-chat
  • Initialize Firebase in your project by running:
firebase init

What will firebase init ask you?

The firebase init command launches an interactive setup in your terminal. Here's how to answer each prompt:

  • Which Firebase features do you want to set up? Use arrow keys to highlight Hosting: Configure files for Firebase Hosting, press Space to select it, then press Enter.
  • Project setup? Select Use an existing project and choose the Firebase project you created in Step 1.
  • What do you want to use as your public directory? Type public and press Enter.
  • Configure as a single-page app? Type y and press Enter.
  • Set up automatic builds and deploys with GitHub? Type N and press Enter.
  • Confirm the setup worked by running:
ls public

You should see index.html and 404.html listed in the output. Firebase generated these default files for you.

Seeing "command not found" for firebase?

  • Make sure you installed the Firebase CLI in Step 1 with npm install -g firebase-tools.
  • Make sure you ran firebase login and signed in with your Google account before running firebase init.
  • Try closing your terminal and opening a new one, then run firebase init again.
  • Help me troubleshoot the firebase init command.

Now open VS Code so you can edit the project files.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the family-chat folder on your Desktop, then click Open.

You'll now replace the default index.html with your chat app layout.

  • In the Explorer sidebar on the left, expand the public folder and click index.html to open it.
  • Select all the existing content by pressing Cmd+A (macOS) or Ctrl+A (Windows).
  • Replace everything with this code:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Family Chat</title>
  <link rel="stylesheet" href="style.css">
  <!-- PWA manifest and mobile web app settings -->
  <link rel="manifest" href="manifest.json">
  <meta name="theme-color" content="#1a73e8">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
</head>
<body>

  <!-- Login Screen - visible by default until user authenticates -->
  <div id="auth-screen">
    <div class="auth-container">
      <h1>๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Family Chat</h1>
      <p>Private messaging with AI search</p>
      <input type="email" id="email" placeholder="Email" />
      <input type="password" id="password" placeholder="Password" />
      <button id="login-btn">Log In</button>
      <button id="signup-btn">Sign Up</button>
      <p id="auth-error" class="error"></p>
    </div>
  </div>

  <!-- Chat Screen - hidden by default, shown after login -->
  <div id="chat-screen" class="hidden">
    <!-- AI search bar at the top of the chat -->
    <div id="search-bar">
      <input type="text" id="search-input" placeholder="Ask AI: e.g. mom's Aadhaar number..." />
      <button id="search-btn">๐Ÿ”</button>
    </div>
    <!-- Search results panel - hidden until a search is performed -->
    <div id="search-results" class="hidden">
      <div id="search-results-content"></div>
      <button id="close-search">โœ• Close</button>
    </div>

    <!-- Chat header with title and logout -->
    <div id="chat-header">
      <h2>Family Chat</h2>
      <button id="logout-btn">Logout</button>
    </div>

    <!-- Message list - chat bubbles render here -->
    <div id="messages"></div>

    <!-- Input bar at the bottom for typing and sending -->
    <div id="input-bar">
      <label for="image-input" id="image-label">๐Ÿ“ท</label>
      <input type="file" id="image-input" accept="image/*" class="hidden" />
      <input type="text" id="message-input" placeholder="Type a message..." />
      <button id="send-btn">Send</button>
    </div>

    <!-- Image preview overlay shown after selecting a photo -->
    <div id="image-preview" class="hidden">
      <img id="preview-img" src="" alt="Preview" />
      <button id="cancel-image">โœ•</button>
    </div>
  </div>

  <!-- App logic loaded as an ES module -->
  <script type="module" src="app.js"></script>
</body>
</html>

What does this HTML do?

  • The #auth-screen div is the login form your family sees first. It has email and password inputs plus Log In and Sign Up buttons.
  • The #chat-screen div is the main chat interface. It starts hidden with the hidden class until a user logs in.
  • Inside the chat screen, #search-bar is where users type AI search queries. #messages is the container where chat bubbles will appear. #input-bar holds the text input and send button.
  • The <script type="module" src="app.js"> tag loads the JavaScript you'll write in the next step. The file doesn't exist yet, so you'll see a console error if you open this page now.
  • Save index.html.

You won't see a visual change yet because the stylesheet hasn't been created. The login screen comes to life after you add CSS in the next substep.

Style the app to look like a messaging app

A chat app needs to feel familiar. You'll create a stylesheet that gives your app a messaging-app look with a gradient login screen, color-coded message bubbles, and a fixed input bar at the bottom.

  • In the Explorer sidebar, right-click the public folder and select New File.
  • Name the file style.css and press Enter.
  • Add the base styles and login screen styling by pasting this code:
/* Reset default browser margins and padding */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* Full-height app with no scrolling on the body */
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: #e5ddd5;
  height: 100vh;
  overflow: hidden;
}

/* Utility class toggled by JavaScript to show/hide elements */
.hidden {
  display: none !important;
}

/* Error message styling for the login form */
.error {
  color: #d32f2f;
  font-size: 0.85rem;
  margin-top: 8px;
}

/* Login screen - centered on a blue gradient background */
#auth-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  background: linear-gradient(135deg, #1a73e8, #0d47a1);
}

/* White card container for the login form */
.auth-container {
  background: white;
  padding: 40px;
  border-radius: 12px;
  text-align: center;
  width: 90%;
  max-width: 360px;
  box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}

.auth-container h1 {
  font-size: 1.5rem;
  margin-bottom: 8px;
}

.auth-container p {
  color: #666;
  margin-bottom: 24px;
  font-size: 0.9rem;
}

/* Full-width inputs for email and password */
.auth-container input {
  width: 100%;
  padding: 12px;
  margin-bottom: 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 1rem;
}

/* Shared button styling for login form */
.auth-container button {
  width: 100%;
  padding: 12px;
  border: none;
  border-radius: 8px;
  font-size: 1rem;
  cursor: pointer;
  margin-bottom: 8px;
}

/* Blue background for the login button */
#login-btn {
  background: #1a73e8;
  color: white;
}

/* Gray background for the signup button */
#signup-btn {
  background: #f1f3f4;
  color: #333;
}

What do these styles do?

  • The * reset removes default browser spacing so every element starts from a clean slate.
  • body fills the full viewport height and prevents scrolling. The chat layout manages its own scrolling internally.
  • .hidden is a utility class that JavaScript toggles to show and hide screens.
  • #auth-screen uses Flexbox to center the login card on a blue gradient background.
  • Save style.css.
  • Open the public folder inside family-chat on your Desktop.
  • Double-click index.html to open it in your browser.

You should see a blue gradient background with a centered white card containing the "Family Chat" title, email and password fields, and two buttons.

Don't see the styled login screen?

  • Make sure style.css is saved inside the public folder, not in the family-chat root folder.
  • Check that the filename is exactly style.css. The HTML references this exact name in the <link> tag.
  • Ignore any red console errors about app.js not being found. That file doesn't exist yet and won't affect the visual layout.
  • Help me debug why my login screen isn't styled.

Now add the styles for the chat interface itself. These styles control the message bubbles, search bar, and input area that appear after a user logs in.

  • Back in style.css, add the chat interface styles below the existing code:
/* Chat screen - vertical flexbox layout filling the viewport */
#chat-screen {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

/* AI search bar pinned to the top */
#search-bar {
  display: flex;
  padding: 8px;
  background: #075e54;
  gap: 8px;
}

#search-input {
  flex: 1;
  padding: 10px 14px;
  border: none;
  border-radius: 20px;
  font-size: 0.9rem;
}

#search-btn {
  background: #25d366;
  border: none;
  border-radius: 50%;
  width: 40px;
  height: 40px;
  cursor: pointer;
  font-size: 1.1rem;
}

/* Search results panel that slides open below the search bar */
#search-results {
  background: #fff;
  padding: 16px;
  border-bottom: 2px solid #075e54;
  max-height: 200px;
  overflow-y: auto;
  font-size: 0.9rem;
  line-height: 1.5;
}

#search-results-content {
  white-space: pre-wrap;
}

#close-search {
  margin-top: 8px;
  background: none;
  border: 1px solid #ccc;
  padding: 4px 12px;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.8rem;
}

/* Chat header bar with app title and logout */
#chat-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  background: #075e54;
  color: white;
}

#chat-header h2 {
  font-size: 1.1rem;
}

#logout-btn {
  background: none;
  border: 1px solid rgba(255,255,255,0.5);
  color: white;
  padding: 6px 12px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.8rem;
}

/* Message list - scrollable area for chat bubbles */
#messages {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* Individual message bubble */
.message {
  max-width: 75%;
  padding: 8px 12px;
  border-radius: 12px;
  font-size: 0.95rem;
  line-height: 1.4;
  word-wrap: break-word;
}

/* Sent messages - green, right-aligned */
.message.sent {
  align-self: flex-end;
  background: #dcf8c6;
  border-bottom-right-radius: 4px;
}

/* Received messages - white, left-aligned */
.message.received {
  align-self: flex-start;
  background: white;
  border-bottom-left-radius: 4px;
}

/* Sender name label on received messages */
.message .sender {
  font-size: 0.75rem;
  font-weight: 600;
  color: #075e54;
  margin-bottom: 2px;
}

/* Timestamp displayed below each message */
.message .time {
  font-size: 0.7rem;
  color: #999;
  text-align: right;
  margin-top: 4px;
}

/* Images shared inside chat bubbles */
.message img {
  max-width: 250px;
  width: 100%;
  border-radius: 8px;
  margin-top: 4px;
  cursor: pointer;
}

/* Input bar fixed at the bottom of the chat */
#input-bar {
  display: flex;
  padding: 8px;
  background: #f0f0f0;
  gap: 8px;
  align-items: center;
}

/* Camera icon button for image uploads */
#image-label {
  font-size: 1.4rem;
  cursor: pointer;
  padding: 4px;
}

/* Message text input field */
#message-input {
  flex: 1;
  padding: 10px 14px;
  border: none;
  border-radius: 20px;
  font-size: 0.95rem;
}

/* Send button */
#send-btn {
  background: #075e54;
  color: white;
  border: none;
  border-radius: 20px;
  padding: 10px 20px;
  cursor: pointer;
  font-size: 0.9rem;
}

/* Image preview overlay shown before sending a photo */
#image-preview {
  position: fixed;
  bottom: 60px;
  left: 8px;
  right: 8px;
  background: white;
  padding: 12px;
  border-radius: 12px;
  box-shadow: 0 -2px 12px rgba(0,0,0,0.15);
  display: flex;
  align-items: center;
  gap: 12px;
}

#preview-img {
  max-height: 80px;
  border-radius: 8px;
}

/* Cancel button on the image preview */
#cancel-image {
  background: #d32f2f;
  color: white;
  border: none;
  border-radius: 50%;
  width: 30px;
  height: 30px;
  cursor: pointer;
  font-size: 1rem;
}

/* Loading state for AI search results */
.loading {
  color: #666;
  font-style: italic;
}

What do these styles do?

  • #chat-screen uses Flexbox in a column direction so the search bar stays at the top, messages fill the middle, and the input bar sticks to the bottom.
  • .message.sent and .message.received create the familiar messaging-app look. Sent messages are green and right-aligned. Received messages are white and left-aligned.
  • #messages has overflow-y: auto so the message list scrolls independently while the input bar stays fixed at the bottom.
  • .message img limits shared photos to 250px wide so they fit neatly inside chat bubbles.
  • Save style.css.

The chat interface styles apply to elements that start hidden. You'll see the full chat layout once you add JavaScript and log in during the next step.

Awesome, I've got everything!

Double check that you've saved style.css and move on to the next substep.

I'd like to double check the full code

Here is the complete style.css file with both chunks combined.

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: #e5ddd5;
  height: 100vh;
  overflow: hidden;
}

.hidden {
  display: none !important;
}

.error {
  color: #d32f2f;
  font-size: 0.85rem;
  margin-top: 8px;
}

/* Auth Screen */
#auth-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  background: linear-gradient(135deg, #1a73e8, #0d47a1);
}

.auth-container {
  background: white;
  padding: 40px;
  border-radius: 12px;
  text-align: center;
  width: 90%;
  max-width: 360px;
  box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}

.auth-container h1 {
  font-size: 1.5rem;
  margin-bottom: 8px;
}

.auth-container p {
  color: #666;
  margin-bottom: 24px;
  font-size: 0.9rem;
}

.auth-container input {
  width: 100%;
  padding: 12px;
  margin-bottom: 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 1rem;
}

.auth-container button {
  width: 100%;
  padding: 12px;
  border: none;
  border-radius: 8px;
  font-size: 1rem;
  cursor: pointer;
  margin-bottom: 8px;
}

#login-btn {
  background: #1a73e8;
  color: white;
}

#signup-btn {
  background: #f1f3f4;
  color: #333;
}

/* Chat Screen */
#chat-screen {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

#search-bar {
  display: flex;
  padding: 8px;
  background: #075e54;
  gap: 8px;
}

#search-input {
  flex: 1;
  padding: 10px 14px;
  border: none;
  border-radius: 20px;
  font-size: 0.9rem;
}

#search-btn {
  background: #25d366;
  border: none;
  border-radius: 50%;
  width: 40px;
  height: 40px;
  cursor: pointer;
  font-size: 1.1rem;
}

#search-results {
  background: #fff;
  padding: 16px;
  border-bottom: 2px solid #075e54;
  max-height: 200px;
  overflow-y: auto;
  font-size: 0.9rem;
  line-height: 1.5;
}

#search-results-content {
  white-space: pre-wrap;
}

#close-search {
  margin-top: 8px;
  background: none;
  border: 1px solid #ccc;
  padding: 4px 12px;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.8rem;
}

#chat-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  background: #075e54;
  color: white;
}

#chat-header h2 {
  font-size: 1.1rem;
}

#logout-btn {
  background: none;
  border: 1px solid rgba(255,255,255,0.5);
  color: white;
  padding: 6px 12px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.8rem;
}

/* Messages */
#messages {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.message {
  max-width: 75%;
  padding: 8px 12px;
  border-radius: 12px;
  font-size: 0.95rem;
  line-height: 1.4;
  word-wrap: break-word;
}

.message.sent {
  align-self: flex-end;
  background: #dcf8c6;
  border-bottom-right-radius: 4px;
}

.message.received {
  align-self: flex-start;
  background: white;
  border-bottom-left-radius: 4px;
}

.message .sender {
  font-size: 0.75rem;
  font-weight: 600;
  color: #075e54;
  margin-bottom: 2px;
}

.message .time {
  font-size: 0.7rem;
  color: #999;
  text-align: right;
  margin-top: 4px;
}

.message img {
  max-width: 250px;
  width: 100%;
  border-radius: 8px;
  margin-top: 4px;
  cursor: pointer;
}

/* Input Bar */
#input-bar {
  display: flex;
  padding: 8px;
  background: #f0f0f0;
  gap: 8px;
  align-items: center;
}

#image-label {
  font-size: 1.4rem;
  cursor: pointer;
  padding: 4px;
}

#message-input {
  flex: 1;
  padding: 10px 14px;
  border: none;
  border-radius: 20px;
  font-size: 0.95rem;
}

#send-btn {
  background: #075e54;
  color: white;
  border: none;
  border-radius: 20px;
  padding: 10px 20px;
  cursor: pointer;
  font-size: 0.9rem;
}

/* Image Preview */
#image-preview {
  position: fixed;
  bottom: 60px;
  left: 8px;
  right: 8px;
  background: white;
  padding: 12px;
  border-radius: 12px;
  box-shadow: 0 -2px 12px rgba(0,0,0,0.15);
  display: flex;
  align-items: center;
  gap: 12px;
}

#preview-img {
  max-height: 80px;
  border-radius: 8px;
}

#cancel-image {
  background: #d32f2f;
  color: white;
  border: none;
  border-radius: 50%;
  width: 30px;
  height: 30px;
  cursor: pointer;
  font-size: 1rem;
}

/* Loading spinner for AI search */
.loading {
  color: #666;
  font-style: italic;
}
Add the PWA manifest and service worker

A Progressive Web App needs two configuration files. A manifest tells the browser how to display the app when installed on a phone's home screen. A service worker caches your files so the app loads quickly on repeat visits.

  • In the Explorer sidebar, right-click the public folder and select New File.
  • Name the file manifest.json and press Enter.
  • Paste this configuration into the file:
{
  "name": "Family Chat",
  "short_name": "FamilyChat",
  "description": "Private family messaging with AI-powered document search",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#075e54",
  "icons": [
    {
      "src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ</text></svg>",
      "sizes": "any",
      "type": "image/svg+xml"
    }
  ]
}

What does this manifest do?

  • display: "standalone" makes the app open without a browser address bar, so it looks and feels like a native app.
  • theme_color sets the status bar color on mobile devices to match the dark green chat header.
  • The icon uses an SVG data URI with a family emoji so you don't need to create a separate image file.
  • Save manifest.json.

Now create the service worker that caches your app files.

  • Right-click the public folder again and select New File.
  • Name the file sw.js and press Enter.
  • Paste this code into the file:
// Cache name - update the version to refresh cached files
const CACHE_NAME = 'family-chat-v1';

// Files to cache for offline access
const urlsToCache = [
  '/',
  '/index.html',
  '/style.css',
  '/app.js'
];

// Cache app files when the service worker installs
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.addAll(urlsToCache))
  );
});

// Serve cached files first, fall back to network
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => response || fetch(event.request))
  );
});

What does this service worker do?

  • When the service worker installs, it opens a cache and stores your HTML, CSS, and JavaScript files.
  • On future visits, the fetch listener checks the cache first. If a file is cached, it loads instantly without a network request. If not, it falls back to fetching from the network.
  • Save sw.js.

Where are these files connected?

The index.html file already includes <link rel="manifest" href="manifest.json"> in the <head> section. The service worker registration code will go in app.js, which you'll create in the next step.

You won't see any visible changes from the manifest or service worker yet. The PWA features activate once app.js registers the service worker and the app is deployed.

Your chat interface is built and styled. In the next step, you'll bring it to life with Firebase authentication and real-time messaging.

Add Authentication and Real-Time Messaging

Your chat interface has a login screen, message area, and input bar. Right now it's all static HTML and CSS with no logic behind it.

In this step, you'll bring the app to life by wiring up Firebase Authentication for login and signup. You'll also connect Firestore's real-time listener so messages appear instantly across all devices without anyone hitting refresh.

In this step, get ready to:
  • Initialize Firebase and connect it to your app.
  • Set up email/password authentication with login and signup.
  • Implement real-time message sending and receiving with Firestore.
Initialize Firebase and import the SDK modules

Your app needs a connection to your Firebase project before any authentication or database features work. You'll import the Firebase modules directly from a CDN, so there's no need for a bundler like Webpack.

  • Create a new file called app.js inside your public folder.
  • Add the Firebase imports and configuration by pasting this code into app.js:
import { initializeApp } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-app.js';
import { getAuth, onAuthStateChanged, signInWithEmailAndPassword, createUserWithEmailAndPassword, signOut } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-auth.js';
import { getFirestore, collection, addDoc, query, orderBy, onSnapshot, serverTimestamp, limit } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-firestore.js';

// Firebase config - Replace with YOUR config from Firebase Console
const firebaseConfig = {
  apiKey: "your-firebase-api-key-here",
  authDomain: "your-project-id.firebaseapp.com",
  projectId: "your-project-id",
  storageBucket: "your-project-id.firebasestorage.app",
  messagingSenderId: "your-sender-id",
  appId: "your-app-id"
};

// Initialize Firebase services
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);

What does this code do?

  • The three import lines pull in Firebase's Auth, Firestore, and App modules directly from Google's CDN. No npm install needed.
  • firebaseConfig tells Firebase which project to connect to. You'll replace these placeholder values with your own config.
  • initializeApp(firebaseConfig) creates the connection. getAuth() and getFirestore() give you references to the Authentication and Database services.
  • Replace every value inside firebaseConfig with the values you copied from the Firebase Console in Step 1.

Pro tip: find your config again

If you lost your config, go to the Firebase Console. Click the gear icon next to Project Overview, then Project settings. Scroll down to Your apps and you'll find the firebaseConfig object.

You won't see a change in the browser yet. The next chunk adds the authentication logic that uses these imports.

Set up authentication with login, signup, and logout

Firebase Auth manages user accounts for you. Your app needs to react when someone logs in or out, and connect the login/signup buttons to Firebase's authentication methods.

  • In app.js, add the DOM element references and the auth state listener below your Firebase initialization code:
// DOM elements
const authScreen = document.getElementById('auth-screen');
const chatScreen = document.getElementById('chat-screen');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const loginBtn = document.getElementById('login-btn');
const signupBtn = document.getElementById('signup-btn');
const authError = document.getElementById('auth-error');
const logoutBtn = document.getElementById('logout-btn');
const messagesDiv = document.getElementById('messages');
const messageInput = document.getElementById('message-input');
const sendBtn = document.getElementById('send-btn');

let currentUser = null;

// Auth state listener - show chat when logged in, login screen when logged out
onAuthStateChanged(auth, (user) => {
  if (user) {
    currentUser = user;
    authScreen.classList.add('hidden');
    chatScreen.classList.remove('hidden');
    loadMessages();
  } else {
    currentUser = null;
    authScreen.classList.remove('hidden');
    chatScreen.classList.add('hidden');
  }
});

What does this code do?

  • The document.getElementById() calls grab references to the HTML elements you built in Step 2 so JavaScript can control them.
  • onAuthStateChanged() is a Firebase listener that fires every time a user logs in or out. It's the gatekeeper for your entire app.
  • When a user is logged in, the code hides the login screen and shows the chat screen. When they log out, it flips them back.
  • loadMessages() is called on login to start the real-time message feed. You'll define this function in the next substep.
  • Below the auth state listener, add the login, signup, and logout handlers:
// Login
loginBtn.addEventListener('click', async () => {
  const email = emailInput.value.trim();
  const password = passwordInput.value.trim();
  if (!email || !password) {
    authError.textContent = 'Please enter email and password.';
    return;
  }
  try {
    await signInWithEmailAndPassword(auth, email, password);
    authError.textContent = '';
  } catch (error) {
    authError.textContent = error.message;
  }
});

// Sign up
signupBtn.addEventListener('click', async () => {
  const email = emailInput.value.trim();
  const password = passwordInput.value.trim();
  if (!email || !password) {
    authError.textContent = 'Please enter email and password.';
    return;
  }
  if (password.length < 6) {
    authError.textContent = 'Password must be at least 6 characters.';
    return;
  }
  try {
    await createUserWithEmailAndPassword(auth, email, password);
    authError.textContent = '';
  } catch (error) {
    authError.textContent = error.message;
  }
});

// Logout
logoutBtn.addEventListener('click', () => signOut(auth));

What does this code do?

  • The login handler calls signInWithEmailAndPassword() with whatever the user typed. Firebase checks the credentials on its servers.
  • The signup handler calls createUserWithEmailAndPassword() to create a new account. It also checks that the password is at least 6 characters, which is Firebase's minimum requirement.
  • Both handlers use try/catch to display error messages (like "email already in use" or "wrong password") to the user.
  • The logout handler calls signOut(). This triggers the onAuthStateChanged listener above, which flips the user back to the login screen.
  • Save app.js.

The login form won't work just yet because loadMessages() hasn't been defined. You'll add that next.

Seeing a syntax error in the console?

  • Make sure every opening brace { has a matching closing brace }. The login and signup handlers each have nested try/catch blocks, so count your braces carefully.
  • Check that your firebaseConfig values are wrapped in double quotes. Each value is a string.
  • Confirm your index.html loads app.js with type="module". Firebase CDN imports only work with ES modules.

Help me debug a syntax error in my Firebase auth code.

Implement real-time message sending and receiving

A chat app needs to show new messages the instant they're sent. Firestore's onSnapshot() listener does exactly this. Instead of your app asking "any new messages?" over and over, Firestore pushes updates to your app the moment data changes.

  • In app.js, add the message loading and rendering functions below your logout handler:
// Load messages with real-time listener
function loadMessages() {
  const q = query(collection(db, 'messages'), orderBy('timestamp', 'asc'), limit(200));
  onSnapshot(q, (snapshot) => {
    messagesDiv.innerHTML = '';
    snapshot.forEach((doc) => {
      const msg = doc.data();
      renderMessage(msg);
    });
    messagesDiv.scrollTop = messagesDiv.scrollHeight;
  });
}

// Render a single message as a chat bubble
function renderMessage(msg) {
  const div = document.createElement('div');
  const isSent = msg.uid === currentUser.uid;
  div.className = `message ${isSent ? 'sent' : 'received'}`;

  let html = '';
  if (!isSent) {
    const senderName = msg.displayName ? msg.displayName.split('@')[0] : 'Unknown';
    html += `<div class="sender">${senderName}</div>`;
  }

  if (msg.imageUrl) {
    html += `<img src="${msg.imageUrl}" alt="Shared image" onclick="window.open('${msg.imageUrl}', '_blank')" />`;
  }

  if (msg.text) {
    html += `<div>${msg.text}</div>`;
  }

  if (msg.timestamp) {
    const date = msg.timestamp.toDate();
    const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
    html += `<div class="time">${timeStr}</div>`;
  }

  div.innerHTML = html;
  messagesDiv.appendChild(div);
}

What does this code do?

  • loadMessages() creates a Firestore query that pulls up to 200 messages from the messages collection, sorted oldest to newest.
  • onSnapshot() is the magic. It fires the callback immediately with current data, then fires again every time any message is added, changed, or deleted. Your chat updates in real-time without polling.
  • renderMessage() builds a chat bubble for each message. It checks msg.uid against the logged-in user to decide whether the bubble aligns left (received) or right (sent).
  • The msg.imageUrl check is there for image messages you'll add in the next step. For now, it won't render anything because text messages have no image URL.
  • messagesDiv.scrollTop = messagesDiv.scrollHeight automatically scrolls to the newest message every time the feed updates.

The message display is ready. The next chunk adds the send function.

  • Below the renderMessage() function, add the send message handler and service worker registration:
// Send message event listeners
sendBtn.addEventListener('click', sendMessage);
messageInput.addEventListener('keypress', (e) => {
  if (e.key === 'Enter') sendMessage();
});

// Send a text message to Firestore
async function sendMessage() {
  const text = messageInput.value.trim();
  if (!text) return;

  await addDoc(collection(db, 'messages'), {
    text: text,
    uid: currentUser.uid,
    displayName: currentUser.email,
    imageUrl: null,
    timestamp: serverTimestamp()
  });

  messageInput.value = '';
}

// Register service worker for PWA support
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(() => console.log('Service Worker registered'))
    .catch((err) => console.log('SW registration failed:', err));
}

What does this code do?

  • Two event listeners trigger sendMessage(). One fires when the user clicks the Send button. The other fires when they press Enter.
  • sendMessage() uses addDoc() to create a new document in the Firestore messages collection. Each message stores the text, the sender's user ID and email, and a server-side timestamp.
  • serverTimestamp() tells Firestore to set the time on its own servers, not the user's device. This keeps message ordering consistent across time zones.
  • The service worker registration at the bottom enables the PWA caching you set up in the previous step.
  • Save app.js.

Seeing 'loadMessages is not defined'?

  • Make sure the loadMessages() function definition appears in your file. The onAuthStateChanged listener calls it, so it must exist.
  • Check that you didn't accidentally nest loadMessages() inside another function's curly braces. It should be a top-level function.

Help me fix a 'not defined' error in my chat app code.

Awesome, I've got everything!

Great. Double check you've saved app.js before moving on to testing.

I'd like to double check the full code

Here's what your complete app.js file should look like so far.

import { initializeApp } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-app.js';
import { getAuth, onAuthStateChanged, signInWithEmailAndPassword, createUserWithEmailAndPassword, signOut } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-auth.js';
import { getFirestore, collection, addDoc, query, orderBy, onSnapshot, serverTimestamp, limit } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-firestore.js';

// Firebase config - Replace with YOUR config from Firebase Console
const firebaseConfig = {
  apiKey: "your-firebase-api-key-here",
  authDomain: "your-project-id.firebaseapp.com",
  projectId: "your-project-id",
  storageBucket: "your-project-id.firebasestorage.app",
  messagingSenderId: "your-sender-id",
  appId: "your-app-id"
};

// Initialize Firebase services
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);

// DOM elements
const authScreen = document.getElementById('auth-screen');
const chatScreen = document.getElementById('chat-screen');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const loginBtn = document.getElementById('login-btn');
const signupBtn = document.getElementById('signup-btn');
const authError = document.getElementById('auth-error');
const logoutBtn = document.getElementById('logout-btn');
const messagesDiv = document.getElementById('messages');
const messageInput = document.getElementById('message-input');
const sendBtn = document.getElementById('send-btn');

let currentUser = null;

// Auth state listener - show chat when logged in, login screen when logged out
onAuthStateChanged(auth, (user) => {
  if (user) {
    currentUser = user;
    authScreen.classList.add('hidden');
    chatScreen.classList.remove('hidden');
    loadMessages();
  } else {
    currentUser = null;
    authScreen.classList.remove('hidden');
    chatScreen.classList.add('hidden');
  }
});

// Login
loginBtn.addEventListener('click', async () => {
  const email = emailInput.value.trim();
  const password = passwordInput.value.trim();
  if (!email || !password) {
    authError.textContent = 'Please enter email and password.';
    return;
  }
  try {
    await signInWithEmailAndPassword(auth, email, password);
    authError.textContent = '';
  } catch (error) {
    authError.textContent = error.message;
  }
});

// Sign up
signupBtn.addEventListener('click', async () => {
  const email = emailInput.value.trim();
  const password = passwordInput.value.trim();
  if (!email || !password) {
    authError.textContent = 'Please enter email and password.';
    return;
  }
  if (password.length < 6) {
    authError.textContent = 'Password must be at least 6 characters.';
    return;
  }
  try {
    await createUserWithEmailAndPassword(auth, email, password);
    authError.textContent = '';
  } catch (error) {
    authError.textContent = error.message;
  }
});

// Logout
logoutBtn.addEventListener('click', () => signOut(auth));

// Load messages with real-time listener
function loadMessages() {
  const q = query(collection(db, 'messages'), orderBy('timestamp', 'asc'), limit(200));
  onSnapshot(q, (snapshot) => {
    messagesDiv.innerHTML = '';
    snapshot.forEach((doc) => {
      const msg = doc.data();
      renderMessage(msg);
    });
    messagesDiv.scrollTop = messagesDiv.scrollHeight;
  });
}

// Render a single message as a chat bubble
function renderMessage(msg) {
  const div = document.createElement('div');
  const isSent = msg.uid === currentUser.uid;
  div.className = `message ${isSent ? 'sent' : 'received'}`;

  let html = '';
  if (!isSent) {
    const senderName = msg.displayName ? msg.displayName.split('@')[0] : 'Unknown';
    html += `<div class="sender">${senderName}</div>`;
  }

  if (msg.imageUrl) {
    html += `<img src="${msg.imageUrl}" alt="Shared image" onclick="window.open('${msg.imageUrl}', '_blank')" />`;
  }

  if (msg.text) {
    html += `<div>${msg.text}</div>`;
  }

  if (msg.timestamp) {
    const date = msg.timestamp.toDate();
    const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
    html += `<div class="time">${timeStr}</div>`;
  }

  div.innerHTML = html;
  messagesDiv.appendChild(div);
}

// Send message event listeners
sendBtn.addEventListener('click', sendMessage);
messageInput.addEventListener('keypress', (e) => {
  if (e.key === 'Enter') sendMessage();
});

// Send a text message to Firestore
async function sendMessage() {
  const text = messageInput.value.trim();
  if (!text) return;

  await addDoc(collection(db, 'messages'), {
    text: text,
    uid: currentUser.uid,
    displayName: currentUser.email,
    imageUrl: null,
    timestamp: serverTimestamp()
  });

  messageInput.value = '';
}

// Register service worker for PWA support
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(() => console.log('Service Worker registered'))
    .catch((err) => console.log('SW registration failed:', err));
}
Test the chat with two accounts

Your authentication and messaging code is complete. Time to see it in action by running a local development server and testing with two accounts.

  • Start the local development server by running this command from your family-chat project folder:
firebase serve

What does this command do?

firebase serve starts a local web server that hosts the files in your public folder. It mimics Firebase Hosting so you can test your app before deploying.

You should see a message like Local server: http://localhost:5000 in your terminal.

  • Open Google Chrome and go to http://localhost:5000.

You should see the login screen with the email and password fields.

  • Type a test email (like your real email address) in the Email field.
  • Type a password with at least 6 characters in the Password field.
  • Click Sign Up to create your first test account.

The app should switch to the chat screen. You should see the green header bar with "Family Chat" and a Logout button.

  • Type Hello family! in the message input at the bottom.
  • Click Send or press Enter.

Your message should appear as a green chat bubble on the right side of the screen.

  • Open a second browser tab or an incognito window and go to http://localhost:5000.
  • Create a second test account with a different email address.
  • Send a message from the second account.

Switch back to your first tab. The message from the second account should have appeared automatically as a white chat bubble on the left side. No page refresh needed.

What is happening behind the scenes?

When the second account sends a message, Firestore stores it in the messages collection. The onSnapshot() listener in the first tab detects the new document immediately and re-renders the message list. This is real-time sync in action.

Chat screen not loading after signup?

  • Open Chrome DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows). Check the Console tab for error messages.
  • If you see auth/configuration-not-found, your firebaseConfig values are wrong. Double check them against your Firebase Console.
  • If you see auth/operation-not-allowed, you forgot to enable the Email/Password provider. Go to the Firebase Console, navigate to Build then Authentication, and enable it under the Sign-in method tab.
  • If messages don't appear, check that you created the Firestore database in Step 1. Go to Build then Firestore Database in the Firebase Console and confirm the database exists.

Help me debug why my Firebase chat app isn't working after signup.

Your chat app has working authentication and real-time messaging. Next up, you'll add image sharing so family members can send photos directly in the chat.

Add Image Sharing

Your chat app sends and receives text messages in real-time. Text is great, but a family chat is only as useful as the photos, receipts, and documents you can share through it.

In this step, you will add image sharing to your app. When someone taps the camera icon, they will see a preview of the selected image before sending it. The image gets uploaded to Firebase Storage, and the download URL is saved alongside the message in Firestore.

In this step, get ready to:
  • Build the image selection and preview flow.
  • Upload images to Firebase Storage and save the download URL in Firestore.
  • Display shared images inside chat message bubbles.
Build the image upload flow

Your HTML already has a hidden file input and a camera icon label from Step 2. Now you need JavaScript to react when someone selects an image and show them a preview before they send it.

  • In app.js, scroll down to the bottom of your file, just above the service worker registration block.
  • Add the image handling code by copying the following above the // Register service worker comment:
// DOM elements for image handling
const imageInput = document.getElementById('image-input');
const imagePreview = document.getElementById('image-preview');
const previewImg = document.getElementById('preview-img');
const cancelImage = document.getElementById('cancel-image');

// Tracks the file the user selected
let selectedImageFile = null;

// Image selection - show preview when user picks a file
imageInput.addEventListener('change', (e) => {
  const file = e.target.files[0];
  if (!file) return;
  selectedImageFile = file;
  const reader = new FileReader();
  reader.onload = (event) => {
    previewImg.src = event.target.result;
    imagePreview.classList.remove('hidden');
  };
  reader.readAsDataURL(file);
});

// Cancel image selection
cancelImage.addEventListener('click', clearImagePreview);

function clearImagePreview() {
  selectedImageFile = null;
  imageInput.value = '';
  imagePreview.classList.add('hidden');
  previewImg.src = '';
}

What does this code do?

  • The DOM references grab the hidden file input, the preview container, the preview image element, and the cancel button from your HTML.
  • selectedImageFile stores the selected file so the send function can access it later.
  • When a user picks a file, FileReader converts the image into a base64 data URL so the browser can display it as a preview without uploading anything yet.
  • clearImagePreview() resets everything so the user can cancel or start over.
  • Save app.js.
  • Refresh your app in the browser and log in.
  • Click the camera icon next to the message input. Select any image from your computer.
  • You should see a small preview of the image appear at the bottom of the screen with a red cancel button next to it.

Don't see the image preview?

  • Make sure you saved app.js and refreshed the browser.
  • Check that you pasted the code above the service worker block, not inside it.
  • Open the browser console (F12 > Console tab) and look for any red error messages.
  • Why isn't my image preview showing up when I click the camera icon?
Upload images to Firebase Storage and save the URL

The preview works, but clicking Send only sends text right now. Your sendMessage() function needs to detect when an image is selected, upload it to Firebase Storage, and include the download URL in the Firestore message document.

  • In app.js, find your existing sendMessage() function. It currently looks like this:
async function sendMessage() {
  const text = messageInput.value.trim();
  if (!text) return;

  await addDoc(collection(db, 'messages'), {
    text: text,
    uid: currentUser.uid,
    displayName: currentUser.email,
    timestamp: serverTimestamp()
  });

  messageInput.value = '';
}
  • Replace the entire sendMessage() function with this updated version that handles image uploads:
async function sendMessage() {
  const text = messageInput.value.trim();

  // Only send if there's text or an image selected
  if (!text && !selectedImageFile) return;

  let imageUrl = null;

  // Upload image to Firebase Storage if one is selected
  if (selectedImageFile) {
    const fileName = `${Date.now()}_${selectedImageFile.name}`;
    const storageRef = ref(storage, `chat-images/${fileName}`);
    const snapshot = await uploadBytes(storageRef, selectedImageFile);
    imageUrl = await getDownloadURL(snapshot.ref);
    clearImagePreview();
  }

  // Save the message with the image URL (if any) to Firestore
  await addDoc(collection(db, 'messages'), {
    text: text || '',
    uid: currentUser.uid,
    displayName: currentUser.email,
    imageUrl: imageUrl,
    timestamp: serverTimestamp()
  });

  messageInput.value = '';
}

What changed in sendMessage?

  • The guard clause now checks for both text AND an image. A user can send just an image, just text, or both.
  • uploadBytes() sends the raw image file to Firebase Storage in a chat-images/ folder. The filename uses a timestamp prefix to avoid name collisions.
  • getDownloadURL() returns a public URL for the uploaded file. This URL is what gets stored in Firestore so other users can view the image.
  • The Firestore document now includes an imageUrl field. It is null for text-only messages.
  • Save app.js.
  • Refresh your app, click the camera icon, select an image, and click Send.
  • Open the Firebase Console and go to Build > Storage. You should see a chat-images folder containing your uploaded image file.

Image not appearing in Storage?

  • Check the browser console for permission errors. If you see storage/unauthorized, go back to the Firebase Console > Storage > Rules and make sure your rules allow authenticated writes.
  • Make sure you are logged in to the app before trying to send. The Storage security rules require authentication.
  • Why is my image upload failing with a permission error?
Display images in chat

Images are uploading to Storage and the URL is saved in Firestore. The chat bubbles do not know how to display images yet though. You need to update renderMessage() to check for image URLs and render them as clickable <img> tags inside the bubble.

  • In app.js, find your renderMessage() function and replace it with this updated version:
// Render a single message
function renderMessage(msg) {
  const div = document.createElement('div');
  const isSent = msg.uid === currentUser.uid;
  div.className = `message ${isSent ? 'sent' : 'received'}`;

  let html = '';
  if (!isSent) {
    const senderName = msg.displayName ? msg.displayName.split('@')[0] : 'Unknown';
    html += `<div class="sender">${senderName}</div>`;
  }

  // Display the image if this message has one
  if (msg.imageUrl) {
    html += `<img src="${msg.imageUrl}" alt="Shared image" onclick="window.open('${msg.imageUrl}', '_blank')" />`;
  }

  if (msg.text) {
    html += `<div>${msg.text}</div>`;
  }

  if (msg.timestamp) {
    const date = msg.timestamp.toDate();
    const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
    html += `<div class="time">${timeStr}</div>`;
  }

  div.innerHTML = html;
  messagesDiv.appendChild(div);
}

What changed in renderMessage?

  • The new if (msg.imageUrl) block checks whether the message includes an uploaded image.
  • If it does, an <img> tag is added with an onclick handler that opens the full-size image in a new tab.
  • The image block appears before the text block. This means a message with both an image and a caption shows the image on top with the text below it.

Images inside chat bubbles also need CSS so they do not stretch to full width and break the layout.

  • In style.css, find the .message .time rule. Add this new rule directly below it:
.message img {
  max-width: 250px;
  width: 100%;
  border-radius: 8px;
  margin-top: 4px;
  cursor: pointer;
}

What does this CSS do?

  • max-width: 250px keeps images from overwhelming the chat bubble. They scale down to fit but never stretch beyond 250 pixels.
  • cursor: pointer signals to the user that clicking the image does something (opens it full-size in a new tab).
  • Save both app.js and style.css.
  • Refresh your app in the browser.
  • Click the camera icon, select an image, and click Send.
  • You should see the image appear inside a chat bubble, sized to fit within the message. Click the image to verify it opens full-size in a new tab.

Image not showing in the chat bubble?

  • Make sure your updated renderMessage() function includes the if (msg.imageUrl) block. Check that it comes before the if (msg.text) block.
  • If the image previously sent in substep 2 does not show, try sending a new image. The old message might not have been saved correctly if the function had errors at that time.
  • Check the browser console for any errors related to image loading or blocked content.
  • My images are uploaded but not displaying in chat bubbles. What is wrong?

Awesome, I've got everything!

Great. Double check you have saved both app.js and style.css.

I'd like to double check the full code

Here is the full app.js as it should look at the end of this step.

import { initializeApp } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-app.js';
import { getAuth, onAuthStateChanged, signInWithEmailAndPassword, createUserWithEmailAndPassword, signOut } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-auth.js';
import { getFirestore, collection, addDoc, query, orderBy, onSnapshot, serverTimestamp, limit } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-firestore.js';
import { getStorage, ref, uploadBytes, getDownloadURL } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-storage.js';

// Firebase config - Replace with YOUR config from Firebase Console
const firebaseConfig = {
  apiKey: "your-firebase-api-key-here",
  authDomain: "your-project-id.firebaseapp.com",
  projectId: "your-project-id",
  storageBucket: "your-project-id.firebasestorage.app",
  messagingSenderId: "your-sender-id",
  appId: "your-app-id"
};

const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);
const storage = getStorage(app);

// DOM elements
const authScreen = document.getElementById('auth-screen');
const chatScreen = document.getElementById('chat-screen');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const loginBtn = document.getElementById('login-btn');
const signupBtn = document.getElementById('signup-btn');
const authError = document.getElementById('auth-error');
const logoutBtn = document.getElementById('logout-btn');
const messagesDiv = document.getElementById('messages');
const messageInput = document.getElementById('message-input');
const sendBtn = document.getElementById('send-btn');
const imageInput = document.getElementById('image-input');
const imagePreview = document.getElementById('image-preview');
const previewImg = document.getElementById('preview-img');
const cancelImage = document.getElementById('cancel-image');

let currentUser = null;
let selectedImageFile = null;

// Auth state listener
onAuthStateChanged(auth, (user) => {
  if (user) {
    currentUser = user;
    authScreen.classList.add('hidden');
    chatScreen.classList.remove('hidden');
    loadMessages();
  } else {
    currentUser = null;
    authScreen.classList.remove('hidden');
    chatScreen.classList.add('hidden');
  }
});

// Login
loginBtn.addEventListener('click', async () => {
  const email = emailInput.value.trim();
  const password = passwordInput.value.trim();
  if (!email || !password) {
    authError.textContent = 'Please enter email and password.';
    return;
  }
  try {
    await signInWithEmailAndPassword(auth, email, password);
    authError.textContent = '';
  } catch (error) {
    authError.textContent = error.message;
  }
});

// Sign up
signupBtn.addEventListener('click', async () => {
  const email = emailInput.value.trim();
  const password = passwordInput.value.trim();
  if (!email || !password) {
    authError.textContent = 'Please enter email and password.';
    return;
  }
  if (password.length < 6) {
    authError.textContent = 'Password must be at least 6 characters.';
    return;
  }
  try {
    await createUserWithEmailAndPassword(auth, email, password);
    authError.textContent = '';
  } catch (error) {
    authError.textContent = error.message;
  }
});

// Logout
logoutBtn.addEventListener('click', () => signOut(auth));

// Load messages with real-time listener
function loadMessages() {
  const q = query(collection(db, 'messages'), orderBy('timestamp', 'asc'), limit(200));
  onSnapshot(q, (snapshot) => {
    messagesDiv.innerHTML = '';
    snapshot.forEach((doc) => {
      const msg = doc.data();
      renderMessage(msg);
    });
    messagesDiv.scrollTop = messagesDiv.scrollHeight;
  });
}

// Render a single message
function renderMessage(msg) {
  const div = document.createElement('div');
  const isSent = msg.uid === currentUser.uid;
  div.className = `message ${isSent ? 'sent' : 'received'}`;

  let html = '';
  if (!isSent) {
    const senderName = msg.displayName ? msg.displayName.split('@')[0] : 'Unknown';
    html += `<div class="sender">${senderName}</div>`;
  }

  // Display the image if this message has one
  if (msg.imageUrl) {
    html += `<img src="${msg.imageUrl}" alt="Shared image" onclick="window.open('${msg.imageUrl}', '_blank')" />`;
  }

  if (msg.text) {
    html += `<div>${msg.text}</div>`;
  }

  if (msg.timestamp) {
    const date = msg.timestamp.toDate();
    const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
    html += `<div class="time">${timeStr}</div>`;
  }

  div.innerHTML = html;
  messagesDiv.appendChild(div);
}

// Send message
sendBtn.addEventListener('click', sendMessage);
messageInput.addEventListener('keypress', (e) => {
  if (e.key === 'Enter') sendMessage();
});

async function sendMessage() {
  const text = messageInput.value.trim();

  // Only send if there's text or an image selected
  if (!text && !selectedImageFile) return;

  let imageUrl = null;

  // Upload image to Firebase Storage if one is selected
  if (selectedImageFile) {
    const fileName = `${Date.now()}_${selectedImageFile.name}`;
    const storageRef = ref(storage, `chat-images/${fileName}`);
    const snapshot = await uploadBytes(storageRef, selectedImageFile);
    imageUrl = await getDownloadURL(snapshot.ref);
    clearImagePreview();
  }

  // Save the message with the image URL (if any) to Firestore
  await addDoc(collection(db, 'messages'), {
    text: text || '',
    uid: currentUser.uid,
    displayName: currentUser.email,
    imageUrl: imageUrl,
    timestamp: serverTimestamp()
  });

  messageInput.value = '';
}

// Image selection - show preview when user picks a file
imageInput.addEventListener('change', (e) => {
  const file = e.target.files[0];
  if (!file) return;
  selectedImageFile = file;
  const reader = new FileReader();
  reader.onload = (event) => {
    previewImg.src = event.target.result;
    imagePreview.classList.remove('hidden');
  };
  reader.readAsDataURL(file);
});

// Cancel image selection
cancelImage.addEventListener('click', clearImagePreview);

function clearImagePreview() {
  selectedImageFile = null;
  imageInput.value = '';
  imagePreview.classList.add('hidden');
  previewImg.src = '';
}

// Register service worker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(() => console.log('Service Worker registered'))
    .catch((err) => console.log('SW registration failed:', err));
}

And here is the new CSS rule you added to style.css (inside the Messages section):

.message img {
  max-width: 250px;
  width: 100%;
  border-radius: 8px;
  margin-top: 4px;
  cursor: pointer;
}

Your family chat app now supports image sharing. Family members can snap a photo, preview it, and share it in the chat for everyone to see. Next up, you will build the AI-powered search that can actually read text from those shared photos and answer questions about your chat history.

Build the AI-Powered Search

Your chat app now supports real-time messaging and image sharing. In this step, you'll add the feature that makes this app truly powerful: AI-powered search.

Imagine someone shared a photo of a document months ago. Instead of scrolling through hundreds of messages, you can type a question like "What is mom's Aadhaar number?" and the AI finds the image, reads the text, and gives you the answer directly.

In this step, get ready to:
  • Wire up the search bar to handle user queries.
  • Build an AI search function that analyzes chat history and reads text from images using Google Gemini.
  • Test the AI search with text messages and document photos.
Create the search interface

The search bar HTML already exists in your index.html from Step 2. It has a text input where users type questions and a search button to trigger the query.

Now you need to connect that search bar to JavaScript that collects your chat history, sends it along with any shared images to the Gemini API, and displays the AI's answer.

How does the AI search work?

When a user submits a search query, the app follows this flow:

  1. Fetch the last 100 messages from Firestore and build a text summary of the chat history.
  2. Collect the URLs of any shared images and convert up to 3 recent images to base64 format.
  3. Send the user's question, chat history text, and image data to the Gemini API in a single request.
  4. Display Gemini's answer in a results panel below the search bar.
Build the AI search function

The search function needs getDocs from Firestore to fetch all messages in one batch. This is different from the onSnapshot() real-time listener you used for the chat feed.

  • In app.js, find your Firestore import line near the top of the file.
  • Find this line:
import { getFirestore, collection, addDoc, query, orderBy, onSnapshot, serverTimestamp, limit } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-firestore.js';
  • Add getDocs to the import list, so the line now looks like this:
import { getFirestore, collection, addDoc, query, orderBy, onSnapshot, getDocs, serverTimestamp, limit } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-firestore.js';

Now it's time to add the main search function. This is the biggest piece of code in the project, but each section has a clear job.

  • Scroll to the bottom of app.js, below the clearImagePreview() function.
  • Add the search event listeners and the performAISearch() function by copying the following code:
// AI Search
searchBtn.addEventListener('click', performAISearch);
searchInput.addEventListener('keypress', (e) => {
  if (e.key === 'Enter') performAISearch();
});

async function performAISearch() {
  const queryText = searchInput.value.trim();
  if (!queryText) return;

  // Show a loading message while the AI processes the search
  searchResults.classList.remove('hidden');
  searchResultsContent.innerHTML = '<p class="loading">๐Ÿ” Searching with AI...</p>';

  try {
    // Fetch all messages for context
    const q = query(collection(db, 'messages'), orderBy('timestamp', 'desc'), limit(100));
    const snapshot = await getDocs(q);

    // Build a text summary of chat history and collect image URLs
    let chatContext = '';
    const imageUrls = [];

    snapshot.forEach((doc) => {
      const msg = doc.data();
      const sender = msg.displayName ? msg.displayName.split('@')[0] : 'Unknown';
      if (msg.text) {
        chatContext += `${sender}: ${msg.text}\n`;
      }
      if (msg.imageUrl) {
        imageUrls.push(msg.imageUrl);
      }
    });

    // Build the content parts for Gemini
    const parts = [];

    parts.push({
      text: `You are a helpful family chat assistant. Search through the following chat history and any provided images to answer the user's question. If the answer might be in an image (like a document, ID card, bill, or receipt), carefully read ALL text visible in the images and extract the relevant information.\n\nChat History:\n${chatContext}\n\nUser's Question: ${queryText}\n\nProvide a clear, direct answer. If you find the information in an image, mention that you found it in a shared photo.`
    });

    // Fetch and include up to 3 recent images for analysis
    const imagesToAnalyze = imageUrls.slice(0, 3);
    for (const url of imagesToAnalyze) {
      try {
        const response = await fetch(url);
        const blob = await response.blob();
        const base64 = await blobToBase64(blob);
        parts.push({
          inlineData: {
            mimeType: blob.type || 'image/jpeg',
            data: base64
          }
        });
      } catch (err) {
        console.log('Could not fetch image for AI analysis:', err);
      }
    }

    // Call Gemini API
    const geminiResponse = await fetch(
      `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          contents: [{ parts: parts }]
        })
      }
    );

    const data = await geminiResponse.json();

    // Display the AI's answer or a fallback message
    if (data.candidates && data.candidates[0] && data.candidates[0].content) {
      const answer = data.candidates[0].content.parts[0].text;
      searchResultsContent.textContent = answer;
    } else {
      searchResultsContent.textContent = 'No results found. Try rephrasing your question.';
    }
  } catch (error) {
    searchResultsContent.textContent = 'Search failed: ' + error.message;
  }
}

What does this code do?

  • The two addEventListener calls trigger the search when the user clicks the search button or presses Enter.
  • getDocs(q) fetches the 100 most recent messages in one batch. A one-time fetch is all the search needs, unlike the continuous onSnapshot() listener used for the chat feed.
  • The function loops through each message, building chatContext (a single string of all text messages) and imageUrls (a list of shared image URLs).
  • Up to 3 recent images are fetched, converted to base64, and sent to Gemini as inlineData. This lets the AI read text directly from photos like document scans.
  • The prompt tells Gemini to act as a family chat assistant, search through the chat history, read any images, and answer the user's question.
  • The response is parsed from data.candidates[0].content.parts[0].text, which is the structure Gemini's REST API uses for text answers.

You won't see a change yet. The search function calls a helper called blobToBase64() that you need to add next.

  • Right below the performAISearch() function, add the base64 helper and the close-search handler by copying this code:
// Helper: convert blob to base64 string (without the data URL prefix)
function blobToBase64(blob) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onloadend = () => {
      // Strip the "data:image/...;base64," prefix so Gemini gets raw base64
      const base64 = reader.result.split(',')[1];
      resolve(base64);
    };
    reader.onerror = reject;
    reader.readAsDataURL(blob);
  });
}

// Close the search results panel when the user clicks the close button
closeSearch.addEventListener('click', () => {
  searchResults.classList.add('hidden');
  searchInput.value = '';
});

What does this helper do?

  • blobToBase64() converts an image file (blob) into a base64 string. The Gemini API expects image data in base64 format, not as a URL.
  • reader.result.split(',')[1] strips the data URL prefix so only the raw image data is sent to the API.
  • The closeSearch listener hides the search results panel and clears the input when the user clicks the close button.
  • Save app.js.

Seeing a syntax error in the console?

  • Check that getDocs is included in your Firestore import line at the top of the file.
  • Make sure the performAISearch function has matching opening and closing braces. The function is long, so a missing brace at the end is a common mistake.
  • Verify that the Gemini prompt uses backtick characters for the template literal, not regular single quotes.
  • Help me debug a syntax error in my AI search function.

Awesome, I've got everything!

Great. Double check you have saved app.js before moving on to testing.

I'd like to double check the full code

Here is the complete app.js file with everything from Steps 3 through 5.

import { initializeApp } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-app.js';
import { getAuth, onAuthStateChanged, signInWithEmailAndPassword, createUserWithEmailAndPassword, signOut } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-auth.js';
import { getFirestore, collection, addDoc, query, orderBy, onSnapshot, getDocs, serverTimestamp, limit } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-firestore.js';
import { getStorage, ref, uploadBytes, getDownloadURL } from 'https://www.gstatic.com/firebasejs/11.3.0/firebase-storage.js';

// Firebase config - Replace with YOUR config from Firebase Console
const firebaseConfig = {
  apiKey: "your-firebase-api-key-here",
  authDomain: "your-project-id.firebaseapp.com",
  projectId: "your-project-id",
  storageBucket: "your-project-id.firebasestorage.app",
  messagingSenderId: "your-sender-id",
  appId: "your-app-id"
};

// Gemini API key - Replace with YOUR key from Google AI Studio
const GEMINI_API_KEY = 'your-gemini-api-key-here';

const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);
const storage = getStorage(app);

// DOM elements
const authScreen = document.getElementById('auth-screen');
const chatScreen = document.getElementById('chat-screen');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const loginBtn = document.getElementById('login-btn');
const signupBtn = document.getElementById('signup-btn');
const authError = document.getElementById('auth-error');
const logoutBtn = document.getElementById('logout-btn');
const messagesDiv = document.getElementById('messages');
const messageInput = document.getElementById('message-input');
const sendBtn = document.getElementById('send-btn');
const imageInput = document.getElementById('image-input');
const imagePreview = document.getElementById('image-preview');
const previewImg = document.getElementById('preview-img');
const cancelImage = document.getElementById('cancel-image');
const searchInput = document.getElementById('search-input');
const searchBtn = document.getElementById('search-btn');
const searchResults = document.getElementById('search-results');
const searchResultsContent = document.getElementById('search-results-content');
const closeSearch = document.getElementById('close-search');

let currentUser = null;
let selectedImageFile = null;

// Auth state listener
onAuthStateChanged(auth, (user) => {
  if (user) {
    currentUser = user;
    authScreen.classList.add('hidden');
    chatScreen.classList.remove('hidden');
    loadMessages();
  } else {
    currentUser = null;
    authScreen.classList.remove('hidden');
    chatScreen.classList.add('hidden');
  }
});

// Login
loginBtn.addEventListener('click', async () => {
  const email = emailInput.value.trim();
  const password = passwordInput.value.trim();
  if (!email || !password) {
    authError.textContent = 'Please enter email and password.';
    return;
  }
  try {
    await signInWithEmailAndPassword(auth, email, password);
    authError.textContent = '';
  } catch (error) {
    authError.textContent = error.message;
  }
});

// Sign up
signupBtn.addEventListener('click', async () => {
  const email = emailInput.value.trim();
  const password = passwordInput.value.trim();
  if (!email || !password) {
    authError.textContent = 'Please enter email and password.';
    return;
  }
  if (password.length < 6) {
    authError.textContent = 'Password must be at least 6 characters.';
    return;
  }
  try {
    await createUserWithEmailAndPassword(auth, email, password);
    authError.textContent = '';
  } catch (error) {
    authError.textContent = error.message;
  }
});

// Logout
logoutBtn.addEventListener('click', () => signOut(auth));

// Load messages with real-time listener
function loadMessages() {
  const q = query(collection(db, 'messages'), orderBy('timestamp', 'asc'), limit(200));
  onSnapshot(q, (snapshot) => {
    messagesDiv.innerHTML = '';
    snapshot.forEach((doc) => {
      const msg = doc.data();
      renderMessage(msg);
    });
    messagesDiv.scrollTop = messagesDiv.scrollHeight;
  });
}

// Render a single message
function renderMessage(msg) {
  const div = document.createElement('div');
  const isSent = msg.uid === currentUser.uid;
  div.className = `message ${isSent ? 'sent' : 'received'}`;

  let html = '';
  if (!isSent) {
    const senderName = msg.displayName ? msg.displayName.split('@')[0] : 'Unknown';
    html += `<div class="sender">${senderName}</div>`;
  }

  if (msg.imageUrl) {
    html += `<img src="${msg.imageUrl}" alt="Shared image" onclick="window.open('${msg.imageUrl}', '_blank')" />`;
  }

  if (msg.text) {
    html += `<div>${msg.text}</div>`;
  }

  if (msg.timestamp) {
    const date = msg.timestamp.toDate();
    const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
    html += `<div class="time">${timeStr}</div>`;
  }

  div.innerHTML = html;
  messagesDiv.appendChild(div);
}

// Send message
sendBtn.addEventListener('click', sendMessage);
messageInput.addEventListener('keypress', (e) => {
  if (e.key === 'Enter') sendMessage();
});

async function sendMessage() {
  const text = messageInput.value.trim();

  if (!text && !selectedImageFile) return;

  let imageUrl = null;

  if (selectedImageFile) {
    const fileName = `${Date.now()}_${selectedImageFile.name}`;
    const storageRef = ref(storage, `chat-images/${fileName}`);
    const snapshot = await uploadBytes(storageRef, selectedImageFile);
    imageUrl = await getDownloadURL(snapshot.ref);
    clearImagePreview();
  }

  await addDoc(collection(db, 'messages'), {
    text: text || '',
    uid: currentUser.uid,
    displayName: currentUser.email,
    imageUrl: imageUrl,
    timestamp: serverTimestamp()
  });

  messageInput.value = '';
}

// Image selection
imageInput.addEventListener('change', (e) => {
  const file = e.target.files[0];
  if (!file) return;
  selectedImageFile = file;
  const reader = new FileReader();
  reader.onload = (event) => {
    previewImg.src = event.target.result;
    imagePreview.classList.remove('hidden');
  };
  reader.readAsDataURL(file);
});

cancelImage.addEventListener('click', clearImagePreview);

function clearImagePreview() {
  selectedImageFile = null;
  imageInput.value = '';
  imagePreview.classList.add('hidden');
  previewImg.src = '';
}

// AI Search
searchBtn.addEventListener('click', performAISearch);
searchInput.addEventListener('keypress', (e) => {
  if (e.key === 'Enter') performAISearch();
});

async function performAISearch() {
  const queryText = searchInput.value.trim();
  if (!queryText) return;

  searchResults.classList.remove('hidden');
  searchResultsContent.innerHTML = '<p class="loading">๐Ÿ” Searching with AI...</p>';

  try {
    // Fetch all messages for context
    const q = query(collection(db, 'messages'), orderBy('timestamp', 'desc'), limit(100));
    const snapshot = await getDocs(q);

    let chatContext = '';
    const imageUrls = [];

    snapshot.forEach((doc) => {
      const msg = doc.data();
      const sender = msg.displayName ? msg.displayName.split('@')[0] : 'Unknown';
      if (msg.text) {
        chatContext += `${sender}: ${msg.text}\n`;
      }
      if (msg.imageUrl) {
        imageUrls.push(msg.imageUrl);
      }
    });

    // Build the content parts for Gemini
    const parts = [];

    parts.push({
      text: `You are a helpful family chat assistant. Search through the following chat history and any provided images to answer the user's question. If the answer might be in an image (like a document, ID card, bill, or receipt), carefully read ALL text visible in the images and extract the relevant information.\n\nChat History:\n${chatContext}\n\nUser's Question: ${queryText}\n\nProvide a clear, direct answer. If you find the information in an image, mention that you found it in a shared photo.`
    });

    // Fetch and include up to 3 recent images for analysis
    const imagesToAnalyze = imageUrls.slice(0, 3);
    for (const url of imagesToAnalyze) {
      try {
        const response = await fetch(url);
        const blob = await response.blob();
        const base64 = await blobToBase64(blob);
        parts.push({
          inlineData: {
            mimeType: blob.type || 'image/jpeg',
            data: base64
          }
        });
      } catch (err) {
        console.log('Could not fetch image for AI analysis:', err);
      }
    }

    // Call Gemini API
    const geminiResponse = await fetch(
      `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          contents: [{ parts: parts }]
        })
      }
    );

    const data = await geminiResponse.json();

    if (data.candidates && data.candidates[0] && data.candidates[0].content) {
      const answer = data.candidates[0].content.parts[0].text;
      searchResultsContent.textContent = answer;
    } else {
      searchResultsContent.textContent = 'No results found. Try rephrasing your question.';
    }
  } catch (error) {
    searchResultsContent.textContent = 'Search failed: ' + error.message;
  }
}

// Helper: convert blob to base64 string (without the data URL prefix)
function blobToBase64(blob) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onloadend = () => {
      const base64 = reader.result.split(',')[1];
      resolve(base64);
    };
    reader.onerror = reject;
    reader.readAsDataURL(blob);
  });
}

closeSearch.addEventListener('click', () => {
  searchResults.classList.add('hidden');
  searchInput.value = '';
});

// Register service worker
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(() => console.log('Service Worker registered'))
    .catch((err) => console.log('SW registration failed:', err));
}
Test the AI search

Your search function is ready. Time to put it through its paces with real messages and images.

  • Make sure your local server is running. If it is not, start it by running:
firebase serve
  • Open your app in Chrome at http://localhost:5000 and log in.
  • Send a few test messages in the chat. For example, type Mom's birthday is on March 15 and Dad's phone number is 9876543210.
  • Upload a test image of any document. You can use a photo of any printed text, a receipt, or an ID card.
  • In the search bar at the top of the chat screen, type What is Dad's phone number? and click the search button.

A white results panel appears below the search bar. After a few seconds, Gemini's answer replaces the loading text with the phone number extracted from your chat history.

  • Try a search that requires reading an image. For example, if you uploaded a document photo, ask a question about the text in that image.
  • Verify that the AI mentions it found the answer in a shared photo.
  • Click the close button on the search results panel to dismiss it.

Why only 3 images?

Each image consumes tokens when sent to Gemini. The free tier allows 250 requests per day, and sending fewer images per request keeps things fast. For a small family group, analyzing the 3 most recent images per search is a solid default.

Search returning an error?

  • If you see "Search failed", check that your GEMINI_API_KEY near the top of app.js is set to your actual API key from Google AI Studio, not the placeholder text.
  • If you see "No results found", try sending more test messages first. The search needs chat history to work with.
  • If images are not being analyzed, check the browser console for CORS errors. Firebase Storage URLs should work, but external image URLs may be blocked.
  • Help me debug my AI search function.

Your AI-powered search is working. Next up, you'll lock down your app with security rules and deploy it live so your family can install it on their phones.

Add Security Rules and Deploy

Your AI-powered search is working. You can find information hidden in messages and even read text from document photos.

Your Firestore database and Firebase Storage bucket are still in test mode, which means anyone could access your data. In this step, you will lock everything down with security rules and deploy your app to a live URL.

In this step, get ready to:
  • Set up security rules for Firestore and Storage.
  • Deploy your app to Firebase Hosting.
  • Invite your family to use the live app.
Set up Firestore and Storage security rules

When you created your Firestore database and Storage bucket earlier, you chose test mode. Test mode lets anyone read and write your data without logging in.

Security rules fix this by requiring users to be authenticated through Firebase Auth before they can access any data.

  • In the Firebase Console, click Firestore Database in the left sidebar.
  • Click the Rules tab at the top of the page.
  • Select all the existing rules text and replace it with the following:
rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    // Only logged-in users can read or write messages
    match /messages/{messageId} {
      allow read, write: if request.auth != null;
    }
  }
}

What do these rules do?

  • request.auth != null checks whether the user is logged in with Firebase Auth. If they are not logged in, the request gets rejected.
  • match /messages/{messageId} scopes the rule to the messages collection. Every document inside that collection follows this rule.
  • Click Publish to save your Firestore rules.

You should see a confirmation message that the rules were published successfully.

  • In the left sidebar, click Storage.
  • Click the Rules tab.
  • Select all the existing rules text and replace it with the following:
rules_version = '2';

service firebase.storage {
  match /b/{bucket}/o {
    // Only logged-in users can upload or download chat images
    match /chat-images/{allPaths=**} {
      allow read, write: if request.auth != null;
    }
  }
}

What do the Storage rules do?

  • These rules follow the same pattern as Firestore. Only logged-in users can upload or download files.
  • {allPaths=**} is a wildcard that matches all files and subfolders inside the chat-images/ path.
  • Click Publish to save your Storage rules.

Both rule sets should now show a published status with today's date.

Rules not publishing?

  • Make sure you replaced all the existing text, including the rules_version line at the top.
  • Check for missing curly braces. Each opening { needs a matching closing }.
  • If you see a red error highlight in the rules editor, hover over it to see the specific syntax issue.
  • Help me fix my Firebase security rules.
Deploy to Firebase Hosting

Your app is secured and ready to go live. Firebase Hosting serves your static files from a global CDN, so your family gets fast load times no matter where they are.

  • Open your terminal and make sure you are in the family-chat folder (the one containing firebase.json).
  • Deploy your app by running this command:
firebase deploy

What should I see?

Firebase uploads your public folder and prints a Hosting URL when it finishes. Look for a line like Hosting URL: https://your-project-id.web.app.

  • Copy the Hosting URL from the terminal output.
  • Open the URL in your browser. You should see the Family Chat login screen.

Deploy failed?

  • Make sure you are inside the family-chat folder. The folder must contain a firebase.json file.
  • Run firebase login to check that you are logged in to the correct Google account.
  • If you see a permissions error, run firebase use --add and select the correct project.
  • Help me fix my Firebase deploy error.

Now test the PWA install experience on your phone.

  • Open the Hosting URL on your phone's browser (Chrome on Android or Safari on iOS).
  • On Android, tap the three-dot menu in Chrome and select Add to Home Screen.
  • On iOS, tap the share icon in Safari and select Add to Home Screen.
  • Open the app from your home screen. It should launch in standalone mode without a browser address bar.

What does standalone mode mean?

Standalone mode means the app opens in its own window without the browser's address bar, tabs, or navigation buttons. It looks and feels like a native app installed from an app store.

Invite your family

Your app is live and secured. Time to get your family on board.

  • Share your Hosting URL with your family members.
  • Have each family member open the URL and tap Sign Up to create an account with their email and password.
  • Send a few messages back and forth to verify real-time messaging works across devices.
  • Upload a document photo (like an ID card or bill) from one device.
  • From another device, type a question about the document in the search bar and tap the search button.

The AI should find and read the text from your uploaded document to answer the question.

Pro tip: test with a second browser

If your family is not available right now, open the Hosting URL in a second browser or an incognito window on your computer. Sign up with a different email to simulate a second family member.

Secret mission

Build a Document Vault

Your family's important documents are scattered across months of chat history. What if you could upload, tag, and instantly search them in a dedicated vault? In this secret mission, you will build a Document Vault feature with automatic text extraction and category filtering.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses the Firebase Spark plan and the Gemini API free tier, so there are no ongoing costs.

Resources you used:

  • Firebase project (Firestore database, Storage bucket, Authentication, and Hosting).
  • Gemini API key (free tier with 10 requests/min and 250 requests/day).
  • Local project files in the family-chat folder.
  • Firebase CLI installed globally via npm.

Keep everything running

No action needed. Choose this if you're still actively building or your family is using the app.

  • The Firebase Spark plan is completely free. Your family can keep using the app at no cost.
  • The Gemini API free tier is sufficient for occasional searches by 4-5 family members.
  • Your hosted app will remain live at your Firebase Hosting URL.

Pause - I'll come back to this later

No action needed here either. Firebase's free tier has no idle charges, so everything stays available even if you stop using it for a while.

  • Your Firestore data, Storage files, and user accounts will persist at no cost.
  • Your Gemini API key will remain active. The free tier rate limits prevent any unexpected charges.
  • Your hosted app will stay live at its URL until you decide to take it down.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Go to the Firebase Console. Open Project Settings (gear icon). Scroll to the bottom of the page and click Delete project. This removes your Firestore database, Storage bucket, Authentication users, and Hosting site.
  • Go to Google AI Studio (aistudio.google.com). Navigate to your API keys and revoke the key you created for this project.
  • Delete the local family-chat folder from your computer.
  • Optionally, uninstall the Firebase CLI by running:
npm uninstall -g firebase-tools

Mission accomplished

Nice Work!

You just built a private family chat app with AI-powered document search. Your family now has a real messaging app they can install on their phones, share photos and documents through, and search with AI to pull information from old images.

You learned how to:

  • Build a real-time chat application with Firebase Firestore that updates messages live across devices, complete with email/password authentication using Firebase Auth.
  • Add image sharing with Firebase Storage and integrate Google Gemini AI to read text from document photos and search your chat history.
  • Create an installable Progressive Web App and deploy it live on Firebase Hosting with a shareable URL your family can use on any device.
  • Build a Document Vault for categorized storage and instant AI-powered retrieval of important family documents.

Ready to quiz yourself?