core, ui: resolve untyped domains (#7198)

* core: resolve untyped domains

* CPError

* only add domain when it has link of correct type

* resolve first

* handle errors

* remove CPError

* update UI types

* remove unused name

Co-authored-by: Evgeny <evgeny@poberezkin.com>

* refactor connection plan

* kotlin: show domain and alternative chat, haskell tests for dual domains

* view/tests

* update kotlin

* dual domains accounting for business chats

* refactor, fix

* fix kotlin

* remove comment

* search

* add resolve mode

* local resolution

* refactor, bot types

* search both contact and channel by name

* fix

* fix searching business chats by name

* fix ui

* ios

* fix ios

* fix icon

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-07-06 08:50:03 +01:00
committed by GitHub
co-authored by Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
parent ff58dbd6df
commit 6150b35a2f
34 changed files with 934 additions and 253 deletions
+22 -7
View File
@@ -131,7 +131,7 @@ enum ChatCommand: ChatCmdProtocol {
case apiAddContact(userId: Int64, incognito: Bool)
case apiSetConnectionIncognito(connId: Int64, incognito: Bool)
case apiChangeConnectionUser(connId: Int64, userId: Int64)
case apiConnectPlan(userId: Int64, connLink: String, linkOwnerSig: LinkOwnerSig?)
case apiConnectPlan(userId: Int64, connLink: String, resolveMode: PlanResolveMode, linkOwnerSig: LinkOwnerSig?)
case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData, verifiedDomain: SimplexDomain?)
case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData, verifiedDomain: SimplexDomain?)
case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64)
@@ -347,9 +347,10 @@ enum ChatCommand: ChatCmdProtocol {
case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))"
case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))"
case let .apiChangeConnectionUser(connId, userId): return "/_set conn user :\(connId) \(userId)"
case let .apiConnectPlan(userId, connLink, linkOwnerSig):
case let .apiConnectPlan(userId, connLink, resolveMode, linkOwnerSig):
let resolveStr = resolveMode == .unknown ? "" : " resolve=\(resolveMode.rawValue)"
let sigStr = if let linkOwnerSig { " sig=\(encodeJSON(linkOwnerSig))" } else { "" }
return "/_connect plan \(userId) \(connLink)\(sigStr)"
return "/_connect plan \(userId) \(connLink)\(resolveStr)\(sigStr)"
case let .apiPrepareContact(userId, connLink, contactShortLinkData, verifiedDomain): return "/_prepare contact \(userId) \(connLink.cmdString)\(verifiedDomain.map{ " \($0.cmdString)" } ?? "") \(encodeJSON(contactShortLinkData))"
case let .apiPrepareGroup(userId, connLink, directLink, groupShortLinkData, verifiedDomain): return "/_prepare group \(userId) \(connLink.cmdString) direct=\(onOff(directLink))\(verifiedDomain.map{ " \($0.cmdString)" } ?? "") \(encodeJSON(groupShortLinkData))"
case let .apiChangePreparedContactUser(contactId, newUserId): return "/_set contact user @\(contactId) \(newUserId)"
@@ -814,7 +815,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, 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)
@@ -938,7 +939,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
case let .invitation(u, connLinkInvitation, connection): return withUser(u, "connLinkInvitation: \(connLinkInvitation)\nconnection: \(connection)")
case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\nnewUserId: \(String(describing: newUser.userId))")
case let .connectionPlan(u, connLink, connectionPlan): return withUser(u, "connLink: \(String(describing: connLink))\nconnectionPlan: \(String(describing: connectionPlan))")
case let .connectionPlan(u, connLink, _, _, connectionPlan): return withUser(u, "connLink: \(String(describing: connLink))\nconnectionPlan: \(String(describing: connectionPlan))")
case let .newPreparedChat(u, chat): return withUser(u, String(describing: chat))
case let .contactUserChanged(u, fromContact, newUser, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\nnewUserId: \(String(describing: newUser.userId))\ntoContact: \(String(describing: toContact))")
case let .groupUserChanged(u, fromGroup, newUser, toGroup): return withUser(u, "fromGroup: \(String(describing: fromGroup))\nnewUserId: \(String(describing: newUser.userId))\ntoGroup: \(String(describing: toGroup))")
@@ -1386,6 +1387,20 @@ enum OwnerVerification: Decodable, Hashable {
case failed(reason: String)
}
struct ConnectionPlanResult {
var connLink: CreatedConnLink
var planSimplexName: SimplexNameInfo?
var otherSimplexName: SimplexNameInfo?
var connectionPlan: ConnectionPlan
}
// APIConnectPlan resolution scope; .never is local-store-only (no network), used for per-keystroke name search
enum PlanResolveMode: String {
case allGroups
case unknown
case never
}
enum ConnectionPlan: Decodable, Hashable {
case invitationLink(invitationLinkPlan: InvitationLinkPlan)
case contactAddress(contactAddressPlan: ContactAddressPlan)
@@ -1401,7 +1416,7 @@ enum InvitationLinkPlan: Decodable, Hashable {
}
enum ContactAddressPlan: Decodable, Hashable {
case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?, verifiedDomain: SimplexDomain?)
case ok(contactSLinkData_: ContactShortLinkData?, ownerVerification: OwnerVerification?)
case ownLink
case connectingConfirmReconnect
case connectingProhibit(contact: Contact)
@@ -1416,7 +1431,7 @@ public struct GroupShortLinkInfo: Decodable, Hashable {
}
enum GroupLinkPlan: Decodable, Hashable {
case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?, verifiedDomain: SimplexDomain?)
case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?, ownerVerification: OwnerVerification?)
case ownLink(groupInfo: GroupInfo)
case connectingConfirmReconnect
case connectingProhibit(groupInfo_: GroupInfo?)
+7 -3
View File
@@ -1026,13 +1026,17 @@ func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> Pendi
if let r { throw r.unexpected } else { return nil }
}
func apiConnectPlan(connLink: String, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue<Bool>) async -> (CreatedConnLink, ConnectionPlan)? {
func apiConnectPlan(connLink: String, resolveMode: PlanResolveMode = .unknown, linkOwnerSig: LinkOwnerSig? = nil, inProgress: BoxedValue<Bool>) async -> ConnectionPlanResult? {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnectPlan: no current user")
return nil
}
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink, linkOwnerSig: linkOwnerSig), inProgress: inProgress)
if case let .result(.connectionPlan(_, connLink, connPlan)) = r { return (connLink, connPlan) }
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink, resolveMode: resolveMode, linkOwnerSig: linkOwnerSig), inProgress: inProgress)
if case let .result(.connectionPlan(_, connLink, planSimplexName, otherSimplexName, connPlan)) = r {
return ConnectionPlanResult(connLink: connLink, planSimplexName: planSimplexName, otherSimplexName: otherSimplexName, connectionPlan: connPlan)
}
// 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) }
return nil
}
+135 -23
View File
@@ -151,7 +151,7 @@ struct ChatListView: View {
@FocusState private var searchFocussed
@State private var searchText = ""
@State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil
@State private var searchChatFilteredBySimplexLink: Set<String> = []
@State private var scrollToSearchBar = false
@State private var userPickerShown: Bool = false
@State private var sheet: SomeSheet<AnyView>? = nil
@@ -511,8 +511,8 @@ struct ChatListView: View {
// Spec: spec/client/chat-list.md#filteredChats
private func filteredChats() -> [Chat] {
if let linkChatId = searchChatFilteredBySimplexLink {
return chatModel.chats.filter { $0.id == linkChatId }
if !searchChatFilteredBySimplexLink.isEmpty {
return chatModel.chats.filter { searchChatFilteredBySimplexLink.contains($0.id) }
} else {
let s = searchString()
return s == ""
@@ -626,13 +626,21 @@ struct ChatListSearchBar: View {
@FocusState.Binding var searchFocussed: Bool
@Binding var searchText: String
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String?
@Binding var searchChatFilteredBySimplexLink: Set<String>
@Binding var parentSheet: SomeSheet<AnyView>?
@State private var ignoreSearchTextChange = false
// when the search text is a SimpleX name, the string to connect to (with @/# preserved); nil otherwise
@State private var connectNameCandidate: String? = nil
@State private var nameSearchTask: Task<Void, Never>? = nil
var body: some View {
VStack(spacing: 12) {
ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) }
// a typed name replaces the list tags with a row to connect to it (as on Android mobile)
if let candidate = connectNameCandidate {
connectByNameRow(candidate)
} else {
ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) }
}
HStack(spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
@@ -675,33 +683,50 @@ struct ChatListSearchBar: View {
if ignoreSearchTextChange {
ignoreSearchTextChange = false
} else {
switch strConnectTarget(t.trimmingCharacters(in: .whitespaces)) {
let s = t.trimmingCharacters(in: .whitespaces)
switch strConnectTarget(s) {
case let .link(text, _, linkText):
nameSearchTask?.cancel()
nameSearchTask = nil
searchFocussed = false
ignoreSearchTextChange = true
searchText = linkText
searchShowingSimplexLink = true
searchChatFilteredBySimplexLink = nil
searchChatFilteredBySimplexLink = []
connectNameCandidate = nil
connect(text)
case let .name(text, _):
searchFocussed = false
planAndConnect(
text,
theme: theme,
dismiss: false,
cleanup: {
searchText = ""
searchFocussed = false
default:
// not a link: a recognized SimpleX name shows the connect-by-name row (in place of the
// list tags) and, debounced, resolves locally per keystroke to narrow the list to the
// matching known chat(s); tapping the row connects online. Clear the filter immediately so
// the list falls back to text search until the search returns.
let candidate = nameSearchCandidate(s)
connectNameCandidate = candidate
searchShowingSimplexLink = false
searchChatFilteredBySimplexLink = []
nameSearchTask?.cancel()
nameSearchTask = nil
if let candidate = candidate {
nameSearchTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 300_000_000)
if Task.isCancelled { return }
// a bare name can be a contact or a channel: search both and keep every match
let targets = candidate.hasPrefix("@") || candidate.hasPrefix("#") ? [candidate] : ["@\(candidate)", "#\(candidate)"]
var ids: [String] = []
for name in targets {
let plan = await apiConnectPlan(connLink: name, resolveMode: .never, inProgress: BoxedValue(false))
if Task.isCancelled { return }
if let id = knownChatId(plan) { ids.append(id) }
}
searchChatFilteredBySimplexLink = Set(ids)
// drop the row only when every searched type is already known locally
if ids.count == targets.count { connectNameCandidate = nil }
}
)
case .none:
if t != "" {
} else if t != "" {
searchFocussed = true
} else {
ConnectProgressManager.shared.cancelConnectProgress()
}
searchShowingSimplexLink = false
searchChatFilteredBySimplexLink = nil
}
}
}
@@ -730,6 +755,33 @@ struct ChatListSearchBar: View {
}
}
// Row shown in place of the list tags when the search text is a SimpleX name. The @ icon marks a
// contact name, the tag icon a channel/other name; tapping hides the keyboard, connects online, and
// clears the field.
private func connectByNameRow(_ name: String) -> some View {
HStack(spacing: 4) {
Image(systemName: name.hasPrefix("@") ? "at" : "number")
.foregroundColor(theme.colors.primary)
Text(String.localizedStringWithFormat(NSLocalizedString("Connect to %@", comment: "new chat action"), name))
.foregroundColor(theme.colors.primary)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
searchFocussed = false
planAndConnect(
name,
theme: theme,
dismiss: false,
cleanup: {
searchText = ""
connectNameCandidate = nil
}
)
}
}
private func connect(_ link: String) {
planAndConnect(
link,
@@ -739,12 +791,72 @@ struct ChatListSearchBar: View {
searchText = ""
searchFocussed = false
},
filterKnownContact: { searchChatFilteredBySimplexLink = $0.id },
filterKnownGroup: { searchChatFilteredBySimplexLink = $0.id }
filterKnownContact: { searchChatFilteredBySimplexLink = [$0.id] },
filterKnownGroup: { searchChatFilteredBySimplexLink = [$0.id] }
)
}
}
// Default top-level part used to complete a bare name typed in the search field (search field only;
// the message parser and the wire format are unchanged).
private let DEFAULT_NAME_TLD = "testing"
// Shortest name that offers the button, so it is discoverable but does not flash on a single letter.
private let MIN_NAME_LENGTH = 2
private func isNameLabel(_ s: String) -> Bool {
s.count >= 1 && s.count <= 63 && s.range(of: "^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$", options: .regularExpression) != nil
}
// On-device candidate for connecting by SimpleX name: the string sent to the core to resolve it.
// The chat id a local (.never) search resolved to a contact, business, or channel or nil on a miss.
// A name-resolved chat may be prepared in the store but not yet listed, so add it so the filter can surface it.
@MainActor
func knownChatId(_ result: ConnectionPlanResult?) -> String? {
guard let plan = result?.connectionPlan else { return nil }
let m = ChatModel.shared
switch plan {
case let .contactAddress(contactAddressPlan):
if case let .known(contact) = contactAddressPlan {
if m.getContactChat(contact.contactId) == nil {
m.addChat(Chat(chatInfo: .direct(contact: contact), chatItems: []))
}
return contact.id
}
return nil
case let .groupLink(groupLinkPlan):
switch groupLinkPlan {
case .known(let groupInfo), .ownLink(let groupInfo):
if m.getGroupChat(groupInfo.groupId) == nil {
m.addChat(Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: nil), chatItems: []))
}
return groupInfo.id
default:
return nil
}
default:
return nil
}
}
// Mirrors the domain grammar (nameLabelP/mkDomain in SimplexName.hs): an optional @/# prefix, then
// dot-separated ASCII labels; a dotless word is completed with the default top-level part. Returns
// the string to send (keeping @/# so the type is preserved), or nil when the text is not a name.
func nameSearchCandidate(_ str: String) -> String? {
let text = str.trimmingCharacters(in: .whitespaces)
let prefix: Character? = text.first.flatMap { $0 == "@" || $0 == "#" ? $0 : nil }
let core = prefix != nil ? String(text.dropFirst()) : text
if core.isEmpty { return nil }
let labels = core.split(separator: ".", omittingEmptySubsequences: false)
if labels.contains(where: { !isNameLabel(String($0)) }) { return nil }
if labels.count > 1 {
return text // already has a top-level part
} else if core.count >= MIN_NAME_LENGTH {
return "\(prefix.map(String.init) ?? "")\(core).\(DEFAULT_NAME_TLD)"
} else {
return nil
}
}
struct TagsView: View {
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var chatModel: ChatModel
+51 -4
View File
@@ -144,8 +144,10 @@ class OpenChatAlertViewController: UIViewController {
private let information: String?
private let cancelTitle: String
private let confirmTitle: String?
private let secondTitle: String?
private let onCancel: () -> Void
private let onConfirm: (() -> Void)?
private let onSecond: (() -> Void)?
init(
profileName: String,
@@ -156,8 +158,10 @@ class OpenChatAlertViewController: UIViewController {
information: String? = nil,
cancelTitle: String = "Cancel",
confirmTitle: String? = "Open",
secondTitle: String? = nil,
onCancel: @escaping () -> Void = {},
onConfirm: (() -> Void)? = nil
onConfirm: (() -> Void)? = nil,
onSecond: (() -> Void)? = nil
) {
self.profileName = profileName
self.profileFullName = profileFullName
@@ -167,8 +171,10 @@ class OpenChatAlertViewController: UIViewController {
self.information = information
self.cancelTitle = cancelTitle
self.confirmTitle = confirmTitle
self.secondTitle = secondTitle
self.onCancel = onCancel
self.onConfirm = onConfirm
self.onSecond = onSecond
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
@@ -277,7 +283,38 @@ class OpenChatAlertViewController: UIViewController {
let buttonStack: UIStackView
var buttonDividerConstraints: [NSLayoutConstraint] = []
if let confirmTitle {
if let confirmTitle, let secondTitle {
// Three buttons (a sibling action is present) always vertical
let confirmButton = UIButton(type: .system)
confirmButton.setTitle(confirmTitle, for: .normal)
confirmButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
let secondButton = UIButton(type: .system)
secondButton.setTitle(secondTitle, for: .normal)
secondButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
secondButton.addTarget(self, action: #selector(secondTapped), for: .touchUpInside)
buttonStack = UIStackView(arrangedSubviews: [confirmButton, secondButton, cancelButton])
buttonStack.axis = .vertical
buttonStack.distribution = .fillEqually
buttonStack.spacing = 0
buttonStack.translatesAutoresizingMaskIntoConstraints = false
buttonStack.heightAnchor.constraint(greaterThanOrEqualToConstant: alertButtonHeight * 3).isActive = true
for button in [secondButton, cancelButton] {
let divider = UIView()
divider.backgroundColor = UIColor.separator
divider.translatesAutoresizingMaskIntoConstraints = false
buttonStack.addSubview(divider)
buttonDividerConstraints += [
divider.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
divider.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
divider.bottomAnchor.constraint(equalTo: button.topAnchor),
divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale)
]
}
} else if let confirmTitle {
let confirmButton = UIButton(type: .system)
confirmButton.setTitle(confirmTitle, for: .normal)
confirmButton.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
@@ -372,6 +409,12 @@ class OpenChatAlertViewController: UIViewController {
self.onConfirm?()
}
}
@objc private func secondTapped() {
dismiss(animated: true) {
self.onSecond?()
}
}
}
@@ -385,8 +428,10 @@ func showOpenChatAlert<Content: View>(
information: String? = nil,
cancelTitle: String = "Cancel",
confirmTitle: String? = "Open",
secondTitle: String? = nil,
onCancel: @escaping () -> Void = {},
onConfirm: (() -> Void)? = nil
onConfirm: (() -> Void)? = nil,
onSecond: (() -> Void)? = nil
) {
let themedView = profileImage.environmentObject(theme)
let hostingController = UIHostingController(rootView: themedView)
@@ -403,8 +448,10 @@ func showOpenChatAlert<Content: View>(
information: information,
cancelTitle: cancelTitle,
confirmTitle: confirmTitle,
secondTitle: secondTitle,
onCancel: onCancel,
onConfirm: onConfirm
onConfirm: onConfirm,
onSecond: onSecond
)
topVC.present(alertVC, animated: true)
}
+51 -10
View File
@@ -1116,6 +1116,8 @@ private func showPrepareContactAlert(
contactShortLinkData: ContactShortLinkData,
ownerVerification: OwnerVerification? = nil,
verifiedDomain: SimplexDomain? = nil,
connectOtherButton: String? = nil,
connectOtherLink: String? = nil,
theme: AppTheme,
dismiss: Bool,
cleanup: (() -> Void)?
@@ -1138,6 +1140,7 @@ private func showPrepareContactAlert(
information: ownerVerificationMessage(ownerVerification),
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
confirmTitle: NSLocalizedString("Open new chat", comment: "new chat action"),
secondTitle: connectOtherButton,
onCancel: { cleanup?() },
onConfirm: {
Task {
@@ -1155,6 +1158,9 @@ private func showPrepareContactAlert(
}
}
}
},
onSecond: connectOtherLink.map { link in
{ planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup) }
}
)
}
@@ -1165,6 +1171,8 @@ private func showPrepareGroupAlert(
groupShortLinkData: GroupShortLinkData,
ownerVerification: OwnerVerification? = nil,
verifiedDomain: SimplexDomain? = nil,
connectOtherButton: String? = nil,
connectOtherLink: String? = nil,
theme: AppTheme,
dismiss: Bool,
cleanup: (() -> Void)?
@@ -1189,6 +1197,7 @@ private func showPrepareGroupAlert(
confirmTitle: isChannel
? NSLocalizedString("Open new channel", comment: "new chat action")
: NSLocalizedString("Open new group", comment: "new chat action"),
secondTitle: connectOtherButton,
onCancel: { cleanup?() },
onConfirm: {
Task {
@@ -1210,6 +1219,9 @@ private func showPrepareGroupAlert(
}
}
}
},
onSecond: connectOtherLink.map { link in
{ planAndConnect(link, theme: theme, dismiss: dismiss, cleanup: cleanup) }
}
)
}
@@ -1217,7 +1229,9 @@ private func showPrepareGroupAlert(
private func showOpenKnownContactAlert(
_ contact: Contact,
theme: AppTheme,
dismiss: Bool
dismiss: Bool,
connectOtherButton: String? = nil,
connectOtherLink: String? = nil
) {
showOpenChatAlert(
profileName: contact.profile.displayName,
@@ -1235,8 +1249,12 @@ private func showOpenKnownContactAlert(
contact.nextConnectPrepared
? NSLocalizedString("Open new chat", comment: "new chat action")
: NSLocalizedString("Open chat", comment: "new chat action"),
secondTitle: connectOtherButton,
onConfirm: {
openKnownContact(contact, dismiss: dismiss, cleanup: nil)
},
onSecond: connectOtherLink.map { link in
{ planAndConnect(link, theme: theme, dismiss: dismiss) }
}
)
}
@@ -1244,7 +1262,9 @@ private func showOpenKnownContactAlert(
private func showOpenKnownGroupAlert(
_ groupInfo: GroupInfo,
theme: AppTheme,
dismiss: Bool
dismiss: Bool,
connectOtherButton: String? = nil,
connectOtherLink: String? = nil
) {
let subscriberCount = groupInfo.groupSummary.publicMemberCount.map { "\($0) subscribers" }
showOpenChatAlert(
@@ -1274,8 +1294,12 @@ private func showOpenKnownGroupAlert(
? NSLocalizedString("Open new chat", comment: "new chat action")
: NSLocalizedString("Open chat", comment: "new chat action")
),
secondTitle: connectOtherButton,
onConfirm: {
openKnownGroup(groupInfo, dismiss: dismiss, cleanup: nil)
},
onSecond: connectOtherLink.map { link in
{ planAndConnect(link, theme: theme, dismiss: dismiss) }
}
)
}
@@ -1319,7 +1343,20 @@ func planAndConnect(
ConnectProgressManager.shared.stopConnectProgress()
}
if !inProgress.boxedValue { return }
if let (connectionLink, connectionPlan) = result {
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
let connectOtherLink = result.otherSimplexName?.shortStr
let connectOtherButton: String? = result.otherSimplexName.map { info in
String.localizedStringWithFormat(
info.nameType == .publicGroup
? NSLocalizedString("Join channel %@", comment: "new chat action")
: NSLocalizedString("Connect to %@", comment: "new chat action"),
info.shortStr
)
}
switch connectionPlan {
case let .invitationLink(ilp):
switch ilp {
@@ -1386,7 +1423,7 @@ func planAndConnect(
}
case let .contactAddress(cap):
switch cap {
case let .ok(contactSLinkData_, ownerVerification, verifiedDomain):
case let .ok(contactSLinkData_, ownerVerification):
if let contactSLinkData = contactSLinkData_ {
logger.debug("planAndConnect, .contactAddress, .ok, short link data present")
await MainActor.run {
@@ -1394,7 +1431,9 @@ func planAndConnect(
connectionLink: connectionLink,
contactShortLinkData: contactSLinkData,
ownerVerification: ownerVerification,
verifiedDomain: verifiedDomain,
verifiedDomain: planSimplexName?.nameDomain,
connectOtherButton: connectOtherButton,
connectOtherLink: connectOtherLink,
theme: theme,
dismiss: dismiss,
cleanup: cleanup
@@ -1443,7 +1482,7 @@ func planAndConnect(
if let f = filterKnownContact {
f(contact)
} else {
showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss)
showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink)
}
}
case let .known(contact):
@@ -1455,7 +1494,7 @@ func planAndConnect(
if let f = filterKnownContact {
f(contact)
} else {
showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss)
showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink)
}
}
case let .contactViaAddress(contact):
@@ -1470,7 +1509,7 @@ func planAndConnect(
}
case let .groupLink(glp):
switch glp {
case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification, verifiedDomain):
case let .ok(groupShortLinkInfo_, groupSLinkData_, ownerVerification):
if let groupSLinkData = groupSLinkData_ {
logger.debug("planAndConnect, .groupLink, .ok, short link data present")
await MainActor.run {
@@ -1479,7 +1518,9 @@ func planAndConnect(
groupShortLinkInfo: groupShortLinkInfo_,
groupShortLinkData: groupSLinkData,
ownerVerification: ownerVerification,
verifiedDomain: verifiedDomain,
verifiedDomain: planSimplexName?.nameDomain,
connectOtherButton: connectOtherButton,
connectOtherLink: connectOtherLink,
theme: theme,
dismiss: dismiss,
cleanup: cleanup
@@ -1538,7 +1579,7 @@ func planAndConnect(
if let f = filterKnownGroup {
f(groupInfo)
} else {
showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss)
showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss, connectOtherButton: connectOtherButton, connectOtherLink: connectOtherLink)
}
}
case let .noRelays(groupSLinkData_):
+1
View File
@@ -763,6 +763,7 @@ public enum ChatErrorType: Decodable, Hashable {
case chatStoreChanged
case invalidConnReq
case simplexDomainNotReady(simplexDomain: SimplexDomain, simplexDomainError: SimplexDomainError)
case notResolvedLocally
case unsupportedConnReq
case invalidChatMessage(connection: Connection, message: String)
case connReqMessageProhibited
+9
View File
@@ -5290,6 +5290,15 @@ public struct SimplexNameInfo: Codable, Equatable, Hashable {
public var nameType: SimplexNameType
public var nameDomain: SimplexDomain
// mirrors backend shortNameInfoStr: "#name" for a simplex public group, else prefix + full domain
public var shortStr: String {
if nameType == .publicGroup && nameDomain.nameTLD == .simplex && nameDomain.subDomain.isEmpty {
return "#" + nameDomain.domain
} else {
return (nameType == .publicGroup ? "#" : "@") + nameDomain.fullDomainName
}
}
public init(nameType: SimplexNameType, nameDomain: SimplexDomain) {
self.nameType = nameType
self.nameDomain = nameDomain
@@ -4887,7 +4887,13 @@ enum class SimplexLinkType(val linkType: String) {
data class SimplexNameInfo(
val nameType: SimplexNameType,
val nameDomain: SimplexDomain
)
) {
// mirrors backend shortNameInfoStr: "#name" for a simplex public group, else prefix + full domain
val shortStr: String get() = when {
nameType == SimplexNameType.publicGroup && nameDomain.nameTLD == SimplexTLD.simplex && nameDomain.subDomain.isEmpty() -> "#" + nameDomain.domain
else -> (if (nameType == SimplexNameType.publicGroup) "#" else "@") + nameDomain.fullDomainName
}
}
@Serializable
data class SimplexDomain(
@@ -1517,10 +1517,12 @@ object ChatController {
return null
}
suspend fun apiConnectPlan(rh: Long?, connLink: String, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState<Boolean>): Pair<CreatedConnLink, ConnectionPlan>? {
suspend fun apiConnectPlan(rh: Long?, connLink: String, resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState<Boolean>): ConnectionPlanResult? {
val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null }
val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink, linkOwnerSig), inProgress = inProgress)
if (r is API.Result && r.res is CR.CRConnectionPlan) return r.res.connLink to r.res.connectionPlan
val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink, resolveMode, linkOwnerSig), inProgress = inProgress)
if (r is API.Result && r.res is CR.CRConnectionPlan) return ConnectionPlanResult(r.res.connLink, r.res.planSimplexName, r.res.otherSimplexName, r.res.connectionPlan)
// a PRMNever (typing) search that matches nothing locally is not an error to surface
if (r is API.Error && r.err is ChatError.ChatErrorChat && r.err.errorType is ChatErrorType.NotResolvedLocally) return null
if (inProgress.value && r != null) apiConnectResponseAlert(r)
return null
}
@@ -3861,7 +3863,7 @@ sealed class CC {
class APIAddContact(val userId: Long, val incognito: Boolean): CC()
class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC()
class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC()
class APIConnectPlan(val userId: Long, val connLink: String, val linkOwnerSig: LinkOwnerSig? = null): CC()
class APIConnectPlan(val userId: Long, val connLink: String, val resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, val linkOwnerSig: LinkOwnerSig? = null): CC()
class APIPrepareContact(val userId: Long, val connLink: CreatedConnLink, val contactShortLinkData: ContactShortLinkData, val verifiedDomain: SimplexDomain? = null): CC()
class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val directLink: Boolean, val groupShortLinkData: GroupShortLinkData, val verifiedDomain: SimplexDomain? = null): CC()
class APIChangePreparedContactUser(val contactId: Long, val newUserId: Long): CC()
@@ -4070,8 +4072,9 @@ sealed class CC {
is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}"
is ApiChangeConnectionUser -> "/_set conn user :$connId $userId"
is APIConnectPlan -> {
val resolveStr = if (resolveMode != PlanResolveMode.PRMUnknown) " resolve=${resolveMode.cmdString}" else ""
val sigStr = if (linkOwnerSig != null) " sig=${json.encodeToString(linkOwnerSig)}" else ""
"/_connect plan $userId $connLink$sigStr"
"/_connect plan $userId $connLink$resolveStr$sigStr"
}
is APIPrepareContact -> "/_prepare contact $userId ${connLink.cmdString}${verifiedDomain?.let { " ${it.cmdString}" } ?: ""} ${json.encodeToString(contactShortLinkData)}"
is APIPrepareGroup -> "/_prepare group $userId ${connLink.cmdString} direct=${onOff(directLink)}${verifiedDomain?.let { " ${it.cmdString}" } ?: ""} ${json.encodeToString(groupShortLinkData)}"
@@ -6513,7 +6516,7 @@ sealed class CR {
@Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connLinkInvitation: CreatedConnLink, val connection: PendingContactConnection): CR()
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR()
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connLink: CreatedConnLink, val connectionPlan: ConnectionPlan): CR()
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connLink: CreatedConnLink, val planSimplexName: SimplexNameInfo? = null, val otherSimplexName: SimplexNameInfo? = null, val connectionPlan: ConnectionPlan): CR()
@Serializable @SerialName("newPreparedChat") class NewPreparedChat(val user: UserRef, val chat: Chat): CR()
@Serializable @SerialName("contactUserChanged") class ContactUserChanged(val user: UserRef, val fromContact: Contact, val newUser: UserRef, val toContact: Contact): CR()
@Serializable @SerialName("groupUserChanged") class GroupUserChanged(val user: UserRef, val fromGroup: GroupInfo, val newUser: UserRef, val toGroup: GroupInfo): CR()
@@ -7103,6 +7106,23 @@ sealed class SimplexDomainError {
@Serializable @SerialName("unknownDomain") object UnknownDomain : SimplexDomainError()
}
data class ConnectionPlanResult(
val connLink: CreatedConnLink,
val planSimplexName: SimplexNameInfo?,
val otherSimplexName: SimplexNameInfo?,
val connectionPlan: ConnectionPlan,
)
// APIConnectPlan resolution scope; PRMNever is local-store-only (no network), used for per-keystroke name search
enum class PlanResolveMode {
PRMAllGroups, PRMUnknown, PRMNever;
val cmdString: String get() = when (this) {
PRMAllGroups -> "allGroups"
PRMUnknown -> "unknown"
PRMNever -> "never"
}
}
@Serializable
sealed class ConnectionPlan {
@Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan()
@@ -7121,7 +7141,7 @@ sealed class InvitationLinkPlan {
@Serializable
sealed class ContactAddressPlan {
@Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val verifiedDomain: SimplexDomain? = null): ContactAddressPlan()
@Serializable @SerialName("ok") class Ok(val contactSLinkData_: ContactShortLinkData? = null, val ownerVerification: OwnerVerification? = null): ContactAddressPlan()
@Serializable @SerialName("ownLink") object OwnLink: ContactAddressPlan()
@Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan()
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan()
@@ -7131,7 +7151,7 @@ sealed class ContactAddressPlan {
@Serializable
sealed class GroupLinkPlan {
@Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null, val verifiedDomain: SimplexDomain? = null): GroupLinkPlan()
@Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null, val ownerVerification: OwnerVerification? = null): GroupLinkPlan()
@Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan()
@Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan()
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan()
@@ -7441,6 +7461,7 @@ sealed class ChatErrorType {
is ConnectionPlanChatError -> "connectionPlan"
is InvalidConnReq -> "invalidConnReq"
is SimplexDomainNotReady -> "simplexDomainNotReady"
is NotResolvedLocally -> "notResolvedLocally"
is UnsupportedConnReq -> "unsupportedConnReq"
is InvalidChatMessage -> "invalidChatMessage"
is ConnReqMessageProhibited -> "connReqMessageProhibited"
@@ -7524,6 +7545,7 @@ sealed class ChatErrorType {
@Serializable @SerialName("connectionPlan") class ConnectionPlanChatError(val connectionPlan: ConnectionPlan): ChatErrorType()
@Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType()
@Serializable @SerialName("simplexDomainNotReady") class SimplexDomainNotReady(val simplexDomain: SimplexDomain, val simplexDomainError: SimplexDomainError): ChatErrorType()
@Serializable @SerialName("notResolvedLocally") object NotResolvedLocally: ChatErrorType()
@Serializable @SerialName("unsupportedConnReq") object UnsupportedConnReq: ChatErrorType()
@Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType()
@Serializable @SerialName("connReqMessageProhibited") object ConnReqMessageProhibited: ChatErrorType()
@@ -49,6 +49,7 @@ import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.json.Json
import kotlin.time.Duration.Companion.seconds
@@ -745,7 +746,7 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
}
@Composable
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<String?>) {
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<Set<String>>, connectNameCandidate: MutableState<String?>) {
Box {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
val focusRequester = remember { FocusRequester() }
@@ -763,6 +764,8 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
searchText = searchText,
enabled = !remember { searchShowingSimplexLink }.value,
trailingContent = null,
// the clear button must line up with the filter icon it replaces, so no reduction here
reducedCloseButtonPadding = 0.dp,
) {
searchText.value = searchText.value.copy(it)
}
@@ -791,30 +794,35 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
LaunchedEffect(Unit) {
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
when (val target = strConnectTarget(it.trim())) {
is ConnectTarget.Link -> {
hideKeyboard(view)
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(target.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
}
is ConnectTarget.Name -> {
// A name lookup means "take me to this contact": open the chat if
// it's already known (visible prompt), unlike a pasted link which
// filters the list. So no filterKnownContact here.
hideKeyboard(view)
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
target.text,
close = null,
cleanup = { searchText.value = TextFieldValue() },
)
.collectLatest {
val target = strConnectTarget(it.trim())
if (target is ConnectTarget.Link) {
hideKeyboard(view)
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = emptySet()
connectNameCandidate.value = null
connect(target.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
} else {
val candidate = nameSearchCandidate(it.trim())
connectNameCandidate.value = candidate
// clear the previous match immediately so the list falls back to text search during the debounce,
// instead of showing a stale filtered chat while the new search runs
searchChatFilteredBySimplexLink.value = emptySet()
if (candidate != null) {
// resolve the name locally on each keystroke, debounced; collectLatest cancels the in-flight
// search when the next keystroke arrives. A bare name can be a contact or a channel, so search
// both and filter every known chat found; drop the row only when both types are already known.
delay(NAME_SEARCH_DEBOUNCE_MS)
val rhId = chatModel.remoteHostId()
val inProgress = mutableStateOf(false) // background search: no spinner, no error alerts
val targets = if (candidate.startsWith("@") || candidate.startsWith("#")) listOf(candidate) else listOf("@$candidate", "#$candidate")
val ids = targets.mapNotNull { name ->
knownChatId(rhId, chatModel.controller.apiConnectPlan(rhId, name, PlanResolveMode.PRMNever, inProgress = inProgress))
}
}
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
searchChatFilteredBySimplexLink.value = ids.toSet()
if (ids.size == targets.size) connectNameCandidate.value = null
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
} else {
@@ -826,7 +834,7 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
}
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
searchChatFilteredBySimplexLink.value = emptySet()
}
}
}
@@ -837,13 +845,13 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
}
}
private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<String?>, cleanup: (() -> Unit)?) {
private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<Set<String>>, cleanup: (() -> Unit)?) {
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
link,
filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id },
filterKnownGroup = { searchChatFilteredBySimplexLink.value = it.id },
filterKnownContact = { searchChatFilteredBySimplexLink.value = setOf(it.id) },
filterKnownGroup = { searchChatFilteredBySimplexLink.value = setOf(it.id) },
close = null,
cleanup = cleanup,
)
@@ -930,7 +938,8 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
// which is related to [derivedStateOf]. Using safe alternative instead
// val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search, allChats.toList()) } }
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<Set<String>>(emptySet()) }
val connectNameCandidate = remember { mutableStateOf<String?>(null) }
val chats = filteredChats(searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList(), activeFilter.value)
val topPaddingToContent = topPaddingToContent(false)
val blankSpaceSize = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else topPaddingToContent
@@ -963,13 +972,23 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
if (oneHandUI.value) {
Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) {
Divider()
TagsView(searchText)
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
// bottom toolbar: search bar below, so on desktop the connect row goes below the tags
TagsOrConnectByName(searchText, connectNameCandidate) { candidate ->
TagsView(searchText)
Divider()
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
}
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
}
} else {
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
TagsView(searchText)
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, connectNameCandidate)
// top toolbar: search bar above, so on desktop the connect row goes above the tags
TagsOrConnectByName(searchText, connectNameCandidate) { candidate ->
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
Divider()
TagsView(searchText)
}
Divider()
}
}
@@ -1017,6 +1036,105 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
}
}
// Default top-level part used to complete a bare name typed in the search field (search field only;
// the message parser and the wire format are unchanged).
private const val DEFAULT_NAME_TLD = "testing"
// Shortest name that offers the button, so it is discoverable but does not flash on a single letter.
private const val MIN_NAME_LENGTH = 2
// Wait this long after the last keystroke before the local name search runs.
internal const val NAME_SEARCH_DEBOUNCE_MS = 300L
private val nameLabelRegex = Regex("[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*")
private fun isNameLabel(s: String): Boolean = s.length in 1..63 && nameLabelRegex.matches(s)
// On-device candidate for connecting by SimpleX name: the string sent to the core to resolve it.
// Mirrors the domain grammar (nameLabelP/mkDomain in SimplexName.hs): an optional @/# prefix, then
// dot-separated ASCII labels; a dotless word is completed with the default top-level part. Returns
// the string to send (keeping @/# so the type is preserved), or null when the text is not a name.
internal fun nameSearchCandidate(str: String): String? {
val text = str.trim()
val prefix = text.firstOrNull()?.takeIf { it == '@' || it == '#' }
val core = if (prefix != null) text.substring(1) else text
val labels = core.split(".")
if (core.isEmpty() || labels.any { !isNameLabel(it) }) return null
return when {
labels.size > 1 -> text // already has a top-level part
core.length >= MIN_NAME_LENGTH -> "${prefix ?: ""}$core.$DEFAULT_NAME_TLD"
else -> null
}
}
// The chat id a local (PRMNever) search resolved to — a contact, a business, or a channel — or null on a miss.
// The core returns the correct type for @ vs # (getContactToConnect / type-filtered getGroupToConnect), so no
// client-side type check is needed.
internal suspend fun knownChatId(rhId: Long?, result: ConnectionPlanResult?): String? = when (val plan = result?.connectionPlan) {
is ConnectionPlan.ContactAddress -> (plan.contactAddressPlan as? ContactAddressPlan.Known)?.contact?.let { contact ->
// a name-resolved chat may be prepared in the store but not yet listed, so add it (as the tap path does)
if (chatModel.getContactChat(contact.contactId) == null) {
chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Direct(contact), chatItems = emptyList()))
}
contact.id
}
is ConnectionPlan.GroupLink -> (when (val g = plan.groupLinkPlan) {
is GroupLinkPlan.Known -> g.groupInfo
is GroupLinkPlan.OwnLink -> g.groupInfo
else -> null
})?.let { gInfo ->
if (chatModel.getGroupChat(gInfo.groupId) == null) {
chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Group(gInfo, groupChatScope = null), chatItems = emptyList()))
}
gInfo.id
}
else -> null
}
// The list tags and the connect-by-name row share one slot. When there is no name, the tags show; on
// mobile the row replaces the tags while shown. On desktop both show, arranged by the caller (which
// knows whether the search bar is above or below), passed as desktopView.
@Composable
private fun TagsOrConnectByName(
searchText: MutableState<TextFieldValue>,
connectNameCandidate: MutableState<String?>,
desktopView: @Composable (candidate: String) -> Unit,
) {
val candidate = connectNameCandidate.value
when {
candidate == null -> TagsView(searchText)
!appPlatform.isDesktop -> ConnectByNameRow(candidate, searchText, connectNameCandidate, close = null)
else -> desktopView(candidate)
}
}
@Composable
internal fun ConnectByNameRow(name: String, searchText: MutableState<TextFieldValue>, connectNameCandidate: MutableState<String?>, close: (() -> Unit)?) {
val view = LocalMultiplatformView()
Row(
Modifier
.fillMaxWidth()
.clickable {
hideKeyboard(view)
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
name,
close = close,
cleanup = {
searchText.value = TextFieldValue()
connectNameCandidate.value = null
},
)
}
}
.padding(vertical = DEFAULT_PADDING_HALF),
verticalAlignment = Alignment.CenterVertically
) {
// icon and text aligned with the search bar's icon and text (same paddings and icon size)
val icon = if (name.startsWith("@")) MR.images.ic_at else MR.images.ic_tag
Icon(painterResource(icon), null, Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(22.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.primary)
Text(String.format(generalGetString(MR.strings.connect_plan_connect_to_name), name), color = MaterialTheme.colors.primary)
}
}
@Composable
private fun NoChatsView(searchText: MutableState<TextFieldValue>) {
val activeFilter = remember { chatModel.activeChatTagFilter }.value
@@ -1324,14 +1442,14 @@ fun ItemPresetFilterAction(
fun filteredChats(
searchShowingSimplexLink: State<Boolean>,
searchChatFilteredBySimplexLink: State<String?>,
searchChatFilteredBySimplexLink: State<Set<String>>,
searchText: String,
chats: List<Chat>,
activeFilter: ActiveFilter? = null,
): List<Chat> {
val linkChatId = searchChatFilteredBySimplexLink.value
return if (linkChatId != null) {
chats.filter { it.id == linkChatId }
val linkChatIds = searchChatFilteredBySimplexLink.value
return if (linkChatIds.isNotEmpty()) {
chats.filter { it.id in linkChatIds }
} else {
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
if (s.isEmpty())
@@ -197,7 +197,7 @@ private fun ShareList(
val chats by remember(search) {
derivedStateOf {
val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready && it.chatInfo.sendMsgEnabled && !(chatModel.sharedContent.value is SharedContent.ChatLink && it.chatInfo is ChatInfo.Local) }.sortedByDescending { it.chatInfo is ChatInfo.Local }
filteredChats(mutableStateOf(false), mutableStateOf(null), search, sorted)
filteredChats(mutableStateOf(false), mutableStateOf<Set<String>>(emptySet()), search, sorted)
}
}
val topPaddingToContent = topPaddingToContent(false)
@@ -291,10 +291,13 @@ class AlertManager {
profileFullName: String,
profileImage: @Composable () -> Unit,
profileBadge: LocalBadge? = null,
nameCaption: String? = null,
subtitle: String? = null,
information: String? = null,
confirmText: String? = generalGetString(MR.strings.connect_plan_open_chat),
onConfirm: (() -> Unit)? = null,
connectOtherButton: String? = null,
onConnectOther: (() -> Unit)? = null,
dismissText: String = generalGetString(MR.strings.cancel_verb),
onDismiss: (() -> Unit)? = null,
) {
@@ -337,6 +340,17 @@ class AlertManager {
modifier = Modifier.fillMaxWidth()
)
if (nameCaption != null) {
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
Text(
nameCaption,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.secondary,
maxLines = 1,
modifier = Modifier.fillMaxWidth()
)
}
if (profileFullName.isNotEmpty() && profileFullName != profileName) {
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
Text(
@@ -388,6 +402,14 @@ class AlertManager {
Text(confirmText)
}
}
if (connectOtherButton != null && onConnectOther != null) {
TextButton(onClick = {
onConnectOther.invoke()
hideAlert()
}) {
Text(connectOtherButton)
}
}
TextButton(onClick = {
onDismiss?.invoke()
hideAlert()
@@ -36,7 +36,7 @@ fun SearchTextField(
placeholder: String = stringResource(MR.strings.search_verb),
enabled: Boolean = true,
trailingContent: @Composable (() -> Unit)? = null,
reducedCloseButtonPadding: Dp = 0.dp,
reducedCloseButtonPadding: Dp = 8.dp,
onValueChange: (String) -> Unit
) {
val focusRequester = remember { FocusRequester() }
@@ -116,7 +116,7 @@ fun SearchTextField(
trailingIcon = if (searchText.value.text.isNotEmpty() || trailingContent != null) {{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.offset(x = 8.dp)
modifier = Modifier.offset(x = reducedCloseButtonPadding)
) {
if (searchText.value.text.isNotEmpty()) {
IconButton({
@@ -74,13 +74,19 @@ private suspend fun planAndConnectTask(
cleanup?.invoke()
completable.complete(!completable.isActive)
}
val result = chatModel.controller.apiConnectPlan(rhId, shortOrFullLink, linkOwnerSig, inProgress = inProgress)
val result = chatModel.controller.apiConnectPlan(rhId, shortOrFullLink, linkOwnerSig = linkOwnerSig, inProgress = inProgress)
connectProgressManager.stopConnectProgress()
if (!inProgress.value) { return completable }
if (result != null) {
val (connectionLink, connectionPlan) = result
val (connectionLink, planSimplexName, otherSimplexName, connectionPlan) = result
val target = strConnectTarget(shortOrFullLink.trim())
val linkText = if (target is ConnectTarget.Link) "<br><br><u>${target.linkText}</u>" else ""
// the name can also resolve to the other kind; its type picks the verb, its short form the label and target
val connectOtherLink = otherSimplexName?.shortStr
val connectOtherButton = otherSimplexName?.let {
val label = if (it.nameType == SimplexNameType.publicGroup) MR.strings.connect_plan_join_name else MR.strings.connect_plan_connect_to_name
generalGetString(label).format(it.shortStr)
}
when (connectionPlan) {
is ConnectionPlan.InvitationLink -> when (connectionPlan.invitationLinkPlan) {
is InvitationLinkPlan.Ok ->
@@ -154,7 +160,9 @@ private suspend fun planAndConnectTask(
connectionLink,
connectionPlan.contactAddressPlan.contactSLinkData_,
ownerVerification = connectionPlan.contactAddressPlan.ownerVerification,
verifiedDomain = connectionPlan.contactAddressPlan.verifiedDomain,
planSimplexName = planSimplexName,
connectOtherButton = connectOtherButton,
connectOtherLink = connectOtherLink,
close,
cleanup
)
@@ -167,6 +175,8 @@ private suspend fun planAndConnectTask(
connectDestructive = false,
cleanup,
ownerVerification = connectionPlan.contactAddressPlan.ownerVerification,
connectOtherButton = connectOtherButton,
connectOtherLink = connectOtherLink,
)
}
ContactAddressPlan.OwnLink -> {
@@ -177,6 +187,8 @@ private suspend fun planAndConnectTask(
text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address) + linkText,
connectDestructive = true,
cleanup = cleanup,
connectOtherButton = connectOtherButton,
connectOtherLink = connectOtherLink,
)
}
ContactAddressPlan.ConnectingConfirmReconnect -> {
@@ -187,6 +199,8 @@ private suspend fun planAndConnectTask(
text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address) + linkText,
connectDestructive = true,
cleanup = cleanup,
connectOtherButton = connectOtherButton,
connectOtherLink = connectOtherLink,
)
}
is ContactAddressPlan.ConnectingProhibit -> {
@@ -195,7 +209,7 @@ private suspend fun planAndConnectTask(
if (filterKnownContact != null) {
filterKnownContact(contact)
} else {
showOpenKnownContactAlert(chatModel, rhId, close, contact)
showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink)
cleanup()
}
}
@@ -211,15 +225,24 @@ private suspend fun planAndConnectTask(
if (filterKnownContact != null) {
filterKnownContact(contact)
} else {
showOpenKnownContactAlert(chatModel, rhId, close, contact)
showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink)
cleanup()
}
}
is ContactAddressPlan.ContactViaAddress -> {
Log.d(TAG, "planAndConnect, .ContactAddress, .ContactViaAddress")
val contact = connectionPlan.contactAddressPlan.contact
askCurrentOrIncognitoProfileConnectContactViaAddress(chatModel, rhId, contact, close, openChat = false)
cleanup()
// the contact is already prepared in the store, so open the existing chat instead of sending a new
// connection request; surface it in the chat list first if it is not there yet (as for Known above)
if (chatModel.getContactChat(contact.contactId) == null) {
chatModel.chatsContext.addChat(Chat(remoteHostId = rhId, chatInfo = ChatInfo.Direct(contact), chatItems = emptyList()))
}
if (filterKnownContact != null) {
filterKnownContact(contact)
} else {
showOpenKnownContactAlert(chatModel, rhId, close, contact, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink)
cleanup()
}
}
}
is ConnectionPlan.GroupLink -> when (connectionPlan.groupLinkPlan) {
@@ -232,7 +255,9 @@ private suspend fun planAndConnectTask(
connectionPlan.groupLinkPlan.groupSLinkInfo_,
connectionPlan.groupLinkPlan.groupSLinkData_,
ownerVerification = connectionPlan.groupLinkPlan.ownerVerification,
verifiedDomain = connectionPlan.groupLinkPlan.verifiedDomain,
planSimplexName = planSimplexName,
connectOtherButton = connectOtherButton,
connectOtherLink = connectOtherLink,
close,
cleanup
)
@@ -245,6 +270,8 @@ private suspend fun planAndConnectTask(
connectDestructive = false,
cleanup = cleanup,
ownerVerification = connectionPlan.groupLinkPlan.ownerVerification,
connectOtherButton = connectOtherButton,
connectOtherLink = connectOtherLink,
)
}
is GroupLinkPlan.OwnLink -> {
@@ -253,7 +280,7 @@ private suspend fun planAndConnectTask(
if (filterKnownGroup != null) {
filterKnownGroup(groupInfo)
} else {
ownGroupLinkConfirmConnect(chatModel, rhId, connectionLink, linkText, connectionPlan, groupInfo, close, cleanup)
ownGroupLinkConfirmConnect(chatModel, rhId, connectionLink, linkText, connectionPlan, groupInfo, close, cleanup, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink)
}
}
GroupLinkPlan.ConnectingConfirmReconnect -> {
@@ -264,6 +291,8 @@ private suspend fun planAndConnectTask(
text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link) + linkText,
connectDestructive = true,
cleanup = cleanup,
connectOtherButton = connectOtherButton,
connectOtherLink = connectOtherLink,
)
}
is GroupLinkPlan.ConnectingProhibit -> {
@@ -301,7 +330,7 @@ private suspend fun planAndConnectTask(
if (filterKnownGroup != null) {
filterKnownGroup(groupInfo)
} else {
showOpenKnownGroupAlert(chatModel, rhId, close, groupInfo)
showOpenKnownGroupAlert(chatModel, rhId, close, groupInfo, planSimplexName = planSimplexName, connectOtherButton = connectOtherButton, connectOtherLink = connectOtherLink)
cleanup()
}
}
@@ -427,6 +456,8 @@ fun askCurrentOrIncognitoProfileAlert(
connectDestructive: Boolean,
cleanup: (() -> Unit)?,
ownerVerification: OwnerVerification? = null,
connectOtherButton: String? = null,
connectOtherLink: String? = null,
) {
val fullText = listOfNotNull(text, ownerVerificationMessage(ownerVerification)).joinToString("\n\n").ifEmpty { null }
AlertManager.privacySensitive.showAlertDialogButtonsColumn(
@@ -451,6 +482,14 @@ fun askCurrentOrIncognitoProfileAlert(
}) {
Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor)
}
if (connectOtherButton != null && connectOtherLink != null) {
SectionItemView({
AlertManager.privacySensitive.hideAlert()
withBGApi { planAndConnect(rhId, connectOtherLink, close = close, cleanup = cleanup) }
}) {
Text(connectOtherButton, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.privacySensitive.hideAlert()
cleanup?.invoke()
@@ -473,7 +512,11 @@ fun openChat_(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, chat: Cha
val alertProfileImageSize = 138.dp
private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, contact: Contact) {
// For alerts that show the name inline (not as a profile with an avatar): "Alice" -> "Alice (@alice.testing)".
private fun nameWithDomain(name: String, planSimplexName: SimplexNameInfo?): String =
name + (planSimplexName?.let { " (${it.shortStr})" } ?: "")
private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, contact: Contact, planSimplexName: SimplexNameInfo? = null, connectOtherButton: String? = null, connectOtherLink: String? = null) {
AlertManager.privacySensitive.showOpenChatAlert(
profileName = contact.profile.displayName,
profileFullName = contact.profile.fullName,
@@ -486,10 +529,13 @@ private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close:
},
// the alert shows the badge inline, so it skips the long-expired (ExpiredOld) badge here too
profileBadge = if (contact.active && contact.profile.localBadge?.status != BadgeStatus.ExpiredOld) contact.profile.localBadge else null,
nameCaption = planSimplexName?.shortStr,
confirmText = generalGetString(if (contact.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat),
onConfirm = {
openKnownContact(chatModel, rhId, close, contact)
},
connectOtherButton = connectOtherButton,
onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close) } } },
onDismiss = null
)
}
@@ -513,11 +559,14 @@ fun ownGroupLinkConfirmConnect(
groupInfo: GroupInfo,
close: (() -> Unit)?,
cleanup: (() -> Unit)?,
planSimplexName: SimplexNameInfo? = null,
connectOtherButton: String? = null,
connectOtherLink: String? = null,
) {
if (groupInfo.useRelays) {
AlertManager.privacySensitive.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel),
text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel_vName), groupInfo.displayName),
text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel_vName), nameWithDomain(groupInfo.displayName, planSimplexName)),
buttons = {
Column {
SectionItemView({
@@ -527,6 +576,14 @@ fun ownGroupLinkConfirmConnect(
}) {
Text(generalGetString(MR.strings.connect_plan_open_channel), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
if (connectOtherButton != null && connectOtherLink != null) {
SectionItemView({
AlertManager.privacySensitive.hideAlert()
withBGApi { planAndConnect(rhId, connectOtherLink, close = close, cleanup = cleanup) }
}) {
Text(connectOtherButton, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.privacySensitive.hideAlert()
cleanup?.invoke()
@@ -585,7 +642,7 @@ fun ownGroupLinkConfirmConnect(
}
}
private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, groupInfo: GroupInfo) {
private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, groupInfo: GroupInfo, planSimplexName: SimplexNameInfo? = null, connectOtherButton: String? = null, connectOtherLink: String? = null) {
val subscriberCount = if (groupInfo.useRelays) groupInfo.groupSummary.publicMemberCount?.let { subscriberCountStr(it) } else null
AlertManager.privacySensitive.showOpenChatAlert(
profileName = groupInfo.groupProfile.displayName,
@@ -597,6 +654,7 @@ private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: ((
icon = groupInfo.chatIconName
)
},
nameCaption = planSimplexName?.shortStr,
subtitle = subscriberCount,
confirmText = generalGetString(
if (groupInfo.useRelays) {
@@ -610,6 +668,8 @@ private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: ((
onConfirm = {
openKnownGroup(chatModel, rhId, close, groupInfo)
},
connectOtherButton = connectOtherButton,
onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close) } } },
onDismiss = null
)
}
@@ -629,7 +689,9 @@ fun showPrepareContactAlert(
connectionLink: CreatedConnLink,
contactShortLinkData: ContactShortLinkData,
ownerVerification: OwnerVerification? = null,
verifiedDomain: SimplexDomain? = null,
planSimplexName: SimplexNameInfo? = null,
connectOtherButton: String? = null,
connectOtherLink: String? = null,
close: (() -> Unit)?,
cleanup: (() -> Unit)?
) {
@@ -647,13 +709,14 @@ fun showPrepareContactAlert(
)
},
profileBadge = if (contactShortLinkData.localBadge?.status == BadgeStatus.ExpiredOld) null else contactShortLinkData.localBadge,
nameCaption = planSimplexName?.shortStr,
information = ownerVerificationMessage(ownerVerification),
confirmText = generalGetString(MR.strings.connect_plan_open_new_chat),
onConfirm = {
AlertManager.privacySensitive.hideAlert()
ModalManager.closeAllModalsEverywhere()
withBGApi {
val chat = chatModel.controller.apiPrepareContact(rhId, connectionLink, contactShortLinkData, verifiedDomain)
val chat = chatModel.controller.apiPrepareContact(rhId, connectionLink, contactShortLinkData, planSimplexName?.nameDomain)
if (chat != null) {
withContext(Dispatchers.Main) {
ChatController.chatModel.chatsContext.addChat(chat)
@@ -663,6 +726,8 @@ fun showPrepareContactAlert(
cleanup?.invoke()
}
},
connectOtherButton = connectOtherButton,
onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, cleanup = cleanup) } } },
onDismiss = {
cleanup?.invoke()
}
@@ -675,7 +740,9 @@ fun showPrepareGroupAlert(
groupShortLinkInfo: GroupShortLinkInfo?,
groupShortLinkData: GroupShortLinkData,
ownerVerification: OwnerVerification? = null,
verifiedDomain: SimplexDomain? = null,
planSimplexName: SimplexNameInfo? = null,
connectOtherButton: String? = null,
connectOtherLink: String? = null,
close: (() -> Unit)?,
cleanup: (() -> Unit)?
) {
@@ -691,6 +758,7 @@ fun showPrepareGroupAlert(
icon = if (isChannel) MR.images.ic_bigtop_updates_circle_filled else MR.images.ic_supervised_user_circle_filled
)
},
nameCaption = planSimplexName?.shortStr,
subtitle = subscriberCount,
information = ownerVerificationMessage(ownerVerification),
confirmText = generalGetString(if (isChannel) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_new_group),
@@ -698,7 +766,7 @@ fun showPrepareGroupAlert(
AlertManager.privacySensitive.hideAlert()
withBGApi {
val directLink = groupShortLinkInfo?.direct ?: true
val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, directLink = directLink, groupShortLinkData, verifiedDomain)
val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, directLink = directLink, groupShortLinkData, planSimplexName?.nameDomain)
if (chat != null) {
withContext(Dispatchers.Main) {
val relays = groupShortLinkInfo?.groupRelays
@@ -715,6 +783,8 @@ fun showPrepareGroupAlert(
cleanup?.invoke()
}
},
connectOtherButton = connectOtherButton,
onConnectOther = connectOtherLink?.let { link -> { withBGApi { planAndConnect(rhId, link, close = close, cleanup = cleanup) } } },
onDismiss = {
cleanup?.invoke()
}
@@ -136,7 +136,8 @@ private fun ModalData.NewChatSheetLayout(
}
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<Set<String>>(emptySet()) }
val connectNameCandidate = remember { mutableStateOf<String?>(null) }
val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
val baseContactTypes = remember { listOf(ContactType.CARD, ContactType.CONTACT_WITH_REQUEST, ContactType.REQUEST, ContactType.RECENT) }
val contactTypes by remember(searchText.value.text.isEmpty()) {
@@ -313,8 +314,13 @@ private fun ModalData.NewChatSheetLayout(
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
connectNameCandidate = connectNameCandidate,
close = close,
)
connectNameCandidate.value?.let { candidate ->
Divider()
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = close)
}
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
}
}
@@ -399,8 +405,13 @@ private fun ModalData.NewChatSheetLayout(
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
connectNameCandidate = connectNameCandidate,
close = close,
)
connectNameCandidate.value?.let { candidate ->
Divider()
ConnectByNameRow(candidate, searchText, connectNameCandidate, close = close)
}
Divider()
}
}
@@ -466,7 +477,8 @@ private fun ContactsSearchBar(
listState: LazyListState,
searchText: MutableState<TextFieldValue>,
searchShowingSimplexLink: MutableState<Boolean>,
searchChatFilteredBySimplexLink: MutableState<String?>,
searchChatFilteredBySimplexLink: MutableState<Set<String>>,
connectNameCandidate: MutableState<String?>,
close: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
@@ -485,6 +497,8 @@ private fun ContactsSearchBar(
alwaysVisible = true,
searchText = searchText,
trailingContent = null,
// the clear button must line up with the filter icon it replaces, so no reduction here
reducedCloseButtonPadding = 0.dp,
) {
searchText.value = searchText.value.copy(it)
}
@@ -523,34 +537,26 @@ private fun ContactsSearchBar(
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
when (val target = strConnectTarget(it.trim())) {
is ConnectTarget.Link -> {
hideKeyboard(view)
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(
link = target.text,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
cleanup = { searchText.value = TextFieldValue() }
)
}
is ConnectTarget.Name -> {
// A name lookup means "take me to this contact": open the chat if
// it's already known (visible prompt), unlike a pasted link which
// filters the list. So no filterKnownContact here.
hideKeyboard(view)
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
target.text,
close = close,
cleanup = { searchText.value = TextFieldValue() },
)
}
}
null -> if (!searchShowingSimplexLink.value || it.isEmpty()) {
val target = strConnectTarget(it.trim())
if (target is ConnectTarget.Link) {
hideKeyboard(view)
searchText.value = searchText.value.copy(target.linkText, selection = TextRange.Zero)
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = emptySet()
connectNameCandidate.value = null
connect(
link = target.text,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
close = close,
cleanup = { searchText.value = TextFieldValue() }
)
} else {
// A name is resolved only when its "Connect to …" row is tapped, not on every keystroke. The
// simplex-name filter is chat-list only: this contacts/deleted view is a scoped subset, so a
// resolved chat id (channel, business, unlisted or active-only contact) may not be present in it.
val candidate = nameSearchCandidate(it.trim())
connectNameCandidate.value = candidate
if (candidate == null && (!searchShowingSimplexLink.value || it.isEmpty())) {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
} else {
@@ -560,7 +566,7 @@ private fun ContactsSearchBar(
}
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
searchChatFilteredBySimplexLink.value = emptySet()
}
}
}
@@ -587,12 +593,12 @@ private fun ToggleFilterButton() {
}
}
private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<String?>, close: () -> Unit, cleanup: (() -> Unit)?) {
private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<Set<String>>, close: () -> Unit, cleanup: (() -> Unit)?) {
withBGApi {
planAndConnect(
chatModel.remoteHostId(),
link,
filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id },
filterKnownContact = { searchChatFilteredBySimplexLink.value = setOf(it.id) },
close = close,
cleanup = cleanup,
)
@@ -602,15 +608,15 @@ private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<
private fun filteredContactChats(
showUnreadAndFavorites: Boolean,
searchShowingSimplexLink: State<Boolean>,
searchChatFilteredBySimplexLink: State<String?>,
searchChatFilteredBySimplexLink: State<Set<String>>,
searchText: String,
contactChats: List<Chat>
): List<Chat> {
val linkChatId = searchChatFilteredBySimplexLink.value
val linkChatIds = searchChatFilteredBySimplexLink.value
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
return if (linkChatId != null) {
contactChats.filter { it.id == linkChatId }
return if (linkChatIds.isNotEmpty()) {
contactChats.filter { it.id in linkChatIds }
} else {
contactChats.filter { chat ->
filterChat(
@@ -666,7 +672,9 @@ private fun ModalData.DeletedContactsView(rh: RemoteHostInfo?, closeDeletedChats
val listState = remember { appBarHandler.listState }
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<Set<String>>(emptySet()) }
// deleted contacts are not connected to by name, so this candidate only stops per-keystroke resolution
val connectNameCandidate = remember { mutableStateOf<String?>(null) }
val showUnreadAndFavorites = remember { appPrefs.showUnreadAndFavorites.state }.value
val allChats by remember(chatModel.chats.value) {
derivedStateOf { filterContactTypes(chatModel.chats.value, listOf(ContactType.CHAT_DELETED)) }
@@ -709,6 +717,7 @@ private fun ModalData.DeletedContactsView(rh: RemoteHostInfo?, closeDeletedChats
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
connectNameCandidate = connectNameCandidate,
close = close,
)
} else {
@@ -718,6 +727,7 @@ private fun ModalData.DeletedContactsView(rh: RemoteHostInfo?, closeDeletedChats
searchText = searchText,
searchShowingSimplexLink = searchShowingSimplexLink,
searchChatFilteredBySimplexLink = searchChatFilteredBySimplexLink,
connectNameCandidate = connectNameCandidate,
close = close,
)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
@@ -15,6 +15,8 @@
<string name="connect_via_link_verb">Connect</string>
<string name="connect_via_link_incognito">Connect incognito</string>
<string name="connect_plan_open_chat">Open chat</string>
<string name="connect_plan_join_name">Join channel %s</string>
<string name="connect_plan_connect_to_name">Connect to %s</string>
<string name="connect_plan_open_new_chat">Open new chat</string>
<string name="connect_plan_open_group">Open group</string>
<string name="connect_plan_open_new_group">Open new group</string>
@@ -560,8 +560,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
<> ".\nIt is hidden from the directory until approved."
notifyAdminUsers $ "The " <> gt <> " " <> groupRef <> " is updated" <> byMember <> "."
sendToApprove g' gr' n'
sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget link)) True Nothing) >>= \case
Right (CRConnectionPlan _ _ (CPGroupLink (GLPKnown {groupInfo = g'}))) ->
sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget link)) PRMAllGroups Nothing) >>= \case
Right (CRConnectionPlan _ _ _ _ (CPGroupLink (GLPKnown {groupInfo = g'}))) ->
case dbOwnerMemberId gr of
Just ownerGMId ->
withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMember db (storeCxt cc) user groupId ownerGMId) >>= \case
@@ -818,11 +818,11 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
forM_ pg_ $ \pg@PublicGroupProfile {groupLink} ->
when (groupRegStatus == GRSActive || pendingApproval groupRegStatus) $ do
let link = ACL SCMContact $ CLShort groupLink
sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget link)) True Nothing) >>= \case
Right (CRConnectionPlan _ _ (CPGroupLink (GLPKnown {groupInfo = g', groupUpdated = BoolDef updated, linkOwners = ListDef owners}))) ->
sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget link)) PRMAllGroups Nothing) >>= \case
Right (CRConnectionPlan _ _ _ _ (CPGroupLink (GLPKnown {groupInfo = g', groupUpdated, linkOwners = ListDef owners}))) ->
checkValidOwner dbOwnerMemberId owners $ do
when updated $ reapprove pg gr groupRegStatus g'
when (updated || summary /= groupSummary g') $ listingsUpdated env
when groupUpdated $ reapprove pg gr groupRegStatus g'
when (groupUpdated || summary /= groupSummary g') $ listingsUpdated env
Left (ChatErrorAgent {agentError = SMP _ err}) | linkDeleted err ->
setGroupStatus logError st env cc groupId GRSRemoved $ \gr' ->
notifyOwner gr' "The channel link is no longer valid.\nThe channel is removed from the directory."
@@ -954,8 +954,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
let link = ACL SCMContact $ CLShort connLink
mId = MemberId oIdBytes
gt' = groupTypeStr gt
sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget link)) True (Just ownerSig)) >>= \case
Right (CRConnectionPlan _ (ACCL SCMContact ccLink) plan) ->
sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget link)) PRMAllGroups (Just ownerSig)) >>= \case
Right (CRConnectionPlan _ (ACCL SCMContact ccLink) _ _ plan) ->
handleGroupLinkPlan ct ccLink mId ownerSig gt' plan
_ -> sendMessage cc ct "Error: could not connect. Please report it to directory admins."
deChatLinkReceived ct (MCLGroup {groupProfile = GroupProfile {publicGroup = Just pg}}) _ =
@@ -984,8 +984,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
(_, Just (OVFailed reason)) -> sendMessage cc ct $ "Link signature verification failed: " <> reason <> ".\nYou must be the " <> gt <> " owner to register it."
(Nothing, _) -> sendMessage cc ct $ "Error: no " <> gt <> " information available via the link."
_ -> sendMessage cc ct $ "Error: could not verify " <> gt <> " ownership. Please report it to directory admins."
GLPKnown {groupInfo = g, groupUpdated = BoolDef updated, ownerVerification} -> case ownerVerification of
Just OVVerified -> deReregistration ct g updated ownerSig
GLPKnown {groupInfo = g, groupUpdated, ownerVerification} -> case ownerVerification of
Just OVVerified -> deReregistration ct g groupUpdated ownerSig
Just (OVFailed reason) -> sendMessage cc ct $ "Link signature verification failed: " <> reason <> ".\nYou must be the " <> gt <> " owner to register it."
Nothing -> sendMessage cc ct $ "Error: could not verify " <> gt <> " ownership."
GLPConnectingProhibit _ -> sendMessage cc ct $ "Already connecting to this " <> gt <> "."
+3 -1
View File
@@ -1370,7 +1370,7 @@ Determine SimpleX link type and if the bot is already connected via this link or
**Parameters**:
- userId: int64
- connectTarget: string?
- resolveKnown: bool
- resolveMode: [PlanResolveMode](./TYPES.md#planresolvemode)
- linkOwnerSig: [LinkOwnerSig](./TYPES.md#linkownersig)?
**Syntax**:
@@ -1393,6 +1393,8 @@ ConnectionPlan: Connection link information.
- type: "connectionPlan"
- user: [User](./TYPES.md#user)
- connLink: [CreatedConnLink](./TYPES.md#createdconnlink)
- planSimplexName: [SimplexNameInfo](./TYPES.md#simplexnameinfo)?
- otherSimplexName: [SimplexNameInfo](./TYPES.md#simplexnameinfo)?
- connectionPlan: [ConnectionPlan](./TYPES.md#connectionplan)
ChatCmdError: Command error (only used in WebSockets API).
+14 -2
View File
@@ -146,6 +146,7 @@ This file is generated automatically.
- [OwnerVerification](#ownerverification)
- [PaginationByTime](#paginationbytime)
- [PendingContactConnection](#pendingcontactconnection)
- [PlanResolveMode](#planresolvemode)
- [PrefEnabled](#prefenabled)
- [Preferences](#preferences)
- [PreparedContact](#preparedcontact)
@@ -1116,6 +1117,9 @@ SimplexDomainNotReady:
- simplexDomain: [SimplexDomain](#simplexdomain)
- simplexDomainError: [SimplexDomainError](#simplexdomainerror)
NotResolvedLocally:
- type: "notResolvedLocally"
UnsupportedConnReq:
- type: "unsupportedConnReq"
@@ -1796,7 +1800,6 @@ Ok:
- type: "ok"
- contactSLinkData_: [ContactShortLinkData](#contactshortlinkdata)?
- ownerVerification: [OwnerVerification](#ownerverification)?
- verifiedDomain: [SimplexDomain](#simplexdomain)?
OwnLink:
- type: "ownLink"
@@ -2393,7 +2396,6 @@ Ok:
- groupSLinkInfo_: [GroupShortLinkInfo](#groupshortlinkinfo)?
- groupSLinkData_: [GroupShortLinkData](#groupshortlinkdata)?
- ownerVerification: [OwnerVerification](#ownerverification)?
- verifiedDomain: [SimplexDomain](#simplexdomain)?
OwnLink:
- type: "ownLink"
@@ -3076,6 +3078,16 @@ count=<count>
- updatedAt: UTCTime
---
## PlanResolveMode
**Enum type**:
- "allGroups"
- "unknown"
- "never"
---
## PrefEnabled
+2
View File
@@ -328,6 +328,7 @@ chatTypesDocsData =
(sti @NoteFolder, STRecord, "", [], "", ""),
(sti @OwnerVerification, STUnion, "OV", [], "", ""),
(sti @PendingContactConnection, STRecord, "", [], "", ""),
(sti @PlanResolveMode, STEnum, "PRM", [], "", ""),
(sti @PrefEnabled, STRecord, "", [], "", ""),
(sti @Preferences, STRecord, "", [], "", ""),
(sti @PreparedContact, STRecord, "", [], "", ""),
@@ -559,6 +560,7 @@ deriving instance Generic NewUser
deriving instance Generic NoteFolder
deriving instance Generic OwnerVerification
deriving instance Generic PendingContactConnection
deriving instance Generic PlanResolveMode
deriving instance Generic PrefEnabled
deriving instance Generic Preferences
deriving instance Generic PreparedContact
@@ -500,7 +500,7 @@ export namespace APIAddContact {
export interface APIConnectPlan {
userId: number // int64
connectTarget?: string
resolveKnown: boolean
resolveMode: T.PlanResolveMode
linkOwnerSig?: T.LinkOwnerSig
}
@@ -186,6 +186,8 @@ export namespace CR {
type: "connectionPlan"
user: T.User
connLink: T.CreatedConnLink
planSimplexName?: T.SimplexNameInfo
otherSimplexName?: T.SimplexNameInfo
connectionPlan: T.ConnectionPlan
}
@@ -1043,6 +1043,7 @@ export type ChatErrorType =
| ChatErrorType.ChatStoreChanged
| ChatErrorType.InvalidConnReq
| ChatErrorType.SimplexDomainNotReady
| ChatErrorType.NotResolvedLocally
| ChatErrorType.UnsupportedConnReq
| ChatErrorType.ConnReqMessageProhibited
| ChatErrorType.ContactNotReady
@@ -1121,6 +1122,7 @@ export namespace ChatErrorType {
| "chatStoreChanged"
| "invalidConnReq"
| "simplexDomainNotReady"
| "notResolvedLocally"
| "unsupportedConnReq"
| "connReqMessageProhibited"
| "contactNotReady"
@@ -1281,6 +1283,10 @@ export namespace ChatErrorType {
simplexDomainError: SimplexDomainError
}
export interface NotResolvedLocally extends Interface {
type: "notResolvedLocally"
}
export interface UnsupportedConnReq extends Interface {
type: "unsupportedConnReq"
}
@@ -2049,7 +2055,6 @@ export namespace ContactAddressPlan {
type: "ok"
contactSLinkData_?: ContactShortLinkData
ownerVerification?: OwnerVerification
verifiedDomain?: SimplexDomain
}
export interface OwnLink extends Interface {
@@ -2681,7 +2686,6 @@ export namespace GroupLinkPlan {
groupSLinkInfo_?: GroupShortLinkInfo
groupSLinkData_?: GroupShortLinkData
ownerVerification?: OwnerVerification
verifiedDomain?: SimplexDomain
}
export interface OwnLink extends Interface {
@@ -3344,6 +3348,12 @@ export interface PendingContactConnection {
updatedAt: string // ISO-8601 timestamp
}
export enum PlanResolveMode {
AllGroups = "allGroups",
Unknown = "unknown",
Never = "never",
}
export interface PrefEnabled {
forUser: boolean
forContact: boolean
@@ -439,7 +439,7 @@ APIAddContact_Response = CR.Invitation | CR.ChatCmdError
class APIConnectPlan(TypedDict):
userId: int # int64
connectTarget: NotRequired[str]
resolveKnown: bool
resolveMode: "T.PlanResolveMode"
linkOwnerSig: NotRequired["T.LinkOwnerSig"]
@@ -57,6 +57,8 @@ class ConnectionPlan(TypedDict):
type: Literal["connectionPlan"]
user: "T.User"
connLink: "T.CreatedConnLink"
planSimplexName: NotRequired["T.SimplexNameInfo"]
otherSimplexName: NotRequired["T.SimplexNameInfo"]
connectionPlan: "T.ConnectionPlan"
class ContactAlreadyExists(TypedDict):
@@ -793,6 +793,9 @@ class ChatErrorType_simplexDomainNotReady(TypedDict):
simplexDomain: "SimplexDomain"
simplexDomainError: "SimplexDomainError"
class ChatErrorType_notResolvedLocally(TypedDict):
type: Literal["notResolvedLocally"]
class ChatErrorType_unsupportedConnReq(TypedDict):
type: Literal["unsupportedConnReq"]
@@ -1024,6 +1027,7 @@ ChatErrorType = (
| ChatErrorType_chatStoreChanged
| ChatErrorType_invalidConnReq
| ChatErrorType_simplexDomainNotReady
| ChatErrorType_notResolvedLocally
| ChatErrorType_unsupportedConnReq
| ChatErrorType_connReqMessageProhibited
| ChatErrorType_contactNotReady
@@ -1080,7 +1084,7 @@ ChatErrorType = (
| ChatErrorType_exception
)
ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "simplexDomainNotReady", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"]
ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "simplexDomainNotReady", "notResolvedLocally", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"]
ChatFeature = Literal["timedMessages", "fullDelete", "reactions", "voice", "files", "calls", "sessions"]
@@ -1428,7 +1432,6 @@ class ContactAddressPlan_ok(TypedDict):
type: Literal["ok"]
contactSLinkData_: NotRequired["ContactShortLinkData"]
ownerVerification: NotRequired["OwnerVerification"]
verifiedDomain: NotRequired["SimplexDomain"]
class ContactAddressPlan_ownLink(TypedDict):
type: Literal["ownLink"]
@@ -1868,7 +1871,6 @@ class GroupLinkPlan_ok(TypedDict):
groupSLinkInfo_: NotRequired["GroupShortLinkInfo"]
groupSLinkData_: NotRequired["GroupShortLinkData"]
ownerVerification: NotRequired["OwnerVerification"]
verifiedDomain: NotRequired["SimplexDomain"]
class GroupLinkPlan_ownLink(TypedDict):
type: Literal["ownLink"]
@@ -2338,6 +2340,8 @@ class PendingContactConnection(TypedDict):
createdAt: str # ISO-8601 timestamp
updatedAt: str # ISO-8601 timestamp
PlanResolveMode = Literal["allGroups", "unknown", "never"]
class PrefEnabled(TypedDict):
forUser: bool
forContact: bool
+22 -5
View File
@@ -529,7 +529,7 @@ data ChatCommand
| AddContact IncognitoEnabled
| APISetConnectionIncognito Int64 IncognitoEnabled
| APIChangeConnectionUser Int64 UserId -- new user id to switch connection to
| APIConnectPlan {userId :: UserId, connectTarget :: Maybe AConnectTarget, resolveKnown :: Bool, linkOwnerSig :: Maybe LinkOwnerSig} -- Maybe AConnectTarget is used to report parsing failure as special error
| APIConnectPlan {userId :: UserId, connectTarget :: Maybe AConnectTarget, resolveMode :: PlanResolveMode, linkOwnerSig :: Maybe LinkOwnerSig} -- Maybe AConnectTarget is used to report parsing failure as special error
| APIPrepareContact UserId ACreatedConnLink (Maybe SimplexDomain) ContactShortLinkData
| APIPrepareGroup UserId CreatedLinkContact DirectLink (Maybe SimplexDomain) GroupShortLinkData
| APIChangePreparedContactUser ContactId UserId
@@ -670,6 +670,22 @@ data ChatCommand
CustomChatCommand ByteString
deriving (Show)
data PlanResolveMode
= PRMAllGroups -- resolve all known groups and all unknown chats
| PRMUnknown -- only resolve if chat is unknown (default)
| PRMNever -- do not resolve links and names, only do local search
deriving (Eq, Show)
planResolveModeP :: A.Parser PlanResolveMode
planResolveModeP =
A.takeTill (== ' ') >>= \case
"allGroups" -> pure PRMAllGroups
"on" -> pure PRMAllGroups
"unknown" -> pure PRMUnknown
"off" -> pure PRMUnknown
"never" -> pure PRMNever
_ -> fail "bad PlanResolveMode"
allowRemoteCommand :: ChatCommand -> Bool -- XXX: consider using Relay/Block/ForceLocal
allowRemoteCommand = \case
StartChat {} -> False
@@ -820,7 +836,7 @@ data ChatResponse
| CRInvitation {user :: User, connLinkInvitation :: CreatedLinkInvitation, connection :: PendingContactConnection}
| CRConnectionIncognitoUpdated {user :: User, toConnection :: PendingContactConnection, customUserProfile :: Maybe Profile}
| CRConnectionUserChanged {user :: User, fromConnection :: PendingContactConnection, toConnection :: PendingContactConnection, newUser :: User}
| CRConnectionPlan {user :: User, connLink :: ACreatedConnLink, connectionPlan :: ConnectionPlan}
| CRConnectionPlan {user :: User, connLink :: ACreatedConnLink, planSimplexName :: Maybe SimplexNameInfo, otherSimplexName :: Maybe SimplexNameInfo, connectionPlan :: ConnectionPlan}
| CRNewPreparedChat {user :: User, chat :: AChat}
| CRContactUserChanged {user :: User, fromContact :: Contact, newUser :: User, toContact :: Contact}
| CRGroupUserChanged {user :: User, fromGroup :: GroupInfo, newUser :: User, toGroup :: GroupInfo}
@@ -1096,7 +1112,7 @@ data InvitationLinkPlan
deriving (Show)
data ContactAddressPlan
= CAPOk {contactSLinkData_ :: Maybe ContactShortLinkData, ownerVerification :: Maybe OwnerVerification, verifiedDomain :: Maybe SimplexDomain}
= CAPOk {contactSLinkData_ :: Maybe ContactShortLinkData, ownerVerification :: Maybe OwnerVerification}
| CAPOwnLink
| CAPConnectingConfirmReconnect
| CAPConnectingProhibit {contact :: Contact}
@@ -1105,11 +1121,11 @@ data ContactAddressPlan
deriving (Show)
data GroupLinkPlan
= GLPOk {groupSLinkInfo_ :: Maybe GroupShortLinkInfo, groupSLinkData_ :: Maybe GroupShortLinkData, ownerVerification :: Maybe OwnerVerification, verifiedDomain :: Maybe SimplexDomain}
= GLPOk {groupSLinkInfo_ :: Maybe GroupShortLinkInfo, groupSLinkData_ :: Maybe GroupShortLinkData, ownerVerification :: Maybe OwnerVerification}
| GLPOwnLink {groupInfo :: GroupInfo}
| GLPConnectingConfirmReconnect
| GLPConnectingProhibit {groupInfo_ :: Maybe GroupInfo}
| GLPKnown {groupInfo :: GroupInfo, groupUpdated :: BoolDef, ownerVerification :: Maybe OwnerVerification, linkOwners :: ListDef GroupLinkOwner}
| GLPKnown {groupInfo :: GroupInfo, groupUpdated :: Bool, ownerVerification :: Maybe OwnerVerification, linkOwners :: ListDef GroupLinkOwner}
| GLPNoRelays {groupSLinkData_ :: Maybe GroupShortLinkData}
| GLPUpdateRequired {groupSLinkData_ :: Maybe GroupShortLinkData}
deriving (Show)
@@ -1433,6 +1449,7 @@ data ChatErrorType
| CEChatStoreChanged
| CEInvalidConnReq
| CESimplexDomainNotReady {simplexDomain :: SimplexDomain, simplexDomainError :: SimplexDomainError}
| CENotResolvedLocally -- a name or link is not a known chat in the local store and online resolution is off (PRMNever)
| CEUnsupportedConnReq
| CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String}
| CEConnReqMessageProhibited
+108 -66
View File
@@ -2058,8 +2058,9 @@ processChatCommand cxt nm = \case
createDirectConnection db newUser agConnId ccLink' Nothing ConnNew Nothing subMode initialChatVersion PQSupportOn
deleteAgentConnectionAsync (aConnId' conn)
pure conn'
APIConnectPlan userId (Just ct) resolveKnown linkOwnerSig_ -> withUserId userId $ \user ->
uncurry (CRConnectionPlan user) <$> connectPlan user ct resolveKnown linkOwnerSig_
APIConnectPlan userId (Just ct) resolveMode linkOwnerSig_ -> withUserId userId $ \user -> do
(ccLink, planSimplexName, otherSimplexName, plan) <- connectPlan user ct resolveMode linkOwnerSig_ Nothing
pure $ CRConnectionPlan user ccLink planSimplexName otherSimplexName plan
APIConnectPlan _ Nothing _ _ -> throwChatError CEInvalidConnReq
APIPrepareContact userId accLink verifiedDomain contactSLinkData -> withUserId userId $ \user -> do
let ContactShortLinkData {profile, message, business} = contactSLinkData
@@ -2283,12 +2284,12 @@ processChatCommand cxt nm = \case
CVRSentInvitation conn incognitoProfile -> pure $ CRSentInvitation user (mkPendingContactConnection conn Nothing) incognitoProfile
APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq
Connect incognito (Just ct) -> withUser $ \user -> do
let con m cReq = pure (ACCL m (CCLink cReq Nothing), CPInvitationLink (ILPOk Nothing Nothing))
(ccLink, plan) <- connectPlan user ct False Nothing `catchAllErrors` \e -> case ct of
let con m cReq = pure (ACCL m (CCLink cReq Nothing), Nothing, Nothing, CPInvitationLink (ILPOk Nothing Nothing))
(ccLink, planSimplexName, otherSimplexName, plan) <- connectPlan user ct PRMUnknown Nothing Nothing `catchAllErrors` \e -> case ct of
ACTarget m (CTFullContact cReq) -> con m cReq
ACTarget m (CTInv (CLFull cReq)) -> con m cReq
_ -> throwError e
connectWithPlan user incognito ccLink plan
connectWithPlan user incognito ccLink planSimplexName otherSimplexName plan
Connect _ Nothing -> throwChatError CEInvalidConnReq
APIVerifyContactDomain contactId -> withUser $ \user -> do
ct@Contact {profile = LocalProfile {contactDomain}, preparedContact} <- withFastStore $ \db -> getContact db cxt user contactId
@@ -2319,8 +2320,8 @@ processChatCommand cxt nm = \case
toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct')
throwError e
ConnectSimplex incognito -> withUser $ \user -> do
plan <- contactRequestPlan user adminContactReq Nothing Nothing `catchAllErrors` const (pure $ CPContactAddress (CAPOk Nothing Nothing Nothing))
connectWithPlan user incognito (ACCL SCMContact (CCLink adminContactReq Nothing)) plan
plan <- contactRequestPlan user adminContactReq Nothing Nothing `catchAllErrors` const (pure $ CPContactAddress (CAPOk Nothing Nothing))
connectWithPlan user incognito (ACCL SCMContact (CCLink adminContactReq Nothing)) Nothing Nothing plan
DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId Nothing) cdm
ClearContact cName -> withContactName cName $ \chatId -> APIClearChat $ ChatRef CTDirect chatId Nothing
APIListContacts userId -> withUserId userId $ \user ->
@@ -4178,13 +4179,13 @@ processChatCommand cxt nm = \case
pure (gId, chatSettings)
_ -> throwCmdError "not supported"
processChatCommand cxt nm $ APISetChatSettings (ChatRef cType chatId Nothing) $ updateSettings chatSettings
connectPlan :: User -> AConnectTarget -> Bool -> Maybe LinkOwnerSig -> CM (ACreatedConnLink, ConnectionPlan)
connectPlan user (ACTarget SCMInvitation (CTInv cLink)) _ sig_ = case cLink of
connectPlan :: User -> AConnectTarget -> PlanResolveMode -> Maybe LinkOwnerSig -> Maybe (Either ChatError NameRecord) -> CM (ACreatedConnLink, Maybe SimplexNameInfo, Maybe SimplexNameInfo, ConnectionPlan)
connectPlan user (ACTarget SCMInvitation (CTInv cLink)) _ sig_ _ = case cLink of
CLFull cReq -> invitationReqAndPlan cReq Nothing Nothing Nothing
CLShort l -> do
let l' = serverShortLink l
knownLinkPlans l' >>= \case
Just r -> pure r
Just (l, p) -> pure (l, Nothing, Nothing, p)
Nothing -> do
(FixedLinkData {linkConnReq = cReq, rootKey}, cData) <- getShortLinkConnReq nm user l'
contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData)
@@ -4199,27 +4200,56 @@ processChatCommand cxt nm = \case
Nothing -> bimap inv (CPInvitationLink . ILPKnown) <$$> getContactViaShortLinkToConnect db cxt user l'
invitationReqAndPlan cReq sLnk_ cld ov = do
plan <- invitationRequestPlan user cReq cld ov `catchAllErrors` (pure . CPError)
pure (ACCL SCMInvitation (CCLink cReq sLnk_), plan)
connectPlan user (ACTarget SCMContact ct) resolveKnown sig_ = case ct of
pure (ACCL SCMInvitation (CCLink cReq sLnk_), Nothing, Nothing, plan)
connectPlan user (ACTarget SCMContact ct) resolveMode sig_ nameRec = case ct of
CTDomain d
-- local search only: look up #d then @d in the store, without online name resolution
| resolveMode == PRMNever -> connectPlanNoName $ ChatError CENotResolvedLocally
| otherwise ->
tryAllErrors (withAgent $ \a -> resolveSimplexName a nm (aUserId user) d) >>= \case
Right nr
| isJust (firstNameLink CCTChannel (nrSimplexChannel nr)) ->
(addOther nr <$> connectPlanName NTPublicGroup (Right nr)) `catchAllErrors` \e ->
(addOther nr <$> connectPlanName NTContact (Right nr) `catchAllErrors` \_ -> throwError e)
| isJust (firstNameLink CCTContact (nrSimplexContact nr)) ->
addOther nr <$> connectPlanName NTContact (Right nr)
| otherwise -> connectPlanNoName $ ChatError $ CESimplexDomainNotReady d SDENoValidLink
Left e -> connectPlanNoName e
where
connectPlanName nameType nr_ = connectPlan user connTarget resolveMode sig_ (Just nr_)
where
connTarget = ACTarget SCMContact $ CTShortContact $ CTName $ SimplexNameInfo nameType d
connectPlanNoName e =
connectPlanName NTPublicGroup (Left e) `catchAllErrors` \e' ->
(connectPlanName NTContact (Left e) `catchAllErrors` \_ -> throwError e')
-- the same domain can resolve to both an @ name (contact or business) and a # channel;
-- keyed off the resolved name's type, so a contact name returning a business group still offers the channel
addOther nr (l, planName, _, p) = (l, planName, otherName, p)
where
otherName = case planName of
Just (SimplexNameInfo NTContact _) | isJust (firstNameLink CCTChannel (nrSimplexChannel nr)) -> Just $ SimplexNameInfo NTPublicGroup d
Just (SimplexNameInfo NTPublicGroup _) | isJust (firstNameLink CCTContact (nrSimplexContact nr)) -> Just $ SimplexNameInfo NTContact d
_ -> Nothing
CTFullContact cReq -> do
plan <- contactOrGroupRequestPlan user cReq `catchAllErrors` (pure . CPError)
pure (ACCL SCMContact $ CCLink cReq Nothing, plan)
pure (ACCL SCMContact $ CCLink cReq Nothing, Nothing, Nothing, plan)
CTShortContact nl ->
case ctType of
(\(l, p) -> (l, simplexName_, Nothing, p)) <$> case ctType of
CCTContact ->
knownLinkPlans >>= \case
Just r -> pure r
Nothing -> do
when (resolveMode == PRMNever) $ throwChatError CENotResolvedLocally
l' <- resolveSLink
(FixedLinkData {linkConnReq = cReq, rootKey}, cData) <- getShortLinkConnReq nm user l'
contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData)
let linkProfile_ = (\ContactShortLinkData {profile} -> profile) <$> contactSLinkData_
linkDomain_ = linkProfile_ >>= \Profile {contactDomain} -> claimDomain <$> contactDomain
verifiedDomain = case nl of CTName ni -> Just (nameDomain ni); _ -> Nothing
refreshContact ct' = case (verifiedDomain, linkProfile_) of
planDomain = case nl of CTName ni -> Just (nameDomain ni); _ -> Nothing
refreshContact ct' = case (planDomain, linkProfile_) of
(Just _, Just p) -> updateContactFromLinkData user ct' p
_ -> pure ct'
forM_ verifiedDomain $ \nameDomain ->
forM_ planDomain $ \nameDomain ->
unless (linkDomain_ == Just nameDomain) $ throwChatError $ CESimplexDomainNotReady nameDomain SDEUnknownDomain
withFastStore' (\db -> getContactWithoutConnViaShortAddress db cxt user l') >>= \case
Just ct' | not (contactDeleted ct') -> do
@@ -4230,15 +4260,15 @@ processChatCommand cxt nm = \case
ov = verifyLinkOwner rootKey owners l' sig_
plan <- contactRequestPlan user cReq contactSLinkData_ ov
case plan of
CPContactAddress cap@(CAPOk {}) -> pure (con l' cReq, CPContactAddress cap {verifiedDomain})
CPContactAddress (CAPKnown ct') -> do
ct'' <- refreshContact ct'
pure (con l' cReq, CPContactAddress (CAPKnown ct''))
pure (con l' cReq, plan {contactAddressPlan = CAPKnown ct''})
CPContactAddress (CAPContactViaAddress ct') -> do
ct'' <- refreshContact ct'
pure (con l' cReq, CPContactAddress (CAPContactViaAddress ct''))
pure (con l' cReq, plan {contactAddressPlan = CAPContactViaAddress ct''})
_ -> pure (con l' cReq, plan)
where
knownLinkPlans :: CM (Maybe (ACreatedConnLink, ConnectionPlan))
knownLinkPlans = withFastStore $ \db ->
liftIO (getUserContactLinkViaTarget db user nl') >>= \case
Just UserContactLink {connLinkContact} -> pure $ Just (ACCL SCMContact connLinkContact, CPContactAddress CAPOwnLink)
@@ -4250,24 +4280,26 @@ processChatCommand cxt nm = \case
CCTChannel -> groupShortLinkPlan
CCTRelay -> throwCmdError "chat relay links are not supported in this version"
where
nl' = case nl of
CTLink sl -> CTLink (serverShortLink sl)
CTName _ -> nl
(nl', simplexName_) = case nl of
CTLink sl -> (CTLink (serverShortLink sl), Nothing)
CTName ni -> (nl, Just ni)
ctType = case nl of
CTLink (CSLContact _ t _ _) -> t
CTName SimplexNameInfo {nameType = NTContact} -> CCTContact
CTName SimplexNameInfo {nameType = NTPublicGroup} -> CCTChannel
resolveSLink = case nl' of
CTLink l' -> pure l'
CTName n -> serverShortLink <$> resolveNameLink user n
CTName n -> serverShortLink <$> resolveNameLink n
con l' cReq = ACCL SCMContact $ CCLink cReq (Just l')
gPlan (ccl, g) = if memberRemoved (membership g) then Nothing else Just (ACCL SCMContact ccl, CPGroupLink (GLPKnown g (BoolDef False) Nothing (ListDef [])))
gPlan (ccl, g) = if memberRemoved (membership g) then Nothing else Just (ACCL SCMContact ccl, CPGroupLink (GLPKnown g False Nothing (ListDef [])))
groupShortLinkPlan :: CM (ACreatedConnLink, ConnectionPlan)
groupShortLinkPlan =
knownLinkPlans >>= \case
Just (_, CPGroupLink (GLPKnown g _ _ _))
| resolveKnown -> resolveKnownGroup g
| resolveMode == PRMAllGroups -> resolveKnownGroup g
Just r -> pure r
Nothing -> do
when (resolveMode == PRMNever) $ throwChatError CENotResolvedLocally
l' <- resolveSLink
(fd, cData@(ContactLinkData _ UserContactData {direct, owners, relays})) <- getShortLinkConnReq' nm user l'
groupSLinkData_ <- liftIO $ decodeLinkUserData cData
@@ -4284,23 +4316,29 @@ processChatCommand cxt nm = \case
(Nothing, Nothing) -> pure ()
_ -> throwChatError CEInvalidConnReq
let ov = verifyLinkOwner rootKey owners l' sig_
verifiedDomain = case nl of CTName ni -> Just (nameDomain ni); _ -> Nothing
plan <- groupJoinRequestPlan user cReq (Just linkInfo) groupSLinkData_ ov
forM_ verifiedDomain $ \nameDomain ->
planDomain = case nl of CTName ni -> Just (nameDomain ni); _ -> Nothing
plan0 <- groupJoinRequestPlan user cReq (Just linkInfo) groupSLinkData_ ov
-- a joined channel is found by link but not by name (its domain is not verified locally,
-- e.g. an un-upgraded relay dropped the claim); refresh its profile from the fresh link
-- data and mark it verified, so the check below passes and future by-name lookups match
plan <- case (planDomain, plan0, groupSLinkData_) of
(Just _, CPGroupLink (GLPKnown g u o os), Just sLinkData) ->
(\(g', _) -> CPGroupLink (GLPKnown g' u o os)) <$> updateGroupFromLinkData user g sLinkData
_ -> pure plan0
forM_ planDomain $ \nameDomain ->
let domain_ = (\GroupProfile {publicGroup} -> claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim)) =<< case plan of
CPGroupLink (GLPOk _ (Just GroupShortLinkData {groupProfile}) _ _) -> Just groupProfile
CPGroupLink (GLPOk _ (Just GroupShortLinkData {groupProfile}) _) -> Just groupProfile
CPGroupLink (GLPKnown GroupInfo {groupProfile} _ _ _) -> Just groupProfile
CPGroupLink (GLPOwnLink GroupInfo {groupProfile}) -> Just groupProfile
CPGroupLink (GLPConnectingProhibit (Just GroupInfo {groupProfile})) -> Just groupProfile
_ -> (\GroupShortLinkData {groupProfile} -> groupProfile) <$> groupSLinkData_
in unless (domain_ == Just nameDomain) $ throwChatError $ CESimplexDomainNotReady nameDomain SDEUnknownDomain
pure $ case plan of
CPGroupLink glp@(GLPOk {}) -> (con l' cReq, CPGroupLink glp {verifiedDomain})
_ -> (con l' cReq, plan)
pure (con l' cReq, plan)
where
unsupportedGroupType = \case
Just GroupShortLinkData {groupProfile = GroupProfile {publicGroup = Just PublicGroupProfile {groupType}}} -> groupType /= GTChannel
_ -> False
knownLinkPlans :: CM (Maybe (ACreatedConnLink, ConnectionPlan))
knownLinkPlans = withFastStore $ \db ->
liftIO (getGroupInfoViaUserTarget db cxt user nl') >>= \case
Just (ccl, g) -> pure $ Just (ACCL SCMContact ccl, CPGroupLink (GLPOwnLink g))
@@ -4314,29 +4352,29 @@ processChatCommand cxt nm = \case
(g', updated) <- case groupSLinkData_ of
Just sLinkData -> updateGroupFromLinkData user g sLinkData
_ -> pure (g, False)
pure (con l' (linkConnReq fd), CPGroupLink (GLPKnown g' (BoolDef updated) ov (ListDef glOwners)))
-- resolve a name to its first contact/channel short link
resolveNameLink :: User -> SimplexNameInfo -> CM (ConnShortLink 'CMContact)
resolveNameLink user SimplexNameInfo {nameType, nameDomain} = do
NameRecord {nrSimplexContact, nrSimplexChannel} <-
withAgent $ \a -> resolveSimplexName a nm (aUserId user) nameDomain
let (candidates, ctType) = case nameType of
NTContact -> (nrSimplexContact, CCTContact)
NTPublicGroup -> (nrSimplexChannel, CCTChannel)
maybe (throwChatError $ CESimplexDomainNotReady nameDomain SDENoValidLink) pure $ firstNameLink ctType candidates
connectWithPlan :: User -> IncognitoEnabled -> ACreatedConnLink -> ConnectionPlan -> CM ChatResponse
connectWithPlan user@User {userId} incognito ccLink plan
pure (con l' (linkConnReq fd), CPGroupLink (GLPKnown g' updated ov (ListDef glOwners)))
-- resolve a name to its first contact/channel short link
resolveNameLink :: SimplexNameInfo -> CM (ConnShortLink 'CMContact)
resolveNameLink SimplexNameInfo {nameType, nameDomain} = do
NameRecord {nrSimplexContact, nrSimplexChannel} <- maybe (withAgent $ \a -> resolveSimplexName a nm (aUserId user) nameDomain) (ExceptT . pure) nameRec
let (candidates, ctType') = case nameType of
NTContact -> (nrSimplexContact, CCTContact)
NTPublicGroup -> (nrSimplexChannel, CCTChannel)
maybe (throwChatError $ CESimplexDomainNotReady nameDomain SDENoValidLink) pure $ firstNameLink ctType' candidates
connectWithPlan :: User -> IncognitoEnabled -> ACreatedConnLink -> Maybe SimplexNameInfo -> Maybe SimplexNameInfo -> ConnectionPlan -> CM ChatResponse
connectWithPlan user@User {userId} incognito ccLink planSimplexName otherSimplexName plan
| connectionPlanProceed plan = do
case plan of CPError e -> eToView e; _ -> pure ()
case plan of
CPContactAddress (CAPContactViaAddress Contact {contactId}) ->
processChatCommand cxt nm $ APIConnectContactViaAddress userId incognito contactId
CPContactAddress (CAPOk (Just sld) _ vName@(Just _)) -> connectContactViaName sld vName
CPGroupLink (GLPOk (Just GroupShortLinkInfo {direct = False}) (Just gld) _ vName)
CPContactAddress (CAPOk (Just sld) _) | isJust vName -> connectContactViaName sld vName
CPGroupLink (GLPOk (Just GroupShortLinkInfo {direct = False}) (Just gld) _)
| ACCL SCMContact ccl <- ccLink -> joinChannelViaRelays ccl gld vName
_ -> processChatCommand cxt nm $ APIConnect userId incognito $ Just ccLink
| otherwise = pure $ CRConnectionPlan user ccLink plan
| otherwise = pure $ CRConnectionPlan user ccLink planSimplexName otherSimplexName plan
where
vName = nameDomain <$> planSimplexName
joinChannelViaRelays :: CreatedLinkContact -> GroupShortLinkData -> Maybe SimplexDomain -> CM ChatResponse
joinChannelViaRelays ccl gld vName = do
GroupInfo {groupId} <- prepareChannelGroup
@@ -4391,21 +4429,22 @@ processChatCommand cxt nm = \case
contactRequestPlan user (CRContactUri crData) cld ov = do
let cReqSchemas = contactCReqSchemas crData
cReqHashes = bimap contactCReqHash contactCReqHash cReqSchemas
plan p = pure $ CPContactAddress p
withFastStore' (\db -> getUserContactLinkByConnReq db user cReqSchemas) >>= \case
Just _ -> pure $ CPContactAddress CAPOwnLink
Just _ -> plan $ CAPOwnLink
Nothing ->
withFastStore' (\db -> getContactConnEntityByConnReqHash db cxt user cReqHashes) >>= \case
Nothing ->
withFastStore' (\db -> getContactWithoutConnViaAddress db cxt user cReqSchemas) >>= \case
Just ct | not (contactDeleted ct) -> pure $ CPContactAddress (CAPContactViaAddress ct)
_ -> pure $ CPContactAddress (CAPOk cld ov Nothing)
Just ct | not (contactDeleted ct) -> plan $ CAPContactViaAddress ct
_ -> plan $ CAPOk cld ov
Just (RcvDirectMsgConnection Connection {connStatus} Nothing)
| connStatus == ConnPrepared -> pure $ CPContactAddress (CAPOk cld ov Nothing)
| otherwise -> pure $ CPContactAddress CAPConnectingConfirmReconnect
| connStatus == ConnPrepared -> plan $ CAPOk cld ov
| otherwise -> plan CAPConnectingConfirmReconnect
Just (RcvDirectMsgConnection _ (Just ct))
| not (contactReady ct) && contactActive ct -> pure $ CPContactAddress (CAPConnectingProhibit ct)
| contactDeleted ct -> pure $ CPContactAddress (CAPOk cld ov Nothing)
| otherwise -> pure $ CPContactAddress (CAPKnown ct)
| not (contactReady ct) && contactActive ct -> plan $ CAPConnectingProhibit ct
| contactDeleted ct -> plan $ CAPOk cld ov
| otherwise -> plan $ CAPKnown ct
-- TODO [short links] RcvGroupMsgConnection branch is deprecated? (old group link protocol?)
Just (RcvGroupMsgConnection _ gInfo _) -> groupPlan gInfo Nothing Nothing Nothing
Just _ -> throwCmdError "found connection entity is not RcvDirectMsgConnection or RcvGroupMsgConnection"
@@ -4413,27 +4452,30 @@ processChatCommand cxt nm = \case
groupJoinRequestPlan user (CRContactUri crData) linkInfo gld ov = do
let cReqSchemas = contactCReqSchemas crData
cReqHashes = bimap contactCReqHash contactCReqHash cReqSchemas
plan p = pure $ CPGroupLink p
withFastStore' (\db -> getGroupInfoByUserContactLinkConnReq db cxt user cReqSchemas) >>= \case
Just g -> pure $ CPGroupLink (GLPOwnLink g)
Just g -> plan $ GLPOwnLink g
Nothing -> do
connEnt_ <- withFastStore' $ \db -> getContactConnEntityByConnReqHash db cxt user cReqHashes
gInfo_ <- withFastStore' $ \db -> getGroupInfoByGroupLinkHash db cxt user cReqHashes
case (gInfo_, connEnt_) of
(Nothing, Nothing) -> pure $ CPGroupLink (GLPOk linkInfo gld ov Nothing)
(Nothing, Nothing) -> plan $ GLPOk linkInfo gld ov
-- TODO [short links] RcvDirectMsgConnection branches are deprecated? (old group link protocol?)
(Nothing, Just (RcvDirectMsgConnection _conn Nothing)) -> pure $ CPGroupLink GLPConnectingConfirmReconnect
(Nothing, Just (RcvDirectMsgConnection _conn Nothing)) -> plan $ GLPConnectingConfirmReconnect
(Nothing, Just (RcvDirectMsgConnection _ (Just ct)))
| not (contactReady ct) && contactActive ct -> pure $ CPGroupLink (GLPConnectingProhibit gInfo_)
| otherwise -> pure $ CPGroupLink (GLPOk linkInfo gld ov Nothing)
| not (contactReady ct) && contactActive ct -> plan $ GLPConnectingProhibit gInfo_
| otherwise -> plan $ GLPOk linkInfo gld ov
(Nothing, Just _) -> throwCmdError "found connection entity is not RcvDirectMsgConnection"
(Just gInfo, _) -> groupPlan gInfo linkInfo gld ov
groupPlan :: GroupInfo -> Maybe GroupShortLinkInfo -> Maybe GroupShortLinkData -> Maybe OwnerVerification -> CM ConnectionPlan
groupPlan gInfo@GroupInfo {membership} linkInfo gld ov
| memberStatus membership == GSMemRejected = pure $ CPGroupLink (GLPKnown gInfo (BoolDef False) ov (ListDef []))
| memberStatus membership == GSMemRejected = plan $ GLPKnown gInfo False ov (ListDef [])
| not (memberActive membership) && not (memberRemoved membership) =
pure $ CPGroupLink (GLPConnectingProhibit $ Just gInfo)
| memberActive membership = pure $ CPGroupLink (GLPKnown gInfo (BoolDef False) ov (ListDef []))
| otherwise = pure $ CPGroupLink (GLPOk linkInfo gld ov Nothing)
plan $ GLPConnectingProhibit $ Just gInfo
| memberActive membership = plan $ GLPKnown gInfo False ov (ListDef [])
| otherwise = plan $ GLPOk linkInfo gld ov
where
plan p = pure $ CPGroupLink p
contactCReqSchemas :: ConnReqUriData -> (ConnReqContact, ConnReqContact)
contactCReqSchemas crData =
( CRContactUri crData {crScheme = SSSimplex},
@@ -5455,7 +5497,7 @@ chatCommandP =
(">#" <|> "> #") *> (SendGroupMessageQuote <$> displayNameP <* A.space <* char_ '@' <*> (Just <$> displayNameP) <* A.space <*> quotedMsg <*> msgTextP),
"/_contacts " *> (APIListContacts <$> A.decimal),
"/contacts" $> ListContacts,
"/_connect plan " *> (APIConnectPlan <$> A.decimal <* A.space <*> ((Just <$> strP) <|> A.takeTill (== ' ') $> Nothing) <*> ((" resolve=" *> onOffP) <|> pure False) <*> optional (" sig=" *> jsonP)),
"/_connect plan " *> (APIConnectPlan <$> A.decimal <* A.space <*> ((Just <$> strP) <|> A.takeTill (== ' ') $> Nothing) <*> ((" resolve=" *> planResolveModeP) <|> pure PRMUnknown) <*> optional (" sig=" *> jsonP)),
"/_prepare contact " *> (APIPrepareContact <$> A.decimal <* A.space <*> connLinkP <*> optional (" domain=" *> strP) <* A.space <*> jsonP),
"/_prepare group " *> (APIPrepareGroup <$> A.decimal <* A.space <*> connLinkP' <*> (" direct=" *> onOffP <|> pure True) <*> optional (" domain=" *> strP) <* A.space <*> jsonP),
"/_set contact user @" *> (APIChangePreparedContactUser <$> A.decimal <* A.space <*> A.decimal),
+9 -3
View File
@@ -1464,8 +1464,8 @@ updatePublicGroupData user gInfo
| otherwise = pure gInfo
updateGroupFromLinkData :: User -> GroupInfo -> GroupShortLinkData -> CM (GroupInfo, Bool)
updateGroupFromLinkData user gInfo@GroupInfo {groupProfile = p, groupSummary = GroupSummary {publicMemberCount = localCount}} GroupShortLinkData {groupProfile, publicGroupData}
| profileChanged || countChanged = do
updateGroupFromLinkData user gInfo@GroupInfo {groupProfile = p, groupDomainVerified, groupSummary = GroupSummary {publicMemberCount = localCount}} GroupShortLinkData {groupProfile, publicGroupData}
| profileChanged || countChanged || verifyChanged = do
cxt <- chatStoreCxt
withStore $ \db -> do
g <- if profileChanged then updateGroupProfile db user gInfo groupProfile else pure gInfo
@@ -1473,13 +1473,19 @@ updateGroupFromLinkData user gInfo@GroupInfo {groupProfile = p, groupSummary = G
Just PublicGroupData {publicMemberCount} | countChanged ->
setPublicMemberCount db cxt user g publicMemberCount
_ -> pure g
pure (g', profileChanged)
-- the group's own link is authoritative for its domain claim, so a claim in the link profile is
-- verified; updateGroupProfile above clears verification on a claim change, so set it afterwards
g'' <- if verifyChanged then liftIO $ setGroupDomainVerified db user g' True else pure g'
pure (g'', profileChanged)
| otherwise = pure (gInfo, False)
where
profileChanged = p /= groupProfile
countChanged = case publicGroupData of
Just PublicGroupData {publicMemberCount} -> Just publicMemberCount /= localCount
_ -> False
groupClaim GroupProfile {publicGroup} = claimDomain <$> (publicGroup >>= publicGroupAccess >>= groupDomainClaim)
newClaim = groupClaim groupProfile
verifyChanged = isJust newClaim && (groupDomainVerified /= Just True || groupClaim p /= newClaim)
updateContactFromLinkData :: User -> Contact -> Profile -> CM Contact
updateContactFromLinkData user ct@Contact {profile = profile@LocalProfile {contactDomain = prevClaim, contactDomainVerified}} linkProfile@Profile {contactDomain = newClaim}
+9 -4
View File
@@ -238,7 +238,7 @@ import Simplex.Chat.Types.MemberRelations (IntroductionDirection (..), MemberRel
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.UITheme
import Simplex.Messaging.Agent.Protocol (ConfirmationId, ConnId, CreatedConnLink (..), InvitationId, OwnerAuth (..), SimplexNameInfo (..), UserId)
import Simplex.Messaging.Agent.Protocol (ConfirmationId, ConnId, CreatedConnLink (..), InvitationId, OwnerAuth (..), SimplexNameInfo (..), SimplexNameType (..), UserId)
import Simplex.Messaging.Agent.Store.AgentStore (firstRow, fromOnlyBI, maybeFirstRow)
import qualified Simplex.FileTransfer.Description as FD
import Simplex.Messaging.Encoding (smpDecode, smpEncode)
@@ -1083,9 +1083,14 @@ getGroupToConnect :: DB.Connection -> StoreCxt -> User -> ContactNameOrLink -> E
getGroupToConnect db cxt user@User {userId} = \case
CTLink sl -> first (`CCLink` Just sl) <$$> getGroupViaShortLinkToConnect db cxt user sl
CTName ni ->
liftIO (maybeFirstRow id $ DB.query db byNameQuery (userId, nameDomain ni)) >>= \case
Just (gId :: Int64, Just cReq, Just (sLnk :: ShortLinkContact)) -> Just . (CCLink cReq (Just sLnk),) <$> getGroupInfo db cxt user gId
_ -> pure Nothing
-- @name is a business (presents as a contact); #name is a channel. The same domain can have both,
-- so the group type must match the requested name type.
let businessCond = case nameType ni of
NTContact -> " AND g.business_chat IS NOT NULL"
NTPublicGroup -> " AND g.business_chat IS NULL"
in liftIO (maybeFirstRow id $ DB.query db (byNameQuery <> businessCond) (userId, nameDomain ni)) >>= \case
Just (gId :: Int64, Just cReq, Just (sLnk :: ShortLinkContact)) -> Just . (CCLink cReq (Just sLnk),) <$> getGroupInfo db cxt user gId
_ -> pure Nothing
where
byNameQuery =
[sql|
+4 -1
View File
@@ -60,7 +60,7 @@ import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.UITheme
import Simplex.FileTransfer.Description (FileDigest)
import Simplex.FileTransfer.Types (RcvFileId, SndFileId)
import Simplex.Messaging.Agent.Protocol (ACorrId, ACreatedConnLink, AConnectionLink (..), AEventTag (..), AEvtTag (..), ConnId, ConnShortLink (..), ConnectionLink (..), ConnectionMode (..), ConnectionModeI, ConnectionRequestUri, ContactConnType (..), CreatedConnLink (..), InvitationId, SAEntity (..), SConnectionMode (..), SimplexNameInfo, UserId)
import Simplex.Messaging.Agent.Protocol (ACorrId, ACreatedConnLink, AConnectionLink (..), AEventTag (..), AEvtTag (..), ConnId, ConnShortLink (..), ConnectionLink (..), ConnectionMode (..), ConnectionModeI, ConnectionRequestUri, ContactConnType (..), CreatedConnLink (..), InvitationId, SAEntity (..), SConnectionMode (..), SimplexDomain, SimplexNameInfo (..), UserId)
import Simplex.Messaging.Agent.Store.DB (Binary (..), blobFieldDecoder, fromTextField_)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.File (CryptoFileArgs (..))
@@ -1789,6 +1789,7 @@ type ConnReqContact = ConnectionRequestUri 'CMContact
data ConnectTarget (m :: ConnectionMode) where
CTFullContact :: ConnectionRequestUri 'CMContact -> ConnectTarget 'CMContact
CTShortContact :: ContactNameOrLink -> ConnectTarget 'CMContact
CTDomain :: SimplexDomain -> ConnectTarget 'CMContact
CTInv :: ConnectionLink 'CMInvitation -> ConnectTarget 'CMInvitation
data ContactNameOrLink = CTName SimplexNameInfo | CTLink (ConnShortLink 'CMContact)
@@ -1813,10 +1814,12 @@ instance StrEncoding AConnectTarget where
CTFullContact cr -> strEncode cr
CTShortContact (CTName n) -> strEncode n
CTShortContact (CTLink sl) -> strEncode sl
CTDomain d -> strEncode d
CTInv l -> strEncode l
strP =
(ACTarget SCMContact . CTShortContact . CTName <$> (lookAhead nameStart *> strP))
<|> (aConnectTarget <$> strP)
<|> (ACTarget SCMContact . CTDomain <$> strP)
where
nameStart = "@" <|> "#" <|> "simplex:/name"
+10 -3
View File
@@ -204,7 +204,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte
CRInvitation u ccLink _ -> ttyUser u $ viewConnReqInvitation ccLink
CRConnectionIncognitoUpdated u c customUserProfile -> ttyUser u $ viewConnectionIncognitoUpdated c customUserProfile testView
CRConnectionUserChanged u c c' nu -> ttyUser u $ viewConnectionUserChanged u c nu c'
CRConnectionPlan u connLink connectionPlan -> ttyUser u $ viewConnectionPlan cfg connLink connectionPlan
CRConnectionPlan u connLink _ otherSimplexName connectionPlan -> ttyUser u $ viewConnectionPlan cfg connLink connectionPlan <> otherSimplexNameNote otherSimplexName
CRNewPreparedChat u (AChat _ (Chat cInfo _ _)) -> ttyUser u $ case cInfo of
DirectChat ct -> [ttyContact' ct <> ": contact is prepared"]
GroupChat g _ -> [ttyGroup' g <> ": group is prepared"]
@@ -2144,6 +2144,12 @@ viewGroupUserChanged
where
userChangedStr = "group " <> ttyGroup' g <> " changed from user " <> plain un <> " to user " <> plain un'
otherSimplexNameNote :: Maybe SimplexNameInfo -> [StyledString]
otherSimplexNameNote = \case
Just ni@(SimplexNameInfo NTPublicGroup _) -> [plain $ "You can also join channel " <> shortNameInfoStr ni]
Just ni@(SimplexNameInfo NTContact _) -> [plain $ "You can also connect to " <> shortNameInfoStr ni <> " in direct chat"]
Nothing -> []
viewConnectionPlan :: ChatConfig -> ACreatedConnLink -> ConnectionPlan -> [StyledString]
viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case
CPInvitationLink ilp -> case ilp of
@@ -2165,7 +2171,7 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case
| business -> ("business address: " <>)
_ -> ("invitation link: " <>)
CPContactAddress cap -> case cap of
CAPOk contactSLinkData ov _ -> [addrOrBiz contactSLinkData "ok to connect"] <> viewSigVerification ov <> [viewJSON contactSLinkData | testView]
CAPOk contactSLinkData ov -> [addrOrBiz contactSLinkData "ok to connect"] <> viewSigVerification ov <> [viewJSON contactSLinkData | testView]
CAPOwnLink -> [ctAddr "own address"]
CAPConnectingConfirmReconnect -> [ctAddr "connecting, allowed to reconnect"]
CAPConnectingProhibit ct -> [ctAddr ("connecting to contact " <> ttyContact' ct)]
@@ -2183,7 +2189,7 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case
| business -> ("business address: " <>)
_ -> ("contact address: " <>)
CPGroupLink glp -> case glp of
GLPOk groupSLinkInfo_ groupSLinkData ov _ ->
GLPOk groupSLinkInfo_ groupSLinkData ov ->
let direct = maybe True (\(GroupShortLinkInfo {direct = d}) -> d) groupSLinkInfo_
in [grpLink $ if direct then "ok to connect directly" else "ok to connect via relays"]
<> viewSigVerification ov
@@ -2687,6 +2693,7 @@ viewChatError isCmd logLevel testView = \case
SDENoValidLink -> "has no valid connection link"
SDEUnknownDomain -> "is not included in the connection link's profile"
in [plain $ "SimpleX name " <> strEncode domain <> " " <> reason]
CENotResolvedLocally -> ["no matching chat found, name resolution is disabled"]
CEUnsupportedConnReq -> [ "", "Connection link is not supported by the your app version, please ugrade it.", plain updateStr]
CEInvalidChatMessage Connection {connId} msgMeta_ msg e ->
[ plain $
+82
View File
@@ -21,6 +21,9 @@ chatNamesTests = do
it "connect by unregistered name fails to resolve" testConnectByNameNotFound
it "set name not resolving to own address is rejected" testSetNameNotOwnAddress
it "connect by channel name" testConnectByChannelName
it "connect by name resolving to channel (primary) and direct contact" testConnectByNameChannelAndContact
it "connect by name resolving to direct contact (primary) and channel" testConnectByNameContactAndChannel
it "connect by name resolving to business (primary) and channel" testConnectByNameBusinessAndChannel
testConnectByName :: HasCallStack => TestParams -> IO ()
testConnectByName ps = withSmpServerAndNames $ \reg ->
@@ -141,3 +144,82 @@ testConnectByChannelName ps = withSmpServerAndNames $ \reg ->
bob <## "use #team <message> to send messages"
where
teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" [])
-- The bare name "team.simplex" resolves to both a channel and a direct contact. The channel is tried
-- first and succeeds (bob has joined #team), so it is the primary (planSimplexName); otherSimplexName
-- is the direct contact @team.simplex, shown as "You can also connect to @team.simplex in direct chat".
testConnectByNameChannelAndContact :: HasCallStack => TestParams -> IO ()
testConnectByNameChannelAndContact ps = withSmpServerAndNames $ \reg ->
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
withNewTestChat ps "bob" bobProfile $ \bob -> do
(channelLink, _) <- prepareChannel1Relay "team" alice cath
alice ##> "/ad"
(contactLink, _) <- getContactLinks alice True
registerName reg teamName (contactAndChannelNameRecord "team" (T.pack contactLink) (T.pack channelLink))
alice ##> "/public group access #team domain=team.simplex"
alice <## "updated public group access: domain=team.simplex"
cath <## "alice updated group #team: (signed)"
cath <## "updated public group access: domain=team.simplex"
bob ##> "/c #team.simplex"
bob <## "#team: connection started"
concurrentlyN_
[ bob
<### [ "#team: joining the group (connecting to relay cath)...",
"#team: you joined the group (connected to relay cath)"
]
, do
cath <## "bob (Bob): accepting request to join group #team..."
cath <## "#team: bob joined the group"
, alice <### [EndsWith "introduced bob (Bob) in the channel"]
]
bob ##> "/_connect plan 1 team.simplex"
bob <## "group link: known group #team"
bob <## "SimpleX name: #team (verified)"
bob <## "use #team <message> to send messages"
bob <## "You can also connect to @team.simplex in direct chat"
where
teamName = SimplexNameInfo NTPublicGroup (SimplexDomain TLDSimplex "team" [])
-- The bare name "acme.simplex" resolves to both a channel and a direct contact. The channel is tried
-- first but its group profile does not claim the domain, so the channel side of the plan fails; the
-- plan falls back to the direct contact as primary (planSimplexName) while otherSimplexName is the
-- channel #acme, shown as "You can also join channel #acme". The channel link is a real, fetchable
-- #acme channel, so the failure is the faithful "channel does not claim this domain" case, not a broken link.
testConnectByNameContactAndChannel :: HasCallStack => TestParams -> IO ()
testConnectByNameContactAndChannel ps = withSmpServerAndNames $ \reg ->
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
withNewTestChat ps "bob" bobProfile $ \bob -> do
(channelLink, _) <- prepareChannel1Relay "acme" alice cath
alice ##> "/ad"
(contactLink, _) <- getContactLinks alice True
registerName reg acmeName (contactAndChannelNameRecord "acme" (T.pack contactLink) (T.pack channelLink))
alice ##> "/_set domain 1 acme.simplex"
alice <## "new contact address set"
bob ##> "/_connect plan 1 acme.simplex"
bob <## "contact address: ok to connect"
_ <- getTermLine bob -- contact short link data (JSON, printed in test view)
bob <## "You can also join channel #acme"
where
acmeName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "acme" [])
testConnectByNameBusinessAndChannel :: HasCallStack => TestParams -> IO ()
testConnectByNameBusinessAndChannel ps = withSmpServerAndNames $ \reg ->
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
withNewTestChat ps "bob" bobProfile $ \bob -> do
(channelLink, _) <- prepareChannel1Relay "biz" alice cath
alice ##> "/ad"
(contactLink, _) <- getContactLinks alice True
registerName reg bizName (contactAndChannelNameRecord "biz" (T.pack contactLink) (T.pack channelLink))
alice ##> "/auto_accept on business"
alice <## "auto_accept on, business"
alice ##> "/_set domain 1 biz.simplex"
alice <## "new contact address set"
bob ##> "/_connect plan 1 biz.simplex"
bob <## "business address: ok to connect"
_ <- getTermLine bob -- contact short link data (JSON, printed in test view)
bob <## "You can also join channel #biz"
where
bizName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "biz" [])
+6
View File
@@ -11,6 +11,7 @@ module NameResolver
registerName,
contactNameRecord,
channelNameRecord,
contactAndChannelNameRecord,
resolverNamesConfig,
)
where
@@ -55,6 +56,11 @@ contactNameRecord name link = (emptyRecord name) {nrSimplexContact = [link]}
channelNameRecord :: Text -> Text -> NameRecord
channelNameRecord name link = (emptyRecord name) {nrSimplexChannel = [link]}
-- | A record whose domain resolves to both a direct contact link and a channel link.
contactAndChannelNameRecord :: Text -> Text -> Text -> NameRecord
contactAndChannelNameRecord name contactLink channelLink =
(emptyRecord name) {nrSimplexContact = [contactLink], nrSimplexChannel = [channelLink]}
emptyRecord :: Text -> NameRecord
emptyRecord name =
NameRecord