Mobile notifications for iOS (#210)

* mobile: wire notification registration and listener

* implement backend components for push notifications

* refactor: agentic comment cleanup

* docs: use proper module name for particle processor

* set required env variables for push notifications

* bump version

* fix: always upsert push token on mobile start

* Revert "fix: always upsert push token on mobile start"

This reverts commit 90ff18a788.

* send push notifications regardless of online status
This commit was merged in pull request #210.
This commit is contained in:
Arjun Patel
2026-05-18 12:44:31 -07:00
committed by GitHub
parent a564ea819b
commit d262f734f0
61 changed files with 1682 additions and 531 deletions
+192 -40
View File
@@ -6,11 +6,15 @@ import (
"log"
"log/slog"
"os"
"strings"
"time"
"github.com/flowy-live/llink/internal/billing"
"github.com/flowy-live/llink/internal/db"
"github.com/flowy-live/llink/internal/depot"
"github.com/flowy-live/llink/internal/human"
"github.com/flowy-live/llink/internal/human/pushnotify"
"github.com/flowy-live/llink/internal/network"
"github.com/flowy-live/llink/internal/particle"
"github.com/flowy-live/llink/internal/speech"
"github.com/flowy-live/llink/internal/utils"
@@ -31,13 +35,9 @@ func createClient(ctx context.Context) *firestore.Client {
return client
}
// The purpose of the particle processor worker is to listen for new particles
// across all streams and perform side effects such as
// - generate transcript if the particle is of type media
// - send mobile notifications if a client is offline
// - update the parent stream's `last_child_created_at`
// - generate vector embedding
// - synthesize and decide whether ai should generate a particle as a response
// Listens for new particles and runs per-particle side effects: transcripts,
// transcode, parent stream's last_child_created_at, freemium usage, and push
// notifications for offline recipients.
func main() {
ctx := context.Background()
@@ -61,6 +61,14 @@ func main() {
speechSvc := speech.NewSpeechService(ctx)
humanSvc := human.NewService(db.Pool())
networkReader := network.NewReader(db.Pool())
pushTokenSvc := pushnotify.NewService(db.Pool())
// EXPO_ACCESS_TOKEN is required: Enhanced Security is on for our Expo
// project (otherwise anyone holding one of our push tokens could spam users).
expoClient := pushnotify.NewExpoClient(utils.MustGetEnv("EXPO_ACCESS_TOKEN"))
notifier := pushnotify.NewNotifier(networkReader, pushTokenSvc, expoClient)
client := createClient(ctx)
defer client.Close()
@@ -102,13 +110,14 @@ func main() {
slog.Debug("processing particle", "particleID", particleID, "data", change.Doc.Data())
// --- Perform side effects ---
// All of them do not stop us from marking the particle as processed
updateParentLastChildCreatedAt(ctx, change.Doc)
transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
// Side effects below are best-effort — failures don't prevent
// marking the particle as processed.
parentDoc := loadParentParticle(ctx, change.Doc)
updateParentLastChildCreatedAt(ctx, change.Doc, parentDoc)
transcript := transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
particle.Transcode(ctx, depotSvc, change.Doc)
recordFreemiumUsage(ctx, billingSvc, change.Doc)
notifyForParticle(ctx, notifier, humanSvc, change.Doc, parentDoc, transcript)
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
slog.Error("failed to mark particle as processed", "particleID", particleID, "error", err)
@@ -117,35 +126,37 @@ func main() {
}
}
func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speechSvc speech.SpeechService, doc *firestore.DocumentSnapshot) {
// Writes the structured transcript to Firestore and returns the raw text;
// returns "" for non-media particles or on any error (logged internally).
func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speechSvc speech.SpeechService, doc *firestore.DocumentSnapshot) string {
var mediaParticle particle.FirestoreMediaParticle
err := doc.DataTo(&mediaParticle)
if err != nil {
slog.Error("unable to marshal particle data", "error", err)
return
return ""
}
particleType, err := particle.ParseParticleType(mediaParticle.Type)
if err != nil {
slog.Error("invalid particle type", "error", err)
return
return ""
}
if particleType != particle.TypeMedia {
slog.Info("received a particle of type", "particle type", particleType)
return
return ""
}
downloadURL, err := depotSvc.GetDownloadURL(ctx, mediaParticle.Properties.ObjectId)
if err != nil {
slog.Error("failed to get download URL", "error", err, "object_id", mediaParticle.Properties.ObjectId)
return
return ""
}
result, err := speechSvc.Transcribe(ctx, downloadURL)
if err != nil {
slog.Error("failed to transcribe media", "error", err, "particleID", doc.Ref.ID)
return
return ""
}
transcript := toFirestoreTranscript(result)
@@ -157,10 +168,11 @@ func transcribeMediaParticle(ctx context.Context, depotSvc depot.Service, speech
}, firestore.MergeAll)
if err != nil {
slog.Error("failed to update transcript in firestore", "error", err, "particleID", doc.Ref.ID)
return
return ""
}
slog.Info("transcribed media particle", "particleID", doc.Ref.ID)
return transcript.Transcript
}
func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTranscript {
@@ -197,10 +209,9 @@ func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTr
}
}
// recordFreemiumUsage bumps the network's daily message counter for non-container
// particles. Idempotent via the surrounding processed_particles guard: the worker
// only reaches this path on first-seen particles, so a crash/restart won't
// double-count.
// Bumps the network's daily message counter for non-container particles.
// The surrounding processed_particles guard keeps this idempotent across
// crashes/restarts.
func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *firestore.DocumentSnapshot) {
rawType, err := doc.DataAt("type")
if err != nil {
@@ -217,7 +228,7 @@ func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *f
slog.Error("invalid particle type", "error", err, "particleID", doc.Ref.ID)
return
}
// Containers (stream/folder) don't count as "messages" for the daily cap.
// Containers don't count toward the daily message cap.
if particleType == particle.TypeStream || particleType == particle.TypeFolder {
return
}
@@ -233,31 +244,34 @@ func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *f
}
}
// updateParentLastChildCreatedAt updates the parent stream's last_child_created_at
// to the child's actual created_at timestamp, so it stays directly comparable with
// playback markers (which also store child created_at values).
func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.DocumentSnapshot) {
// Returns nil (and logs) if the path has no parent or the read fails.
func loadParentParticle(ctx context.Context, doc *firestore.DocumentSnapshot) *firestore.DocumentSnapshot {
parentChildrenCollectionRef := doc.Ref.Parent
if parentChildrenCollectionRef == nil {
return
return nil
}
parentParticleDocRef := parentChildrenCollectionRef.Parent
if parentParticleDocRef == nil {
slog.Error("particle has no parent document", "particleID", doc.Ref.ID)
return
return nil
}
parentParticleDoc, err := parentParticleDocRef.Get(ctx)
if err != nil {
slog.Error("failed to get parent particle", "error", err)
slog.Error("failed to get parent particle", "error", err, "particleID", doc.Ref.ID)
return nil
}
return parentParticleDoc
}
// Sets last_child_created_at to the child's created_at so it stays directly
// comparable with playback markers (which also store child created_at values).
func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.DocumentSnapshot, parent *firestore.DocumentSnapshot) {
if parent == nil {
return
}
slog.Info("parent particle is", "parent particle id", parentParticleDoc.Ref.ID)
var streamParticle particle.FirestoreStreamParticle
if err := parentParticleDoc.DataTo(&streamParticle); err != nil {
if err := parent.DataTo(&streamParticle); err != nil {
slog.Error("failed to parse stream particle", "error", err)
return
}
@@ -272,21 +286,159 @@ func updateParentLastChildCreatedAt(ctx context.Context, doc *firestore.Document
return
}
// Read the child's created_at — this is the same value that playback markers store
childCreatedAt, err := doc.DataAt("created_at")
if err != nil {
slog.Error("failed to read child created_at", "error", err, "particleID", doc.Ref.ID)
return
}
slog.Info("going to update the last_child_created_at for parent particle")
_, err = parentParticleDocRef.Update(ctx, []firestore.Update{
_, err = parent.Ref.Update(ctx, []firestore.Update{
{
Path: "last_child_created_at",
Value: childCreatedAt,
},
})
if err != nil {
slog.Error("unable to update parent particle `last_child_created_at`")
slog.Error("unable to update parent particle `last_child_created_at`", "error", err)
}
}
// Skips containers and particles whose parent isn't a stream — notifications
// are scoped to stream messages today. The transcript arg becomes the preview
// body for media particles when available.
func notifyForParticle(
ctx context.Context,
notifier *pushnotify.Notifier,
humanSvc human.Service,
doc *firestore.DocumentSnapshot,
parent *firestore.DocumentSnapshot,
transcript string,
) {
if parent == nil {
slog.Info("notify: skip — no parent", "particleID", doc.Ref.ID)
return
}
typeStr, _ := doc.DataAt("type")
typeName, _ := typeStr.(string)
pType, err := particle.ParseParticleType(typeName)
if err != nil {
slog.Info("notify: skip — unparseable particle type",
"particleID", doc.Ref.ID, "type", typeName, "error", err)
return
}
if pType == particle.TypeStream || pType == particle.TypeFolder {
slog.Info("notify: skip — container particle",
"particleID", doc.Ref.ID, "type", pType)
return
}
var parentStream particle.FirestoreStreamParticle
if err := parent.DataTo(&parentStream); err != nil {
slog.Error("notify: failed to parse parent stream", "error", err)
return
}
parentType, err := particle.ParseParticleType(parentStream.Type)
if err != nil || parentType != particle.TypeStream {
slog.Info("notify: skip — parent isn't a stream",
"particleID", doc.Ref.ID, "parentType", parentType, "parseErr", err)
return
}
networkID, err := particle.NetworkIDFromParticlePath(doc.Ref.Path)
if err != nil {
slog.Error("notify: failed to derive network id", "error", err, "path", doc.Ref.Path)
return
}
senderHumanID := parentStream.CreatedByHumanId
if v, err := doc.DataAt("created_by_human_id"); err == nil {
if s, ok := v.(string); ok && s != "" {
senderHumanID = s
}
}
streamName := ""
if v, err := parent.DataAt("properties.name"); err == nil {
if s, ok := v.(string); ok {
streamName = s
}
}
senderEmailPrefix := ""
if senderHumanID != "" {
if sender, err := humanSvc.GetByID(ctx, senderHumanID); err == nil {
senderEmailPrefix = sender.EmailPrefix
} else {
slog.Warn("notify: failed to look up sender", "error", err, "humanID", senderHumanID)
}
}
if err := notifier.NotifyParticleCreated(ctx, pushnotify.NotifyInput{
NetworkID: networkID,
SenderHumanID: senderHumanID,
SenderEmailPrefix: senderEmailPrefix,
ParticleID: doc.Ref.ID,
ParticleKind: string(pType),
StreamID: parent.Ref.ID,
StreamName: streamName,
StreamVisibleTo: parentStream.VisibleTo,
Body: previewForParticle(pType, doc, transcript),
}); err != nil {
slog.Error("notify: dispatch failed", "error", err, "particleID", doc.Ref.ID, "networkID", networkID)
}
}
// Builds the notification body. Kept short — lockscreens truncate aggressively.
// Media prefers transcript text and falls back to a generic "Sent a …" line.
func previewForParticle(pType particle.ParticleType, doc *firestore.DocumentSnapshot, transcript string) string {
switch pType {
case particle.TypeText:
if v, err := doc.DataAt("properties.content"); err == nil {
if s, ok := v.(string); ok {
return truncatePreview(s, 140)
}
}
return "Sent a message"
case particle.TypeMedia:
if t := strings.TrimSpace(transcript); t != "" {
return truncatePreview(t, 140)
}
mime := ""
if v, err := doc.DataAt("properties.mime_type"); err == nil {
if s, ok := v.(string); ok {
mime = s
}
}
if strings.HasPrefix(mime, "video/") {
return "Sent a video"
}
return "Sent a voice message"
case particle.TypeFile:
return "Sent a file"
case particle.TypeQuest:
if v, err := doc.DataAt("properties.title"); err == nil {
if s, ok := v.(string); ok && s != "" {
return "Quest: " + truncatePreview(s, 120)
}
}
return "Added a quest"
case particle.TypePaper:
if v, err := doc.DataAt("properties.title"); err == nil {
if s, ok := v.(string); ok && s != "" {
return "Paper: " + truncatePreview(s, 120)
}
}
return "Added a paper"
default:
return "New activity"
}
}
func truncatePreview(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) <= n {
return s
}
return s[:n] + "…"
}