fix(apple): make cancelling a targeted send actually cancel

Creating a targeted transfer contacts the peer and only returns once the
offer is answered or its timeouts expire — connection_timeout plus
offer_wait_timeout, so minutes against a device that never answers. The
composer disabled every control for that whole window, including Close,
leaving no way out. Worse, the core records the row before it reaches out
and leaves it in `failed` when the peer never replies, so giving up still
produced a failure notification and a history entry for a send the user
had already called off.

Cancelling now reaches the core while that create is still running. The
`created` lifecycle event carries the transfer id and is emitted before
the peer is contacted, so the id is known in time; the cancel goes out
through the interrupt lane, which exists precisely to reach a core busy
inside another call. The transfer is then deleted, and its id is filtered
out of the published list so a refresh racing the delete cannot leak it
into history or into a notification. If the id has not arrived yet, the
result carries it and the same cleanup runs on return. Picked sources are
released only once the call lands, because the import owns them until
then, and a generation counter keeps a late result from disturbing a
newer send.

Close and Cancel were also the same action under two labels. There is now
one control: the sheet's cancellation item, reading Cancel while a send is
waiting and Close otherwise, never disabled.

Two further fixes from device testing:

- Receive and Resume were gated on transfer state alone, so an approved
  outgoing transfer offered the sender a Receive button — an invitation to
  download the files it was uploading. Both pull into a local folder and
  are now receiver-only.
- Renamed the deprecated `laptopcomputerAndIphone` symbol to
  `macbookAndIphone`; the deployment targets are well past where it was
  introduced, so no availability guard is needed.

Adds a typed `targetedTransferId` accessor on CoreEventModel. This is a
narrow, deliberate exception to the wake-up-only event rule in
DESIGN-DEVICE-HISTORY.md §13: it takes the subject id and never state,
and it exists because no query can answer while the create holds the
serial lane — which is exactly when the user wants to cancel.

Known gap: direction is inferred by comparing endpoint ids, because the
binding does not expose the row's role. After an identity reset, rows
predating it match neither endpoint, so past sends read as incoming from
the device's own retired identity. Fixing that needs `role` on the core's
TargetedTransfer.
This commit is contained in:
2026-08-13 22:33:06 +02:00
parent 2d4fcd78b5
commit 6b6d5f158d
11 changed files with 383 additions and 18 deletions

View File

@@ -42,6 +42,24 @@ final class FakeCoreGateway: CoreGateway {
private(set) var resetUnrecoverableIdentityCount = 0 private(set) var resetUnrecoverableIdentityCount = 0
func setState(_ state: CoreState) { stateSubject.send(state) } func setState(_ state: CoreState) { stateSubject.send(state) }
/// Publishes the `created` lifecycle event the core emits once a targeted
/// transfer row exists, then the signal that follows it the sequence that
/// lets a caller cancel a create still holding the serial lane.
func emitTargetedTransferCreated(id: String) {
var state = stateSubject.value
state.events.insert(
CoreEventModel(
id: "event-\(id)", revision: 1, timestamp: 1, scope: "endpoint", transferId: nil,
direction: nil, phase: EventPhase.targetedTransfer.rawValue,
kind: EventKind.created.rawValue,
dataJson: #"{"targeted_transfer_id":"\#(id)"}"#
),
at: 0
)
stateSubject.send(state)
emit(.targetedTransferChanged)
}
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) } func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
func initialize( func initialize(
@@ -185,10 +203,29 @@ final class FakeCoreGateway: CoreGateway {
offerResponses.append((transferId, accepted)) offerResponses.append((transferId, accepted))
return offerResponseResult return offerResponseResult
} }
/// Holds `createTargetedTransfer` open until `releaseTargetedCreate()`, so a
/// test can observe the model while a create is genuinely in flight the
/// state the user is stuck in when the receiving device never answers.
var holdsTargetedCreate = false
private var targetedCreateGate: CheckedContinuation<Void, Never>?
/// True once the call is parked on the gate. `isCreatingSend` flips before the
/// task body runs, so releasing on that alone can resume nothing and hang.
var isHoldingTargetedCreate: Bool { targetedCreateGate != nil }
func releaseTargetedCreate() {
let gate = targetedCreateGate
targetedCreateGate = nil
gate?.resume()
}
func createTargetedTransfer( func createTargetedTransfer(
receiverEndpointId: String, sources: [ShareSource], transferName: String? receiverEndpointId: String, sources: [ShareSource], transferName: String?
) async -> Result<TargetedTransferModel, Error> { ) async -> Result<TargetedTransferModel, Error> {
createdTargetedTransfers.append((receiverEndpointId, sources, transferName)) createdTargetedTransfers.append((receiverEndpointId, sources, transferName))
if holdsTargetedCreate {
await withCheckedContinuation { targetedCreateGate = $0 }
}
return createTargetedTransferResult return createTargetedTransferResult
} }
func listTargetedTransfers() async -> Result<[TargetedTransferModel], Error> { func listTargetedTransfers() async -> Result<[TargetedTransferModel], Error> {

View File

@@ -373,6 +373,27 @@ final class SavedDevicesModelTests: XCTestCase {
XCTAssertEqual(gateway.forgottenDevices, [Self.peer]) XCTAssertEqual(gateway.forgottenDevices, [Self.peer])
} }
// MARK: - Transfer actions
func testOnlyTheReceivingSideIsOfferedReceiveAndResume() async {
gateway.savedDevices = [savedDevice()]
gateway.targetedTransfers = [
transfer(id: "in-approved", sender: Self.peer, receiver: Self.localEndpoint, state: .approved),
transfer(id: "out-approved", sender: Self.localEndpoint, receiver: Self.peer, state: .approved),
transfer(id: "in-interrupted", sender: Self.peer, receiver: Self.localEndpoint, state: .interrupted),
transfer(id: "out-interrupted", sender: Self.localEndpoint, receiver: Self.peer, state: .interrupted),
]
let model = await makeModel()
let byId = Dictionary(uniqueKeysWithValues: model.state.targetedTransfers.map { ($0.id, $0) })
XCTAssertEqual(byId["in-approved"]?.canReceive, true)
XCTAssertEqual(byId["in-interrupted"]?.canResume, true)
// The sender has nothing to pull: it is the one holding the files. Offering
// "Receive" there asked it to download its own outgoing transfer.
XCTAssertEqual(byId["out-approved"]?.canReceive, false)
XCTAssertEqual(byId["out-interrupted"]?.canResume, false)
}
// MARK: - Targeted send // MARK: - Targeted send
private func picked(_ name: String, isDirectory: Bool = false) -> PickedShareFile { private func picked(_ name: String, isDirectory: Bool = false) -> PickedShareFile {
@@ -415,6 +436,146 @@ final class SavedDevicesModelTests: XCTestCase {
XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"]) XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"])
} }
/// Sets up a create that never returns until released an unavailable peer,
/// where the core waits out its connection and offer timeouts. `announcesId`
/// mirrors the core emitting `created` once the row exists, which it does
/// before it ever contacts the peer.
private func stalledSend(
_ model: SavedDevicesModel,
id: String = "t-inflight",
announcesId: Bool = true
) async {
gateway.holdsTargetedCreate = true
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("a.txt")])
model.createTargetedTransfer()
await waitUntil { self.gateway.isHoldingTargetedCreate }
if announcesId {
gateway.emitTargetedTransferCreated(id: id)
await waitUntil { !model.state.isCreatingSend || model.knowsInFlightSendTransfer }
}
XCTAssertTrue(model.state.isCreatingSend)
}
func testCancellingAnUnansweredSendReachesTheCoreWhileItIsStillRunning() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight"))
let model = await makeModel()
await stalledSend(model)
model.abandonSend()
// The point of cancelling: it must not wait for the create to finish. The
// cancel goes out while the core is still parked inside that very call.
await waitUntil { !self.gateway.cancelledTargetedTransfers.isEmpty }
XCTAssertEqual(gateway.cancelledTargetedTransfers, ["t-inflight"])
XCTAssertFalse(model.state.isCreatingSend)
XCTAssertNil(model.state.sendTargetPeerId)
gateway.releaseTargetedCreate()
}
func testCancelledSendIsDeletedRatherThanLeftInHistory() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight"))
let model = await makeModel()
await stalledSend(model)
model.abandonSend()
await waitUntil { !self.gateway.deletedTargetedTransfers.isEmpty }
// Cancelling is not "closing the sheet": the core would otherwise record a
// failed transfer, and the user would be told a send they called off failed.
XCTAssertEqual(gateway.deletedTargetedTransfers, ["t-inflight"])
gateway.releaseTargetedCreate()
}
func testCancelledSendNeverReachesHistoryEvenIfARefreshRacesTheDelete() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight"))
// The core reports it as failed the state an unanswered offer lands in.
gateway.targetedTransfers = [transfer(id: "t-inflight", state: .failed)]
let model = await makeModel()
await stalledSend(model)
model.abandonSend()
gateway.releaseTargetedCreate()
await waitUntil { !self.gateway.deletedTargetedTransfers.isEmpty }
// Nothing about the cancelled send may surface, or the notification
// coordinator announces a failure for work the user called off.
XCTAssertTrue(model.state.targetedTransfers.isEmpty)
}
func testCancellingReleasesTheComposerBeforeTheCreateReturns() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight"))
let model = await makeModel()
await stalledSend(model)
model.abandonSend()
XCTAssertFalse(model.state.isCreatingSend)
XCTAssertTrue(model.state.sendFiles.isEmpty)
// The import still owns the sources until the call lands.
XCTAssertTrue(fileSystem.discardedFiles.isEmpty)
gateway.releaseTargetedCreate()
await waitUntil { !self.fileSystem.discardedFiles.isEmpty }
XCTAssertEqual(fileSystem.discardedFiles, ["/tmp/a.txt"])
}
func testCancelledSendWithNoIdYetIsStillCleanedUpWhenTheCreateReturns() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer(id: "t-late"))
let model = await makeModel()
// The create beat its own `created` event, so cancelling has no id to use.
await stalledSend(model, announcesId: false)
model.abandonSend()
XCTAssertTrue(gateway.cancelledTargetedTransfers.isEmpty)
gateway.releaseTargetedCreate()
// The result carries the id, so the cleanup still happens just later.
await waitUntil { !self.gateway.cancelledTargetedTransfers.isEmpty }
XCTAssertEqual(gateway.cancelledTargetedTransfers, ["t-late"])
XCTAssertEqual(gateway.deletedTargetedTransfers, ["t-late"])
}
func testCancelledSendThatNeverRegisteredHasNothingToCancel() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .failure(TestError.unimplemented)
let model = await makeModel()
await stalledSend(model, announcesId: false)
model.abandonSend()
gateway.releaseTargetedCreate()
await waitUntil { !self.fileSystem.discardedFiles.isEmpty }
XCTAssertTrue(gateway.cancelledTargetedTransfers.isEmpty)
// The composition is gone, so a failure the user walked away from is not
// resurrected as an error they have to dismiss.
XCTAssertTrue(model.state.sendFiles.isEmpty)
}
func testCancelledResultDoesNotDisturbANewerSend() async {
gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .success(transfer(id: "t-inflight"))
let model = await makeModel()
await stalledSend(model)
model.abandonSend()
// The user starts composing again while the first call is still pending.
model.beginSend(to: Self.peer)
model.onSendFilesPicked([picked("b.txt")])
gateway.releaseTargetedCreate()
await waitUntil { !self.fileSystem.discardedFiles.isEmpty }
XCTAssertEqual(model.state.sendTargetPeerId, Self.peer)
XCTAssertEqual(model.state.sendFiles.map(\.value), ["/tmp/b.txt"])
XCTAssertFalse(model.state.isCreatingSend)
}
func testSendFailureKeepsCompositionForRetry() async { func testSendFailureKeepsCompositionForRetry() async {
gateway.savedDevices = [savedDevice()] gateway.savedDevices = [savedDevice()]
gateway.createTargetedTransferResult = .failure(TestError.unimplemented) gateway.createTargetedTransferResult = .failure(TestError.unimplemented)

View File

@@ -27,6 +27,30 @@ struct CoreEventModel: Equatable, Identifiable, Sendable {
var eventDirection: EventDirection? { direction.flatMap(EventDirection.init(rawValue:)) } var eventDirection: EventDirection? { direction.flatMap(EventDirection.init(rawValue:)) }
var eventPhase: EventPhase? { EventPhase(rawValue: phase) } var eventPhase: EventPhase? { EventPhase(rawValue: phase) }
var eventKind: EventKind? { EventKind(rawValue: kind) } var eventKind: EventKind? { EventKind(rawValue: kind) }
/// Subject of a `.targetedTransfer` event.
///
/// This is an identifier, not state: it says *which* transfer changed, which
/// is all a consumer may take from an event before re-reading durable state.
/// It exists because the id is otherwise unobtainable while `create` is still
/// running that call occupies the serial lane, so no query can answer until
/// it returns, which is exactly when the user wants to cancel it.
var targetedTransferId: String? {
guard eventPhase == .targetedTransfer, let data = dataJson.data(using: .utf8) else {
return nil
}
return try? JSONDecoder().decode(TargetedTransferEventData.self, from: data).targetedTransferId
}
}
/// Payload of a `.targetedTransfer` event. Named keys rather than a raw string
/// subscript so the wire contract lives in one declared place.
private struct TargetedTransferEventData: Decodable {
let targetedTransferId: String?
private enum CodingKeys: String, CodingKey {
case targetedTransferId = "targeted_transfer_id"
}
} }
/// Direction of a core event, matching the wire strings the core emits. /// Direction of a core event, matching the wire strings the core emits.

View File

@@ -315,12 +315,12 @@ private struct TargetedTransferRow: View {
let run: () -> Void let run: () -> Void
} }
/// At most one of receive/resume applies, and only in one state each. /// At most one of receive/resume applies: one state each, receiving side only.
private var primaryAction: TransferAction? { private var primaryAction: TransferAction? {
if transfer.state.canReceive { if transfer.canReceive {
return TransferAction(id: "receive", title: L10n.Saved.devicesTransferReceive, run: onReceive) return TransferAction(id: "receive", title: L10n.Saved.devicesTransferReceive, run: onReceive)
} }
if transfer.state.canResume { if transfer.canResume {
return TransferAction(id: "resume", title: L10n.Saved.devicesTransferResume, run: onResume) return TransferAction(id: "resume", title: L10n.Saved.devicesTransferResume, run: onResume)
} }
return nil return nil
@@ -468,14 +468,23 @@ private struct TargetedSendSheet: View {
#endif #endif
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button(String(localized: L10n.Button.close), action: model.cancelSend) // Never disabled: a create in flight can take minutes against an
.disabled(model.state.isCreatingSend) // unavailable device, and a dead Close left no way out at all.
// It reads "Cancel" while waiting, because that is what leaving
// now does abandoning keeps the sources alive for the core's
// import and cleans them up once the call lands.
Button(String(localized: model.state.isCreatingSend
? L10n.Button.cancel
: L10n.Button.close)) {
if model.state.isCreatingSend {
model.abandonSend()
} else {
model.cancelSend()
}
}
} }
} }
} }
// A create in flight owns the picked sources; discarding them mid-call
// would pull the files out from under the core's import.
.interactiveDismissDisabled(model.state.isCreatingSend)
.sheetSize(windowClass: windowClass, minWidth: 460, minHeight: 480) .sheetSize(windowClass: windowClass, minWidth: 460, minHeight: 480)
} }
} }

View File

@@ -76,4 +76,12 @@ struct SavedDeviceTransferItem: Equatable, Identifiable, Sendable {
guard totalSize > 0, state.isActive || state == .interrupted else { return nil } guard totalSize > 0, state.isActive || state == .interrupted else { return nil }
return min(1, Double(verifiedBytes) / Double(totalSize)) return min(1, Double(verifiedBytes) / Double(totalSize))
} }
/// Pulling content is the receiving side's move. Gating on state alone put a
/// "Receive" button on the sender's own outgoing transfer, offering to
/// download the files it was in the middle of sending.
var canReceive: Bool { direction == .incoming && state.canReceive }
/// Resuming likewise pulls into a local folder, so it is receiver-only.
var canResume: Bool { direction == .incoming && state.canResume }
} }

View File

@@ -46,7 +46,7 @@ extension TargetedTransferStateModel {
/// hardware type, so showing a specific device silhouette would imply knowledge /// hardware type, so showing a specific device silhouette would imply knowledge
/// VniDrop does not have. /// VniDrop does not have.
struct DeviceAvatar: View { struct DeviceAvatar: View {
var symbol: SFSymbol = .laptopcomputerAndIphone var symbol: SFSymbol = .macbookAndIphone
var tint: Color = .secondary var tint: Color = .secondary
var size: CGFloat = 40 var size: CGFloat = 40

View File

@@ -89,6 +89,18 @@ final class SavedDevicesModel: ObservableObject {
/// it suppresses the prompt locally without consuming the core's single-use /// it suppresses the prompt locally without consuming the core's single-use
/// capability, so the device stays actionable from the list. /// capability, so the device stays actionable from the list.
private var dismissedEligibility: Set<String> = [] private var dismissedEligibility: Set<String> = []
/// Identifies each create attempt so a result arriving after the user gave up
/// can tell whether it is the one that was abandoned.
private var sendGeneration = 0
private var abandonedSendGeneration: Int?
/// The transfer the in-flight create registered, learned from its `created`
/// event. Cancelling needs an id, and the create does not return one until it
/// has already spent its timeouts against a device that may never answer.
private var inFlightSendTransferId: String?
/// Transfers the user walked away from. They are cancelled and deleted, but
/// until that lands they stay out of the published list so history and
/// notifications never mention a transfer the user called off.
private var abandonedTransferIds: Set<String> = []
private var receiveFolder: ReceiveFolder? private var receiveFolder: ReceiveFolder?
init( init(
@@ -121,7 +133,10 @@ final class SavedDevicesModel: ObservableObject {
switch signal { switch signal {
case .pairingChanged, .targetedTransferChanged: case .pairingChanged, .targetedTransferChanged:
// Wake-up only: re-read durable state rather than trusting the // Wake-up only: re-read durable state rather than trusting the
// event payload (see DESIGN-DEVICE-HISTORY.md §13). // event payload (see DESIGN-DEVICE-HISTORY.md §13). The one
// exception is noting *which* transfer a create just made, which
// no query can answer while that create holds the serial lane.
self.noteInFlightSendTransferId()
if self.repository.state.isInitialized { self.scheduleRefresh() } if self.repository.state.isInitialized { self.scheduleRefresh() }
case .approvalChanged, .receiverHistoryChanged, .transfersChanged: case .approvalChanged, .receiverHistoryChanged, .transfersChanged:
// Invitation-share domain; owned by SendModel. // Invitation-share domain; owned by SendModel.
@@ -300,6 +315,57 @@ final class SavedDevicesModel: ObservableObject {
discardPickedFiles(discarded) discardPickedFiles(discarded)
} }
/// Cancels a create that is still in flight.
///
/// The create reaches out to the peer and only returns once the offer is
/// answered or its timeouts expire, which against an unavailable device is
/// minutes. So this cancels the transfer by id off the serial lane, which
/// is what lets it reach a core busy inside that very call and drops the
/// composer immediately. Cancelling is not merely closing the sheet: without
/// it the core would go on to record a failed transfer, then announce and
/// list a send the user had already called off.
func abandonSend() {
guard state.isCreatingSend else { return }
abandonedSendGeneration = sendGeneration
state.isCreatingSend = false
state.sendTargetPeerId = nil
state.sendFiles = []
state.sendTransferName = ""
guard let transferId = inFlightSendTransferId else { return }
abandonedTransferIds.insert(transferId)
Task { await cancelAndForget(transferId) }
}
/// Whether the in-flight create has announced the transfer it registered, and
/// so whether cancelling can reach it now rather than after the call returns.
var knowsInFlightSendTransfer: Bool { inFlightSendTransferId != nil }
/// Captures the id of the transfer the in-flight create just registered. The
/// core inserts the row and emits `created` before it contacts the peer, so
/// this lands well before the wait the user gives up on.
private func noteInFlightSendTransferId() {
guard state.isCreatingSend, inFlightSendTransferId == nil else { return }
let created = repository.state.events.first {
$0.eventPhase == .targetedTransfer && $0.eventKind == .created
}
guard let id = created?.targetedTransferId else { return }
inFlightSendTransferId = id
}
/// Cancels an abandoned transfer and removes it from history. Both are best
/// effort: the user has moved on, so a failure here is logged rather than
/// raised as an error about work they already dismissed.
private func cancelAndForget(_ transferId: String) async {
if case .failure(let error) = await repository.cancelTargetedTransfer(id: transferId) {
AppLogger.error("saved-devices", "abandoned send cancel failed", error)
}
if case .failure(let error) = await repository.deleteTargetedTransfer(id: transferId) {
AppLogger.error("saved-devices", "abandoned send delete failed", error)
}
await refresh()
}
func selectSendFiles() { pendingFilePick = true } func selectSendFiles() { pendingFilePick = true }
func selectSendFolder() { pendingFolderPick = true } func selectSendFolder() { pendingFolderPick = true }
@@ -347,6 +413,9 @@ final class SavedDevicesModel: ObservableObject {
guard state.canCreateTargetedTransfer, let peerId = state.sendTargetPeerId else { return } guard state.canCreateTargetedTransfer, let peerId = state.sendTargetPeerId else { return }
let files = state.sendFiles let files = state.sendFiles
let name = state.sendTransferName.trimmingCharacters(in: .whitespacesAndNewlines) let name = state.sendTransferName.trimmingCharacters(in: .whitespacesAndNewlines)
sendGeneration &+= 1
let generation = sendGeneration
inFlightSendTransferId = nil
state.isCreatingSend = true state.isCreatingSend = true
Task { Task {
let result = await fileSystemService.sendPickedFilesToSavedDevice( let result = await fileSystemService.sendPickedFilesToSavedDevice(
@@ -355,7 +424,14 @@ final class SavedDevicesModel: ObservableObject {
transferName: name, transferName: name,
receiverEndpointId: peerId receiverEndpointId: peerId
) )
// The user walked away from this attempt, and may already have started
// another one; touching the composer now would stomp that newer state.
if abandonedSendGeneration == generation {
await reconcileAbandonedSend(result, files: files)
return
}
state.isCreatingSend = false state.isCreatingSend = false
inFlightSendTransferId = nil
switch result { switch result {
case .success: case .success:
messages.tryShow( messages.tryShow(
@@ -375,6 +451,24 @@ final class SavedDevicesModel: ObservableObject {
} }
} }
/// Runs when an abandoned create finally returns. The cancel itself already
/// went out by id; this releases the sources, which the core's import owned
/// until now, and covers the case where the id never arrived the create
/// beat its own `created` event, or failed before registering anything.
private func reconcileAbandonedSend(
_ result: Result<TargetedTransferModel, Error>,
files: [PickedShareFile]
) async {
inFlightSendTransferId = nil
discardPickedFiles(files)
if case .success(let transfer) = result, !abandonedTransferIds.contains(transfer.id) {
abandonedTransferIds.insert(transfer.id)
await cancelAndForget(transfer.id)
return
}
await refresh()
}
private func defaultTransferName(_ files: [PickedShareFile]) -> String { private func defaultTransferName(_ files: [PickedShareFile]) -> String {
guard let first = files.first else { return "" } guard let first = files.first else { return "" }
return files.count == 1 return files.count == 1
@@ -545,7 +639,10 @@ final class SavedDevicesModel: ObservableObject {
state.pendingRelationships = pendingRelationships state.pendingRelationships = pendingRelationships
state.savedDevices = savedDevices.sorted { $0.createdAt > $1.createdAt } state.savedDevices = savedDevices.sorted { $0.createdAt > $1.createdAt }
state.targetedTransfers = transfers state.targetedTransfers = transfers
.filter { $0.state != .deleted } // A cancelled-and-deleted transfer can still be in this snapshot if
// the read raced the delete. Publishing it would put a send the user
// called off into history and fire a notification about it.
.filter { $0.state != .deleted && !abandonedTransferIds.contains($0.id) }
.sorted { $0.updatedAt > $1.updatedAt } .sorted { $0.updatedAt > $1.updatedAt }
.map { $0.toExperienceItem(localEndpointId: localEndpointId, savedNames: savedNames) } .map { $0.toExperienceItem(localEndpointId: localEndpointId, savedNames: savedNames) }
// Leave a prompt mid-answer alone; replacing it would strand the // Leave a prompt mid-answer alone; replacing it would strand the

View File

@@ -129,7 +129,7 @@ struct SavedDevicesScreen: View {
/// First-use state: the only place that explains what a saved device is. /// First-use state: the only place that explains what a saved device is.
private var emptyState: some View { private var emptyState: some View {
ContentUnavailableView { ContentUnavailableView {
Label(String(localized: L10n.Saved.devicesEmptyTitle), systemSymbol: .laptopcomputerAndIphone) Label(String(localized: L10n.Saved.devicesEmptyTitle), systemSymbol: .macbookAndIphone)
} description: { } description: {
Text(String(localized: L10n.Saved.devicesEmpty)) Text(String(localized: L10n.Saved.devicesEmpty))
} }

View File

@@ -93,11 +93,25 @@ struct TargetedSendComposer: View {
private var actions: some View { private var actions: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
if state.isCreatingSend {
// Reaching an unavailable device can take minutes, so the wait says
// so. Abandoning it lives on the toolbar's cancel item alone a
// second button here would be the same action under a second name.
HStack(spacing: 10) {
ProgressView().controlSize(.small)
Text(String(localized: L10n.Saved.devicesSendWaiting))
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
} else {
PrimaryButton( PrimaryButton(
title: String(localized: L10n.Saved.devicesSendAction), title: String(localized: L10n.Saved.devicesSendAction),
action: model.createTargetedTransfer, action: model.createTargetedTransfer,
enabled: state.canCreateTargetedTransfer enabled: state.canCreateTargetedTransfer
) )
}
HStack(spacing: 10) { HStack(spacing: 10) {
sourceButton( sourceButton(
title: L10n.Button.changeFiles, symbol: .docBadgeArrowUp, action: model.selectSendFiles title: L10n.Button.changeFiles, symbol: .docBadgeArrowUp, action: model.selectSendFiles

View File

@@ -24,7 +24,7 @@ enum AppDestination: String, CaseIterable, Identifiable {
switch self { switch self {
case .send: return .paperplane case .send: return .paperplane
case .receive: return .trayAndArrowDown case .receive: return .trayAndArrowDown
case .savedDevices: return .laptopcomputerAndIphone case .savedDevices: return .macbookAndIphone
case .settings: return .gearshape case .settings: return .gearshape
} }
} }

View File

@@ -5485,6 +5485,21 @@
"ru": "Transfer offer sent" "ru": "Transfer offer sent"
} }
}, },
"saved_devices_send_waiting": {
"context": "Progress label while a targeted transfer offer is waiting for the receiving device to answer.",
"targets": ["apple"],
"translations": {
"en": "Waiting for the device to answer…",
"fr": "Waiting for the device to answer…",
"es": "Waiting for the device to answer…",
"it": "Waiting for the device to answer…",
"de": "Waiting for the device to answer…",
"pt": "Waiting for the device to answer…",
"pl": "Waiting for the device to answer…",
"nl": "Waiting for the device to answer…",
"ru": "Waiting for the device to answer…"
}
},
"saved_devices_forgotten": { "saved_devices_forgotten": {
"context": "Snackbar after forgetting a saved device.", "context": "Snackbar after forgetting a saved device.",
"translations": { "translations": {