Architecture
Data models
This section documents how the chat template stores data in Firebase Realtime Database and Cloud Firestore, and how the frontend consumes these models.
Realtime Database
Realtime Database is used for fast, ephemeral state that changes very frequently: user presence, typing status, and in-room presence. This keeps Firestore writes minimal and reduces cost.
Presence tree
Each signed-in user has a single presence node, updated based on their connection state.
presence/{uid} {
status: "online" | "offline";
updatedAt: ServerTimestamp;
}Implemented in src/services/presence.service.ts using onDisconnect to automatically set users offline when their connection drops.
Typing indicators
Every room has a node with the list of users currently typing in that room.
typing/{roomId}/{uid}: true | null- Write:
handleTyping(roomId, uid)intyping.service.ts - Read:
subscribeToTyping(roomId)+useTypingIndicator
Room presence
Tracks which users are currently viewing a specific room.
room_presence/{roomId}/{uid}: true- Enter room:
enterRoom(uid, roomId) - Leave room:
leaveRoom(uid, roomId) - Subscribe:
subscribeToRoomPresence(roomId)+useRoomPresence
Firestore
Firestore holds all persistent data: users, rooms and messages. Reads are optimized for the main UI queries: "my rooms" and "messages in a room".
Users collection
users/{uid} {
displayName: string;
avatarURL: string | null;
email: string;
createdAt: Timestamp;
}Used by user search, avatars and the chat header. Managed via user-info.service.ts and useUserInfo.
Rooms collection
rooms/{roomId} {
type: "private" | "group";
groupName?: string;
groupAvatarURL?: string;
// Participants
participants: string[]; // uids in this room
participantsCount: number; // denormalized for fast ordering
// Per-user unread counts
unreadCounts: {
[uid: string]: number;
};
// Metadata
createdBy: string; // uid of creator
createdAt: Timestamp;
// Denormalized last message for room list preview
lastMessage?: {
text: string;
senderId: string;
createdAt: Timestamp;
type: "text" | "image" | "file" | "system";
};
}- My rooms: query by
where("participants", "array-contains", uid)andorderBy("lastMessage.createdAt", "desc")(seerooms.service.ts). - Unread counts: incremented per user in a Firestore batch whenever a message is sent.
Messages sub-collection
rooms/{roomId}/messages/{messageId} {
senderId: string;
text: string;
type: "text" | "image" | "file" | "system" | "reply";
createdAt: Timestamp; // used for ordering
}- Messages are always queried with
orderBy("createdAt", "asc")to render the timeline. - Sending a message uses a Firestore batch to save the message and update
rooms/{roomId}.lastMessageplusunreadCountsin one atomic operation (seemessages.service.ts).
How the frontend uses these models
- Room list: uses
subscribeRoomsByUserto listen to all rooms where the current user is a participant. - Message list: uses
subscribeMessagesByRoomIdto stream messages in the active room. - Typing indicator: combines Realtime Database subscriptions with
useTypingIndicatorto show who is currently typing. - Presence: the
PresenceSyncProvidersets up the current user's presence and exposes helpers to the rest of the app.
You can adjust these models (for example, add message reactions or per-room settings) as long as you keep the queries used in the services compatible with your new schema.