Skip to main content
Version: Latest
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.
- **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.- **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.
| **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 }` \| 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 || 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 |
"requesterId": "<me>", "pendingPostRequest": { "id": "req_x", "postId": "post_y" }, "pendingPostRequest": { "id": "req_x", "postId": "post_y", "selectedOptions": ["Frontend"] }, "messages": [ { "type": "POST_SHARE", "sharedPostId": "post_y", "content": "…the note…" } ]
|---|---|| non-null | Banner: "Awaiting a response to your post". The `POST_SHARE` card is the thread's first message; tapping it opens the post. **Do not disable the composer on this field** || 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 |

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 pendingPostRequest now includes selectedOptions, 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-01post_request_opened is 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)

SurfaceNew
A request opens its DM at SUBMIT, not on acceptthe 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 requestthe 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 itResponses 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 objectconversationId (string | null, set from submit), authorReadAt (ISO | null), submitCount (int)
Author inbox countsunread number
POST /api/posts/:id/requests submitopens/attaches the DM, returns conversationId. The note is moderated — 422 MESSAGE_BLOCKED on blocked words
PATCH .../requests/:reqId acceptflips the existing DM to ACCEPTED and backfills receipts; never writes a second seed message
post_request_accepted notificationdata.conversationId + chat deep link
Post object (list + detail)viewerRequestConversationId (string | null) — "Open chat" CTA target when the viewer's request is ACCEPTED
PATCH .../requests/:reqIdnew "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/:id409 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 Conversation with a real Message from 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…" } ]
}
pendingPostRequestRender
non-nullBanner: "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
nullno 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:

EventDo
conversation_status_changedACCEPTEDthe author accepted — full composer; pendingPostRequest is null on the next fetch
chat_list_update with pendingPostRequest: nullthe request was answered on the author's side — drop the banner. No refetch: the event carries pendingPostRequest and tray
conversation_clearedthe request was declined or withdrawn — drop the row. Nothing was deleted; a later message brings the thread back
chat_list_updateordinary 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.

ACCEPTED with conversationId: null is 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:

EventRoomWhen
conversation_status_changed { conversationId, status: "ACCEPTED" }both usersonly if a PENDING chat request already existed between the pair (it flips)
new_message (full message, type: "POST_SHARE")conversation roomthe seed. Resolve the card via POST /api/posts/share-cards as usual
chat_list_update { conversationId, status: "ACCEPTED", lastMessage, unreadIncrement }both personal roomssidebar 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: null in 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 authorReadAt set → no highlight. Keep the highlight for the lifetime of the screen from the payload you rendered — do not refetch to "confirm".
  • counts.unread is 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 unread until actually delivered.
  • unread spans 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 eventAt never 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

CaseWhat the client seesDo
submit, blocked pair200, conversationId: nullrequest stands with no chat; accept will retry the linkage
submit while the author has an unanswered message request to you200, conversationId: nullthe request cannot attach to a thread you have not accepted; accept creates the DM instead
accept, blocked pair200, conversationId: nullauthor: normal accepted row, no chat affordance. requester: ACCEPTED row linking to post
decline / withdraw, seed-only threadrow drops from the DM tab via conversation_clearednothing is deleted — a later message from either side brings the thread back
decline / withdraw after the requester has chattedno conversation_cleared — the thread survives on both sides; a post_request_declined / _withdrawn system message lands in itrefetch 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 answersauthor's Requests tray gains the row (with the seed card + the message); requester's own row is unchanged in their inboxthe 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 inbox409 "Request is not pending"refetch inbox
post deleted after acceptseed message with sharedPostId: null; requester's /requests/mine row gone (cascade)placeholder card; DM survives
conversation deleted laterconversationId back to null on the rowfall back to post link
same requester on 2 postssame conversationId on both rowsone DM, two seed messages
withdraw then re-submita new seed message, higher sequenceNumberthe 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 requestrow back to PENDING, conversationId kept; the conversation stays ACCEPTEDchat is unaffected and stays usable — only the post CTA reverts to pendingLabel
request while intake paused/completed409 on submitper base guide — CTA state from the post object

7 — Wiring checklist

  1. DM tab = GET /api/chat/conversations?folder=inbox, nothing else. Render pendingPostRequest rows as "Waiting for acceptance" with the composer disabled (§1). Delete any merge and fake-conversation code.
  2. On post_request_accepted: drop the request row, navigate via data.conversationId; null → post fallback (§2).
  3. Render the seed like any POST_SHARE message — zero new message UI (§2).
  4. Handle the three accept-moment socket events (§3) — all pre-existing handlers from the chat guide.
  5. Responses screens: highlight authorReadAt: null rows from the fetched payload; badges from counts.unread (§4).
  6. Disable post editing when eventAt has 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.

DecisionEffect here
post_request_opened retiredShipped. 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
status chip on the request cardWithdrawn 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 linesShipped 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.

⤓ Download .md