UPDATED 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.
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.
What's new
- 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 withautoHidden: true, and an author whose content a human confirms as removed gets an email. Also new:GET /api/reports/reasonsserves 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. - 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
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.
}
{
"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
CONVERSATIONtarget (404). There is no group to answer for — report the other person withtargetType: "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
{
"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: falseon 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: trueand gotblocked: false. The report is safe and durable. The block is the thing that failed. Show "Reported — couldn't block, try again", and retry withPOST /api/blocks/:userId. Do not re-submit the report — you'll just getduplicate: 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.
{
"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
/clear404s 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/:id404s 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) byGET /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, noviewerLeft, 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
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
# 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
USERreports 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/:usernamenow 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/reasonsserves it, in order, withrequiresDetailsandtargetTypes. 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: trueon 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: trueis not an error. It's a 200. Don't show a red toast.- Feed and
repostedBylag 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.
alsoBlockon a single call is ordered correctly for you. Two separate calls are not — block first and the content report 404s.