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:
@@ -0,0 +1,180 @@
|
||||
package pushnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/flowy-live/llink/internal/network"
|
||||
)
|
||||
|
||||
type NotifyInput struct {
|
||||
NetworkID string
|
||||
SenderHumanID string
|
||||
SenderEmailPrefix string
|
||||
|
||||
ParticleID string
|
||||
// One of "text", "media", "file", "quest", "paper". Containers (stream,
|
||||
// folder) are dropped by the caller before reaching the notifier.
|
||||
ParticleKind string
|
||||
|
||||
// Parent stream context — drives the title and the recipient set.
|
||||
StreamID string
|
||||
StreamName string
|
||||
StreamVisibleTo []string
|
||||
|
||||
// Body — already formatted by the caller (e.g. truncated text, "Sent a
|
||||
// voice message"). Title is derived inside the notifier.
|
||||
Body string
|
||||
}
|
||||
|
||||
// Notifier fans out one particle to Expo:
|
||||
// 1. Resolve recipients (visibility ∩ network members, minus sender).
|
||||
// 2. Send a batched Expo request for every recipient's tokens.
|
||||
// 3. Prune tokens Expo reports as DeviceNotRegistered.
|
||||
//
|
||||
// Online/offline presence is intentionally NOT consulted: a live WebSocket
|
||||
// is a poor proxy for "user is actively consuming this particle right now"
|
||||
// (backgrounded apps, idle desktops, etc. all look online), and the resulting
|
||||
// false-negatives outweigh the duplicate-notification cost on a focused
|
||||
// device, which the OS handles via Focus modes and per-app settings.
|
||||
type Notifier struct {
|
||||
networkR network.Reader
|
||||
tokens Service
|
||||
expo *ExpoClient
|
||||
}
|
||||
|
||||
func NewNotifier(networkR network.Reader, tokens Service, expo *ExpoClient) *Notifier {
|
||||
return &Notifier{
|
||||
networkR: networkR,
|
||||
tokens: tokens,
|
||||
expo: expo,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Notifier) NotifyParticleCreated(ctx context.Context, in NotifyInput) error {
|
||||
if in.NetworkID == "" || in.ParticleID == "" {
|
||||
slog.Info("pushnotify: skip — missing ids",
|
||||
"networkID", in.NetworkID,
|
||||
"particleID", in.ParticleID,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
members, err := n.networkR.ListMembers(ctx, in.NetworkID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list network members: %w", err)
|
||||
}
|
||||
|
||||
recipients := network.ResolveVisibility(in.StreamVisibleTo, members)
|
||||
recipientsBeforeSenderFilter := len(recipients)
|
||||
recipients = filterOut(recipients, in.SenderHumanID)
|
||||
if len(recipients) == 0 {
|
||||
slog.Info("pushnotify: skip — no recipients",
|
||||
"networkID", in.NetworkID,
|
||||
"particleID", in.ParticleID,
|
||||
"senderHumanID", in.SenderHumanID,
|
||||
"members", len(members),
|
||||
"visibleTo", in.StreamVisibleTo,
|
||||
"resolved", recipientsBeforeSenderFilter,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
tokens, err := n.tokens.ListForHumans(ctx, recipients)
|
||||
if err != nil {
|
||||
return fmt.Errorf("token lookup: %w", err)
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
slog.Info("pushnotify: skip — no tokens for recipients",
|
||||
"networkID", in.NetworkID,
|
||||
"particleID", in.ParticleID,
|
||||
"recipients", len(recipients),
|
||||
"recipientIDs", recipients,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
msgs := buildMessages(tokens, in)
|
||||
tickets, sendErr := n.expo.Send(ctx, msgs)
|
||||
slog.Info("pushnotify: dispatch",
|
||||
"networkID", in.NetworkID,
|
||||
"particleID", in.ParticleID,
|
||||
"recipients", len(recipients),
|
||||
"tokens", len(tokens),
|
||||
"sent", len(tickets),
|
||||
)
|
||||
|
||||
n.cleanupDeadTokens(ctx, msgs, tickets)
|
||||
|
||||
if sendErr != nil {
|
||||
return fmt.Errorf("expo send: %w", sendErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeviceNotRegistered is the one feedback signal we honor; other ticket
|
||||
// errors (MessageTooBig, RateLimit, …) are logged and dropped.
|
||||
func (n *Notifier) cleanupDeadTokens(ctx context.Context, msgs []Message, tickets []Ticket) {
|
||||
for i, t := range tickets {
|
||||
if i >= len(msgs) {
|
||||
break
|
||||
}
|
||||
if t.Status != "error" || t.Details == nil {
|
||||
continue
|
||||
}
|
||||
code, _ := t.Details["error"].(string)
|
||||
if code != ExpoErrorDeviceNotRegistered {
|
||||
if t.Status == "error" {
|
||||
slog.Warn("pushnotify: ticket error", "code", code, "message", t.Message, "to", msgs[i].To)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := n.tokens.DeleteByToken(ctx, msgs[i].To); err != nil && !errors.Is(err, ErrNotFound) {
|
||||
slog.Error("pushnotify: failed to delete dead token", "error", err, "token", msgs[i].To)
|
||||
} else {
|
||||
slog.Info("pushnotify: removed unregistered token", "token", msgs[i].To)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildMessages(tokens []*PushToken, in NotifyInput) []Message {
|
||||
title := in.SenderEmailPrefix
|
||||
if in.StreamName != "" {
|
||||
title = in.SenderEmailPrefix + " in " + in.StreamName
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"kind": "particle_created",
|
||||
"network_id": in.NetworkID,
|
||||
"stream_id": in.StreamID,
|
||||
"particle_id": in.ParticleID,
|
||||
"sender_human_id": in.SenderHumanID,
|
||||
"particle_kind": in.ParticleKind,
|
||||
}
|
||||
|
||||
msgs := make([]Message, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
msgs = append(msgs, Message{
|
||||
To: t.Token,
|
||||
Title: title,
|
||||
Body: in.Body,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
func filterOut(ids []string, exclude string) []string {
|
||||
if exclude == "" {
|
||||
return ids
|
||||
}
|
||||
out := ids[:0:len(ids)]
|
||||
for _, id := range ids {
|
||||
if id != exclude {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user