~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
158 lines · 5.4 KB Raw History
# Push Notifications Design

## Overview

Add push notification support across three repos: hatk gains an `on-commit` hook primitive and push delivery infrastructure, grain wires notification events to push, and grain-native registers for and handles APNs.

## hatk: `on-commit` Hook Primitive

A new hook trigger type that fires after the firehose indexer writes a record to the database. Follows the existing `defineHook(event, handler)` pattern with an options object for declarative collection filtering.

```ts
// server/hooks/on-commit-favorite.ts
export default defineHook(
  "on-commit",
  { collections: ["social.grain.favorite"] },
  async ({ action, collection, record, repo, uri, db, lookup, push }) => {
    // action: "create" | "delete"
    // collection: NSID that matched
    // record: the record value
    // repo: DID of the committing actor
    // uri: AT URI of the record
    // db: { query, run } — raw SQL access
    // lookup: typed record lookup (same as BaseContext)
    // push: push delivery interface
  },
);
```

The firehose indexer already processes commits in `flushBuffer()`. After `insertRecord` / `deleteRecord`, it invokes matching `on-commit` hooks filtered by `collections`. Multiple hooks can match the same commit. Hooks run async and non-blocking (same pattern as `runLabelRules`).

## hatk: Push Token Registration & Storage

### Database

```sql
CREATE TABLE _push_tokens (
  did       TEXT NOT NULL,
  token     TEXT NOT NULL,
  platform  TEXT NOT NULL,  -- "apns" for now, "fcm"/"web" later
  createdAt TEXT NOT NULL,
  PRIMARY KEY (did, token)
);
```

### Built-in XRPC Endpoints

- `social.hatk.push.registerToken` — accepts `{ token, platform }`, associates with authenticated viewer's DID. Handles duplicates via upsert.
- `social.hatk.push.unregisterToken` — accepts `{ token }`, removes it.

### Configuration

```ts
// hatk.config.ts
push: {
  apns: {
    keyFile: "./certs/AuthKey_XXXX.p8",
    keyId: "XXXX",
    teamId: "YYYY",
    bundleId: "com.grain.app",
  }
}
```

Push features are disabled entirely if no `push` config is present.

## hatk: `push.send()` Delivery

The `push` object is injected into hook handler context:

```ts
await push.send({
  did: string,                     // recipient's DID
  title: string,                   // notification title
  body: string,                    // notification body
  data?: Record<string, string>,   // custom payload (e.g. { uri, type })
  collapseId?: string,             // APNs collapse-id for grouping
})
```

Internals:

- Looks up all tokens for the DID in `_push_tokens`
- Builds APNs payload, sends via HTTP/2 to Apple's push gateway
- If Apple responds with invalid/expired token, deletes it from `_push_tokens` (self-cleaning)
- Fire-and-forget — delivery failures are logged via `emit()` but don't throw
- No queuing for v1. Hooks run async after flush (like label rules). A queue can be added behind the same API later.

## grain: Hook Implementations

One `on-commit` hook file per notification trigger:

```
server/hooks/
  on-login.ts                (existing)
  on-commit-favorite.ts      (new)
  on-commit-comment.ts       (new)
  on-commit-follow.ts        (new)
```

### Example: Favorites

```ts
export default defineHook(
  "on-commit",
  { collections: ["social.grain.favorite"] },
  async ({ action, record, repo, db, lookup, push }) => {
    if (action !== "create") return;
    const [gallery] = await db.query(
      `SELECT did AS author, uri FROM "social.grain.gallery" WHERE uri = $1`,
      [record.subject],
    );
    if (!gallery || gallery.author === repo) return;
    const profiles = await lookup("app.bsky.actor.profile", "did", [repo]);
    const actor = profiles.get(repo);
    await push.send({
      did: gallery.author,
      title: "New favorite",
      body: `${actor?.value.displayName ?? "Someone"} favorited your gallery`,
      data: { type: "gallery-favorite", uri: gallery.uri },
    });
  },
);
```

Comments and follows follow the same pattern. Mentions and replies parse facets or look up parent comment authors but use the same shape.

The existing `getNotifications` query is unchanged — it remains the source of truth for the notification list. Push alerts the user to come look.

## grain-native: APNs Registration & Handling

### Token Registration Flow

1. On app launch (after auth), call `UNUserNotificationCenter.requestAuthorization()`
2. On grant, call `UIApplication.shared.registerForRemoteNotifications()`
3. In `AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken`, send the hex-encoded token to `social.hatk.push.registerToken` via XRPC client
4. Re-register on every app launch (token can change; server upserts)

### Handling Incoming Pushes

- Tap on a notification reads `data.type` and `data.uri` from the payload
- Routes to the appropriate view: gallery detail for favorites/comments, profile for follows

### Unregistration

- On logout, call `social.hatk.push.unregisterToken` to remove the token server-side

### Entitlements

- Add Push Notifications capability in Xcode
- Add `aps-environment` entitlement (development/production)
- Register bundle ID with APNs in Apple Developer portal

## What's NOT in v1

- No FCM / web push (table has `platform` column for future use)
- No queuing / retry system (async inline delivery, failures logged)
- No notification preferences / muting (all types push to all registered devices)
- No rich notifications (images, actions) — title + body + data only