UPDATED 2026-09-09 — The conversation's pendingPostRequest now includes selectedOptions, so the chat card can show the ticked options exactly like the author's Responses tray. No extra call.
Mobile Post Request ↔ DM Wiring — Integration Guide
Wiring guide for the request→DM integration only: a request opening its conversation at submit time, accept flipping it, the author's "new response" badge, and the edit freeze. Prerequisites (read first):
mobile-post-requests-guide.md(request endpoints + CTA),mobile-chat-guide.md(conversations + sockets),mobile-post-share-guide.md(POST_SHARE card rendering).
- 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-09-09 — The conversation's
pendingPostRequestnow includesselectedOptions, so the chat card can show the ticked options exactly like the author's Responses tray. No extra call. - 2026-09-01 — Request system messages now name the post — "Your request for "Debate Partner" was accepted." (section 7). The title is snapshotted, so it stays readable even if the post changes later.
- 2026-09-01 — The status chip on the request card is withdrawn. Build the card plus the thread's own lines; there is no
postRequest: { id, status }field to render. - 2026-09-01 —
post_request_openedis gone — submitting a request now writes only the seed message. A rejected request reads "wasn't accepted". - 2026-08-31 — Chat design-review decisions were recorded ahead of implementation — check the "decided, not yet shipped" note before building.
What changed (delta over the base requests guide)
| Surface | New |
|---|---|
| A request opens its DM at SUBMIT, not on accept | the requester's outgoing request is an ordinary row in folder=inbox. The client-side merge and the fake-conversation screen are gone (§1) |
Conversation object (list, :id, search) | pendingPostRequest ({ id, postId, selectedOptions } | null) — provenance, not a lock: set while the request is unanswered, so you can banner "Awaiting a response to your post" and open the post from the card |
| Sending into an unanswered request | the ordinary PENDING-DM rule: the requester sends text (media / GIF / post card → 403), the author cannot send until they accept. Changed 2026-08-31 — the old 403 "Waiting for the author to accept your request" that refused both sides is gone |
| Where the author sees it | Responses while the thread is only the request; once the requester writes a message of their own it also appears in their chat Requests tray with the whole history and the usual Accept / Delete / Block |
PostRequest object | conversationId (string | null, set from submit), authorReadAt (ISO | null), submitCount (int) |
Author inbox counts | unread number |
POST /api/posts/:id/requests submit | opens/attaches the DM, returns conversationId. The note is moderated — 422 MESSAGE_BLOCKED on blocked words |
PATCH .../requests/:reqId accept | flips the existing DM to ACCEPTED and backfills receipts; never writes a second seed message |
post_request_accepted notification | data.conversationId + chat deep link |
| Post object (list + detail) | viewerRequestConversationId (string | null) — "Open chat" CTA target when the viewer's request is ACCEPTED |
PATCH .../requests/:reqId | new "reopen" action — ACCEPTED back to PENDING (+ post_request_reopened notification). The conversation stays ACCEPTED and keeps its conversationId — reopening a request never closes an open chat |
PATCH /api/posts/:id | 409 once eventAt has passed |
1 — Requester's DM tab (nothing to merge)
This section replaced a client-side merge and a "fake conversation" rendering contract. Both are gone — delete that code. A post request is a real
Conversationwith a realMessagefrom the moment it is submitted, so there is nothing to splice into the chat list and nothing to fake.
The DM tab is one call:
GET /api/chat/conversations?folder=inbox
A pending outgoing request comes back as an ordinary row — real
conversationId, real message, real sequenceNumber, receipts, sockets,
cursor pagination, and updatedAt ordering that already puts it in the right
place among the user's other chats. No second fetch, no merge, no client sort.
It is marked so you can render its state:
{
"id": "conv_abc",
"status": "PENDING",
"requesterId": "<me>",
"pendingPostRequest": { "id": "req_x", "postId": "post_y", "selectedOptions": ["Frontend"] },
"messages": [ { "type": "POST_SHARE", "sharedPostId": "post_y", "content": "…the note…" } ]
}
pendingPostRequest | Render |
|---|---|
| non-null | Banner: "Awaiting a response to your post". The POST_SHARE card is the thread's first message; tapping it opens the post, and selectedOptions are the options the requester ticked ([] when none) — render them as chips beside the note. Do not disable the composer on this field |
| null | no banner — an ordinary chat |
The composer follows the same rule as every other conversation, from status +
requesterId alone:
if (conv.status === "ACCEPTED") full();
else if (conv.requesterId === me) textOnly(); // ← a post requester lands here
else locked(); // Accept / Delete / Block
So the requester may keep writing text into their own request before the author
answers — media[] and postId stay 403 until it is accepted, exactly as in a
cold message request.
Opening the thread is a normal chat screen against the normal endpoints
(GET /api/chat/conversations/:id/messages). The request note and the post card
are already in the timeline as message #1, sent by the requester.
State changes arrive as they do for any chat:
| Event | Do |
|---|---|
conversation_status_changed → ACCEPTED | the author accepted — full composer; pendingPostRequest is null on the next fetch |
chat_list_update with pendingPostRequest: null | the request was answered on the author's side — drop the banner. No refetch: the event carries pendingPostRequest and tray |
conversation_cleared | the request was declined or withdrawn — drop the row. Nothing was deleted; a later message brings the thread back |
chat_list_update | ordinary list refresh |
There is still no socket event for PostRequest state itself — request status
lives on the post CTA and in /requests/mine; the chat side only ever tells you
about the conversation.
If the pair already had a chat, the request appends to that thread instead
of opening a new one, pendingPostRequest is not set (an open conversation
stays open), and it is an ordinary message in an ordinary thread — full composer
on both sides. One user-to-user relationship has exactly one conversation.
Accepting in the chat tray is not answering the request. If the author taps
Accept on the row in their Requests tray, the conversation flips to ACCEPTED
and the chat opens — but the PostRequest stays PENDING and still needs an
Accept or Ignore in Responses. Two surfaces, two decisions.
2 — Accept → conversation handoff
The conversation already exists — the requester has had it in their inbox since
they submitted. Accept flips it from PENDING to ACCEPTED, backfills the
author's receipt for the seed message, and opens the author's composer (the
requester's was never closed). No second
conversation and no second seed message are ever created.
The requester's next fetch (or the push payload) carries the same chat:
// PATCH /api/posts/:id/requests/:reqId → 200 (author side)
// GET /api/posts/requests/mine → items[] (requester side)
{
"status": "ACCEPTED",
"conversationId": "conv_abc123", // navigate: /chat/conv_abc123
...
}
The DM opens with a seed message already inside: a normal POST_SHARE
message, senderId = the requester, content = their request note,
sharedPostId = the post. Render it exactly like any shared post
(mobile-post-share-guide.md) — no special casing. Accepting more requests
from the same person appends more seed messages into the same DM.
⚠
ACCEPTEDwithconversationId: nullis a VALID state (blocked pair, or linkage failed server-side). Never assume ACCEPTED ⇒ chat exists. Null → link to the post instead of the chat. Do not retry client-side; the backend has an operational repair path.
If the post is later deleted, the seed message survives with
sharedPostId: null → render the standard "post unavailable" placeholder.
3 — Socket events you will receive
All existing events from mobile-chat-guide.md — the seed message reuses the
normal send pipeline. On the accept moment:
| Event | Room | When |
|---|---|---|
conversation_status_changed { conversationId, status: "ACCEPTED" } | both users | only if a PENDING chat request already existed between the pair (it flips) |
new_message (full message, type: "POST_SHARE") | conversation room | the seed. Resolve the card via POST /api/posts/share-cards as usual |
chat_list_update { conversationId, status: "ACCEPTED", lastMessage, unreadIncrement } | both personal rooms | sidebar row. unreadIncrement: 1 for the author, 0 for the requester (they're the sender) |
No message / message_request push fires for the seed — the
post_request_accepted push is the single notification and it deep-links to
ksn://chat/:conversationId (falls back to ksn://posts/:id when
conversationId is null).
4 — Author's responses screen: "new" badge
Both author inboxes (GET /api/posts/:id/requests,
GET /api/posts/requests/inbox) now return:
"counts": { "pending": 3, "accepted": 1, "rejected": 0, "withdrawn": 2, "unread": 2 }
Contract — fetch marks read. There is no mark-read endpoint and no viewport tracking:
- A row with
authorReadAt: nullin the payload you just received is "new" → highlight it. The server marks exactly those rows read as it serves them. - Your next fetch returns them with
authorReadAtset → no highlight. Keep the highlight for the lifetime of the screen from the payload you rendered — do not refetch to "confirm". counts.unreadis computed before the same response's marking, so the badge you display alongside page 1 is accurate at fetch time.- Pagination: only served rows get marked. Unfetched pages still count in
unreaduntil actually delivered. unreadspans all statuses (a withdrawn-before-seen row is still "new"). Requester endpoints never mark anything.
Badge suggestion: use counts.unread from the aggregate inbox for the
home-screen "Requests" tab badge; per-post counts.unread for each "View
Responses" entry point.
5 — Post edit freeze (author UX)
PATCH /api/posts/:id now returns 409
("Post can no longer be edited after its event date") once the post's
eventAt is in the past. Wiring:
- Hide/disable the edit action when
post.eventAt < now. - Posts without
eventAtnever freeze. - This is independent of the existing prompt freeze (
requestPrompt*locked after the first request — also 409). Handle both by surfacing the server message.
6 — Edge matrix
| Case | What the client sees | Do |
|---|---|---|
| submit, blocked pair | 200, conversationId: null | request stands with no chat; accept will retry the linkage |
| submit while the author has an unanswered message request to you | 200, conversationId: null | the request cannot attach to a thread you have not accepted; accept creates the DM instead |
| accept, blocked pair | 200, conversationId: null | author: normal accepted row, no chat affordance. requester: ACCEPTED row linking to post |
| decline / withdraw, seed-only thread | row drops from the DM tab via conversation_cleared | nothing is deleted — a later message from either side brings the thread back |
| decline / withdraw after the requester has chatted | no conversation_cleared — the thread survives on both sides; a post_request_declined / _withdrawn system message lands in it | refetch the row: pendingPostRequest is now null, so drop the banner. For the author it stays an ordinary message request in Requests |
| requester messages before the author answers | author's Requests tray gains the row (with the seed card + the message); requester's own row is unchanged in their inbox | the author's message_request notification fires, and their chat_list_update carries tray: "requests" + pendingPostRequest — place and render it without a fetch |
| accept twice / stale inbox | 409 "Request is not pending" | refetch inbox |
| post deleted after accept | seed message with sharedPostId: null; requester's /requests/mine row gone (cascade) | placeholder card; DM survives |
| conversation deleted later | conversationId back to null on the row | fall back to post link |
| same requester on 2 posts | same conversationId on both rows | one DM, two seed messages |
| withdraw then re-submit | a new seed message, higher sequenceNumber | the row reappears in the DM tab showing only what arrived after the withdrawal. Two seeds are still not a conversation — the author sees it in Responses only, until the requester writes something of their own |
| author reopens an accepted request | row back to PENDING, conversationId kept; the conversation stays ACCEPTED | chat is unaffected and stays usable — only the post CTA reverts to pendingLabel |
| request while intake paused/completed | 409 on submit | per base guide — CTA state from the post object |
7 — Wiring checklist
- DM tab =
GET /api/chat/conversations?folder=inbox, nothing else. RenderpendingPostRequestrows as "Waiting for acceptance" with the composer disabled (§1). Delete any merge and fake-conversation code. - On
post_request_accepted: drop the request row, navigate viadata.conversationId; null → post fallback (§2). - Render the seed like any POST_SHARE message — zero new message UI (§2).
- Handle the three accept-moment socket events (§3) — all pre-existing handlers from the chat guide.
- Responses screens: highlight
authorReadAt: nullrows from the fetched payload; badges fromcounts.unread(§4). - Disable post editing when
eventAthas passed; surface 409 messages (§5).
7 — Decisions from the 2026-08-31 design review
Four touch these screens. Two shipped on 2026-09-01 (marked ✅); the other
two are decided and not built. Full reasoning:
docs/CHAT_SYSTEM_MESSAGES.md.
| Decision | Effect here |
|---|---|
✅ post_request_opened retired | Shipped. Submitting writes no system row — the seed card is the artifact, carrying the post, the note and View original post. Old threads keep rows written before 2026-09-01; those now arrive as system.unsupported: true with a neutral line. Nothing else about submit changed: the seed, conversation_cleared, tray placement and the socket events in §3 are all as documented above |
| ❌ | Withdrawn 2026-09-01 — don't build it. Accepted, not-accepted and reopened are each already a system line in the thread; pending is the absence of one. A chip renders the same facts twice. No server field is coming |
| ✅ "not accepted" replaced "declined" | Shipped. The notification title now reads "Your request to join wasn't accepted" (verb from the post type's catalog, as before), and the thread line reads "Your request wasn't accepted." Event codes and the post_request_rejected type are unchanged — don't match on the words |
| ✅ Post titles in the request lines | Shipped 2026-09-01. "You accepted Arjun's request for "Debate Partner"." postTitle is snapshotted into system.data at write time, never resolved from postId — a deleted post must not blank an old sentence. null on pre-2026-09-01 rows, where the clause is simply dropped (it does not mean the post was deleted). Clipped to ~40 chars server-side. Two lines also changed voice: the requester reads "Your request for … was accepted." and, after a reopen, "Your request for … is being looked at again." |
The seed-only decline/withdraw behaviour in §6 (thread clears for both, no system message, outcome by notification) was reviewed and kept. It is deliberate, not a gap.