package pushnotify import ( "context" "errors" "fmt" "github.com/flowy-live/llink/internal/utils/flog" "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 == "" { flog.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 { flog.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 { flog.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) flog.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" { flog.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) { flog.Error("pushnotify: failed to delete dead token", "error", err, "token", msgs[i].To) } else { flog.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 }