Architecture

How ThisIsHumanMade's messaging system works

Firebase Realtime DB, native macOS and iOS apps in SwiftUI, push notifications via FCM and Cloud Functions. A technical breakdown of a "human-first" chat built without a custom backend.

This article documents a project built entirely with AI. The code - Swift, JavaScript, Cloud Functions - was generated by Claude Code. But the AI doesn't decide anything on its own. I choose the architecture, validate every technical decision, and debug when things go wrong. The AI is the instrument, the developer remains the conductor. And it's precisely because I have 20 years of dev experience behind me that I know what to ask it, when to correct it, and where it's wrong.

On thisishumanmade.com, when a visitor writes a message in the chat, I get a push notification on my iPhone - even if the app is killed. I reply, and the visitor sees my reply in real time. No Crisp, no Intercom, no SaaS. Just Firebase, some Swift, and a 50-line Cloud Function.

Here's how it all fits together.

HumanMade app on macOS - menu bar with sidebar conversations and chat
The macOS app: status, conversation list, and real-time chat.

Overview

The system consists of 4 components:

  1. The website - a vanilla JS chat widget that writes to Firebase Realtime DB
  2. Firebase Realtime DB - the real-time database that syncs everything
  3. The native app - SwiftUI, available on macOS (menu bar) and iOS (NavigationStack)
  4. A Cloud Function - triggered on every new message, sends a push via FCM
Visitor Admin (Emmanuel) thisishumanmade.com macOS App iOS App | | | | write message | observe() | observe() v v v +------------------------------------------+ | Firebase Realtime Database | | /conversations/{id}/messages/{msgId} | +------------------------------------------+ | | onValueCreated (trigger) v +---------------------+ | Cloud Function | | notifyNewMessage | +---------------------+ | | admin.messaging().send() v +---------------------+ | FCM --> APNs | +---------------------+ | v Push notification on iPhone

The website: vanilla JS chat widget

The chat on the site is a framework-free JavaScript widget. When a visitor opens the page, a conversationId is generated and stored in sessionStorage. Each message is written to Firebase Realtime DB via the JavaScript SDK:

// Ecriture d'un message visiteur
const msgRef = push(ref(db, `conversations/${convId}/messages`));
set(msgRef, {
  text: message,
  sender: visitorName,
  timestamp: Date.now()
});

The choice of sessionStorage (rather than localStorage) is deliberate: a refresh keeps the conversation, but a new tab creates a new one. This lets the visitor have independent conversations.

The Firebase SDK also listens for replies in real time with onChildAdded, which displays replies instantly without polling.

Firebase Realtime DB: the backbone

All the data flows through Firebase Realtime Database. No custom backend, no REST API to maintain. The structure is simple:

{
  "status": {
    "current": "available",
    "updatedAt": 1707840000000
  },
  "statusConfig": {
    "available": { "label": "Disponible", "emoji": "🟢", "chatMessage": "..." },
    "busy":      { "label": "Occupe",     "emoji": "🟠", "chatMessage": "..." },
    "offline":   { "label": "Absent",     "emoji": "⚪",  "chatMessage": "..." }
  },
  "conversations": {
    "-ABC123": {
      "visitorName": "Marie",
      "startedAt": 1707840000000,
      "lastMessageAt": 1707840060000,
      "status": "active",
      "messages": {
        "-MSG001": { "text": "Bonjour !", "sender": "Marie", "timestamp": ... },
        "-MSG002": { "text": "Salut Marie", "sender": "emmanuel", "timestamp": ... }
      }
    }
  },
  "admin": {
    "fcmToken": "dhOeso..."
  }
}

Why Realtime DB and not Firestore?

For this use case (1 admin, a few simultaneous visitors, simple data), Realtime Database is a better fit than Firestore:

  • Latency - RTDB is optimized for small real-time data (~10ms vs ~50ms for Firestore)
  • Pricing - billed on bandwidth + storage, not on number of reads (an RTDB observer counts as 1 read, not N)
  • Native SSE - the Server-Sent Events protocol lets you plug in any REST client without an SDK

Firestore shines for complex queries, composite indexes, and horizontal scaling. Here, none of that is needed.

Security rules

{
  "rules": {
    "conversations": { ".read": true, ".write": true },
    "admin": {
      "fcmToken": { ".read": false, ".write": true }
    }
  }
}

The FCM token is write-only. The iOS app can write it, but no client can read it. Only the Cloud Function accesses it - via the Admin SDK, which bypasses security rules. This prevents a visitor from retrieving the token to send unauthorized pushes.

The macOS app: menu bar with SwiftUI

The macOS app lives in the menu bar. No dock icon, no permanent window - just a discreet icon with an unread message badge.

It uses the Firebase iOS SDK (which also works on macOS via Swift Package Manager) and maintains an observer .observe(.value) on the node /conversations. Every change in the DB triggers an instant callback.

// Observer temps reel sur toutes les conversations
conversationsHandle = db.child("conversations")
    .observe(.value) { snapshot in
        // Parse + diff + notification locale si nouveau message
    }

When a new visitor message arrives, the app:

  1. Detects unknown messages by diffing IDs (Set existing vs new)
  2. Increments the counter unreadCount
  3. Sends a local notification via UNUserNotificationCenter

On macOS, no need for FCM push - the app is always alive in the menu bar. Firebase observers never die.

The iOS app: same code, different problems

The iOS app shares 90% of the code thanks to #if os(iOS) / #if os(macOS). The views are in conditional extensions:

struct MenuBarView: View {
    var body: some View {
        #if os(iOS)
        iOSBody      // NavigationStack
        #else
        macOSBody    // HStack avec sidebar
        #endif
    }
}

The iOS background problem

On macOS, Firebase observers run indefinitely. On iOS, it's a different story:

  • iOS suspends the app ~30 seconds after going to background
  • Network connections are cut - Firebase observers die
  • Messages pile up and arrive all at once on reopening

That's why local notifications aren't enough on iOS. You need a server-side system that pushes notifications even when the app is dead.

FCM + Cloud Function: push that actually works

The solution has two parts:

1. The iOS app registers with FCM

On launch, the app requests an APNs token from Apple, passes it to Firebase Cloud Messaging, and stores the FCM token in the DB:

// iOSAppDelegate.swift
class iOSAppDelegate: NSObject, UIApplicationDelegate, MessagingDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions ...) -> Bool {
        FirebaseApp.configure()
        Messaging.messaging().delegate = self
        application.registerForRemoteNotifications()
        return true
    }

    // Apple donne un token APNs binaire -> on le passe a FCM
    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        Messaging.messaging().apnsToken = deviceToken
    }

    // FCM echange le token APNs contre son propre token string
    func messaging(_ messaging: Messaging,
                   didReceiveRegistrationToken fcmToken: String?) {
        guard let token = fcmToken else { return }
        // Stocke dans Firebase RTDB a /admin/fcmToken
        FirebaseService.shared.storeFCMToken(token)
    }
}

The key point: there are two different tokens. The APNs token is a binary blob from Apple. FCM exchanges it for its own string token. It's this FCM token that the Cloud Function uses.

2. The Cloud Function sends the push

A Cloud Function onValueCreated triggers on every new message in the DB:

// functions/index.js
const { onValueCreated } = require("firebase-functions/v2/database");

exports.notifyNewMessage = onValueCreated(
  { ref: "/conversations/{convId}/messages/{msgId}", region: "us-central1" },
  async (event) => {
    const message = event.data.val();

    // Ignore nos propres reponses
    if (message.sender === "emmanuel") return null;

    const visitorName = await getVisitorName(event.params.convId);
    const fcmToken = await getFCMToken();
    if (!fcmToken) return null;

    await getMessaging().send({
      token: fcmToken,
      notification: { title: visitorName, body: message.text },
      data: { conversationId: event.params.convId },
      apns: { payload: { aps: { sound: "default", badge: 1 } } }
    });
  }
);

Important points:

  • onValueCreated only triggers on creation of a node, not on modification - no double notification if a message is edited
  • The filter sender === "emmanuel" avoids notifying yourself
  • The region us-central1 is mandatory - RTDB triggers must be in the same region as the database
  • If the token is invalid (app uninstalled), the function automatically cleans it up from the DB

The complete flow of a notification

1. Visitor types "Hello" on the site 2. Firebase JS SDK writes to /conversations/{id}/messages/{newId} 3. Cloud Function notifyNewMessage triggers (~200ms) 4. The function reads the fcmToken and calls admin.messaging().send() 5. FCM forwards to APNs (Apple's servers) 6. APNs pushes the notification to the iPhone 7. The user taps the notification -> the app opens on the conversation

Avoiding duplicate notifications

Without precautions, on iOS you'd receive two notifications when the app is in foreground:

  • A local notification (from the Firebase observer still running)
  • A push notification (from the Cloud Function via FCM)

The solution is simple: on iOS, the sendNotification() method does an immediate return . Local notifications are disabled - FCM takes care of it:

private func sendNotification(title: String, body: String, conversationId: String) {
    #if os(iOS)
    return  // FCM gere les push sur iOS
    #else
    // macOS : notification locale classique
    let content = UNMutableNotificationContent()
    content.title = title
    content.body = body
    content.sound = .default
    UNUserNotificationCenter.current().add(...)
    #endif
}

Badge and navigation handling

Badge on the icon

The Cloud Function sends badge: 1 in the APNs payload. When the user opens the app, the badge resets to zero via scenePhase :

// HumanMadeApp.swift
WindowGroup { ... }
.onChange(of: scenePhase) { phase in
    if phase == .active {
        UNUserNotificationCenter.current().setBadgeCount(0)
    }
}

Tapping the notification -> the right conversation

The FCM payload includes data.conversationId. When the user taps the notification, the NotificationDelegate retrieves the ID and stores it in a published property. The NavigationStack listens for this change and pushes the right view:

// NotificationDelegate
func userNotificationCenter(_ center: ..., didReceive response: ...) {
    let convId = response.notification.request.content.userInfo["conversationId"]
    FirebaseService.shared.pendingConversationId = convId
}

// MenuBarView (iOS)
.onChange(of: firebase.pendingConversationId) { convId in
    guard let convId else { return }
    firebase.pendingConversationId = nil
    navigationPath = NavigationPath()  // Reset
    navigationPath.append(convId)      // Push la conversation
}

Typing indicator ("is typing...")

When I type a reply on the iOS app, the visitor on the site sees the three animated dots appear - exactly like on iMessage or WhatsApp. The implementation relies on an ephemeral node in Firebase.

The Firebase node

Each conversation can contain a typingnode. This node only exists while Emmanuel is typing - it's removed as soon as he stops or sends the message:

"conversations": {
  "-ABC123": {
    "messages": { ... },
    "typing": true    // existe uniquement pendant la frappe
  }
}

Using removeValue() rather than setValue(false) is deliberate: it avoids storing unnecessary data. The node only exists when it has a reason to.

On iOS: typing detection

The app observes changes to the text field via .onChange(of: replyText). On every keystroke, it writes typing: true to Firebase and restarts a 2-second timer:

// ChatView.swift - detection de frappe
TextField("Repondre...", text: $replyText)
    .onChange(of: replyText) { newValue in
        if !newValue.trimmingCharacters(in: .whitespaces).isEmpty {
            firebase.setTyping(conversationId: conversationId, isTyping: true)
        }
    }

// A l'envoi : reset immediat
func sendReply() {
    firebase.setTyping(conversationId: conversationId, isTyping: false)
    firebase.sendReply(conversationId: conversationId, text: text)
}

Debounce on the service side

The FirebaseService manages an internal timer. Every call to setTyping(isTyping: true) restarts the 2-second timer. If the user stops typing, the timer expires and automatically removes the node:

// FirebaseService.swift
func setTyping(conversationId: String, isTyping: Bool) {
    let typingRef = db.child("conversations/\(conversationId)/typing")

    if isTyping {
        typingRef.setValue(true)
        typingRef.onDisconnectRemoveValue()  // securite : cleanup si crash

        typingTimer?.cancel()
        let timer = DispatchWorkItem {
            typingRef.removeValue()
        }
        typingTimer = timer
        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: timer)
    } else {
        typingTimer?.cancel()
        typingRef.removeValue()
    }
}

The onDisconnectRemoveValue() is a safeguard: if the app crashes or loses its connection, Firebase automatically removes the typing node server-side. Without it, the visitor would see a ghost "typing..." indefinitely.

On the website: displaying the dots

The site listens to the typing node with a onValue, listener. When the value becomes true, the three animated dots appear. When it disappears, they hide:

// Listener sur le noeud typing
fb.onValue(fb.ref(fb.db, 'conversations/' + convId + '/typing'), function(snapshot) {
  if (snapshot.val() === true) {
    if (!document.getElementById('typingIndicator')) showTyping();
  } else {
    hideTyping();
  }
});

The functions showTyping() and hideTyping() already existed for the intro sequence - they're reused as-is for the real-time indicator.

The complete flow

1. Emmanuel types in the TextField on iOS 2. .onChange(of: replyText) detects the keystroke 3. setTyping(isTyping: true) writes /conversations/{id}/typing: true 4. The website receives the change via onValue (~100ms) 5. showTyping() displays the three animated dots 6. Emmanuel sends the message or stops typing (2s) 7. setTyping(isTyping: false) removes the node 8. hideTyping() hides the dots

Project structure

thisishumanmade.com/ | |-- website/ | |-- index.html Chat widget (vanilla JS + Firebase SDK) | |-- firebase.json Firebase config (database + functions) |-- database.rules.json RTDB security rules | |-- functions/ Cloud Function (Node.js) | |-- index.js notifyNewMessage | |-- package.json | |-- mac-app-xcode/ |-- HumanMade/ |-- HumanMadeApp.swift @main + iOSAppDelegate (FCM) |-- HumanMade.entitlements aps-environment |-- Services/ | |-- FirebaseService.swift Observers + storeFCMToken() |-- Views/ | |-- MenuBarView.swift Shared UI iOS/macOS | |-- ChatView.swift Conversation |-- Models/ |-- Conversation.swift Data models

Tech stack

  • Website - vanilla HTML/CSS/JS, Firebase JS SDK v11 (ES modules)
  • Database - Firebase Realtime Database (Blaze plan, actual cost: 0 euros/month)
  • Native app - SwiftUI (cross-platform macOS + iOS), Firebase iOS SDK 12.9
  • Push notifications - Firebase Cloud Messaging + APNs
  • Cloud Function - Node.js 20, firebase-functions v5, trigger onValueCreated
  • Auth - none (single-admin, open DB rules, FCM token protected write-only)

What it costs

With the Blaze plan (pay-as-you-go):

  • Realtime Database - free tier: 1 GB storage, 10 GB/month transfer. More than enough.
  • Cloud Functions - free tier: 2 million invocations/month. For a few messages a day, that's ~0.
  • FCM - free, unlimited.

Total cost: 0 euros/month. The only investment is the Apple Developer account at 99 euros/year (required for APNs push).