diff --git a/apple/VniDrop/App/AppGraph.swift b/apple/VniDrop/App/AppGraph.swift index bc41554..927dea5 100644 --- a/apple/VniDrop/App/AppGraph.swift +++ b/apple/VniDrop/App/AppGraph.swift @@ -12,6 +12,7 @@ final class AppGraph: ObservableObject { let preferencesRepository: AppPreferencesRepository let filePreviewRepository: FilePreviewRepository let approvalCoordinator: ApprovalCoordinator + let contactsModel: ContactsModel let transferNotificationCoordinator: TransferNotificationCoordinator let backgroundActivity: BackgroundActivityController @@ -27,6 +28,11 @@ final class AppGraph: ObservableObject { themeMode: .system ) ) + self.contactsModel = ContactsModel( + repository: coreRepository, + messages: messages, + preferences: preferencesRepository + ) self.approvalCoordinator = ApprovalCoordinator( repository: coreRepository, notifications: dependencies.notificationService, diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift index 5d2527a..78c7789 100644 --- a/apple/VniDrop/App/RootView.swift +++ b/apple/VniDrop/App/RootView.swift @@ -60,6 +60,11 @@ struct RootView: View { approvals: graph.approvalCoordinator, sendModel: sendModel ) + ContactPromptLayer( + contacts: graph.contactsModel, + receiveModel: receiveModel, + approvals: graph.approvalCoordinator + ) // Top-most so the toast is never covered by the approval overlay's // full-bleed clear layer. Observes the live `graph.messages` directly. SnackbarHost(controller: graph.messages) @@ -167,7 +172,8 @@ struct RootView: View { switch destination { case .send: SendScreen(model: sendModel, windowClass: windowClass) case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass) - case .settings: SettingsScreen(model: settingsModel, windowClass: windowClass) + case .settings: + SettingsScreen(model: settingsModel, contacts: graph.contactsModel, windowClass: windowClass) } } @@ -283,3 +289,49 @@ import UIKit #else import AppKit #endif + +/// Hosts the device-history consent prompts, alongside `ApprovalLayer`. +/// +/// Separate from the approval layer because the two never compete: an approval +/// belongs to a transfer this device is sending, and these belong to a device +/// asking to reach it. Both are suppressed while the other is up so the user is +/// never answering two modals at once. +private struct ContactPromptLayer: View { + @ObservedObject var contacts: ContactsModel + let receiveModel: ReceiveModel + @ObservedObject var approvals: ApprovalCoordinator + + @State private var showPrompt = false + + var body: some View { + ContactPromptHost( + isPresented: $showPrompt, + state: contacts.state, + onPairingResponse: { endpointId, accepted in + Task { await contacts.respondToPairing(endpointId: endpointId, accepted: accepted) } + }, + onOfferResponse: { offerId, accepted in + Task { + // The ticket is released only on acceptance; the receive then + // runs through the ordinary path so the platform picks the + // destination. + if let ticket = await contacts.respondToOffer(offerId: offerId, accepted: accepted) { + receiveModel.receiveOffered(ticket: ticket) + } + } + } + ) + .onChange(of: promptKey) { _, key in + showPrompt = key != nil + } + } + + /// One identity for "is there something to answer", so an offer replacing a + /// pairing prompt re-presents rather than silently swapping content. + private var promptKey: String? { + 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)" } + return nil + } +} diff --git a/apple/VniDrop/Features/Contacts/ContactPrompts.swift b/apple/VniDrop/Features/Contacts/ContactPrompts.swift new file mode 100644 index 0000000..1edfddc --- /dev/null +++ b/apple/VniDrop/Features/Contacts/ContactPrompts.swift @@ -0,0 +1,133 @@ +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 + + 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. + 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()) + } + } + } +} + +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: .laptopcomputerAndIphone) + .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) + } +} diff --git a/apple/VniDrop/Features/Contacts/ContactsScreen.swift b/apple/VniDrop/Features/Contacts/ContactsScreen.swift new file mode 100644 index 0000000..870d9d2 --- /dev/null +++ b/apple/VniDrop/Features/Contacts/ContactsScreen.swift @@ -0,0 +1,280 @@ +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 + + 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: contact.endpointId) { + ContactRow(contact: contact) + } + } + } + } + + 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) + } + } + + GrantLifetimeSection( + selection: model.state.grantLifetime, + onSelect: model.setGrantLifetime + ) + + if !model.state.contacts.isEmpty { + Section { + ForgetAllButton { Task { await model.forgetAll() } } + } + } + } + .formStyle(.grouped) + .navigationTitle(Text(String(localized: L10n.Contacts.title))) + .navigationDestination(for: String.self) { endpointId in + ContactDetailScreen(model: model, endpointId: endpointId) + } + .task { await model.refresh() } + } +} + +private struct ContactsEmptyState: View { + var body: some View { + VStack(spacing: 8) { + Image(systemSymbol: .laptopcomputerAndIphone) + .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 GrantLifetimeSection: View { + let selection: GrantLifetimeOption + let onSelect: (GrantLifetimeOption) -> Void + + var body: some View { + Section { + Picker( + String(localized: L10n.Contacts.grantLifetimeTitle), + selection: Binding(get: { selection }, set: onSelect) + ) { + 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. + LabeledContent(String(localized: L10n.Approval.nearbyDevice)) { + Text(contact.shortFingerprint) + .font(.callout.monospaced()) + } + } footer: { + Text(L10n.Approval.endpointId(deviceId: contact.endpointId)) + .font(.caption2) + .textSelection(.enabled) + } + + if !contact.canSend { + 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 ?? "")) + .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) } + } +} diff --git a/apple/VniDrop/Features/Receive/ReceiveModel.swift b/apple/VniDrop/Features/Receive/ReceiveModel.swift index 3927e92..94c1b18 100644 --- a/apple/VniDrop/Features/Receive/ReceiveModel.swift +++ b/apple/VniDrop/Features/Receive/ReceiveModel.swift @@ -6,6 +6,9 @@ enum ReceiveMethod { case invitationFile case qrCode case nfc + /// Pushed by a remembered device and already accepted by the user, so no + /// invitation was acquired by hand. + case offer } enum ReceiveHistoryDeleteTarget: Equatable { @@ -154,6 +157,40 @@ final class ReceiveModel: ObservableObject { } } + /// Receive a transfer the user has already accepted in the offer prompt. + /// + /// The consent happened in that prompt, so this does not ask again: it + /// inspects the ticket and starts, falling back to the ordinary review sheet + /// only when the destination is not usable and the user has to fix it. + func receiveOffered(ticket: String) { + let trimmed = ticket.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return messages.error(.resource(L10n.Error.invitationEmpty)) } + state.ticket = trimmed + state.method = .offer + state.inspection = nil + state.isInspecting = true + Task { + switch await repository.inspectTicket(trimmed) { + case .success(let inspection): + state.inspection = inspection + state.isInspecting = false + if state.canReceive(coreInitialized: coreState.isInitialized) { + receive() + } else { + // Usually a missing or unwritable destination: show the review + // sheet so the user can point it somewhere valid. + state.isAcquisitionOpen = true + } + case .failure(let error): + state.ticket = "" + state.method = nil + state.inspection = nil + state.isInspecting = false + messages.error(error) + } + } + } + func receive() { let current = state guard let folder = current.receiveFolder else { return } diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift index c3f26d0..2aab52e 100644 --- a/apple/VniDrop/Features/Settings/SettingsModel.swift +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -8,6 +8,7 @@ enum SettingsSection: Hashable { case appearance case notifications case network + case contacts case storage case about case bugReport @@ -19,6 +20,7 @@ enum SettingsSection: Hashable { case .appearance: return L10n.Appearance.title case .notifications: return L10n.Notifications.title case .network: return L10n.Settings.networkTitle + case .contacts: return L10n.Contacts.title case .storage: return L10n.Storage.title case .about: return L10n.About.title case .bugReport: return L10n.About.bugReport diff --git a/apple/VniDrop/Features/Settings/SettingsScreen.swift b/apple/VniDrop/Features/Settings/SettingsScreen.swift index 4d2945b..dc05ee5 100644 --- a/apple/VniDrop/Features/Settings/SettingsScreen.swift +++ b/apple/VniDrop/Features/Settings/SettingsScreen.swift @@ -5,6 +5,7 @@ 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 @@ -54,6 +55,15 @@ struct SettingsScreen: View { NavigationLink(value: SettingsSection.storage) { SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil) } + NavigationLink(value: SettingsSection.contacts) { + SettingsRow( + icon: .laptopcomputerAndIphone, + 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) { @@ -80,6 +90,17 @@ 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 section == .contacts { + ContactsScreen(model: contacts) + } else { + settingsSectionForm(section) + } + } + + @ViewBuilder + private func settingsSectionForm(_ section: SettingsSection) -> some View { let content = Form { SettingsSectionContent(model: model, section: section) } @@ -130,6 +151,9 @@ private struct SettingsSectionContent: View { NetworkSettings(model: model) case .storage: StorageSettings(model: model) + case .contacts: + // Rendered by SettingsScreen itself, which owns the contacts model. + EmptyView() case .about: AboutSettings(model: model) case .bugReport: