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) in typing.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) and orderBy("lastMessage.createdAt", "desc") (see rooms.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}.lastMessage plus unreadCounts in one atomic operation (see messages.service.ts).

How the frontend uses these models

  • Room list: uses subscribeRoomsByUser to listen to all rooms where the current user is a participant.
  • Message list: uses subscribeMessagesByRoomId to stream messages in the active room.
  • Typing indicator: combines Realtime Database subscriptions with useTypingIndicator to show who is currently typing.
  • Presence: the PresenceSyncProvider sets 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.