# Mobile Search — Integration Guide

One query box, one endpoint, three result sections: **people**, **posts**, and
**posts filed under a matching topic**. This is the contract for the React
Native client — what to call, what comes back, how to page each section, and
the handful of behaviours that will look like bugs if you don't know about them.

- API base URL (local): `http://localhost:3001`
- Auth: `Authorization: Bearer <accessToken>` on every request.
- Send `X-Client-Type: mobile` on every request.

## 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-10** — First version. The search screen is a single call,
  `GET /api/search?q=…`, which returns page 1 of all three sections at once.
  Two things to build around: **you cannot page the combined call** — re-request
  one section with `?type=<section>&cursor=<its own nextCursor>` — and
  **queries shorter than 3 characters cannot search post text**, so the `posts`
  section comes back with `unavailable: "MIN_QUERY_LEN"` while people and topics
  still work. Nothing here is new backend behaviour; it was just never written down.

---

## Short version — just the calls

```
GET /api/search?q=music                        → page 1 of users + posts + topics
GET /api/search?q=music&type=posts&cursor=…    → page 2+ of ONE section
GET /api/search?q=music&type=users&limit=30    → one section, bigger page
```

- One endpoint powers the whole screen. Don't fan out to `/api/users/search`
  for the People tab — this call already returns it.
- `cursor` is **only** valid together with `type`. The combined call is page-1-only.
- Rate limit: **30 requests / 10 seconds**, shared across every `type`. Debounce.

---

## 1 — The request

```http
GET /api/search?q=music&type=posts&cursor=eyJ...&limit=20
Authorization: Bearer <accessToken>
X-Client-Type: mobile
```

| Param    | Type   | Required | Notes                                                                          |
| -------- | ------ | -------- | ------------------------------------------------------------------------------ |
| `q`      | string | yes      | 1–80 chars. Trimmed server-side; `%` characters are stripped before searching. |
| `type`   | enum   | no       | `users` \| `posts` \| `topics`. Omit for the combined (page 1) call.           |
| `cursor` | string | no       | Opaque cursor from that section's `nextCursor`. **Requires `type`.**            |
| `limit`  | int    | no       | 1–50, default 20. Applied **per section** — the combined call can return 3×.   |

**Rate limit:** 30 requests / 10 seconds per user, one shared bucket for every
`type`. A paging call and a keystroke call spend from the same budget.

### 1.1 — Two modes, derived from `q.length`

The response tells you which one you got, in `data.mode`:

| `mode`   | When         | People match by                    | Post text search              | Topics |
| -------- | ------------ | ---------------------------------- | ----------------------------- | ------ |
| `prefix` | 1–2 chars    | username **prefix** only           | **unavailable**               | works  |
| `full`   | 3+ chars     | username or display name substring | works                         | works  |

Post text search below 3 characters would be a full table scan, so it's turned
off rather than made slow. You get one of two things:

- **Combined call** → `posts` is present but empty, with a marker:
  ```jsonc
  "posts": { "items": [], "nextCursor": null, "unavailable": "MIN_QUERY_LEN" }
  ```
  Render "Keep typing to search posts", not "No results".
- **`?type=posts`** with a short query → **400 `QUERY_TOO_SHORT`** carrying
  `minQueryLength: 3`. An empty 200 would be a lie for a call that explicitly
  asked for posts.

Display-name search is also off in prefix mode — `?q=al` finds `alice` by
handle, but not a user whose display name is "Alan" with a different username.
It starts working at 3 characters.

---

## 2 — The response

Combined call, `GET /api/search?q=al&limit=2` (real body, trimmed):

```jsonc
{
  "success": true,
  "data": {
    "query": "al",          // what was actually searched, after trim + % stripping
    "mode": "prefix",
    "users": {
      "items": [
        {
          "id": "cml805q0u0000iaqhjdfk6z2y",
          "username": "alice",
          "createdAt": "2026-02-04T12:27:15.068Z",
          "profile": {
            "displayName": "Alice Johnson",
            "avatarUrl": "https://…/avatars/…/display.webp"
          }
        }
      ],
      "nextCursor": null
    },
    "posts": { "items": [], "nextCursor": null, "unavailable": "MIN_QUERY_LEN" },
    "topics": {
      "matched": [
        { "id": "019f…", "slug": "football", "name": "football",
          "category": "Sports & Fitness", "icon": null }
      ],
      "items": [ /* POSTS tagged with a matched topic */ ],
      "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2…"
    }
  }
}
```

**A section you didn't ask for is absent, not empty.** With `?type=users` the
body carries `data.users` and nothing else — no `posts` key at all. So:

```ts
if (!data.posts) { /* not requested — leave the tab as it was */ }
else if (data.posts.items.length === 0) { /* requested, no hits — empty state */ }
```

`"users" in data === false` and `data.users.items.length === 0` mean different
things. Don't collapse them.

### 2.1 — `users.items`

Exactly the fields shown above: `id`, `username`, `createdAt`, and a nullable
`profile { displayName, avatarUrl }`. Note what is **not** there:

- **No follow state.** No `youFollow` / `followsYou` / `isPrivate`. If your row
  needs a Follow button, it takes a second call — either `GET /api/users/:username`
  on tap, or drive the button from the profile screen you navigate to. (The
  mention picker, `GET /api/users/mention-search`, *does* return follow state —
  it's a different endpoint for a different job. See
  [Mentions](../mentions/mobile-mentions-guide.md).)
- **No counts.** No follower/post counts.

**Ordering** is recency (newest account first), with two relevance nudges applied
to the page you're given:

1. An **exact username match is hoisted to position 1** — page 1 only.
2. Within the page: exact handle, then username-prefix, then display-name-prefix,
   then everything else. Recency order survives inside each group.

There is no cross-page relevance score. A perfect match that sits on page 3 by
recency stays on page 3 (except the exact-handle hoist, which is a separate
lookup). Don't sort client-side — you'd only undo this.

**Never returned:** deactivated / mid-deletion accounts, accounts hidden by
moderation, and anyone on either side of a block (they blocked you, or you
blocked them). No client-side filtering needed.

### 2.2 — `posts.items`

**Canonical serialized posts** — the identical shape to `GET /api/posts/*` and
the feed, so reuse your post card component unchanged. Field list:
[Post Data Structure](../post/mobile-post-data-structure-guide.md)
(`author`, `media`, `topics`, `isLiked`, `isSaved`, `isReposted`,
`interestStatus`, `cta`, repost `original`, …).

Matched against post `content` **or** `title`, case-insensitive substring.
Visibility is fully applied server-side: private authors you don't follow,
blocked users (including a repost's original author) and hidden posts never appear.

### 2.3 — `topics` — the section that trips people up

```jsonc
"topics": {
  "matched": [ /* TOPIC objects — the chip row */ ],
  "items":   [ /* POSTS tagged with any of them */ ],
  "nextCursor": "…"
}
```

- **`matched` is the chip row.** Every topic whose `name` or `slug` matched.
  Complete and uncapped — not paginated, no cursor. `?q=music` currently returns
  20 chips. Render them scrollable and expect double digits.
- **`items` are POSTS, not topics.** Same post shape as §2.2.
- **`nextCursor` pages `items` only.** The chips never change while you page.
- **Works in both modes** — it filters on topic id, never on post text. `?q=AI`
  returns AI-topic posts even though `posts` is unavailable at 2 characters.
- **Matching is substring, so short queries are noisy.** `?q=al` matches
  `football` and `local-travel`. Expected, not a bug — but it's a reason to
  render the chip row below the People results on a short query.

**`posts` and `topics.items` overlap on purpose and are not de-duplicated.** They
answer different questions — *your words appear in this post* vs *this post is
filed under a topic you named* — and they page independently. Keep them in
separate tabs/sections; don't merge the two arrays.

---

## 3 — Paging

Each section has its **own** keyset cursor. One cursor cannot address three
sections, so the API rejects the ambiguous call rather than guessing:

```
GET /api/search?q=music&cursor=…               → 400 CURSOR_REQUIRES_TYPE
GET /api/search?q=music&type=posts&cursor=…    → ✅ page 2 of posts
```

The flow for a tabbed search screen:

1. **Query changes** → one combined call, no `type`. Fill all three tabs from it.
2. **User scrolls a tab to the bottom** → re-request *that section only*:
   `?q=<same q>&type=<section>&cursor=<that section's nextCursor>&limit=<same limit>`.
3. `nextCursor === null` → end of that section. Stop.
4. **Query changes again** → throw all three cursors away and go back to step 1.

Keep `q` byte-identical across a section's pages. Changing `q` mid-page invalidates
nothing server-side — it just silently pages a different search.

Malformed or hand-built cursors are **400 `INVALID_CURSOR`**. Treat a cursor as
opaque; never parse, cache across queries, or reuse one section's cursor for another.

---

## 4 — Typing behaviour

The endpoint is keystroke-driven; the budget is 30 requests / 10 seconds shared
across all three sections. Same debounce shape as the mention picker:

```typescript
let abortController: AbortController | null = null;

async function search(q: string, limit = 20) {
  abortController?.abort();
  abortController = new AbortController();

  await sleep(250);                                  // debounce
  if (abortController.signal.aborted) return;

  const res = await api.get(`/search?q=${encodeURIComponent(q)}&limit=${limit}`, {
    signal: abortController.signal,
  });
  return res.data.data;
}
```

- **Debounce 250ms** and cancel the in-flight request on the next keystroke.
- **Don't fire on an empty box.** `q` is required; an empty one is a wasted 400.
- Fire from 1 character — people and topics work there. The `posts` tab shows
  "Keep typing to search posts" until `q.length >= 3`.
- A paging request costs from the same bucket. If a user scrolls while typing,
  cancel the paging call first.

---

## 5 — Errors

| Status | `code`                 | Trigger                                                          | What to show                                              |
| ------ | ---------------------- | ---------------------------------------------------------------- | --------------------------------------------------------- |
| 400    | — (`Validation Error`) | `q` missing or >80 chars, bad `type`, non-numeric `limit`        | Bug on the client — don't fire the call                    |
| 400    | `QUERY_REQUIRED`       | `q` was only whitespace / `%` characters                         | Idle state, no error toast                                 |
| 400    | `QUERY_TOO_SHORT`      | `?type=posts` with fewer than 3 chars (`minQueryLength: 3`)      | "Keep typing to search posts"                              |
| 400    | `CURSOR_REQUIRES_TYPE` | `cursor` sent without `type`                                     | Bug — see §3                                               |
| 400    | `INVALID_CURSOR`       | Malformed / stale cursor                                         | Drop the cursor, reload the section from page 1            |
| 401    | —                      | Missing or expired token                                         | Refresh, then retry                                        |
| 422    | `CONTENT_BLOCKED`      | The query itself hit the blocked-word filter                     | Show `error` under the box; **don't** show empty results   |
| 429    | —                      | >30 requests / 10s                                               | Back off; retry after `Retry-After`                        |

Error envelope for the coded 400s:

```jsonc
{ "success": false, "error": "Query must be at least 3 characters to search posts",
  "code": "QUERY_TOO_SHORT", "minQueryLength": 3 }
```

### 5.1 — A blocked search query

The search box goes through the same app-wide filter as every other text input:

```jsonc
{ "success": false, "error": "Please use kinder words.",
  "code": "CONTENT_BLOCKED", "reason": "PROFANITY", "field": "q" }
```

`field: "q"` points at the search input. This is the shared 422 you already
handle everywhere else — see
[Content Moderation](../moderation/mobile-content-moderation-guide.md). A blocked
query is an **error**, not an empty result set; rendering "No results" here tells
the child their word was fine and nothing matched.

---

## 6 — Which search endpoint

| Screen                          | Endpoint                             | Why not `/api/search`                                |
| ------------------------------- | ------------------------------------ | ----------------------------------------------------- |
| The search screen               | `GET /api/search`                    | —                                                     |
| `@` autocomplete in a composer  | `GET /api/users/mention-search`      | Context-boosted, returns follow state, capped at 10   |
| "New message" / chat search     | `GET /api/chat/search`               | Searches your threads + startable contacts            |
| Events                          | `GET /api/events/search`             | **`/api/search` does not cover events at all**        |
| School picker                   | `GET /api/schools?q=…`               | Different catalog entirely                            |
| Topic / interest picker         | `GET /api/users/topics`              | Returns topics to pick, not posts                     |

Notably: **there is no separate People-tab endpoint to call.** `/api/users/search`
exists but is the older, narrower version of the same thing — build the People tab
from `data.users`.

---

## 7 — Gotchas reference

| Case                                          | Behaviour                                                                        |
| --------------------------------------------- | -------------------------------------------------------------------------------- |
| `?q=%25` (a literal `%`)                       | Stripped before searching. All-`%` query → 400 `QUERY_REQUIRED`                  |
| `?q=%20%20al%20%20` (padded)                   | Trimmed. `data.query` echoes what was actually searched — render that, not your raw input |
| `limit=20` on a combined call                  | Up to **60** items total, 20 per section                                         |
| Same post in `posts` and `topics.items`        | By design, not de-duplicated (§2.3)                                              |
| `topics.matched` is huge on a 2-char query     | Substring match; expected. Scroll the chip row                                   |
| `users.items` is empty but you know the handle | Blocked either direction, deactivated, or moderation-hidden. Nothing to retry    |
| Paging `topics` with the `posts` cursor        | Silently pages the wrong thing or 400s. Keep cursors per section                 |
| Two identical searches, different order        | Ordering is recency; a new signup or a new post shifts the page. Expected         |

---

## 8 — Implementation checklist

1. **One debounced call** (250ms + `AbortController`) on `q` change, no `type`.
2. **Three tabs off one response** — People / Posts / Topics. Missing key ≠ empty array.
3. **Posts tab honours `unavailable: "MIN_QUERY_LEN"`** — "keep typing", not "no results".
4. **Topic chips from `topics.matched`**, topic posts from `topics.items` — chips are
   not paginated, posts are.
5. **Per-section paging** with `?type=` + that section's `nextCursor`; reset all three
   cursors whenever `q` changes.
6. **Reuse the post card** — `posts.items` and `topics.items` are canonical serialized posts.
7. **Person rows carry no follow state** — either fetch the profile on tap or drop the
   inline Follow button.
8. **Handle 422 `CONTENT_BLOCKED` / `field: "q"`** with the shared moderation handler, and
   429 with a back-off.
