feat(dm): server-grouped inbox, single-message fetch, and photo attachments (G22) - #69
Merged
Merged
Conversation
…hments Closes the three live DM routes the feature shipped without (G22, #53). GET /api/dm/conversations — the Inbox now reads the server-grouped feed instead of collapsing a folder page client-side. The old grouping was only ever as complete as the page fetched, so a conversation whose newest message fell off the end was invisible; this route groups by pairKey with its own cursor and has no such failure mode. Sent and Deleted keep the folder listing — the conversations route is the inbox, not a folder. POST /api/dm/images/upload — the user-visible gap. The composer advertised attachments it could not perform. Both DM composers now attach up to 8 photos through the shared ImagePrep + ContentLimits path (G14 tail), not fresh constants, and thread bubbles render imageURLs, which were previously never displayed at all. An upload that fails does not cost the user their draft: the text still sends and the failure is surfaced. GET /api/dm/{id} — single-message fetch, consumed by the list view model's deep-link resolver, which answers from the loaded listing before fetching. Probed read-only 2026-09-09 (GET/OPTIONS only, no writes): conversations returns 200 {"items":[],"nextCursor":null}; an unknown id 404s with {"error":"Message not found.","code":"not_found"}; the upload route allows OPTIONS, POST. The populated items[] shape could NOT be verified — the shared test account's inbox is empty and sending a DM to populate it was not permitted — so DMConversationDTO and DMMessageResponse decode permissively (nested and flattened rows; {message}/{data}/bare) with tests pinning that tolerance. This is the G21 all-nil decode lesson applied: an unknown key degrades one field rather than failing the page. Tighten once a real payload is captured. Photo sending requires a verified email. No client gate is built here by design — the server's 403 is surfaced verbatim, and TODO(#41) markers name issue #41's CapabilityGate as the single owner. Also enforces the documented DM ceilings client-side: 8 photos per message and a 10,000-character body — deliberately not the post composer's 5,000 from /api/limits, which is a different surface. The recipient picker's empty state now explains the mutual-follow rule instead of just reporting empty. Tests: 22 Kit (up 9), 30 Domain DM (up 15), and the App quartets for the conversations feed, photo attachments, and deep-link resolution. Gate: App build SUCCEEDED; App tests 779/779; Kit 412/412; Domain 790/790; Persistence 135/135; zero `import InterlinedKit` in App/Features, Navigation, MenuCommands; live ContractTests ran (4/4). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019G7jZBbfgKcFU4fsNS12qs
This was referenced Sep 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes the three live Direct Message routes the feature shipped without — GitHub #53 /
work-consolidation.mdG22. The Inbox now reads the server-groupedGET /api/dm/conversationsfeed instead of collapsing a folder page client-side,GET /api/dm/{id}backs deep links, andPOST /api/dm/images/uploadmakes the composer's advertised photo attachments real.The image gap was the user-visible one: the composer offered attachments it could not perform, and received photos were never rendered at all.
Changes
DirectMessages.conversations(cursor:),.message(id:),.uploadImage(_:contentType:). NewDMConversationDTO/DMConversationsPage/DMMessageResponse; the upload reuses the existingMediaUploadResponse.DMConversationSummary+DMConversationPageand their mappers;DMLimits(8 photos, 10,000 characters);DirectMessagesService.conversations/.message(id:)/.uploadImage. Upload runs through the sharedImagePrep+ContentLimitspath (G14 tail), so the size budget stays server-driven — no fresh constants.DMAttachmentDraft; thread bubbles renderimageURLs. Recipient-picker empty state now explains the mutual-follow rule rather than just reporting emptiness.work-consolidation.mdG22 marked shipped, with the probe evidence and the open follow-ups recorded inline.Why the conversations switch is a correctness fix
Client-side grouping was only ever as complete as the page fetched, so a conversation whose newest message fell off the end of the folder page was invisible. The server groups by
pairKeywith its own cursor and has no such failure mode. A regression test covers exactly this (test_givenConversationOlderThanAFolderPage_whenLoadingInbox_thenItIsStillListed).Probe evidence — read-only, 2026-09-09
GET/OPTIONSonly against the shared test account; zero writes.GET /api/dm/conversations200 {"items":[],"nextCursor":null}— Bearer acceptedOPTIONS /api/dm/conversations204,allow: GET, HEAD, OPTIONSGET /api/dm/{unknown-id}404 {"error":"Message not found.","code":"not_found"}OPTIONS /api/dm/{id}204,allow: GET, HEAD, OPTIONSOPTIONS /api/dm/images/upload204,allow: OPTIONS, POST(aGETis405)The populated
items[]shape has never been seen. The test account's inbox is empty and populating it (sending a real DM) was out of scope for this change. Rather than guess one spelling and ship a silent all-nil decode — the G21 link-metadata defect —DMConversationDTOaccepts both a nested row (lastMessage/latestMessage/message) and a flattened one (the row is the newest message, the naturalGROUP BY pairKeyoutput), plus alternates for participant and unread count.DMMessageResponseaccepts{message},{data}, and the bare object. Every field is optional, so an unknown key degrades one field instead of failing the page. Tests pin that tolerance in both directions.Follow-up: capture a populated payload, tighten both decoders to the real keys, and delete the alternates.
Open dependencies
APIError.forbidden. SevenTODO(#41)markers name issue bug(compose): nothing gates on email verification, so posting fails at publish time (G38) #41'sCapabilityGateas the single owner; wire the composers to it when that branch merges. Deliberately not a second gating path..directMessagesOpenMessageis the seam and the resolver is tested, but nothing posts it yet — there is no direct-messageNotificationKind. The producer arrives with whatever surface introduces DM notifications or a URL scheme./api/limits. That endpoint'smessage.maxContentLength(5000 live) is the public post limit. The 8-photo / 10,000-character DM numbers come from/help/direct-messagesand live inDMLimits.Verification
Full E2E gate, re-run after merging
origin/dev(PR #37) so these are the numbers for what actually ships:xcodebuild … build→** BUILD SUCCEEDED **xcodebuild … test(App target) →Executed 787 tests, with 0 failures (0 unexpected)→** TEST SUCCEEDED **swift test --package-path Packages/InterlinedKit→Executed 412 tests, with 0 failures (0 unexpected)swift test --package-path Packages/InterlinedDomain→Executed 790 tests, with 0 failures (0 unexpected)swift test --package-path Packages/InterlinedPersistence→Executed 135 tests, with 0 failures (0 unexpected)grep -rn "^import InterlinedKit" App/Features App/Navigation App/MenuCommands→ 0 hits (Decision 0003)ContractTestsran live: 4/4 passed.Tests added: 9 Kit, 15 Domain, plus App quartets for the conversations feed, photo attachments, and deep-link resolution.
🤖 Generated with Claude Code
https://claude.ai/code/session_019G7jZBbfgKcFU4fsNS12qs