# Mobile Report & Block Guide

The two safety actions. **Block** is a state you own and can undo. **Report**
goes to a human being — there is still no queue to poll and no status to show,
but two reasons now hide the reported content the moment you submit, and the
response tells you when that happened.

All routes below are **authenticated** (`Authorization: Bearer <access>`).
Prefixes: reports = `/api/reports`, blocks = `/api/blocks`.

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

## 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-08** — **This guide was wrong and is now corrected.** It said "nothing is auto-hidden by a report" and "the reported user is never told". Both are false as of today: the two new first-person reasons (`SHOWS_ME_WITHOUT_CONSENT`, `IMPERSONATION_OF_ME`) hide the content immediately and come back with `autoHidden: true`, and an author whose content a human confirms as removed gets an email. Also new: `GET /api/reports/reasons` serves the picker, so a new reason no longer needs an app release. Read §1, §3 and §9 again — the old behaviour is not a fallback.
- **2026-09-07** — The report box is **never** profanity-filtered, on purpose: a child reporting bullying has to be able to quote it. Don't pre-filter that input in the app either. Every other text field in the app did just gain a blocked-word check — see [`mobile-content-moderation-guide.md`](../moderation/mobile-content-moderation-guide.md).
- **2026-09-01** — Blocked DMs still disappear for both sides — deliberately. Groups you left became visible-and-read-only in the same release; blocks were carved out, because restoring a blocked thread would undo what the blocker asked for and quietly tell the other child they were blocked.
- **2026-08-31** — Some chat decisions are recorded but not built yet — see the "decided, not yet shipped" note before building against them.

---

## 1. Mental Model

Don't merge the two buttons. They answer different questions.

```
Block   →  "I never want to see this person again."   reversible, instant, mine
Report  →  "Someone should look at this."             one-way, queued, theirs
        →  "That's me in it."                         …and it disappears now
```

A block takes effect immediately and the user can undo it from their block
list. A report goes to a human who reads it out of band.

**Most reports hide nothing.** The post stays on screen until you remove it
client-side, and the reported user is never told a report exists.

**Two reasons are different.** `SHOWS_ME_WITHOUT_CONSENT` and
`IMPERSONATION_OF_ME` are first-person claims — "that is me, and I did not
agree to it". Those hide the content the instant they are submitted, before any
human looks, and the response says so with `autoHidden: true`. Everything else
behaves exactly as it always has.

A hidden item is not deleted. A person reviews it and either leaves it hidden
(and emails the author) or puts it back. Nobody is told anything at hide time —
if a report turns out to be wrong, the content simply reappears.

The one place they meet is `alsoBlock`, which lets a single tap do both.

---

## 2. Report

```
POST /api/reports        # body below; no path params
```

```ts
interface CreateReportBody {
  targetType: "USER" | "POST" | "COMMENT" | "MESSAGE" | "CONVERSATION";
  targetId: string;      // user / post / comment / message / group ID — matches targetType
  reason: ReportReason;  // slug from §3
  details?: string;      // ≤ 500 chars, trimmed. REQUIRED for 4 reasons — see §3
  alsoBlock?: boolean;   // default false. Blocks the content's author.
}
```

```json
{
  "success": true,
  "data": {
    "id": "0198e2...",
    "status": "PENDING",
    "duplicate": false,
    "blocked": false,
    "autoHidden": false
  }
}
```

**`targetId` follows `targetType`.** For `USER` it is the user's ID (not the
username — same rule as `/api/follows/*`). For `POST` and `COMMENT` it is the
content ID. For `MESSAGE` it is the chat message ID; for `CONVERSATION` it is
the **group** conversation ID.

**Chat targets need you to be in the chat.** A `MESSAGE` or `CONVERSATION`
report requires you to be an **active participant** — anything else is `404`,
never `403`, so ids can't be probed. Two consequences worth designing around:

- Leaving a group means you can no longer report it or its messages. Offer the
  report action **before** the leave action in the overflow menu.
- A **DM is not a `CONVERSATION` target** (404). There is no group to answer
  for — report the other person with `targetType: "USER"` instead.

A `MESSAGE` report resolves to the message's **sender**, so `alsoBlock` blocks
them. A `CONVERSATION` report resolves to the group's **admin** — meaning the
admin cannot report their own group (`400`); they can delete it instead.
Soft-deleted messages stay reportable: the harm was seen before it was pulled.

**`alsoBlock` blocks the author, not the target.** Report a post with
`alsoBlock: true` and you block whoever wrote it. On a `USER` report those are
the same person.

**Reporting is idempotent per (you, target).** Reporting the same thing twice
never creates a second row and never errors — you get `duplicate: true`.

---

## 3. Reason slugs

The **slug** is the contract. Labels are still your UI copy — localize them in
the app; the server does not send display strings.

**New: ask the server for the list.**

```
GET /api/reports/reasons                        # all of them
GET /api/reports/reasons?targetType=CONVERSATION # only the ones valid there
```

```json
{
  "success": true,
  "data": {
    "reasons": [
      { "slug": "BULLYING_HARASSMENT", "requiresDetails": false,
        "targetTypes": ["USER", "POST", "COMMENT", "MESSAGE", "CONVERSATION"] },
      { "slug": "COPYRIGHT", "requiresDetails": true,
        "targetTypes": ["USER", "POST", "COMMENT", "MESSAGE", "CONVERSATION"] }
    ]
  }
}
```

**Render them in the order you receive them** — that array order is the picker
order, not an implementation detail. Adding a reason no longer needs an app
release, so build the picker off this call rather than a hard-coded list. Cache
it for the session; it changes on the order of once a year.

Ten today:

| Slug | Label | `details` | Notes |
|------|-------|-----------|-------|
| `BULLYING_HARASSMENT` | Bullying or harassment | optional | |
| `HATE_OR_DISCRIMINATION` | Hate or discrimination | optional | |
| `SPAM` | Spam | optional | |
| `FALSE_INFORMATION` | False information | optional | |
| `INAPPROPRIATE_CONTENT` | Inappropriate content | optional | |
| `SELF_HARM_OR_SAFETY` | Self-harm or safety concern | optional | Reaches a human first. Consider showing crisis resources alongside it |
| `COPYRIGHT` | This uses my work without permission | **required** | Never hides anything — a person decides |
| `SHOWS_ME_WITHOUT_CONSENT` | This shows me without my consent | **required** | **Hides on submit** |
| `IMPERSONATION_OF_ME` | This is pretending to be me | **required** | **Hides on submit.** Not offered on a group |
| `OTHER` | Something else | **required** | |

**Four reasons require `details`** — read `requiresDetails` rather than
hard-coding that set. Whitespace does not count: `"   "` is rejected exactly
like an empty string. Disable submit until the field has real characters, or
you will eat a 400.

**The two first-person reasons are worded as a claim the user is making about
themselves.** Keep that in your labels — "this shows *me*", not "this shows
someone". Selecting one is the assertion; the server cannot verify who is in a
photo, and it hides the content on the strength of that statement alone. A
confirmation step before submitting is worth building.

**`targetTypes` filters the picker.** Both first-person reasons are absent on
`CONVERSATION`: there is no way to hide a whole group, and doing so on one
person's claim would be wrong. Either pass `?targetType=` and render what comes
back, or filter locally on `targetTypes`.

---

## 4. Reading the response

Five fields now, and the booleans are independent. Render from all three:

| `duplicate` | `autoHidden` | `blocked` | What happened | Show |
|---|---|---|---|---|
| `false` | `false` | `false` | New report filed, nothing hidden | "Thanks — we're looking into it." |
| `false` | `false` | `true` | New report filed **and** author blocked | "Reported and blocked." |
| `false` | **`true`** | `false` | Filed **and the content is hidden right now** | "We've hidden this while someone checks it." |
| `false` | **`true`** | `true` | Same, plus the author is blocked | "Hidden while we check it. You've also blocked them." |
| `true` | `false` | `false` | **You already reported this.** Nothing was written. | "You've already reported this." — **not** "Report submitted." |
| `true` | `false` | `true` | Already reported, but this call applied the block | "Already reported. Blocked." |

`status` mirrors it: `"AUTO_HIDDEN"` when `autoHidden` is true, `"PENDING"`
otherwise. Both mean *no human has looked yet* — neither is a resolution, and
there is still nothing to poll.

> **`autoHidden: false` on a first-person reason is not an error, and you cannot
> tell why.** It means either that reason does not hide, or that this user has
> filed a lot of first-person reports recently and the hide was withheld. The
> API will not tell you which, deliberately — otherwise the limit is discoverable
> by anyone probing it. The report itself is always filed either way, so never
> retry and never show a failure. Just fall back to the ordinary "we're looking
> into it" copy.

**Remove the item from your local list yourself, exactly as before.** Even with
`autoHidden: true`, the copy of the post already in your state does not
disappear on its own — the *next* fetch will 404 or omit it. On a hidden chat
message you will also receive the existing `message:deleted` socket event, which
your current handler already drops from the thread; no new event to wire.

And the failure case worth handling:

> **You sent `alsoBlock: true` and got `blocked: false`.** The report is safe
> and durable. The block is the thing that failed. Show "Reported — couldn't
> block, try again", and retry with `POST /api/blocks/:userId`. **Do not
> re-submit the report** — you'll just get `duplicate: true`.

So the block state you render is `blocked`, never the `alsoBlock` you sent.

---

## 5. Block / unblock / list

```
POST   /api/blocks/:userId     # block — 204, idempotent
DELETE /api/blocks/:userId     # unblock — 204, idempotent
GET    /api/blocks?cursor=&limit=   # who I have blocked, newest first
```

All three take a **user ID**, not a username.

```json
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "0198c1...",
        "username": "someone",
        "profile": { "displayName": "Someone", "avatarUrl": "https://..." },
        "blockedAt": "2026-08-10T09:14:22.031Z"
      }
    ],
    "nextCursor": "2026-08-10T09:14:22.031Z"
  }
}
```

**The cursor is an ISO date string, not an ID.** Pass `nextCursor` straight
back as `?cursor=`. `nextCursor: null` means you have the whole list. `limit`
defaults to 30 and is capped at 100.

The block list is the one screen that deliberately ignores the visibility
rules — you can always see who you blocked.

---

## 6. What blocking actually does

One transaction, the moment you `POST /api/blocks/:userId`:

- **Follows are deleted both ways**, at any status. Following, followers and
  pending requests between you both disappear.
- **Shared 1-on-1 DMs are soft-left by both sides**, request or accepted. The
  conversation row survives and stays in history; new messages 403 either way.
  Block gates the other person; it does not erase what they already sent.
  In the requests tray, block is enough on its own — the row is gone from your
  tray, and you can't dismiss it afterwards, since `/clear` 404s once you are
  no longer an active participant.

  What that looks like on screen today: the DM **disappears from both people's
  conversation lists**, `GET /conversations/:id` 404s for both, and a send
  attempt is refused by the soft-leave branch — so the error reads
  `"You were removed from this conversation"`, which is not what happened.
  Message history is still served (clamped at the block) by
  `GET /conversations/:id/messages`, but nothing in the UI leads there.

  > **Settled 2026-09-01: this stays as it is.** Groups you left or were
  > removed from became visible and read-only that day (see
  > `mobile-chat-groups-guide.md` §8) — blocked DMs deliberately did **not**
  > join them. Resurrecting the thread in the blocker's list undoes the point
  > of blocking, and a "you can't message this account" banner would tell the
  > other child they were blocked. So: no list row, no `viewerLeft`, still 404
  > on fetch, and **no system message** — a block is reversible state, and a
  > permanent timeline row would outlive it. `docs/CHAT_SYSTEM_MESSAGES.md`
  > §3.3.
  >
  > Still worth fixing one day: the send error says "You were removed from this
  > conversation", because the soft-leave branch catches it first.
- **Follow notifications between you both are deleted** (`follow`,
  `follow_request`, `follow_accepted`).
- **Group chats are untouched** — leaving would tell the other members
  something happened.

What it does **not** do:

- Existing comments, likes and mentions stay visible. Only future
  interactions are gated. This is the product decision, not a bug.
- **Unblock restores nothing.** Follows stay gone, DM participation stays
  left, notifications stay deleted. Both users start from zero. Say so in the
  confirm dialog before you call `DELETE`.

After a block, the other user's profile returns **404** to you and theirs to
you — see the Gotchas.

---

## 7. Errors

| Code | When | What to do |
|------|------|-----------|
| 400 | Reporting your own post / comment / account | Never render the Report action on your own content. |
| 400 | A `requiresDetails` reason without `details`, or `details` > 500 chars | Four reasons need it — `COPYRIGHT`, `SHOWS_ME_WITHOUT_CONSENT`, `IMPERSONATION_OF_ME`, `OTHER`. Read `requiresDetails` from `GET /reasons` instead of hard-coding that list; the error names the `details` field. |
| 400 | Blocking yourself | Hide the Block action on your own profile. |
| 404 | Target doesn't exist, **or you're not allowed to see it** | The content is gone or restricted. Refresh the screen; do not retry. |
| 429 | More than 20 reports in an hour | Read `Retry-After` (seconds). Show a calm "you've reported a lot recently" message. |

**409 is never returned.** A duplicate report is a 200 — see §4.

---

## 8. Reference Snippets

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

export type ReportTargetType =
  | "USER" | "POST" | "COMMENT" | "MESSAGE" | "CONVERSATION";

export interface ReportReason {
  slug: string;
  requiresDetails: boolean;
  targetTypes: ReportTargetType[];
}

export interface ReportResult {
  id: string;
  status: "PENDING" | "AUTO_HIDDEN";
  duplicate: boolean;
  blocked: boolean;
  /** true = the content is hidden now. false is ambiguous — see §4. */
  autoHidden: boolean;
}

/** Render in the order returned — that IS the picker order. */
export const fetchReportReasons = (targetType?: ReportTargetType) =>
  api
    .get<{ data: { reasons: ReportReason[] } }>("/api/reports/reasons", {
      ...H,
      params: targetType ? { targetType } : undefined,
    })
    .then((r) => r.data.data.reasons);

export async function report(body: {
  targetType: ReportTargetType;
  targetId: string;
  reason: string;
  details?: string;
  alsoBlock?: boolean;
}): Promise<ReportResult> {
  const { data } = await api.post("/api/reports", body, H);
  return data.data as ReportResult;
}

export async function block(userId: string) {
  await api.post(`/api/blocks/${userId}`, null, H);
}

export async function unblock(userId: string) {
  await api.delete(`/api/blocks/${userId}`, H);
}

export async function listBlocked(cursor?: string) {
  const { data } = await api.get("/api/blocks", { ...H, params: { cursor } });
  return data.data as { items: BlockedUser[]; nextCursor: string | null };
}

// "Report and block" sheet — one call, then reconcile the block separately.
export async function reportAndBlock(
  targetType: ReportTargetType,
  targetId: string,
  authorId: string,
  reason: string,
  details?: string,
) {
  const r = await report({ targetType, targetId, reason, details, alsoBlock: true });
  if (!r.blocked) {
    // Report landed, block didn't. Retry just the block.
    await block(authorId);
  }
  return r;
}
```

### curl

```bash
# Report a post
curl -X POST http://localhost:3001/api/reports \
  -H "Authorization: Bearer <access>" -H "X-Client-Type: mobile" \
  -H "Content-Type: application/json" \
  -d '{"targetType":"POST","targetId":"<postId>","reason":"BULLYING_HARASSMENT"}'

# Report a user and block them in one call
curl -X POST http://localhost:3001/api/reports \
  -H "Authorization: Bearer <access>" -H "X-Client-Type: mobile" \
  -H "Content-Type: application/json" \
  -d '{"targetType":"USER","targetId":"<userId>","reason":"OTHER","details":"keeps messaging me","alsoBlock":true}'

# Block / unblock directly
curl -i -X POST   http://localhost:3001/api/blocks/<userId> -H "Authorization: Bearer <access>"
curl -i -X DELETE http://localhost:3001/api/blocks/<userId> -H "Authorization: Bearer <access>"

# My block list
curl http://localhost:3001/api/blocks?limit=30 -H "Authorization: Bearer <access>"
```

---

## 9. Gotchas

- **You can report a user you already blocked — but not a post you can no
  longer see.** The asymmetry is deliberate. Reporting someone *after*
  blocking them is the normal flow, so `USER` reports have no visibility gate.
  Content reports do, because post IDs are guessable enough to fish with. If
  you need "report the post too", collect the report **before** you block.
- **A blocked profile is a 404, not a 403.** `GET /api/users/:username` now
  hides users on either side of a block. Render it as "unavailable" — never
  as "private", and never retry.
- **Most reports hide nothing — two do.** Ordinary reasons leave the post in
  the feed until a human acts, no matter how many people report it. The two
  first-person reasons hide it on submit and tell you with `autoHidden: true`.
  Either way, **remove the item from your local list yourself** — your already-
  loaded copy does not vanish on its own.
- **Don't build a picker from a hard-coded list.** `GET /api/reports/reasons`
  serves it, in order, with `requiresDetails` and `targetTypes`. A new reason
  should not need an app release.
- **There is no report history endpoint.** Track "I reported this" locally.
  After a reinstall that state is lost — the server will tell you again via
  `duplicate: true` on the next attempt.
- **Never show the reporter's identity anywhere in the app.** Reports stay
  invisible to the reported user by design, and nothing in the API exposes
  them. That still holds: even the removal email an author may receive quotes
  the reviewer's own note, never the reporter's words.
- **An author only hears from us when something was actually removed.** If a
  person upholds a report *and* the content is hidden as a result, the author
  gets an email saying what was removed and why, and can reply to appeal.
  Upholding a report that hid nothing — an ordinary reason, or any group report
  — sends nothing at all. Nothing is sent at hide time either, and a restored
  item is silent. There is no in-app surface for any of this and nothing to
  poll — do not build a moderation inbox.
- **A hidden item is not deleted.** It comes back if the report is rejected.
  Render a 404 on something the user just reported as "unavailable", not as
  "deleted", and refetch rather than caching the absence.
- **`duplicate: true` is not an error.** It's a 200. Don't show a red toast.
- **Feed and `repostedBy` lag a block by up to 5 minutes.** Both are cached.
  A just-blocked user can appear in an already-loaded feed page — drop them
  client-side after a block rather than waiting for the server.
- **Report before you block, if you're doing both by hand.** `alsoBlock` on a
  single call is ordered correctly for you. Two separate calls are not — block
  first and the content report 404s.
