diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 0752279494..cc71c537ae 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -16,7 +16,7 @@ final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get() @Published var currentUser: User? - @Published private(set) var users: [UserInfo] = [] + @Published var users: [UserInfo] = [] @Published var chatInitialized = false @Published var chatRunning: Bool? @Published var chatDbChanged = false @@ -492,31 +492,6 @@ final class ChatModel: ObservableObject { return reversedChatItems[min(i - 1, maxIx)] } - func updateUsers(_ new: [UserInfo]) { - users = new - .sorted { $0.user.chatViewName.compare($1.user.chatViewName) == .orderedAscending } - .sorted { first, _ in first.user.activeUser } - } - - func changeActiveUser(_ toUserId: Int64) { - do { - let activeUser = try apiSetActiveUser(toUserId) - var users = users - let oldActiveIndex = users.firstIndex(where: { $0.user.userId == currentUser?.userId })! - var oldActive = users[oldActiveIndex] - oldActive.user.activeUser = false - users[oldActiveIndex] = oldActive - - currentUser = activeUser - let currentActiveIndex = users.firstIndex(where: { $0.user.userId == activeUser.userId })! - users[currentActiveIndex] = UserInfo(user: activeUser, unreadCount: users[currentActiveIndex].unreadCount) - updateUsers(users) - try getUserChatData(self) - } catch { - logger.error("Unable to set active user: \(error.localizedDescription)") - } - } - func updateContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) { networkStatuses[contact.activeConn.connId] = status } diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index b817b44857..90e092701d 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -39,7 +39,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)") if let userId = content.userInfo["userId"] as? Int64, userId != chatModel.currentUser?.userId { - chatModel.changeActiveUser(userId) + changeActiveUser(userId) } if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact, let chatId = content.userInfo["chatId"] as? String { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d0169fbff2..b76a3a3b2b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -131,10 +131,12 @@ func apiCreateActiveUser(_ p: Profile) throws -> User { throw r } -func listUsers() -> [UserInfo] { +func listUsers() throws -> [UserInfo] { let r = chatSendCmdSync(.listUsers) - if case let .usersList(users) = r { return users } - return [] + if case let .usersList(users) = r { + return users.sorted { $0.user.chatViewName.compare($1.user.chatViewName) == .orderedAscending } + } + throw r } func apiSetActiveUser(_ userId: Int64) throws -> User { @@ -917,9 +919,9 @@ func startChat() throws { let m = ChatModel.shared try setNetworkConfig(getNetCfg()) let justStarted = try apiStartChat() - m.updateUsers(listUsers()) + m.users = try listUsers() if justStarted { - try getUserChatData(m) + try getUserChatData() NtfManager.shared.setNtfBadgeCount(m.totalUnreadCount()) try refreshCallInvitations() (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() @@ -937,7 +939,19 @@ func startChat() throws { chatLastStartGroupDefault.set(Date.now) } -func getUserChatData(_ m: ChatModel) throws { +func changeActiveUser(_ toUserId: Int64) { + let m = ChatModel.shared + do { + m.currentUser = try apiSetActiveUser(toUserId) + m.users = try listUsers() + try getUserChatData() + } catch let error { + logger.error("Unable to set active user: \(responseError(error))") + } +} + +func getUserChatData() throws { + let m = ChatModel.shared m.userAddress = try apiGetUserAddress() (m.userSMPServers, m.presetSMPServers) = try getUserSMPServers() m.chatItemTTL = try getChatItemTTL() diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 6df579d964..bd50a5d707 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -15,7 +15,6 @@ struct ChatListView: View { @State private var searchText = "" @State private var showAddChat = false @State var userPickerVisible = false - @State var selectedView: Int? = nil var body: some View { ZStack(alignment: .topLeading) { @@ -29,9 +28,6 @@ struct ChatListView: View { } else { chatList } - NavigationLink(destination: UserProfilesView().navigationTitle("Profiles"), tag: 0, selection: $selectedView) { - EmptyView() - } } } .navigationViewStyle(.stack) @@ -42,14 +38,7 @@ struct ChatListView: View { } } } - UserPicker( - showSettings: $showSettings, - userPickerVisible: $userPickerVisible, - manageUsers: { - selectedView = 0 - userPickerVisible = false - } - ) + UserPicker(showSettings: $showSettings, userPickerVisible: $userPickerVisible) } } diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index 4f817c8753..7078cdbfd1 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -10,11 +10,10 @@ private let fillColorDark = Color(uiColor: UIColor(red: 0.11, green: 0.11, blue: private let fillColorLight = Color(uiColor: UIColor(red: 0.99, green: 0.99, blue: 0.99, alpha: 255)) struct UserPicker: View { - @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme @Binding var showSettings: Bool @Binding var userPickerVisible: Bool - var manageUsers: () -> Void = {} @State var scrollViewContentSize: CGSize = .zero @State var disableScrolling: Bool = true private let menuButtonHeight: CGFloat = 68 @@ -31,35 +30,9 @@ struct UserPicker: View { ScrollView { ScrollViewReader { sp in VStack(spacing: 0) { - ForEach(Array(chatModel.users.enumerated()), id: \.0) { i, userInfo in - Button(action: { - if !userInfo.user.activeUser { - chatModel.changeActiveUser(userInfo.user.userId) - userPickerVisible = false - } - }, label: { - HStack(spacing: 0) { - ProfileImage(imageStr: userInfo.user.image) - .frame(width: 44, height: 44) - .padding(.trailing, 12) - Text(userInfo.user.chatViewName) - .fontWeight(i == 0 ? .medium : .regular) - .foregroundColor(.primary) - .overlay(DetermineWidth()) - Spacer() - if i == 0 { - Image(systemName: "checkmark") - } else if userInfo.unreadCount > 0 { - unreadCounter(userInfo.unreadCount) - } - } - .padding(.trailing) - .padding([.leading, .vertical], 12) - }) - .buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill))) - if i < chatModel.users.count - 1 { - Divider() - } + ForEach(Array(m.users.sorted(by: { u, _ in u.user.activeUser }))) { u in + userView(u) + Divider() } } .overlay { @@ -69,7 +42,7 @@ struct UserPicker: View { let scenes = UIApplication.shared.connectedScenes if let windowScene = scenes.first as? UIWindowScene { let layoutFrame = windowScene.windows[0].safeAreaLayoutGuide.layoutFrame - disableScrolling = scrollViewContentSize.height + menuButtonHeight * 2 + 10 < layoutFrame.height + disableScrolling = scrollViewContentSize.height + menuButtonHeight + 10 < layoutFrame.height } } return Color.clear @@ -85,11 +58,6 @@ struct UserPicker: View { .simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000)) .frame(maxHeight: scrollViewContentSize.height) - Divider() - menuButton("Your user profiles", icon: "pencil") { - manageUsers() - } - Divider() menuButton("Settings", icon: "gearshape") { showSettings = true withAnimation { @@ -108,27 +76,43 @@ struct UserPicker: View { .onPreferenceChange(DetermineWidth.Key.self) { chatViewNameWidth = $0 } .frame(maxWidth: chatViewNameWidth > 0 ? min(300, chatViewNameWidth + 130) : 300) .padding(8) - .onChange(of: [chatModel.currentUser?.chatViewName, chatModel.currentUser?.image] ) { _ in - reloadCurrentUser() - } .opacity(userPickerVisible ? 1.0 : 0.0) .onAppear { - reloadUsers() + do { + m.users = try listUsers() + } catch let error { + logger.error("Error updating users \(responseError(error))") + } } } - private func reloadCurrentUser() { - if let updatedUser = chatModel.currentUser, let index = chatModel.users.firstIndex(where: { $0.user.userId == updatedUser.userId }) { - var users = chatModel.users - users[index] = UserInfo(user: updatedUser, unreadCount: users[index].unreadCount) - chatModel.updateUsers(users) - } - } - - private func reloadUsers() { - Task { - chatModel.updateUsers(listUsers()) - } + private func userView(_ u: UserInfo) -> some View { + let user = u.user + return Button(action: { + if !user.activeUser { + changeActiveUser(user.userId) + userPickerVisible = false + } + }, label: { + HStack(spacing: 0) { + ProfileImage(imageStr: user.image) + .frame(width: 44, height: 44) + .padding(.trailing, 12) + Text(user.chatViewName) + .fontWeight(user.activeUser ? .medium : .regular) + .foregroundColor(.primary) + .overlay(DetermineWidth()) + Spacer() + if user.activeUser { + Image(systemName: "checkmark") + } else if u.unreadCount > 0 { + unreadCounter(u.unreadCount) + } + } + .padding(.trailing) + .padding([.leading, .vertical], 12) + }) + .buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill))) } private func menuButton(_ title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View { @@ -161,7 +145,7 @@ func unreadCounter(_ unread: Int64) -> some View { struct UserPicker_Previews: PreviewProvider { static var previews: some View { let m = ChatModel() - m.updateUsers([UserInfo.sampleData, UserInfo.sampleData]) + m.users = [UserInfo.sampleData, UserInfo.sampleData] return UserPicker( showSettings: Binding.constant(false), userPickerVisible: Binding.constant(true) diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 701374f662..11b5ef25db 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -67,6 +67,23 @@ struct DatabaseView: View { private func chatDatabaseView() -> some View { List { let stopped = m.chatRunning == false + Section { + Picker("Delete messages after", selection: $chatItemTTL) { + ForEach(ChatItemTTL.values) { ttl in + Text(ttl.deleteAfterText).tag(ttl) + } + if case .seconds = chatItemTTL { + Text(chatItemTTL.deleteAfterText).tag(chatItemTTL) + } + } + .frame(height: 36) + .disabled(m.chatDbChanged || progressIndicator) + } header: { + Text("Messages") + } footer: { + Text("This setting applies to messages in your current chat profile **\(m.currentUser?.displayName ?? "")**.") + } + Section { settingsRow( stopped ? "exclamationmark.octagon.fill" : "play.fill", @@ -157,22 +174,12 @@ struct DatabaseView: View { } Section { - Picker("Delete messages after", selection: $chatItemTTL) { - ForEach(ChatItemTTL.values) { ttl in - Text(ttl.deleteAfterText).tag(ttl) - } - if case .seconds = chatItemTTL { - Text(chatItemTTL.deleteAfterText).tag(chatItemTTL) - } - } - .frame(height: 36) - .disabled(m.chatDbChanged || progressIndicator) - Button("Delete files & media", role: .destructive) { + Button(m.users.count > 1 ? "Delete files for all chat profiles" : "Delete all files", role: .destructive) { alert = .deleteFilesAndMedia } .disabled(!stopped || appFilesCountAndSize?.0 == 0) } header: { - Text("Data") + Text("Files & media") } footer: { if let (fileCount, size) = appFilesCountAndSize { if fileCount == 0 { diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index 512cbeda35..211905255c 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -11,7 +11,7 @@ import SimpleXChat struct CreateProfile: View { @EnvironmentObject var m: ChatModel - @Environment(\.presentationMode) var presentationMode: Binding + @Environment(\.dismiss) var dismiss @State private var displayName: String = "" @State private var fullName: String = "" @FocusState private var focusDisplayName @@ -97,12 +97,13 @@ struct CreateProfile: View { ) do { m.currentUser = try apiCreateActiveUser(profile) - try startChat() - if m.users.count == 1 { + if m.users.isEmpty { + try startChat() withAnimation { m.onboardingStage = .step3_SetNotificationsMode } } else { - presentationMode.wrappedValue.dismiss() - try getUserChatData(m) + dismiss() + m.users = try listUsers() + try getUserChatData() } } catch { fatalError("Failed to create user or start chat: \(responseError(error))") diff --git a/apps/ios/Shared/Views/UserSettings/SMPServersView.swift b/apps/ios/Shared/Views/UserSettings/SMPServersView.swift index 372c5bb34a..7cea87cb6a 100644 --- a/apps/ios/Shared/Views/UserSettings/SMPServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/SMPServersView.swift @@ -44,7 +44,7 @@ struct SMPServersView: View { private func smpServersView() -> some View { List { - Section("SMP servers") { + Section { ForEach($servers) { srv in smpServerView(srv) } @@ -57,6 +57,11 @@ struct SMPServersView: View { Button("Add server…") { showAddServer = true } + } header: { + Text("SMP servers") + } footer: { + Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.") + .lineLimit(10) } Section { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index f41c022dc3..9a57df8d1f 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -113,12 +113,19 @@ struct SettingsView: View { Section("You") { NavigationLink { UserProfile() - .navigationTitle("Your chat profile") + .navigationTitle("Your current profile") } label: { ProfilePreview(profileOf: user) .padding(.leading, -8) } + NavigationLink { + UserProfilesView() + .navigationTitle("Your chat profiles") + } label: { + settingsRow("person.crop.rectangle.stack") { Text("Your chat profiles") } + } + incognitoRow() NavigationLink { diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index fd1f920002..adb75d6a10 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -9,57 +9,90 @@ import SimpleXChat struct UserProfilesView: View { @EnvironmentObject private var m: ChatModel @Environment(\.editMode) private var editMode - @State private var selectedUser: Int? = nil - @State private var showAddUser: Bool? = false + @State private var alert: UserProfilesAlert? + + private enum UserProfilesAlert: Identifiable { + case deleteUser(index: Int) + case error(title: LocalizedStringKey, error: LocalizedStringKey = "") + + var id: String { + switch self { + case let .deleteUser(index): return "deleteUser \(index)" + case let .error(title, _): return "error \(title)" + } + } + } var body: some View { List { - Section("Your profiles") { - ForEach(Array(m.users.enumerated()), id: \.0) { i, userInfo in - userProfileView(userInfo, index: i) - .deleteDisabled(userInfo.user.activeUser) + Section { + ForEach(m.users) { u in + userView(u.user) } .onDelete { indexSet in - AlertManager.shared.showAlert( - Alert( - title: Text("Delete profile?"), - message: Text("All chats and messages will be deleted - this cannot be undone!"), - primaryButton: .destructive(Text("Delete")) { - removeUser(index: indexSet.first!) - }, - secondaryButton: .cancel() - )) + if let i = indexSet.first { + alert = .deleteUser(index: i) + } } - NavigationLink(destination: CreateProfile(), tag: true, selection: $showAddUser) { - Text("Add profile…") + + NavigationLink { + CreateProfile() + } label: { + Label("Add profile", systemImage: "plus") } + .frame(height: 44) + .padding(.vertical, 4) + } footer: { + Text("Your chat profiles are stored locally, only on your device.") } } .toolbar { EditButton() } + .alert(item: $alert) { alert in + switch alert { + case let .deleteUser(index): + return Alert( + title: Text("Delete user profile?"), + message: Text("All chats and messages will be deleted - this cannot be undone!"), + primaryButton: .destructive(Text("Delete")) { + removeUser(index: index) + }, + secondaryButton: .cancel() + ) + case let .error(title, error): + return Alert(title: Text(title), message: Text(error)) + } + } } private func removeUser(index: Int) { do { try apiDeleteUser(m.users[index].user.userId) - var users = m.users - users.remove(at: index) - m.updateUsers(users) - } catch { - AlertManager.shared.showAlertMsg( - title: "Failed to delete the user", - message: "Error: \(responseError(error))" - ) + m.users.remove(at: index) + } catch let error { + let a = getErrorAlert(error, "Error deleting user profile") + alert = .error(title: a.title, error: a.message) } } - private func userProfileView(_ userBinding: UserInfo, index: Int) -> some View { - let user = userBinding.user - return NavigationLink(tag: index, selection: $selectedUser) { -// UserPrefs(user: userBinding, index: index) -// .navigationBarTitle(user.chatViewName) -// .navigationBarTitleDisplayMode(.large) + + @ViewBuilder private func userView(_ user: User) -> some View { + Button { + if !user.activeUser { + changeActiveUser(user.userId) + } } label: { - Text(user.chatViewName) + HStack { + ProfileImage(imageStr: user.image) + .frame(width: 44, height: 44) + .padding(.vertical, 4) + .padding(.trailing, 12) + Text(user.chatViewName) + Spacer() + Image(systemName: "checkmark") + .foregroundColor(user.activeUser ? .primary : .clear) + } } + .foregroundColor(.primary) + .deleteDisabled(user.activeUser) } } diff --git a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..146d9f8052 --- /dev/null +++ b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "codescanner", + "kind" : "remoteSourceControl", + "location" : "https://github.com/twostraws/CodeScanner", + "state" : { + "revision" : "c27a66149b7483fe42e2ec6aad61d5c3fffe522d", + "version" : "2.1.1" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif", + "state" : { + "branch" : "master", + "revision" : "5e8619335d394901379c9add5c4c1c2f420b3800" + } + } + ], + "version" : 2 +} diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 64dbeec2a8..9f059fa339 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -34,7 +34,7 @@ public struct User: Decodable, NamedChat, Identifiable { ) } -public struct UserInfo: Decodable { +public struct UserInfo: Decodable, Identifiable { public var user: User public var unreadCount: Int64 @@ -43,6 +43,8 @@ public struct UserInfo: Decodable { self.unreadCount = unreadCount } + public var id: Int64 { user.userId } + public static let sampleData = UserInfo( user: User.sampleData, unreadCount: 1