# Firebase Realtime Chat — Reusable Build Prompt

## How to use this file

Paste this document to an AI coding assistant at the start of a **new project**
(or when adding chat to an existing one). Fill in the `{{PLACEHOLDER}}` values —
collection name prefix, chat-identity field, navigation route — then implement
section by section. This is a working pattern already proven in production
(Doliplay marketplace chat: `src/lib/chatService.ts` + `src/lib/MessagingContext.tsx`).

Stack assumed: **Next.js (App Router) + Firebase Firestore + React Context**.
No backend API and no Redux slice are required for the chat data path itself —
Firestore is both the transport and the store. If the project already has
[[firebase-auth-prompt]]-style hybrid auth, chat identity should key off the
same backend-issued user ID, not the Firebase Auth UID (see §2).

---

## 1. Core architecture principle

> **Firestore is the entire chat backend. There is no REST API for messages.**
> A thin service layer wraps every Firestore call; a React Context maps
> Firestore snapshots into UI-ready state; components only ever talk to the
> Context, never to Firestore directly.

```
Component → Context (subscribe/send) → chatService.ts → Firestore
Component ← Context (mapped state)   ← onSnapshot      ← Firestore
```

Why Firestore-only instead of backend + websockets:
- `onSnapshot` gives free real-time delivery, offline cache, and reconnect handling — don't rebuild that with a custom socket server.
- No polling, no backend fan-out logic for "who's in this conversation."
- The backend stays responsible for what it's good at (users, listings, orders); chat is a bounded, self-contained subsystem.

Trade-off to accept going in: Firestore security rules become the only
authorization boundary for chat data (see §9) — there is no server-side
gatekeeper checking "is this user allowed to read this conversation."

---

## 2. Identity: chat ID vs Firebase Auth UID

Don't assume the chat participant ID is the Firebase Auth UID. In a hybrid
auth setup, the backend already has its own user identifier (e.g. `channel_id`,
`user_id`) — **use that as the chat identity**, not the Firebase UID, so chat
works even for users who never went through Firebase Auth (e.g. backend-only
accounts) and stays consistent if the project ever swaps auth providers.

```ts
// {{getChatIdentity}} — resolve once per session, from your auth state/cookie
const myChatId = user.{{chat_id_field}} ?? Cookies.get("{{chat_id_field}}");
```

Register every user who participates in chat into a Firestore "directory"
collection on first contact — this is what lets you look up a conversation
partner's display name/avatar without a backend round-trip:

```ts
export interface ChatUserDoc {
  channel_id: string;   // {{chat_id_field}} — primary key, same as doc ID
  user_id: string;      // backend user id, if different from channel_id
  name: string;
  email: string;
  profileurl: string;
  isOnline: boolean;
  createdAt: number;
}

export async function registerChatUser(user: ChatUserDoc) {
  const ref = doc(db, USERS_COL, user.channel_id);
  const snap = await getDoc(ref);
  if (!snap.exists()) await setDoc(ref, user);
  // create-if-not-exists — never overwrite an existing profile on re-login
}
```

---

## 3. Firestore data model

Two top-level collections (prefix with `{{app}}` to avoid clashing with other
apps sharing one Firebase project):

```
{{app}}_users/{chatId}
  → ChatUserDoc (see §2)

{{app}}_chatrooms/{convId}
  → convid: string
  → users: [chatIdA, chatIdB]              ← array-contains queries key off this
  → contextId: string                      ← e.g. listingId/orderId/ticketId — WHAT the chat is about
  → contextTitle, contextImage, contextMeta ← denormalized preview fields
  → lastMessage: { content, idFrom, idTo, read, timestamp } | null

{{app}}_chatrooms/{convId}/{convId}/{timestamp}
  → FirestoreChatMessage: { idFrom, idTo, timestamp, content, type: number, read: boolean }
```

**Known gotcha to design around, not copy:** naming the messages subcollection
identically to the parent doc ID (`chatrooms/{convId}/{convId}/...`) works but
reads confusingly. Prefer `{{app}}_chatrooms/{convId}/messages/{timestamp}` —
functionally identical, clearer in the console and in queries.

**Message type field:** keep `type: number` (`0 = text`, reserve `1 = image`,
`2 = system`, etc.) from day one even if only text is implemented — adding a
type later means migrating every existing document.

### Conversation ID — deterministic, not random

Generate the conversation ID from the two participant IDs so both sides
compute the same doc without a lookup round-trip:

```ts
export function generateConvId(idA: string, idB: string): string {
  return idA > idB ? `${idA}-${idB}` : `${idB}-${idA}`;
}
```

**Decide up front whether a conversation is scoped per-context or per-pair:**
- *Per-pair* (as above): the same two users always land in one conversation,
  regardless of which listing/order/topic they're discussing. Simpler, but a
  second context with the same two users overwrites `contextId`/`contextTitle`
  on the existing doc — old context is lost unless you keep per-message context.
- *Per-context* (recommended if users can have multiple concurrent chats,
  e.g. a marketplace where two users chat about listing A and separately
  about listing B): fold the context into the ID —
  `generateConvId(idA, idB) + "-" + contextId`. Pick this unless the product
  genuinely only ever has one topic per user pair (e.g. 1:1 direct messaging).

---

## 4. Real-time listeners

Two `onSnapshot` subscriptions, both returning their raw unsubscribe function
so the caller owns cleanup:

```ts
export function subscribeConversations(
  myChatId: string,
  cb: (docs: FirestoreConversation[]) => void,
) {
  const q = query(
    collection(db, CHATROOMS_COL),
    where("users", "array-contains", myChatId),
  );
  return onSnapshot(q, (snap) => cb(snap.docs.map((d) => d.data() as FirestoreConversation)));
}

export function subscribeMessages(
  convId: string,
  msgLimit: number,
  cb: (docs: FirestoreChatMessage[]) => void,
) {
  const q = query(
    collection(db, CHATROOMS_COL, convId, MESSAGES_SUBCOL),
    orderBy("timestamp", "desc"),
    limit(msgLimit),
  );
  return onSnapshot(q, (snap) => cb(snap.docs.map((d) => d.data() as FirestoreChatMessage).reverse()));
}
```

Notes:
- Sort the conversation list **client-side** (by `lastMessage.timestamp`) rather than adding `orderBy` to the conversations query — avoids needing a composite index for `array-contains` + `orderBy`.
- Fetch messages `desc` + `limit`, then `.reverse()` for oldest-first render — this bounds read cost on long threads instead of loading the entire history.
- `limit` default of ~50 is a reasonable start; wire "load older" as a separate paginated query (`startAfter`) rather than raising the limit unbounded.

### Cleanup pattern (React Context or hook)

```ts
const convUnsubRef = useRef<(() => void) | null>(null);
const msgUnsubRef = useRef<(() => void) | null>(null);

useEffect(() => {
  if (!myChatId) return;
  convUnsubRef.current?.();
  convUnsubRef.current = subscribeConversations(myChatId, (docs) => {
    setConversations(docs.map(mapFirestoreToConversation));
  });
  return () => convUnsubRef.current?.();
}, [myChatId]);

useEffect(() => {
  if (!selectedConversationId) return;
  msgUnsubRef.current?.();
  msgUnsubRef.current = subscribeMessages(selectedConversationId, 50, (docs) => {
    setMessages(docs.map(mapFirestoreToMessage));
  });
  return () => msgUnsubRef.current?.();
}, [selectedConversationId]);
```

**Rule:** always tear down the previous listener before attaching a new one,
and always return the unsubscribe from the effect. Leaked `onSnapshot`
listeners are the #1 source of "chat still updates on a screen I navigated
away from" bugs and silent read-quota growth.

---

## 5. Starting a conversation

No backend call — purely a Firestore create-if-not-exists sequence, then a
client-side navigation:

```
startConversation(myChatId, myProfile, otherChatId, otherProfile, context):
  1. convId = generateConvId(myChatId, otherChatId) [+ context.id if per-context]
  2. registerChatUser(otherChatId, otherProfile)   — create-if-not-exists
  3. ensureConversation(convId, {
       convid: convId,
       users: [myChatId, otherChatId],
       contextId: context.id,
       contextTitle: context.title,
       contextImage: context.image,
       lastMessage: null,
     })                                             — create-if-not-exists, never overwrite an existing doc
  4. navigate to {{CHAT_ROUTE}}?conv={convId}
  5. on the chat page, once the conversation list has loaded, auto-select
     the `conv` query param — don't block navigation on the listener firing
```

```ts
export async function ensureConversation(convId: string, data: FirestoreConversation) {
  const ref = doc(db, CHATROOMS_COL, convId);
  const snap = await getDoc(ref);
  if (!snap.exists()) await setDoc(ref, data);
}
```

Register the **current** user into the chat directory once per session, as
soon as auth is ready — don't defer it to "first time they open a
conversation," or their own profile won't exist yet if someone messages them
first.

---

## 6. Sending a message

Two writes per send — the message itself, plus a denormalized `lastMessage`
on the parent doc (this is what drives the conversation-list preview and
unread badge without reading the whole subcollection):

```ts
export async function sendMessage(convId: string, myChatId: string, toChatId: string, content: string) {
  const ts = String(Date.now());
  const msgData: FirestoreChatMessage = {
    idFrom: myChatId,
    idTo: toChatId,
    timestamp: ts,
    content: content.trim(),
    type: 0,
    read: false,
  };
  await setDoc(doc(db, CHATROOMS_COL, convId, MESSAGES_SUBCOL, ts), msgData);
  await updateDoc(doc(db, CHATROOMS_COL, convId), { lastMessage: msgData });
}
```

Using the timestamp as the message doc ID gives free chronological ordering
and de-dupes accidental double-sends within the same millisecond is not a
real risk in practice — don't add a UUID unless the project needs
client-generated offline-queued IDs.

---

## 7. Read receipts and unread state — do this better than the baseline

The minimal version (single `read: boolean` per message, unread badge derived
from `lastMessage.read === false && lastMessage.idTo === myChatId`) only
supports a 0-or-1 "has unread" indicator, not an actual count, and is easy to
leave half-wired (e.g. computing a `readAt` field in the UI that the write
path never actually sets — a real bug to avoid, not a pattern to copy).

**Build it correctly from the start:**

```ts
export async function markConversationRead(convId: string, myChatId: string) {
  const q = query(
    collection(db, CHATROOMS_COL, convId, MESSAGES_SUBCOL),
    where("idTo", "==", myChatId),
    where("read", "==", false),
  );
  const snap = await getDocs(q);
  const batch = writeBatch(db);
  snap.docs.forEach((d) => batch.update(d.ref, { read: true, readAt: Date.now() }));
  if (snap.docs.length) {
    batch.update(doc(db, CHATROOMS_COL, convId), { "lastMessage.read": true });
  }
  await batch.commit();
}
```

- Set `readAt` here, in the same write that sets `read: true` — if a UI
  component reads `msg.readAt` for a checkmark icon, this is the only place
  that field is allowed to originate.
- Call `markConversationRead` when a conversation is opened **and** whenever
  a new message arrives while it's the active conversation (the `onSnapshot`
  callback already fires — hook it there).
- If you need a numeric unread *count* per conversation (not just a boolean),
  maintain it as a denormalized counter field updated via a
  [Cloud Function](#10-optional-cloud-functions) on message create — doing
  it purely client-side is unreliable across multiple open tabs/devices.

---

## 8. Optional: typing indicator

Don't ship a typing-indicator UI component without the presence write behind
it — a stubbed `typingConversationId: null` that never changes is worse than
no indicator at all (dead code that looks functional).

Minimal real implementation — a short-lived presence doc per user per
conversation, cleared automatically via a TTL/manual clear:

```ts
export async function setTyping(convId: string, myChatId: string, isTyping: boolean) {
  const ref = doc(db, CHATROOMS_COL, convId, "typing", myChatId);
  if (isTyping) await setDoc(ref, { at: Date.now() });
  else await deleteDoc(ref);
}

// subscribe to the sibling's typing doc, treat "exists and < 5s old" as typing
export function subscribeTyping(convId: string, otherChatId: string, cb: (isTyping: boolean) => void) {
  const ref = doc(db, CHATROOMS_COL, convId, "typing", otherChatId);
  return onSnapshot(ref, (snap) => {
    const data = snap.data();
    cb(!!data && Date.now() - data.at < 5000);
  });
}
```

Debounce the write (call `setTyping(true)` on keystroke, `setTyping(false)`
after ~3s of inactivity or on send) — writing on every keystroke is wasteful
and will hit Firestore write-rate limits on active conversations.

---

## 9. Optional: image/file attachments (Firebase Storage)

Not needed for text-only chat, but if the project requires it:

```ts
import { getStorage, ref, uploadBytes, getDownloadURL } from "firebase/storage";

export async function sendImageMessage(convId: string, myChatId: string, toChatId: string, file: File) {
  const storage = getStorage();
  const path = `{{app}}_chat_uploads/${convId}/${Date.now()}_${file.name}`;
  const storageRef = ref(storage, path);
  await uploadBytes(storageRef, file);
  const url = await getDownloadURL(storageRef);

  const ts = String(Date.now());
  const msgData: FirestoreChatMessage = {
    idFrom: myChatId, idTo: toChatId, timestamp: ts,
    content: url, type: 1, // 1 = image, per the type convention from §3
    read: false,
  };
  await setDoc(doc(db, CHATROOMS_COL, convId, MESSAGES_SUBCOL, ts), msgData);
  await updateDoc(doc(db, CHATROOMS_COL, convId), { lastMessage: { ...msgData, content: "📷 Photo" } });
}
```

Use a placeholder string (`"📷 Photo"`) for `lastMessage.content` in image
messages so the conversation-list preview doesn't render a raw URL.

---

## 10. Optional: Cloud Functions

Anything that must happen exactly once regardless of how many clients are
listening (push notifications on new message, denormalized unread counters,
pruning old typing docs) belongs in a Cloud Function triggered on document
create, not in client code — every open client tab would otherwise fire it
redundantly.

```
onDocumentCreated("{{app}}_chatrooms/{convId}/messages/{msgId}", async (event) => {
  // send push notification to idTo, increment unread counter, etc.
});
```

Skip this section entirely for an MVP — client-only writes are fine until
push notifications or accurate unread counts are actually required.

---

## 11. Firestore security rules — do not ship without these

This is the part most tutorials skip and most incidents come from. Chat has
no backend gatekeeper, so rules are the *only* thing stopping user A from
reading user B's conversations.

```
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    match /{{app}}_users/{chatId} {
      allow read: if request.auth != null;
      allow write: if request.auth != null && request.auth.uid == chatId;
    }

    match /{{app}}_chatrooms/{convId} {
      allow read, update: if request.auth != null
        && request.auth.uid in resource.data.users;
      allow create: if request.auth != null
        && request.auth.uid in request.resource.data.users;

      match /messages/{msgId} {
        allow read: if request.auth != null
          && request.auth.uid in get(/databases/$(database)/documents/{{app}}_chatrooms/$(convId)).data.users;
        allow create: if request.auth != null
          && request.resource.data.idFrom == request.auth.uid;
        allow update: if request.auth != null
          && request.resource.data.diff(resource.data).affectedKeys().hasOnly(["read", "readAt"]);
      }
    }
  }
}
```

**If chat identity (§2) isn't the Firebase Auth UID**, these rules need a
different anchor — either store the Firebase UID alongside the chat ID in
the user doc and look it up, or issue Firebase custom auth tokens keyed to
the same backend user ID so `request.auth.uid` *is* the chat ID. Don't ship
rules that trust a client-supplied `chatId` field with no server-verified
link to `request.auth.uid` — that's an unauthenticated read/write path.

---

## 12. Error-handling and UX rules

- ❌ Never let a `permission-denied` from a security rule surface as a raw error toast — map it to "You don't have access to this conversation."
- ❌ Never write on every keystroke for anything other than typing presence (§8), and even that should be debounced.
- ❌ Never assume `onSnapshot`'s first callback is real-time data — the first emission may be a cached/offline snapshot; check `snap.metadata.fromCache` if staleness matters.
- ✅ Show an optimistic pending state on send (append the message locally, replace/reconcile when the snapshot listener echoes it back) — Firestore round-trips are usually fast but not zero-latency.
- ✅ Unsubscribe every listener on unmount and on conversation switch (§4) — no exceptions.
- ✅ Keep the message subcollection paginated (§4) — never query an entire thread's history on every load.

---

## 13. NPM packages

```bash
npm install firebase
```

(No additional chat-specific package needed — `firebase/firestore` covers
everything above. Add `date-fns` or similar only if message timestamps need
relative formatting like "2m ago".)

---

## 14. Per-project adaptation checklist

- [ ] Fill in `{{app}}` collection prefix and `{{chat_id_field}}` (match it to the project's actual backend user identifier, per §2).
- [ ] Decide per-pair vs per-context conversation IDs (§3) before writing any data — changing this later means migrating every conversation doc.
- [ ] Rename the messages subcollection to something clearer than "same name as parent doc" (§3).
- [ ] Write and deploy the Firestore security rules from §11 — do not defer this past the first real user.
- [ ] Implement read receipts with `readAt` actually set on write (§7) if the UI shows read-status icons — don't ship a UI that reads a field nothing ever writes.
- [ ] Only build the typing indicator (§8) if you're also wiring the presence write — otherwise leave the UI out entirely.
- [ ] Add Firebase Storage (§9) only if attachments are in scope for this project.
- [ ] Confirm `getApps().length` HMR guard exists in `lib/firebase.ts` (shared with [[firebase-auth-prompt]] — same file, same guard).
