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:
@@ -223,3 +223,187 @@ final class ContactsModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(model.state.contacts.first?.canSend, false)
|
XCTAssertEqual(model.state.contacts.first?.canSend, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Post-transfer suggestions
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PairingSuggestionTests: XCTestCase {
|
||||||
|
private func makeModel(
|
||||||
|
_ gateway: FakeCoreGateway,
|
||||||
|
defaults: UserDefaults
|
||||||
|
) -> (ContactsModel, AppPreferencesRepository) {
|
||||||
|
let preferences = AppPreferencesRepository(
|
||||||
|
defaults: defaults,
|
||||||
|
fallback: AppPreferencesDefaults(
|
||||||
|
username: "tester",
|
||||||
|
receiveFolder: ReceiveFolder(
|
||||||
|
kind: .fileSystemPath,
|
||||||
|
value: "/tmp",
|
||||||
|
displayName: "Downloads"
|
||||||
|
),
|
||||||
|
themeMode: .system
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let model = ContactsModel(
|
||||||
|
repository: gateway,
|
||||||
|
messages: UiMessageController(),
|
||||||
|
preferences: preferences
|
||||||
|
)
|
||||||
|
return (model, preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func newDefaults() -> UserDefaults {
|
||||||
|
UserDefaults(suiteName: "suggestion-tests-\(UUID().uuidString)")!
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completedReceive(from peerId: String?) -> Transfer {
|
||||||
|
Transfer(
|
||||||
|
localId: "local-1",
|
||||||
|
transferId: 1,
|
||||||
|
direction: .receive,
|
||||||
|
status: .done,
|
||||||
|
peerId: peerId,
|
||||||
|
transferName: "photos",
|
||||||
|
contentHash: nil,
|
||||||
|
fileCount: 1,
|
||||||
|
totalSize: 10,
|
||||||
|
ticket: nil,
|
||||||
|
accessPolicy: .requireApproval,
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func state(with transfers: [Transfer]) -> CoreState {
|
||||||
|
var core = CoreState()
|
||||||
|
core.isInitialized = true
|
||||||
|
core.transfers = transfers
|
||||||
|
return core
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCompletedReceiveSuggestsItsSender() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
XCTAssertEqual(model.state.currentSuggestion?.endpointId, "sender-endpoint")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transfer that never recorded a peer cannot be turned into a suggestion.
|
||||||
|
func testReceiveWithoutAPeerIsNotSuggested() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
gateway.setState(state(with: [completedReceive(from: nil)]))
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
XCTAssertNil(model.state.currentSuggestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAlreadyRememberedDeviceIsNotSuggested() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.contactsResult = .success([
|
||||||
|
DeviceContact(
|
||||||
|
endpointId: "sender-endpoint",
|
||||||
|
localLabel: nil,
|
||||||
|
remoteDisplayName: nil,
|
||||||
|
lastTransferAt: nil,
|
||||||
|
createdAt: 0,
|
||||||
|
canSend: true
|
||||||
|
)
|
||||||
|
])
|
||||||
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
XCTAssertNil(model.state.currentSuggestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBlockedDeviceIsNotSuggested() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.blockedResult = .success(["sender-endpoint"])
|
||||||
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
XCTAssertNil(model.state.currentSuggestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declining has to stick, or every later transfer with the same device
|
||||||
|
/// re-asks the question the user already answered.
|
||||||
|
func testDecliningIsRememberedAcrossLaterTransfers() async {
|
||||||
|
let defaults = newDefaults()
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, preferences) = makeModel(gateway, defaults: defaults)
|
||||||
|
await model.refresh()
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
let suggestion = try? XCTUnwrap(model.state.currentSuggestion)
|
||||||
|
|
||||||
|
model.declineSuggestion(suggestion!)
|
||||||
|
|
||||||
|
XCTAssertNil(model.state.currentSuggestion)
|
||||||
|
XCTAssertTrue(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
||||||
|
|
||||||
|
// A second transfer with the same device must stay silent.
|
||||||
|
gateway.setState(CoreState())
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
XCTAssertNil(model.state.currentSuggestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAcceptingASuggestionIssuesAGrantUnderTheLocalUsername() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||||
|
await model.refresh()
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
let suggestion = try? XCTUnwrap(model.state.currentSuggestion)
|
||||||
|
|
||||||
|
await model.acceptSuggestion(suggestion!)
|
||||||
|
|
||||||
|
XCTAssertEqual(gateway.allowedDevices.map(\.endpointId), ["sender-endpoint"])
|
||||||
|
XCTAssertEqual(gateway.allowedDevices.first?.displayName, "tester")
|
||||||
|
XCTAssertNil(model.state.currentSuggestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pairing deliberately after declining should work, so the decline is
|
||||||
|
/// cleared rather than blocking the device forever.
|
||||||
|
func testAcceptingClearsAnEarlierDecline() async {
|
||||||
|
let defaults = newDefaults()
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, preferences) = makeModel(gateway, defaults: defaults)
|
||||||
|
let suggestion = PairingSuggestion(
|
||||||
|
endpointId: "sender-endpoint",
|
||||||
|
displayName: nil,
|
||||||
|
transferName: nil
|
||||||
|
)
|
||||||
|
model.declineSuggestion(suggestion)
|
||||||
|
XCTAssertTrue(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
||||||
|
|
||||||
|
await model.acceptSuggestion(suggestion)
|
||||||
|
|
||||||
|
XCTAssertFalse(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTheSameDeviceIsOnlySuggestedOnce() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
XCTAssertEqual(model.state.suggestions.count, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -319,6 +319,13 @@ private struct ContactPromptLayer: View {
|
|||||||
receiveModel.receiveOffered(ticket: ticket)
|
receiveModel.receiveOffered(ticket: ticket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onSuggestionResponse: { suggestion, accepted in
|
||||||
|
if accepted {
|
||||||
|
Task { await contacts.acceptSuggestion(suggestion) }
|
||||||
|
} else {
|
||||||
|
contacts.declineSuggestion(suggestion)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.onChange(of: promptKey) { _, key in
|
.onChange(of: promptKey) { _, key in
|
||||||
@@ -332,6 +339,7 @@ private struct ContactPromptLayer: View {
|
|||||||
guard approvals.state.current == nil else { return nil }
|
guard approvals.state.current == nil else { return nil }
|
||||||
if let offer = contacts.state.currentOffer { return "offer-\(offer.offerId)" }
|
if let offer = contacts.state.currentOffer { return "offer-\(offer.offerId)" }
|
||||||
if let pairing = contacts.state.currentPairing { return "pairing-\(pairing.endpointId)" }
|
if let pairing = contacts.state.currentPairing { return "pairing-\(pairing.endpointId)" }
|
||||||
|
if let suggestion = contacts.state.currentSuggestion { return "suggest-\(suggestion.endpointId)" }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ struct AppPreferences: Equatable {
|
|||||||
var relayConfiguration: RelayConfiguration
|
var relayConfiguration: RelayConfiguration
|
||||||
/// Idle lifetime applied to grants this device issues from now on.
|
/// Idle lifetime applied to grants this device issues from now on.
|
||||||
var grantLifetime: GrantLifetimeOption
|
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 {
|
struct AppPreferencesDefaults {
|
||||||
@@ -148,6 +151,7 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
static let diagnosticsInstallId = "diagnostics_install_id"
|
static let diagnosticsInstallId = "diagnostics_install_id"
|
||||||
static let relayConfiguration = "relay_configuration"
|
static let relayConfiguration = "relay_configuration"
|
||||||
static let grantLifetime = "grant_lifetime"
|
static let grantLifetime = "grant_lifetime"
|
||||||
|
static let declinedPairingSuggestions = "declined_pairing_suggestions"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
||||||
@@ -163,13 +167,15 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
||||||
let grantLifetime = defaults.string(forKey: Key.grantLifetime)
|
let grantLifetime = defaults.string(forKey: Key.grantLifetime)
|
||||||
.flatMap(GrantLifetimeOption.init(rawValue:)) ?? .days90
|
.flatMap(GrantLifetimeOption.init(rawValue:)) ?? .days90
|
||||||
|
let declined = Set(defaults.stringArray(forKey: Key.declinedPairingSuggestions) ?? [])
|
||||||
return AppPreferences(
|
return AppPreferences(
|
||||||
username: username,
|
username: username,
|
||||||
receiveFolder: folder,
|
receiveFolder: folder,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
diagnosticsInstallId: installId,
|
diagnosticsInstallId: installId,
|
||||||
relayConfiguration: resolveRelayConfiguration(defaults),
|
relayConfiguration: resolveRelayConfiguration(defaults),
|
||||||
grantLifetime: grantLifetime
|
grantLifetime: grantLifetime,
|
||||||
|
declinedPairingSuggestions: declined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +221,22 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
setReceiveFolder(fallback.receiveFolder)
|
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) {
|
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
|
||||||
defaults.set(lifetime.rawValue, forKey: Key.grantLifetime)
|
defaults.set(lifetime.rawValue, forKey: Key.grantLifetime)
|
||||||
reload()
|
reload()
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ struct ContactPromptHost: View {
|
|||||||
let state: ContactsState
|
let state: ContactsState
|
||||||
let onPairingResponse: (String, Bool) -> Void
|
let onPairingResponse: (String, Bool) -> Void
|
||||||
let onOfferResponse: (String, Bool) -> Void
|
let onOfferResponse: (String, Bool) -> Void
|
||||||
|
let onSuggestionResponse: (PairingSuggestion, Bool) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Color.clear
|
Color.clear
|
||||||
.sheet(isPresented: $isPresented) {
|
.sheet(isPresented: $isPresented) {
|
||||||
// An incoming transfer is the more urgent of the two, and a
|
// Ordered by who is waiting: a sender is blocked on an offer, a
|
||||||
// pairing offer keeps until the consent window lapses.
|
// pairing request keeps until its consent window lapses, and a
|
||||||
|
// post-transfer suggestion has nobody waiting at all.
|
||||||
if let offer = state.currentOffer {
|
if let offer = state.currentOffer {
|
||||||
OfferSheet(
|
OfferSheet(
|
||||||
offer: offer,
|
offer: offer,
|
||||||
@@ -36,6 +38,16 @@ struct ContactPromptHost: View {
|
|||||||
)
|
)
|
||||||
.interactiveDismissDisabled(true)
|
.interactiveDismissDisabled(true)
|
||||||
.modifier(ContactPromptDetents())
|
.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)
|
.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 Combine
|
||||||
import Foundation
|
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 {
|
struct ContactsState: Equatable {
|
||||||
var contacts: [DeviceContact] = []
|
var contacts: [DeviceContact] = []
|
||||||
var blocked: [String] = []
|
var blocked: [String] = []
|
||||||
@@ -12,6 +31,7 @@ struct ContactsState: Equatable {
|
|||||||
/// blocking the rest of the list.
|
/// blocking the rest of the list.
|
||||||
var busyEndpoints: Set<String> = []
|
var busyEndpoints: Set<String> = []
|
||||||
var busyOfferIds: Set<String> = []
|
var busyOfferIds: Set<String> = []
|
||||||
|
var suggestions: [PairingSuggestion] = []
|
||||||
var selectedEndpointId: String?
|
var selectedEndpointId: String?
|
||||||
|
|
||||||
var selected: DeviceContact? {
|
var selected: DeviceContact? {
|
||||||
@@ -23,6 +43,7 @@ struct ContactsState: Equatable {
|
|||||||
/// sheets on top of each other reads as a loop of dialogs.
|
/// sheets on top of each other reads as a loop of dialogs.
|
||||||
var currentPairing: PendingPairingModel? { pendingPairings.first }
|
var currentPairing: PendingPairingModel? { pendingPairings.first }
|
||||||
var currentOffer: IncomingOfferModel? { pendingOffers.first }
|
var currentOffer: IncomingOfferModel? { pendingOffers.first }
|
||||||
|
var currentSuggestion: PairingSuggestion? { suggestions.first }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drives the device-history surfaces: the list, its detail, and the two
|
/// Drives the device-history surfaces: the list, its detail, and the two
|
||||||
@@ -54,12 +75,22 @@ final class ContactsModel: ObservableObject {
|
|||||||
Task { await self.refresh() }
|
Task { await self.refresh() }
|
||||||
case .offersChanged:
|
case .offersChanged:
|
||||||
Task { await self.refreshOffers() }
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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
|
repository.statePublisher
|
||||||
.map(\.isInitialized)
|
.map(\.isInitialized)
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
@@ -98,6 +129,73 @@ final class ContactsModel: ObservableObject {
|
|||||||
state.pendingOffers = await repository.pendingOffers()
|
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
|
// MARK: - Selection
|
||||||
|
|
||||||
func select(_ endpointId: String?) { state.selectedEndpointId = endpointId }
|
func select(_ endpointId: String?) { state.selectedEndpointId = endpointId }
|
||||||
|
|||||||
Reference in New Issue
Block a user