mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-12 03:09:15 +00:00
Merge branch 'master' into f/msg-signing
This commit is contained in:
@@ -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?)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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_):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5296,6 +5296,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
|
||||
|
||||
+7
-1
@@ -4894,7 +4894,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(
|
||||
|
||||
+30
-8
@@ -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()
|
||||
|
||||
+155
-37
@@ -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())
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+22
@@ -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()
|
||||
|
||||
+2
-2
@@ -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({
|
||||
|
||||
+87
-17
@@ -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()
|
||||
}
|
||||
|
||||
+48
-38
@@ -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 <> "."
|
||||
|
||||
@@ -1371,7 +1371,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**:
|
||||
@@ -1394,6 +1394,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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: 836254a4c6bd5e17b40acbcaf77a83802d44919e
|
||||
tag: 551de8039fbfcbc75d8e2685a4c631a55ae28fb2
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
@@ -12,6 +12,7 @@ The first messaging platform that has no user identifiers of any kind — 100% p
|
||||
- [Secret groups](./secret-groups.md)
|
||||
- [Channel webpage](./channel-webpage.md)
|
||||
- [Chat profiles](./chat-profiles.md)
|
||||
- [Registering a SimpleX name](./register-simplex-name.md)
|
||||
- [Managing data](./managing-data.md)
|
||||
- [Audio & video calls](./audio-video-calls.md)
|
||||
- [Privacy & security](./privacy-security.md)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
flowchart TD
|
||||
S([Start]) --> A["Step 1: Create a wallet<br/>save your recovery phrase"]
|
||||
A --> B["Step 2: Register the name<br/>connect wallet, search, choose years"]
|
||||
B --> C["On Create your profile, paste your link into<br/>SimpleX contact (@name) or SimpleX channel (#name)"]
|
||||
C --> H["Confirm: two transactions,<br/>60 seconds apart"]
|
||||
H --> I["Step 3: search or type your name<br/>shows 'Unconfirmed name'"]
|
||||
I --> D{"A contact or a channel?"}
|
||||
D -->|"@name (contact)"| J1["Step 4: set Your SimpleX name<br/>in your SimpleX address"]
|
||||
D -->|"#name (channel)"| J2["Step 4: set SimpleX name<br/>in the channel"]
|
||||
J1 --> K["Step 5: from another device,<br/>search or type the name"]
|
||||
J2 --> K
|
||||
K --> Z([Connected. Name verified.])
|
||||
|
||||
classDef start fill:#f4ecf7,stroke:#8e44ad,color:#4a235a;
|
||||
classDef step fill:#eaf2fb,stroke:#2e86de,color:#1b3a5b;
|
||||
classDef ok fill:#d5f5e3,stroke:#27ae60,color:#145a32;
|
||||
class S start;
|
||||
class Z ok;
|
||||
class A,B,C,H,I,J1,J2,K step;
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 263 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Registering a SimpleX name
|
||||
---
|
||||
|
||||
# Registering a SimpleX name
|
||||
|
||||
A SimpleX name lets people reach you by typing a short `@name` instead of pasting
|
||||
a long link. You register the name on a website and add your SimpleX link to it,
|
||||
then claim the name in the app, to connect it with your SimpleX address or
|
||||
channel name.
|
||||
|
||||
> Names currently run on a testing network. The registration page is
|
||||
> [testing-names.simplex.chat](https://testing-names.simplex.chat) and names end
|
||||
> in `.testing`. The steps stay the same when the main names launch.
|
||||
|
||||
## What you need
|
||||
|
||||
- A crypto wallet such as MetaMask.
|
||||
- The SimpleX app.
|
||||
- A link for the name to open: your **contact address** (for one-to-one chats),
|
||||
your **channel link** (to join a channel), or both.
|
||||
|
||||
## How it works
|
||||
|
||||
Setting up a name takes two parts. On the website you register the name and add
|
||||
your link, so people can find your link by name. In the app you set the same name
|
||||
on your profile - to prevent any other names from opening your profile or channel.
|
||||
|
||||

|
||||
|
||||
## Step 1. Create a wallet
|
||||
|
||||
Install MetaMask (browser extension or mobile app) and create a wallet. Write
|
||||
down your recovery phrase somewhere safe and offline, as anyone with that phrase
|
||||
controls your names.
|
||||
|
||||
On the testing network you need a small amount of test ETH to pay network fees.
|
||||
|
||||
## Step 2. Register the name and add your link
|
||||
|
||||
Open the registration page and tap **Connect** to link your wallet. Type the
|
||||
name you want in **Search for a name** and open it. Choose how many years to
|
||||
register for, then tap **Next**. Names are currently 6 characters or more, and
|
||||
some names are reserved.
|
||||
|
||||

|
||||
|
||||
On **Create your profile**, paste your SimpleX link into the matching field:
|
||||
|
||||

|
||||
|
||||
- **SimpleX contact** for one-to-one chats. Copy your address from the app
|
||||
(**Create SimpleX address**) and paste it here. People reach it with `@name`.
|
||||
- **SimpleX channel** for a channel. Copy your **Channel link** and paste it here.
|
||||
People join it with `#name`.
|
||||
- **Advanced usage:** fill both to use one name for your chat (`@name`) and your
|
||||
channel (`#name`).
|
||||
|
||||
Then tap **Next**.
|
||||
|
||||
Finally, tap **Begin** and complete the three steps the site shows:
|
||||
|
||||
> Before we start. Registering your name takes three steps:
|
||||
> 1. Complete a transaction to begin the timer.
|
||||
> 2. Wait 60 seconds for the timer to complete.
|
||||
> 3. Complete a second transaction to secure your name.
|
||||
|
||||
These two transactions protect the name you are registering. The first records
|
||||
only a secret code for the name, not the name itself, so nobody watching the
|
||||
blockchain can see which name you want. The wait lets that first transaction
|
||||
settle, and the second transaction then reveals and claims the name. Without this
|
||||
two-step process, someone could see your registration in progress and grab the
|
||||
name before you.
|
||||
|
||||

|
||||
|
||||
## Step 3. Check what the app sees
|
||||
|
||||
Copy your `#name.testing` (or `@name.testing`). Paste the name into the search bar, or simply type it.
|
||||
|
||||
The app reports that the name is registered but not yet added to your profile:
|
||||
|
||||
> Unconfirmed name. The SimpleX name is registered, but not added to profile.
|
||||
> Please add it to your address or channel profile, if you are the owner.
|
||||
|
||||
This is expected, and confirms the name points at your link. The next step
|
||||
finishes the setup.
|
||||
|
||||
## Step 4. Claim the name in the app
|
||||
|
||||
Set the name on the same profile the record points at.
|
||||
|
||||
- For your **contact address**, open your SimpleX address, tap **Your SimpleX
|
||||
name**, enter the name, and save.
|
||||
- For your **channel**, open the channel information, tap **SimpleX name** (below
|
||||
**Channel link**), enter the name, and save.
|
||||
|
||||
This proves to people who connect to you that `#name.testing` (or `@name.testing`) is your name.
|
||||
|
||||
## Step 5. Connect by name
|
||||
|
||||
On another device, copy `#name.testing` (or `@name.testing`). Paste the name into the search bar, or simply type it. This time the app connects, and the name
|
||||
shows a check mark next to it.
|
||||
|
||||
You can paste the name in your own device too - the app will warn you that it is your own name.
|
||||
|
||||
Anyone can now reach you by name, and their app confirms it is really you.
|
||||
|
||||
## If something doesn't work
|
||||
|
||||
| Message | Meaning | Fix |
|
||||
|---|---|---|
|
||||
| Unconfirmed name | The name points at a link, but the link's profile does not claim the name yet. | Do step 4: set the name on the profile it points at. |
|
||||
| Name not found | No name is registered. | Check the spelling, or register it (step 2). |
|
||||
| No valid link | The name has no contact or channel link. | Add a link to the name on the website, on its **Records** tab. |
|
||||
| Error saving name | You tried to claim a name that has no matching link on the website. | Add your contact address or channel link to the name, then set the name again. |
|
||||
| None of your servers are set to resolve SimpleX names | No server in the app is set to resolve names. | In server settings, turn on **To resolve names** for a server. |
|
||||
|
||||
## See also
|
||||
|
||||
- [Making connections](./making-connections.md)
|
||||
- [Chat profiles](./chat-profiles.md)
|
||||
- [Channel webpage](./channel-webpage.md)
|
||||
@@ -501,7 +501,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
|
||||
|
||||
@@ -440,7 +440,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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."836254a4c6bd5e17b40acbcaf77a83802d44919e" = "1g4385sv7i1h63yk3ch2kw8hprak1dzvr34v9mn6xz0fy10g8xlg";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."551de8039fbfcbc75d8e2685a4c631a55ae28fb2" = "1gn0fkxp60cizh2v7vjsd4c2bab2si7fl2bdzikisvg0na8ffnhw";
|
||||
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
|
||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
|
||||
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ cabal-version: 1.12
|
||||
-- see: https://github.com/sol/hpack
|
||||
|
||||
name: simplex-chat
|
||||
version: 7.0.0.6
|
||||
version: 7.0.0.7
|
||||
category: Web, System, Services, Cryptography
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
author: simplex.chat
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2061,8 +2061,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
|
||||
@@ -2286,12 +2287,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
|
||||
@@ -2322,8 +2323,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 ->
|
||||
@@ -4182,13 +4183,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)
|
||||
@@ -4203,27 +4204,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
|
||||
@@ -4234,15 +4264,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)
|
||||
@@ -4254,24 +4284,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
|
||||
@@ -4288,23 +4320,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))
|
||||
@@ -4318,29 +4356,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
|
||||
@@ -4395,21 +4433,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"
|
||||
@@ -4417,27 +4456,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},
|
||||
@@ -5459,7 +5501,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),
|
||||
|
||||
@@ -929,13 +929,15 @@ acceptContactRequestAsync
|
||||
profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True
|
||||
cxt <- chatStoreCxt
|
||||
let chatV = vr cxt `peerConnChatVersion` cReqChatVRange
|
||||
(cmdId, acId) <- agentAcceptContactAsync user True cReqInvId (XInfo profileToSend) subMode cReqPQSup chatV
|
||||
(cmdId, acId) <- prepareAgentAccept user True cReqInvId cReqPQSup
|
||||
currentTs <- liftIO getCurrentTime
|
||||
withStore $ \db -> do
|
||||
ct' <- withStore $ \db -> do
|
||||
forM_ xContactId $ \xcId -> liftIO $ setContactAcceptedXContactId db ct xcId
|
||||
Connection {connId} <- liftIO $ createAcceptedContactConn db user (Just uclId) contactId acId chatV cReqChatVRange cReqPQSup incognitoProfile subMode currentTs
|
||||
liftIO $ setCommandConnId db user cmdId connId
|
||||
getContact db cxt user contactId
|
||||
agentAcceptContactAsync cmdId acId True cReqInvId (XInfo profileToSend) cReqPQSup chatV subMode
|
||||
pure ct'
|
||||
|
||||
acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> Maybe GroupMember -> CM GroupMember
|
||||
acceptGroupJoinRequestAsync
|
||||
@@ -983,10 +985,12 @@ acceptGroupJoinRequestAsync
|
||||
}
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
let chatV = vr cxt `peerConnChatVersion` cReqChatVRange
|
||||
connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV
|
||||
withStore $ \db -> do
|
||||
liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode
|
||||
(cmdId, acId) <- prepareAgentAccept user True cReqInvId PQSupportOff
|
||||
m <- withStore $ \db -> do
|
||||
liftIO $ createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode
|
||||
getGroupMemberById db cxt user groupMemberId
|
||||
agentAcceptContactAsync cmdId acId True cReqInvId msg PQSupportOff chatV subMode
|
||||
pure m
|
||||
|
||||
acceptGroupJoinSendRejectAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> GroupRejectionReason -> CM GroupMember
|
||||
acceptGroupJoinSendRejectAsync
|
||||
@@ -1013,10 +1017,12 @@ acceptGroupJoinSendRejectAsync
|
||||
}
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
let chatV = vr cxt `peerConnChatVersion` cReqChatVRange
|
||||
connIds <- agentAcceptContactAsync user False cReqInvId msg subMode PQSupportOff chatV
|
||||
withStore $ \db -> do
|
||||
liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode
|
||||
(cmdId, acId) <- prepareAgentAccept user False cReqInvId PQSupportOff
|
||||
m <- withStore $ \db -> do
|
||||
liftIO $ createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode
|
||||
getGroupMemberById db cxt user groupMemberId
|
||||
agentAcceptContactAsync cmdId acId False cReqInvId msg PQSupportOff chatV subMode
|
||||
pure m
|
||||
|
||||
acceptBusinessJoinRequestAsync :: User -> Int64 -> GroupInfo -> GroupMember -> UserContactRequest -> CM (GroupInfo, GroupMember)
|
||||
acceptBusinessJoinRequestAsync
|
||||
@@ -1045,10 +1051,11 @@ acceptBusinessJoinRequestAsync
|
||||
}
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
let chatV = vr cxt `peerConnChatVersion` cReqChatVRange
|
||||
connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV
|
||||
(cmdId, acId) <- prepareAgentAccept user True cReqInvId PQSupportOff
|
||||
withStore' $ \db -> do
|
||||
forM_ xContactId $ \xcId -> setBusinessChatAcceptedXContactId db gInfo xcId
|
||||
createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode
|
||||
createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode
|
||||
agentAcceptContactAsync cmdId acId True cReqInvId msg PQSupportOff chatV subMode
|
||||
let cd = CDGroupSnd gInfo Nothing
|
||||
-- TODO [short links] move to profileContactRequest?
|
||||
createInternalChatItem user cd (CISndGroupE2EEInfo $ e2eInfoGroup gInfo) Nothing
|
||||
@@ -1071,12 +1078,14 @@ acceptRelayJoinRequestAsync
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
cxt <- chatStoreCxt
|
||||
let chatV = vr cxt `peerConnChatVersion` cReqChatVRange
|
||||
connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV
|
||||
withStore $ \db -> do
|
||||
liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode
|
||||
(cmdId, acId) <- prepareAgentAccept user True cReqInvId PQSupportOff
|
||||
r <- withStore $ \db -> do
|
||||
liftIO $ createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode
|
||||
gInfo' <- liftIO $ updateRelayOwnStatusFromTo db gInfo RSInvited RSAccepted
|
||||
ownerMember' <- getGroupMemberById db cxt user groupMemberId
|
||||
pure (gInfo', ownerMember')
|
||||
agentAcceptContactAsync cmdId acId True cReqInvId msg PQSupportOff chatV subMode
|
||||
pure r
|
||||
|
||||
rejectRelayInvitationAsync
|
||||
:: User
|
||||
@@ -1096,9 +1105,10 @@ rejectRelayInvitationAsync user uclId cxt groupRelayInv invId reqChatVRange init
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
chatVR <- chatVersionRange
|
||||
let chatV = chatVR `peerConnChatVersion` reqChatVRange
|
||||
connIds <- agentAcceptContactAsync user False invId msg subMode PQSupportOff chatV
|
||||
(cmdId, acId) <- prepareAgentAccept user False invId PQSupportOff
|
||||
withStore' $ \db ->
|
||||
createJoiningMemberConnection db user uclId connIds chatV reqChatVRange groupMemberId subMode
|
||||
createJoiningMemberConnection db user uclId (cmdId, acId) chatV reqChatVRange groupMemberId subMode
|
||||
agentAcceptContactAsync cmdId acId False invId msg PQSupportOff chatV subMode
|
||||
|
||||
businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile
|
||||
businessGroupProfile Profile {displayName, fullName, shortDescr, image} groupPreferences =
|
||||
@@ -1454,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
|
||||
@@ -1463,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}
|
||||
@@ -2770,18 +2786,24 @@ msgContentHasLink mc ft_ = case msgContentTag mc of
|
||||
MCLink_ -> True
|
||||
_ -> maybe False hasLinks ft_
|
||||
|
||||
createAgentConnectionAsync :: ConnectionModeI c => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> CM (CommandId, ConnId)
|
||||
createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do
|
||||
prepareAgentCreation :: ConnectionModeI c => User -> CommandFunction -> Bool -> SConnectionMode c -> CM (CommandId, ConnId)
|
||||
prepareAgentCreation user cmdFunction enableNtfs cMode = do
|
||||
cmdId <- withStore' $ \db -> createCommand db user Nothing cmdFunction
|
||||
connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode IKPQOff subMode
|
||||
connId <- withAgent $ \a -> prepareConnectionToCreate a (aUserId user) enableNtfs cMode PQSupportOff
|
||||
pure (cmdId, connId)
|
||||
|
||||
joinAgentConnectionAsync :: User -> Maybe Connection -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> CM (CommandId, ConnId)
|
||||
joinAgentConnectionAsync user conn_ enableNtfs cReqUri cInfo subMode = do
|
||||
prepareAgentJoin :: User -> Maybe Connection -> Bool -> ConnectionRequestUri c -> CM (CommandId, ConnId)
|
||||
prepareAgentJoin user conn_ enableNtfs cReqUri = do
|
||||
cmdId <- withStore' $ \db -> createCommand db user (dbConnId <$> conn_) CFJoinConn
|
||||
connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) (aConnId <$> conn_) enableNtfs cReqUri cInfo PQSupportOff subMode
|
||||
connId <- case conn_ of
|
||||
Just conn -> pure $ aConnId conn
|
||||
Nothing -> withAgent $ \a -> prepareConnectionToJoin a (aUserId user) enableNtfs cReqUri PQSupportOff
|
||||
pure (cmdId, connId)
|
||||
|
||||
joinAgentConnectionAsync :: ConnectionModeI c => CommandId -> Bool -> ConnId -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> CM ()
|
||||
joinAgentConnectionAsync cmdId updateConn connId enableNtfs cReqUri cInfo subMode =
|
||||
withAgent $ \a -> joinConnectionAsync a (aCorrId cmdId) updateConn connId enableNtfs cReqUri cInfo PQSupportOff subMode
|
||||
|
||||
allowAgentConnectionAsync :: MsgEncodingI e => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> CM ()
|
||||
allowAgentConnectionAsync user conn@Connection {connId, pqSupport, connChatVersion} confId msg = do
|
||||
cmdId <- withStore' $ \db -> createCommand db user (Just connId) CFAllowConn
|
||||
@@ -2789,13 +2811,17 @@ allowAgentConnectionAsync user conn@Connection {connId, pqSupport, connChatVersi
|
||||
withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm
|
||||
withStore' $ \db -> updateConnectionStatus db conn ConnAccepted
|
||||
|
||||
agentAcceptContactAsync :: MsgEncodingI e => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> PQSupport -> VersionChat -> CM (CommandId, ConnId)
|
||||
agentAcceptContactAsync user enableNtfs invId msg subMode pqSup chatV = do
|
||||
prepareAgentAccept :: User -> Bool -> InvitationId -> PQSupport -> CM (CommandId, ConnId)
|
||||
prepareAgentAccept user enableNtfs invId pqSup = do
|
||||
cmdId <- withStore' $ \db -> createCommand db user Nothing CFAcceptContact
|
||||
dm <- encodeConnInfoPQ pqSup chatV msg
|
||||
connId <- withAgent $ \a -> acceptContactAsync a (aUserId user) (aCorrId cmdId) enableNtfs invId dm pqSup subMode
|
||||
connId <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) enableNtfs invId pqSup
|
||||
pure (cmdId, connId)
|
||||
|
||||
agentAcceptContactAsync :: MsgEncodingI e => CommandId -> ConnId -> Bool -> InvitationId -> ChatMsgEvent e -> PQSupport -> VersionChat -> SubscriptionMode -> CM ()
|
||||
agentAcceptContactAsync cmdId connId enableNtfs invId msg pqSup chatV subMode = do
|
||||
dm <- encodeConnInfoPQ pqSup chatV msg
|
||||
withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) connId enableNtfs invId dm pqSup subMode
|
||||
|
||||
deleteAgentConnectionAsync :: ConnId -> CM ()
|
||||
deleteAgentConnectionAsync acId = deleteAgentConnectionAsync' acId False
|
||||
{-# INLINE deleteAgentConnectionAsync #-}
|
||||
|
||||
@@ -639,9 +639,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
forM_ gli_ $ \GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do
|
||||
groupInfo <- withStore $ \db -> getGroupInfo db cxt user groupId
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode
|
||||
groupConnIds@(cmdId, connId) <- prepareAgentCreation user CFCreateConnGrpInv True SCMInvitation
|
||||
gVar <- asks random
|
||||
withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds connChatVersion peerChatVRange subMode
|
||||
withAgent $ \a -> createConnectionAsync a (aCorrId cmdId) connId True SCMInvitation CR.IKPQOff subMode
|
||||
-- TODO REMOVE LEGACY ^^^
|
||||
SENT msgId proxy -> do
|
||||
void $ continueSending connEntity conn
|
||||
@@ -1242,7 +1243,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo incognitoProfile
|
||||
dm <- encodeXMemberConnInfo gInfo relayMemberId profileToSend
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
void $ joinAgentConnectionAsync user (Just conn) True cReq dm subMode
|
||||
(cmdId, connId') <- prepareAgentJoin user (Just conn) True cReq
|
||||
joinAgentConnectionAsync cmdId True connId' True cReq dm subMode
|
||||
CFGetRelayDataAccept -> do
|
||||
let GroupMember {memberId = MemberId expectedMemberId} = m
|
||||
if linkEntityId == Just expectedMemberId
|
||||
@@ -1639,12 +1641,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
let sig = C.signatureBytes $ C.sign' privKey challenge
|
||||
msg = XGrpRelayTest challenge (Just sig)
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
chatVR <- chatVersionRange
|
||||
let chatV = chatVR `peerConnChatVersion` chatVRange
|
||||
(cmdId, acId) <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV
|
||||
let chatV = vr cxt `peerConnChatVersion` chatVRange
|
||||
(cmdId, acId) <- prepareAgentAccept user True invId PQSupportOff
|
||||
withStore $ \db -> do
|
||||
Connection {connId = testCId} <- createRelayTestConnection db cxt user acId ConnAccepted chatV subMode
|
||||
liftIO $ setCommandConnId db user cmdId testCId
|
||||
agentAcceptContactAsync cmdId acId True invId msg PQSupportOff chatV subMode
|
||||
| otherwise = messageError "relay test sent to non-relay link"
|
||||
where
|
||||
User {userChatRelay} = user
|
||||
@@ -2634,12 +2636,13 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
then do
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
dm <- encodeConnInfo $ XGrpAcpt membershipMemId
|
||||
connIds <- joinAgentConnectionAsync user Nothing True connRequest dm subMode
|
||||
connIds@(cmdId, acId) <- prepareAgentJoin user Nothing True connRequest
|
||||
withStore' $ \db -> do
|
||||
setViaGroupLinkUri db groupId connId
|
||||
createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode
|
||||
updateGroupMemberStatusById db userId hostId GSMemAccepted
|
||||
updateGroupMemberStatus db userId membership GSMemAccepted
|
||||
joinAgentConnectionAsync cmdId False acId True connRequest dm subMode
|
||||
toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct)
|
||||
else do
|
||||
let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole
|
||||
@@ -3200,16 +3203,14 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
Just (ChatVersionRange mcvr)
|
||||
| maxVersion mcvr >= groupDirectInvVersion -> do
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
-- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second
|
||||
groupConnIds <- createConn subMode
|
||||
groupConnIds@(cmdId, connId) <- prepareAgentCreation user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation
|
||||
let chatV = maybe (minVersion (vr cxt)) (\peerVR -> vr cxt `peerConnChatVersion` fromChatVRange peerVR) memChatVRange
|
||||
void $ withStore $ \db -> do
|
||||
reMember <- createIntroReMember db cxt user gInfo memInfo memRestrictions
|
||||
createIntroReMemberConn db user m reMember chatV memInfo groupConnIds subMode
|
||||
withAgent $ \a -> createConnectionAsync a (aCorrId cmdId) connId (chatHasNtfs chatSettings) SCMInvitation CR.IKPQOff subMode
|
||||
| otherwise -> messageError "x.grp.mem.intro: member chat version range incompatible"
|
||||
_ -> messageError "x.grp.mem.intro can be only sent by host member"
|
||||
where
|
||||
createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation subMode
|
||||
|
||||
sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> CM ()
|
||||
sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do
|
||||
@@ -3252,12 +3253,16 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile gInfo membership $ fromLocalProfile $ memberProfile membership
|
||||
dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile
|
||||
-- [async agent commands] no continuation needed, but commands should be asynchronous for stability
|
||||
groupConnIds <- joinAgentConnectionAsync user Nothing (chatHasNtfs chatSettings) groupConnReq dm subMode
|
||||
directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user Nothing True dcr dm subMode
|
||||
let enableNtfsGrp = chatHasNtfs chatSettings
|
||||
groupConnIds@(gCmdId, gAcId) <- prepareAgentJoin user Nothing enableNtfsGrp groupConnReq
|
||||
directConnIds <- mapM (prepareAgentJoin user Nothing True) directConnReq
|
||||
let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo
|
||||
mcvr = maybe chatInitialVRange fromChatVRange memChatVRange
|
||||
chatV = vr cxt `peerConnChatVersion` mcvr
|
||||
withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode
|
||||
joinAgentConnectionAsync gCmdId False gAcId enableNtfsGrp groupConnReq dm subMode
|
||||
forM_ ((,) <$> directConnIds <*> directConnReq) $ \((dCmdId, dAcId), dcr) ->
|
||||
joinAgentConnectionAsync dCmdId False dAcId True dcr dm subMode
|
||||
|
||||
-- rollback defense (channels): apply an owner-signed role/removal only at a version >= the persisted
|
||||
-- roster_version (not the batch-constant gInfo, which a relay can stale by reordering events in one
|
||||
@@ -3777,11 +3782,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
}
|
||||
joinExistingContact subMode mCt@Contact {contactId = mContactId}
|
||||
| autoAcceptMemberContacts user = do
|
||||
(cmdId, acId) <- joinConn subMode
|
||||
(cmdId, acId) <- prepareAgentJoin user Nothing True connReq
|
||||
mCt' <- withStore $ \db -> do
|
||||
updateMemberContactInvited db user mCt groupDirectInv
|
||||
void $ liftIO $ createMemberContactConn db user acId (Just cmdId) g mConn ConnJoined mContactId subMode
|
||||
getContact db cxt user mContactId
|
||||
joinMemberContactAsync cmdId acId subMode
|
||||
securityCodeChanged mCt'
|
||||
createItems mCt' m
|
||||
| otherwise = do
|
||||
@@ -3795,13 +3801,14 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
createItems mCt' m
|
||||
createNewContact subMode
|
||||
| autoAcceptMemberContacts user = do
|
||||
(cmdId, acId) <- joinConn subMode
|
||||
(cmdId, acId) <- prepareAgentJoin user Nothing True connReq
|
||||
-- [incognito] reuse membership incognito profile
|
||||
(mCt, m') <- withStore $ \db -> do
|
||||
(mContactId, m') <- liftIO $ createMemberContactInvited db user g m groupDirectInv
|
||||
void $ liftIO $ createMemberContactConn db user acId (Just cmdId) g mConn ConnJoined mContactId subMode
|
||||
mCt <- getContact db cxt user mContactId
|
||||
pure (mCt, m')
|
||||
joinMemberContactAsync cmdId acId subMode
|
||||
createInternalChatItem user (CDDirectSnd mCt) CIChatBanner (Just epochStart)
|
||||
createItems mCt m'
|
||||
| otherwise = do
|
||||
@@ -3814,12 +3821,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
createInternalChatItem user (CDDirectSnd mCt) CIChatBanner (Just epochStart)
|
||||
createInternalChatItem user (CDDirectRcv mCt) (CIRcvDirectEvent $ RDEGroupInvLinkReceived gp) Nothing
|
||||
createItems mCt m'
|
||||
joinConn subMode = do
|
||||
joinMemberContactAsync cmdId acId subMode = do
|
||||
-- [incognito] send membership incognito profile
|
||||
p <- presentUserBadge user (incognitoMembershipProfile g) $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True
|
||||
-- TODO PQ should negotitate contact connection with PQSupportOn? (use encodeConnInfoPQ)
|
||||
dm <- encodeConnInfo $ XInfo p
|
||||
joinAgentConnectionAsync user Nothing True connReq dm subMode
|
||||
joinAgentConnectionAsync cmdId False acId True connReq dm subMode
|
||||
createItems mCt' m' = do
|
||||
(g', m'', scopeInfo) <- mkGroupChatScope g m'
|
||||
createInternalChatItem user (CDGroupRcv g' scopeInfo m'') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 $
|
||||
|
||||
@@ -449,9 +449,9 @@ testJoinGroup ps =
|
||||
cath <## ""
|
||||
cath <## "Send captcha text to join the group privacy."
|
||||
captcha <- dropStrPrefix "#privacy (support) 'SimpleX Directory_1'> " . dropTime <$> getTermLine cath
|
||||
cath #> ("#privacy (support) " <> captcha)
|
||||
cath <## "contact and member are merged: 'SimpleX Directory', #privacy 'SimpleX Directory_1'"
|
||||
cath <## "use @'SimpleX Directory' <message> to send messages"
|
||||
cath #> ("#privacy (support) " <> captcha)
|
||||
cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha)
|
||||
cath <## " Correct, you joined the group privacy"
|
||||
cath <## "#privacy: you joined the group"
|
||||
@@ -1277,6 +1277,8 @@ testKnocking ps =
|
||||
cath <## "#privacy: you joined the group, connecting to group moderators for admission to group"
|
||||
cath <## "#privacy: 'SimpleX Directory' accepted you to the group, pending review"
|
||||
bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting and pending review...), use /_accept member #1 3 <role> to accept member"
|
||||
cath <## "#privacy: member bob (Bob) is connected"
|
||||
bob <## "#privacy: new member cath is connected and pending review, use /_accept member #1 3 <role> to accept member"
|
||||
|
||||
testCaptchaByDefault :: HasCallStack => TestParams -> IO ()
|
||||
testCaptchaByDefault ps =
|
||||
|
||||
@@ -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" [])
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user