fix(apple): show receiver-approval modal on macOS release builds

The approval modal never appeared for a macOS sender: the receiver request
reached the core and even fired its notification, but the modal stayed hidden.

Root cause was observation, not presentation. `RootView` derived `approvals`
and `messages` as `@ObservedObject` in `init` from a freshly built `AppGraph`.
`init` runs on every view re-creation and each run makes a throwaway graph, so
those observed objects were repointed to a dead `ApprovalCoordinator` that never
receives core events — while the persisted `@StateObject graph` (and the models
wired to it) kept the live one. Debug happened not to re-init the view, so it
stayed on the live instance; release re-inits it, exposing the bug.

Move the snackbar + approval modal into an `OverlayLayer` child view that takes
the coordinator/messages as `@ObservedObject` and is constructed in `body` from
the persisted `graph`, so the subscription is always against the live instances.

While here:
- Present the approval only after any open share/QR sheet has actually finished
  dismissing (macOS can't stack sheets), driven off the sheet's real
  `onDismiss` completion via a new `AdaptiveDrawer.onDismissed` hook and
  `SendModel.shareSheetsDismissed` — no wall-clock delay.
- Move the list-level share-sheet state (`shareTargetId`) into `SendModel` so the
  approval flow can dismiss every share surface centrally.
- Add a fallback: pending receiver rows in the Receivers panel now offer an
  Approve action (`SendModel.acceptReceiver`) alongside Refuse, for the case the
  modal didn't surface.
This commit is contained in:
2026-07-31 11:19:28 +02:00
parent 56d19014d4
commit 5424da855e
5 changed files with 162 additions and 53 deletions

View File

@@ -25,6 +25,11 @@ struct SendState: Equatable {
var selectedTransferId: UInt64?
var transferThumbnails: [UInt64: Data] = [:]
var detailPanel: TransferDetailPanel?
/// Transfer whose share panel is presented inline from the list context menu
/// (distinct from `detailPanel == .share`, which shows it from the detail view).
/// Held in the model not `SendScreen` @State so the approval flow can dismiss
/// it centrally before presenting its modal.
var shareTargetId: UInt64?
var receiverHistory: [ReceiverRequestModel] = []
var isLoadingReceivers = false
var isDeleteConfirmationOpen = false
@@ -56,6 +61,18 @@ final class SendModel: ObservableObject {
private let messages: UiMessageController
private var cancellables = Set<AnyCancellable>()
/// Fires *after* a share/QR sheet (the detail-view panel or the list-level share
/// sheet) has finished animating out. The approval flow waits on this to present
/// its modal on macOS, where a sheet shown while another is still dismissing is
/// dropped using the real completion instead of a guessed delay.
private let shareSheetsDismissedSubject = PassthroughSubject<Void, Never>()
var shareSheetsDismissed: AnyPublisher<Void, Never> {
shareSheetsDismissedSubject.eraseToAnyPublisher()
}
/// Invoked by a share sheet's `onDismiss` completion.
func shareSheetDidDismiss() { shareSheetsDismissedSubject.send(()) }
init(
repository: CoreGateway,
fileSystemService: FileSystemService,
@@ -201,6 +218,17 @@ final class SendModel: ObservableObject {
}
func closeDetailPanel() { state.detailPanel = nil }
func openShareTarget(_ transferId: UInt64) { state.shareTargetId = transferId }
func closeShareTarget() { state.shareTargetId = nil }
/// Dismisses every share/QR surface at once the detail-view share panel and the
/// list-level share sheet. Used before presenting the receiver-approval modal, so
/// no competing sheet is left open (macOS drops a sheet shown over another).
func dismissShareSheets() {
state.detailPanel = nil
state.shareTargetId = nil
}
func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true }
func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
@@ -257,8 +285,19 @@ final class SendModel: ObservableObject {
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
/// receivers that are still pending or accepted.
func cancelReceiver(requestId: String) {
respondToReceiver(requestId: requestId, accepted: false)
}
/// Approves a single pending receiver by responding to its request positively.
/// A fallback for when the approval modal didn't surface the pending receiver
/// can still be accepted from its row in the transfer's receivers panel.
func acceptReceiver(requestId: String) {
respondToReceiver(requestId: requestId, accepted: true)
}
private func respondToReceiver(requestId: String, accepted: Bool) {
Task {
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil)
let result = await repository.respondReceiverRequest(requestId: requestId, accepted: accepted, reason: nil)
switch result {
case .success:
if let transferId = state.selectedTransferId { refreshReceivers(transferId) }

View File

@@ -7,14 +7,18 @@ struct SendScreen: View {
@ObservedObject var model: SendModel
let windowClass: WindowClass
/// Transfer whose share panel is presented inline from the list context menu.
@State private var shareTarget: Transfer?
/// Transfer pending an inline (list-level) delete confirmation.
@State private var deleteTarget: Transfer?
private var outgoing: [Transfer] {
model.coreState.transfers.filter { $0.direction == .send }
}
/// The transfer whose list-level share sheet is open, resolved from the model's
/// `shareTargetId` (kept in the model so the approval flow can dismiss it).
private var shareTarget: Transfer? {
guard let id = model.state.shareTargetId else { return nil }
return outgoing.first { $0.transferId == id }
}
private var selectedTransfer: Transfer? {
guard let id = model.state.selectedTransferId else { return nil }
return outgoing.first { $0.transferId == id }
@@ -50,9 +54,10 @@ struct SendScreen: View {
// composer drawer on the outer body, so the two don't clash). Opens the
// share panel over the list without navigating into the transfer detail.
.adaptiveDrawer(
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { shareTarget = nil } }),
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { model.closeShareTarget() } }),
windowClass: windowClass,
onDismiss: { shareTarget = nil }
onDismiss: model.closeShareTarget,
onDismissed: model.shareSheetDidDismiss
) {
if let shareTarget {
TransferSharePanel(model: model, transfer: shareTarget)
@@ -92,7 +97,8 @@ struct SendScreen: View {
.adaptiveDrawer(
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
windowClass: windowClass,
onDismiss: model.closeDetailPanel
onDismiss: model.closeDetailPanel,
onDismissed: model.shareSheetDidDismiss
) {
if let panel = model.state.detailPanel {
DetailPanelContent(model: model, transfer: transfer, panel: panel)
@@ -127,7 +133,7 @@ struct SendScreen: View {
.contextMenu {
if transfer.ticket != nil {
Button {
shareTarget = transfer
model.openShareTarget(transfer.transferId)
} label: {
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
}

View File

@@ -142,7 +142,8 @@ struct DetailPanelContent: View {
loading: model.state.isLoadingReceivers,
events: model.coreState.events,
transferTotalSize: transfer.totalSize,
onCancel: model.cancelReceiver
onCancel: model.cancelReceiver,
onAccept: model.acceptReceiver
)
case .share:
TransferSharePanel(model: model, transfer: transfer)
@@ -193,6 +194,7 @@ struct ReceiverHistoryPanel: View {
let events: [CoreEventModel]
let transferTotalSize: UInt64
let onCancel: (String) -> Void
let onAccept: (String) -> Void
var body: some View {
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
@@ -203,7 +205,12 @@ struct ReceiverHistoryPanel: View {
} else {
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
if index > 0 { Divider().overlay(colors.borderDefault) }
ReceiverRow(receiver: receiver, sendProgress: sendProgress(for: receiver), onCancel: onCancel)
ReceiverRow(
receiver: receiver,
sendProgress: sendProgress(for: receiver),
onCancel: onCancel,
onAccept: onAccept
)
}
}
}
@@ -224,6 +231,7 @@ private struct ReceiverRow: View {
let receiver: ReceiverRequestModel
let sendProgress: TransferProgress?
let onCancel: (String) -> Void
let onAccept: (String) -> Void
/// Only pending requests can be cancelled per-receiver: the core rejects a
/// negative response to an already-accepted request ("...not approved, or it
@@ -257,14 +265,27 @@ private struct ReceiverRow: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
if isCancelable {
Button(role: .destructive) {
onCancel(receiver.id)
} label: {
Text(String(localized: L10n.Button.refuse))
.font(VniType.bodySmall)
VStack(alignment: .trailing, spacing: 8) {
Button(role: .destructive) {
onCancel(receiver.id)
} label: {
Text(String(localized: L10n.Button.refuse))
.font(VniType.bodySmall)
}
.buttonStyle(.borderless)
.tint(.red)
// Fallback approve action, in case the approval modal didn't surface.
Button {
onAccept(receiver.id)
} label: {
Text(String(localized: L10n.Button.approve))
.font(VniType.bodySmall).fontWeight(.medium)
.foregroundStyle(.white)
.padding(.horizontal, 16).padding(.vertical, 7)
.background(Color.green, in: Capsule())
}
.buttonStyle(.plain)
}
.buttonStyle(.borderless)
.tint(.red)
}
}
.frame(maxWidth: .infinity, alignment: .leading)