mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 11:19:58 +02:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user