Skip to content

refactor(ios): no inbox — receive flow hands off to system share sheet#70

Open
mondominator wants to merge 1 commit intomainfrom
refactor/no-inbox
Open

refactor(ios): no inbox — receive flow hands off to system share sheet#70
mondominator wants to merge 1 commit intomainfrom
refactor/no-inbox

Conversation

@mondominator
Copy link
Copy Markdown
Owner

What

Beamlet's iOS app used to have a full inbox model: tap a notification, land on InboxView, see your file in a list, tap to open the in-app ImageViewerView / QuickReplySheet, optionally pin / filter / quick reply.

This PR strips it down to a delivery-only flow:

Notification tap Old New
URL/link inbox row → tap → in-app viewer → "open in browser" button Safari opens immediately + brief toast
Text/message inbox row → tap → quick-reply sheet clipboard + brief toast
Photo/video inbox row → tap → in-app viewer system share sheet → save to Photos / Files / wherever
File inbox row → tap → preview system share sheet → save to Files / open in Books / etc.

No inbox screen. No file lives inside Beamlet for longer than the ~200ms it takes to hand it off to the system share sheet. After the share sheet dismisses (success or cancel), the temp file is deleted.

Why

Beamlet was building UI for a job iOS already does perfectly via UIActivityViewController. The destination for an incoming file is somewhere else — Photos, Files, Books, Safari, whatever app the user picks. Maintaining a custom in-app viewer / pin / filter / read-receipt system on top of that was a lot of code for marginal value.

−870 LoC net (839 deleted from Inbox/, 202 from dead BeamletWidget/, +393 added).

What changed

New:

  • `Beamlet/Presentation/Receive/IncomingFileRouter.swift` — observable state machine that turns a file_id into the right user-visible action. Owns the temp-file lifecycle.
  • `BeamletAPI.downloadItem(_:)` — sibling of `downloadFile` returning a `DownloadedItem` sum type (`.media`/`.text`/`.link`) so the router pattern-matches instead of inspecting raw headers. Parses Content-Disposition for the original filename so the share-sheet preview is sensible.

Replaced:

  • `MainTabView.swift` — was a 3-tab bar (Inbox/Send/Settings); is now a single NavigationStack wrapping SendView with a gear-icon entry to Settings, plus `.sheet(item:)` for the share sheet and an overlay banner for toast/error messages.

Deleted:

  • `Beamlet/Presentation/Inbox/*` — 5 files, 839 LoC: InboxView, InboxViewModel, InboxItemView, ImageViewerView, QuickReplySheet
  • `BeamletWidget/*` — 3 files, 202 LoC. Never wired into XcodeGen anyway (target was commented out in project.yml), and the only thing it showed was "recent received files" which no longer exists conceptually.

Wired:

  • `BeamletApp.swift` owns the router as @State, injects via `.environment(...)`, and forwards `.didTapNotification` posts to `router.receive(fileID:)`. The APNs delegate is unchanged — same notification name, same userInfo key — so server-side push payloads need no changes.

What stays untouched

  • SendView, SendViewModel, contacts, BLE proximity, QR invites, invite redemption, share extension, settings, theme
  • Server (Phase 2 will deal with that — see below)
  • Android client (separate test cycle)

What you lose

  • "I missed it" recovery. Swipe a notification away before tapping it and the file is unreachable from Beamlet.
  • History of received content. If something landed in Photos / Files / Safari history when you tapped it, those system apps are your history now.
  • Pin / filter / search / quick reply / read receipts on the receive side (sender still gets a delivered receipt because the router calls markRead after a successful handoff).
  • Inbox widget (deleted — was dead code anyway).

Test plan

  • `xcodebuild -scheme Beamlet -destination 'generic/platform=iOS' build` — BUILD SUCCEEDED
  • `xcodebuild test -scheme Beamlet -destination 'platform=iOS Simulator,name=iPhone 16'` — 8/8 pass (was 6, +2 new tests covering DownloadedItem JSON decoding for link and text payloads)
  • Manual on device:
    • Send a photo from another Beamlet user → tap the notification → share sheet appears with the photo preview → "Save Image" → photo lands in Photos
    • Send a link → tap notification → Safari opens directly to the URL + toast appears
    • Send a text message → tap notification → toast says "Copied to clipboard" → paste in Notes works
    • Send a PDF → tap notification → share sheet → "Save to Files" → file is in Files app
    • Open Beamlet directly (no notification) → land on send screen, tap gear → settings
    • Swipe a notification away without tapping → confirm there's no way to recover it inside the app (intentional)

Phase 2 — separate PR, not yet

Server: drop ExpiryDays, switch to delete-on-first-download with ref counting for multi-recipient sends, ephemeral retention with a short safety window (e.g. 24h) so the file survives notification re-delivery races. Migration step: delete every file currently sitting in /data/files — needs explicit OK at that point. Held until this PR has been used for a few days.

🤖 Generated with Claude Code

Beamlet's iOS app used to have a full inbox model: tap a notification,
land on InboxView, see your file in a list, tap to open the in-app
ImageViewerView / QuickReplySheet, optionally pin or filter or quick
reply. The premise was that the app was your file destination.

That's the wrong premise for a file relay. The actual destination for
an incoming file is somewhere else: Photos, Files, Books, Safari, the
clipboard, whatever app the user picks. Beamlet was building UI for a
job iOS already does perfectly well via UIActivityViewController.

This commit lightens iOS Beamlet to a delivery-only model:

  - Tap notification → IncomingFileRouter downloads the file in the
    background → routes by content type:
      • URL  → UIApplication.open() (Safari) + brief toast
      • Text → copy to clipboard + brief toast
      • File → write to a per-call temp folder, present
        UIActivityViewController, clean up the temp folder once the
        sheet dismisses (success or cancel)
  - No inbox. No persistence on the receiver side. No file lives
    inside Beamlet for longer than the 200ms it takes to hand it off
    to a system share sheet.

## What changed

New:
  - Beamlet/Presentation/Receive/IncomingFileRouter.swift — observable
    state machine that turns a file_id into the right user-visible
    action. Owns the temp-file lifecycle. ~150 LoC.
  - Beamlet/Data/BeamletAPI.swift `downloadItem(_:)` — sibling of the
    existing `downloadFile(_:)` that returns a `DownloadedItem` sum
    type (.media / .text / .link) so the router can pattern-match
    instead of inspecting raw headers. Parses Content-Disposition for
    the original filename so the share sheet preview is sensible.

Replaced:
  - Beamlet/Presentation/Components/MainTabView.swift — was a 3-tab
    bar with Inbox / Send / Settings; is now a single NavigationStack
    wrapping SendView with a gear-icon entry to Settings, plus
    .sheet(item:) for the share sheet and an overlay banner for
    toast/error messages.

Deleted:
  - Beamlet/Presentation/Inbox/* (5 files, 839 LoC):
    InboxView, InboxViewModel, InboxItemView, ImageViewerView,
    QuickReplySheet
  - BeamletWidget/* (3 files, 202 LoC) — never wired into XcodeGen
    anyway (the target was commented out in project.yml), and the
    only thing the widget showed was "recent received files" which
    no longer exists conceptually.

Wired:
  - BeamletApp.swift — owns the IncomingFileRouter as @State, injects
    via .environment(receiveRouter), and forwards .didTapNotification
    posts to router.receive(fileID:). The APNs delegate
    (AppDelegate.userNotificationCenter:didReceive:) is unchanged —
    it still posts the same notification name with the same userInfo
    key, so server-side push payloads need no changes.

## What stays

  - SendView, SendViewModel, contacts, BLE proximity, QR invites,
    invite redemption, share extension, settings, theme, contact list
  - Server is untouched this PR — old files still expire on the same
    timer they always have (BEAMLET_EXPIRY_DAYS, default 30). Phase 2
    will switch the server to delete-on-download, but that's a
    one-way migration with data loss so it gets its own PR after
    this one has bedded in.
  - Android client untouched — separate test cycle.

## What you lose

  - "I missed it" recovery. If you swipe a notification away before
    tapping it, the file is unreachable from Beamlet. APNs is
    best-effort delivery; flaky LTE = lost messages.
  - History of received content. If something landed in Photos /
    Files / Safari history when you tapped it, those system apps are
    your history now.
  - Pin / filter / search / quick reply / read receipts on the
    receive side (sender still gets a delivered receipt because the
    router calls `markRead` after a successful handoff).
  - Inbox widget (deleted — was dead code anyway).

## Tests

  - 8/8 BeamletTests pass on iPhone 16 simulator (was 6, +2 new tests
    covering DownloadedItem JSON decoding for link and text payloads).
  - `xcodebuild -scheme Beamlet -destination 'generic/platform=iOS'
    build` — clean.
  - SwiftLint: not run as part of the iOS build (project doesn't
    have a SwiftLint phase).

## Phase 2 (separate PR, not yet)

  - Server: drop ExpiryDays, switch to delete-on-first-download with
    ref counting for multi-recipient sends, ephemeral retention with
    a short safety window (e.g. 24h) so the file survives notification
    re-delivery races. Migration step: delete every file currently
    sitting in /data/files (needs explicit OK at that point).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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