mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 19:29:57 +02:00
feat(apple): add the contacts feature model
MVVM model for the device list, its detail, and the two consent prompts. Accepting an offer is the only path that returns a ticket, matching the core: a declined offer hands over nothing. Grant lifetime lives in preferences because the core holds it in memory only, so it is pushed back on every start rather than silently reverting to the default.
This commit is contained in:
225
apple/Tests/ContactsModelTests.swift
Normal file
225
apple/Tests/ContactsModelTests.swift
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ContactsModelTests: XCTestCase {
|
||||||
|
private func makeModel(
|
||||||
|
_ gateway: FakeCoreGateway
|
||||||
|
) -> (ContactsModel, AppPreferencesRepository) {
|
||||||
|
let defaults = UserDefaults(suiteName: "contacts-tests-\(UUID().uuidString)")!
|
||||||
|
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 contact(
|
||||||
|
_ endpointId: String,
|
||||||
|
label: String? = nil,
|
||||||
|
remoteName: String? = nil,
|
||||||
|
canSend: Bool = true
|
||||||
|
) -> DeviceContact {
|
||||||
|
DeviceContact(
|
||||||
|
endpointId: endpointId,
|
||||||
|
localLabel: label,
|
||||||
|
remoteDisplayName: remoteName,
|
||||||
|
lastTransferAt: nil,
|
||||||
|
createdAt: 0,
|
||||||
|
canSend: canSend
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func offer(_ offerId: String, from endpointId: String = "peer") -> IncomingOfferModel {
|
||||||
|
IncomingOfferModel(
|
||||||
|
offerId: offerId,
|
||||||
|
fromEndpointId: endpointId,
|
||||||
|
senderDisplayName: "Peer",
|
||||||
|
transferName: "photos",
|
||||||
|
fileCount: 2,
|
||||||
|
totalBytes: 1_024,
|
||||||
|
receivedAt: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRefreshLoadsContactsBlocksAndPrompts() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.contactsResult = .success([contact("a"), contact("b")])
|
||||||
|
gateway.blockedResult = .success(["blocked-one"])
|
||||||
|
gateway.pairings = [PendingPairingModel(endpointId: "c", displayName: "Laptop", receivedAt: 0)]
|
||||||
|
gateway.offers = [offer("offer-1")]
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
XCTAssertEqual(model.state.contacts.count, 2)
|
||||||
|
XCTAssertEqual(model.state.blocked, ["blocked-one"])
|
||||||
|
XCTAssertEqual(model.state.currentPairing?.endpointId, "c")
|
||||||
|
XCTAssertEqual(model.state.currentOffer?.offerId, "offer-1")
|
||||||
|
XCTAssertFalse(model.state.isLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepting an offer is the only path that yields a ticket; the caller needs
|
||||||
|
/// it to run the receive with its own destination.
|
||||||
|
func testAcceptingAnOfferReturnsTheTicket() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.offers = [offer("offer-1")]
|
||||||
|
gateway.offerTicket = "vnd1:abc"
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
let ticket = await model.respondToOffer(offerId: "offer-1", accepted: true)
|
||||||
|
|
||||||
|
XCTAssertEqual(ticket, "vnd1:abc")
|
||||||
|
XCTAssertTrue(model.state.pendingOffers.isEmpty)
|
||||||
|
XCTAssertEqual(gateway.offerResponses.map(\.accepted), [true])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecliningAnOfferYieldsNoTicketAndClearsThePrompt() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.offers = [offer("offer-1")]
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
let ticket = await model.respondToOffer(offerId: "offer-1", accepted: false)
|
||||||
|
|
||||||
|
XCTAssertNil(ticket, "a declined offer must not hand over a capability")
|
||||||
|
XCTAssertTrue(model.state.pendingOffers.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declining to be remembered must leave nothing behind for the peer.
|
||||||
|
func testDecliningPairingClearsThePromptWithoutAddingAContact() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.pairings = [PendingPairingModel(endpointId: "peer", displayName: nil, receivedAt: 0)]
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
await model.respondToPairing(endpointId: "peer", accepted: false)
|
||||||
|
|
||||||
|
XCTAssertTrue(model.state.pendingPairings.isEmpty)
|
||||||
|
XCTAssertTrue(model.state.contacts.isEmpty)
|
||||||
|
XCTAssertEqual(gateway.pairingResponses.map(\.accepted), [false])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAcceptingPairingAddsTheContact() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.pairings = [PendingPairingModel(endpointId: "peer", displayName: "Laptop", receivedAt: 0)]
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
await model.refresh()
|
||||||
|
gateway.contactsResult = .success([contact("peer", remoteName: "Laptop")])
|
||||||
|
|
||||||
|
await model.respondToPairing(endpointId: "peer", accepted: true)
|
||||||
|
|
||||||
|
XCTAssertTrue(model.state.pendingPairings.isEmpty)
|
||||||
|
XCTAssertEqual(model.state.contacts.map(\.endpointId), ["peer"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testForgettingClearsTheSelectionAndReloads() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.contactsResult = .success([contact("peer")])
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
await model.refresh()
|
||||||
|
model.select("peer")
|
||||||
|
|
||||||
|
gateway.contactsResult = .success([])
|
||||||
|
await model.forget(endpointId: "peer")
|
||||||
|
|
||||||
|
XCTAssertEqual(gateway.forgottenContacts, ["peer"])
|
||||||
|
XCTAssertNil(model.state.selectedEndpointId)
|
||||||
|
XCTAssertTrue(model.state.contacts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBlockingRemovesTheContactAndKeepsItListedAsBlocked() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.contactsResult = .success([contact("peer")])
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
await model.refresh()
|
||||||
|
model.select("peer")
|
||||||
|
|
||||||
|
gateway.contactsResult = .success([])
|
||||||
|
gateway.blockedResult = .success(["peer"])
|
||||||
|
await model.block(endpointId: "peer")
|
||||||
|
|
||||||
|
XCTAssertEqual(gateway.blockedContactIds, ["peer"])
|
||||||
|
XCTAssertNil(model.state.selectedEndpointId)
|
||||||
|
XCTAssertEqual(model.state.blocked, ["peer"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty label clears the override rather than storing whitespace, so the
|
||||||
|
/// row falls back to the name the device reports.
|
||||||
|
func testBlankLabelClearsTheLocalName() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
await model.setLabel(endpointId: "peer", label: " ")
|
||||||
|
|
||||||
|
XCTAssertEqual(gateway.contactLabels.count, 1)
|
||||||
|
XCTAssertNil(gateway.contactLabels[0].label)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLabelIsTrimmedBeforeStoring() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
await model.setLabel(endpointId: "peer", label: " Work Mac ")
|
||||||
|
|
||||||
|
XCTAssertEqual(gateway.contactLabels[0].label, "Work Mac")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The core holds the lifetime in memory only, so the stored preference is
|
||||||
|
/// the durable copy and both have to move together.
|
||||||
|
func testGrantLifetimeIsPersistedAndPushedToTheCore() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, preferences) = makeModel(gateway)
|
||||||
|
|
||||||
|
model.setGrantLifetime(.days365)
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
XCTAssertEqual(model.state.grantLifetime, .days365)
|
||||||
|
XCTAssertEqual(preferences.preferences.grantLifetime, .days365)
|
||||||
|
XCTAssertEqual(gateway.grantLifetimes.last, .days365)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDefaultGrantLifetimeIsNinetyDays() {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
XCTAssertEqual(model.state.grantLifetime, .days90)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local label wins over whatever the peer calls itself.
|
||||||
|
func testDisplayNamePrefersTheLocalLabel() {
|
||||||
|
let subject = contact("peer", label: "Work Mac", remoteName: "Totally Not Evil")
|
||||||
|
|
||||||
|
XCTAssertEqual(subject.displayName, "Work Mac")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDisplayNameFallsBackToTheReportedName() {
|
||||||
|
let subject = contact("peer", remoteName: "Laptop")
|
||||||
|
|
||||||
|
XCTAssertEqual(subject.displayName, "Laptop")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnreachableContactIsSurfacedForRepairing() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.contactsResult = .success([contact("peer", canSend: false)])
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
XCTAssertEqual(model.state.contacts.first?.canSend, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,8 @@ struct AppPreferences: Equatable {
|
|||||||
var themeMode: ThemeMode
|
var themeMode: ThemeMode
|
||||||
var diagnosticsInstallId: String
|
var diagnosticsInstallId: String
|
||||||
var relayConfiguration: RelayConfiguration
|
var relayConfiguration: RelayConfiguration
|
||||||
|
/// Idle lifetime applied to grants this device issues from now on.
|
||||||
|
var grantLifetime: GrantLifetimeOption
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AppPreferencesDefaults {
|
struct AppPreferencesDefaults {
|
||||||
@@ -145,6 +147,7 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
static let themeMode = "theme_mode"
|
static let themeMode = "theme_mode"
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
||||||
@@ -158,12 +161,15 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
||||||
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
||||||
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
||||||
|
let grantLifetime = defaults.string(forKey: Key.grantLifetime)
|
||||||
|
.flatMap(GrantLifetimeOption.init(rawValue:)) ?? .days90
|
||||||
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +215,11 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
setReceiveFolder(fallback.receiveFolder)
|
setReceiveFolder(fallback.receiveFolder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
|
||||||
|
defaults.set(lifetime.rawValue, forKey: Key.grantLifetime)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
func setThemeMode(_ mode: ThemeMode) {
|
func setThemeMode(_ mode: ThemeMode) {
|
||||||
defaults.set(mode.rawValue, forKey: Key.themeMode)
|
defaults.set(mode.rawValue, forKey: Key.themeMode)
|
||||||
reload()
|
reload()
|
||||||
|
|||||||
212
apple/VniDrop/Features/Contacts/ContactsModel.swift
Normal file
212
apple/VniDrop/Features/Contacts/ContactsModel.swift
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
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 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
|
||||||
|
private let repository: CoreGateway
|
||||||
|
private let messages: UiMessageController
|
||||||
|
private let preferences: AppPreferencesRepository
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
repository: CoreGateway,
|
||||||
|
messages: UiMessageController,
|
||||||
|
preferences: AppPreferencesRepository
|
||||||
|
) {
|
||||||
|
self.repository = repository
|
||||||
|
self.messages = messages
|
||||||
|
self.preferences = preferences
|
||||||
|
state.grantLifetime = preferences.preferences.grantLifetime
|
||||||
|
|
||||||
|
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 .approvalChanged, .receiverHistoryChanged, .transfersChanged:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
state.pendingPairings = await repository.pendingPairings()
|
||||||
|
await refreshOffers()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshOffers() async {
|
||||||
|
state.pendingOffers = await repository.pendingOffers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: - 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user