Comment fonctionne le systeme de messagerie de ThisIsHumanMade

Firebase Realtime DB, app native macOS et iOS en SwiftUI, push notifications via FCM et Cloud Functions. Autopsie technique d'un chat "humain-first" construit sans backend custom.

Cet article documente un projet entierement construit avec l'IA. Le code — Swift, JavaScript, Cloud Functions — a ete genere par Claude Code. Mais l'IA ne decide de rien toute seule. C'est moi qui choisis l'architecture, qui valide chaque decision technique, qui debugge quand ca deraille. L'IA est l'instrument, le developpeur reste le chef d'orchestre. Et c'est justement parce que j'ai 20 ans de dev derriere moi que je sais quoi lui demander, quand la corriger, et ou elle se trompe.

Sur thisishumanmade.com, quand un visiteur ecrit un message dans le chat, je recois une notification push sur mon iPhone — meme si l'app est tuee. Je reponds, et le visiteur voit ma reponse en temps reel. Pas de Crisp, pas d'Intercom, pas de SaaS. Juste Firebase, du Swift et une Cloud Function de 50 lignes.

Voici comment tout ca s'articule.

App HumanMade sur macOS — menu bar avec sidebar conversations et chat
L'app macOS : status, liste des conversations, et chat en temps reel.

Vue d'ensemble

Le systeme tient en 4 composants :

  1. Le site web — un chat widget en vanilla JS qui ecrit dans Firebase Realtime DB
  2. Firebase Realtime DB — la base de donnees temps reel qui synchronise tout
  3. L'app native — SwiftUI, disponible sur macOS (menu bar) et iOS (NavigationStack)
  4. Une Cloud Function — declenchee a chaque nouveau message, envoie un push via FCM
Visiteur Admin (Emmanuel) thisishumanmade.com App macOS App iOS | | | | 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 sur iPhone

Le site web : chat widget en vanilla JS

Le chat sur le site est un widget JavaScript sans framework. Quand un visiteur ouvre la page, un conversationId est genere et stocke dans sessionStorage. Chaque message est ecrit dans Firebase Realtime DB via le SDK JavaScript :

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

Le choix de sessionStorage (et non localStorage) est delibere : un refresh garde la conversation, mais un nouvel onglet en cree une nouvelle. Le visiteur peut ainsi avoir des conversations independantes.

Le SDK Firebase ecoute aussi les reponses en temps reel avec onChildAdded, ce qui affiche les reponses instantanement sans polling.

Firebase Realtime DB : la colonne vertebrale

Toute la donnee transite par Firebase Realtime Database. Pas de backend custom, pas d'API REST a maintenir. La structure est 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..."
  }
}

Pourquoi Realtime DB et pas Firestore ?

Pour ce cas d'usage (1 admin, quelques visiteurs simultanes, donnees simples), Realtime Database est plus adapte que Firestore :

  • Latence — RTDB est optimise pour les petites donnees temps reel (~10ms vs ~50ms pour Firestore)
  • Pricing — facture au bandwidth + storage, pas au nombre de reads (un observer RTDB compte comme 1 read, pas N)
  • SSE natif — le protocole Server-Sent Events permet de brancher n'importe quel client REST sans SDK

Firestore brille pour les queries complexes, les indexes composes et le scaling horizontal. Ici, on n'a besoin de rien de tout ca.

Regles de securite

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

Le FCM token est en write-only. L'app iOS peut l'ecrire, mais aucun client ne peut le lire. Seule la Cloud Function y accede — via l'Admin SDK qui bypass les regles de securite. Cela empeche un visiteur de recuperer le token pour envoyer des pushes non autorises.

L'app macOS : menu bar avec SwiftUI

L'app macOS vit dans la menu bar. Pas de dock icon, pas de fenetre permanente — juste une icone discrete avec un badge de messages non lus.

Elle utilise le Firebase iOS SDK (qui fonctionne aussi sur macOS via Swift Package Manager) et maintient un observer .observe(.value) sur le noeud /conversations. Chaque changement dans la DB declenche un callback instantane.

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

Quand un nouveau message visiteur arrive, l'app :

  1. Detecte les messages inconnus par diff d'IDs (Set existant vs nouveau)
  2. Incremente le compteur unreadCount
  3. Envoie une notification locale via UNUserNotificationCenter

Sur macOS, pas besoin de push FCM — l'app est toujours vivante dans la menu bar. Les observers Firebase ne meurent jamais.

L'app iOS : meme code, problemes differents

L'app iOS partage 90% du code grace a #if os(iOS) / #if os(macOS). Les vues sont dans des extensions conditionnelles :

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

Le probleme du background iOS

Sur macOS, les observers Firebase tournent indefiniment. Sur iOS, c'est une autre histoire :

  • iOS suspend l'app ~30 secondes apres le passage en background
  • Les connexions reseau sont coupees — les observers Firebase meurent
  • Les messages s'accumulent et arrivent tous d'un coup a la reouverture

C'est pour ca que les notifications locales ne suffisent pas sur iOS. Il faut un systeme server-side qui pousse les notifications meme quand l'app est morte.

FCM + Cloud Function : les push qui marchent vraiment

La solution est en deux pieces :

1. L'app iOS s'enregistre aupres de FCM

Au lancement, l'app demande un token APNs a Apple, le passe a Firebase Cloud Messaging, et stocke le FCM token dans la 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)
    }
}

Le point cle : il y a deux tokens differents. Le token APNs est un blob binaire d'Apple. FCM l'echange contre son propre token string. C'est ce token FCM que la Cloud Function utilise.

2. La Cloud Function envoie le push

Une Cloud Function onValueCreated se declenche a chaque nouveau message dans la 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 } } }
    });
  }
);

Points importants :

  • onValueCreated ne se declenche qu'a la creation d'un noeud, pas a la modification — pas de double notification si un message est edite
  • Le filtre sender === "emmanuel" evite de se notifier soi-meme
  • La region us-central1 est obligatoire — les triggers RTDB doivent etre dans la meme region que la base de donnees
  • Si le token est invalide (app desintallee), la fonction le nettoie automatiquement de la DB

Le flux complet d'une notification

1. Visiteur tape "Bonjour" sur le site 2. Firebase JS SDK ecrit dans /conversations/{id}/messages/{newId} 3. Cloud Function notifyNewMessage se declenche (~200ms) 4. La fonction lit le fcmToken et appelle admin.messaging().send() 5. FCM transmet a APNs (serveurs Apple) 6. APNs pousse la notification sur l'iPhone 7. L'utilisateur tape la notification -> l'app s'ouvre sur la conversation

Eviter les doublons de notifications

Sans precaution, sur iOS on recevrait deux notifications quand l'app est en foreground :

  • Une notification locale (depuis l'observer Firebase qui tourne encore)
  • Une notification push (depuis la Cloud Function via FCM)

La solution est simple : sur iOS, la methode sendNotification() fait un return immediat. Les notifications locales sont desactivees — FCM s'en charge :

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
}

Gestion du badge et navigation

Badge sur l'icone

La Cloud Function envoie badge: 1 dans le payload APNs. Quand l'utilisateur ouvre l'app, le badge se remet a zero via scenePhase :

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

Tap sur la notification -> bonne conversation

Le payload FCM inclut data.conversationId. Quand l'utilisateur tape la notification, le NotificationDelegate recupere l'ID et le stocke dans une published property. Le NavigationStack ecoute ce changement et push la bonne vue :

// 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
}

Indicateur de frappe ("est en train d'ecrire...")

Quand je tape une reponse sur l'app iOS, le visiteur sur le site voit apparaitre les trois petits points animes — exactement comme sur iMessage ou WhatsApp. L'implementation repose sur un noeud ephemere dans Firebase.

Le noeud Firebase

Chaque conversation peut contenir un noeud typing. Ce noeud n'existe que quand Emmanuel est en train de taper — il est supprime des qu'il arrete ou envoie le message :

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

L'utilisation de removeValue() plutot que setValue(false) est deliberee : ca evite de stocker des donnees inutiles. Le noeud n'existe que quand il a une raison d'exister.

Cote iOS : detection de la frappe

L'app observe les changements dans le champ texte via .onChange(of: replyText). A chaque frappe, on ecrit typing: true dans Firebase et on relance un timer de 2 secondes :

// 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)
}

Le debounce cote service

Le FirebaseService gere un timer interne. Chaque appel a setTyping(isTyping: true) relance le timer de 2 secondes. Si l'utilisateur arrete de taper, le timer expire et supprime le noeud automatiquement :

// 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()
    }
}

Le onDisconnectRemoveValue() est une securite : si l'app crash ou perd sa connexion, Firebase supprime automatiquement le noeud typing cote serveur. Sans ca, le visiteur verrait un fantome "en train d'ecrire..." indefiniment.

Cote site web : affichage des points

Le site ecoute le noeud typing avec un listener onValue. Quand la valeur passe a true, les trois points animes apparaissent. Quand elle disparait, ils se cachent :

// 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();
  }
});

Les fonctions showTyping() et hideTyping() existaient deja pour la sequence d'intro — on les reutilise telles quelles pour l'indicateur temps reel.

Le flux complet

1. Emmanuel tape dans le TextField sur iOS 2. .onChange(of: replyText) detecte la frappe 3. setTyping(isTyping: true) ecrit /conversations/{id}/typing: true 4. Le site web recoit le changement via onValue (~100ms) 5. showTyping() affiche les trois points animes 6. Emmanuel envoie le message ou arrete de taper (2s) 7. setTyping(isTyping: false) supprime le noeud 8. hideTyping() cache les points

Structure du projet

thisishumanmade.com/ | |-- website/ | |-- index.html Chat widget (vanilla JS + Firebase SDK) | |-- firebase.json Config Firebase (database + functions) |-- database.rules.json Regles de securite RTDB | |-- 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 UI partagee iOS/macOS | |-- ChatView.swift Conversation |-- Models/ |-- Conversation.swift Modeles de donnees

Stack technique

  • Site web — HTML/CSS/JS vanilla, Firebase JS SDK v11 (modules ES)
  • Base de donnees — Firebase Realtime Database (plan Blaze, cout reel : 0 euros/mois)
  • App native — SwiftUI (multi-plateforme macOS + iOS), Firebase iOS SDK 12.9
  • Push notifications — Firebase Cloud Messaging + APNs
  • Cloud Function — Node.js 20, firebase-functions v5, trigger onValueCreated
  • Auth — aucune (single-admin, regles DB ouvertes, token FCM protege en write-only)

Ce que ca coute

Avec le plan Blaze (pay-as-you-go) :

  • Realtime Database — free tier : 1 Go stockage, 10 Go/mois transfer. Largement suffisant.
  • Cloud Functions — free tier : 2 millions invocations/mois. Pour quelques messages par jour, c'est ~0.
  • FCM — gratuit, illimite.

Cout total : 0 euros/mois. Le seul investissement est le compte Apple Developer a 99 euros/an (necessaire pour les push APNs).

Retour aux articles