Mobile Mentions — Integration Guide
For mobile development. Everything needed to surface
@usernameautocomplete, 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: mobileon 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
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:
{
"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
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.
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
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:
{
"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 commententityType === "MESSAGE"→ deep-link to/chat/<conversationId>(use thedata.conversationIdfrom 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:
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:
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
@usernamerepeated in one message = oneMentionrow, 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
{
"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
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:
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:
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:
- Autocomplete trigger — detect
@in<TextInput>. Extract query substring. Call mention search withcontextparam (passconversation:<id>orpost:<id>if in context). - Debounce + cancel — 200ms debounce,
AbortControllerper keystroke. - Picker UI — render
username+displayName+avatarUrl. Tap inserts@usernameinto input, closes picker. - Submit raw text — POST
contentas-is. Do not modify or strip@tokens before sending. - Render mentions in feed / chat — apply regex highlight across all
contentdisplay sites.MentionChiplinks to user profile. - Mention inbox screen — GET
/api/users/me/mentions, cursor-paginate, deep-link on row tap. - Push handling — register listener for
data.entityType === "mention". Deep-link toentityType(post / comment / chat). - Notification prefs — expose mention toggle in settings (push / in-app).