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.
Vue d'ensemble
Le systeme tient en 4 composants :
- Le site web — un chat widget en vanilla JS qui ecrit dans Firebase Realtime DB
- Firebase Realtime DB — la base de donnees temps reel qui synchronise tout
- L'app native — SwiftUI, disponible sur macOS (menu bar) et iOS (NavigationStack)
- Une Cloud Function — declenchee a chaque nouveau message, envoie un push via FCM
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 :
- Detecte les messages inconnus par diff d'IDs (
Setexistant vs nouveau) - Incremente le compteur
unreadCount - 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 :
onValueCreatedne 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-central1est 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
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
Structure du projet
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