diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 592ad2e041..f49cd101be 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -849,7 +849,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case invitation(user: UserRef, connLinkInvitation: CreatedConnLink, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef) - case connectionPlan(user: UserRef, connLink: CreatedConnLink, planSimplexName: SimplexNameInfo?, otherSimplexName: SimplexNameInfo?, connectionPlan: ConnectionPlan) + case connectionPlan(user: UserRef, connLink: CreatedConnLink?, planSimplexName: SimplexNameInfo?, otherSimplexName: SimplexNameInfo?, connectionPlan: ConnectionPlan) case newPreparedChat(user: UserRef, chat: ChatData) case contactUserChanged(user: UserRef, fromContact: Contact, newUser: UserRef, toContact: Contact) case groupUserChanged(user: UserRef, fromGroup: GroupInfo, newUser: UserRef, toGroup: GroupInfo) @@ -1437,7 +1437,7 @@ enum OwnerVerification: Decodable, Hashable { } struct ConnectionPlanResult { - var connLink: CreatedConnLink + var connLink: CreatedConnLink? var planSimplexName: SimplexNameInfo? var otherSimplexName: SimplexNameInfo? var connectionPlan: ConnectionPlan @@ -1448,13 +1448,93 @@ enum PlanResolveMode: String { case allGroups case unknown case never + case all } enum ConnectionPlan: Decodable, Hashable { case invitationLink(invitationLinkPlan: InvitationLinkPlan) - case contactAddress(contactAddressPlan: ContactAddressPlan) - case groupLink(groupLinkPlan: GroupLinkPlan) + case contactAddress(contactAddressPlan: ContactAddressPlan, nameRegistration_: NameRegistration?) + case groupLink(groupLinkPlan: GroupLinkPlan, nameRegistration_: NameRegistration?) + // the name resolved but there is nothing to connect to, and no local chat claims it + case nameNotConnectable(simplexDomain: SimplexDomain, nameRegistration: NameRegistration) case error(chatError: ChatError) + + // the registry answer this plan was built from, when the target was a name + var nameRegistration: NameRegistration? { + switch self { + case let .contactAddress(_, reg): reg + case let .groupLink(_, reg): reg + case let .nameNotConnectable(_, reg): reg + default: nil + } + } +} + +// What the registry holds for a name. This JSON is the RNAME payload, so it is "type"-tagged on +// every platform, and the unset text fields of the record are not read here. +enum NameRegistration: Hashable { + // held by someone; expires/graceUntil are absent from an older router, which means "not known", not "live forever" + case registered(expires: Int64?, graceUntil: Int64?, reservedReason_: String?) + case available(pricing: NamePricing) + case reserved(reservedReason: String) + + // the registry may add reasons after this version, so any other value is just "not registrable" + static let reservedCommunity = "community" + + enum CodingKeys: String, CodingKey { + case type + case expires + case graceUntil + case reservedReason_ + case pricing + case reservedReason + } + + // a name past its expiry does not connect: only its owner can renew it until the grace ends + func expired(_ now: Int64) -> Bool { + if case let .registered(expires, _, _) = self, let expires { expires < now } else { false } + } + + var reservedForCommunity: Bool { + switch self { + case let .reserved(reason): reason == NameRegistration.reservedCommunity + case let .registered(_, _, reason): reason == NameRegistration.reservedCommunity + case .available: false + } + } +} + +extension NameRegistration: Decodable { + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let type = try c.decode(String.self, forKey: CodingKeys.type) + switch type { + case "registered": + let expires = try c.decodeIfPresent(Int64.self, forKey: CodingKeys.expires) + let graceUntil = try c.decodeIfPresent(Int64.self, forKey: CodingKeys.graceUntil) + let reservedReason_ = try c.decodeIfPresent(String.self, forKey: CodingKeys.reservedReason_) + self = .registered(expires: expires, graceUntil: graceUntil, reservedReason_: reservedReason_) + case "available": + let pricing = try c.decode(NamePricing.self, forKey: CodingKeys.pricing) + self = .available(pricing: pricing) + case "reserved": + let reservedReason = try c.decode(String.self, forKey: CodingKeys.reservedReason) + self = .reserved(reservedReason: reservedReason) + default: + throw DecodingError.dataCorruptedError(forKey: CodingKeys.type, in: c, debugDescription: "Unsupported name registration type: \(type)") + } + } +} + +// enough to price a name locally: US cents per year, and the shortest label the registry accepts +struct NamePricing: Decodable, Hashable { + var registrationPrices: [String: Int64] + var basePrice: Int64 + var minLabelLength: Int + + func centsPerYear(_ labelLength: Int) -> Int64 { + registrationPrices[String(labelLength)] ?? basePrice + } } enum InvitationLinkPlan: Decodable, Hashable { @@ -1465,7 +1545,7 @@ enum InvitationLinkPlan: Decodable, Hashable { } enum ContactAddressPlan: Decodable, Hashable { - case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?) + case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?, addressChanged: Bool) case ownLink case connectingConfirmReconnect case connectingProhibit(contact: Contact) @@ -1480,7 +1560,7 @@ public struct GroupShortLinkInfo: Decodable, Hashable { } enum GroupLinkPlan: Decodable, Hashable { - case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?) + case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?, addressChanged: Bool) case ownLink(groupInfo: GroupInfo) case connectingConfirmReconnect case connectingProhibit(groupInfo_: GroupInfo?) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 1bf711ca5f..6068e13473 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -1484,3 +1484,51 @@ enum UIRemoteCtrlSessionState { case pendingConfirmation(remoteCtrl_: RemoteCtrlInfo?, sessionCode: String) case connected(remoteCtrl: RemoteCtrlInfo, sessionCode: String) } + +// How recently each SimpleX name was resolved from the registry. +// +// The lookup canvas asks that a name you already have a chat for is resolved at most once a day, +// or once its registration has expired, while any other name resolves on every tap. Core stays +// stateless for this: .never answers from the store without a network round trip and reports a +// miss, and .all always resolves. So a fresh name is tried locally first and only falls through +// to the registry when no chat claims it - which is exactly "every tap" for a name you do not have. +enum NameResolution { + private static let daySeconds: Int64 = 24 * 60 * 60 + + private struct Resolved: Codable { + var at: Int64 + var expires: Int64? + } + + // when each SimpleX name was last resolved, so a name with a chat is not re-resolved on every tap + private static let resolvedAtDefault = CodableDefault<[String: Resolved]>(defaults: UserDefaults.standard, forKey: DEFAULT_SIMPLEX_NAMES_RESOLVED_AT, withDefault: [:]) + + private static func save(_ m: [String: Resolved]) { + // only names looked up in the last week are worth remembering + let now = nowSeconds() + resolvedAtDefault.set(m.filter { now - $0.value.at < 7 * daySeconds }) + } + + private static func nowSeconds() -> Int64 { Int64(Date.now.timeIntervalSince1970) } + + // a cached answer is stale a day after it was taken, or as soon as the name it described expired + static func isFresh(_ domain: SimplexDomain) -> Bool { + guard let r = resolvedAtDefault.get()[domain.fullDomainName] else { return false } + let now = nowSeconds() + if now - r.at >= daySeconds { return false } + return r.expires.map { now < $0 } ?? true + } + + static func record(_ domain: SimplexDomain, _ reg: NameRegistration?) { + var m = resolvedAtDefault.get() + let expires: Int64? = if case let .registered(expires, _, _) = reg { expires } else { nil } + m[domain.fullDomainName] = Resolved(at: nowSeconds(), expires: expires) + save(m) + } + + // a name registered, claimed or dropped on this device must not keep reading as it did before + static func forget(_ fullDomainName: String) { + var m = resolvedAtDefault.get() + if m.removeValue(forKey: fullDomainName) != nil { save(m) } + } +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 31995a9d5c..aa901b3405 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1051,7 +1051,7 @@ func apiConnectPlan(connLink: String, resolveMode: PlanResolveMode = .unknown, l } // a .never (typing) search that matches nothing locally is not an error to surface if case .error(.error(.notResolvedLocally)) = r { return nil } - if let r { await apiConnectResponseAlert(r) } + if let r, inProgress.boxedValue { await apiConnectResponseAlert(r) } return nil } @@ -1390,6 +1390,8 @@ func showSetSimplexNameError(_ r: APIResult, isChannel: Bool) async { func apiSetUserDomain(_ simplexDomain: String?) async throws -> User { let userId = try currentUserId("apiSetUserDomain") let r: APIResult = await chatApiSendCmd(.apiSetUserDomain(userId: userId, simplexDomain: simplexDomain)) + // a name claimed or dropped here must not keep reading from the answer taken before + if let d = ChatModel.shared.currentUser?.profile.contactDomain?.domain { NameResolution.forget(d) } switch r { case let .result(.userProfileUpdated(user, _, _, _)): return user case let .result(.userProfileNoChange(user)): return user diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 61f29485a7..4be54a3cad 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -918,7 +918,7 @@ func knownChatId(_ result: ConnectionPlanResult?) -> String? { guard let plan = result?.connectionPlan else { return nil } let m = ChatModel.shared switch plan { - case let .contactAddress(contactAddressPlan): + case let .contactAddress(contactAddressPlan, _): if case let .known(contact) = contactAddressPlan { if m.getContactChat(contact.contactId) == nil { m.addChat(Chat(chatInfo: .direct(contact: contact), chatItems: [])) @@ -926,7 +926,7 @@ func knownChatId(_ result: ConnectionPlanResult?) -> String? { return contact.id } return nil - case let .groupLink(groupLinkPlan): + case let .groupLink(groupLinkPlan, _): switch groupLinkPlan { case .known(let groupInfo), .ownLink(let groupInfo): if m.getGroupChat(groupInfo.groupId) == nil { diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index ef691b10f9..411bf70103 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -1119,6 +1119,7 @@ private func showPrepareContactAlert( verifiedDomain: SimplexDomain? = nil, connectOtherButton: String? = nil, connectOtherLink: String? = nil, + addressChanged: Bool = false, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)? @@ -1138,6 +1139,7 @@ private func showPrepareContactAlert( ), profileBadge: contactShortLinkData.localBadge, theme: theme, + subtitle: addressChanged ? verifiedDomain.map { String.localizedStringWithFormat(NSLocalizedString("%@ now leads to a new address.", comment: "alert subtitle"), $0.fullDomainName) } : nil, information: ownerVerificationMessage(ownerVerification), cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: NSLocalizedString("Open new chat", comment: "new chat action"), @@ -1317,6 +1319,123 @@ private func showOpenKnownGroupAlert( ) } +// The name-lookup answers the registry can give for a name, as the lookup canvas draws them. +// Only an actionable answer becomes an alert; everything else leaves the plan to speak for itself. + +private let simplexNamesHowToURL = "https://simplex.domains/#testing" + +private func nameDate(_ seconds: Int64) -> String { + Date(timeIntervalSince1970: TimeInterval(seconds)).formatted(date: .abbreviated, time: .omitted) +} + +private func nameCentsPerYear(_ pricing: NamePricing, _ domain: SimplexDomain) -> String { + let cents = pricing.centsPerYear(domain.domain.count) + return "$\(cents / 100)" + (cents % 100 == 0 ? "" : String(format: ".%02lld", cents % 100)) +} + +private func showNameNotRegisteredAlert() { + showAlert( + NSLocalizedString("Name not registered", comment: "alert title"), + message: NSLocalizedString("This SimpleX name is not registered. Please check the name.", comment: "") + ) +} + +// bands 2, 3 and 4 of the lookup canvas: what the registry said, when the user can act on it. +// Returns false when the answer is not actionable, and the caller shows its usual plan UI. +private func showNameRegistrationAlert( + domain: SimplexDomain, + reg: NameRegistration, + isOwn: Bool, + notConnectable: Bool, + hasLocalChat: Bool, + openExistingChat: (() -> Void)?, + cleanup: (() -> Void)? +) -> Bool { + let nameStr = domain.fullDomainName + let now = Int64(Date.now.timeIntervalSince1970) + // an extra way back to the chat the user already has, when there is no filtered list behind the alert + func alert(_ title: String, _ message: String, action: (title: String, handler: () -> Void)? = nil) { + showAlert(title, message: message, actions: { + var actions: [UIAlertAction] = [] + if let action { + actions.append(UIAlertAction(title: action.title, style: .default) { _ in action.handler() }) + } + if let openExistingChat { + actions.append(UIAlertAction(title: NSLocalizedString("Open existing chat", comment: "alert action"), style: .default) { _ in openExistingChat() }) + } + actions.append(UIAlertAction(title: NSLocalizedString("Ok", comment: "alert button"), style: .default) { _ in cleanup?() }) + return actions + }) + } + let openHowTo = { openBrowserAlert(uri: simplexNamesHowToURL) } + switch reg { + // 2b / 3b / 4c: expired, and only its owner can renew it until the grace ends + case let .registered(expires, graceUntil, _) where reg.expired(now): + if isOwn { + let message = if let expires, let graceUntil { + String.localizedStringWithFormat(NSLocalizedString("Your name %1$@ expired on %2$@. Renew it before %3$@.", comment: "alert message"), nameStr, nameDate(expires), nameDate(graceUntil)) + } else { + String.localizedStringWithFormat(NSLocalizedString("Your name %@ has expired. Renew it before the grace period ends.", comment: "alert message"), nameStr) + } + alert(NSLocalizedString("Your name has expired", comment: "alert title"), message, action: (NSLocalizedString("Renew", comment: "alert action"), openHowTo)) + } else { + let message = if let expires, let graceUntil { + String.localizedStringWithFormat(NSLocalizedString("%1$@ expired on %2$@. Its owner can renew it until %3$@.", comment: "alert message"), nameStr, nameDate(expires), nameDate(graceUntil)) + } else { + String.localizedStringWithFormat(NSLocalizedString("%@ has expired. Its owner can still renew it for a limited time.", comment: "alert message"), nameStr) + } + alert(NSLocalizedString("Name expired", comment: "alert title"), message) + } + return true + // 2c / 3d / 4d: free to register, unless it is shorter than the registry accepts (then 2e) + case let .available(pricing) where domain.domain.count >= pricing.minLabelLength: + let price = nameCentsPerYear(pricing, domain) + if isOwn { + alert( + NSLocalizedString("Your name has expired", comment: "alert title"), + String.localizedStringWithFormat(NSLocalizedString("Your name %1$@ is no longer registered. It is available for registration from %2$@ per year.", comment: "alert message"), nameStr, price), + action: (NSLocalizedString("Re-register", comment: "alert action"), openHowTo) + ) + } else { + alert( + hasLocalChat + ? NSLocalizedString("Name no longer registered", comment: "alert title") + : NSLocalizedString("Name not registered", comment: "alert title"), + String.localizedStringWithFormat(NSLocalizedString("%1$@ is available for registration from %2$@ per year.", comment: "alert message"), nameStr, price), + action: (NSLocalizedString("Register", comment: "alert action"), openHowTo) + ) + } + return true + // 2d: the only reserved reason that says so; every other one reads as "not registered" + case _ where reg.reservedForCommunity: + alert( + NSLocalizedString("Name not registered", comment: "alert title"), + String.localizedStringWithFormat(NSLocalizedString("%@ is reserved for community. Please contact SimpleX Chat if you are interested in using it.", comment: "alert message"), nameStr), + action: (NSLocalizedString("Connect to SimpleX team", comment: "alert action"), { + // simplexTeamURL targets this same app; route to the in-app connect flow + ChatModel.shared.appOpenUrl = simplexTeamURL + }) + ) + return true + // 2e: too short, reserved for another reason, or otherwise not registrable + case .reserved, .available: + if hasLocalChat || isOwn { return false } + alert(NSLocalizedString("Name not registered", comment: "alert title"), NSLocalizedString("This SimpleX name is not registered. Please check the name.", comment: "")) + return true + // 2f: registered and live, but the record has no usable link. Only the plan knows that: + // a live registration the plan can connect to has nothing to say. + case .registered where notConnectable && !hasLocalChat && !isOwn: + alert( + NSLocalizedString("No valid link", comment: ""), + String.localizedStringWithFormat(NSLocalizedString("The SimpleX name %@ is registered, but it has no valid link.", comment: ""), nameStr) + ) + return true + // 3a: still leads to the chat you have, nothing to say + default: + return false + } +} + // Spec: spec/client/navigation.md#planAndConnect func planAndConnect( _ shortOrFullLink: String, @@ -1351,13 +1470,27 @@ func planAndConnect( func connectTask(_ inProgress: BoxedValue) { Task { - let result = await apiConnectPlan(connLink: shortOrFullLink, linkOwnerSig: linkOwnerSig, inProgress: inProgress) + // A name whose cached answer is still fresh is tried against the store first, so a chat the user + // already has does not cost a lookup; a miss falls through to a full resolution, which is what a + // name with no chat gets on every tap. Anything that is not a name resolves as it always did. + let nameTarget: SimplexNameInfo? = if case let .name(_, nameInfo) = strConnectTarget(shortOrFullLink) { nameInfo } else { nil } + var result: ConnectionPlanResult? = nil + if let nameTarget, NameResolution.isFresh(nameTarget.nameDomain) { + // a local probe: no error alerts, so a miss falls through silently + result = await apiConnectPlan(connLink: shortOrFullLink, resolveMode: .never, linkOwnerSig: linkOwnerSig, inProgress: BoxedValue(false)) + } + if result == nil && inProgress.boxedValue { + result = await apiConnectPlan(connLink: shortOrFullLink, resolveMode: nameTarget != nil ? .all : .unknown, linkOwnerSig: linkOwnerSig, inProgress: inProgress) + // remember when this name was last taken from the registry, and when its registration runs out + if let nameTarget, let result { + NameResolution.record(nameTarget.nameDomain, result.connectionPlan.nameRegistration) + } + } await MainActor.run { ConnectProgressManager.shared.stopConnectProgress() } if !inProgress.boxedValue { return } if let result { - let connectionLink = result.connLink let connectionPlan = result.connectionPlan let planSimplexName = result.planSimplexName // the name can also resolve to the other kind; its type picks the verb, its short form the label and target @@ -1370,7 +1503,66 @@ func planAndConnect( info.shortStr ) } + // One actionable answer from the registry is one alert, shown over whatever the plan would have. + // With a list to filter the chats found stay behind it; from a message the alert opens them instead. + let nameDomain: SimplexDomain? = if case let .nameNotConnectable(simplexDomain, _) = connectionPlan { simplexDomain } else { planSimplexName?.nameDomain } + if let nameReg = connectionPlan.nameRegistration, let nameDomain { + let knownContact: Contact? = if case let .contactAddress(.known(contact), _) = connectionPlan { contact } else { nil } + let knownGroup: GroupInfo? = if case let .groupLink(.known(groupInfo), _) = connectionPlan { groupInfo } else { nil } + let isOwnName = switch connectionPlan { + case .contactAddress(.ownLink, _), .groupLink(.ownLink, _): true + default: false + } + let handled = await MainActor.run { + // the chat found stays reachable: filtered into the list where there is one, opened from the alert where there is not + var openExisting: (() -> Void)? = nil + if let contact = knownContact { + if ChatModel.shared.getContactChat(contact.contactId) == nil { + ChatModel.shared.addChat(Chat(chatInfo: .direct(contact: contact))) + } + if let f = filterKnownContact { + f(contact) + } else { + openExisting = { openKnownContact(contact, dismiss: dismiss, cleanup: nil) } + } + } else if let groupInfo = knownGroup { + if ChatModel.shared.getGroupChat(groupInfo.groupId) == nil { + ChatModel.shared.addChat(Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil))) + } + if let f = filterKnownGroup { + f(groupInfo) + } else { + openExisting = { openKnownGroup(groupInfo, dismiss: dismiss, cleanup: nil) } + } + } + return showNameRegistrationAlert( + domain: nameDomain, + reg: nameReg, + isOwn: isOwnName, + notConnectable: { if case .nameNotConnectable = connectionPlan { true } else { false } }(), + hasLocalChat: knownContact != nil || knownGroup != nil, + openExistingChat: openExisting, + cleanup: cleanup + ) + } + if handled { return } + } + guard let connectionLink = result.connLink else { + // a name with nothing to connect to has no link; an actionable answer was already alerted above + await MainActor.run { + if case .nameNotConnectable = connectionPlan { showNameNotRegisteredAlert() } + cleanup?() + } + return + } switch connectionPlan { + case .nameNotConnectable: + // nothing actionable and nothing to connect to: as today, the name simply did not resolve + logger.debug("planAndConnect, .nameNotConnectable") + await MainActor.run { + showNameNotRegisteredAlert() + cleanup?() + } case let .invitationLink(ilp): switch ilp { case let .ok(contactSLinkData_, ownerVerification): @@ -1434,9 +1626,9 @@ func planAndConnect( } } } - case let .contactAddress(cap): + case let .contactAddress(cap, _): switch cap { - case let .ok(contactSLinkData_, ownerVerification): + case let .ok(contactSLinkData_, ownerVerification, addressChanged): if let contactSLinkData = contactSLinkData_ { logger.debug("planAndConnect, .contactAddress, .ok, short link data present") await MainActor.run { @@ -1447,6 +1639,7 @@ func planAndConnect( verifiedDomain: planSimplexName?.nameDomain, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink, + addressChanged: addressChanged, theme: theme, dismiss: dismiss, cleanup: cleanup @@ -1520,9 +1713,9 @@ func planAndConnect( ) } } - case let .groupLink(glp): + case let .groupLink(glp, _): switch glp { - case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification): + case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification, _): if let groupSLinkData = groupSLinkData_ { logger.debug("planAndConnect, .groupLink, .ok, short link data present") await MainActor.run { @@ -1784,6 +1977,7 @@ private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType? case .invitationLink: .invitation case .contactAddress: .contact case .groupLink: .groupLink + case .nameNotConnectable: nil case .error: nil } } diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 767acade71..0a697e197e 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -33,6 +33,7 @@ let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved t let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode" let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews" let DEFAULT_PRIVACY_VERIFY_SIMPLEX_NAMES = "privacyVerifySimplexNames" +let DEFAULT_SIMPLEX_NAMES_RESOLVED_AT = "simplexNamesResolvedAt" let DEFAULT_PRIVACY_SHOW_SIGNATURE = "privacyShowSignature" let DEFAULT_PRIVACY_SHOW_FILE_ENCRYPTION = "privacyShowEncryption" let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"