Small group chat app with three fixed rooms (General, Random, Dev). Users send text and short voice messages. A Node backend sends Expo push notifications so tapping a notification opens the correct room.
- Frontend: Expo (React Native) + TypeScript + Redux Toolkit + react-native-paper
- Data: Firebase Firestore (messages) + Firebase Storage (audio)
- Push:
expo-notifications+ Expo Push API via Express backend - Build: EAS Build (Android APK)
root/
frontend/ # Expo app
backend/ # Express push relay
README.md
firestore.rules
storage.rules
- Node.js LTS
- Expo account (
npm i -g eas-clitheneas login) - Firebase project with Firestore and Storage enabled
- Physical Android device (push does not fully work in Expo Go for production-like FCM flows; use an EAS APK)
- FCM credentials configured in EAS (
eas credentials) for Android push
- Create a Firebase project.
- Enable Firestore and Storage.
- Deploy rules from this repo (or paste them in the console):
firebase deploy --only firestore:rules,storage- Create a Web app in Firebase and copy the config values.
cd backend
cp .env.example .env
npm install
npm run devFill backend/.env:
PORT(optional, default4000)- Firebase Admin (required for user registration add-on): either
GOOGLE_APPLICATION_CREDENTIALS— path to a service account JSON file, orFIREBASE_SERVICE_ACCOUNT_JSON— full JSON string (useful on Render)
Create a service account in Firebase Console → Project settings → Service accounts → Generate new private key. Keep the file out of git.
Backend listens on http://0.0.0.0:4000.
Endpoints:
GET /healthPOST /register—{ "token": "<expo-push-token>", "senderName": "Ada", "userId?", "notificationPreferencesByRoom?": { "general": "mentions", "random": "all" } }POST /notify—{ "roomId", "roomName", "senderName", "senderToken", "messageType": "text"|"voice", "text?", "mentionedUserIds?" }POST /users/lookup—{ "email" }→{ found, id?, email?, name?, avatarId?, suggestions }POST /users/register—{ "email", "name", "avatarId" }→{ id, email, name, avatarId, suggestions }(or409if name taken)GET /users/ids—{ "ids": ["..."], "profiles": [{ "id", "avatarId" }] }(for read-receipt ticks + avatars)
Device tokens are stored in memory (restarts clear them), as allowed by the assignment. Registered users are stored in Firestore users.
cd frontend
cp .env.example .envFill .env:
EXPO_PUBLIC_API_URL— your PC LAN IP, e.g.http://192.168.1.10:4000(notlocalhoston a physical device)- Firebase
EXPO_PUBLIC_FIREBASE_*values from the Firebase console
npm install
npx expo startFor day-to-day chat UI work you can use Expo Go.
Important: From SDK 53, Android remote push does not work in Expo Go (the app will skip push APIs there). To test notifications you must use an EAS development or preview APK (eas build -p android --profile preview or a development build), with FCM credentials configured.
cd frontend
eas initReplace placeholders in app.json:
extra.eas.projectIdowner
Upload FCM V1 credentials:
eas credentialsBuild an installable APK:
eas build -p android --profile previewInstall the APK on a fresh Android device.
- App requests permission and obtains an Expo push token.
- App registers the token with
POST /register. - After a message is written to Firestore, the sender calls
POST /notify. - Backend fans out Expo push messages to all tokens except the sender.
- Notification
data.roomIdis used for deep linking. - Tap handling covers:
- App open (foreground listener)
- Background
- Fully closed / cold start via
getLastNotificationResponseAsync
- Navigation
resets toRooms → Chat(roomId)so Back returns to the rooms list.
Cold start differs because the JS runtime was not running; the last notification response must be read after navigation mounts.
- Text messages send and appear in the correct room
- Voice recording works; mic denial does not crash
- Voice messages upload and play back
- Playing one voice message stops any other
- Notification arrives (app open / background / killed)
- Tap opens the correct room in all three states
- Back from notification-opened chat lands on rooms list
- Message list handles a few hundred messages without lag
- Keyboard does not cover the input
- APK installs on a fresh device
- Enter display name; open a room; send text; show it on a second device
- Record and send a voice message; play it; start another to show single playback
- Deny mic permission (or revoke) and show graceful handling
- Notification while app open → tap → correct room
- Background the app → notification → tap → correct room
- Force-stop / swipe away → notification → cold start → correct room + Back to rooms
- Brief walkthrough of
notificationService+useNotificationNavigation+ backend/notify - Any add-ons (registration, avatars, edit/delete, read receipts, typing, presence below)
- Replace demo Firebase rules with authenticated access
- Persist push tokens (DB) if the backend must survive restarts in production
- Host the backend publicly (Railway/Fly/Render) instead of LAN IP
- Complete EAS
projectId/ FCM credentials on your Expo account before final APK testing - Add real auth (password / OTP) if registration leaves demo mode
What: Users enter an email + unique display name before joining rooms. Returning emails autofill the registered name (editable). Fancy name suggestions are always shown.
Why: Personalizes the app and avoids display-name collisions (own-message bubbles and chat identity).
How it works:
- Frontend
NameScreen→POST /users/lookupandPOST /users/register - Backend stores
{ id, email, name }in Firestoreusers(plusemailLower/nameLowerfor uniqueness) - Email format is validated on client and server; names are globally unique (case-insensitive)
- Changing a name updates the profile only; older messages keep the previous
senderName
Security caveat (demo only): There is no password or OTP. Anyone who knows an email can open as that user. Do not use this pattern for production.
Setup: Add Firebase Admin credentials to backend/.env (see Backend setup above). Client Firestore rules deny direct access to users; only the Admin SDK writes.
What: Long-press your own chat bubble to edit a text message or permanently delete a text/voice message.
Why: Lets users fix typos and remove mistaken sends without leaving leftover “deleted” placeholders.
How it works:
- Own messages are matched by
senderId(when present) with a fallback tosenderNamefor older messages - New sends store
senderIdfrom the registered user profile - Choosing Edit loads the text into the chat input; Send saves the change and Cancel (×) exits edit mode
- Text edit updates Firestore
textand setseditedAt(shown as “edited” on the bubble) - Hard delete removes the Firestore document; voice messages also attempt to delete the Storage audio file afterward
- Room list previews update automatically via the existing latest-message listener
Security caveat (demo only): Chat Firestore/Storage write rules remain open for the three rooms (same as the rest of the chat demo). Do not treat client-side ownership checks as real authorization.
What: Own messages show WhatsApp-style ticks (single gray = sent, double blue = read by every other registered user). Re-entering a room with unread traffic shows a “New messages” separator. The list also auto-scrolls to the latest message.
Why: Makes delivery/read state visible in a multi-user room without adding a full chat backend.
How it works:
- Each viewer writes
rooms/{roomId}/reads/{userId}.lastReadAtwhile the chat is open (throttled) and on leave GET /users/idsloads all registered accounts (clients cannot read the privateuserscollection)- Ticks turn double-blue only when every other registered user has
lastReadAt >= message.createdAt - On enter, the previous
lastReadAtis captured before updating, and used to place the separator - Solo account (no other registered users) shows blue ticks immediately (vacuous “read by all”)
Caveat: If a new user registers later, previously blue ticks can drop back to single until that user also opens and reads the room.
Security caveat (demo only): reads docs use the same open room write rules as messages. Redeploy firestore.rules after pulling this change.
What: On registration, users pick one of 9 original comic-hero avatars. The choice shows on incoming chat bubbles for everyone.
Why: Personalizes the room without image uploads or Storage usage.
How it works:
- Avatar art lives only on the frontend (
constants/avatars.ts+UserAvatar) — original hero faces, not third-party IP - Firestore
usersstoresavatarId("1"…"9") with the profile - Returning users get their saved avatar from lookup; they can change it before Continue
- Chat resolves others’ avatars via
GET /users/ids→profilesusingmessage.senderId(legacy messages withoutsenderIdfall back to initials)
What: While someone is composing in a room, others see “Ada is typing…” above the composer.
Why: Makes the chat feel live in a two-device demo without extra backend work.
How it works:
- Composer keystrokes write
rooms/{roomId}/typing/{userId}withdisplayName+updatedAt(throttled ~900ms) - Idle (~2.5s), send, leave room, or clearing the input deletes the typing doc
- Listeners ignore their own doc and treat entries older than ~4s as stale (client TTL)
- Labels: one name, two names, or “Several people are typing…”
Security caveat (demo only): Typing docs use the same open room write rules. Redeploy firestore.rules after pulling this change.
What: Chat shows a people strip of other users with a green online dot or a red offline dot plus relative last seen (2m ago).
Why: Demonstrates real-time presence with Firestore and AppState.
How it works:
- While the app is foregrounded, the client writes
presence/{userId}(isOnline: true) and heartbeats about every 25s - Background / leave clears to offline and sets
lastSeenAt - Chat subscribes to all presence docs (excludes self); stale online heartbeats (>45s) render as offline
- Redeploy
firestore.rulessopresence/{userId}is readable/writable
What: Type @ in the composer to tag someone (e.g. @Ada). Suggestions come from the presence people list; mentions are highlighted in bubbles.
Why: Makes addressing people in a group room explicit and easy to demo.
How it works:
@at the end of the input opens filtered suggestions (excludes self)- Selecting a name inserts
@DisplayName - On send/edit, matched mentions are stored as
mentionedUserIds/mentionedNames - Bubble text highlights known
@Namespans
What: Chat header menu (⋮) → Chat notification settings — choose All messages or Only mentions for that room.
Why: Lets users cut noise in busy rooms while still getting pinged when tagged, without affecting other rooms.
How it works:
- Preference is stored per room in Redux (persisted) and sent with
POST /registerasnotificationPreferencesByRoom - On send,
POST /notifyincludesroomId+mentionedUserIds - Backend applies that device’s preference for the message’s room only (
mentions→ skip unlessuserIdis mentioned) - Rooms with no saved preference default to
all
Roughly ~18 hours total:
| Area | Approx. time |
|---|---|
| Setup, Expo project structure, env / Firebase wiring | ~2.5 h |
| Frontend screens (name, rooms, chat) + Redux + Paper UI | ~4 h |
Voice record / upload / playback (expo-audio, Storage quirks) |
~3 h |
Express backend (/register, /notify, Expo Push) |
~2 h |
| Android FCM + EAS credentials + native / USB debug build | ~2.5 h |
| Notification deep links + device testing (open / background / killed) | ~2 h |
| Add-ons / polish (e.g. mentions, per-room prefs, Render host) + README | ~2 h |
Estimates only;
| Location | Command | Purpose |
|---|---|---|
frontend |
npm start |
Expo dev server |
frontend |
eas build -p android --profile preview |
APK build |
backend |
npm run dev |
Start push API with reload |
backend |
npm start |
Start push API |