# Mobile Follow Graph Guide

Instagram-style follow graph: follow / unfollow, public vs **private**
profiles, pending requests, and how the profile screen degrades when the
viewer is not allowed to see a private account.

All routes below are **authenticated** (`Authorization: Bearer <access>`).
Prefixes: follow graph = `/api/follows`, profile header = `/api/users`,
posts grid = `/api/posts`.

Companion to [`mobile-verification-guide.md`](./mobile-verification-guide.md).

---

## 1. Mental Model

There is **no separate "public/private" fork on the follow button** — the
same `POST /api/follows/:userId` handles both. The server decides the
resulting state from the target's `profile.isPrivate`:

```
none --follow--> ACCEPTED     (target is PUBLIC — instant)
none --follow--> PENDING      (target is PRIVATE — needs their approval)
PENDING --they accept--> ACCEPTED
PENDING --they reject / I cancel--> none
ACCEPTED|PENDING --I unfollow--> none
```

**Friends** = mutual `ACCEPTED` in both directions. Derived, no table.

The client never sends "please make this pending" — it just follows, reads
the returned state, and renders the right label. `requestedByMe: true` means
the target was private and you now have a pending request.

---

## 2. Relation State (drives every button)

Every follow/unfollow/state call returns the same shape:

```ts
type RelationState = "none" | "pending" | "accepted";

interface FollowStateView {
  iFollow: RelationState;      // my edge → them
  theyFollow: RelationState;   // their edge → me
  requestedByMe: boolean;      // iFollow === "pending"
  requestedByThem: boolean;    // theyFollow === "pending"
  isFriend: boolean;           // both accepted
  isSelf?: boolean;            // only present on GET /state/:userId
}
```

### Button label matrix

Render off `iFollow` (primary) with `theyFollow` as tiebreaker:

| `iFollow`  | `theyFollow` | Button        | Tap action                          |
|------------|--------------|---------------|-------------------------------------|
| `none`     | `none`       | **Follow**    | `POST /:userId`                     |
| `none`     | `accepted`   | **Follow Back** | `POST /:userId`                   |
| `pending`  | any          | **Requested** | `DELETE /:userId` (cancels request) |
| `accepted` | not accepted | **Following** | `DELETE /:userId` (unfollow)        |
| `accepted` | `accepted`   | **Friends**   | `DELETE /:userId` (unfollow)        |

`isSelf: true` → hide the button entirely (show Edit Profile instead).

---

## 3. Follow / Unfollow

### Follow

```
POST /api/follows/:userId          # :userId = target's user ID
```

No body. **Idempotent** — following an already-followed user just returns
current state, no duplicate, no repeat notification.

Response 200 → `FollowStateView`:

```json
{ "success": true, "data": {
  "iFollow": "pending", "theyFollow": "none",
  "requestedByMe": true, "requestedByThem": false, "isFriend": false
}}
```

- Public target → `iFollow: "accepted"`, target gets a `follow` notification.
- Private target → `iFollow: "pending"`, target gets a `follow_request`
  notification. **This is the private-profile path.**

Errors:

| Code | When                       | UX                                |
|------|----------------------------|-----------------------------------|
| 400  | Following yourself         | Should be unreachable — hide button on own profile. |
| 403  | You/they blocked either way | "Cannot follow this user."        |
| 404  | Target user gone           | Pop back / refresh.               |

### Unfollow / cancel request

```
DELETE /api/follows/:userId
```

Idempotent. Same call **unfollows** an accepted edge AND **cancels** a
pending request — both are "delete my outgoing edge." Returns fresh
`FollowStateView`. Optimistically flip the button to **Follow** on tap.

### Read state on its own

```
GET /api/follows/state/:userId
```

Returns `FollowStateView` + `isSelf`. Use it when you land on a profile and
need the button state without mutating. This is the call in your logs that
correctly returns 200 even for private accounts — **state is never gated.**

---

## 4. Private Profiles: what you get vs what's blocked

This is the Instagram model. **The profile header is always visible; the
grid and social lists are locked** until you're an approved follower.

### Always visible (NO gate) — render the header for anyone

```
GET /api/users/:username
```

Returns for public AND private targets:

```json
{ "success": true, "data": {
  "id": "...", "username": "zoya_sketch", "createdAt": "...",
  "profile": {
    "displayName": "Zoya", "bio": "...", "avatarUrl": "...",
    "location": "...", "website": "...", "isPrivate": true
  },
  "_count": { "followers": 42, "following": 88, "posts": 17 },
  "isFollowing": false, "requestedByMe": false,
  "isFollower": false, "requestedByThem": false, "isFriend": false
}}
```

So even on a locked account you show: avatar, display name, bio, location,
website, and **follower / following / post counts**. Enough for a full
header + a Follow button.

### Blocked when `isPrivate: true` AND you are not an approved follower

These return **403 `private_profile`**:

| Route                              | Content            |
|------------------------------------|--------------------|
| `GET /api/posts/user/:userName`    | posts grid         |
| `GET /api/follows/:userId/followers` | followers list   |
| `GET /api/follows/:userId/following` | following list   |
| `GET /api/follows/:userId/friends`   | mutuals list     |

"Approved follower" = you have an `ACCEPTED` follow on them (`iFollow ===
"accepted"`). Pending does **not** unlock. Blocked (either direction) also
returns 403 here — and the header 404s/hides in that case.

### The 403 is expected — do NOT retry

```
403 { "message": "private_profile" }
```

Treat it as a **state, not an error.** On 403:

1. Keep the header (already fetched from `/api/users/:username`).
2. Replace the grid/list with a locked empty state:
   *"🔒 This account is private. Follow to see their posts."*
3. **Stop fetching.** Do not re-request on focus/scroll/retry — your logs
   showed 4 rapid re-hits; that's a client retry loop, not a server bug.

Gate the grid fetch on `isPrivate && !isFollowing` so you never fire the
request you know will 403:

```ts
const locked = profile.isPrivate && !profile.isFollowing;
const posts = useQuery({
  queryKey: ["posts", username],
  queryFn: () => getUserPosts(username),
  enabled: !locked,            // don't even ask when locked
  retry: false,                // and never retry a 403
});
```

When the follow request is later accepted, `isFollowing` flips to `true`,
`enabled` turns on, and the grid loads. No special-casing.

---

## 5. Follow Requests (the private side, both directions)

When you follow a private account you become an **incoming request** on
their side. Manage from either end:

### Requests I received (approve/deny inbox)

```
GET  /api/follows/requests/incoming?limit=20&cursor=<id>
POST /api/follows/requests/:userId/accept    # :userId = the requester
POST /api/follows/requests/:userId/reject    # silent — no notification
```

`incoming` items: `{ followId, createdAt, user }`. Accept → requester gets a
`follow_accepted` notification and can now see your content. Reject silently
deletes the request (they're not told).

### Requests I sent (still pending)

```
GET /api/follows/requests/outgoing?limit=20&cursor=<id>
```

Cancel any of them with `DELETE /api/follows/:userId` (same unfollow call).

### Notification types to route on

| `type`            | Fires when                    | Deep link                |
|-------------------|-------------------------------|--------------------------|
| `follow`          | public follow / follow-back   | `ksn://users/<username>` |
| `follow_request`  | someone requests a private me | `ksn://follow-requests`  |
| `follow_accepted` | my request was approved       | `ksn://users/<username>` |

---

## 6. Lists & Suggestions

All list routes are cursor-paginated (`?limit=&cursor=`), return
`{ items, nextCursor }`, and each item carries `isFollowing` / `isFollower`
so you can render a per-row Follow button without extra `/state` calls.

```
GET /api/follows/:userId/followers   # 403 private_profile if locked
GET /api/follows/:userId/following   # 403 private_profile if locked
GET /api/follows/:userId/friends     # 403 private_profile if locked
GET /api/follows/suggestions?limit=10   # never gated; excludes existing edges + blocks
```

Same locked-state handling as the grid: skip the fetch when
`isPrivate && !isFollowing`.

---

## 7. Reference Snippets

```ts
const H = { headers: { "X-Client-Type": "mobile" } }; // + auth interceptor

export async function follow(userId: string) {
  const { data } = await api.post(`/api/follows/${userId}`, null, H);
  return data.data as FollowStateView;
}

export async function unfollow(userId: string) {
  const { data } = await api.delete(`/api/follows/${userId}`, H);
  return data.data as FollowStateView; // also cancels a pending request
}

export async function getFollowState(userId: string) {
  const { data } = await api.get(`/api/follows/state/${userId}`, H);
  return data.data as FollowStateView & { isSelf: boolean };
}

export async function getProfile(username: string) {
  // Header only — safe for private accounts, never 403s.
  const { data } = await api.get(`/api/users/${username}`, H);
  return data.data;
}

export async function acceptRequest(requesterId: string) {
  await api.post(`/api/follows/requests/${requesterId}/accept`, null, H);
}
export async function rejectRequest(requesterId: string) {
  await api.post(`/api/follows/requests/${requesterId}/reject`, null, H);
}

// Button label from state.
export function followLabel(s: FollowStateView): string {
  if (s.iFollow === "pending") return "Requested";
  if (s.iFollow === "accepted") return s.isFriend ? "Friends" : "Following";
  return s.theyFollow === "accepted" ? "Follow Back" : "Follow";
}
```

### curl

```bash
# Follow a private account → pending request
curl -X POST http://localhost:3001/api/follows/<userId> \
  -H "Authorization: Bearer <access>" -H "X-Client-Type: mobile"

# Header loads even when locked
curl http://localhost:3001/api/users/zoya_sketch \
  -H "Authorization: Bearer <access>" -H "X-Client-Type: mobile"

# Grid is 403 when locked
curl -i http://localhost:3001/api/posts/user/zoya_sketch \
  -H "Authorization: Bearer <access>" -H "X-Client-Type: mobile"
```

---

## 8. Gotchas

- **`private_profile` (403) is not an error — it's the locked UI state.**
  Never toast it, never retry it. Gate the fetch on
  `isPrivate && !isFollowing` and short-circuit to a lock screen.
- **Header ≠ content.** `/api/users/:username` (header + counts) is ungated
  *for privacy*; the grid/lists are gated. Fetch the header first, always;
  fetch content only when unlocked. The one exception is a block — see below.
- **Pending does not unlock content.** Only an `ACCEPTED` follow
  (`iFollow === "accepted"`) lets you read a private user's posts/lists.
  A `pending` request still 403s.
- **Follow is idempotent; so is unfollow.** Safe to fire on double-tap. One
  `DELETE` covers both "unfollow" and "cancel my request" — no separate
  cancel endpoint.
- **`:userId` is a user ID, not a username** on every `/api/follows/*`
  route (accept/reject too — it's the *requester's* ID). The header route
  `/api/users/:username` and posts route `/api/posts/user/:userName` take
  the **username**. Don't cross the wires.
- **Counts are ACCEPTED-only.** `_count.followers/following` exclude pending
  requests, so a private user's header count matches what an approved
  follower would see in the list.
- **Blocks gate before privacy.** A blocked viewer gets 403 on follow and
  never learns the target's private/public state — the block check runs
  first. `/api/users/:username` returns **404** on either side of a block
  (not 403, not a locked header). Treat it as "unavailable," not "private,"
  and never retry. See
  [`mobile-report-block-guide.md`](./mobile-report-block-guide.md).
- **Optimistic UI:** flip the button on tap, reconcile with the returned
  `FollowStateView`. For a private target, expect `Follow → Requested`
  (not `Following`) — read the response, don't assume.
