Post Share (in-app) — Mobile Integration Guide
Sharing a post into a chat as a native card — not a pasted link. The
message stores only the postId; the server renders a live, per-viewer card on
read. This doc is for the mobile (React Native) client. Full chat flow:
mobile-chat-guide.md (this is §5.10, standalone here).
Scope
In-app DM/group share only. For external channels (WhatsApp, Messages,
email) keep using the URL share (POST /api/posts/:id/share, see
mobile-posts-guide.md). Native share wins in-app
because the card renders instantly (no OG-preview wait), works with link
previews off, and its counts/thumbnail are always current — the body is
resolved fresh on every read, never a frozen snapshot.
1 — Send a share
It's a normal message send with a postId instead of text/media.
POST /api/chat/conversations/:id/messages
Authorization: Bearer <accessToken>
Content-Type: application/json
{
"postId": "019f45c6-…", // the post being shared (required)
"content": "check this out", // optional caption (≤2000)
"clientMessageId": "uuid-…" // same idempotency rules as any message
}
Response 200
{
"success": true,
"data": {
"id": "019f4673-…",
"type": "POST_SHARE", // branch on this
"sharedPostId": "019f45c6-…",
"content": "check this out", // the caption (may be "")
"conversationId": "019f4673-…",
"senderId": "…",
"status": { "type": "sent", … }
// NOTE: no `sharedPost` card body here — resolve it, see §2
}
}
Errors
| Code | When |
|---|---|
400 | postId + media sent together (a share can't carry attachments) |
401 | Missing/expired token |
403 | Not a participant / removed / messaging-restricted |
404 | Post not found/hidden, or you can't view it (can't share what you can't see) |
429 | Rate limited (60 messages / min) |
Sending a native share also records the post's share count (_count.shares),
same as a URL share.
2 — Get the card (one resolver, two entry points)
The card is never on the new_message socket broadcast — a single
broadcast can't hold a viewer-specific card. Resolve it the same way in both
cases:
- Loading history —
GET /api/chat/conversations/:id/messagesalready inlinessharedPoston everyPOST_SHARErow. Nothing extra to do. - Live (
new_messagearrives) — the event carriestype:"POST_SHARE"+sharedPostIdonly. Batch-resolve:
POST /api/posts/share-cards
Authorization: Bearer <accessToken>
Content-Type: application/json
{ "postIds": ["019f45c6-…", "019f4676-…"] } // dedupe across the visible page
// Response — cards come back in request order
{ "success": true, "data": { "cards": [ /* SharedPostCard */ ] } }
Cache each card by postId. Counts are live, so refetch when you want fresh
numbers (e.g. reopening the thread).
3 — sharedPost shape + the three states
Branch on available (and reason when false).
// ── available: true → render the full card ──
{
"id": "019f45c6-…",
"available": true,
"author": {
"id": "…", "username": "adolf",
"grade": "7", "school": "Lincoln HS", // may be null
"profile": { "displayName": "Adolf", "avatarUrl": "https://…" }
},
"type": "FIND_TEAMMATES", // PostType
"title": "Chess team", // may be null
"content": "Looking for a chess team…", // WHOLE body — clamp for display
"thumbnailUrl": "https://…/thumb.jpg", // first image thumbnail, or null
"mediaCount": 1,
"counts": { "likes": 0, "comments": 0, "reposts": 0, "shares": 1 },
"createdAt": "2026-07-09T10:40:02.530Z"
}
// ── available: false, reason: "private" → identity-only LOCKED card ──
// Author is private and the viewer isn't an accepted follower.
{
"id": "019f4676-…",
"available": false,
"reason": "private",
"author": { // identity ONLY
"id": "…", "username": "charlie",
"profile": { "displayName": "Charlie Brown", "avatarUrl": "https://…" }
}
}
// ── available: false, reason: "unavailable" → "Post unavailable" ──
// Post deleted/hidden, OR the author blocked the viewer. NO identity.
{ "id": "019f4676-…", "available": false, "reason": "unavailable" }
| State | Render |
|---|---|
available: true | Full card. Tap → deep-link to the post detail screen. |
reason: "private" | Locked card: avatar + displayName + "Private account — follow to view". Non-navigable. |
reason: "unavailable" | Neutral "Post unavailable" bubble. No identity. Non-navigable. |
A caption (message.content) may accompany any state — render it above the
card as normal message text.
4 — Client checklist
- Send: reuse your normal optimistic-send +
clientMessageIdretry path (§5.2/5.3 of the chat guide). The optimistic bubble can render the card immediately from the post the user just tapped-share on — no round trip. - On
new_messagewithtype:"POST_SHARE": resolve viaPOST /api/posts/share-cards(batch the visible page, dedupe bypostId). - Sidebar/last-message preview for a bare share (no caption) shows
"Shared a post" — the server already returns that as
lastMessage.content. - Never trust a cached card's counts as authoritative — refetch on thread open.
5 — Examples
# alice@example.com / Password123 → accessToken
TOKEN=...
CID=<conversationId> POST_ID=<postId>
# Send a native share with a caption
curl -X POST localhost:3001/api/chat/conversations/$CID/messages \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"postId\":\"$POST_ID\",\"content\":\"check this out\",\"clientMessageId\":\"$(uuidgen)\"}"
# Resolve the card(s) as the recipient
curl -X POST localhost:3001/api/posts/share-cards \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"postIds\":[\"$POST_ID\"]}"
# History (card is inlined on POST_SHARE rows)
curl -s localhost:3001/api/chat/conversations/$CID/messages \
-H "Authorization: Bearer $TOKEN" \
| jq '.data.items[] | select(.type=="POST_SHARE") | {sharedPostId, sharedPost}'
Swagger UI: http://localhost:3001/api/docs (Chat → POST /api/chat/conversations/{id}/messages, Posts → POST /api/posts/share-cards).