UPDATED 2026-11-04 — When an original post is deleted, its reposts are deleted too. They no longer show "Original removed" in profiles and lists. Drop any reposts you hold in local state for that post. Reposts that were orphaned before today still show the placeholder.
Mobile Post Interactions — Integration Guide
Save, repost, share. Everything mobile needs. Backend already shipped. Companion to
mobile-posts-guide.md— read that first for post-object shape + auth headers.Visibility model: the active gate today is profile-level only (
docs/private-profile-implementation.md). Saving / reposting / sharing a post by an author whose profile is currently public works normally; if that author later flips private and the viewer is not approved, the saved-list returns a shell (isUnavailable: true,unavailableReason: "RESTRICTED") andGET /posts/:idreturns 404. Per-post visibility (Everyone / Friends) is deferred — seedocs/post-visibility-implementation.md.
- Base URL:
http://localhost:3001(dev) — production URL from backend team - Auth:
Authorization: Bearer <accessToken>on every endpoint - Headers:
Content-Type: application/json,X-Client-Type: mobile
What's new
- 2026-11-04 — When an original post is deleted, its reposts are deleted too. They no longer show "Original removed" in profiles and lists. Drop any reposts you hold in local state for that post. Reposts that were orphaned before today still show the placeholder.
- 2026-10-08 — The guide was wrong: it said to branch a repost on
originalPostId. Branch onoriginalinstead.original: nullmeans "Original removed" even whenoriginalPostIdis still set, which happens whenever the original is hidden or its author is unavailable (and, from today, when the author is inside the 30-day deletion window). Also: comments and likes by an account in its 30-day deletion window (or moderation-hidden) are left out of comment threads,total, and the likers list. The post's_countisn't recounted, so it can be a little higher than the rows you get. - 2026-10-05 —
originalon a repost now carries the full original post, so a repost card can render exactly like the original's card: location, mode, event/deadline, link, details, topics, author grade/school, counts, request prompt/options,requestState,cta, plus your ownviewerRequestStatus/viewerRequestConversationId. Nothing removed — existing fields unchanged. Per-viewerisLiked/isSavedare not embedded; act onoriginal.id. - 2026-09-09 — The share URL in §3.1 was wrong: it is
https://letsclustr.com/posts/<id>?ref=<code>, nothttp://localhost:3000/p/<id>. The link is live now — it opens the app, or a download page if the app isn't installed. Treatrefas opaque. Platform setup: mobile-deep-links-guide.md.
At-a-glance endpoint table
| Method | Path | Use |
|---|---|---|
PUT | /api/posts/:id/save | bookmark on/off |
GET | /api/posts/saved | "Saved" tab list |
POST | /api/posts/:id/repost | one-tap repost (empty body) |
DELETE | /api/posts/:id/repost | undo simple-repost |
GET | /api/posts/:id/reposts | list reposters |
POST | /api/posts/:id/share | record share + get URL |
GET | /api/posts/:id/share/preview | share-sheet prefill |
Post object — new fields
Every post (detail, list, feed, /me, /user/:name, /saved) now carries:
{
// existing fields...
"isLiked": false,
"isSaved": false, // viewer's bookmark state
"isReposted": false, // viewer has reposted this
"viewerRepostId": null, // viewer's repost row id
"originalPostId": null, // set on repost rows; null on regular posts
"original": null, // embedded original-post summary on reposts
"repostedBy": null, // mutual-follow repost attribution (see §2.4)
"_count": {
"likes": 0,
"comments": 0,
"requests": 0,
"reposts": 0, // new
"shares": 0 // new
// saves intentionally omitted — private
}
}
original shape (only present when type === "REPOST" + original not deleted).
Same fields as the original's own list card, except isLiked / isSaved
(fetch the original if you need them) and location is the coarse city only:
{
"id": "pst_root123",
"type": "FIND_TEAMMATES",
"title": "Looking for a 4th",
"content": "Hackathon team...",
"location": "Mumbai", // coarse, same rule as list cards
"locationMode": "ONLINE",
"eventAt": "2026-09-24T18:30:00Z",
"deadlineAt": "2026-09-21T18:30:00Z",
"link": null,
"details": null,
"authorId": "usr_a",
"isAiGenerated": false,
"createdAt": "2026-06-10T12:00:00Z",
"author": { "id": "usr_a", "username": "alice", "grade": "12th", "school": "...", "profile": { "displayName": "Alice", "avatarUrl": "..." } },
"topics": [ { "id": "...", "slug": "academics-learning", "name": "Academics & Learning", "icon": null } ],
"media": [ /* same shape as top-level media */ ],
"_count": { "likes": 0, "comments": 0, "reposts": 1, "shares": 0 },
"requestPrompt": "Why are you a good fit?",
"requestPromptDescription": "...",
"requestOptions": ["I have experience"],
"requestPromptSource": "AUTHOR",
"requestState": "OPEN",
"cta": { /* resolved for YOU, same as a normal post */ },
"viewerRequestStatus": null,
"viewerRequestConversationId": null
}
1 — Save (private bookmark)
Private. No notification to author. No public count.
1.1 — Toggle save
PUT /api/posts/:id/save
{ "saved": true } // or false
Response:
{ "success": true, "data": { "saved": true, "updatedAt": "2026-06-15T07:35:40.451Z" } }
Optimistic UI (mandatory):
- On tap: flip
isSavedlocally. No spinner. - Fire PUT in background.
- On HTTP error: roll back + toast.
- Track
updatedAtper postId — discard response if older than last applied. - On refetch: server is truth, overwrite local.
Rate-limited 30/10s per user.
1.2 — Saved tab list
GET /api/posts/saved?limit=20&cursor=<opaque>
Returns { items, nextCursor } — full post objects, ordered by save
time (not post time). Cursor is base64url; treat as opaque, pass back as
?cursor=. Stop when nextCursor === null.
1.3 — Errors
| code | meaning |
|---|---|
| 401 | bad auth |
| 404 | post not found |
| 429 | rate limit |
2 — Repost (one-tap, v2)
A repost is a Post row with type=REPOST, content="" and originalPostId
set. Renders as "X reposted" + embedded original.
v2 change: quote reposts ("with thoughts") were removed.
POST /:id/reposttakes an empty body — any non-empty body is a 400. Historical quote rows (content!="") may still appear in feeds and inGET /:id/reposts(kind:"quote"); render their content above the embedded original as before.
Server collapses chains — repost-of-a-repost always anchors at the root.
One repost per user per original, DB-enforced. REPOST never appears in the
kind picker (GET /api/posts/types filters it out).
2.1 — Create
POST /api/posts/:id/repost
{} // body MUST be empty
Response: full post object (same shape as POST /api/posts) with
type=REPOST, originalPostId populated, embedded original.
After response: bump _count.reposts on the source card by +1, set
isReposted=true, store viewerRepostId from response.
409 = already reposted. Treat as success client-side: set
isReposted=true and leave the count alone (a previous tap already won).
2.2 — Undo
DELETE /api/posts/:id/repost
:id = original (or any post in same chain — server resolves to root).
Sets isReposted=false, decrement _count.reposts.
2.3 — Reposters list
GET /api/posts/:id/reposts?limit=20&cursor=<opaque>
{
"success": true,
"data": {
"items": [
{
"id": "pst_repost1",
"kind": "simple", // "simple" | "quote" (quote = legacy rows only)
"createdAt": "2026-06-15T07:00:00Z",
"user": { "id": "usr_b", "username": "bob", "profile": { ... } },
"contentPreview": "" // empty for simple
}
],
"nextCursor": "eyJj..."
}
}
2.4 — Render rules + repostedBy
The feed only ever contains original posts. You will not get
type=REPOST cards in GET /api/feed — a repost boosts the original into
feeds, and repostedBy tells you who to credit. Use post.id for
everything (like, comment, navigate); there is no second id to worry about.
type=REPOST cards appear only on profile lists (GET /posts/me,
GET /posts/user/:name — "you/they reposted" entries) and when a
notification deep-links to a repost id:
if (post.type === "REPOST") {
// Branch on `original`, not `originalPostId` — the id stays set when the
// original is hidden or its author is unavailable (moderation-hidden, or in
// the 30-day deletion window).
if (post.original !== null) {
// Hat: "<author.username> reposted"
// Legacy quote rows: if post.content !== "" render it above the original.
// Embedded original card = post.original.
// Tap on embedded original card → navigate to post.original.id.
} else {
// Original deleted, hidden, or its author unavailable.
// Render "Original removed" placeholder.
// (Only case where a REPOST card can also reach the feed.)
}
} else {
// Standard render. _count.reposts shows how many times reposted.
// isReposted tells you if the viewer is one of them.
}
repostedBy — every post (list, feed, detail) carries mutual-follow
repost attribution. Only the viewer's mutuals (both follow each other) are
ever named; strangers never leak:
"repostedBy": {
"users": [ // up to 3, newest first
{ "id": "usr_c", "username": "charlie", "profile": { "displayName": "Charlie", "avatarUrl": "..." } }
],
"mutualCount": 5 // total MUTUAL reposters (public total = _count.reposts)
}
// null when no mutual has reposted
Render: "Reposted by Charlie", "Reposted by Charlie, Dana and 3 others".
It is keyed on the root post, so an original card and a repost card of it
show the same attribution. May lag follow/unfollow changes by up to 5
minutes (server-side mutual cache).
2.5 — Errors
| code | meaning |
|---|---|
| 400 | self-repost / non-empty body |
| 404 | original not found, hidden, or not visible to you — hidden was a 400 before 2026-08-25; render your existing not-found state |
| 409 | already reposted (POST — treat as success) / no repost to remove (DELETE) |
| 429 | rate limit (30 / 10 min) |
2.6 — Notifications
| type | recipient | tap-target |
|---|---|---|
repost | root post author | data.entityId = repost id |
data.originalPostId carried alongside if you want to navigate to the
original instead. (quote_repost / repost mention notifications no longer
fire — historical rows may still exist with those types.)
3 — Share
Generates a tracked URL the viewer can paste anywhere. Records each share.
Status: backend ready. Tap-through to the app (Universal Links / App Links) lands in a later phase once a mobile build exists. Right now the URL is safe to copy-paste but does not open the app or render a rich preview when the recipient taps it. Ship the button anyway — flip on link infra later without API changes.
3.1 — Get share URL
Call this before opening the native share sheet:
POST /api/posts/:id/share
{ "channel": "NATIVE" }
channel values:
WHATSAPP | TWITTER | INSTAGRAM | FACEBOOK | COPY_LINK | CHAT | NATIVE | OTHER
Use NATIVE for the OS share sheet (default). Use a specific channel only
when the user picks an in-app target you know about (e.g. CHAT for
in-app conversation share, COPY_LINK for the copy button).
Response:
{
"success": true,
"data": {
"url": "https://letsclustr.com/posts/019f45c6-…?ref=Ab12CdEf34",
"shareCode": "Ab12CdEf34",
"channel": "NATIVE"
}
}
Pass url to:
- iOS:
UIActivityViewController - Android:
Intent.ACTION_SENDwithEXTRA_TEXT
3.2 — Prefill share sheet preview
Optional but recommended — improves the link preview the recipient sees:
GET /api/posts/:id/share/preview
{
"success": true,
"data": {
"title": "Looking for a 4th for hackathon team",
"description": "Building a flashcard app...",
"imageUrl": "https://cdn.../uploads/abc.jpg"
}
}
iOS — attach as UIActivityItemSource metadata. Android — pass through
the share Intent extras (EXTRA_TITLE, EXTRA_TEXT).
3.3 — Share-count on post
post._count.shares increments after each successful POST /share. No
per-viewer isShared flag — share is a transient action, not a state.
3.4 — In-app chat share
Two paths:
- Today:
POST /sharewithchannel: "CHAT"→ take the returnedurl, send it as a regular chat message (URL preview rendered by chat UI). - Future: dedicated
MessageType=POST_SHAREwith embeddedpostIdis on the roadmap (cleaner native render). Not implemented yet — use the URL path for now.
3.5 — Errors
| code | meaning |
|---|---|
| 404 | post not found or hidden |
| 429 | rate limit (60 / minute per user) |
4 — Optimistic UI cheatsheet
| Action | Optimistic? | Race protection |
|---|---|---|
| like | yes (existing) | updatedAt per postId |
| save | yes (mandatory) | updatedAt per postId |
| repost (simple) | optional — fast enough to await | n/a |
| repost (quote) | no — compose screen waits | n/a |
| share | n/a — call returns the URL, share happens after | n/a |
Save mirrors like exactly. Same updatedAt discard rule (apply only if
response updatedAt > last applied for this postId).
5 — Rate limits
| Endpoint | Limit |
|---|---|
PUT /:id/save | 30 / 10s per user |
POST /:id/repost | 30 / 10 min per user |
POST /:id/share | 60 / minute per user |
All return 429 with no body when exceeded. Debounce taps that happen faster than a human can tap.
6 — UI mapping (suggested icons + states)
| Action | Idle icon | Active state |
|---|---|---|
| Save | bookmark outline | bookmark filled when isSaved |
| Repost | repeat (two-arrow) | green/accent tint when isReposted |
| Share | share-arrow (iOS up-arrow / Android tri-dot) | static — no toggle |
Render rules:
- Save / repost / share counts: hide when 0 (or grey out — product call).
post._count.repostsincludes both simple and quote reposts.- Saved icon should not show a count anywhere.
7 — Open questions for product
- Show count beside repost icon, or hide until non-zero?
- Long-press share = "copy link" shortcut? (Would call POST
/sharewithchannel: "COPY_LINK".) - "Saved" tab placement: profile sub-tab, or settings shortcut?
- Quote-repost compose screen: full-screen modal or sheet?
Backend doesn't care — answers shape only mobile UX.
8 — Quick integration checklist
- Add
isSaved,isReposted,viewerRepostId,originalPostId,original,_count.reposts,_count.sharesto your Post type. - Save button: optimistic flip +
PUT /save+updatedAtreconcile. - Saved tab:
GET /api/posts/saved+ cursor paginate. - Repost button: bottom-sheet → simple (
POST /repost {}) or quote (POST /repost { content }). - Feed render: handle
type === "REPOST"(hat + embeddedoriginal). - Like/comment on repost card act on repost id, not original.
- Share button:
POST /share→ passurlto native share sheet (never build the URL yourself). - Optional:
GET /share/previewfor richer share-sheet metadata. - Handle notifications:
repost,quote_repost. Tap → navigate todata.entityId(the repost).
That's it.