refactor(core): remove prototype contact paths for experimental saved devices

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-11 06:05:31 +02:00
parent 0a6ecb5c3b
commit a2385912c2
63 changed files with 514 additions and 7806 deletions

View File

@@ -1,185 +0,0 @@
import SFSafeSymbols
import SwiftUI
/// Consent prompts for device history, presented as sheets like the receiver
/// approval modal.
///
/// Both are dismissable by answering only. An incoming offer in particular must
/// not be acceptable by accident, and a swipe-away would leave the sender
/// waiting on a decision that never comes.
struct ContactPromptHost: View {
/// Driven by the host so a prompt is never presented while another sheet is
/// still animating out macOS silently drops the second one.
@Binding var isPresented: Bool
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) {
// 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,
busy: state.busyOfferIds.contains(offer.offerId),
onRespond: onOfferResponse
)
.interactiveDismissDisabled(true)
.modifier(ContactPromptDetents())
} else if let pairing = state.currentPairing {
PairingSheet(
pairing: pairing,
busy: state.busyEndpoints.contains(pairing.endpointId),
onRespond: onPairingResponse
)
.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())
}
}
}
}
private struct ContactPromptDetents: ViewModifier {
func body(content: Content) -> some View {
#if os(iOS)
content.presentationDetents([.medium])
#else
content.frame(minWidth: 420, minHeight: 300)
#endif
}
}
/// "A remembered device wants to send you files."
private struct OfferSheet: View {
let offer: IncomingOfferModel
let busy: Bool
let onRespond: (String, Bool) -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemSymbol: .trayAndArrowDownFill)
.font(.system(size: 44))
.foregroundStyle(.tint)
.padding(.top, 12)
Text(String(localized: L10n.Offer.title))
.font(.title2).fontWeight(.semibold)
Text(L10n.Offer.body(device: offer.resolvedSenderName, transferName: offer.transferName))
.multilineTextAlignment(.center)
Text(L10n.Transfer.fileCount(count: Int(offer.fileCount)))
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
HStack(spacing: 12) {
Button(role: .cancel) {
onRespond(offer.offerId, false)
} label: {
Text(String(localized: L10n.Offer.decline)).frame(maxWidth: .infinity)
}
Button {
onRespond(offer.offerId, true)
} label: {
Text(String(localized: L10n.Offer.accept)).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
.disabled(busy)
}
.padding(20)
}
}
/// "This device offered to let you reach it. Remember it?"
private struct PairingSheet: View {
let pairing: PendingPairingModel
let busy: Bool
let onRespond: (String, Bool) -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemSymbol: .macbookAndIphone)
.font(.system(size: 44))
.foregroundStyle(.tint)
.padding(.top, 12)
Text(String(localized: L10n.Pairing.requestTitle))
.font(.title2).fontWeight(.semibold)
Text(L10n.Pairing.requestBody(device: pairing.resolvedName))
.multilineTextAlignment(.center)
// Names are peer-supplied; the endpoint id is what actually identifies
// the device.
Text(L10n.Approval.endpointId(deviceId: pairing.endpointId))
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Spacer(minLength: 0)
HStack(spacing: 12) {
Button(role: .cancel) {
onRespond(pairing.endpointId, false)
} label: {
Text(String(localized: L10n.Pairing.decline)).frame(maxWidth: .infinity)
}
Button {
onRespond(pairing.endpointId, true)
} label: {
Text(String(localized: L10n.Pairing.accept)).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
.disabled(busy)
}
.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,451 +0,0 @@
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] = []
var pendingPairings: [PendingPairingModel] = []
var pendingOffers: [IncomingOfferModel] = []
var grantLifetime: GrantLifetimeOption = .days90
var isLoading = false
/// Endpoints with an in-flight decision, so a row can disable itself without
/// blocking the rest of the list.
var busyEndpoints: Set<String> = []
var busyOfferIds: Set<String> = []
var suggestions: [PairingSuggestion] = []
/// Transfers this device is holding for contacts that were not running.
var heldOffers: [HeldOfferModel] = []
var checkForOffersOnOpen = false
var isCheckingForOffers = false
var selectedEndpointId: String?
var selected: DeviceContact? {
guard let selectedEndpointId else { return nil }
return contacts.first { $0.endpointId == selectedEndpointId }
}
/// One prompt at a time: pairing consent is a modal decision and stacking
/// 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
/// consent prompts. Ported in the MVVM shape used by the other feature models.
@MainActor
final class ContactsModel: ObservableObject {
@Published private(set) var state = ContactsState()
/// Set when the detail screen asks for a file picker; the platform picker
/// modifier observes it, mirroring `SendModel`.
@Published var pendingFilePick = false
/// Device the picked files are destined for.
@Published private(set) var sendTarget: String?
private let repository: CoreGateway
private let messages: UiMessageController
private let preferences: AppPreferencesRepository
private let fileSystemService: FileSystemService
private var cancellables = Set<AnyCancellable>()
init(
repository: CoreGateway,
messages: UiMessageController,
preferences: AppPreferencesRepository,
fileSystemService: FileSystemService
) {
self.repository = repository
self.messages = messages
self.preferences = preferences
self.fileSystemService = fileSystemService
state.grantLifetime = preferences.preferences.grantLifetime
state.checkForOffersOnOpen = preferences.preferences.checkForOffersOnOpen
repository.signals
.sink { [weak self] signal in
guard let self else { return }
switch signal {
case .contactsChanged:
Task { await self.refresh() }
case .offersChanged:
Task { await self.refreshOffers() }
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()
.sink { [weak self] isInitialized in
guard let self, isInitialized else { return }
// The core owns the lifetime; push the stored preference on start
// so a restart does not silently fall back to the default.
Task {
await self.repository.setGrantLifetime(self.state.grantLifetime)
await self.refresh()
}
}
.store(in: &cancellables)
}
// MARK: - Loading
func refresh() async {
state.isLoading = true
defer { state.isLoading = false }
switch await repository.contacts() {
case .success(let contacts):
state.contacts = contacts
case .failure(let error):
messages.error(error)
}
if case .success(let blocked) = await repository.blockedContacts() {
state.blocked = blocked
}
if case .success(let held) = await repository.heldOffers() {
state.heldOffers = held
}
state.pendingPairings = await repository.pendingPairings()
await refreshOffers()
}
func refreshOffers() async {
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: - Collecting waiting transfers
func setCheckForOffersOnOpen(_ enabled: Bool) {
state.checkForOffersOnOpen = enabled
preferences.setCheckForOffersOnOpen(enabled)
}
/// Called when the app comes to the foreground.
///
/// Opt-in, because asking every contact whether they have something waiting
/// also tells them the app was opened. Never runs in the background.
func checkForOffersOnForeground() async {
guard state.checkForOffersOnOpen else { return }
_ = await collectWaitingOffers()
}
/// Explicit "check now". Returns how many transfers were collected so the
/// caller can report an empty result, which a silent refresh cannot.
@discardableResult
func collectWaitingOffers() async -> UInt64 {
guard !state.isCheckingForOffers else { return 0 }
state.isCheckingForOffers = true
defer { state.isCheckingForOffers = false }
switch await repository.pollContactsForOffers() {
case .success(let collected):
await refreshOffers()
return collected
case .failure(let error):
messages.error(error)
return 0
}
}
// MARK: - Selection
func select(_ endpointId: String?) { state.selectedEndpointId = endpointId }
// MARK: - Pairing consent
/// Agree to be reachable by a device, typically right after a transfer.
func allowDeviceToReachMe(endpointId: String, displayName: String?) async {
state.busyEndpoints.insert(endpointId)
defer { state.busyEndpoints.remove(endpointId) }
if case .failure(let error) = await repository.allowDeviceToReachMe(
endpointId: endpointId,
displayName: displayName
) {
messages.error(error)
return
}
await refresh()
}
/// Answer a device's offer to be remembered.
func respondToPairing(endpointId: String, accepted: Bool) async {
state.busyEndpoints.insert(endpointId)
defer { state.busyEndpoints.remove(endpointId) }
switch await repository.respondToPairing(endpointId: endpointId, accepted: accepted) {
case .success:
// Drop the prompt immediately: the core has already consumed it, and
// leaving it on screen invites a second answer that does nothing.
state.pendingPairings.removeAll { $0.endpointId == endpointId }
if accepted { await refresh() }
case .failure(let error):
messages.error(error)
}
}
// MARK: - Incoming offers
/// Answer an incoming offer. Returns the ticket when accepted so the caller
/// can run the receive with a platform-appropriate destination; the core
/// releases it only on acceptance.
func respondToOffer(offerId: String, accepted: Bool) async -> String? {
state.busyOfferIds.insert(offerId)
defer { state.busyOfferIds.remove(offerId) }
let ticket = await repository.respondToOffer(offerId: offerId, accepted: accepted)
state.pendingOffers.removeAll { $0.offerId == offerId }
return ticket
}
// MARK: - Sending to a device
/// Start choosing files to send to a remembered device.
func chooseFilesToSend(to endpointId: String) {
sendTarget = endpointId
pendingFilePick = true
}
func onFilePickFailed(_ reason: String) {
sendTarget = nil
messages.error(InvitationError.raw(reason))
}
/// Send the picked selection straight to the chosen device.
///
/// Only the receiving user is prompted; this call returns once they have
/// answered, so the button stays busy until then.
func onFilesPicked(_ files: [PickedShareFile]) async {
guard let endpointId = sendTarget else { return }
sendTarget = nil
guard !files.isEmpty else { return }
state.busyEndpoints.insert(endpointId)
defer { state.busyEndpoints.remove(endpointId) }
let result = await fileSystemService.sharePickedFiles(
repository: repository,
files: files,
transferName: files.count == 1 ? files[0].displayName : "",
senderName: preferences.preferences.username,
destination: .contact(endpointId: endpointId)
)
await fileSystemService.discardPickedFiles(files)
switch result {
case .success(let outcome):
// A closed app is a delay, not a failure: say so rather than
// reporting success for something nobody has received.
let text: UiText = outcome.delivered
? .resource(L10n.Send.transferCreated)
: .resource(L10n.Contacts.offerHeld)
messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
await refresh()
case .failure(let error):
messages.error(error)
}
}
/// Fire-and-report variant of ``offerTransfer(transferId:to:)``.
///
/// Owned by the model rather than a view so the request survives the picker
/// being dismissed: the answer depends on a person at the other device.
func offerTransferInBackground(transferId: UInt64, to contact: DeviceContact) {
Task { await offerTransfer(transferId: transferId, to: contact) }
}
/// Push an existing transfer to a remembered device.
///
/// Returns whether it landed, so the caller can distinguish "accepted" from
/// "waiting for that device to open the app".
@discardableResult
func offerTransfer(transferId: UInt64, to contact: DeviceContact) async -> Bool {
state.busyEndpoints.insert(contact.endpointId)
defer { state.busyEndpoints.remove(contact.endpointId) }
switch await repository.offerTransferToContact(
transferId: transferId,
endpointId: contact.endpointId
) {
case .success(let outcome):
let text: UiText = outcome.delivered
? .dynamic(L10n.Contacts.sentToDevice(device: contact.displayName))
: .resource(L10n.Contacts.offerHeld)
messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
await refresh()
return outcome.delivered
case .failure(let error) where error.offerRefusal != nil:
// The offer was delivered and a person said no, or nobody answered.
// Neither is a failure of this device, so neither is shown as one.
let text = error.offerRefusal == .declined
? L10n.Contacts.declinedByDevice(device: contact.displayName)
: L10n.Contacts.noAnswer(device: contact.displayName)
messages.tryShow(UiMessage(text: .dynamic(text), tone: .info))
await refresh()
return false
case .failure(let error):
messages.error(error)
return false
}
}
// MARK: - Management
func setLabel(endpointId: String, label: String) async {
let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines)
if case .failure(let error) = await repository.setContactLabel(
endpointId: endpointId,
label: trimmed.isEmpty ? nil : trimmed
) {
messages.error(error)
return
}
await refresh()
}
func forget(endpointId: String) async {
state.busyEndpoints.insert(endpointId)
defer { state.busyEndpoints.remove(endpointId) }
if case .failure(let error) = await repository.forgetContact(endpointId: endpointId) {
messages.error(error)
return
}
if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil }
await refresh()
}
func forgetAll() async {
if case .failure(let error) = await repository.forgetAllContacts() {
messages.error(error)
return
}
state.selectedEndpointId = nil
await refresh()
}
func block(endpointId: String) async {
state.busyEndpoints.insert(endpointId)
defer { state.busyEndpoints.remove(endpointId) }
if case .failure(let error) = await repository.blockContact(endpointId: endpointId) {
messages.error(error)
return
}
if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil }
await refresh()
}
func unblock(endpointId: String) async {
if case .failure(let error) = await repository.unblockContact(endpointId: endpointId) {
messages.error(error)
return
}
await refresh()
}
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
state.grantLifetime = lifetime
preferences.setGrantLifetime(lifetime)
Task { await repository.setGrantLifetime(lifetime) }
}
}

View File

@@ -1,344 +0,0 @@
import SFSafeSymbols
import SwiftUI
/// Device history: the remembered devices, their detail, and the block list.
///
/// Pushed from Settings rather than owning a tab it is a management surface,
/// not part of the send/receive flow.
struct ContactsScreen: View {
@ObservedObject var model: ContactsModel
/// Reports an empty result, which a silent refresh cannot convey.
let onNothingWaiting: () -> Void
var body: some View {
Form {
Section {
Text(String(localized: L10n.Contacts.subtitle))
.font(.footnote)
.foregroundStyle(.secondary)
}
if model.state.contacts.isEmpty {
Section {
ContactsEmptyState()
}
} else {
Section(String(localized: L10n.Contacts.title)) {
ForEach(model.state.contacts) { contact in
NavigationLink(value: SettingsSection.contactDetail(endpointId: contact.endpointId)) {
ContactRow(contact: contact)
}
}
}
}
if !model.state.heldOffers.isEmpty {
Section(String(localized: L10n.Contacts.waitingTitle)) {
ForEach(model.state.heldOffers) { offer in
VStack(alignment: .leading, spacing: 2) {
Text(offer.transferName)
Text(String(offer.endpointId.prefix(16)))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
Text(String(localized: L10n.Contacts.waitingHint))
.font(.footnote)
.foregroundStyle(.secondary)
}
}
if !model.state.blocked.isEmpty {
Section(String(localized: L10n.Contacts.blockedTitle)) {
ForEach(model.state.blocked, id: \.self) { endpointId in
BlockedRow(endpointId: endpointId) {
Task { await model.unblock(endpointId: endpointId) }
}
}
Text(String(localized: L10n.Contacts.unblockHint))
.font(.footnote)
.foregroundStyle(.secondary)
}
}
CollectOffersSection(model: model, onNothingWaiting: onNothingWaiting)
GrantLifetimeSection(model: model)
if !model.state.contacts.isEmpty {
Section {
ForgetAllButton { Task { await model.forgetAll() } }
}
}
}
.formStyle(.grouped)
.navigationTitle(Text(String(localized: L10n.Contacts.title)))
.task { await model.refresh() }
}
}
private struct ContactsEmptyState: View {
var body: some View {
VStack(spacing: 8) {
Image(systemSymbol: .macbookAndIphone)
.font(.system(size: 32))
.foregroundStyle(.tint)
Text(String(localized: L10n.Contacts.emptyTitle))
.font(.headline)
Text(String(localized: L10n.Contacts.emptyBody))
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
}
private struct ContactRow: View {
let contact: DeviceContact
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(contact.displayName)
if contact.canSend {
if let lastTransferAt = contact.lastTransferAt {
Text(L10n.Contacts.lastTransfer(date: Self.format(lastTransferAt)))
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
// Reachability is derived from holding a live grant, so this is
// the honest signal that sending will not work.
Label(
String(localized: L10n.Contacts.unreachable),
systemSymbol: .exclamationmarkTriangleFill
)
.font(.caption)
.foregroundStyle(.orange)
}
}
}
private static func format(_ millis: Int64) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(millis) / 1_000)
return date.formatted(.relative(presentation: .named))
}
}
private struct BlockedRow: View {
let endpointId: String
let onUnblock: () -> Void
var body: some View {
HStack {
Text(String(endpointId.prefix(16)))
.font(.callout.monospaced())
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Button(String(localized: L10n.Contacts.unblock), action: onUnblock)
.buttonStyle(.borderless)
}
}
}
private struct CollectOffersSection: View {
@ObservedObject var model: ContactsModel
let onNothingWaiting: () -> Void
var body: some View {
Section {
Toggle(
String(localized: L10n.Contacts.checkOnOpen),
isOn: Binding(
get: { model.state.checkForOffersOnOpen },
set: { model.setCheckForOffersOnOpen($0) }
)
)
Button {
Task {
let collected = await model.collectWaitingOffers()
if collected == 0 { onNothingWaiting() }
}
} label: {
HStack {
Text(String(localized: L10n.Contacts.checkNow))
if model.state.isCheckingForOffers {
Spacer()
ProgressView().controlSize(.small)
}
}
}
.disabled(model.state.isCheckingForOffers)
} footer: {
// The privacy cost is the point of the setting, so it is stated
// where the switch is, not buried elsewhere.
Text(String(localized: L10n.Contacts.checkOnOpenHint))
}
}
}
private struct GrantLifetimeSection: View {
@ObservedObject var model: ContactsModel
var body: some View {
Section {
Picker(
String(localized: L10n.Contacts.grantLifetimeTitle),
selection: Binding(
get: { model.state.grantLifetime },
set: { model.setGrantLifetime($0) }
)
) {
ForEach(GrantLifetimeOption.allCases) { option in
Text(Self.label(option)).tag(option)
}
}
Text(String(localized: L10n.Contacts.grantLifetimeHint))
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private static func label(_ option: GrantLifetimeOption) -> String {
guard let days = option.days else {
return String(localized: L10n.Contacts.grantLifetimeNever)
}
return L10n.Contacts.grantLifetimeDays(count: days)
}
}
private struct ForgetAllButton: View {
let onConfirm: () -> Void
@State private var isConfirming = false
var body: some View {
Button(role: .destructive) {
isConfirming = true
} label: {
Text(String(localized: L10n.Contacts.forgetAll))
}
.confirmationDialog(
String(localized: L10n.Contacts.forgetAll),
isPresented: $isConfirming,
titleVisibility: .visible
) {
Button(String(localized: L10n.Contacts.forgetAll), role: .destructive, action: onConfirm)
} message: {
Text(String(localized: L10n.Contacts.forgetBody))
}
}
}
/// Detail for one remembered device: rename, send, forget, block.
struct ContactDetailScreen: View {
@ObservedObject var model: ContactsModel
let endpointId: String
@State private var label = ""
@State private var isConfirmingForget = false
@State private var isConfirmingBlock = false
private var contact: DeviceContact? {
model.state.contacts.first { $0.endpointId == endpointId }
}
var body: some View {
Form {
if let contact {
Section {
TextField(
String(localized: L10n.Contacts.nameField),
text: $label,
prompt: Text(contact.displayName)
)
.onSubmit { commitLabel() }
Text(String(localized: L10n.Contacts.nameHint))
.font(.footnote)
.foregroundStyle(.secondary)
}
Section {
// The endpoint id is the only real identity: two devices can
// claim the same name, but not the same key. Shown in full
// and selectable so it can actually be compared.
Text(L10n.Approval.endpointId(deviceId: contact.endpointId))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if contact.canSend {
Section {
Button {
model.chooseFilesToSend(to: endpointId)
} label: {
Label(
String(localized: L10n.Contacts.sendTo),
systemSymbol: .paperplane
)
}
.disabled(model.state.busyEndpoints.contains(endpointId))
}
} else {
Section {
Label(
String(localized: L10n.Contacts.unreachableBody),
systemSymbol: .exclamationmarkTriangleFill
)
.font(.footnote)
}
}
Section {
Button(role: .destructive) {
isConfirmingForget = true
} label: {
Text(String(localized: L10n.Contacts.forget))
}
Button(role: .destructive) {
isConfirmingBlock = true
} label: {
Text(String(localized: L10n.Contacts.block))
}
}
.disabled(model.state.busyEndpoints.contains(endpointId))
}
}
.formStyle(.grouped)
.navigationTitle(Text(contact?.displayName ?? ""))
.contactSendPickers(model: model)
.onAppear { label = contact?.localLabel ?? "" }
.onDisappear { commitLabel() }
.confirmationDialog(
String(localized: L10n.Contacts.forget),
isPresented: $isConfirmingForget,
titleVisibility: .visible
) {
Button(String(localized: L10n.Contacts.forget), role: .destructive) {
Task { await model.forget(endpointId: endpointId) }
}
} message: {
Text(String(localized: L10n.Contacts.forgetBody))
}
.confirmationDialog(
String(localized: L10n.Contacts.block),
isPresented: $isConfirmingBlock,
titleVisibility: .visible
) {
Button(String(localized: L10n.Contacts.block), role: .destructive) {
Task { await model.block(endpointId: endpointId) }
}
} message: {
Text(String(localized: L10n.Contacts.unblockHint))
}
}
private func commitLabel() {
guard label != (contact?.localLabel ?? "") else { return }
Task { await model.setLabel(endpointId: endpointId, label: label) }
}
}

View File

@@ -1,73 +0,0 @@
import SFSafeSymbols
import SwiftUI
/// Picks a remembered device to send an existing transfer to.
///
/// Offered next to the QR code as another way to deliver the same invitation,
/// not as a second share of the same files.
struct DevicePickerSheet: View {
@ObservedObject var model: ContactsModel
let transferId: UInt64
@Environment(\.dismiss) private var dismiss
/// Only devices holding a live grant: the rest cannot be reached until they
/// are paired again, so offering them here would fail on tap.
private var reachable: [DeviceContact] {
model.state.contacts.filter(\.canSend)
}
var body: some View {
NavigationStack {
Group {
if reachable.isEmpty {
ContentUnavailableView {
Label(
String(localized: L10n.Contacts.pickDeviceTitle),
systemSymbol: .macbookAndIphone
)
} description: {
Text(String(localized: L10n.Contacts.pickDeviceEmpty))
}
} else {
List(reachable) { contact in
Button {
// Close first. The other device's user has to accept,
// which can take as long as they take, and holding a
// modal open on someone else's decision reads as a
// hang. The outcome arrives as a message instead.
dismiss()
model.offerTransferInBackground(transferId: transferId, to: contact)
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(contact.displayName)
Text(contact.shortFingerprint)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Spacer()
if model.state.busyEndpoints.contains(contact.endpointId) {
ProgressView().controlSize(.small)
}
}
}
.disabled(model.state.busyEndpoints.contains(contact.endpointId))
}
}
}
.navigationTitle(Text(String(localized: L10n.Contacts.pickDeviceTitle)))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: L10n.Button.cancel)) { dismiss() }
}
}
}
.task { await model.refresh() }
#if os(macOS)
.frame(minWidth: 380, minHeight: 320)
#endif
}
}

View File

@@ -111,7 +111,7 @@ final class TransferNotificationCoordinator: ObservableObject {
switch signal {
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
Task { await self.syncReceivers(transferId: transferId) }
case .approvalChanged, .contactsChanged, .offersChanged:
case .approvalChanged, .transfersChanged:
break
}
}

View File

@@ -96,8 +96,6 @@ final class SendModel: ObservableObject {
case .receiverHistoryChanged(let id), .approvalChanged(let id):
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
self.refreshReceiverStatuses(for: id)
case .contactsChanged, .offersChanged:
break
}
}
.store(in: &cancellables)

View File

@@ -5,7 +5,6 @@ import SFSafeSymbols
/// with the composer and detail panels as native sheets and delete as an alert.
struct SendScreen: View {
@ObservedObject var model: SendModel
@ObservedObject var contacts: ContactsModel
let windowClass: WindowClass
/// Transfer pending an inline (list-level) delete confirmation.
@@ -61,7 +60,7 @@ struct SendScreen: View {
onDismissed: model.shareSheetDidDismiss
) {
if let shareTarget {
TransferSharePanel(model: model, contacts: contacts, transfer: shareTarget)
TransferSharePanel(model: model, transfer: shareTarget)
}
}
}
@@ -94,7 +93,7 @@ struct SendScreen: View {
/// 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).
private func detailView(for transfer: Transfer) -> some View {
TransferDetailsView(model: model, contacts: contacts, transfer: transfer, events: model.coreState.events)
TransferDetailsView(model: model, transfer: transfer, events: model.coreState.events)
.adaptiveDrawer(
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
windowClass: windowClass,
@@ -102,7 +101,7 @@ struct SendScreen: View {
onDismissed: model.shareSheetDidDismiss
) {
if let panel = model.state.detailPanel {
DetailPanelContent(model: model, contacts: contacts, transfer: transfer, panel: panel)
DetailPanelContent(model: model, transfer: transfer, panel: panel)
}
}
.alert(

View File

@@ -6,7 +6,6 @@ import CoreImage.CIFilterBuiltins
struct TransferDetailsView: View {
@ObservedObject var model: SendModel
@ObservedObject var contacts: ContactsModel
let transfer: Transfer
let events: [CoreEventModel]
@State private var showStopConfirmation = false
@@ -130,7 +129,6 @@ private struct DetailDestination: View {
struct DetailPanelContent: View {
@ObservedObject var model: SendModel
@ObservedObject var contacts: ContactsModel
let transfer: Transfer
let panel: TransferDetailPanel
@@ -148,7 +146,7 @@ struct DetailPanelContent: View {
onAccept: model.acceptReceiver
)
case .share:
TransferSharePanel(model: model, contacts: contacts, transfer: transfer)
TransferSharePanel(model: model, transfer: transfer)
}
}
}
@@ -298,7 +296,6 @@ private struct ReceiverRow: View {
struct TransferSharePanel: View {
@Environment(\.vniColors) private var colors
@ObservedObject var model: SendModel
@ObservedObject var contacts: ContactsModel
let transfer: Transfer
var body: some View {
@@ -312,7 +309,7 @@ struct TransferSharePanel: View {
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
.frame(maxWidth: .infinity)
}
ShareActionsView(model: model, contacts: contacts, transfer: transfer, ticket: ticket)
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
case .preparing:
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
case .unavailable:

View File

@@ -20,25 +20,14 @@ protocol TransferShareActions: AnyObject {
struct ShareActionsView: View {
@Environment(\.vniColors) private var colors
@ObservedObject var model: SendModel
@ObservedObject var contacts: ContactsModel
let transfer: Transfer
let ticket: String
@State private var actions: TransferShareActions = makePlatformShareActions()
@State private var writingNfc = false
@State private var choosingDevice = false
var body: some View {
VStack(spacing: 12) {
// Sending straight to a remembered device is another way to deliver
// this same invitation, so it belongs with the other delivery
// methods rather than in a separate flow.
if contacts.state.contacts.contains(where: \.canSend) {
SecondaryButton(
title: String(localized: L10n.Contacts.sendToDevice),
action: { choosingDevice = true }
)
}
if actions.nfcAvailability != .hidden {
SecondaryButton(
title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc),
@@ -68,8 +57,5 @@ struct ShareActionsView: View {
}, enabled: actions.canUseNativeShare)
}
.onDisappear { actions.cancelNfcWrite() }
.sheet(isPresented: $choosingDevice) {
DevicePickerSheet(model: contacts, transferId: transfer.transferId)
}
}
}

View File

@@ -8,10 +8,6 @@ enum SettingsSection: Hashable {
case appearance
case notifications
case network
case contacts
/// One device's detail. Part of this enum because the Settings stack has a
/// typed path: a link carrying any other value type cannot push onto it.
case contactDetail(endpointId: String)
case storage
case about
case bugReport
@@ -23,7 +19,6 @@ enum SettingsSection: Hashable {
case .appearance: return L10n.Appearance.title
case .notifications: return L10n.Notifications.title
case .network: return L10n.Settings.networkTitle
case .contacts, .contactDetail: return L10n.Contacts.title
case .storage: return L10n.Storage.title
case .about: return L10n.About.title
case .bugReport: return L10n.About.bugReport
@@ -179,11 +174,6 @@ final class SettingsModel: ObservableObject {
loadDeviceInfo()
}
/// Surfaces "nothing waiting" from the contacts screen, which has no
/// message controller of its own.
func reportNothingWaiting() {
messages.tryShow(UiMessage(text: .resource(L10n.Contacts.checkNone), tone: .info))
}
func selectSection(_ section: SettingsSection) {
state.selectedSection = section

View File

@@ -5,7 +5,6 @@ import SFSafeSymbols
/// navigation. The model stays the source of truth via a derived path binding.
struct SettingsScreen: View {
@ObservedObject var model: SettingsModel
@ObservedObject var contacts: ContactsModel
let windowClass: WindowClass
@State private var showBugReport = false
@@ -15,8 +14,6 @@ struct SettingsScreen: View {
switch model.state.selectedSection {
case .overview: return []
case .bugReport: return [.about, .bugReport]
case .contactDetail(let endpointId):
return [.contacts, .contactDetail(endpointId: endpointId)]
case let section: return [section]
}
},
@@ -57,15 +54,6 @@ struct SettingsScreen: View {
NavigationLink(value: SettingsSection.storage) {
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
}
NavigationLink(value: SettingsSection.contacts) {
SettingsRow(
icon: .macbookAndIphone,
title: String(localized: L10n.Contacts.title),
value: contacts.state.contacts.isEmpty
? nil
: String(contacts.state.contacts.count)
)
}
}
Section(String(localized: L10n.Settings.advancedTitle)) {
NavigationLink(value: SettingsSection.network) {
@@ -92,17 +80,7 @@ struct SettingsScreen: View {
@ViewBuilder
private func sectionForm(_ section: SettingsSection) -> some View {
// Contacts brings its own Form and push destination, so it is not wrapped
// in the shared section chrome.
if case .contactDetail(let endpointId) = section {
ContactDetailScreen(model: contacts, endpointId: endpointId)
} else if section == .contacts {
ContactsScreen(model: contacts) {
model.reportNothingWaiting()
}
} else {
settingsSectionForm(section)
}
settingsSectionForm(section)
}
@ViewBuilder
@@ -157,9 +135,6 @@ private struct SettingsSectionContent: View {
NetworkSettings(model: model)
case .storage:
StorageSettings(model: model)
case .contacts, .contactDetail:
// Rendered by SettingsScreen itself, which owns the contacts model.
EmptyView()
case .about:
AboutSettings(model: model)
case .bugReport: