From 7b4710d198b05ffae929037cbfb1dce0df974421 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 12 Dec 2022 08:59:35 +0000 Subject: [PATCH] ios: verify connection security code (#1542) * ios: verify connection security code * verification in member sheet (still crashes) * use navigation view for members list * ios: show verified status in the lists * update verification status in the list of members * verified shield layout * update icon, make add member navigation to right * refactor chatPreviewTitle --- apps/ios/Shared/Model/SimpleXAPI.swift | 38 +++- .../Shared/Views/Chat/ChatInfoToolbar.swift | 13 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 69 +++++-- apps/ios/Shared/Views/Chat/ChatView.swift | 37 ++-- .../Chat/Group/AddGroupMembersView.swift | 30 +-- .../Views/Chat/Group/GroupChatInfoView.swift | 82 +++++---- .../Chat/Group/GroupMemberInfoView.swift | 172 ++++++++++++------ apps/ios/Shared/Views/Chat/ScanCodeView.swift | 60 ++++++ .../Shared/Views/Chat/VerifyCodeView.swift | 126 +++++++++++++ .../Views/ChatList/ChatPreviewView.swift | 35 ++-- .../Views/UserSettings/ScanSMPServer.swift | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 + apps/ios/SimpleXChat/APITypes.swift | 23 +++ apps/ios/SimpleXChat/ChatTypes.swift | 13 ++ 14 files changed, 543 insertions(+), 165 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/ScanCodeView.swift create mode 100644 apps/ios/Shared/Views/Chat/VerifyCodeView.swift diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 4c19ac25e..a38f1229f 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -356,14 +356,14 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings)) } -func apiContactInfo(contactId: Int64) async throws -> (ConnectionStats?, Profile?) { +func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) { let r = await chatSendCmd(.apiContactInfo(contactId: contactId)) if case let .contactInfo(_, connStats, customUserProfile) = r { return (connStats, customUserProfile) } throw r } -func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (ConnectionStats?) { - let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) +func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (ConnectionStats?) { + let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) if case let .groupMemberInfo(_, _, connStats_) = r { return (connStats_) } throw r } @@ -376,6 +376,32 @@ func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) async throws try await sendCommandOkResp(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId)) } +func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) { + let r = await chatSendCmd(.apiGetContactCode(contactId: contactId)) + if case let .contactCode(contact, connectionCode) = r { return (contact, connectionCode) } + throw r +} + +func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, String) { + let r = chatSendCmdSync(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId)) + if case let .groupMemberCode(_, member, connectionCode) = r { return (member, connectionCode) } + throw r +} + +func apiVerifyContact(_ contactId: Int64, connectionCode: String?) -> (Bool, String)? { + let r = chatSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode)) + if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) } + logger.error("apiVerifyContact error: \(String(describing: r))") + return nil +} + +func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCode: String?) -> (Bool, String)? { + let r = chatSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode)) + if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) } + logger.error("apiVerifyGroupMember error: \(String(describing: r))") + return nil +} + func apiAddContact() async -> String? { let r = await chatSendCmd(.addContact, bgTask: false) if case let .invitation(connReqInvitation) = r { return connReqInvitation } @@ -782,6 +808,12 @@ func apiListMembers(_ groupId: Int64) async -> [GroupMember] { return [] } +func apiListMembersSync(_ groupId: Int64) -> [GroupMember] { + let r = chatSendCmdSync(.apiListMembers(groupId: groupId)) + if case let .groupMembers(group) = r { return group.members } + return [] +} + func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] { let memberContactIds = ms.compactMap{ m in m.memberCurrent ? m.memberContactId : nil } return ChatModel.shared.chats diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index 7e527aef4..d0f4b6e55 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -32,15 +32,26 @@ struct ChatInfoToolbar: View { .frame(width: imageSize, height: imageSize) .padding(.trailing, 4) VStack { - Text(cInfo.displayName).font(.headline) + let t = Text(cInfo.displayName).font(.headline) + (cInfo.contact?.verified == true ? contactVerifiedShield + t : t) + .lineLimit(1) if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName { Text(cInfo.fullName).font(.subheadline) + .lineLimit(1) } } } .foregroundColor(.primary) .frame(width: 220) } + + private var contactVerifiedShield: Text { + (Text(Image(systemName: "checkmark.shield")) + Text(" ")) + .font(.caption) + .foregroundColor(.secondary) + .baselineOffset(1) + .kerning(-2) + } } struct ChatInfoToolbar_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index f6f4d00d3..f6c958760 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -55,8 +55,9 @@ struct ChatInfoView: View { @ObservedObject var chat: Chat @State var contact: Contact @Binding var connectionStats: ConnectionStats? - var customUserProfile: Profile? + @Binding var customUserProfile: Profile? @State var localAlias: String + @Binding var connectionCode: String? @FocusState private var aliasTextFieldFocused: Bool @State private var alert: ChatInfoViewAlert? = nil @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @@ -89,7 +90,9 @@ struct ChatInfoView: View { aliasTextFieldFocused = false } - localAliasTextEdit() + Group { + localAliasTextEdit() + } .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -100,6 +103,7 @@ struct ChatInfoView: View { } Section { + if let code = connectionCode { verifyCodeButton(code) } contactPreferencesButton() } @@ -143,17 +147,23 @@ struct ChatInfoView: View { } } - func contactInfoHeader() -> some View { + private func contactInfoHeader() -> some View { VStack { let cInfo = chat.chatInfo ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill)) .frame(width: 192, height: 192) .padding(.top, 12) .padding() - Text(contact.profile.displayName) - .font(.largeTitle) - .lineLimit(1) - .padding(.bottom, 2) + HStack { + if contact.verified { + Image(systemName: "checkmark.shield") + .foregroundColor(.secondary) + } + Text(contact.profile.displayName) + .font(.largeTitle) + .lineLimit(1) + .padding(.bottom, 2) + } if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName { Text(cInfo.fullName) .font(.title2) @@ -163,7 +173,7 @@ struct ChatInfoView: View { .frame(maxWidth: .infinity, alignment: .center) } - func localAliasTextEdit() -> some View { + private func localAliasTextEdit() -> some View { TextField("Set contact name…", text: $localAlias) .disableAutocorrection(true) .focused($aliasTextFieldFocused) @@ -194,7 +204,36 @@ struct ChatInfoView: View { } } - func contactPreferencesButton() -> some View { + private func verifyCodeButton(_ code: String) -> some View { + NavigationLink { + VerifyCodeView( + displayName: contact.displayName, + connectionCode: code, + connectionVerified: contact.verified, + verify: { code in + if let r = apiVerifyContact(chat.chatInfo.apiId, connectionCode: code) { + let (verified, existingCode) = r + contact.activeConn.connectionCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil + connectionCode = existingCode + DispatchQueue.main.async { + chat.chatInfo = .direct(contact: contact) + } + return r + } + return nil + } + ) + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Security code") + } label: { + Label( + contact.verified ? "View security code" : "Verify security code", + systemImage: contact.verified ? "checkmark.shield" : "shield" + ) + } + } + + private func contactPreferencesButton() -> some View { NavigationLink { ContactPreferencesView( contact: $contact, @@ -208,7 +247,7 @@ struct ChatInfoView: View { } } - func networkStatusRow() -> some View { + private func networkStatusRow() -> some View { HStack { Text("Network status") Image(systemName: "info.circle") @@ -221,14 +260,14 @@ struct ChatInfoView: View { } } - func serverImage() -> some View { + private func serverImage() -> some View { let status = chat.serverInfo.networkStatus return Image(systemName: status.imageName) .foregroundColor(status == .connected ? .green : .secondary) .font(.system(size: 12)) } - func deleteContactButton() -> some View { + private func deleteContactButton() -> some View { Button(role: .destructive) { alert = .deleteContactAlert } label: { @@ -237,7 +276,7 @@ struct ChatInfoView: View { } } - func clearChatButton() -> some View { + private func clearChatButton() -> some View { Button() { alert = .clearChatAlert } label: { @@ -323,7 +362,9 @@ struct ChatInfoView_Previews: PreviewProvider { chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), contact: Contact.sampleData, connectionStats: Binding.constant(nil), - localAlias: "" + customUserProfile: Binding.constant(nil), + localAlias: "", + connectionCode: Binding.constant(nil) ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index e38b7435f..36b2883c4 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -23,6 +23,7 @@ struct ChatView: View { @State private var showDeleteMessage = false @State private var connectionStats: ConnectionStats? @State private var customUserProfile: Profile? + @State private var connectionCode: String? @State private var tableView: UITableView? @State private var loadingItems = false @State private var firstPage = false @@ -33,8 +34,7 @@ struct ChatView: View { @FocusState private var searchFocussed // opening GroupMemberInfoView on member icon @State private var selectedMember: GroupMember? = nil - @State private var memberConnectionStats: ConnectionStats? - + var body: some View { let cInfo = chat.chatInfo return VStack(spacing: 0) { @@ -90,24 +90,30 @@ struct ChatView: View { Button { Task { do { - let (stats, profile) = try await apiContactInfo(contactId: chat.chatInfo.apiId) + let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId) + let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId) await MainActor.run { connectionStats = stats customUserProfile = profile + connectionCode = code + if contact.activeConn.connectionCode != ct.activeConn.connectionCode { + chat.chatInfo = .direct(contact: ct) + } } } catch let error { - logger.error("apiContactInfo error: \(responseError(error))") + logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))") } await MainActor.run { showChatInfoSheet = true } } } label: { ChatInfoToolbar(chat: chat) } - .appSheet(isPresented: $showChatInfoSheet, onDismiss: { + .sheet(isPresented: $showChatInfoSheet, onDismiss: { connectionStats = nil customUserProfile = nil + connectionCode = nil }) { - ChatInfoView(chat: chat, contact: contact, connectionStats: $connectionStats, customUserProfile: customUserProfile, localAlias: chat.chatInfo.localAlias) + ChatInfoView(chat: chat, contact: contact, connectionStats: $connectionStats, customUserProfile: $customUserProfile, localAlias: chat.chatInfo.localAlias, connectionCode: $connectionCode) } } else if case let .group(groupInfo) = cInfo { Button { @@ -381,22 +387,9 @@ struct ChatView: View { if showMember { ProfileImage(imageStr: member.memberProfile.image) .frame(width: memberImageSize, height: memberImageSize) - .onTapGesture { - Task { - do { - let stats = try await apiGroupMemberInfo(member.groupId, member.groupMemberId) - await MainActor.run { memberConnectionStats = stats } - } catch let error { - logger.error("apiGroupMemberInfo error: \(responseError(error))") - } - await MainActor.run { selectedMember = member } - } - } - .appSheet(item: $selectedMember, onDismiss: { - selectedMember = nil - memberConnectionStats = nil - }) { _ in - GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $memberConnectionStats) + .onTapGesture { selectedMember = member } + .appSheet(item: $selectedMember) { member in + GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true) } } else { Rectangle().fill(.clear) diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 2f5456731..e2ce4fc64 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -34,10 +34,24 @@ struct AddGroupMembersView: View { } var body: some View { - NavigationView { - let membersToAdd = filterMembersToAdd(chatModel.groupMembers) + if creatingGroup { + NavigationView { + addGroupMembersView() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button ("Skip") { addedMembersCb?(selectedContacts) } + } + } + } + } else { + addGroupMembersView() + } + } - let v = List { + private func addGroupMembersView() -> some View { + VStack { + let membersToAdd = filterMembersToAdd(chatModel.groupMembers) + List { ChatInfoToolbar(chat: chat, imageSize: 48) .frame(maxWidth: .infinity, alignment: .center) .listRowBackground(Color.clear) @@ -80,16 +94,6 @@ struct AddGroupMembersView: View { } } } - - if creatingGroup { - v.toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button ("Skip") { addedMembersCb?(selectedContacts) } - } - } - } else { - v.navigationBarHidden(true) - } } .frame(maxHeight: .infinity, alignment: .top) .alert(item: $alert) { alert in diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index d7adf0a38..3e560e6e2 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -14,12 +14,13 @@ struct GroupChatInfoView: View { @Environment(\.dismiss) var dismiss: DismissAction @ObservedObject var chat: Chat @State var groupInfo: GroupInfo + @State var selectedMember: Int64? = nil @ObservedObject private var alertManager = AlertManager.shared @State private var alert: GroupChatInfoViewAlert? = nil @State private var groupLink: String? @State private var showAddMembersSheet: Bool = false - @State private var selectedMember: GroupMember? = nil @State private var connectionStats: ConnectionStats? + @State private var connectionCode: String? @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false enum GroupChatInfoViewAlert: Identifiable { @@ -65,27 +66,22 @@ struct GroupChatInfoView: View { } memberView(groupInfo.membership, user: true) ForEach(members) { member in - Button { - Task { - do { - let stats = try await apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) - await MainActor.run { connectionStats = stats } - } catch let error { - logger.error("apiGroupMemberInfo error: \(responseError(error))") - } - await MainActor.run { selectedMember = member } - } - } label: { memberView(member) } + NavLinkPlain( + tag: member.groupMemberId, + selection: $selectedMember, + label: { memberView(member) } + ) } - } - .appSheet(isPresented: $showAddMembersSheet) { - AddGroupMembersView(chat: chat, groupInfo: groupInfo) - } - .appSheet(item: $selectedMember, onDismiss: { - selectedMember = nil - connectionStats = nil - }) { _ in - GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $connectionStats) + .background( + NavigationLink( + destination: memberInfoView(selectedMember), + isActive: Binding( + get: { selectedMember != nil }, + set: { _, _ in selectedMember = nil } + ) + ) { EmptyView() } + .opacity(0) + ) } Section { @@ -125,7 +121,7 @@ struct GroupChatInfoView: View { } } - func groupInfoHeader() -> some View { + private func groupInfoHeader() -> some View { VStack { let cInfo = chat.chatInfo ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill)) @@ -146,35 +142,32 @@ struct GroupChatInfoView: View { } private func addMembersButton() -> some View { - Button { - Task { - let groupMembers = await apiListMembers(groupInfo.groupId) - await MainActor.run { - ChatModel.shared.groupMembers = groupMembers - showAddMembersSheet = true + NavigationLink { + AddGroupMembersView(chat: chat, groupInfo: groupInfo) + .onAppear { + ChatModel.shared.groupMembers = apiListMembersSync(groupInfo.groupId) } - } } label: { Label("Invite members", systemImage: "plus") } } - func serverImage() -> some View { + private func serverImage() -> some View { let status = chat.serverInfo.networkStatus return Image(systemName: status.imageName) .foregroundColor(status == .connected ? .green : .secondary) } - func memberView(_ member: GroupMember, user: Bool = false) -> some View { + private func memberView(_ member: GroupMember, user: Bool = false) -> some View { HStack{ ProfileImage(imageStr: member.image) .frame(width: 38, height: 38) .padding(.trailing, 2) // TODO server connection status VStack(alignment: .leading) { - Text(member.chatViewName) + let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary) + (member.verified ? memberVerifiedShield + t : t) .lineLimit(1) - .foregroundColor(member.memberIncognito ? .indigo : .primary) let s = Text(member.memberStatus.shortText) (user ? Text ("you: ") + s : s) .lineLimit(1) @@ -190,6 +183,21 @@ struct GroupChatInfoView: View { } } + private var memberVerifiedShield: Text { + (Text(Image(systemName: "checkmark.shield")) + Text(" ")) + .font(.caption) + .baselineOffset(2) + .kerning(-2) + .foregroundColor(.secondary) + } + + @ViewBuilder private func memberInfoView(_ groupMemberId: Int64?) -> some View { + if let mId = groupMemberId, let member = chatModel.groupMembers.first(where: { $0.groupMemberId == mId }) { + GroupMemberInfoView(groupInfo: groupInfo, member: member) + .navigationBarHidden(false) + } + } + private func groupLinkButton() -> some View { NavigationLink { GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink) @@ -200,7 +208,7 @@ struct GroupChatInfoView: View { } } - func editGroupButton() -> some View { + private func editGroupButton() -> some View { NavigationLink { GroupProfileView( groupInfo: $groupInfo, @@ -213,7 +221,7 @@ struct GroupChatInfoView: View { } } - func deleteGroupButton() -> some View { + private func deleteGroupButton() -> some View { Button(role: .destructive) { alert = .deleteGroupAlert } label: { @@ -222,7 +230,7 @@ struct GroupChatInfoView: View { } } - func clearChatButton() -> some View { + private func clearChatButton() -> some View { Button() { alert = .clearChatAlert } label: { @@ -231,7 +239,7 @@ struct GroupChatInfoView: View { } } - func leaveGroupButton() -> some View { + private func leaveGroupButton() -> some View { Button(role: .destructive) { alert = .leaveGroupAlert } label: { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index ac28a5419..e43e39f2c 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -13,8 +13,10 @@ struct GroupMemberInfoView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction var groupInfo: GroupInfo - @Binding var member: GroupMember? - @Binding var connectionStats: ConnectionStats? + @State var member: GroupMember + var navigation: Bool = false + @State private var connectionStats: ConnectionStats? = nil + @State private var connectionCode: String? = nil @State private var newRole: GroupMemberRole = .member @State private var alert: GroupMemberInfoViewAlert? @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @@ -36,76 +38,93 @@ struct GroupMemberInfoView: View { } var body: some View { - NavigationView { - if let member = member { - List { - groupMemberInfoHeader(member) - .listRowBackground(Color.clear) + if navigation { + NavigationView { groupMemberInfoView() } + } else { + groupMemberInfoView() + } + } + private func groupMemberInfoView() -> some View { + VStack { + List { + groupMemberInfoHeader(member) + .listRowBackground(Color.clear) + + Section { if let contactId = member.memberContactId { if let chat = chatModel.getContactChat(contactId), chat.chatInfo.contact?.directContact ?? false { - Section { - knownDirectChatButton(chat) - } + knownDirectChatButton(chat) } else if groupInfo.fullGroupPreferences.directMessages.on { - Section { - newDirectChatButton(contactId) - } + newDirectChatButton(contactId) } } + if let code = connectionCode { verifyCodeButton(code) } + } - Section("Member") { - infoRow("Group", groupInfo.displayName) + Section("Member") { + infoRow("Group", groupInfo.displayName) - if let roles = member.canChangeRoleTo(groupInfo: groupInfo) { - Picker("Change role", selection: $newRole) { - ForEach(roles) { role in - Text(role.text) - } + if let roles = member.canChangeRoleTo(groupInfo: groupInfo) { + Picker("Change role", selection: $newRole) { + ForEach(roles) { role in + Text(role.text) } - } else { - infoRow("Role", member.memberRole.text) - } - - // TODO invited by - need to get contact by contact id - if let conn = member.activeConn { - let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel) - infoRow("Connection", connLevelDesc) } + .frame(height: 36) + } else { + infoRow("Role", member.memberRole.text) } + // TODO invited by - need to get contact by contact id + if let conn = member.activeConn { + let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel) + infoRow("Connection", connLevelDesc) + } + } + + if let connStats = connectionStats { Section("Servers") { // TODO network connection status Button("Change receiving address") { alert = .switchAddressAlert } - if let connStats = connectionStats { - smpServers("Receiving via", connStats.rcvServers) - smpServers("Sending via", connStats.sndServers) - } - } - - if member.canBeRemoved(groupInfo: groupInfo) { - Section { - removeMemberButton(member) - } - } - - if developerTools { - Section("For console") { - infoRow("Local name", member.localDisplayName) - infoRow("Database ID", "\(member.groupMemberId)") - } + smpServers("Receiving via", connStats.rcvServers) + smpServers("Sending via", connStats.sndServers) } } - .navigationBarHidden(true) - .onAppear { newRole = member.memberRole } - .onChange(of: newRole) { _ in - if newRole != member.memberRole { - alert = .changeMemberRoleAlert(mem: member, role: newRole) + + if member.canBeRemoved(groupInfo: groupInfo) { + Section { + removeMemberButton(member) } } + + if developerTools { + Section("For console") { + infoRow("Local name", member.localDisplayName) + infoRow("Database ID", "\(member.groupMemberId)") + } + } + } + .navigationBarHidden(true) + .onAppear { + newRole = member.memberRole + do { + let stats = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) + let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil) + member = mem + connectionStats = stats + connectionCode = code + } catch let error { + logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))") + } + } + .onChange(of: newRole) { _ in + if newRole != member.memberRole { + alert = .changeMemberRoleAlert(mem: member, role: newRole) + } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) @@ -155,10 +174,15 @@ struct GroupMemberInfoView: View { .frame(width: 192, height: 192) .padding(.top, 12) .padding() - Text(mem.displayName) - .font(.largeTitle) - .lineLimit(1) - .padding(.bottom, 2) + HStack { + if mem.verified { + Image(systemName: "checkmark.shield") + } + Text(mem.displayName) + .font(.largeTitle) + .lineLimit(1) + } + .padding(.bottom, 2) if mem.fullName != "" && mem.fullName != mem.displayName { Text(mem.fullName) .font(.title2) @@ -168,7 +192,38 @@ struct GroupMemberInfoView: View { .frame(maxWidth: .infinity, alignment: .center) } - func removeMemberButton(_ mem: GroupMember) -> some View { + private func verifyCodeButton(_ code: String) -> some View { + NavigationLink { + VerifyCodeView( + displayName: member.displayName, + connectionCode: code, + connectionVerified: member.verified, + verify: { code in + if let r = apiVerifyGroupMember(member.groupId, member.groupMemberId, connectionCode: code) { + let (verified, existingCode) = r + let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil + connectionCode = existingCode + member.activeConn?.connectionCode = connCode + if let i = chatModel.groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) { + chatModel.groupMembers[i].activeConn?.connectionCode = connCode + } + return r + } + return nil + } + ) + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Security code") + } label: { + Label( + member.verified ? "View security code" : "Verify security code", + systemImage: member.verified ? "checkmark.shield" : "shield" + ) + } + + } + + private func removeMemberButton(_ mem: GroupMember) -> some View { Button(role: .destructive) { alert = .removeMemberAlert(mem: mem) } label: { @@ -230,9 +285,7 @@ struct GroupMemberInfoView: View { private func switchMemberAddress() { Task { do { - if let member = member { - try await apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) - } + try await apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) } catch let error { logger.error("switchMemberAddress apiSwitchGroupMember error: \(responseError(error))") let a = getErrorAlert(error, "Error changing address") @@ -248,8 +301,7 @@ struct GroupMemberInfoView_Previews: PreviewProvider { static var previews: some View { GroupMemberInfoView( groupInfo: GroupInfo.sampleData, - member: Binding.constant(GroupMember.sampleData), - connectionStats: Binding.constant(nil) + member: GroupMember.sampleData ) } } diff --git a/apps/ios/Shared/Views/Chat/ScanCodeView.swift b/apps/ios/Shared/Views/Chat/ScanCodeView.swift new file mode 100644 index 000000000..d5b6edf90 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ScanCodeView.swift @@ -0,0 +1,60 @@ +// +// ScanCodeView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 10/12/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import CodeScanner + +struct ScanCodeView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @Binding var connectionVerified: Bool + var verify: (String?) async -> (Bool, String)? + @State private var showCodeError = false + + var body: some View { + VStack(alignment: .leading) { + CodeScannerView(codeTypes: [.qr], completion: processQRCode) + .aspectRatio(1, contentMode: .fit) + .border(.gray) + Text("Scan security code from your contact's app.") + .padding(.top) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .alert(isPresented: $showCodeError) { + Alert(title: Text("Incorrect security code!")) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + func processQRCode(_ resp: Result) { + switch resp { + case let .success(r): + Task { + if let (ok, _) = await verify(r.string) { + await MainActor.run { + connectionVerified = ok + if ok { + dismiss() + } else { + showCodeError = true + } + } + } + } + case let .failure(e): + logger.error("ScanCodeView.processQRCode QR code error: \(e.localizedDescription)") + dismiss() + } + } +} + +struct ScanCodeView_Previews: PreviewProvider { + static var previews: some View { + ScanCodeView(connectionVerified: Binding.constant(true), verify: {_ in nil}) + } +} diff --git a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift new file mode 100644 index 000000000..21269f843 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift @@ -0,0 +1,126 @@ +// +// VerifyCodeView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 10/12/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct VerifyCodeView: View { + @Environment(\.dismiss) var dismiss: DismissAction + var displayName: String + @State var connectionCode: String? + @State var connectionVerified: Bool + var verify: (String?) -> (Bool, String)? + @State private var showCodeError = false + + var body: some View { + if let code = connectionCode { + verifyCodeView(code) + } + } + + private func verifyCodeView(_ code: String) -> some View { + ScrollView { + let splitCode = splitToParts(code, length: 24) + VStack(alignment: .leading) { + Group { + HStack { + if connectionVerified { + Image(systemName: "checkmark.shield") + .foregroundColor(.secondary) + Text("\(displayName) is verified") + } else { + Text("\(displayName) is not verified") + } + } + .frame(height: 24) + + QRCode(uri: code) + .padding(.horizontal) + + Text(splitCode) + .multilineTextAlignment(.leading) + .font(.body.monospaced()) + .lineLimit(20) + .padding(.bottom, 8) + } + .frame(maxWidth: .infinity, alignment: .center) + + Text("To verify end-to-end encryption with your contact compare (or scan) the code on your devices.") + .padding(.bottom) + + Group { + if connectionVerified { + Button { + verifyCode(nil) + } label: { + Label("Clear verification", systemImage: "shield") + } + .padding() + } else { + HStack { + NavigationLink { + ScanCodeView(connectionVerified: $connectionVerified, verify: verify) + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Scan code") + } label: { + Label("Scan code", systemImage: "qrcode") + } + .padding() + Button { + verifyCode(code) { verified in + if !verified { showCodeError = true } + } + } label: { + Label("Mark verified", systemImage: "checkmark.shield") + } + .padding() + .alert(isPresented: $showCodeError) { + Alert(title: Text("Incorrect security code!")) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .center) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showShareSheet(items: [splitCode]) + } label: { + Image(systemName: "square.and.arrow.up") + } + } + } + .onChange(of: connectionVerified) { _ in + if connectionVerified { dismiss() } + } + } + } + + private func verifyCode(_ code: String?, _ cb: ((Bool) -> Void)? = nil) { + if let (verified, existingCode) = verify(code) { + connectionVerified = verified + connectionCode = existingCode + cb?(verified) + } + } + + private func splitToParts(_ s: String, length: Int) -> String { + if length >= s.count { return s } + return (0 ... (s.count - 1) / length) + .map { s.dropFirst($0 * length).prefix(length) } + .joined(separator: "\n") + } +} + +struct VerifyCodeView_Previews: PreviewProvider { + static var previews: some View { + VerifyCodeView(displayName: "alice", connectionCode: "12345 67890 12345 67890", connectionVerified: false, verify: {_ in nil}) + } +} diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 1be0d070a..00afb65aa 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -79,26 +79,33 @@ struct ChatPreviewView: View { } @ViewBuilder private func chatPreviewTitle() -> some View { - let v = Text(chat.chatInfo.chatViewName) - .font(.title3) - .fontWeight(.bold) - .lineLimit(1) - .frame(alignment: .topLeading) - switch (chat.chatInfo) { - case .direct: - v.foregroundColor(chat.chatInfo.ready ? .primary : .secondary) - case .group(groupInfo: let groupInfo): + let t = Text(chat.chatInfo.chatViewName).font(.title3).fontWeight(.bold) + switch chat.chatInfo { + case let .direct(contact): + previewTitle(contact.verified == true ? verifiedIcon + t : t) + .foregroundColor(chat.chatInfo.ready ? .primary : .secondary) + case let .group(groupInfo): + let v = previewTitle(t) switch (groupInfo.membership.memberStatus) { - case .memInvited: - chat.chatInfo.incognito ? v.foregroundColor(.indigo) : v.foregroundColor(.accentColor) - case .memAccepted: - v.foregroundColor(.secondary) + case .memInvited: v.foregroundColor(chat.chatInfo.incognito ? .indigo : .accentColor) + case .memAccepted: v.foregroundColor(.secondary) default: v } - default: v + default: previewTitle(t) } } + private func previewTitle(_ t: Text) -> some View { + t.lineLimit(1).frame(alignment: .topLeading) + } + + private var verifiedIcon: Text { + (Text(Image(systemName: "checkmark.shield")) + Text(" ")) + .foregroundColor(.secondary) + .baselineOffset(1) + .kerning(-2) + } + @ViewBuilder private func chatPreviewText(_ cItem: ChatItem?) -> some View { if let cItem = cItem { let itemText = !cItem.meta.itemDeleted ? cItem.text : NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text") diff --git a/apps/ios/Shared/Views/UserSettings/ScanSMPServer.swift b/apps/ios/Shared/Views/UserSettings/ScanSMPServer.swift index f1091a38e..37f41b2d5 100644 --- a/apps/ios/Shared/Views/UserSettings/ScanSMPServer.swift +++ b/apps/ios/Shared/Views/UserSettings/ScanSMPServer.swift @@ -47,7 +47,7 @@ struct ScanSMPServer: View { showAddressError = true } case let .failure(e): - logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)") + logger.error("ScanSMPServer.processQRCode QR code error: \(e.localizedDescription)") dismiss() } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 3a56b7ce8..c206f49d3 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -93,6 +93,8 @@ 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E327A8683A00ACCCDD /* UserAddress.swift */; }; 5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */; }; + 5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */; }; + 5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */; }; 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */; }; 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; @@ -306,6 +308,8 @@ 5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; 5CB924E327A8683A00ACCCDD /* UserAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddress.swift; sourceTree = ""; }; 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListNavLink.swift; sourceTree = ""; }; + 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyCodeView.swift; sourceTree = ""; }; + 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanCodeView.swift; sourceTree = ""; }; 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = ""; }; 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; @@ -453,6 +457,8 @@ 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */, 5CE4407127ADB1D0007B033A /* Emoji.swift */, 5CADE79B292131E900072E13 /* ContactPreferencesView.swift */, + 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */, + 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */, ); path = Chat; sourceTree = ""; @@ -936,6 +942,7 @@ 5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */, 5C029EAA283942EA004A9677 /* CallController.swift in Sources */, 5CCA7DF32905735700C8FEBA /* AcceptRequestsView.swift in Sources */, + 5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */, 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */, @@ -943,6 +950,7 @@ 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */, 5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */, 5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */, + 5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 9dc98d2c2..1b6fa411f 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -58,6 +58,10 @@ public enum ChatCommand { case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) case apiSwitchContact(contactId: Int64) case apiSwitchGroupMember(groupId: Int64, groupMemberId: Int64) + case apiGetContactCode(contactId: Int64) + case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64) + case apiVerifyContact(contactId: Int64, connectionCode: String?) + case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) case addContact case connect(connReq: String) case apiDeleteChat(type: ChatType, id: Int64) @@ -138,6 +142,12 @@ public enum ChatCommand { case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" case let .apiSwitchContact(contactId): return "/_switch @\(contactId)" case let .apiSwitchGroupMember(groupId, groupMemberId): return "/_switch #\(groupId) \(groupMemberId)" + case let .apiGetContactCode(contactId): return "/_get code @\(contactId)" + case let .apiGetGroupMemberCode(groupId, groupMemberId): return "/_get code #\(groupId) \(groupMemberId)" + case let .apiVerifyContact(contactId, .some(connectionCode)): return "/_verify code @\(contactId) \(connectionCode)" + case let .apiVerifyContact(contactId, .none): return "/_verify code @\(contactId)" + case let .apiVerifyGroupMember(groupId, groupMemberId, .some(connectionCode)): return "/_verify code #\(groupId) \(groupMemberId) \(connectionCode)" + case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)" case .addContact: return "/connect" case let .connect(connReq): return "/connect \(connReq)" case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" @@ -217,6 +227,10 @@ public enum ChatCommand { case .apiGroupMemberInfo: return "apiGroupMemberInfo" case .apiSwitchContact: return "apiSwitchContact" case .apiSwitchGroupMember: return "apiSwitchGroupMember" + case .apiGetContactCode: return "apiGetContactCode" + case .apiGetGroupMemberCode: return "apiGetGroupMemberCode" + case .apiVerifyContact: return "apiVerifyContact" + case .apiVerifyGroupMember: return "apiVerifyGroupMember" case .addContact: return "addContact" case .connect: return "connect" case .apiDeleteChat: return "apiDeleteChat" @@ -300,6 +314,9 @@ public enum ChatResponse: Decodable, Error { case networkConfig(networkConfig: NetCfg) case contactInfo(contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?) case groupMemberInfo(groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) + case contactCode(contact: Contact, connectionCode: String) + case groupMemberCode(groupInfo: GroupInfo, member: GroupMember, connectionCode: String) + case connectionVerified(verified: Bool, expectedCode: String) case invitation(connReqInvitation: String) case sentConfirmation case sentInvitation @@ -403,6 +420,9 @@ public enum ChatResponse: Decodable, Error { case .networkConfig: return "networkConfig" case .contactInfo: return "contactInfo" case .groupMemberInfo: return "groupMemberInfo" + case .contactCode: return "contactCode" + case .groupMemberCode: return "groupMemberCode" + case .connectionVerified: return "connectionVerified" case .invitation: return "invitation" case .sentConfirmation: return "sentConfirmation" case .sentInvitation: return "sentInvitation" @@ -506,6 +526,9 @@ public enum ChatResponse: Decodable, Error { case let .networkConfig(networkConfig): return String(describing: networkConfig) case let .contactInfo(contact, connectionStats, customUserProfile): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))" case let .groupMemberInfo(groupInfo, member, connectionStats_): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_)))" + case let .contactCode(contact, connectionCode): return "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)" + case let .groupMemberCode(groupInfo, member, connectionCode): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)" + case let .connectionVerified(verified, expectedCode): return "verified: \(verified)\nconnectionCode: \(expectedCode)" case let .invitation(connReqInvitation): return connReqInvitation case .sentConfirmation: return noDetails case .sentInvitation: return noDetails diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 4db722310..f0735bdb4 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -817,6 +817,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var fullName: String { get { profile.fullName } } public var image: String? { get { profile.image } } public var localAlias: String { profile.localAlias } + public var verified: Bool { activeConn.connectionCode != nil } public var directContact: Bool { (activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed @@ -858,6 +859,7 @@ public struct Connection: Decodable { public var connLevel: Int public var viaGroupLink: Bool public var customUserProfileId: Int64? + public var connectionCode: SecurityCode? public var id: ChatId { get { ":\(connId)" } } @@ -869,6 +871,16 @@ public struct Connection: Decodable { ) } +public struct SecurityCode: Decodable, Equatable { + public init(securityCode: String, verifiedAt: Date) { + self.securityCode = securityCode + self.verifiedAt = verifiedAt + } + + public var securityCode: String + public var verifiedAt: Date +} + public struct UserContact: Decodable { public var userContactLinkId: Int64 @@ -1124,6 +1136,7 @@ public struct GroupMember: Identifiable, Decodable { } public var fullName: String { get { memberProfile.fullName } } public var image: String? { get { memberProfile.image } } + public var verified: Bool { activeConn?.connectionCode != nil } var directChatId: ChatId? { get {