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 no queue to poll and no status to show. Nothing is hidden for anyone else when you report, whatever the reason; it only tidies your own feed and discovery. Which reasons you may offer depends on what is being reported — see §3.
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-10-12 — Reports no longer hide anything for other people — not even "shows me without consent" or "pretending to be me".
autoHiddenis now alwaysfalseandstatusalways"PENDING"(the fields stay; stop branching ontrue). What changes is only the reporter's own view: a reported post leaves your feed, a reported profile leaves your feed, search, discover and suggestions (its posts leave your feed too). Both still open by direct link. Content is hidden for everyone only after a moderator confirms; then the author is emailed. Delete the "we've hidden this" copy path. See §1, §4. - 2026-10-08 — When two people have blocked each other, each sees the other as "Clustr user" everywhere, Settings → Blocked included. That row still has
blockedAt, the realidandblockedByMe: true: render "Clustr user · blocked· Unblock". The dummy also carries blockedByMein chats, so you can offer Unblock there. Their profile is a 404. See §6.1. - 2026-10-07 — Blocks now work one way for identity. The person you block can't see you: they get a placeholder (
isUnavailable: true, "Clustr user", no photo) wherever you'd appear. You still see them, flaggedblockedByMe: true, and their profile opens as a limited header with Unblock. Shared groups stay; replying to or reacting to each other's messages is 403. See §6.1. - 2026-10-07 — Unblocking now re-opens the DM. When the last block between two people is lifted, the same thread (history intact) becomes writable again for both: the old 403 "You can't send messages to this account" goes away and both sides get a live
chat_list_updatewithviewerLeft: null— show the composer again on that. If the other person still blocks you too, the thread stays frozen. Don't create a new DM after unblock;POST /chat/conversationsreturns the re-opened one. Follows and notifications are still not restored. Also: §6 still said a blocked DM disappears from both lists — wrong since 2026-10-01 (it stays, read-only); corrected. - 2026-10-07 — Every user object here now also carries
gradeandschool(bothstring | null, from the user's current school) next toisStudentVerified— show them under the name.null= no current school on file. Only sent to student-verified viewers: an unverified viewer gets neither key on anyone but themselves, so treat missing as "hidden". Additive for verified users. Details: Student verification guide.
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
(only YOUR view changes)
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.
A report hides nothing for anyone else. Every reason behaves the same way,
including the two first-person claims (SHOWS_ME_WITHOUT_CONSENT on a post,
IMPERSONATION_OF_ME on a profile — those are only queued high priority).
The post or profile stays visible to everyone until a person reviews it, and the
reported user is never told a report exists.
What changes for the reporter: a reported post leaves their home feed; a reported profile leaves their feed, people search, discover and suggestions (and that person's posts leave their feed). Both are permanent for the reporter and both still open by direct link / from the author's profile. Remove the item from your already-loaded list yourself.
When a person confirms a report, the content is hidden for everyone and the author is emailed. If they reject it, nothing changes for anyone.
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.
Reasons depend on what is being reported
Since 2026-09-10 the reason list is not the same on every screen, and it
is enforced: POST /api/reports returns 400 for a reason the target does not
offer. "Copyright" against a person and "fake account" against a comment are
both now errors, where before they filed a report a human had to throw away.
GET /api/reports/reasons?targetType=USER # what a profile accepts, in profile order
GET /api/reports/reasons?targetType=POST # what a post accepts, in post order
GET /api/reports/reasons # all 12 — the union, NOT a picker
Always pass targetType and render exactly what comes back. A hard-coded
picker will push users into a 400 they cannot fix.
The order differs per screen too, not just the set. A profile leads with the
account reasons and puts "Spam" above "Bullying"; a post leads with "Bullying".
The bare GET /api/reports/reasons returns every slug in one flat order that
matches no screen — it is there for tooling, not for a picker. Don't render
it and don't re-sort what you get back.
{
"success": true,
"data": {
"reasons": [
{
"slug": "FAKE_ACCOUNT",
"requiresDetails": false,
"targetTypes": ["USER"]
},
{
"slug": "BULLYING_HARASSMENT",
"requiresDetails": false,
"targetTypes": ["USER", "POST", "COMMENT", "MESSAGE", "CONVERSATION"]
}
]
}
}
Render them in the order you receive them — that array order is the picker
order for that surface, not an implementation detail. Cache per targetType for
the session.
What each surface offers, in the order it serves them
| Reporting a… | targetType | Reasons, in picker order |
|---|---|---|
| Profile | USER | FAKE_ACCOUNT, INAPPROPRIATE_PROFILE, SPAM, SELF_HARM_OR_SAFETY, BULLYING_HARASSMENT, IMPERSONATION_OF_ME, OTHER |
| Post | POST | the 7 content reasons, with COPYRIGHT and SHOWS_ME_WITHOUT_CONSENT inserted just before OTHER |
| Comment | COMMENT | BULLYING_HARASSMENT, HATE_OR_DISCRIMINATION, SPAM, FALSE_INFORMATION, INAPPROPRIATE_CONTENT, SELF_HARM_OR_SAFETY, OTHER |
| Chat message | MESSAGE | same 7 as a comment |
| Group | CONVERSATION | same 7 as a comment |
A profile is judged as an account, not as content: no "false information", no
"copyright", no "inappropriate content" — a bad avatar or bio is
INAPPROPRIATE_PROFILE, and someone pretending to be a real person is
FAKE_ACCOUNT (or IMPERSONATION_OF_ME if it is the reporter they are
pretending to be).
All twelve slugs
| Slug | Suggested label | details | Offered on | Notes |
|---|---|---|---|---|
FAKE_ACCOUNT | This account is fake | optional | profile | Not about the reporter — for a bot, or someone pretending to be a person in general |
INAPPROPRIATE_PROFILE | Inappropriate profile | optional | profile | The avatar, bio, username — not something they posted |
BULLYING_HARASSMENT | Bullying or harassment | optional | everything | |
HATE_OR_DISCRIMINATION | Hate or discrimination | optional | content | |
SPAM | Spam | optional | everything | |
FALSE_INFORMATION | False information | optional | content | |
INAPPROPRIATE_CONTENT | Inappropriate content | optional | content | |
SELF_HARM_OR_SAFETY | Self-harm or safety concern — on a profile, "Safety concern" | optional | everything | Reaches a human first. Consider showing crisis resources alongside it |
COPYRIGHT | This uses my work without permission | required | post | A person decides |
SHOWS_ME_WITHOUT_CONSENT | This shows me without my consent | required | post | High priority for the reviewer. Hides nothing on submit |
IMPERSONATION_OF_ME | This is pretending to be me | required | profile | High priority for the reviewer. Hides nothing on submit |
OTHER | Something else | required | everything |
"content" = post, comment, chat message and group.
SELF_HARM_OR_SAFETY carries two labels on purpose. It is one slug, handled
one way; on a profile the natural wording is "Safety concern", on a post it is
"Self-harm or safety". There is no separate SAFETY_CONCERN value — don't look
for one.
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, so it goes to the top of the review queue rather than hiding anything.
Photos inside a comment or a chat message have no "that's me" option.
SHOWS_ME_WITHOUT_CONSENT is post-only; elsewhere the closest
reason is INAPPROPRIATE_CONTENT, which has normal review priority.
That is deliberate, not an oversight — don't send the post-only slug anyway, it
is a 400.
4. Reading the response
autoHidden is always false and status is always "PENDING" (both stay on
the wire so existing clients keep parsing). Render from duplicate and
blocked:
duplicate | blocked | What happened | Show |
|---|---|---|---|
false | false | New report filed | "Thanks — we're looking into it." |
false | true | New report filed and author blocked | "Reported and blocked." |
true | false | You already reported this. Nothing was written. | "You've already reported this." — not "Report submitted." |
true | true | Already reported, but this call applied the block | "Already reported. Blocked." |
Don't build a "we've hidden this" path. Before 2026-10-12 the two first-person reasons returned
autoHidden: true. They no longer do, for anyone. If you still branch on it, that branch is simply dead.
Remove the item from your local list yourself. The copy of the post or profile already in your state does not disappear on its own — the next feed fetch omits it for you (and only you).
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.On screen the DM stays in both conversation lists, read-only, with
viewerLeft: { reason: "blocked" }— render a neutral "can't send messages to this account" banner for both sides. A send 403s with that same text. No system message is written. Full contract:mobile-chat-guide.md§13. -
Every notification between you both is deleted, any type (it used to be follow types only), and none are sent between you from now on.
-
Group chats are kept — both of you stay members and can read and send. Leaving would tell the other members something happened. What changes inside the group is identity and interaction, §6.1.
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 re-opens the DM, nothing else. Since 2026-10-07 the frozen DM
becomes writable again for both (same thread,
chat_list_updatewithviewerLeft: null) — unless the other person also blocked you, in which case it stays frozen until they unblock too. Follows stay gone and notifications stay deleted. Say so in the confirm dialog before you callDELETE.
After a block the blocked person gets 404 on the blocker's profile; the blocker gets a limited header for the person they blocked — §6.1.
6.1 Directional identity (since 2026-10-07)
A block now works one way for identity. If A blocks B:
| A (the blocker) | B (the blocked) | |
|---|---|---|
| B's / A's profile | GET /users/B → limited header { id, username, isStudentVerified, displayName, avatarUrl, limited: true, blockedByMe: true } — render it with an Unblock button | GET /users/A → 404, like a deleted account |
| The other person in chats, groups, comments, likes, lists, sockets | real identity, plus blockedByMe: true on the user object | a dummy: { id, username: null, isStudentVerified: false, isUnavailable: true, displayName / profile.displayName: "Clustr user", avatarUrl: null } |
@handle in text | as written | @unavailable |
| Shared group | still a member, reads + sends | still a member, reads + sends |
| Reply to / react to the other's message | 403 "You can't interact with this message" | 403, same |
| Notifications from the other | none | none |
isUnavailable: true→ non-navigable. No profile tap, no mention, no reply, no react, generic avatar. The id is real on purpose so message bubbles keep grouping, but every direct lookup 404s anyway.blockedByMe: true→ disable reply / react / mention on that person's messages up front instead of waiting for the 403.- B can only unblock A if B created a block of their own.
Both blocked each other (mutual). Each of you sees the other as the dummy — their block means you don't see them, wherever that is:
| Where | What you get | What to render |
|---|---|---|
Settings → Blocked (GET /api/blocks) | { id, username: null, isUnavailable: true, profile: { displayName: "Clustr user", avatarUrl: null }, blockedAt, blockedByMe: true } | "Clustr user · blocked DELETE /api/blocks/:id with the real id) |
| Their profile | 404 (no limited header) | "unavailable" |
| DM header / group member / message sender | the dummy with blockedByMe: true | generic avatar; offer Unblock from the menu |
Unblocking your side turns it back into a one-way block: you see them for
real again (with blockedByMe: true), they still see your dummy. The DM
re-opens only when both sides have unblocked.
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 | A reason the target does not offer — e.g. COPYRIGHT on a USER | details[].field is reason and the message reads <REASON> is not a valid reason for <TARGET>. This is a bug in your picker, not something the user can fix — build it from GET /reasons?targetType= and it cannot happen. |
| 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;
/** Always "PENDING" (AUTO_HIDDEN no longer occurs). */
status: "PENDING" | "AUTO_HIDDEN";
duplicate: boolean;
blocked: boolean;
/** Always false since 2026-10-12 — nothing hides on submit. See §4. */
autoHidden: boolean;
}
/**
* Render in the order returned — that IS the picker order for that surface.
* ALWAYS pass `targetType`: both the set and the order differ per surface, and
* POST /api/reports 400s a reason its target does not offer. Omitting it returns
* the flat union, which matches no screen.
*/
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 profile that blocked you is a 404, not a 403. Render it as
"unavailable" — never as "private", and never retry. A profile you
blocked is a 200 with
blockedByMe: true(§6.1). - Reports hide nothing for others. Whatever the reason, the post or profile stays visible to other people until a human confirms it. The reporter never sees a reported post in their home feed again, and never sees a reported profile (or its posts) in feed, search, discover or suggestions — but both are still reachable by id / on the author's profile. 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, and always pass
?targetType=. Neither the reasons nor their order are the same on every screen — a profile has no "copyright", a comment has no "fake account", and the profile picker puts "Spam" above "Bullying" where a post does the opposite. Sending a reason the target does not offer is a 400 the user cannot fix.GET /api/reports/reasons?targetType=serves the right list in the right 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, the content is hidden for everyone and the author gets an email saying what was removed and why, and can reply to appeal. Upholding a group report (nothing to hide) sends nothing at all. Nothing is sent when a report is filed, and a rejected report 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. Content a moderator upheld is hidden for everyone. Render a 404 on a post or profile 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.