feat(apple): offer to remember a device after a transfer

Closes the loop: until now nothing in the UI could create a contact, so the
list stayed empty unless the peer initiated.

A completed receive names its sender and a completed delivery names its
receiver, so both sides get the suggestion. Declining is persisted, or every
later transfer with the same device would re-ask a question already
answered; pairing deliberately afterwards clears that.

The suggestion sheet ranks below the two other prompts, since nobody is
waiting on the answer.
This commit is contained in:
2026-08-06 18:06:19 +02:00
parent 1369df4578
commit dc23e87c56
5 changed files with 368 additions and 4 deletions

View File

@@ -319,6 +319,13 @@ private struct ContactPromptLayer: View {
receiveModel.receiveOffered(ticket: ticket)
}
}
},
onSuggestionResponse: { suggestion, accepted in
if accepted {
Task { await contacts.acceptSuggestion(suggestion) }
} else {
contacts.declineSuggestion(suggestion)
}
}
)
.onChange(of: promptKey) { _, key in
@@ -332,6 +339,7 @@ private struct ContactPromptLayer: View {
guard approvals.state.current == nil else { return nil }
if let offer = contacts.state.currentOffer { return "offer-\(offer.offerId)" }
if let pairing = contacts.state.currentPairing { return "pairing-\(pairing.endpointId)" }
if let suggestion = contacts.state.currentSuggestion { return "suggest-\(suggestion.endpointId)" }
return nil
}
}

View File

@@ -124,6 +124,9 @@ struct AppPreferences: Equatable {
var relayConfiguration: RelayConfiguration
/// Idle lifetime applied to grants this device issues from now on.
var grantLifetime: GrantLifetimeOption
/// Devices the user declined to remember. Persisted so a repeat transfer
/// with the same device does not re-ask forever.
var declinedPairingSuggestions: Set<String>
}
struct AppPreferencesDefaults {
@@ -148,6 +151,7 @@ final class AppPreferencesRepository: ObservableObject {
static let diagnosticsInstallId = "diagnostics_install_id"
static let relayConfiguration = "relay_configuration"
static let grantLifetime = "grant_lifetime"
static let declinedPairingSuggestions = "declined_pairing_suggestions"
}
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
@@ -163,13 +167,15 @@ final class AppPreferencesRepository: ObservableObject {
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
let grantLifetime = defaults.string(forKey: Key.grantLifetime)
.flatMap(GrantLifetimeOption.init(rawValue:)) ?? .days90
let declined = Set(defaults.stringArray(forKey: Key.declinedPairingSuggestions) ?? [])
return AppPreferences(
username: username,
receiveFolder: folder,
themeMode: themeMode,
diagnosticsInstallId: installId,
relayConfiguration: resolveRelayConfiguration(defaults),
grantLifetime: grantLifetime
grantLifetime: grantLifetime,
declinedPairingSuggestions: declined
)
}
@@ -215,6 +221,22 @@ final class AppPreferencesRepository: ObservableObject {
setReceiveFolder(fallback.receiveFolder)
}
func declinePairingSuggestion(_ endpointId: String) {
var declined = preferences.declinedPairingSuggestions
declined.insert(endpointId)
defaults.set(Array(declined), forKey: Key.declinedPairingSuggestions)
reload()
}
/// Clears the decline so the device can be suggested again, used when the
/// user pairs with it deliberately.
func clearDeclinedPairingSuggestion(_ endpointId: String) {
var declined = preferences.declinedPairingSuggestions
guard declined.remove(endpointId) != nil else { return }
defaults.set(Array(declined), forKey: Key.declinedPairingSuggestions)
reload()
}
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
defaults.set(lifetime.rawValue, forKey: Key.grantLifetime)
reload()

View File

@@ -14,12 +14,14 @@ struct ContactPromptHost: View {
let state: ContactsState
let onPairingResponse: (String, Bool) -> Void
let onOfferResponse: (String, Bool) -> Void
let onSuggestionResponse: (PairingSuggestion, Bool) -> Void
var body: some View {
Color.clear
.sheet(isPresented: $isPresented) {
// An incoming transfer is the more urgent of the two, and a
// pairing offer keeps until the consent window lapses.
// Ordered by who is waiting: a sender is blocked on an offer, a
// pairing request keeps until its consent window lapses, and a
// post-transfer suggestion has nobody waiting at all.
if let offer = state.currentOffer {
OfferSheet(
offer: offer,
@@ -36,6 +38,16 @@ struct ContactPromptHost: View {
)
.interactiveDismissDisabled(true)
.modifier(ContactPromptDetents())
} else if let suggestion = state.currentSuggestion {
// Lowest priority: nobody is waiting on this answer, it just
// follows a transfer that already finished.
SuggestionSheet(
suggestion: suggestion,
busy: state.busyEndpoints.contains(suggestion.endpointId),
onRespond: onSuggestionResponse
)
.interactiveDismissDisabled(true)
.modifier(ContactPromptDetents())
}
}
}
@@ -131,3 +143,43 @@ private struct PairingSheet: View {
.padding(20)
}
}
/// "You just transferred with this device. Let it reach you next time?"
private struct SuggestionSheet: View {
let suggestion: PairingSuggestion
let busy: Bool
let onRespond: (PairingSuggestion, Bool) -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemSymbol: .clockArrowCirclepath)
.font(.system(size: 44))
.foregroundStyle(.tint)
.padding(.top, 12)
Text(String(localized: L10n.Pairing.allowTitle))
.font(.title2).fontWeight(.semibold)
Text(L10n.Pairing.requestBody(device: suggestion.resolvedName))
.multilineTextAlignment(.center)
Text(String(localized: L10n.Pairing.allowBody))
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Spacer(minLength: 0)
HStack(spacing: 12) {
Button(role: .cancel) {
onRespond(suggestion, false)
} label: {
Text(String(localized: L10n.Pairing.decline)).frame(maxWidth: .infinity)
}
Button {
onRespond(suggestion, true)
} label: {
Text(String(localized: L10n.Pairing.allowConfirm)).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
.disabled(busy)
}
.padding(20)
}
}

View File

@@ -1,6 +1,25 @@
import Combine
import Foundation
/// A device worth remembering after a completed transfer.
///
/// Only a suggestion: nothing is issued until the user agrees, because being
/// reachable is a standing permission and a transfer is a one-off.
struct PairingSuggestion: Equatable, Identifiable {
let endpointId: String
let displayName: String?
let transferName: String?
var id: String { endpointId }
var resolvedName: String {
guard let displayName, !displayName.isEmpty else {
return String(localized: L10n.Approval.nearbyDevice)
}
return displayName
}
}
struct ContactsState: Equatable {
var contacts: [DeviceContact] = []
var blocked: [String] = []
@@ -12,6 +31,7 @@ struct ContactsState: Equatable {
/// blocking the rest of the list.
var busyEndpoints: Set<String> = []
var busyOfferIds: Set<String> = []
var suggestions: [PairingSuggestion] = []
var selectedEndpointId: String?
var selected: DeviceContact? {
@@ -23,6 +43,7 @@ struct ContactsState: Equatable {
/// sheets on top of each other reads as a loop of dialogs.
var currentPairing: PendingPairingModel? { pendingPairings.first }
var currentOffer: IncomingOfferModel? { pendingOffers.first }
var currentSuggestion: PairingSuggestion? { suggestions.first }
}
/// Drives the device-history surfaces: the list, its detail, and the two
@@ -54,12 +75,22 @@ final class ContactsModel: ObservableObject {
Task { await self.refresh() }
case .offersChanged:
Task { await self.refreshOffers() }
case .approvalChanged, .receiverHistoryChanged, .transfersChanged:
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
// A completed delivery names the device that received from us.
Task { await self.considerSendPeers(transferId: transferId) }
case .approvalChanged:
break
}
}
.store(in: &cancellables)
repository.statePublisher
.sink { [weak self] core in
guard let self, core.isInitialized else { return }
self.considerReceivePeers(core.transfers)
}
.store(in: &cancellables)
repository.statePublisher
.map(\.isInitialized)
.removeDuplicates()
@@ -98,6 +129,73 @@ final class ContactsModel: ObservableObject {
state.pendingOffers = await repository.pendingOffers()
}
// MARK: - Post-transfer suggestions
/// A completed receive names its sender, so that device becomes a candidate.
private func considerReceivePeers(_ transfers: [Transfer]) {
let candidates = transfers
.filter { $0.direction == .receive && $0.status == .done }
.compactMap { transfer -> PairingSuggestion? in
guard let peerId = transfer.peerId else { return nil }
return PairingSuggestion(
endpointId: peerId,
displayName: nil,
transferName: transfer.transferName
)
}
add(suggestions: candidates)
}
/// A completed delivery names the device we sent to.
private func considerSendPeers(transferId: UInt64) async {
guard case .success(let requests) = await repository.receiverRequests(transferId: transferId) else {
return
}
let candidates = requests
.filter { $0.status == .completed }
.map { request in
PairingSuggestion(
endpointId: request.remoteEndpointId,
displayName: request.receiverName ?? request.receiverDeviceName,
transferName: request.transferName
)
}
add(suggestions: candidates)
}
/// Filters candidates down to devices actually worth asking about.
private func add(suggestions candidates: [PairingSuggestion]) {
let known = Set(state.contacts.map(\.endpointId))
let blocked = Set(state.blocked)
let declined = preferences.preferences.declinedPairingSuggestions
let pending = Set(state.suggestions.map(\.endpointId))
let fresh = candidates.filter { candidate in
!known.contains(candidate.endpointId)
&& !blocked.contains(candidate.endpointId)
&& !declined.contains(candidate.endpointId)
&& !pending.contains(candidate.endpointId)
}
guard !fresh.isEmpty else { return }
state.suggestions.append(contentsOf: fresh)
}
/// Agree to be reachable by a suggested device.
func acceptSuggestion(_ suggestion: PairingSuggestion) async {
state.suggestions.removeAll { $0.endpointId == suggestion.endpointId }
preferences.clearDeclinedPairingSuggestion(suggestion.endpointId)
await allowDeviceToReachMe(
endpointId: suggestion.endpointId,
displayName: preferences.preferences.username
)
}
/// Decline, and remember the decline so the next transfer does not re-ask.
func declineSuggestion(_ suggestion: PairingSuggestion) {
state.suggestions.removeAll { $0.endpointId == suggestion.endpointId }
preferences.declinePairingSuggestion(suggestion.endpointId)
}
// MARK: - Selection
func select(_ endpointId: String?) { state.selectedEndpointId = endpointId }