From 3a077d927d1a108de2a351419529793e4e64ecc4 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Thu, 25 Aug 2022 17:36:26 +0400 Subject: [PATCH] ios: contact aliases (#970) * ios: contact aliases * wip * wip * wip * move onTapGesture * revert test * improve search * corrections * font size * remove parameter * clear button * button style * remove clear button * ternary * refactor search * rename * ios: contact aliases translations (#972) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 10 +++- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 48 +++++++++++++++-- apps/ios/Shared/Views/Chat/ChatView.swift | 4 +- .../Views/Chat/Group/GroupChatInfoView.swift | 6 +-- .../Chat/Group/GroupMemberInfoView.swift | 4 +- .../Shared/Views/ChatList/ChatListView.swift | 13 +++-- .../Views/UserSettings/UserProfile.swift | 2 +- .../en.xcloc/Localized Contents/en.xliff | 5 ++ .../ru.xcloc/Localized Contents/ru.xliff | 7 ++- apps/ios/SimpleXChat/APITypes.swift | 10 +++- apps/ios/SimpleXChat/ChatTypes.swift | 53 +++++++++++++++---- apps/ios/ru.lproj/Localizable.strings | 5 +- 12 files changed, 137 insertions(+), 30 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 6651b577b2..ea5df407da 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -324,9 +324,9 @@ func apiContactInfo(contactId: Int64) async throws -> (ConnectionStats?, Profile throw r } -func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (ConnectionStats?, Profile?) { +func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (ConnectionStats?, LocalProfile?) { let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) - if case let .groupMemberInfo(_, _, connStats_, mainProfile) = r { return (connStats_, mainProfile) } + if case let .groupMemberInfo(_, _, connStats_, localMainProfile) = r { return (connStats_, localMainProfile) } throw r } @@ -435,6 +435,12 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? { } } +func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? { + let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias)) + if case let .contactAliasUpdated(toContact) = r { return toContact } + throw r +} + func apiCreateUserAddress() async throws -> String { let r = await chatSendCmd(.createMyAddress) if case let .userContactLinkCreated(connReq) = r { return connReq } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 0450f79816..62449f90a0 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -46,8 +46,11 @@ struct ChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction @ObservedObject var chat: Chat + var contact: Contact var connectionStats: ConnectionStats? var customUserProfile: Profile? + @State var localAlias: String + @FocusState private var aliasTextFieldFocused: Bool @State private var alert: ChatInfoViewAlert? = nil @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @@ -64,6 +67,14 @@ struct ChatInfoView: View { List { contactInfoHeader() .listRowBackground(Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + aliasTextFieldFocused = false + } + + localAliasTextEdit() + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) if let customUserProfile = customUserProfile { Section("Incognito") { @@ -113,11 +124,11 @@ struct ChatInfoView: View { .frame(width: 192, height: 192) .padding(.top, 12) .padding() - Text(cInfo.displayName) + Text(contact.profile.displayName) .font(.largeTitle) .lineLimit(1) .padding(.bottom, 2) - if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName { + if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName { Text(cInfo.fullName) .font(.title2) .lineLimit(2) @@ -126,6 +137,37 @@ struct ChatInfoView: View { .frame(maxWidth: .infinity, alignment: .center) } + func localAliasTextEdit() -> some View { + TextField("Set contact name…", text: $localAlias) + .disableAutocorrection(true) + .focused($aliasTextFieldFocused) + .submitLabel(.done) + .onChange(of: aliasTextFieldFocused) { focused in + if !focused { + setContactAlias() + } + } + .onSubmit { + setContactAlias() + } + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + } + + private func setContactAlias() { + Task { + do { + if let contact = try await apiSetContactAlias(contactId: chat.chatInfo.apiId, localAlias: localAlias) { + await MainActor.run { + chatModel.updateContact(contact) + } + } + } catch { + logger.error("setContactAlias error: \(responseError(error))") + } + } + } + func networkStatusRow() -> some View { HStack { Text("Network status") @@ -209,6 +251,6 @@ struct ChatInfoView: View { struct ChatInfoView_Previews: PreviewProvider { static var previews: some View { - ChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) + ChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), contact: Contact.sampleData, localAlias: "") } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 049637f2d1..b5ea8afcc4 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -101,8 +101,8 @@ struct ChatView: View { } .sheet(isPresented: $showChatInfoSheet) { switch cInfo { - case .direct: - ChatInfoView(chat: chat, connectionStats: connectionStats, customUserProfile: customUserProfile) + case let .direct(contact): + ChatInfoView(chat: chat, contact: contact, connectionStats: connectionStats, customUserProfile: customUserProfile, localAlias: chat.chatInfo.localAlias) case let .group(groupInfo): GroupChatInfoView(chat: chat, groupInfo: groupInfo) default: diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 68f402c0eb..90e721d431 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -20,7 +20,7 @@ struct GroupChatInfoView: View { @State private var selectedMember: GroupMember? = nil @State private var showGroupProfile: Bool = false @State private var connectionStats: ConnectionStats? - @State private var mainProfile: Profile? + @State private var memberMainProfile: LocalProfile? @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false enum GroupChatInfoViewAlert: Identifiable { @@ -53,7 +53,7 @@ struct GroupChatInfoView: View { let (stats, profile) = try await apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) await MainActor.run { connectionStats = stats - mainProfile = profile + memberMainProfile = profile } } catch let error { logger.error("apiGroupMemberInfo error: \(responseError(error))") @@ -67,7 +67,7 @@ struct GroupChatInfoView: View { AddGroupMembersView(chat: chat, groupInfo: groupInfo) } .sheet(item: $selectedMember, onDismiss: { connectionStats = nil }) { member in - GroupMemberInfoView(groupInfo: groupInfo, member: member, connectionStats: connectionStats, mainProfile: mainProfile) + GroupMemberInfoView(groupInfo: groupInfo, member: member, connectionStats: connectionStats, mainProfile: memberMainProfile) } .sheet(isPresented: $showGroupProfile) { GroupProfileView(groupId: groupInfo.apiId, groupProfile: groupInfo.groupProfile) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 3c0b257b21..96127affc6 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -15,7 +15,7 @@ struct GroupMemberInfoView: View { var groupInfo: GroupInfo var member: GroupMember var connectionStats: ConnectionStats? - var mainProfile: Profile? + var mainProfile: LocalProfile? @State private var alert: GroupMemberInfoViewAlert? @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @@ -76,7 +76,7 @@ struct GroupMemberInfoView: View { } } - private func mainProfileRow(_ mainProfile: Profile) -> some View { + private func mainProfileRow(_ mainProfile: LocalProfile) -> some View { HStack { Text("Known main profile") Spacer() diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 71bdd682c4..4a4dd4d658 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -77,9 +77,16 @@ struct ChatListView: View { let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase return s == "" ? chatModel.chats - : chatModel.chats.filter { - $0.chatInfo.chatType != .contactConnection && - $0.chatInfo.chatViewName.localizedLowercase.contains(s) + : chatModel.chats.filter { chat in + let contains = chat.chatInfo.chatViewName.localizedLowercase.contains(s) + switch chat.chatInfo { + case let .direct(contact): + return contains + || contact.profile.displayName.localizedLowercase.contains(s) + || contact.fullName.localizedLowercase.contains(s) + case .contactConnection: return false + default: return contains + } } } } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index a008b3d912..05951e5d31 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -142,7 +142,7 @@ struct UserProfile: View { if let newProfile = try await apiUpdateProfile(profile: profile) { DispatchQueue.main.async { if let profileId = chatModel.currentUser?.profile.profileId { - chatModel.currentUser?.profile = toLocalProfile(profileId, newProfile) + chatModel.currentUser?.profile = toLocalProfile(profileId, newProfile, "") } profile = newProfile } diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 32ee0be336..54abdd9d69 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -1648,6 +1648,11 @@ We will be adding server redundancy to prevent lost messages. Servers No comment provided by engineer. + + Set contact name… + Set contact name… + No comment provided by engineer. + Set timeouts for proxy/VPN Set timeouts for proxy/VPN diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 50ab0d6b07..7902c5b90c 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -194,7 +194,7 @@ Accent color - Цвет акцента + Основной цвет No comment provided by engineer. @@ -1648,6 +1648,11 @@ We will be adding server redundancy to prevent lost messages. Серверы No comment provided by engineer. + + Set contact name… + Имя контакта… + No comment provided by engineer. + Set timeouts for proxy/VPN Установить таймауты для прокси/VPN diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 5fb8cb19ed..060c55ccd0 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -55,6 +55,7 @@ public enum ChatCommand { case apiClearChat(type: ChatType, id: Int64) case listContacts case apiUpdateProfile(profile: Profile) + case apiSetContactAlias(contactId: Int64, localAlias: String) case createMyAddress case deleteMyAddress case showMyAddress @@ -120,6 +121,7 @@ public enum ChatCommand { case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" case .listContacts: return "/contacts" case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" + case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))" case .createMyAddress: return "/address" case .deleteMyAddress: return "/delete_address" case .showMyAddress: return "/show_address" @@ -184,6 +186,7 @@ public enum ChatCommand { case .apiClearChat: return "apiClearChat" case .listContacts: return "listContacts" case .apiUpdateProfile: return "apiUpdateProfile" + case .apiSetContactAlias: return "apiSetContactAlias" case .createMyAddress: return "createMyAddress" case .deleteMyAddress: return "deleteMyAddress" case .showMyAddress: return "showMyAddress" @@ -229,7 +232,7 @@ public enum ChatResponse: Decodable, Error { case userSMPServers(smpServers: [String]) case networkConfig(networkConfig: NetCfg) case contactInfo(contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?) - case groupMemberInfo(groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?, mainProfile: Profile?) + case groupMemberInfo(groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?, localMainProfile: LocalProfile?) case invitation(connReqInvitation: String) case sentConfirmation case sentInvitation @@ -238,6 +241,7 @@ public enum ChatResponse: Decodable, Error { case chatCleared(chatInfo: ChatInfo) case userProfileNoChange case userProfileUpdated(fromProfile: Profile, toProfile: Profile) + case contactAliasUpdated(toContact: Contact) case userContactLink(connReqContact: String) case userContactLinkCreated(connReqContact: String) case userContactLinkDeleted @@ -329,6 +333,7 @@ public enum ChatResponse: Decodable, Error { case .chatCleared: return "chatCleared" case .userProfileNoChange: return "userProfileNoChange" case .userProfileUpdated: return "userProfileUpdated" + case .contactAliasUpdated: return "contactAliasUpdated" case .userContactLink: return "userContactLink" case .userContactLinkCreated: return "userContactLinkCreated" case .userContactLinkDeleted: return "userContactLinkDeleted" @@ -411,7 +416,7 @@ public enum ChatResponse: Decodable, Error { case let .userSMPServers(smpServers): return String(describing: smpServers) 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_, mainProfile): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_))\nmainProfile: \(String(describing: mainProfile))" + case let .groupMemberInfo(groupInfo, member, connectionStats_, localMainProfile): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_))\nlocalMainProfile: \(String(describing: localMainProfile))" case let .invitation(connReqInvitation): return connReqInvitation case .sentConfirmation: return noDetails case .sentInvitation: return noDetails @@ -420,6 +425,7 @@ public enum ChatResponse: Decodable, Error { case let .chatCleared(chatInfo): return String(describing: chatInfo) case .userProfileNoChange: return noDetails case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) + case let .contactAliasUpdated(toContact): return String(describing: toContact) case let .userContactLink(connReq): return connReq case let .userContactLinkCreated(connReq): return connReq case .userContactLinkDeleted: return noDetails diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index b5fea9e1bb..a15ac63df1 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -19,6 +19,7 @@ public struct User: Decodable, NamedChat { public var displayName: String { get { profile.displayName } } public var fullName: String { get { profile.fullName } } public var image: String? { get { profile.image } } + public var localAlias: String { get { "" } } public static let sampleData = User( userId: 1, @@ -43,6 +44,7 @@ public struct Profile: Codable, NamedChat { public var displayName: String public var fullName: String public var image: String? + public var localAlias: String { get { "" } } var profileViewName: String { (fullName == "" || displayName == fullName) ? displayName : "\(displayName) (\(fullName))" @@ -55,31 +57,36 @@ public struct Profile: Codable, NamedChat { } public struct LocalProfile: Codable, NamedChat { - public init(profileId: Int64, displayName: String, fullName: String, image: String? = nil) { + public init(profileId: Int64, displayName: String, fullName: String, image: String? = nil, localAlias: String) { self.profileId = profileId self.displayName = displayName self.fullName = fullName self.image = image + self.localAlias = localAlias } public var profileId: Int64 public var displayName: String public var fullName: String public var image: String? + public var localAlias: String var profileViewName: String { - (fullName == "" || displayName == fullName) ? displayName : "\(displayName) (\(fullName))" + localAlias == "" + ? (fullName == "" || displayName == fullName) ? displayName : "\(displayName) (\(fullName))" + : localAlias } static let sampleData = LocalProfile( profileId: 1, displayName: "alice", - fullName: "Alice" + fullName: "Alice", + localAlias: "" ) } -public func toLocalProfile (_ profileId: Int64, _ profile: Profile ) -> LocalProfile { - LocalProfile(profileId: profileId, displayName: profile.displayName, fullName: profile.fullName, image: profile.image) +public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias: String) -> LocalProfile { + LocalProfile(profileId: profileId, displayName: profile.displayName, fullName: profile.fullName, image: profile.image, localAlias: localAlias) } public func fromLocalProfile (_ profile: LocalProfile) -> Profile { @@ -97,11 +104,14 @@ public protocol NamedChat { var displayName: String { get } var fullName: String { get } var image: String? { get } + var localAlias: String { get } } extension NamedChat { public var chatViewName: String { - get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") } + localAlias == "" + ? displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") + : localAlias } } @@ -157,6 +167,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { } } + public var localAlias: String { + get { + switch self { + case let .direct(contact): return contact.localAlias + case let .group(groupInfo): return groupInfo.localAlias + case let .contactRequest(contactRequest): return contactRequest.localAlias + case let .contactConnection(contactConnection): return contactConnection.localAlias + } + } + } + public var id: ChatId { get { switch self { @@ -303,9 +324,10 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var apiId: Int64 { get { contactId } } public var ready: Bool { get { activeConn.connStatus == .ready } } public var sendMsgEnabled: Bool { get { true } } - public var displayName: String { get { profile.displayName } } + public var displayName: String { localAlias == "" ? profile.displayName : localAlias } public var fullName: String { get { profile.fullName } } public var image: String? { get { profile.image } } + public var localAlias: String { profile.localAlias } public var isIndirectContact: Bool { activeConn.connLevel > 0 || viaGroup != nil @@ -384,6 +406,7 @@ public struct UserContactRequest: Decodable, NamedChat { public var displayName: String { get { profile.displayName } } public var fullName: String { get { profile.fullName } } public var image: String? { get { profile.image } } + public var localAlias: String { "" } public static let sampleData = UserContactRequest( contactRequestId: 1, @@ -425,6 +448,7 @@ public struct PendingContactConnection: Decodable, NamedChat { } public var fullName: String { get { "" } } public var image: String? { get { nil } } + public var localAlias: String { "" } public var initiated: Bool { get { (pccConnStatus.initiated ?? false) && !viaContactUri } } public var incognito: Bool { @@ -524,6 +548,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat { public var displayName: String { get { groupProfile.displayName } } public var fullName: String { get { groupProfile.fullName } } public var image: String? { get { groupProfile.image } } + public var localAlias: String { "" } public var canEdit: Bool { return membership.memberRole == .owner && membership.memberCurrent @@ -559,6 +584,7 @@ public struct GroupProfile: Codable, NamedChat { public var displayName: String public var fullName: String public var image: String? + public var localAlias: String { "" } public static let sampleData = GroupProfile( displayName: "team", @@ -581,7 +607,12 @@ public struct GroupMember: Identifiable, Decodable { public var activeConn: Connection? public var id: String { "#\(groupId) @\(groupMemberId)" } - public var displayName: String { get { memberProfile.displayName } } + public var displayName: String { + get { + let p = memberProfile + return p.localAlias == "" ? p.displayName : p.localAlias + } + } public var fullName: String { get { memberProfile.fullName } } public var image: String? { get { memberProfile.image } } @@ -598,7 +629,9 @@ public struct GroupMember: Identifiable, Decodable { public var chatViewName: String { get { let p = memberProfile - return p.displayName + (p.fullName == "" || p.fullName == p.displayName ? "" : " / \(p.fullName)") + return p.localAlias == "" + ? p.displayName + (p.fullName == "" || p.fullName == p.displayName ? "" : " / \(p.fullName)") + : p.localAlias } } @@ -860,7 +893,7 @@ public struct ChatItem: Identifiable, Decodable { public var memberDisplayName: String? { get { if case let .groupRcv(groupMember) = chatDir { - return groupMember.memberProfile.displayName + return groupMember.displayName } else { return nil } diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 2e84149b57..23da2d5727 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -125,7 +125,7 @@ "above, then choose:" = "наверху, затем выберите:"; /* No comment provided by engineer. */ -"Accent color" = "Цвет акцента"; +"Accent color" = "Основной цвет"; /* accept contact request via notification accept incoming call via notification */ @@ -1175,6 +1175,9 @@ /* No comment provided by engineer. */ "Servers" = "Серверы"; +/* No comment provided by engineer. */ +"Set contact name…" = "Имя контакта…"; + /* No comment provided by engineer. */ "Set timeouts for proxy/VPN" = "Установить таймауты для прокси/VPN";