From c7ebaee15bd53d06f2153c50bb775d0ed1e3bdd1 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:08:37 +0200 Subject: [PATCH] feat(apple): add context menus to Send and Receive rows Send rows get a context menu that acts inline without navigating: Share opens the share panel (QR + delivery) over the list via a dedicated sheet host, Stop sharing (active shares) and Delete transfer run in place, the latter through a new id-based SendModel.deleteTransfer and a list-level confirmation alert. Receive rows get a Delete action mirroring swipe-to-delete (handy on macOS). --- .../Features/Receive/ReceiveScreen.swift | 9 +++ apple/VniDrop/Features/Send/SendModel.swift | 25 +++++++++ apple/VniDrop/Features/Send/SendScreen.swift | 55 +++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/apple/VniDrop/Features/Receive/ReceiveScreen.swift b/apple/VniDrop/Features/Receive/ReceiveScreen.swift index a49012f..314300c 100644 --- a/apple/VniDrop/Features/Receive/ReceiveScreen.swift +++ b/apple/VniDrop/Features/Receive/ReceiveScreen.swift @@ -79,6 +79,15 @@ struct ReceiveScreen: View { } } } + .contextMenu { + if transfer.status.isTerminalReceiveHistory { + Button(role: .destructive) { + model.requestDeleteHistoryItem(transfer.transferId) + } label: { + Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash) + } + } + } } } header: { Text(String(localized: L10n.Receive.historyTitle)) diff --git a/apple/VniDrop/Features/Send/SendModel.swift b/apple/VniDrop/Features/Send/SendModel.swift index 18175dc..eda9c95 100644 --- a/apple/VniDrop/Features/Send/SendModel.swift +++ b/apple/VniDrop/Features/Send/SendModel.swift @@ -228,6 +228,31 @@ final class SendModel: ObservableObject { } } + /// Deletes a transfer by id, independent of the detail selection — used by the + /// list context menu so it can act inline without navigating into the detail. + func deleteTransfer(id: UInt64) { + if state.isDeleting { return } + state.isDeleting = true + Task { + let result = await repository.delete(transferId: id) + switch result { + case .success: + filePreviewRepository.remove(transferId: id) + if state.selectedTransferId == id { + state.selectedTransferId = nil + state.detailPanel = nil + state.receiverHistory = [] + } + state.isDeleting = false + _ = await repository.refresh() + messages.tryShow(UiMessage(text: .resource(L10n.Transfer.deleted), tone: .success)) + case .failure(let error): + state.isDeleting = false + messages.error(error) + } + } + } + /// Cancels/refuses a single receiver by responding to its request negatively. /// Uses the core's `respondReceiverRequest` (no backend change); applies to /// receivers that are still pending or accepted. diff --git a/apple/VniDrop/Features/Send/SendScreen.swift b/apple/VniDrop/Features/Send/SendScreen.swift index ee8b55d..4d73bea 100644 --- a/apple/VniDrop/Features/Send/SendScreen.swift +++ b/apple/VniDrop/Features/Send/SendScreen.swift @@ -7,6 +7,11 @@ 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 } } @@ -41,6 +46,18 @@ struct SendScreen: View { detailView(for: transfer) } } + // Attached inside the NavigationStack (a different sheet host than the + // 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 } }), + windowClass: windowClass, + onDismiss: { shareTarget = nil } + ) { + if let shareTarget { + TransferSharePanel(model: model, transfer: shareTarget) + } + } } .adaptiveDrawer( isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }), @@ -49,8 +66,24 @@ struct SendScreen: View { ) { TransferComposer(model: model, windowClass: windowClass) } + .alert( + Text(String(localized: L10n.Transfer.deleteTitle)), + isPresented: Binding(get: { deleteTarget != nil }, set: { if !$0 { deleteTarget = nil } }) + ) { + Button(String(localized: L10n.Button.cancel), role: .cancel) { deleteTarget = nil } + Button(String(localized: L10n.Button.deleteTransfer), role: .destructive) { + if let target = deleteTarget { model.deleteTransfer(id: target.transferId) } + deleteTarget = nil + } + } message: { + if let target = deleteTarget { + Text(L10n.Transfer.deleteDescription( + transferName: target.transferName ?? String(localized: L10n.Send.newTransferTitle))) + } + } } + /// The pushed transfer details view, with its detail-panel sheet and delete /// alert attached here so they present from the detail's own context (presenting /// modals from the parent stack while a detail is pushed is unreliable on macOS). @@ -91,6 +124,28 @@ struct SendScreen: View { ) } .buttonStyle(.plain) + .contextMenu { + if transfer.ticket != nil { + Button { + shareTarget = transfer + } label: { + Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp) + } + } + if transfer.status == .sharing { + Button(role: .destructive) { + model.stopSharing(transferId: transfer.transferId) + } label: { + Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle) + } + } + Divider() + Button(role: .destructive) { + deleteTarget = transfer + } label: { + Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash) + } + } } } header: { Text(String(localized: L10n.Send.transfersTitle))