Skip to content

feat(dm): server-grouped inbox, single-message fetch, and photo attachments (G22) - #69

Merged
Adron merged 2 commits into
devfrom
feat/dm-inbox-photos-g22
Sep 13, 2026
Merged

Adron merged 2 commits into
devfrom
feat/dm-inbox-photos-g22

Conversation

@Adron

@Adron Adron commented Sep 9, 2026

Copy link
Copy Markdown
Member

Summary

Closes the three live Direct Message routes the feature shipped without — GitHub #53 / work-consolidation.md G22. The Inbox now reads the server-grouped GET /api/dm/conversations feed instead of collapsing a folder page client-side, GET /api/dm/{id} backs deep links, and POST /api/dm/images/upload makes 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

  • KitDirectMessages.conversations(cursor:), .message(id:), .uploadImage(_:contentType:). New DMConversationDTO / DMConversationsPage / DMMessageResponse; the upload reuses the existing MediaUploadResponse.
  • DomainDMConversationSummary + DMConversationPage and their mappers; DMLimits (8 photos, 10,000 characters); DirectMessagesService.conversations / .message(id:) / .uploadImage. Upload runs through the shared ImagePrep + ContentLimits path (G14 tail), so the size budget stays server-driven — no fresh constants.
  • App — the Inbox paints from the conversations feed with its own cursor; Sent/Deleted keep the folder listing, which this route does not replace. Both composers attach photos via a shared DMAttachmentDraft; thread bubbles render imageURLs. Recipient-picker empty state now explains the mutual-follow rule rather than just reporting emptiness.
  • Docswork-consolidation.md G22 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 pairKey with 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/OPTIONS only against the shared test account; zero writes.

Probe Result
GET /api/dm/conversations 200 {"items":[],"nextCursor":null} — Bearer accepted
OPTIONS /api/dm/conversations 204, allow: GET, HEAD, OPTIONS
GET /api/dm/{unknown-id} 404 {"error":"Message not found.","code":"not_found"}
OPTIONS /api/dm/{id} 204, allow: GET, HEAD, OPTIONS
OPTIONS /api/dm/images/upload 204, allow: OPTIONS, POST (a GET is 405)

⚠️ Reviewer note — the decoders are permissive on purpose

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 — DMConversationDTO accepts both a nested row (lastMessage/latestMessage/message) and a flattened one (the row is the newest message, the natural GROUP BY pairKey output), plus alternates for participant and unread count. DMMessageResponse accepts {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

  • Email verification (bug(compose): nothing gates on email verification, so posting fails at publish time (G38) #41). Photo sending requires a verified email. No client gate is built here by design — the server's 403 surfaces verbatim through APIError.forbidden. Seven TODO(#41) markers name issue bug(compose): nothing gates on email verification, so posting fails at publish time (G38) #41's CapabilityGate as the single owner; wire the composers to it when that branch merges. Deliberately not a second gating path.
  • DM deep links. .directMessagesOpenMessage is the seam and the resolver is tested, but nothing posts it yet — there is no direct-message NotificationKind. The producer arrives with whatever surface introduces DM notifications or a URL scheme.
  • DM ceilings are not in /api/limits. That endpoint's message.maxContentLength (5000 live) is the public post limit. The 8-photo / 10,000-character DM numbers come from /help/direct-messages and live in DMLimits.

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/InterlinedKitExecuted 412 tests, with 0 failures (0 unexpected)
  • swift test --package-path Packages/InterlinedDomainExecuted 790 tests, with 0 failures (0 unexpected)
  • swift test --package-path Packages/InterlinedPersistenceExecuted 135 tests, with 0 failures (0 unexpected)
  • grep -rn "^import InterlinedKit" App/Features App/Navigation App/MenuCommands0 hits (Decision 0003)
  • Env-gated ContractTests ran 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

Adron and others added 2 commits September 9, 2026 10:01
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant