diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 3dd38dc75d..ba7047a62e 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -837,8 +837,8 @@ enum ChatResponse1: Decodable, ChatAPIResult { case let .groupAliasUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))") - case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails) - case let .userContactLinkUpdated(u, contactLink): return withUser(u, contactLink.responseDetails) + case let .userContactLink(u, contactLink): return withUser(u, String(describing: contactLink)) + case let .userContactLinkUpdated(u, contactLink): return withUser(u, String(describing: contactLink)) case let .userContactLinkCreated(u, connLink): return withUser(u, String(describing: connLink)) case .userContactLinkDeleted: return noDetails case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) @@ -888,8 +888,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole) case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool) case groupUpdated(user: UserRef, toGroup: GroupInfo) - case groupLinkCreated(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole) - case groupLink(user: UserRef, groupInfo: GroupInfo, connLinkContact: CreatedConnLink, memberRole: GroupMemberRole) + case groupLinkCreated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink) + case groupLink(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink) case groupLinkDeleted(user: UserRef, groupInfo: GroupInfo) case newMemberContact(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) case newMemberContactSentInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) @@ -984,8 +984,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)") case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)") case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) - case let .groupLinkCreated(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") - case let .groupLink(u, groupInfo, connLinkContact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\nconnLinkContact: \(connLinkContact)\nmemberRole: \(memberRole)") + case let .groupLinkCreated(u, groupInfo, groupLink): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)") + case let .groupLink(u, groupInfo, groupLink): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)") case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .newMemberContact(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") case let .newMemberContactSentInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") @@ -1395,11 +1395,8 @@ struct UserMsgReceiptSettings: Codable { struct UserContactLink: Decodable, Hashable { var connLinkContact: CreatedConnLink + var shortLinkDataSet: Bool var autoAccept: AutoAccept? - - var responseDetails: String { - "connLinkContact: \(connLinkContact)\nautoAccept: \(AutoAccept.cmdString(autoAccept))" - } } struct AutoAccept: Codable, Hashable { @@ -1420,6 +1417,14 @@ struct AutoAccept: Codable, Hashable { } } +struct GroupLink: Decodable, Hashable { + var userContactLinkId: Int64 + var connLinkContact: CreatedConnLink + var shortLinkDataSet: Bool + var groupLinkId: String + var acceptMemberRole: GroupMemberRole +} + struct DeviceToken: Decodable { var pushProvider: PushProvider var token: String diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index d0cca72880..51530bca80 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -404,6 +404,10 @@ final class ChatModel: ObservableObject { remoteCtrlSession?.active ?? false } + var addressShortLinkDataSet: Bool { + userAddress?.shortLinkDataSet ?? true + } + func getUser(_ userId: Int64) -> User? { currentUser?.userId == userId ? currentUser @@ -1216,9 +1220,7 @@ struct ShowingInvitation { var connChatUsed: Bool } -// TODO [short links] incognito if !userAddress.shortLinkDataSet; or remove if no access to chat model here struct NTFContactRequest { - var incognito: Bool var chatId: String } diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 25954f35ef..79f4ef2f09 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -12,7 +12,6 @@ import UIKit import SimpleXChat let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT" -let ntfActionAcceptContactIncognito = "NTF_ACT_ACCEPT_CONTACT_INCOGNITO" let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL" let ntfActionRejectCall = "NTF_ACT_REJECT_CALL" @@ -59,13 +58,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { logger.debug("NtfManager.processNotificationResponse changeActiveUser") changeActiveUser(userId, viewPwd: nil) } - if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito), + if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact, let chatId = content.userInfo["chatId"] as? String { - let incognito = action == ntfActionAcceptContactIncognito if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { - Task { await acceptContactRequest(incognito: incognito, contactRequestId: contactRequest.apiId) } + Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) } } else { - chatModel.ntfContactRequest = NTFContactRequest(incognito: incognito, chatId: chatId) + chatModel.ntfContactRequest = NTFContactRequest(chatId: chatId) } } else if let (chatId, ntfAction) = ntfCallAction(content, action) { if let invitation = chatModel.callInvitations.removeValue(forKey: chatId) { @@ -161,12 +159,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { identifier: ntfActionAcceptContact, title: NSLocalizedString("Accept", comment: "accept contact request via notification"), options: .foreground - ), - // TODO [short links] if !userAddress.shortLinkDataSet; or remove if no access to chat model here - UNNotificationAction( - identifier: ntfActionAcceptContactIncognito, - title: NSLocalizedString("Accept incognito", comment: "accept contact request via notification"), - options: .foreground ) ], intentIdentifiers: [], diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f58c109048..604eae7358 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1758,16 +1758,16 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws throw r.unexpected } -func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (CreatedConnLink, GroupMemberRole) { +func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink { let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS) let r: ChatResponse2 = try await chatSendCmd(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole, short: short)) - if case let .groupLinkCreated(_, _, connLink, memberRole) = r { return (connLink, memberRole) } + if case let .groupLinkCreated(_, _, groupLink) = r { return groupLink } throw r.unexpected } -func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> (CreatedConnLink, GroupMemberRole) { +func apiGroupLinkMemberRole(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink { let r: ChatResponse2 = try await chatSendCmd(.apiGroupLinkMemberRole(groupId: groupId, memberRole: memberRole)) - if case let .groupLink(_, _, connLink, memberRole) = r { return (connLink, memberRole) } + if case let .groupLink(_, _, groupLink) = r { return groupLink } throw r.unexpected } @@ -1777,20 +1777,20 @@ func apiDeleteGroupLink(_ groupId: Int64) async throws { throw r.unexpected } -func apiGetGroupLink(_ groupId: Int64) throws -> (CreatedConnLink, GroupMemberRole)? { +func apiGetGroupLink(_ groupId: Int64) throws -> GroupLink? { let r: APIResult = chatApiSendCmdSync(.apiGetGroupLink(groupId: groupId)) switch r { - case let .result(.groupLink(_, _, connLink, memberRole)): - return (connLink, memberRole) + case let .result(.groupLink(_, _, groupLink)): + return groupLink case .error(.errorStore(storeError: .groupLinkNotFound)): return nil default: throw r.unexpected } } -func apiAddGroupShortLink(_ groupId: Int64) async throws -> (CreatedConnLink, GroupMemberRole) { +func apiAddGroupShortLink(_ groupId: Int64) async throws -> GroupLink { let r: ChatResponse2 = try await chatSendCmd(.apiAddGroupShortLink(groupId: groupId)) - if case let .groupLink(_, _, connLink, memberRole) = r { return (connLink, memberRole) } + if case let .groupLink(_, _, groupLink) = r { return groupLink } throw r.unexpected } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index f2fe451aa4..5fbdafe19f 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -164,7 +164,7 @@ struct SimpleXApp: App { if let ncr = chatModel.ntfContactRequest { await MainActor.run { chatModel.ntfContactRequest = nil } if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo { - Task { await acceptContactRequest(incognito: ncr.incognito, contactRequestId: contactRequest.apiId) } + Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) } } } } catch let error { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index b410777d17..75e029ff36 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -47,7 +47,7 @@ struct ChatView: View { @State private var selectedMember: GMember? = nil // opening GroupLinkView on link button (incognito) @State private var showGroupLinkSheet: Bool = false - @State private var groupLink: CreatedConnLink? + @State private var groupLink: GroupLink? @State private var groupLinkMemberRole: GroupMemberRole = .member @State private var forwardedChatItems: [ChatItem] = [] @State private var selectedChatItems: Set? = nil @@ -1040,8 +1040,9 @@ struct ChatView: View { if case let .group(gInfo, _) = chat.chatInfo { Task { do { - if let link = try apiGetGroupLink(gInfo.groupId) { - (groupLink, groupLinkMemberRole) = link + if let gLink = try apiGetGroupLink(gInfo.groupId) { + groupLink = gLink + groupLinkMemberRole = gLink.acceptMemberRole } } catch let error { logger.error("ChatView apiGetGroupLink: \(responseError(error))") diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift index bacf7e3a35..0f8ab652e7 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift @@ -56,29 +56,36 @@ func showRejectRequestAlert(_ contactRequestId: Int64) { } func showAcceptRequestAlert(_ contactRequestId: Int64) { - showAlert( - NSLocalizedString("Accept contact request", comment: "alert title"), - actions: {[ + var actions: [UIAlertAction] = [] + actions.append( + UIAlertAction( + title: NSLocalizedString("Accept", comment: "alert action"), + style: .default, + handler: { _ in + Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequestId) } + } + ) + ) + if !ChatModel.shared.addressShortLinkDataSet { + actions.append( UIAlertAction( - title: NSLocalizedString("Accept", comment: "alert action"), + title: NSLocalizedString("Accept incognito", comment: "alert action"), style: .default, handler: { _ in - Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequestId) } - } - ), - // TODO [short links] if !userAddress.shortLinkDataSet; check other places - // UIAlertAction( - // title: NSLocalizedString("Accept incognito", comment: "alert action"), - // style: .default, - // handler: { _ in - // Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } - // } - // ), - UIAlertAction( - title: NSLocalizedString("Cancel", comment: "alert action"), - style: .default + Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequestId) } + } ) - ]} + ) + } + actions.append( + UIAlertAction( + title: NSLocalizedString("Cancel", comment: "alert action"), + style: .default + ) + ) + showAlert( + NSLocalizedString("Accept contact request", comment: "alert title"), + actions: { actions } ) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index e2ff8c2511..a1c511adc0 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -22,7 +22,7 @@ struct GroupChatInfoView: View { @State var localAlias: String @FocusState private var aliasTextFieldFocused: Bool @State private var alert: GroupChatInfoViewAlert? = nil - @State private var groupLink: CreatedConnLink? + @State private var groupLink: GroupLink? @State private var groupLinkMemberRole: GroupMemberRole = .member @State private var groupLinkNavLinkActive: Bool = false @State private var addMembersNavLinkActive: Bool = false @@ -221,8 +221,9 @@ struct GroupChatInfoView: View { } sendReceipts = SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault) do { - if let link = try apiGetGroupLink(groupInfo.groupId) { - (groupLink, groupLinkMemberRole) = link + if let gLink = try apiGetGroupLink(groupInfo.groupId) { + groupLink = gLink + groupLinkMemberRole = gLink.acceptMemberRole } } catch let error { logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 49e4d49e86..ce52c70cab 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -12,7 +12,7 @@ import SimpleXChat struct GroupLinkView: View { @EnvironmentObject var theme: AppTheme var groupId: Int64 - @Binding var groupLink: CreatedConnLink? + @Binding var groupLink: GroupLink? @Binding var groupLinkMemberRole: GroupMemberRole var showTitle: Bool = false var creatingGroup: Bool = false @@ -78,19 +78,27 @@ struct GroupLinkView: View { } } .frame(height: 36) - SimpleXCreatedLinkQRCode(link: groupLink, short: $showShortLink) - .id("simplex-qrcode-view-for-\(groupLink.simplexChatUri(short: showShortLink))") + SimpleXCreatedLinkQRCode(link: groupLink.connLinkContact, short: $showShortLink) + .id("simplex-qrcode-view-for-\(groupLink.connLinkContact.simplexChatUri(short: showShortLink))") Button { - showShareSheet(items: [groupLink.simplexChatUri(short: showShortLink)]) + showShareSheet(items: [groupLink.connLinkContact.simplexChatUri(short: showShortLink)]) } label: { Label("Share link", systemImage: "square.and.arrow.up") } - if (groupLink.connShortLink == nil && UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS)) { - Button { - addShortLink() - } label: { - Label("Add short link", systemImage: "plus") + if UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS) { + if groupLink.connLinkContact.connShortLink == nil { + Button { + addShortLink() + } label: { + Label("Add short link", systemImage: "plus") + } + } else if !groupLink.shortLinkDataSet { + Button { + addShortLink() + } label: { + Label("Share group profile via link", systemImage: "plus") + } } } @@ -106,8 +114,8 @@ struct GroupLinkView: View { .disabled(creatingLink) } } header: { - if let groupLink, groupLink.connShortLink != nil { - ToggleShortLinkHeader(text: Text(""), link: groupLink, short: $showShortLink) + if let groupLink, groupLink.connLinkContact.connShortLink != nil { + ToggleShortLinkHeader(text: Text(""), link: groupLink.connLinkContact, short: $showShortLink) } } .alert(item: $alert) { alert in @@ -134,7 +142,7 @@ struct GroupLinkView: View { .onChange(of: groupLinkMemberRole) { _ in Task { do { - _ = try await apiGroupLinkMemberRole(groupId, memberRole: groupLinkMemberRole) + groupLink = try await apiGroupLinkMemberRole(groupId, memberRole: groupLinkMemberRole) } catch let error { let a = getErrorAlert(error, "Error updating group link") alert = .error(title: a.title, error: a.message) @@ -155,10 +163,10 @@ struct GroupLinkView: View { Task { do { creatingLink = true - let link = try await apiCreateGroupLink(groupId) + let gLink = try await apiCreateGroupLink(groupId) await MainActor.run { creatingLink = false - (groupLink, groupLinkMemberRole) = link + groupLink = gLink } } catch let error { logger.error("GroupLinkView apiCreateGroupLink: \(responseError(error))") @@ -175,10 +183,10 @@ struct GroupLinkView: View { Task { do { creatingLink = true - let link = try await apiAddGroupShortLink(groupId) + let gLink = try await apiAddGroupShortLink(groupId) await MainActor.run { creatingLink = false - (groupLink, groupLinkMemberRole) = link + groupLink = gLink } } catch let error { logger.error("apiAddGroupShortLink: \(responseError(error))") @@ -194,8 +202,14 @@ struct GroupLinkView: View { struct GroupLinkView_Previews: PreviewProvider { static var previews: some View { - @State var groupLink: CreatedConnLink? = CreatedConnLink(connFullLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", connShortLink: nil) - @State var noGroupLink: CreatedConnLink? = nil + @State var groupLink: GroupLink? = GroupLink( + userContactLinkId: 1, + connLinkContact: CreatedConnLink(connFullLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", connShortLink: nil), + shortLinkDataSet: false, + groupLinkId: "abc", + acceptMemberRole: .member + ) + @State var noGroupLink: GroupLink? = nil return Group { GroupLinkView(groupId: 1, groupLink: $groupLink, groupLinkMemberRole: Binding.constant(.member)) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index ef476db9c7..ec70a5d995 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -136,12 +136,14 @@ struct ChatListNavLink: View { Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequestId) } } label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) } .tint(theme.colors.primary) - Button { - Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequestId) } - } label: { - SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI) + if !ChatModel.shared.addressShortLinkDataSet { + Button { + Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequestId) } + } label: { + SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI) + } + .tint(.indigo) } - .tint(.indigo) Button { AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequestId)) } label: { @@ -461,12 +463,14 @@ struct ChatListNavLink: View { Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) } } label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) } .tint(theme.colors.primary) - Button { - Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) } - } label: { - SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI) + if !ChatModel.shared.addressShortLinkDataSet { + Button { + Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) } + } label: { + SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI) + } + .tint(.indigo) } - .tint(.indigo) Button { AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest.apiId)) } label: { @@ -478,7 +482,9 @@ struct ChatListNavLink: View { .onTapGesture { showContactRequestDialog = true } .confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) { Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) } } - Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) } } + if !ChatModel.shared.addressShortLinkDataSet { + Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) } } + } Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest.apiId) } } } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 6f5fbeb250..d4b6b9c2a0 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -343,6 +343,8 @@ struct ChatPreviewView: View { if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active { chatPreviewInfoText("Tap to Connect") .foregroundColor(theme.colors.primary) + } else if contact.nextAcceptContactRequest { + chatPreviewInfoText("swipe or open to connect") } else if contact.sendMsgToConnect { chatPreviewInfoText("send to connect") } else if !contact.sndReady && contact.activeConn != nil && contact.active { diff --git a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift index e5bab9ba28..031713ee24 100644 --- a/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift +++ b/apps/ios/Shared/Views/Contacts/ContactListNavLink.swift @@ -94,12 +94,14 @@ struct ContactListNavLink: View { Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequestId) } } label: { Label("Accept", systemImage: "checkmark") } .tint(theme.colors.primary) - Button { - Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequestId) } - } label: { - Label("Accept incognito", systemImage: "theatermasks") + if !ChatModel.shared.addressShortLinkDataSet { + Button { + Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequestId) } + } label: { + Label("Accept incognito", systemImage: "theatermasks") + } + .tint(.indigo) } - .tint(.indigo) Button { alert = SomeAlert(alert: rejectContactRequestAlert(contactRequestId), id: "rejectContactRequestAlert") } label: { @@ -258,12 +260,14 @@ struct ContactListNavLink: View { Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) } } label: { Label("Accept", systemImage: "checkmark") } .tint(theme.colors.primary) - Button { - Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) } - } label: { - Label("Accept incognito", systemImage: "theatermasks") + if !ChatModel.shared.addressShortLinkDataSet { + Button { + Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) } + } label: { + Label("Accept incognito", systemImage: "theatermasks") + } + .tint(.indigo) } - .tint(.indigo) Button { alert = SomeAlert(alert: rejectContactRequestAlert(contactRequest.apiId), id: "rejectContactRequestAlert") } label: { @@ -273,7 +277,9 @@ struct ContactListNavLink: View { } .confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) { Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) } } - Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) } } + if !ChatModel.shared.addressShortLinkDataSet { + Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) } } + } Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest.apiId) } } } } diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index b3c33e95ea..433d06b9d7 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -23,7 +23,7 @@ struct AddGroupView: View { @State private var showTakePhoto = false @State private var chosenImage: UIImage? = nil @State private var showInvalidNameAlert = false - @State private var groupLink: CreatedConnLink? + @State private var groupLink: GroupLink? @State private var groupLinkMemberRole: GroupMemberRole = .member var body: some View { diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift index a2f5db7f03..3a58826371 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift @@ -77,9 +77,10 @@ struct CreateSimpleXAddress: View { progressIndicator = true Task { do { - let connLinkContact = try await apiCreateUserAddress(short: false) + let short = false + let connLinkContact = try await apiCreateUserAddress(short: short) DispatchQueue.main.async { - m.userAddress = UserContactLink(connLinkContact: connLinkContact) + m.userAddress = UserContactLink(connLinkContact: connLinkContact, shortLinkDataSet: short) } await MainActor.run { progressIndicator = false } } catch let error { diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index f374995f78..1b0b11dd23 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -153,8 +153,12 @@ struct UserAddressView: View { } } addressSettingsButton(userAddress) - if (userAddress.connLinkContact.connShortLink == nil && UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS)) { - addShortLinkButton() + if UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS) { + if userAddress.connLinkContact.connShortLink == nil { + addShortLinkButton() + } else if !userAddress.shortLinkDataSet { + addProfileToShortLinkButton() + } } } header: { ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink) @@ -199,7 +203,7 @@ struct UserAddressView: View { let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS) let connLinkContact = try await apiCreateUserAddress(short: short) DispatchQueue.main.async { - chatModel.userAddress = UserContactLink(connLinkContact: connLinkContact) + chatModel.userAddress = UserContactLink(connLinkContact: connLinkContact, shortLinkDataSet: short) alert = .shareOnCreate progressIndicator = false } @@ -214,12 +218,30 @@ struct UserAddressView: View { private func addShortLinkButton() -> some View { Button { - addShortLink() + showAddShortLinkAlert() } label: { Label("Add short link", systemImage: "plus") } } + private func addProfileToShortLinkButton() -> some View { + Button { + showAddShortLinkAlert() + } label: { + Label("Share profile via link", systemImage: "plus") + } + } + + private func showAddShortLinkAlert() { + showAlert( + title: NSLocalizedString("Share profile via link", comment: "alert title"), + message: NSLocalizedString("Profile will be shared via the address short link. This change to the address cannot be reversed, other than fully deleting it. Do you wish to update the address?", comment: "alert message"), + buttonTitle: NSLocalizedString("Update (and share profile)", comment: "alert button"), + buttonAction: { addShortLink() }, + cancelButton: true + ) + } + private func addShortLink() { progressIndicator = true Task { @@ -231,7 +253,7 @@ struct UserAddressView: View { await MainActor.run { progressIndicator = false } } catch let error { logger.error("apiAddMyAddressShortLink: \(responseError(error))") - let a = getErrorAlert(error, "Error creating address") + let a = getErrorAlert(error, "Error adding address short link") alert = .error(title: a.title, error: a.message) await MainActor.run { progressIndicator = false } } @@ -527,7 +549,7 @@ struct UserAddressSettingsView: View { private func autoAcceptSection() -> some View { Section { - if !aas.business { + if !ChatModel.shared.addressShortLinkDataSet && !aas.business { acceptIncognitoToggle() } welcomeMessageEditor() @@ -594,7 +616,10 @@ private func saveAAS(_ aas: Binding, _ savedAAS: Binding displayName <> ", creating the link…" sendChatCmd cc (APICreateGroupLink groupId GRMember False) >>= \case - Right CRGroupLinkCreated {connLinkContact = CCLink gLink _} -> do + Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact = CCLink gLink _}} -> do setGroupStatus st gr GRSPendingUpdate notifyOwner gr @@ -446,7 +446,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId) where profileUpdate = \case - Right CRGroupLink {connLinkContact = CCLink cr sl_} -> + Right CRGroupLink {groupLink = GroupLink {connLinkContact = CCLink cr sl_}} -> let hadLinkBefore = profileHasGroupLink fromGroup hasLinkNow = profileHasGroupLink toGroup profileHasGroupLink GroupInfo {groupProfile = gp} = @@ -712,11 +712,11 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName let GroupInfo {groupProfile = GroupProfile {displayName = n}} = g case mRole_ of Nothing -> - getGroupLinkRole cc user g >>= \case - Just (_, CCLink gLink _, _, mRole) -> do - let anotherRole = case mRole of GRObserver -> GRMember; _ -> GRObserver + getGroupLink' cc user g >>= \case + Just GroupLink {connLinkContact = CCLink gLink _, acceptMemberRole} -> do + let anotherRole = case acceptMemberRole of GRObserver -> GRMember; _ -> GRObserver sendReply $ - initialRole n mRole + initialRole n acceptMemberRole <> ("Send */role " <> tshow gId <> " " <> strEncodeTxt anotherRole <> "* to change it.\n\n") <> onlyViaLink gLink Nothing -> sendReply $ "Error: failed reading the initial member role for the group " <> n @@ -893,11 +893,11 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ _ -> sendChatCmd cc (APIGetGroupLink groupId) >>= \case - Right CRGroupLink {connLinkContact = CCLink cReq _, memberRole} -> + Right CRGroupLink {groupLink = GroupLink {connLinkContact = CCLink cReq _, acceptMemberRole}} -> sendReply $ T.unlines [ "The link to join the group " <> groupRef <> ":", strEncodeTxt $ simplexChatContact cReq, - "New member role: " <> strEncodeTxt memberRole + "New member role: " <> strEncodeTxt acceptMemberRole ] Left (ChatErrorStore (SEGroupLinkNotFound _)) -> sendReply $ "The group " <> groupRef <> " has no public link." @@ -1045,15 +1045,15 @@ vr :: ChatController -> VersionRangeChat vr ChatController {config = ChatConfig {chatVRange}} = chatVRange {-# INLINE vr #-} -getGroupLinkRole :: ChatController -> User -> GroupInfo -> IO (Maybe (Int64, CreatedLinkContact, GroupLinkId, GroupMemberRole)) -getGroupLinkRole cc user gInfo = +getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Maybe GroupLink) +getGroupLink' cc user gInfo = withDB "getGroupLink" cc $ \db -> getGroupLink db user gInfo setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe ConnReqContact) setGroupLinkRole cc GroupInfo {groupId} mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole groupId mRole) where resp = \case - Right (CRGroupLink _ _ (CCLink gLink _) _) -> Just gLink + Right (CRGroupLink {groupLink = GroupLink {connLinkContact = CCLink gLink _sLnk}}) -> Just gLink _ -> Nothing unexpectedError :: Text -> Text diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 69a2d8b24e..42c81db7e8 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -62,7 +62,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion import Simplex.Chat.Remote.Types import Simplex.Chat.Stats (PresentedServersSummary) -import Simplex.Chat.Store (AutoAccept, ChatLockEntity, StoreError (..), UserContactLink, GroupLinkInfo, UserMsgReceiptSettings) +import Simplex.Chat.Store (AutoAccept, ChatLockEntity, GroupLink, GroupLinkInfo, StoreError (..), UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -723,8 +723,8 @@ data ChatResponse | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} | CRGroupProfile {user :: User, groupInfo :: GroupInfo} | CRGroupDescription {user :: User, groupInfo :: GroupInfo} -- only used in CLI - | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connLinkContact :: CreatedLinkContact, memberRole :: GroupMemberRole} - | CRGroupLink {user :: User, groupInfo :: GroupInfo, connLinkContact :: CreatedLinkContact, memberRole :: GroupMemberRole} + | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink} + | CRGroupLink {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink} | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} | CRNewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 37622f0ff8..9af29228a6 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1144,19 +1144,17 @@ processChatCommand' vr = \case withFastStore' $ \db -> deleteNoteFolderCIs db user nf pure $ CRChatCleared user (AChatInfo SCTLocal $ LocalChat nf) _ -> throwCmdError "not supported" - -- TODO [short links] prohibit incognito if short link data is set for address - -- TODO - add user_contact_links.short_link_data_set; UserContactLink.shortLinkDataSet - -- TODO - same for auto-accept: - -- TODO - set incognito to false on setting short link data - -- TODO - ignore on accept (for foolproofing) - -- TODO - hide in UI - APIAcceptContact incognito connReqId -> withUser $ \_ -> do - userContactLinkId <- withFastStore $ \db -> getUserContactLinkIdByCReq db connReqId - withUserContactLock "acceptContact" userContactLinkId $ do - (user@User {userId}, cReq) <- withFastStore $ \db -> getContactRequest' db connReqId + APIAcceptContact incognito connReqId -> withUser $ \user@User {userId} -> do + (uclId, (ucl, gLinkInfo_)) <- withFastStore $ \db -> do + uclId <- getUserContactLinkIdByCReq db connReqId + uclGLinkInfo <- getUserContactLinkById db userId uclId + pure (uclId, uclGLinkInfo) + let UserContactLink {shortLinkDataSet} = ucl + when (shortLinkDataSet && incognito) $ throwCmdError "incognito not allowed for address with short link data" + withUserContactLock "acceptContact" uclId $ do + cReq <- withFastStore $ \db -> getContactRequest db user connReqId (ct, conn@Connection {connId}, sqSecured) <- acceptContactRequest user cReq incognito - ucl <- withFastStore $ \db -> getUserContactLinkById db userId userContactLinkId - let contactUsed = (\(_, gLinkInfo_) -> isNothing gLinkInfo_) ucl + let contactUsed = isNothing gLinkInfo_ ct' <- withStore' $ \db -> do deleteContactRequestRec db user cReq updateContactAccepted db user ct contactUsed @@ -1764,9 +1762,9 @@ processChatCommand' vr = \case -- TODO [short links] change prepared entity user -- TODO - UI would call these APIs before APIConnectPrepared... APIs -- TODO - UI to transition to new user keeping chat opened - APIChangeContactUser contactId newUserId -> withUser $ \user -> do + APIChangeContactUser _contactId _newUserId -> withUser $ \_user -> do ok_ - APIChangeGroupUser groupId newUserId -> withUser $ \user -> do + APIChangeGroupUser _groupId _newUserId -> withUser $ \_user -> do ok_ APIConnectPreparedContact contactId incognito msgContent_ -> withUser $ \user -> do Contact {connLinkToConnect} <- withFastStore $ \db -> getContact db vr user contactId @@ -1837,37 +1835,13 @@ processChatCommand' vr = \case processChatCommand $ APIListContacts userId APICreateMyAddress userId short -> withUserId userId $ \user -> procCmd $ do subMode <- chatReadVar subscriptionMode - -- TODO [short links] incognito interaction with contact address - -- TODO - problems: - -- TODO 1 now that user profile is advertised in short link, giving an option to - -- TODO share incognito profile on accept doesn't make sense. - -- TODO 2 even advertising a random incognito profile in short link is somewhat broken, - -- TODO as it would be the same for all connecting clients, but then changed on accept (in current implementation), - -- TODO so it would be clear it's incognito profile; it might as well be clearly a placeholder - -- TODO (e.g. "Hidden profile" in profile name), and to avoid distinguishing incognito and main profiles, - -- TODO it can be a setting that can be set for main profile too. - -- TODO 3 changing short link data from main to incognito profile also defeats the purpose of incognito profile, - -- TODO as by scanning the short link in the past, connecting clients may know main profile (even if it's - -- TODO currently set as incognito). - -- TODO - some possibilities: - -- TODO 1 always base choice on autoAccept -> acceptIncognito choice, don't give option on user accept action - -- TODO 2 in this case, replace short link user data on change of this setting? (doesn't solve problem 3) - -- TODO 3 give setting to include "Hidden profile" in short link, even if autoAccept is not set to incognito - -- TODO 4 share same random profile on each accept, this way connecting clients technically - -- TODO wouldn't be able to distinguish incognito profile (placeholder problem); - -- TODO 5 only give choice to accept with main or incognito profile if random profile or placeholder is shared - -- TODO in short link user data; if main profile is shared, don't give choice - -- TODO 6 don't allow to change from main profile in short link to "Hidden profile" (or incognito autoAccept), - -- TODO only allow to change vice versa, in one direction (solves problem 3) - -- TODO 7 remove incognito functionality from address altogether - -- TODO - it seems measures 3, 5, 6 are the most reasonable, or removing incognito functionality for addresses altogether let userData = if short then Just $ encodeShortLinkData (ContactShortLinkData (userProfileToSend user Nothing Nothing False) Nothing) else Nothing (connId, ccLink) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact userData Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink - withFastStore $ \db -> createUserContactLink db user connId ccLink' subMode + withFastStore $ \db -> createUserContactLink db user connId ccLink' short subMode pure $ CRUserContactLinkCreated user ccLink' CreateMyAddress short -> withUser $ \User {userId} -> processChatCommand $ APICreateMyAddress userId short @@ -1889,10 +1863,8 @@ processChatCommand' vr = \case ShowMyAddress -> withUser' $ \User {userId} -> processChatCommand $ APIShowMyAddress userId APIAddMyAddressShortLink userId -> withUserId' userId $ \user -> do - (ucl@UserContactLink {connLinkContact = CCLink connFullLink sLnk_, autoAccept}, conn) <- + (ucl@UserContactLink {connLinkContact = CCLink connFullLink _sLnk_, autoAccept}, conn) <- withFastStore $ \db -> (,) <$> getUserAddress db user <*> getUserAddressConnection db vr user - when (isJust sLnk_) $ throwCmdError "address already has short link" - -- TODO [short links] allow to add short link without data if autoAccept was set to incognito? let shortLinkProfile = userProfileToSend user Nothing Nothing False shortLinkMsg = autoAccept >>= autoReply >>= (Just . msgContentText) userData = encodeShortLinkData (ContactShortLinkData shortLinkProfile shortLinkMsg) @@ -1900,7 +1872,8 @@ processChatCommand' vr = \case case entityId conn of Just uclId -> do withFastStore' $ \db -> setUserContactLinkShortLink db uclId sLnk - let ucl' = (ucl :: UserContactLink) {connLinkContact = CCLink connFullLink (Just sLnk)} + let autoAccept' = autoAccept >>= \aa -> Just aa {acceptIncognito = False} + ucl' = (ucl :: UserContactLink) {connLinkContact = CCLink connFullLink (Just sLnk), shortLinkDataSet = True, autoAccept = autoAccept'} pure $ CRUserContactLink user ucl' Nothing -> throwChatError $ CEException "no user contact link id" APISetProfileAddress userId False -> withUserId userId $ \user@User {profile = p} -> do @@ -1915,7 +1888,9 @@ processChatCommand' vr = \case processChatCommand $ APISetProfileAddress userId onOff APIAddressAutoAccept userId autoAccept_ -> withUserId userId $ \user -> do -- TODO [short links] update adress short link data if message changed - forM_ autoAccept_ $ \AutoAccept {businessAddress, acceptIncognito} -> + UserContactLink {shortLinkDataSet} <- withFastStore (`getUserAddress` user) + forM_ autoAccept_ $ \AutoAccept {businessAddress, acceptIncognito} -> do + when (shortLinkDataSet && acceptIncognito) $ throwCmdError "incognito not allowed for address with short link data" when (businessAddress && acceptIncognito) $ throwCmdError "requests to business address cannot be accepted incognito" contactLink <- withFastStore (\db -> updateUserAddressAutoAccept db user autoAccept_) pure $ CRUserContactLinkUpdated user contactLink @@ -2482,37 +2457,39 @@ processChatCommand' vr = \case crClientData = encodeJSON $ CRDataGroup groupLinkId (connId, ccLink) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact userData (Just crClientData) IKPQOff subMode ccLink' <- createdGroupLink <$> shortenCreatedLink ccLink - withFastStore $ \db -> createGroupLink db user gInfo connId ccLink' groupLinkId mRole subMode - pure $ CRGroupLinkCreated user gInfo ccLink' mRole + gLink <- withFastStore $ \db -> createGroupLink db user gInfo connId ccLink' groupLinkId mRole short subMode + pure $ CRGroupLinkCreated user gInfo gLink APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withGroupLock "groupLinkMemberRole" groupId $ do gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId - (groupLinkId, groupLink, _, mRole) <- withFastStore $ \db -> getGroupLink db user gInfo + gLnk@GroupLink {acceptMemberRole} <- withFastStore $ \db -> getGroupLink db user gInfo assertUserGroupRole gInfo GRAdmin when (mRole' > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole' - when (mRole' /= mRole) $ withFastStore' $ \db -> setGroupLinkMemberRole db user groupLinkId mRole' - pure $ CRGroupLink user gInfo groupLink mRole' + gLnk' <- + if mRole' /= acceptMemberRole + then withFastStore' $ \db -> setGroupLinkMemberRole db user gLnk mRole' + else pure gLnk + pure $ CRGroupLink user gInfo gLnk' APIDeleteGroupLink groupId -> withUser $ \user -> withGroupLock "deleteGroupLink" groupId $ do gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId deleteGroupLink' user gInfo pure $ CRGroupLinkDeleted user gInfo APIGetGroupLink groupId -> withUser $ \user -> do gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId - (_, groupLink, _, mRole) <- withFastStore $ \db -> getGroupLink db user gInfo - pure $ CRGroupLink user gInfo groupLink mRole + gLnk <- withFastStore $ \db -> getGroupLink db user gInfo + pure $ CRGroupLink user gInfo gLnk APIAddGroupShortLink groupId -> withUser $ \user -> do - (gInfo, (uclId, _gLink@(CCLink connFullLink sLnk_), gLinkId, mRole), conn) <- withFastStore $ \db -> do + (gInfo, gLink, conn) <- withFastStore $ \db -> do gInfo <- getGroupInfo db vr user groupId gLink <- getGroupLink db user gInfo conn <- getGroupLinkConnection db vr user gInfo pure (gInfo, gLink, conn) - when (isJust sLnk_) $ throwCmdError "group link already has short link" let GroupInfo {groupProfile} = gInfo userData = encodeShortLinkData (GroupShortLinkData groupProfile) - crClientData = encodeJSON $ CRDataGroup gLinkId + GroupLink {groupLinkId} = gLink + crClientData = encodeJSON $ CRDataGroup groupLinkId sLnk <- shortenShortLink' . toShortGroupLink =<< withAgent (\a -> setConnShortLink a (aConnId conn) SCMContact userData (Just crClientData)) - withFastStore' $ \db -> setUserContactLinkShortLink db uclId sLnk - let groupLink' = CCLink connFullLink (Just sLnk) - pure $ CRGroupLink user gInfo groupLink' mRole + gLink' <- withFastStore' $ \db -> setGroupLinkShortLink db gLink sLnk + pure $ CRGroupLink user gInfo gLink' APICreateMemberContact gId gMemberId -> withUser $ \user -> do (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId assertUserGroupRole g GRAuthor @@ -3313,7 +3290,7 @@ processChatCommand' vr = \case case ct of CCTContact -> withFastStore' (\db -> getUserContactLinkViaShortLink db user l') >>= \case - Just (UserContactLink (CCLink cReq _) _) -> pure (ACCL SCMContact $ CCLink cReq (Just l'), CPContactAddress CAPOwnLink) + Just (UserContactLink (CCLink cReq _) _ _) -> pure (ACCL SCMContact $ CCLink cReq (Just l'), CPContactAddress CAPOwnLink) Nothing -> do (cReq, cData) <- getShortLinkConnReq user l' let contactSLinkData_ = decodeShortLinkData $ linkUserData cData diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index af320776ac..ba5330312b 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -577,9 +577,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = probeMatchingContactsAndMembers ct' (contactConnIncognito ct') doProbeContacts withStore' $ \db -> resetContactConnInitiated db user conn' forM_ viaUserContactLink $ \userContactLinkId -> do - ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId - let (UserContactLink {autoAccept}, gli_) = ucl - when (connChatVersion < batchSend2Version) $ sendAutoReply ct' autoAccept + (ucl, gli_) <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId + when (connChatVersion < batchSend2Version) $ sendAutoReply ucl ct' -- TODO REMOVE LEGACY vvv forM_ gli_ $ \GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do groupInfo <- withStore $ \db -> getGroupInfo db vr user groupId @@ -645,9 +644,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = lift $ setContactNetworkStatus ct NSConnected toView $ CEvtContactSndReady user ct forM_ viaUserContactLink $ \userContactLinkId -> do - ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId - let (UserContactLink {autoAccept}, _) = ucl - when (connChatVersion >= batchSend2Version) $ sendAutoReply ct autoAccept + (ucl, _) <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId + when (connChatVersion >= batchSend2Version) $ sendAutoReply ucl ct QCONT -> void $ continueSending connEntity conn MWARN msgId err -> do @@ -667,13 +665,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO add debugging output _ -> pure () where - -- TODO [short links] don't send auto-reply message if it should have been created by connecting client - -- TODO - based on version + whether address has short link data (shortLinkDataSet) - sendAutoReply ct = \case - Just AutoAccept {autoReply = Just mc} -> do - (msg, _) <- sendDirectContactMessage user ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) - ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] + sendAutoReply UserContactLink {shortLinkDataSet, autoAccept} ct = case autoAccept of + Just AutoAccept {autoReply = Just mc} + | not shortLinkDataSet || connChatVersion < shortLinkDataVersion -> do + (msg, _) <- sendDirectContactMessage user ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) + ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] _ -> pure () processGroupMessage :: AEvent e -> ConnectionEntity -> Connection -> GroupInfo -> GroupMember -> CM () @@ -1227,8 +1224,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MsgContent -> PQSupport -> CM () profileContactRequest invId chatVRange p@Profile {displayName} xContactId_ mc_ reqPQSup = do - ucl <- withStore $ \db -> getUserContactLinkById db userId uclId - let (UserContactLink {connLinkContact = CCLink connReq _, autoAccept}, gLinkInfo_) = ucl + uclGLinkInfo <- withStore $ \db -> getUserContactLinkById db userId uclId + let (UserContactLink {connLinkContact = CCLink connReq _, shortLinkDataSet, autoAccept}, gLinkInfo_) = uclGLinkInfo isSimplexTeam = sameConnReqContact connReq adminContactReq v = maxVersion chatVRange case autoAccept of @@ -1265,7 +1262,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Just ct -> toView $ CEvtContactRequestAlreadyAccepted user ct Nothing -> do -- [incognito] generate profile to send, create connection with incognito profile - incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing + incognitoProfile <- + if not shortLinkDataSet && acceptIncognito + then Just . NewIncognito <$> liftIO generateRandomProfile + else pure Nothing ct <- acceptContactRequestAsync user uclId invId chatVRange p xContactId_ reqPQSup incognitoProfile forM_ mc_ $ \mc -> createInternalChatItem user (CDDirectRcv ct) (CIRcvMsgContent mc) Nothing diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 044c421c4d..b206cec6fb 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -78,12 +78,13 @@ import Simplex.Messaging.Version hiding (version) -- 12 - support sending and receiving content reports (2025-01-03) -- 14 - support sending and receiving group join rejection (2025-02-24) -- 15 - support specifying message scopes for group messages (2025-03-12) +-- 16 - support short link data (2025-06-10) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 15 +currentChatVersion = VersionChat 16 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -142,6 +143,10 @@ groupJoinRejectVersion = VersionChat 14 groupKnockingVersion :: VersionChat groupKnockingVersion = VersionChat 15 +-- support short link data in invitation, contact and group links +shortLinkDataVersion :: VersionChat +shortLinkDataVersion = VersionChat 16 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index a41641f88d..fd3392990f 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -6,6 +6,7 @@ module Simplex.Chat.Store ChatLockEntity (..), UserMsgReceiptSettings (..), UserContactLink (..), + GroupLink (..), GroupLinkInfo (..), AutoAccept (..), createChatStore, @@ -14,6 +15,7 @@ module Simplex.Chat.Store ) where +import Simplex.Chat.Store.Groups (GroupLink (..)) import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Messaging.Agent.Store.Common (DBStore (..), withTransaction) diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 726fb03219..c8e840c199 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -60,7 +60,6 @@ module Simplex.Chat.Store.Direct getAcceptedContactByXContactId, getAcceptedBusinessChatByXContactId, getUserContactLinkIdByCReq, - getContactRequest', getContactRequest, getContactRequestIdByName, deleteContactRequest, @@ -830,11 +829,6 @@ getUserContactLinkIdByCReq db contactRequestId = ExceptT . firstRow fromOnly (SEContactRequestNotFound contactRequestId) $ DB.query db "SELECT user_contact_link_id FROM contact_requests WHERE contact_request_id = ?" (Only contactRequestId) -getContactRequest' :: DB.Connection -> Int64 -> ExceptT StoreError IO (User, UserContactRequest) -getContactRequest' db contactRequestId = do - user <- getUserByContactRequestId db contactRequestId - (user,) <$> getContactRequest db user contactRequestId - getContactRequest :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO UserContactRequest getContactRequest db User {userId} contactRequestId = ExceptT . firstRow toContactRequest (SEContactRequestNotFound contactRequestId) $ diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 89a6d376da..0d77d98e31 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -8,6 +8,7 @@ {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeOperators #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} @@ -17,6 +18,7 @@ module Simplex.Chat.Store.Groups GroupInfoRow, GroupMemberRow, MaybeGroupMemberRow, + GroupLink (..), toGroupInfo, toGroupMember, toMaybeGroupMember, @@ -28,6 +30,7 @@ module Simplex.Chat.Store.Groups getGroupLink, getGroupLinkId, setGroupLinkMemberRole, + setGroupLinkShortLink, getGroupAndMember, createNewGroup, createGroupInvitation, @@ -151,6 +154,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) +import qualified Data.Aeson.TH as J import Data.Bifunctor (second) import Data.Bitraversable (bitraverse) import Data.Either (rights) @@ -175,6 +179,7 @@ import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff) +import Simplex.Messaging.Parsers (defaultJSON) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (eitherToMaybe, firstRow', ($>>=), (<$$>)) import Simplex.Messaging.Version @@ -194,16 +199,17 @@ toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just member Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs)) toMaybeGroupMember _ _ = Nothing -createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () -createGroupLink db User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId (CCLink cReq shortLink) groupLinkId memberRole subMode = +createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> Bool -> SubscriptionMode -> ExceptT StoreError IO GroupLink +createGroupLink db user@User {userId} groupInfo@GroupInfo {groupId, localDisplayName} agentConnId (CCLink cReq shortLink) groupLinkId memberRole shortLinkDataSet subMode = do checkConstraint (SEDuplicateGroupLink groupInfo) . liftIO $ do currentTs <- getCurrentTime DB.execute db - "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, short_link_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)" - (userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, shortLink, memberRole, BI True, currentTs, currentTs) + "INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, short_link_contact, short_link_data_set, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)" + ((userId, groupId, groupLinkId, "group_link_" <> localDisplayName, cReq, shortLink, BI shortLinkDataSet) :. (memberRole, BI True, currentTs, currentTs)) userContactLinkId <- insertedRowId db void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId ConnNew initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode PQSupportOff + getGroupLink db user groupInfo getGroupLinkConnection :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ExceptT StoreError IO Connection getGroupLinkConnection db vr User {userId} groupInfo@GroupInfo {groupId} = @@ -262,21 +268,51 @@ deleteGroupLink db User {userId} GroupInfo {groupId} = do (userId, groupId) DB.execute db "DELETE FROM user_contact_links WHERE user_id = ? AND group_id = ?" (userId, groupId) -getGroupLink :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO (Int64, CreatedLinkContact, GroupLinkId, GroupMemberRole) +data GroupLink = GroupLink + { userContactLinkId :: Int64, -- db id + connLinkContact :: CreatedLinkContact, + shortLinkDataSet :: Bool, + groupLinkId :: GroupLinkId, + acceptMemberRole :: GroupMemberRole + } + deriving (Show) + +getGroupLink :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO GroupLink getGroupLink db User {userId} gInfo@GroupInfo {groupId} = - ExceptT . firstRow groupLink (SEGroupLinkNotFound gInfo) $ - DB.query db "SELECT user_contact_link_id, conn_req_contact, short_link_contact, group_link_id, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId) + ExceptT . firstRow toGroupLink (SEGroupLinkNotFound gInfo) $ + DB.query db "SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, group_link_id, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId) where - groupLink (linkId, cReq, shortLink, gLinkId, mRole_) = (linkId, CCLink cReq shortLink, gLinkId, fromMaybe GRMember mRole_) + toGroupLink (userContactLinkId, cReq, shortLink, BI shortLinkDataSet, groupLinkId, mRole_) = + GroupLink { + userContactLinkId, + connLinkContact = CCLink cReq shortLink, + shortLinkDataSet, + groupLinkId, + acceptMemberRole = fromMaybe GRMember mRole_ + } getGroupLinkId :: DB.Connection -> User -> GroupInfo -> IO (Maybe GroupLinkId) getGroupLinkId db User {userId} GroupInfo {groupId} = fmap join . maybeFirstRow fromOnly $ DB.query db "SELECT group_link_id FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1" (userId, groupId) -setGroupLinkMemberRole :: DB.Connection -> User -> Int64 -> GroupMemberRole -> IO () -setGroupLinkMemberRole db User {userId} userContactLinkId memberRole = +setGroupLinkMemberRole :: DB.Connection -> User -> GroupLink -> GroupMemberRole -> IO GroupLink +setGroupLinkMemberRole db User {userId} gLnk@GroupLink{userContactLinkId} memberRole = do DB.execute db "UPDATE user_contact_links SET group_link_member_role = ? WHERE user_id = ? AND user_contact_link_id = ?" (memberRole, userId, userContactLinkId) + pure gLnk {acceptMemberRole = memberRole} + +setGroupLinkShortLink :: DB.Connection -> GroupLink -> ShortLinkContact -> IO GroupLink +setGroupLinkShortLink db gLnk@GroupLink {userContactLinkId, connLinkContact = CCLink connFullLink _sLnk_} shortLink = do + DB.execute + db + [sql| + UPDATE user_contact_links + SET short_link_contact = ?, + short_link_data_set = ? + WHERE user_contact_link_id = ? + |] + (shortLink, BI True, userContactLinkId) + pure gLnk {connLinkContact = CCLink connFullLink (Just shortLink), shortLinkDataSet = True} getGroupAndMember :: DB.Connection -> User -> Int64 -> VersionRangeChat -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember db User {userId, userContactId} groupMemberId vr = do @@ -2747,3 +2783,5 @@ updateGroupAlias db userId g@GroupInfo {groupId} localAlias = do updatedAt <- getCurrentTime DB.execute db "UPDATE groups SET local_alias = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (localAlias, updatedAt, userId, groupId) pure (g :: GroupInfo) {localAlias = localAlias} + +$(J.deriveJSON defaultJSON ''GroupLink) diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 38b2615686..2db11c5e31 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -354,14 +354,14 @@ getUserContactProfiles db User {userId} = toContactProfile :: (ContactName, Text, Maybe ImageData, Maybe ConnLinkContact, Maybe Preferences) -> Profile toContactProfile (displayName, fullName, image, contactLink, preferences) = Profile {displayName, fullName, image, contactLink, preferences} -createUserContactLink :: DB.Connection -> User -> ConnId -> CreatedLinkContact -> SubscriptionMode -> ExceptT StoreError IO () -createUserContactLink db User {userId} agentConnId (CCLink cReq shortLink) subMode = +createUserContactLink :: DB.Connection -> User -> ConnId -> CreatedLinkContact -> Bool -> SubscriptionMode -> ExceptT StoreError IO () +createUserContactLink db User {userId} agentConnId (CCLink cReq shortLink) shortLinkDataSet subMode = checkConstraint SEDuplicateContactLink . liftIO $ do currentTs <- getCurrentTime DB.execute db - "INSERT INTO user_contact_links (user_id, conn_req_contact, short_link_contact, created_at, updated_at) VALUES (?,?,?,?,?)" - (userId, cReq, shortLink, currentTs, currentTs) + "INSERT INTO user_contact_links (user_id, conn_req_contact, short_link_contact, short_link_data_set, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (userId, cReq, shortLink, BI shortLinkDataSet, currentTs, currentTs) userContactLinkId <- insertedRowId db void $ createConnection_ db userId ConnUserContact (Just userContactLinkId) agentConnId ConnNew initialChatVersion chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff @@ -451,6 +451,7 @@ data UserMsgReceiptSettings = UserMsgReceiptSettings data UserContactLink = UserContactLink { connLinkContact :: CreatedLinkContact, + shortLinkDataSet :: Bool, autoAccept :: Maybe AutoAccept } deriving (Show) @@ -472,9 +473,9 @@ $(J.deriveJSON defaultJSON ''AutoAccept) $(J.deriveJSON defaultJSON ''UserContactLink) -toUserContactLink :: (ConnReqContact, Maybe ShortLinkContact, BoolInt, BoolInt, BoolInt, Maybe MsgContent) -> UserContactLink -toUserContactLink (connReq, shortLink, BI autoAccept, BI businessAddress, BI acceptIncognito, autoReply) = - UserContactLink (CCLink connReq shortLink) $ +toUserContactLink :: (ConnReqContact, Maybe ShortLinkContact, BoolInt, BoolInt, BoolInt, BoolInt, Maybe MsgContent) -> UserContactLink +toUserContactLink (connReq, shortLink, BI shortLinkDataSet, BI autoAccept, BI businessAddress, BI acceptIncognito, autoReply) = + UserContactLink (CCLink connReq shortLink) shortLinkDataSet $ if autoAccept then Just AutoAccept {businessAddress, acceptIncognito, autoReply} else Nothing getUserAddress :: DB.Connection -> User -> ExceptT StoreError IO UserContactLink @@ -488,7 +489,7 @@ getUserContactLinkById db userId userContactLinkId = DB.query db [sql| - SELECT conn_req_contact, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role + SELECT conn_req_contact, short_link_contact, short_link_data_set, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? |] @@ -524,7 +525,7 @@ getUserContactLinkViaShortLink db User {userId} shortLink = userContactLinkQuery :: Query userContactLinkQuery = [sql| - SELECT conn_req_contact, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content + SELECT conn_req_contact, short_link_contact, short_link_data_set, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content FROM user_contact_links |] @@ -534,10 +535,12 @@ setUserContactLinkShortLink db userContactLinkId shortLink = db [sql| UPDATE user_contact_links - SET short_link_contact = ? + SET short_link_contact = ?, + short_link_data_set = ?, + auto_accept_incognito = ? WHERE user_contact_link_id = ? |] - (shortLink, userContactLinkId) + (shortLink, BI True, BI False, userContactLinkId) getContactWithoutConnViaAddress :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe Contact) getContactWithoutConnViaAddress db vr user@User {userId} (cReqSchema1, cReqSchema2) = do diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250526_short_links.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250526_short_links.hs index 2dfd329eab..dfeafe6b98 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250526_short_links.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250526_short_links.hs @@ -14,6 +14,8 @@ ALTER TABLE contacts ADD COLUMN conn_short_link_to_connect BLOB; ALTER TABLE contacts ADD COLUMN contact_request_id INTEGER REFERENCES contact_requests ON DELETE SET NULL; CREATE INDEX idx_contacts_contact_request_id ON contacts(contact_request_id); +ALTER TABLE user_contact_links ADD COLUMN short_link_data_set INTEGER NOT NULL DEFAULT 0; + ALTER TABLE groups ADD COLUMN conn_full_link_to_connect BLOB; ALTER TABLE groups ADD COLUMN conn_short_link_to_connect BLOB; ALTER TABLE groups ADD COLUMN conn_link_started_connection INTEGER NOT NULL DEFAULT 0; @@ -28,6 +30,8 @@ ALTER TABLE contacts DROP COLUMN conn_short_link_to_connect; DROP INDEX idx_contacts_contact_request_id; ALTER TABLE contacts DROP COLUMN contact_request_id; +ALTER TABLE user_contact_links DROP COLUMN short_link_data_set; + ALTER TABLE groups DROP COLUMN conn_full_link_to_connect; ALTER TABLE groups DROP COLUMN conn_short_link_to_connect; ALTER TABLE groups DROP COLUMN conn_link_started_connection; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 897057db11..19824f8ab3 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -3062,7 +3062,7 @@ Plan: SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT conn_req_contact, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role + SELECT conn_req_contact, short_link_contact, short_link_data_set, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? @@ -4854,21 +4854,21 @@ CORRELATED SCALAR SUBQUERY 1 SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) Query: - SELECT conn_req_contact, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content + SELECT conn_req_contact, short_link_contact, short_link_data_set, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content FROM user_contact_links WHERE user_id = ? AND conn_req_contact IN (?,?) Plan: SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) Query: - SELECT conn_req_contact, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content + SELECT conn_req_contact, short_link_contact, short_link_data_set, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content FROM user_contact_links WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL Plan: SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) Query: - SELECT conn_req_contact, short_link_contact, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content + SELECT conn_req_contact, short_link_contact, short_link_data_set, auto_accept, business_address, auto_accept_incognito, auto_reply_msg_content FROM user_contact_links WHERE user_id = ? AND short_link_contact = ? Plan: @@ -5022,19 +5022,6 @@ SEARCH u USING INTEGER PRIMARY KEY (rowid=?) SEARCH uct USING INTEGER PRIMARY KEY (rowid=?) SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.image, ucp.contact_link, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes - FROM users u - JOIN contacts uct ON uct.contact_id = u.contact_id - JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id - JOIN contact_requests cr ON cr.user_id = u.user_id WHERE cr.contact_request_id = ? -Plan: -SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) -SEARCH u USING INTEGER PRIMARY KEY (rowid=?) -SEARCH uct USING INTEGER PRIMARY KEY (rowid=?) -SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.image, ucp.contact_link, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes @@ -5648,10 +5635,10 @@ SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) Query: INSERT INTO temp_conn_ids (conn_id) VALUES (?) Plan: -Query: INSERT INTO user_contact_links (user_id, conn_req_contact, short_link_contact, created_at, updated_at) VALUES (?,?,?,?,?) +Query: INSERT INTO user_contact_links (user_id, conn_req_contact, short_link_contact, short_link_data_set, created_at, updated_at) VALUES (?,?,?,?,?,?) Plan: -Query: INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, short_link_contact, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?) +Query: INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, short_link_contact, short_link_data_set, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) Plan: Query: INSERT INTO users (agent_user_id, local_display_name, active_user, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, created_at, updated_at) VALUES (?,?,?,?,0,?,?,?,?,?) @@ -5880,7 +5867,7 @@ Query: SELECT user_contact_link_id FROM contact_requests WHERE contact_request_i Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) -Query: SELECT user_contact_link_id, conn_req_contact, short_link_contact, group_link_id, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1 +Query: SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, group_link_id, group_link_member_role FROM user_contact_links WHERE user_id = ? AND group_id = ? LIMIT 1 Plan: SEARCH user_contact_links USING INDEX idx_user_contact_links_group_id (group_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 2bd5f32141..e3d751d0b9 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -332,6 +332,7 @@ CREATE TABLE user_contact_links( group_link_member_role TEXT NULL, business_address INTEGER DEFAULT 0, short_link_contact BLOB, + short_link_data_set INTEGER NOT NULL DEFAULT 0, UNIQUE(user_id, local_display_name) ); CREATE TABLE contact_requests( diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index a73b75e505..75575feaaf 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -50,7 +50,7 @@ import Simplex.Chat.Operators import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion (AppVersion (..), pattern AppVersionRange) import Simplex.Chat.Remote.Types -import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (..)) +import Simplex.Chat.Store (AutoAccept (..), GroupLink (..), StoreError (..), UserContactLink (..)) import Simplex.Chat.Styled import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -233,8 +233,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m CRGroupProfile u g -> ttyUser u $ viewGroupProfile g CRGroupDescription u g -> ttyUser u $ viewGroupDescription g - CRGroupLinkCreated u g ccLink mRole -> ttyUser u $ groupLink_ "Group link is created!" g ccLink mRole - CRGroupLink u g ccLink mRole -> ttyUser u $ groupLink_ "Group link:" g ccLink mRole + CRGroupLinkCreated u g gLink -> ttyUser u $ groupLink_ "Group link is created!" g gLink + CRGroupLink u g gLink -> ttyUser u $ groupLink_ "Group link:" g gLink CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g CRNewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m] @@ -1090,13 +1090,13 @@ autoAcceptStatus_ = \case | otherwise = "" _ -> ["auto_accept off"] -groupLink_ :: StyledString -> GroupInfo -> CreatedLinkContact -> GroupMemberRole -> [StyledString] -groupLink_ intro g (CCLink cReq shortLink) mRole = +groupLink_ :: StyledString -> GroupInfo -> GroupLink -> [StyledString] +groupLink_ intro g GroupLink {connLinkContact = CCLink cReq shortLink, acceptMemberRole} = [ intro, "", plain $ maybe cReqStr strEncode shortLink, "", - "Anybody can connect to you and join group as " <> showRole mRole <> " with: " <> highlight' "/c ", + "Anybody can connect to you and join group as " <> showRole acceptMemberRole <> " with: " <> highlight' "/c ", "to show it again: " <> highlight ("/show link #" <> viewGroupName g), "to delete it: " <> highlight ("/delete link #" <> viewGroupName g) <> " (joined members will remain connected to you)" ] diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 22559463ea..c9ae4e4bc8 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -2811,6 +2811,8 @@ testShortLinkAddressPrepareContact = ] alice <## "to accept: /ac bob" alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac i bob" + alice <## "bad chat command: incognito not allowed for address with short link data" alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request, you can send messages to contact" concurrently_ diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 92f4f9428c..646be1afbf 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -133,7 +133,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-15\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-16\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -249,13 +249,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-15\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-16\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-15\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-16\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -270,7 +270,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-15\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-16\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"