# Mobile Mentions — Integration Guide

> For mobile development. Everything needed to surface `@username` autocomplete,
> render mention highlights, read a user's mention inbox, and handle mention
> push notifications. No backend changes needed on your side.

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

---

## Short version — just the calls

```
1. GET  /api/users/mention-search?q=al&context=conversation:abc
                                        → autocomplete picker results
2. GET  /api/users/me/mentions          → mention inbox (cursor-paginated)
3. POST /api/chat/conversations/:id/messages  }
   POST /api/posts                            } — mentions auto-detected
   POST /api/posts/:postId/comments           }   from `content` field
```

Mentions are created **server-side** — you never POST a list of mentioned user
IDs. Write the raw content string; the backend parses `@username` tokens,
resolves them to user IDs, persists `Mention` rows, and fires notifications.

---

## 1 — Autocomplete search

### 1.1 — Endpoint

```http
GET /api/users/mention-search?q=al&context=conversation:abc&limit=8
```

**Auth:** Required.
**Rate limit:** 30 requests / 10 seconds per user.

| Param     | Type   | Required | Notes                                                                         |
| --------- | ------ | -------- | ----------------------------------------------------------------------------- |
| `q`       | string | yes      | 1–20 chars, `[A-Za-z0-9_]` only. Server normalizes before lookup.             |
| `limit`   | int    | no       | 1–10. Default 8.                                                              |
| `context` | string | no       | `post:<id>` \| `comment:<id>` \| `conversation:<id>` — boosts relevant users. |

**Response:**

```jsonc
{
  "success": true,
  "data": [
    {
      "id": "usr_01H...",
      "username": "Alice",
      "displayName": "Alice Smith",
      "avatarUrl": "https://...",
      "youFollow": true,
      "followsYou": false,
      "inContext": true, // in the conversation / post's comment thread
    },
  ],
}
```

Results are pre-ranked server-side:

| Signal                                  | Weight |
| --------------------------------------- | ------ |
| Candidate is in the `context` boost set | +8     |
| You follow the candidate                | +4     |
| Candidate follows you                   | +2     |
| Username (normalized) starts with `q`   | +1     |

Tie-break: alphabetical `username`. You do not need to sort client-side.

### 1.2 — When to fire

Trigger when the user types `@` followed by at least one word character in a
text input. The substring between the `@` and the cursor is `q`.

```
"hey @al"    → q = "al"
"hey @Alice" → q = "Alice"
```

Stop firing and close the picker when:

- User taps outside the picker
- User presses space or newline after the `@` token
- User backspaces past the `@`
- User selects a result

### 1.3 — Debounce + cancellation

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

async function fetchMentionSuggestions(q: string, context?: string) {
  abortController?.abort();
  abortController = new AbortController();

  // Debounce: wait 200ms before firing
  await sleep(200);
  if (abortController.signal.aborted) return;

  const params = new URLSearchParams({ q, limit: "8" });
  if (context) params.set("context", context);

  const res = await api.get(`/users/mention-search?${params}`, {
    signal: abortController.signal,
  });
  return res.data.data;
}
```

- Debounce **200ms** — avoids one request per keystroke.
- Cancel in-flight request on next keystroke (`AbortController`).
- Cache results keyed by `(q, context)` for the picker session lifetime.
- Fire on `q.length >= 1`. Server enforces; client just avoids empty calls.

### 1.4 — Picker selection

When the user taps a result, replace the `@<typed>` token with `@<username>`
(preserving the chosen user's casing from `username` field) and append a
space. Submit the **raw text** to the entity create endpoint — never send a
separate list of mentioned user IDs.

```typescript
function insertMention(
  text: string,
  atIndex: number,
  username: string,
): string {
  // atIndex = position of the @ that opened the picker
  // replace everything from @ to cursor with @username + space
  const before = text.slice(0, atIndex);
  const after = text.slice(getCursorPos()); // text after current cursor
  return `${before}@${username} ${after}`;
}
```

---

## 2 — Mention inbox

### 2.1 — Endpoint

```http
GET /api/users/me/mentions
GET /api/users/me/mentions?cursor=m_01G...&limit=20
```

Returns mentions where the authenticated user is the mentioned user. Ordered
by `createdAt DESC` (newest first). Default limit 20, max 50.

**Response:**

```jsonc
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "m_01H...",
        "entityType": "POST", // POST | COMMENT | MESSAGE
        "entityId": "p_01H...",
        "usernameSnapshot": "alice", // your username at time of mention
        "startOffset": 4, // UTF-16 offset of @ in content
        "endOffset": 11,
        "createdAt": "2026-05-07T10:00:00.000Z",
        "mentionerUser": {
          "id": "usr_b",
          "username": "Bob",
          "profile": { "displayName": "Bob", "avatarUrl": null },
        },
        "entity": {
          "id": "p_01H...",
          "content": "hey @alice check this",
        },
      },
    ],
    "nextCursor": "m_01G...", // null on final page
  },
}
```

Pass `nextCursor` as `?cursor=` for the next page. Stop when null.

### 2.2 — Rendering the inbox row

```
[Avatar]  Bob mentioned you in a post
          "hey @alice check this"          ← entity.content, truncate ~80 chars
          2 hours ago
```

- `entityType === "POST"` → deep-link to `/posts/<entityId>`
- `entityType === "COMMENT"` → deep-link to the parent post + scroll to comment
- `entityType === "MESSAGE"` → deep-link to `/chat/<conversationId>` (use the
  `data.conversationId` from the push payload — the inbox row doesn't carry it
  directly, so resolve via the chat screen)

### 2.3 — Highlight the mention in content

Use `startOffset` / `endOffset` (UTF-16 code-unit offsets) to slice the
`@username` token directly:

```typescript
function highlightMentions(
  content: string,
  startOffset: number,
  endOffset: number,
) {
  const before = content.slice(0, startOffset);
  const mention = content.slice(startOffset, endOffset); // "@alice"
  const after = content.slice(endOffset);
  return `${before}<b>${mention}</b>${after}`;
}
```

If offsets are null (legacy rows), fall back to regex highlight:

```typescript
const MENTION_REGEX = /(?<![a-zA-Z0-9_])@([a-zA-Z0-9_]{3,20})/g;
```

---

## 3 — How mentions are created (you don't do anything extra)

Whenever you POST a `content` field to any of these endpoints, the backend
auto-parses `@username` tokens, resolves them, and creates `Mention` rows:

| Endpoint                                    | Mention context                          |
| ------------------------------------------- | ---------------------------------------- |
| `POST /api/posts`                           | `entityType: POST`                       |
| `POST /api/posts/:postId/comments`          | `entityType: COMMENT`                    |
| `POST /api/chat/conversations/:id/messages` | `entityType: MESSAGE` (group chats only) |

**In DM chats:** mention parsing runs but notifications are suppressed — DM
recipients already get a `message` notification on every send. No duplicate.

**Restrictions:**

- A user cannot mention themselves (silently ignored).
- Inactive / deleted users are silently ignored.
- In group chats, only current participants can be mentioned.
- Same `@username` repeated in one message = one `Mention` row, one notification.

---

## 4 — Push notifications for mentions

When someone mentions you in a group chat or post/comment, you receive a `mention`
push notification. The backend suppresses the generic `message` notification
for that user to avoid double-notifying.

### 4.1 — Push payload shape

```jsonc
{
  "notification": {
    "title": "Bob mentioned you in a post", // or "chat" / "comment"
    "body": "hey @alice check this", // first 100 chars of content
  },
  "data": {
    "entityType": "post", // "post" | "comment" | "message"
    "entityId": "p_01H...",
    "actorId": "usr_b",
    "actorName": "Bob",
    "webPath": "/posts/p_01H...",
    "deepLink": "ksn://posts/p_01H...",
    // For comment mentions:
    "postId": "p_parent_01H...",
    // For message mentions:
    "conversationId": "cnv_01H...",
  },
}
```

### 4.2 — Deep-link handling

```typescript
function handleMentionPush(data: PushData) {
  switch (data.entityType) {
    case "post":
      navigate(`/posts/${data.entityId}`);
      break;
    case "comment":
      navigate(`/posts/${data.postId}`, { scrollTo: data.entityId });
      break;
    case "message":
      navigate(`/chat/${data.conversationId}`, { highlight: data.entityId });
      break;
  }
}
```

### 4.3 — Notification preferences

Users can mute mention notifications per channel:

```http
GET  /api/notifications/preferences
PUT  /api/notifications/preferences
{
  "preferences": [
    { "type": "mention", "pushEnabled": true, "inAppEnabled": true, "emailEnabled": false }
  ]
}
```

Default: push on, in-app on, email off. Server gates fan-out by these prefs —
no client-side suppression needed.

---

## 5 — Rendering @mentions in content

Anywhere you display `content` from posts, comments, or chat messages, apply
the mention renderer:

```typescript
const MENTION_REGEX = /(?<![a-zA-Z0-9_])@([a-zA-Z0-9_]{3,20})/g;

function renderContent(content: string): React.ReactNode {
  const parts: React.ReactNode[] = [];
  let lastIndex = 0;

  for (const match of content.matchAll(MENTION_REGEX)) {
    const [full, username] = match;
    const start = match.index!;

    if (start > lastIndex) {
      parts.push(content.slice(lastIndex, start));
    }
    parts.push(
      <MentionChip key={start} username={username} /> // tappable, links to profile
    );
    lastIndex = start + full.length;
  }

  parts.push(content.slice(lastIndex));
  return <>{parts}</>;
}
```

`MentionChip` should navigate to `GET /api/users/:username` profile on tap.
Use `mentionedUserId` (from `Mention` rows) as the durable ID — username may
change, but the ID is permanent.

---

## 6 — Error codes

| Code | Meaning                                                                                          |
| ---- | ------------------------------------------------------------------------------------------------ |
| 400  | `q` param missing, out of range, or invalid chars                                                |
| 401  | Missing / expired `Authorization` token                                                          |
| 403  | Not authenticated                                                                                |
| 429  | Mention search rate limit (30 req / 10s). Back off autocomplete; show "Slow down" if persistent. |

### 6.1 — Autocomplete error UX

| Scenario        | UX                                                                        |
| --------------- | ------------------------------------------------------------------------- |
| 429             | Close picker or show "Slow down" badge. Retry after `Retry-After` header. |
| Empty results   | Show "No matches"                                                         |
| Network error   | Silent — do not block typing                                              |
| Aborted request | Silent — next debounce cycle will fire                                    |

---

## 7 — Edge cases reference

| Case                                     | Behavior                                                                                 |
| ---------------------------------------- | ---------------------------------------------------------------------------------------- |
| User mentions themselves                 | Silently ignored — no row, no notif                                                      |
| `@alice` appears twice in one message    | One `Mention` row, one notification (unique constraint)                                  |
| Mentioned user is inactive               | Silently ignored — no row, no notif                                                      |
| Username changed after mention was saved | `Mention.mentionedUserId` still routes correctly; `usernameSnapshot` shows original text |
| User deletes account                     | Mention row auto-deleted (DB cascade). Render gracefully.                                |
| Edit removes a mention                   | Mention row deleted; push already delivered is NOT retracted                             |
| `@user` in a DM                          | Mention row created but notification suppressed (DM already notifies)                    |
| `email@domain.com` in content            | Regex skips — lookbehind prevents `@domain` from matching                                |

---

## 8 — Quick implementation checklist

Work in this order:

1. **Autocomplete trigger** — detect `@` in `<TextInput>`. Extract query substring. Call mention search with `context` param (pass `conversation:<id>` or `post:<id>` if in context).
2. **Debounce + cancel** — 200ms debounce, `AbortController` per keystroke.
3. **Picker UI** — render `username` + `displayName` + `avatarUrl`. Tap inserts `@username ` into input, closes picker.
4. **Submit raw text** — POST `content` as-is. Do not modify or strip `@` tokens before sending.
5. **Render mentions in feed / chat** — apply regex highlight across all `content` display sites. `MentionChip` links to user profile.
6. **Mention inbox screen** — GET `/api/users/me/mentions`, cursor-paginate, deep-link on row tap.
7. **Push handling** — register listener for `data.entityType === "mention"`. Deep-link to `entityType` (post / comment / chat).
8. **Notification prefs** — expose mention toggle in settings (push / in-app).
