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
+138
View File
@@ -0,0 +1,138 @@
package pushnotify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
expoPushAPIURL = "https://exp.host/--/api/v2/push/send"
// expoMaxBatchSize is the documented per-request cap on push messages.
expoMaxBatchSize = 100
// Ticket error codes returned by Expo Push API. The only one we act on is
// DeviceNotRegistered — others are logged but not retried (per product call).
ExpoErrorDeviceNotRegistered = "DeviceNotRegistered"
)
// Sound defaults to "default" when empty (set in Send).
type Message struct {
To string `json:"to"`
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
Data map[string]any `json:"data,omitempty"`
Sound string `json:"sound,omitempty"`
}
// Status is "ok" or "error". On error, Details["error"] carries the code
// (e.g. "DeviceNotRegistered", "MessageTooBig", "InvalidCredentials").
type Ticket struct {
Status string `json:"status"`
ID string `json:"id,omitempty"`
Message string `json:"message,omitempty"`
Details map[string]any `json:"details,omitempty"`
}
// ExpoClient does NOT poll receipts and does NOT retry — fire-and-forget,
// with DeviceNotRegistered handled out-of-band by the notifier.
type ExpoClient struct {
http *http.Client
accessToken string
}
func NewExpoClient(accessToken string) *ExpoClient {
return &ExpoClient{
http: &http.Client{Timeout: 15 * time.Second},
accessToken: accessToken,
}
}
type expoSendResponse struct {
Data []Ticket `json:"data"`
Errors []map[string]any `json:"errors,omitempty"`
}
// Send batches msgs (cap expoMaxBatchSize) and preserves input order:
// tickets[i] corresponds to msgs[i]. A request-level failure aborts the
// remaining batches; tickets already collected are returned with the error.
func (c *ExpoClient) Send(ctx context.Context, msgs []Message) ([]Ticket, error) {
if len(msgs) == 0 {
return nil, nil
}
for i := range msgs {
if msgs[i].Sound == "" {
msgs[i].Sound = "default"
}
}
tickets := make([]Ticket, 0, len(msgs))
for start := 0; start < len(msgs); start += expoMaxBatchSize {
end := start + expoMaxBatchSize
if end > len(msgs) {
end = len(msgs)
}
batch := msgs[start:end]
batchTickets, err := c.sendBatch(ctx, batch)
tickets = append(tickets, batchTickets...)
if err != nil {
return tickets, fmt.Errorf("expo push batch [%d:%d]: %w", start, end, err)
}
}
return tickets, nil
}
func (c *ExpoClient) sendBatch(ctx context.Context, batch []Message) ([]Ticket, error) {
body, err := json.Marshal(batch)
if err != nil {
return nil, fmt.Errorf("marshal batch: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, expoPushAPIURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Encoding", "gzip, deflate")
if c.accessToken != "" {
req.Header.Set("Authorization", "Bearer "+c.accessToken)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("expo push api returned %d: %s", resp.StatusCode, truncate(string(raw), 512))
}
var parsed expoSendResponse
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if len(parsed.Data) != len(batch) {
return parsed.Data, fmt.Errorf("expo returned %d tickets for %d messages", len(parsed.Data), len(batch))
}
return parsed.Data, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
+180
View File
@@ -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
}
@@ -0,0 +1,95 @@
package pushnotify
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type repository interface {
upsert(ctx context.Context, t *PushToken) error
deleteForHuman(ctx context.Context, humanID, token string) error
deleteByToken(ctx context.Context, token string) error
listForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error)
}
type repositoryImpl struct {
pool *pgxpool.Pool
}
func newRepository(pool *pgxpool.Pool) repository {
return &repositoryImpl{pool: pool}
}
func (r *repositoryImpl) upsert(ctx context.Context, t *PushToken) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO push_tokens (token, human_id, platform, app_version)
VALUES ($1, $2, $3, NULLIF($4, ''))
ON CONFLICT (token) DO UPDATE SET
human_id = EXCLUDED.human_id,
platform = EXCLUDED.platform,
app_version = EXCLUDED.app_version,
last_seen_at = NOW()`,
t.Token, t.HumanID, string(t.Platform), t.AppVersion,
)
return err
}
func (r *repositoryImpl) deleteForHuman(ctx context.Context, humanID, token string) error {
result, err := r.pool.Exec(ctx,
`DELETE FROM push_tokens WHERE human_id = $1 AND token = $2`,
humanID, token,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (r *repositoryImpl) deleteByToken(ctx context.Context, token string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM push_tokens WHERE token = $1`,
token,
)
return err
}
func (r *repositoryImpl) listForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) {
if len(humanIDs) == 0 {
return nil, nil
}
rows, err := r.pool.Query(ctx,
`SELECT token, human_id, platform, app_version, created_at, last_seen_at
FROM push_tokens
WHERE human_id = ANY($1)`,
humanIDs,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
defer rows.Close()
var tokens []*PushToken
for rows.Next() {
var t PushToken
var appVersion *string
var platform string
if err := rows.Scan(&t.Token, &t.HumanID, &platform, &appVersion, &t.CreatedAt, &t.LastSeenAt); err != nil {
return nil, err
}
t.Platform = Platform(platform)
if appVersion != nil {
t.AppVersion = *appVersion
}
tokens = append(tokens, &t)
}
return tokens, rows.Err()
}
+66
View File
@@ -0,0 +1,66 @@
package pushnotify
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
// Service stores per-device Expo push tokens and exposes the operations
// needed by both the HTTP handlers and the worker-side notifier.
type Service interface {
// Register returns ErrInvalidToken / ErrInvalidPlatform on bad input.
Register(ctx context.Context, humanID string, in RegisterInput) error
// Unregister is scoped to humanID so a user can't delete another user's
// token. Returns ErrNotFound if the token isn't owned by humanID.
Unregister(ctx context.Context, humanID, token string) error
// ListForHumans returns an empty slice when nothing matches.
ListForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error)
// DeleteByToken removes a token regardless of owner — used to prune after
// Expo reports DeviceNotRegistered.
DeleteByToken(ctx context.Context, token string) error
}
type RegisterInput struct {
Token string
Platform Platform
AppVersion string
}
type serviceImpl struct {
repo repository
}
func NewService(pool *pgxpool.Pool) Service {
return &serviceImpl{repo: newRepository(pool)}
}
func (s *serviceImpl) Register(ctx context.Context, humanID string, in RegisterInput) error {
if !in.Platform.Valid() {
return ErrInvalidPlatform
}
if !IsValidExpoToken(in.Token) {
return ErrInvalidToken
}
return s.repo.upsert(ctx, &PushToken{
Token: in.Token,
HumanID: humanID,
Platform: in.Platform,
AppVersion: in.AppVersion,
})
}
func (s *serviceImpl) Unregister(ctx context.Context, humanID, token string) error {
if token == "" {
return ErrInvalidToken
}
return s.repo.deleteForHuman(ctx, humanID, token)
}
func (s *serviceImpl) ListForHumans(ctx context.Context, humanIDs []string) ([]*PushToken, error) {
return s.repo.listForHumans(ctx, humanIDs)
}
func (s *serviceImpl) DeleteByToken(ctx context.Context, token string) error {
return s.repo.deleteByToken(ctx, token)
}
+43
View File
@@ -0,0 +1,43 @@
// Package pushnotify owns mobile push notification delivery: storage of per-device
// Expo push tokens, and the worker-side orchestration of sending notifications
// to offline recipients via the Expo Push API.
package pushnotify
import (
"errors"
"strings"
"time"
)
type Platform string
const (
PlatformIOS Platform = "ios"
PlatformAndroid Platform = "android"
)
func (p Platform) Valid() bool {
return p == PlatformIOS || p == PlatformAndroid
}
type PushToken struct {
Token string
HumanID string
Platform Platform
AppVersion string
CreatedAt time.Time
LastSeenAt time.Time
}
var (
ErrInvalidPlatform = errors.New("invalid platform")
ErrInvalidToken = errors.New("invalid expo push token")
ErrNotFound = errors.New("push token not found")
)
// IsValidExpoToken matches the two prefix formats Expo currently uses.
// We don't validate the inner contents — Expo's server will reject malformed
// tokens with a per-message error and we'll clean those up via DeviceNotRegistered.
func IsValidExpoToken(token string) bool {
return strings.HasPrefix(token, "ExponentPushToken[") || strings.HasPrefix(token, "ExpoPushToken[")
}