Building a Real-Time Chat Application with Angular and Firebase

Moe Mollaie
13 min readJul 5, 2024

--

In the modern digital era, real-time communication has become a crucial feature for various applications. From social media platforms to collaborative tools, real-time interaction enhances user experience by providing instantaneous updates and interactions. This article explores the need for real-time connections, compares different solutions, and provides a step-by-step guide to setting up a real-time chat application using Angular and Firebase.

The Need for Real-Time Connections

Scenarios Requiring Real-Time Communication

  1. Social Media and Messaging Apps: Platforms like WhatsApp, Facebook Messenger, and Slack rely on real-time updates to ensure users can communicate instantaneously.
  2. Collaborative Tools: Applications such as Google Docs and Trello provide real-time collaboration features, enabling multiple users to work together seamlessly.
  3. Live Sports and News Updates: Real-time updates are essential for delivering the latest scores, news, and alerts to users.
  4. Customer Support: Real-time chat systems enhance customer service by providing immediate assistance and support to users.

Comparing Real-Time Communication Solutions

Several technologies enable real-time communication, each with its advantages and trade-offs:

  1. WebSockets: Provides full-duplex communication channels over a single TCP connection. Ideal for scenarios requiring low latency and high-frequency message exchange.
  2. Server-Sent Events (SSE): Allows servers to push updates to the client. Suitable for applications needing real-time updates without bidirectional communication.
  3. Polling: Clients regularly request updates from the server. Easier to implement but less efficient and can lead to higher latency.
  4. Firebase Realtime Database: A NoSQL cloud database that updates data in real-time. Simplifies implementation but can be less flexible for complex queries.
  5. Firebase Firestore: Offers more advanced querying capabilities and real-time data synchronization. It’s scalable and suitable for complex applications.

Setting Up an Angular and Firebase Project

Step 1: Setting Up the Angular Project

First, create a new Angular project using the Angular CLI:

ng new real-time-chat
cd real-time-chat

Install the necessary Firebase packages:

ng add @angular/fire
ng add ngxtension

Step 2: Configuring Firebase

Create a Firebase project in the Firebase Console and obtain your Firebase configuration object. Add this configuration to your Angular environment files:

// src/environments/environment.ts
export const environment = {
production: false,
firebase: {
apiKey: "your-api-key",
authDomain: "your-auth-domain",
projectId: "your-project-id",
storageBucket: "your-storage-bucket",
messagingSenderId: "your-messaging-sender-id",
appId: "your-app-id"
}
};

Step 3: Setting Up Firebase in Angular

Modify your app.config.ts to initialize Firebase:

import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';

import { routes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser';
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
import { getAuth, provideAuth } from '@angular/fire/auth';
import { getFirestore, provideFirestore } from '@angular/fire/firestore';
import { environment } from '../environments/environment.development';

export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding()),
provideClientHydration(),
provideFirebaseApp(() => initializeApp(environment.firebase)),
provideAuth(() => getAuth()),
provideFirestore(() => getFirestore()),
],
};

Implementing Real-Time Chat Functionality

Step 4: Authentication

Create an authentication store to handle user registration, login, and logout:

// src/app/stores/auth.store.ts

import { createInjectable } from 'ngxtension/create-injectable';
import { inject, signal } from '@angular/core';
import {
Auth,
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
User,
} from '@angular/fire/auth';
import { Firestore, doc, setDoc, getDoc } from '@angular/fire/firestore';

export interface AppUser {
uid: string;
email: string;
displayName?: string;
}

export const useAuthStore = createInjectable(() => {
const auth = inject(Auth);
const firestore = inject(Firestore);
const currentUser = signal<AppUser | null>(null);

auth.onAuthStateChanged(async (user) => {
if (user) {
const appUser = await getUserFromFirestore(user.uid);
currentUser.set(appUser);
} else {
currentUser.set(null);
}
});

async function getUserFromFirestore(uid: string): Promise<AppUser | null> {
const userDoc = await getDoc(doc(firestore, `users/${uid}`));
if (userDoc.exists()) {
return userDoc.data() as AppUser;
}
return null;
}

async function createUserInFirestore(user: User): Promise<void> {
const newUser: AppUser = {
uid: user.uid,
email: user.email!,
displayName: user.displayName || undefined,
};
await setDoc(doc(firestore, `users/${user.uid}`), newUser);
}

async function signUp(email: string, password: string, displayName: string) {
try {
const userCredential = await createUserWithEmailAndPassword(
auth,
email,
password
);
await createUserInFirestore({ ...userCredential.user, displayName });
const appUser = await getUserFromFirestore(userCredential.user.uid);
currentUser.set(appUser);
} catch (error: any) {
console.error('Signup error:', error);
switch (error.code) {
case 'auth/email-already-in-use':
throw new Error(
'This email is already in use. Please try a different one.'
);
case 'auth/invalid-email':
throw new Error('The email address is not valid.');
case 'auth/weak-password':
throw new Error(
'The password is too weak. Please use a stronger password.'
);
default:
throw new Error(
'An error occurred during sign up. Please try again.'
);
}
}
}

async function login(email: string, password: string) {
try {
const userCredential = await signInWithEmailAndPassword(
auth,
email,
password
);
const appUser = await getUserFromFirestore(userCredential.user.uid);
currentUser.set(appUser);
} catch (error: any) {
console.error('Login error:', error);
switch (error.code) {
case 'auth/user-not-found':
case 'auth/wrong-password':
throw new Error('Invalid email or password. Please try again.');
case 'auth/invalid-email':
throw new Error('The email address is not valid.');
case 'auth/user-disabled':
throw new Error(
'This account has been disabled. Please contact support.'
);
default:
throw new Error('An error occurred during login. Please try again.');
}
}
}

async function logout() {
try {
await signOut(auth);
currentUser.set(null);
} catch (error) {
console.error('Logout error:', error);
throw new Error('An error occurred during logout. Please try again.');
}
}

return {
currentUser,
signUp,
login,
logout,
};
});

Step 5: Firestore Chat Store

Create a chat store to manage chat-related operations:

// src/app/stores/chat.store.ts

import { createInjectable } from 'ngxtension/create-injectable';
import { inject, signal, computed, effect } from '@angular/core';
import {
Firestore,
collection,
doc,
onSnapshot,
addDoc,
updateDoc,
Timestamp,
query,
where,
getDocs,
orderBy,
getDoc,
} from '@angular/fire/firestore';
import { useAuthStore, AppUser } from './auth.store';

export interface Chat {
id: string;
participants: string[];
participantNames?: string[];
lastMessage: string;
lastMessageTimestamp: Timestamp;
}

export interface Message {
id: string;
chatId: string;
senderId: string;
text: string;
timestamp: Timestamp;
}

export const useChatStore = createInjectable(() => {
const firestore = inject(Firestore);
const authStore = inject(useAuthStore);
const chats = signal<Chat[]>([]);
const currentChatId = signal<string | null>(null);
const messages = signal<Message[]>([]);

const currentChat = computed(() =>
chats().find((chat) => chat.id === currentChatId())
);

effect(() => {
const userId = authStore.currentUser()?.uid;
if (userId) {
listenToChats(userId);
}
});

async function getUserName(userId: string): Promise<string> {
const userDoc = await getDoc(doc(firestore, `users/${userId}`));
if (userDoc.exists()) {
const userData = userDoc.data() as AppUser;
return userData.displayName || userData.email || 'Unknown User';
}
return 'Unknown User';
}

async function fetchParticipantNames(
participantIds: string[]
): Promise<string[]> {
const names = await Promise.all(participantIds.map(getUserName));
return names;
}

function listenToChats(userId: string) {
const chatsRef = collection(firestore, 'chats');
const q = query(chatsRef, where('participants', 'array-contains', userId));
return onSnapshot(q, async (snapshot) => {
const updatedChats = await Promise.all(
snapshot.docs.map(async (doc) => {
const chatData = { id: doc.id, ...doc.data() } as Chat;
chatData.participantNames = await fetchParticipantNames(
chatData.participants
);
return chatData;
})
);
chats.set(updatedChats);
});
}

function listenToMessages(chatId: string) {
currentChatId.set(chatId);
const messagesRef = collection(firestore, `chats/${chatId}/messages`);
const q = query(messagesRef, orderBy('timestamp', 'asc'));
return onSnapshot(q, (snapshot) => {
const updatedMessages = snapshot.docs.map(
(doc) => ({ id: doc.id, ...doc.data() } as Message)
);
messages.set(updatedMessages);
});
}

async function sendMessage(chatId: string, senderId: string, text: string) {
const messagesRef = collection(firestore, `chats/${chatId}/messages`);
const newMessage = {
chatId,
senderId,
text,
timestamp: Timestamp.now(),
};
await addDoc(messagesRef, newMessage);

// Update the last message in the chat document
const chatRef = doc(firestore, `chats/${chatId}`);
await updateDoc(chatRef, {
lastMessage: text,
lastMessageTimestamp: Timestamp.now(),
});
}

async function createNewChat(participantEmail: string) {
const currentUser = authStore.currentUser();
if (!currentUser) throw new Error('You must be logged in to create a chat');

// Find the user with the given email
const usersRef = collection(firestore, 'users');
const q = query(usersRef, where('email', '==', participantEmail));
const querySnapshot = await getDocs(q);

if (querySnapshot.empty) {
throw new Error('User not found');
}

const participantUser = querySnapshot.docs[0].data() as AppUser;

// Check if a chat already exists between these users
const existingChatQuery = query(
collection(firestore, 'chats'),
where('participants', 'array-contains', currentUser.uid),
where('participants', 'array-contains', participantUser.uid)
);
const existingChatSnapshot = await getDocs(existingChatQuery);

if (!existingChatSnapshot.empty) {
// Chat already exists, return its ID
return existingChatSnapshot.docs[0].id;
}

// Create a new chat document
const chatsRef = collection(firestore, 'chats');
const newChat = await addDoc(chatsRef, {
participants: [currentUser.uid, participantUser.uid],
lastMessage: '',
lastMessageTimestamp: Timestamp.now(),
});

return newChat.id;
}

return {
chats,
currentChat,
messages,
listenToChats,
listenToMessages,
sendMessage,
createNewChat,
getUserName,
};
});

Step 6: Creating UI Components

  1. Sign Up Component:
// src/app/components/signup/signup.component.ts

import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { useAuthStore } from '../stores/auth.store';

@Component({
selector: 'app-signup',
standalone: true,
imports: [FormsModule, RouterModule],
template: `
<div
class="min-h-screen bg-gray-100 flex flex-col justify-center py-12 sm:px-6 lg:px-8"
>
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create a new account
</h2>
</div>

<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
@if (errorMessage()) {
<div
class="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert"
>
<span class="block sm:inline">{{ errorMessage() }}</span>
</div>
}

<form (ngSubmit)="onSubmit()" class="space-y-6">
<div>
<label
for="email"
class="block text-sm font-medium text-gray-700"
>
Email address
</label>
<div class="mt-1">
<input
id="email"
name="email"
type="email"
required
[(ngModel)]="email"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div>
<label
for="displayName"
class="block text-sm font-medium text-gray-700"
>
Display Name
</label>
<div class="mt-1">
<input
id="displayName"
name="displayName"
type="text"
required
[(ngModel)]="displayName"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>

<div>
<label
for="password"
class="block text-sm font-medium text-gray-700"
>
Password
</label>
<div class="mt-1">
<input
id="password"
name="password"
type="password"
required
[(ngModel)]="password"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>

<div>
<button
type="submit"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Sign up
</button>
</div>
</form>

<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500"> Or </span>
</div>
</div>

<div class="mt-6">
<a
routerLink="/login"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-indigo-600 bg-white hover:bg-gray-50"
>
Sign in to existing account
</a>
</div>
</div>
</div>
</div>
</div>
`,
})
export class SignupComponent {
email = '';
password = '';
displayName = '';
errorMessage = signal<string | null>(null);
private authStore = inject(useAuthStore);
private router = inject(Router);

async onSubmit() {
try {
console.log(this.displayName);
await this.authStore.signUp(this.email, this.password, this.displayName);
this.router.navigate(['/chat']);
// Navigate to chat list after successful signup
} catch (error) {
console.error('Signup error:', error);
if (error instanceof Error) {
this.errorMessage.set(error.message);
} else {
this.errorMessage.set(
'An unexpected error occurred. Please try again.'
);
}
}
}
}

Login Component:

// src/app/components/login/login.component.ts

import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { useAuthStore } from '../stores/auth.store';

@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule, RouterModule],
template: `
<div
class="min-h-screen bg-gray-100 flex flex-col justify-center py-12 sm:px-6 lg:px-8"
>
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
</div>

<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
@if (errorMessage()) {
<div
class="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert"
>
<span class="block sm:inline">{{ errorMessage() }}</span>
</div>
}

<form (ngSubmit)="onSubmit()" class="space-y-6">
<div>
<label
for="email"
class="block text-sm font-medium text-gray-700"
>
Email address
</label>
<div class="mt-1">
<input
id="email"
name="email"
type="email"
required
[(ngModel)]="email"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>

<div>
<label
for="password"
class="block text-sm font-medium text-gray-700"
>
Password
</label>
<div class="mt-1">
<input
id="password"
name="password"
type="password"
required
[(ngModel)]="password"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>

<div>
<button
type="submit"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Sign in
</button>
</div>
</form>

<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500"> Or </span>
</div>
</div>

<div class="mt-6">
<a
routerLink="/signup"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-indigo-600 bg-white hover:bg-gray-50"
>
Create new account
</a>
</div>
</div>
</div>
</div>
</div>
`,
})
export class LoginComponent {
email = '';
password = '';
errorMessage = signal<string | null>(null);
private authStore = inject(useAuthStore);
private router = inject(Router);

async onSubmit() {
try {
await this.authStore.login(this.email, this.password);
this.router.navigate(['/chat']);
// Navigate to chat list after successful login
} catch (error) {
console.error('Login error:', error);
if (error instanceof Error) {
this.errorMessage.set(error.message);
} else {
this.errorMessage.set(
'An unexpected error occurred. Please try again.'
);
}
}
}
}

Chat List Component:

// src/app/components/chat-list/chat-list.component.ts

import { Component, inject, OnInit } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
import { Chat, useChatStore } from '../stores/chat.store';
import { useAuthStore } from '../stores/auth.store';
import { DatePipe } from '@angular/common';

@Component({
selector: 'app-chat-list',
standalone: true,
imports: [RouterModule, DatePipe],
template: `
<div class="min-h-screen bg-gray-100">
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Chats</h1>
<a
routerLink="/new-chat"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
New Chat
</a>
</div>

<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul role="list" class="divide-y divide-gray-200">
@for (chat of chatStore.chats(); track chat.id) {
<li>
<a
[routerLink]="['/chat', chat.id]"
class="block hover:bg-gray-50"
>
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-indigo-600 truncate">
{{ getChatName(chat) }}
</p>
<div class="ml-2 flex-shrink-0 flex">
<p
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"
>
{{
chat.lastMessageTimestamp.toDate() | date : 'short'
}}
</p>
</div>
</div>
<div class="mt-2 sm:flex sm:justify-between">
<div class="sm:flex">
<p class="flex items-center text-sm text-gray-500">
{{ chat.lastMessage }}
</p>
</div>
</div>
</div>
</a>
</li>
}
</ul>
</div>

@if (chatStore.chats().length === 0) {
<p class="mt-4 text-gray-500">
No chats available. Start a new chat!
</p>
}

<button
(click)="logout()"
class="mt-6 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Logout
</button>
</div>
</div>
</div>
`,
})
export class ChatListComponent implements OnInit {
chatStore = inject(useChatStore);
authStore = inject(useAuthStore);
router = inject(Router);

ngOnInit() {
const userId = this.authStore.currentUser()?.uid;
if (userId) {
this.chatStore.listenToChats(userId);
} else {
this.router.navigate(['/login']);
}
}

getChatName(chat: Chat): string {
if (chat.participantNames) {
return chat.participantNames
.filter((name) => name !== this.authStore.currentUser()?.displayName)
.join(', ');
}
return 'Loading...';
}

logout() {
this.authStore.logout();
this.router.navigate(['/login']);
}
}

Chat Detail Component:

// src/app/components/new-chat/new-chat.component.ts

import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { useChatStore } from '../stores/chat.store';

@Component({
selector: 'app-new-chat',
standalone: true,
imports: [FormsModule],
template: `
<div
class="min-h-screen bg-gray-100 flex flex-col justify-center py-12 sm:px-6 lg:px-8"
>
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Start a New Chat
</h2>
</div>

<div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
@if (errorMessage()) {
<div
class="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert"
>
<span class="block sm:inline">{{ errorMessage() }}</span>
</div>
}

<form (ngSubmit)="onSubmit()" class="space-y-6">
<div>
<label
for="email"
class="block text-sm font-medium text-gray-700"
>
Participant's Email
</label>
<div class="mt-1">
<input
id="email"
name="email"
type="email"
required
[(ngModel)]="participantEmail"
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>

<div>
<button
type="submit"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Start Chat
</button>
</div>
</form>
</div>
</div>
</div>
`,
})
export class NewChatComponent {
participantEmail = '';
errorMessage = signal<string | null>(null);
private chatStore = inject(useChatStore);
private router = inject(Router);

async onSubmit() {
try {
const chatId = await this.chatStore.createNewChat(this.participantEmail);
this.router.navigate(['/chat', chatId]);
} catch (error) {
console.error('New chat error:', error);
if (error instanceof Error) {
this.errorMessage.set(error.message);
} else {
this.errorMessage.set(
'An unexpected error occurred. Please try again.'
);
}
}
}
}

Conclusion

This article provides a comprehensive guide to building a real-time chat application using Angular and Firebase. By leveraging Firebase’s real-time capabilities and Angular’s robust framework, developers can create seamless, real-time communication experiences for users. The provided code examples and step-by-step instructions aim to facilitate the development process and demonstrate the practical implementation of these technologies.

Whether you are building a social media platform, a collaborative tool, or any application requiring real-time updates, this guide serves as a valuable resource for integrating real-time chat functionality into your projects. You can access the complete source code for this project on GitHub: Real-Time Chat Application.

--

--