# Mobile Post Interactions — Integration Guide

> Save, repost, share. Everything mobile needs. Backend already shipped.
> Companion to `mobile-posts-guide.md` — read that first for post-object
> shape + auth headers.
>
> **Visibility model:** the active gate today is profile-level only
> (`docs/private-profile-implementation.md`). Saving / reposting / sharing
> a post by an author whose profile is currently public works normally;
> if that author later flips private and the viewer is not approved, the
> saved-list returns a shell (`isUnavailable: true`,
> `unavailableReason: "RESTRICTED"`) and `GET /posts/:id` returns 404.
> Per-post visibility (Everyone / Friends) is **deferred** — see
> `docs/post-visibility-implementation.md`.

- Base URL: `http://localhost:3001` (dev) — production URL from backend team
- Auth: `Authorization: Bearer <accessToken>` on every endpoint
- Headers: `Content-Type: application/json`, `X-Client-Type: mobile`

## What's new

<!-- Newest first, plain language, max 5 entries. Every session that changes this
     guide adds one line here and drops the oldest past 5 — see CLAUDE.md rule 8. -->

- **2026-09-09** — The share URL in §3.1 was wrong: it is
  `https://letsclustr.com/posts/<id>?ref=<code>`, not `http://localhost:3000/p/<id>`.
  The link is live now — it opens the app, or a download page if the app isn't
  installed. Treat `ref` as opaque. Platform setup:
  [mobile-deep-links-guide.md](../deep-links/mobile-deep-links-guide.md).

---

## At-a-glance endpoint table

| Method | Path | Use |
|---|---|---|
| `PUT` | `/api/posts/:id/save` | bookmark on/off |
| `GET` | `/api/posts/saved` | "Saved" tab list |
| `POST` | `/api/posts/:id/repost` | one-tap repost (empty body) |
| `DELETE` | `/api/posts/:id/repost` | undo simple-repost |
| `GET` | `/api/posts/:id/reposts` | list reposters |
| `POST` | `/api/posts/:id/share` | record share + get URL |
| `GET` | `/api/posts/:id/share/preview` | share-sheet prefill |

---

## Post object — new fields

Every post (detail, list, feed, /me, /user/:name, /saved) now carries:

```jsonc
{
  // existing fields...
  "isLiked": false,
  "isSaved": false,                        // viewer's bookmark state
  "isReposted": false,                     // viewer has reposted this
  "viewerRepostId": null,                  // viewer's repost row id
  "originalPostId": null,                  // set on repost rows; null on regular posts
  "original": null,                        // embedded original-post summary on reposts
  "repostedBy": null,                      // mutual-follow repost attribution (see §2.4)
  "_count": {
    "likes": 0,
    "comments": 0,
    "requests": 0,
    "reposts": 0,                          // new
    "shares": 0                            // new
    // saves intentionally omitted — private
  }
}
```

`original` shape (only present when `type === "REPOST"` + original not deleted):

```jsonc
{
  "id": "pst_root123",
  "type": "FIND_TEAMMATES",
  "title": "Looking for a 4th",
  "content": "Hackathon team...",
  "createdAt": "2026-06-10T12:00:00Z",
  "author": { "id": "usr_a", "username": "alice", "profile": { "displayName": "Alice", "avatarUrl": "..." } },
  "media": [ /* same shape as top-level media */ ]
}
```

---

## 1 — Save (private bookmark)

Private. No notification to author. No public count.

### 1.1 — Toggle save

```http
PUT /api/posts/:id/save
{ "saved": true }     // or false
```

Response:

```jsonc
{ "success": true, "data": { "saved": true, "updatedAt": "2026-06-15T07:35:40.451Z" } }
```

**Optimistic UI (mandatory)**:
1. On tap: flip `isSaved` locally. No spinner.
2. Fire PUT in background.
3. On HTTP error: roll back + toast.
4. Track `updatedAt` per postId — discard response if older than last applied.
5. On refetch: server is truth, overwrite local.

Rate-limited 30/10s per user.

### 1.2 — Saved tab list

```http
GET /api/posts/saved?limit=20&cursor=<opaque>
```

Returns `{ items, nextCursor }` — full post objects, ordered by **save
time** (not post time). Cursor is base64url; treat as opaque, pass back as
`?cursor=`. Stop when `nextCursor === null`.

### 1.3 — Errors

| code | meaning |
|---|---|
| 401 | bad auth |
| 404 | post not found |
| 429 | rate limit |

---

## 2 — Repost (one-tap, v2)

A repost is a Post row with `type=REPOST`, `content=""` and `originalPostId`
set. Renders as "X reposted" + embedded original.

> **v2 change**: quote reposts ("with thoughts") were removed. `POST
> /:id/repost` takes an **empty body** — any non-empty body is a 400.
> Historical quote rows (`content!=""`) may still appear in feeds and in
> `GET /:id/reposts` (`kind:"quote"`); render their content above the
> embedded original as before.

Server collapses chains — repost-of-a-repost always anchors at the root.
One repost per user per original, DB-enforced. REPOST never appears in the
kind picker (`GET /api/posts/types` filters it out).

### 2.1 — Create

```http
POST /api/posts/:id/repost
{}                                         // body MUST be empty
```

Response: full post object (same shape as `POST /api/posts`) with
`type=REPOST`, `originalPostId` populated, embedded `original`.

After response: bump `_count.reposts` on the source card by +1, set
`isReposted=true`, store `viewerRepostId` from response.

**409 = already reposted.** Treat as success client-side: set
`isReposted=true` and leave the count alone (a previous tap already won).

### 2.2 — Undo

```http
DELETE /api/posts/:id/repost
```

`:id` = original (or any post in same chain — server resolves to root).
Sets `isReposted=false`, decrement `_count.reposts`.

### 2.3 — Reposters list

```http
GET /api/posts/:id/reposts?limit=20&cursor=<opaque>
```

```jsonc
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "pst_repost1",
        "kind": "simple",                 // "simple" | "quote" (quote = legacy rows only)
        "createdAt": "2026-06-15T07:00:00Z",
        "user": { "id": "usr_b", "username": "bob", "profile": { ... } },
        "contentPreview": ""              // empty for simple
      }
    ],
    "nextCursor": "eyJj..."
  }
}
```

### 2.4 — Render rules + `repostedBy`

**The feed only ever contains original posts.** You will not get
`type=REPOST` cards in `GET /api/feed` — a repost boosts the original into
feeds, and `repostedBy` tells you who to credit. Use `post.id` for
everything (like, comment, navigate); there is no second id to worry about.

`type=REPOST` cards appear only on **profile lists** (`GET /posts/me`,
`GET /posts/user/:name` — "you/they reposted" entries) and when a
notification deep-links to a repost id:

```
if (post.type === "REPOST") {
  if (post.originalPostId !== null) {
    // Hat: "<author.username> reposted"
    // Legacy quote rows: if post.content !== "" render it above the original.
    // Embedded original card = post.original.
    // Tap on embedded original card → navigate to post.original.id.
  } else {
    // Original was deleted. Render "Original removed" placeholder.
    // (Only case where a REPOST card can also reach the feed.)
  }
} else {
  // Standard render. _count.reposts shows how many times reposted.
  // isReposted tells you if the viewer is one of them.
}
```

**`repostedBy`** — every post (list, feed, detail) carries mutual-follow
repost attribution. Only the viewer's mutuals (both follow each other) are
ever named; strangers never leak:

```jsonc
"repostedBy": {
  "users": [                               // up to 3, newest first
    { "id": "usr_c", "username": "charlie", "profile": { "displayName": "Charlie", "avatarUrl": "..." } }
  ],
  "mutualCount": 5                         // total MUTUAL reposters (public total = _count.reposts)
}
// null when no mutual has reposted
```

Render: `"Reposted by Charlie"`, `"Reposted by Charlie, Dana and 3 others"`.
It is keyed on the root post, so an original card and a repost card of it
show the same attribution. May lag follow/unfollow changes by up to 5
minutes (server-side mutual cache).

### 2.5 — Errors

| code | meaning |
|---|---|
| 400 | self-repost / non-empty body |
| 404 | original not found, hidden, or not visible to you — **hidden was a 400 before 2026-08-25**; render your existing not-found state |
| 409 | already reposted (POST — treat as success) / no repost to remove (DELETE) |
| 429 | rate limit (30 / 10 min) |

### 2.6 — Notifications

| type | recipient | tap-target |
|---|---|---|
| `repost` | root post author | `data.entityId` = repost id |

`data.originalPostId` carried alongside if you want to navigate to the
original instead. (`quote_repost` / repost `mention` notifications no longer
fire — historical rows may still exist with those types.)

---

## 3 — Share

Generates a tracked URL the viewer can paste anywhere. Records each share.

> **Status**: backend ready. Tap-through to the app (Universal Links / App
> Links) lands in a later phase once a mobile build exists. Right now the
> URL is safe to copy-paste but **does not open the app or render a rich
> preview** when the recipient taps it. Ship the button anyway — flip on
> link infra later without API changes.

### 3.1 — Get share URL

Call this **before** opening the native share sheet:

```http
POST /api/posts/:id/share
{ "channel": "NATIVE" }
```

`channel` values:
`WHATSAPP | TWITTER | INSTAGRAM | FACEBOOK | COPY_LINK | CHAT | NATIVE | OTHER`

Use `NATIVE` for the OS share sheet (default). Use a specific channel only
when the user picks an in-app target you know about (e.g. `CHAT` for
in-app conversation share, `COPY_LINK` for the copy button).

Response:

```jsonc
{
  "success": true,
  "data": {
    "url": "https://letsclustr.com/posts/019f45c6-…?ref=Ab12CdEf34",
    "shareCode": "Ab12CdEf34",
    "channel": "NATIVE"
  }
}
```

Pass `url` to:
- iOS: `UIActivityViewController`
- Android: `Intent.ACTION_SEND` with `EXTRA_TEXT`

### 3.2 — Prefill share sheet preview

Optional but recommended — improves the link preview the recipient sees:

```http
GET /api/posts/:id/share/preview
```

```jsonc
{
  "success": true,
  "data": {
    "title": "Looking for a 4th for hackathon team",
    "description": "Building a flashcard app...",
    "imageUrl": "https://cdn.../uploads/abc.jpg"
  }
}
```

iOS — attach as `UIActivityItemSource` metadata. Android — pass through
the share Intent extras (`EXTRA_TITLE`, `EXTRA_TEXT`).

### 3.3 — Share-count on post

`post._count.shares` increments after each successful `POST /share`. No
per-viewer `isShared` flag — share is a transient action, not a state.

### 3.4 — In-app chat share

Two paths:

- **Today**: `POST /share` with `channel: "CHAT"` → take the returned
  `url`, send it as a regular chat message (URL preview rendered by chat
  UI).
- **Future**: dedicated `MessageType=POST_SHARE` with embedded `postId` is
  on the roadmap (cleaner native render). Not implemented yet — use the
  URL path for now.

### 3.5 — Errors

| code | meaning |
|---|---|
| 404 | post not found or hidden |
| 429 | rate limit (60 / minute per user) |

---

## 4 — Optimistic UI cheatsheet

| Action | Optimistic? | Race protection |
|---|---|---|
| like | yes (existing) | `updatedAt` per postId |
| **save** | **yes (mandatory)** | `updatedAt` per postId |
| **repost (simple)** | optional — fast enough to await | n/a |
| **repost (quote)** | no — compose screen waits | n/a |
| **share** | n/a — call returns the URL, share happens after | n/a |

Save mirrors like exactly. Same `updatedAt` discard rule (apply only if
response `updatedAt` > last applied for this postId).

---

## 5 — Rate limits

| Endpoint | Limit |
|---|---|
| `PUT /:id/save` | 30 / 10s per user |
| `POST /:id/repost` | 30 / 10 min per user |
| `POST /:id/share` | 60 / minute per user |

All return 429 with no body when exceeded. Debounce taps that happen
faster than a human can tap.

---

## 6 — UI mapping (suggested icons + states)

| Action | Idle icon | Active state |
|---|---|---|
| Save | bookmark outline | bookmark filled when `isSaved` |
| Repost | repeat (two-arrow) | green/accent tint when `isReposted` |
| Share | share-arrow (iOS up-arrow / Android tri-dot) | static — no toggle |

Render rules:

- Save / repost / share counts: hide when 0 (or grey out — product call).
- `post._count.reposts` includes both simple and quote reposts.
- Saved icon should not show a count anywhere.

---

## 7 — Open questions for product

- Show count beside repost icon, or hide until non-zero?
- Long-press share = "copy link" shortcut? (Would call POST `/share` with
  `channel: "COPY_LINK"`.)
- "Saved" tab placement: profile sub-tab, or settings shortcut?
- Quote-repost compose screen: full-screen modal or sheet?

Backend doesn't care — answers shape only mobile UX.

---

## 8 — Quick integration checklist

- [ ] Add `isSaved`, `isReposted`, `viewerRepostId`, `originalPostId`,
      `original`, `_count.reposts`, `_count.shares` to your Post type.
- [ ] Save button: optimistic flip + `PUT /save` + `updatedAt` reconcile.
- [ ] Saved tab: `GET /api/posts/saved` + cursor paginate.
- [ ] Repost button: bottom-sheet → simple (`POST /repost {}`) or quote
      (`POST /repost { content }`).
- [ ] Feed render: handle `type === "REPOST"` (hat + embedded `original`).
- [ ] Like/comment on repost card act on repost id, not original.
- [ ] Share button: `POST /share` → pass `url` to native share sheet (never build the URL yourself).
- [ ] Optional: `GET /share/preview` for richer share-sheet metadata.
- [ ] Handle notifications: `repost`, `quote_repost`. Tap → navigate to
      `data.entityId` (the repost).

That's it.
