Skip to content

feat(messages): wire up DM trash and restore (W3, #50) - #73

Open
Adron wants to merge 1 commit into
mainfrom
feat/dm-trash-restore
Open

feat(messages): wire up DM trash and restore (W3, #50)#73
Adron wants to merge 1 commit into
mainfrom
feat/dm-trash-restore

Conversation

@Adron

@Adron Adron commented Sep 9, 2026

Copy link
Copy Markdown
Member

Summary

APIClient.trashDM(id:) and restoreDM(id:) have existed since the DM surface
landed and were called from nowhere, so MessagesInboxView shipped a three-way
folder picker whose Deleted tab nothing could ever fill — and a message the
user deleted on the web had no way back. This PR connects the folders to the
endpoints: swipe-to-delete on Inbox/Sent, swipe-to-restore in Deleted, and a
per-message Delete in the thread view, matching the web.

What's included

Commit 3e0f554:

  • MessagesInboxView — one swipeActions block on the existing row,
    branching on folder: destructive Delete (POST /api/dm/:id/trash) in
    Inbox and Sent, Restore (POST /api/dm/:id/restore) in Deleted. The
    picker, the row and the empty states are untouched, so W3: Grouped DM inbox via GET /api/dm/conversations #51's rebuild of this
    list on /api/dm/conversations inherits a small diff.
  • DMThreadView — a Delete context-menu action on a bubble, mirroring
    components/messages-dm/MessageThread.tsx. Also exposed as an
    accessibilityAction, since a context menu on its own isn't reachable by
    assistive input.
  • DMFolderMutation (Models/DirectMessage.swift) — the optimistic edit,
    extracted next to DMRecipientFilter so the rollback is testable without
    SwiftUI. The row is dropped before the request goes out and reinserted at the
    index it came from on failure; reinserting clamps the index and ignores a row
    a concurrent refresh already restored, so a rollback can neither trap nor
    duplicate.
  • Unread badge — refreshed after each success. Trashing sets
    recipientDeletedAt, which the server's unread query filters on, so the count
    really does move when an unread inbox message is trashed.
  • dmUnreadRefresh environment value — injected once in MainTabView
    rather than passing AppDataStore down. DMThreadView is also reached from
    UserProfileView's sheet, from a deep link, and from a #Preview, none of
    which have a store in scope; an @EnvironmentObject would trap there, an
    absent environment hook no-ops. No other call site needed touching.
  • Failure path — the row comes back and an alert explains why. A
    feature-endpoint 401 goes through authState.handleUnauthorized(), never
    logout().

Testing

  • xcodebuild -scheme InterlinedList -destination 'platform=iOS Simulator,id=18ABC526-…' -parallel-testing-enabled NO -skip-testing:InterlinedListTests/E2EReadOnlyTests test953 tests, 0 failures (942 on this base plus 11 new). Build succeeds.
  • New: request shapes for both endpoints (empty {} body, Bearer header,
    application/json, percent-encoded id, 401 → .status(401), 404 → .server),
    and the rollback (original index restored, first/last rows, unknown id,
    duplicate suppression, index clamped when the list shrank underneath).
  • E2E was not run — these are write endpoints against the shared live account.

Follow-ups

  • APIClient.directMessage(id:) is still uncalled; nothing in this UI needs a
    single-message fetch.
  • Restoring returns a message to whichever folder it came from (the server clears
    only the caller's side), so no client-side folder bookkeeping is needed — worth
    re-checking when W3: Grouped DM inbox via GET /api/dm/conversations #51 reshapes the list around conversations.

Closes #50.

🤖 Generated with Claude Code

`trashDM`, `restoreDM` and `directMessage` have been implemented in `APIClient`
since the DM surface landed, and every one of them was called from nowhere. The
folder picker in `MessagesInboxView` therefore shipped a **Deleted** tab that
nothing could ever put a message into, and a message the user deleted on the web
was unreachable from iOS. This connects the three folders to the two endpoints.

- Swipe-to-delete (destructive, `POST /api/dm/:id/trash`) on Inbox and Sent rows;
  swipe-to-restore (`POST /api/dm/:id/restore`) on Deleted rows. One `swipeActions`
  block branching on `folder`, so the picker and row layout are untouched — #51
  rebuilds this list on `/api/dm/conversations` and should inherit a small diff
- A **Delete** context-menu action on a bubble in `DMThreadView`, matching the
  web thread view. Also exposed as an `accessibilityAction`, since a context menu
  alone is not reachable by assistive input
- Both paths are optimistic: `DMFolderMutation` drops the row before the request
  goes out and puts it back at the index it came from if the request fails. It
  lives in the model layer next to `DMRecipientFilter` so the rollback is testable
  without SwiftUI; `reinserting` clamps the index and ignores a row a concurrent
  refresh already restored, so a rollback can neither trap nor duplicate
- The unread badge is refreshed after each success. Trashing sets
  `recipientDeletedAt`, which `countUnread` filters on, so the server count moves
  the moment an unread inbox message is trashed
- That refresh arrives as an environment value (`dmUnreadRefresh`) injected once
  in `MainTabView`, not as an `@EnvironmentObject`. `DMThreadView` is also
  presented from `UserProfileView`'s sheet, from a deep link and from a `#Preview`,
  none of which have an `AppDataStore` to hand; an absent hook has to no-op rather
  than trap, and none of those three call sites needed touching
- Failures roll the row back and surface an alert. A feature-endpoint 401 goes
  through `authState.handleUnauthorized()` — the trash/restore routes 404 a
  non-participant and 401 only on a genuinely rejected credential, but the rule
  holds regardless: never `logout()` on a feature endpoint

Tests: request shapes for both endpoints (empty `{}` body, Bearer, `application/json`,
percent-encoded id, 401 → `.status(401)`, 404 → `.server`), and the rollback —
original index restored, first/last rows, unknown id, duplicate suppression, index
clamped when the list shrank underneath.

Closes #50.

Verified: build succeeds; 953 tests, 0 failures (iPhone 16 · 18ABC526-…,
-parallel-testing-enabled NO, E2E skipped) — 942 on this base plus 11 new.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M65ZdXdGFRW3TyDKSBwhTd
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.

W3: Trash and restore direct messages

1 participant