refactor(ios): no inbox — receive flow hands off to system share sheet#70
Open
mondominator wants to merge 1 commit intomainfrom
Open
refactor(ios): no inbox — receive flow hands off to system share sheet#70mondominator wants to merge 1 commit intomainfrom
mondominator wants to merge 1 commit intomainfrom
Conversation
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>
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.
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:
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:
Replaced:
Deleted:
Wired:
What stays untouched
What you lose
Test plan
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