diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 63811449e..e8f917af3 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -67,6 +67,31 @@ final class ChatModel: ObservableObject { static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } + func getUser(_ userId: Int64) -> User? { + currentUser?.userId == userId + ? currentUser + : users.first { $0.user.userId == userId }?.user + } + + func getUserIndex(_ user: User) -> Int? { + users.firstIndex { $0.user.userId == user.userId } + } + + func updateUser(_ user: User) { + if let i = getUserIndex(user) { + users[i].user = user + } + if currentUser?.userId == user.userId { + currentUser = user + } + } + + func removeUser(_ user: User) { + if let i = getUserIndex(user), users[i].user.userId != currentUser?.userId { + users.remove(at: i) + } + } + func hasChat(_ id: String) -> Bool { chats.first(where: { $0.id == id }) != nil } diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 9663a7319..4a4511eae 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 { - changeActiveUser(userId) + changeActiveUser(userId, viewPwd: nil) } if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact, let chatId = content.userInfo["chatId"] as? String { @@ -87,13 +87,17 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { switch content.categoryIdentifier { case ntfCategoryMessageReceived: let recent = recentInTheSameChat(content) - if model.chatId == nil { + let userId = content.userInfo["userId"] as? Int64 + if let userId = userId, let user = model.getUser(userId), !user.showNotifications { + // ... inactive user with disabled notifications + return [] + } else if model.chatId == nil { // in the chat list... - if model.currentUser?.userId == (content.userInfo["userId"] as? Int64) { - // ... of the current user + if model.currentUser?.userId == userId { + // ... of the active user return recent ? [] : [.sound, .list] } else { - // ... of different user + // ... of inactive user return recent ? [.banner] : [.sound, .banner, .list] } } else if model.chatId == content.targetContentIdentifier { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f2bb6ba19..d5ad40b85 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -132,21 +132,56 @@ func apiCreateActiveUser(_ p: Profile) throws -> User { } func listUsers() throws -> [UserInfo] { - let r = chatSendCmdSync(.listUsers) + return try listUsersResponse(chatSendCmdSync(.listUsers)) +} + +func listUsersAsync() async throws -> [UserInfo] { + return try listUsersResponse(await chatSendCmd(.listUsers)) +} + +private func listUsersResponse(_ r: ChatResponse) throws -> [UserInfo] { 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 { - let r = chatSendCmdSync(.apiSetActiveUser(userId: userId)) +func apiSetActiveUser(_ userId: Int64, viewPwd: String?) throws -> User { + let r = chatSendCmdSync(.apiSetActiveUser(userId: userId, viewPwd: viewPwd)) if case let .activeUser(user) = r { return user } throw r } -func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool) throws { - let r = chatSendCmdSync(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues)) +func apiSetActiveUserAsync(_ userId: Int64, viewPwd: String?) async throws -> User { + let r = await chatSendCmd(.apiSetActiveUser(userId: userId, viewPwd: viewPwd)) + if case let .activeUser(user) = r { return user } + throw r +} + +func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User { + try await setUserPrivacy_(.apiHideUser(userId: userId, viewPwd: viewPwd)) +} + +func apiUnhideUser(_ userId: Int64, viewPwd: String?) async throws -> User { + try await setUserPrivacy_(.apiUnhideUser(userId: userId, viewPwd: viewPwd)) +} + +func apiMuteUser(_ userId: Int64, viewPwd: String?) async throws -> User { + try await setUserPrivacy_(.apiMuteUser(userId: userId, viewPwd: viewPwd)) +} + +func apiUnmuteUser(_ userId: Int64, viewPwd: String?) async throws -> User { + try await setUserPrivacy_(.apiUnmuteUser(userId: userId, viewPwd: viewPwd)) +} + +func setUserPrivacy_(_ cmd: ChatCommand) async throws -> User { + let r = await chatSendCmd(cmd) + if case let .userPrivacy(user) = r { return user } + throw r +} + +func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) async throws { + let r = await chatSendCmd(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: viewPwd)) if case .cmdOk = r { return } throw r } @@ -209,8 +244,16 @@ func apiStorageEncryption(currentKey: String = "", newKey: String = "") async th } func apiGetChats() throws -> [ChatData] { - guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetChats: no current user") } - let r = chatSendCmdSync(.apiGetChats(userId: userId)) + let userId = try currentUserId("apiGetChats") + return try apiChatsResponse(chatSendCmdSync(.apiGetChats(userId: userId))) +} + +func apiGetChatsAsync() async throws -> [ChatData] { + let userId = try currentUserId("apiGetChats") + return try apiChatsResponse(await chatSendCmd(.apiGetChats(userId: userId))) +} + +private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] { if case let .apiChats(_, chats) = r { return chats } throw r } @@ -337,19 +380,27 @@ func apiDeleteToken(token: DeviceToken) async throws { } func getUserSMPServers() throws -> ([ServerCfg], [String]) { - guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("getUserSMPServers: no current user") } - let r = chatSendCmdSync(.apiGetUserSMPServers(userId: userId)) + let userId = try currentUserId("getUserSMPServers") + return try userSMPServersResponse(chatSendCmdSync(.apiGetUserSMPServers(userId: userId))) +} + +func getUserSMPServersAsync() async throws -> ([ServerCfg], [String]) { + let userId = try currentUserId("getUserSMPServersAsync") + return try userSMPServersResponse(await chatSendCmd(.apiGetUserSMPServers(userId: userId))) +} + +private func userSMPServersResponse(_ r: ChatResponse) throws -> ([ServerCfg], [String]) { if case let .userSMPServers(_, smpServers, presetServers) = r { return (smpServers, presetServers) } throw r } func setUserSMPServers(smpServers: [ServerCfg]) async throws { - guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("setUserSMPServers: no current user") } + let userId = try currentUserId("setUserSMPServers") try await sendCommandOkResp(.apiSetUserSMPServers(userId: userId, smpServers: smpServers)) } func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> { - guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("testSMPServer: no current user") } + let userId = try currentUserId("testSMPServer") let r = await chatSendCmd(.apiTestSMPServer(userId: userId, smpServer: smpServer)) if case let .smpTestResult(_, testFailure) = r { if let t = testFailure { @@ -361,14 +412,22 @@ func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> } func getChatItemTTL() throws -> ChatItemTTL { - guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("getChatItemTTL: no current user") } - let r = chatSendCmdSync(.apiGetChatItemTTL(userId: userId)) + let userId = try currentUserId("getChatItemTTL") + return try chatItemTTLResponse(chatSendCmdSync(.apiGetChatItemTTL(userId: userId))) +} + +func getChatItemTTLAsync() async throws -> ChatItemTTL { + let userId = try currentUserId("getChatItemTTLAsync") + return try chatItemTTLResponse(await chatSendCmd(.apiGetChatItemTTL(userId: userId))) +} + +private func chatItemTTLResponse(_ r: ChatResponse) throws -> ChatItemTTL { if case let .chatItemTTL(_, chatItemTTL) = r { return ChatItemTTL(chatItemTTL) } throw r } func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws { - guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("setChatItemTTL: no current user") } + let userId = try currentUserId("setChatItemTTL") try await sendCommandOkResp(.apiSetChatItemTTL(userId: userId, seconds: chatItemTTL.seconds)) } @@ -539,14 +598,14 @@ func clearChat(_ chat: Chat) async { } func apiListContacts() throws -> [Contact] { - guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiListContacts: no current user") } + let userId = try currentUserId("apiListContacts") let r = chatSendCmdSync(.apiListContacts(userId: userId)) if case let .contactsList(_, contacts) = r { return contacts } throw r } func apiUpdateProfile(profile: Profile) async throws -> Profile? { - guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiUpdateProfile: no current user") } + let userId = try currentUserId("apiUpdateProfile") let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile)) switch r { case .userProfileNoChange: return nil @@ -574,22 +633,30 @@ func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> Pe } func apiCreateUserAddress() async throws -> String { - guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiCreateUserAddress: no current user") } + let userId = try currentUserId("apiCreateUserAddress") let r = await chatSendCmd(.apiCreateMyAddress(userId: userId)) if case let .userContactLinkCreated(_, connReq) = r { return connReq } throw r } func apiDeleteUserAddress() async throws { - guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiDeleteUserAddress: no current user") } + let userId = try currentUserId("apiDeleteUserAddress") let r = await chatSendCmd(.apiDeleteMyAddress(userId: userId)) if case .userContactLinkDeleted = r { return } throw r } func apiGetUserAddress() throws -> UserContactLink? { - guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetUserAddress: no current user") } - let r = chatSendCmdSync(.apiShowMyAddress(userId: userId)) + let userId = try currentUserId("apiGetUserAddress") + return try userAddressResponse(chatSendCmdSync(.apiShowMyAddress(userId: userId))) +} + +func apiGetUserAddressAsync() async throws -> UserContactLink? { + let userId = try currentUserId("apiGetUserAddressAsync") + return try userAddressResponse(await chatSendCmd(.apiShowMyAddress(userId: userId))) +} + +private func userAddressResponse(_ r: ChatResponse) throws -> UserContactLink? { switch r { case let .userContactLink(_, contactLink): return contactLink case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil @@ -598,7 +665,7 @@ func apiGetUserAddress() throws -> UserContactLink? { } func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContactLink? { - guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("userAddressAutoAccept: no current user") } + let userId = try currentUserId("userAddressAutoAccept") let r = await chatSendCmd(.apiAddressAutoAccept(userId: userId, autoAccept: autoAccept)) switch r { case let .userContactLinkUpdated(_, contactLink): return contactLink @@ -793,7 +860,7 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { } func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo { - guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiNewGroup: no current user") } + let userId = try currentUserId("apiNewGroup") let r = chatSendCmdSync(.apiNewGroup(userId: userId, groupProfile: p)) if case let .groupCreated(_, groupInfo) = r { return groupInfo } throw r @@ -909,6 +976,13 @@ func apiGetVersion() throws -> CoreVersionInfo { throw r } +private func currentUserId(_ funcName: String) throws -> Int64 { + if let userId = ChatModel.shared.currentUser?.userId { + return userId + } + throw RuntimeError("\(funcName): no current user") +} + func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool = true) throws { logger.debug("initializeChat") let m = ChatModel.shared @@ -958,21 +1032,38 @@ func startChat(refreshInvitations: Bool = true) throws { chatLastStartGroupDefault.set(Date.now) } -func changeActiveUser(_ userId: Int64) { +func changeActiveUser(_ userId: Int64, viewPwd: String?) { do { - try changeActiveUser_(userId) + try changeActiveUser_(userId, viewPwd: viewPwd) } catch let error { logger.error("Unable to set active user: \(responseError(error))") } } -func changeActiveUser_(_ userId: Int64) throws { +private func changeActiveUser_(_ userId: Int64, viewPwd: String?) throws { let m = ChatModel.shared - m.currentUser = try apiSetActiveUser(userId) + m.currentUser = try apiSetActiveUser(userId, viewPwd: viewPwd) m.users = try listUsers() try getUserChatData() } +func changeActiveUserAsync_(_ userId: Int64, viewPwd: String?) async throws { + let currentUser = try await apiSetActiveUserAsync(userId, viewPwd: viewPwd) + let users = try await listUsersAsync() + await MainActor.run { + let m = ChatModel.shared + m.currentUser = currentUser + m.users = users + } + try await getUserChatDataAsync() + await MainActor.run { + if var (_, invitation) = ChatModel.shared.callInvitations.first(where: { _, inv in inv.user.userId == userId }) { + invitation.user = currentUser + activateCall(invitation) + } + } +} + func getUserChatData() throws { let m = ChatModel.shared m.userAddress = try apiGetUserAddress() @@ -982,6 +1073,20 @@ func getUserChatData() throws { m.chats = chats.map { Chat.init($0) } } +private func getUserChatDataAsync() async throws { + let userAddress = try await apiGetUserAddressAsync() + let servers = try await getUserSMPServersAsync() + let chatItemTTL = try await getChatItemTTLAsync() + let chats = try await apiGetChatsAsync() + await MainActor.run { + let m = ChatModel.shared + m.userAddress = userAddress + (m.userSMPServers, m.presetSMPServers) = servers + m.chatItemTTL = chatItemTTL + m.chats = chats.map { Chat.init($0) } + } +} + class ChatReceiver { private var receiveLoop: Task? private var receiveMessages = true @@ -1050,18 +1155,18 @@ func processReceivedMsg(_ res: ChatResponse) async { m.removeChat(contact.activeConn.id) } case let .receivedContactRequest(user, contactRequest): - if !active(user) { return } - - let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest) - if m.hasChat(contactRequest.id) { - m.updateChatInfo(cInfo) - } else { - m.addChat(Chat( - chatInfo: cInfo, - chatItems: [] - )) - NtfManager.shared.notifyContactRequest(user, contactRequest) + if active(user) { + let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest) + if m.hasChat(contactRequest.id) { + m.updateChatInfo(cInfo) + } else { + m.addChat(Chat( + chatInfo: cInfo, + chatItems: [] + )) + } } + NtfManager.shared.notifyContactRequest(user, contactRequest) case let .contactUpdated(user, toContact): if active(user) && m.hasChat(toContact.id) { let cInfo = ChatInfo.direct(contact: toContact) @@ -1304,7 +1409,7 @@ func refreshCallInvitations() throws { let invitation = m.callInvitations.removeValue(forKey: chatId) { m.ntfCallInvitationAction = nil CallController.shared.callAction(invitation: invitation, action: ntfAction) - } else if let invitation = callInvitations.last { + } else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) { activateCall(invitation) } } @@ -1317,6 +1422,7 @@ func justRefreshCallInvitations() throws -> [RcvCallInvitation] { } func activateCall(_ callInvitation: RcvCallInvitation) { + if !callInvitation.user.showNotifications { return } let m = ChatModel.shared CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in if let error = error { diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 3f338d771..6a20eee59 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -214,8 +214,10 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) { logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callkitUUID), privacy: .public)") if CallController.useCallKit(), let uuid = invitation.callkitUUID { - let update = cxCallUpdate(invitation: invitation) - provider.reportNewIncomingCall(with: uuid, update: update, completion: completion) + if invitation.callTs.timeIntervalSinceNow >= -180 { + let update = cxCallUpdate(invitation: invitation) + provider.reportNewIncomingCall(with: uuid, update: update, completion: completion) + } } else { NtfManager.shared.notifyCallInvitation(invitation) if invitation.callTs.timeIntervalSinceNow >= -180 { diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index b9f3c3bc4..76bac7a28 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -29,7 +29,9 @@ struct UserPicker: View { VStack(spacing: 0) { ScrollView { ScrollViewReader { sp in - let users = m.users.sorted { u, _ in u.user.activeUser } + let users = m.users + .filter({ u in u.user.activeUser || !u.user.hidden }) + .sorted { u, _ in u.user.activeUser } VStack(spacing: 0) { ForEach(users) { u in userView(u) @@ -97,14 +99,18 @@ struct UserPicker: View { userPickerVisible.toggle() } } else { - do { - try changeActiveUser_(user.userId) - userPickerVisible = false - } catch { - AlertManager.shared.showAlertMsg( - title: "Error switching profile!", - message: "Error: \(responseError(error))" - ) + Task { + do { + try await changeActiveUserAsync_(user.userId, viewPwd: nil) + await MainActor.run { userPickerVisible = false } + } catch { + await MainActor.run { + AlertManager.shared.showAlertMsg( + title: "Error switching profile!", + message: "Error: \(responseError(error))" + ) + } + } } } }, label: { diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index 22ab2a4ed..acc86ff78 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -73,11 +73,11 @@ struct DatabaseEncryptionView: View { } if !initialRandomDBPassphrase && m.chatDbEncrypted == true { - DatabaseKeyField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey)) + PassphraseField(key: $currentKey, placeholder: "Current passphrase…", valid: validKey(currentKey)) } - DatabaseKeyField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true) - DatabaseKeyField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey) + PassphraseField(key: $newKey, placeholder: "New passphrase…", valid: validKey(newKey), showStrength: true) + PassphraseField(key: $confirmNewKey, placeholder: "Confirm new passphrase…", valid: confirmNewKey == "" || newKey == confirmNewKey) settingsRow("lock.rotation") { Button("Update database passphrase") { @@ -255,7 +255,7 @@ struct DatabaseEncryptionView: View { } -struct DatabaseKeyField: View { +struct PassphraseField: View { @Binding var key: String var placeholder: LocalizedStringKey var valid: Bool diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index 5c6451e50..4830c8172 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -66,7 +66,7 @@ struct DatabaseErrorView: View { } private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View { - DatabaseKeyField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit) + PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit) } private func saveAndOpenButton() -> some View { diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 5b063e8ba..4174e5b43 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -100,7 +100,7 @@ struct TerminalView: View { func sendMessage() { let cmd = ChatCommand.string(composeState.message) if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) { - let resp = ChatResponse.chatCmdError(user: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty"))) + let resp = ChatResponse.chatCmdError(user_: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty"))) DispatchQueue.main.async { ChatModel.shared.addTerminalItem(.cmd(.now, cmd)) ChatModel.shared.addTerminalItem(.resp(.now, resp)) diff --git a/apps/ios/Shared/Views/UserSettings/HiddenProfileView.swift b/apps/ios/Shared/Views/UserSettings/HiddenProfileView.swift new file mode 100644 index 000000000..d01fee92f --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/HiddenProfileView.swift @@ -0,0 +1,84 @@ +// +// ProfilePrivacyView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 17/03/2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct HiddenProfileView: View { + @State var user: User + @Binding var profileHidden: Bool + @EnvironmentObject private var m: ChatModel + @Environment(\.dismiss) var dismiss: DismissAction + @State private var hidePassword = "" + @State private var confirmHidePassword = "" + @State private var saveErrorAlert = false + @State private var savePasswordError: String? + + var body: some View { + List { + Text("Hide profile") + .font(.title) + .bold() + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + + Section() { + ProfilePreview(profileOf: user) + .padding(.leading, -8) + } + + Section { + PassphraseField(key: $hidePassword, placeholder: "Password to show", valid: true, showStrength: true) + PassphraseField(key: $confirmHidePassword, placeholder: "Confirm password", valid: confirmValid) + + settingsRow("lock") { + Button("Save profile password") { + Task { + do { + let u = try await apiHideUser(user.userId, viewPwd: hidePassword) + await MainActor.run { + m.updateUser(u) + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation { profileHidden = true } + } + } + } catch let error { + saveErrorAlert = true + savePasswordError = responseError(error) + } + } + } + } + .disabled(saveDisabled) + } header: { + Text("Hidden profile password") + } footer: { + Text("To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page.") + .font(.body) + .padding(.top, 8) + } + } + .alert(isPresented: $saveErrorAlert) { + Alert( + title: Text("Error saving user password"), + message: Text(savePasswordError ?? "") + ) + } + } + + var confirmValid: Bool { confirmHidePassword == "" || hidePassword == confirmHidePassword } + + var saveDisabled: Bool { hidePassword == "" || confirmHidePassword == "" || !confirmValid } +} + +struct ProfilePrivacyView_Previews: PreviewProvider { + static var previews: some View { + HiddenProfileView(user: User.sampleData, profileHidden: Binding.constant(false)) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 5b9cd4952..c934d36d6 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct PrivacySettings: View { + @EnvironmentObject var m: ChatModel @AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true @AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 4f439de6f..cb58d2fea 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -40,6 +40,8 @@ let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue" let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown" +let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice" +let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert" let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion" let appDefaults: [String: Any] = [ @@ -62,7 +64,9 @@ let appDefaults: [String: Any] = [ DEFAULT_ACCENT_COLOR_BLUE: 1.000, DEFAULT_USER_INTERFACE_STYLE: 0, DEFAULT_CONNECT_VIA_LINK_TAB: "scan", - DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false + DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, + DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true, + DEFAULT_SHOW_MUTE_PROFILE_ALERT: true, ] enum SimpleXLinkMode: String, Identifiable { diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index d78b7ea66..c62559a84 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -9,19 +9,31 @@ import SimpleXChat struct UserProfilesView: View { @EnvironmentObject private var m: ChatModel @Environment(\.editMode) private var editMode + @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false + @AppStorage(DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE) private var showHiddenProfilesNotice = true + @AppStorage(DEFAULT_SHOW_MUTE_PROFILE_ALERT) private var showMuteProfileAlert = true @State private var showDeleteConfirmation = false - @State private var userToDelete: Int? + @State private var userToDelete: UserInfo? @State private var alert: UserProfilesAlert? - @State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) + @State private var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA) + @State private var searchTextOrPassword = "" + @State private var selectedUser: User? + @State private var profileHidden = false private enum UserProfilesAlert: Identifiable { - case deleteUser(index: Int, delSMPQueues: Bool) + case deleteUser(userInfo: UserInfo, delSMPQueues: Bool) + case cantDeleteLastUser + case hiddenProfilesNotice + case muteProfileAlert case activateUserError(error: String) case error(title: LocalizedStringKey, error: LocalizedStringKey = "") var id: String { switch self { - case let .deleteUser(index, delSMPQueues): return "deleteUser \(index) \(delSMPQueues)" + case let .deleteUser(userInfo, delSMPQueues): return "deleteUser \(userInfo.user.userId) \(delSMPQueues)" + case .cantDeleteLastUser: return "cantDeleteLastUser" + case .hiddenProfilesNotice: return "hiddenProfilesNotice" + case .muteProfileAlert: return "muteProfileAlert" case let .activateUserError(err): return "activateUserError \(err)" case let .error(title, _): return "error \(title)" } @@ -41,45 +53,103 @@ struct UserProfilesView: View { private func userProfilesView() -> some View { List { + if profileHidden { + Button { + withAnimation { profileHidden = false } + } label: { + Label("Enter password above to show!", systemImage: "lock.open") + } + } Section { - ForEach(m.users) { u in + let users = filteredUsers() + ForEach(users) { u in userView(u.user) } .onDelete { indexSet in if let i = indexSet.first { - showDeleteConfirmation = true - userToDelete = i + if m.users.count > 1 && (m.users[i].user.hidden || visibleUsersCount > 1) { + showDeleteConfirmation = true + userToDelete = users[i] + } else { + alert = .cantDeleteLastUser + } } } - NavigationLink { - CreateProfile() - } label: { - Label("Add profile", systemImage: "plus") + if searchTextOrPassword == "" { + NavigationLink { + CreateProfile() + } label: { + Label("Add profile", systemImage: "plus") + } + .frame(height: 44) + .padding(.vertical, 4) } - .frame(height: 44) - .padding(.vertical, 4) } footer: { - Text("Your chat profiles are stored locally, only on your device.") + Text("Tap to activate profile.") + .font(.body) + .padding(.top, 8) + } } .toolbar { EditButton() } .navigationTitle("Your chat profiles") + .searchable(text: $searchTextOrPassword, placement: .navigationBarDrawer(displayMode: .always)) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) + .onAppear { + if showHiddenProfilesNotice && m.users.count > 1 { + alert = .hiddenProfilesNotice + } + } .confirmationDialog("Delete chat profile?", isPresented: $showDeleteConfirmation, titleVisibility: .visible) { deleteModeButton("Profile and server connections", true) deleteModeButton("Local profile data only", false) } + .sheet(item: $selectedUser) { user in + HiddenProfileView(user: user, profileHidden: $profileHidden) + } + .onChange(of: profileHidden) { _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + withAnimation { profileHidden = false } + } + } .alert(item: $alert) { alert in switch alert { - case let .deleteUser(index, delSMPQueues): + case let .deleteUser(userInfo, delSMPQueues): 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, delSMPQueues) + Task { await removeUser(userInfo, delSMPQueues) } }, secondaryButton: .cancel() ) + case .cantDeleteLastUser: + return Alert( + title: Text("Can't delete user profile!"), + message: m.users.count > 1 + ? Text("There should be at least one visible user profile.") + : Text("There should be at least use user profile.") + ) + case .hiddenProfilesNotice: + return Alert( + title: Text("Make profile private!"), + message: Text("You can hide or mute a user profile - swipe it to the right.\nSimpleX Lock must be enabled."), + primaryButton: .default(Text("Don't show again")) { + showHiddenProfilesNotice = false + }, + secondaryButton: .default(Text("Ok")) + ) + case .muteProfileAlert: + return Alert( + title: Text("Muted when inactive!"), + message: Text("You will still receive calls and notifications from muted profiles when they are active."), + primaryButton: .default(Text("Don't show again")) { + showMuteProfileAlert = false + }, + secondaryButton: .default(Text("Ok")) + ) case let .activateUserError(error: err): return Alert( title: Text("Error switching profile!"), @@ -91,43 +161,66 @@ struct UserProfilesView: View { } } + private func filteredUsers() -> [UserInfo] { + let s = searchTextOrPassword.trimmingCharacters(in: .whitespaces) + let lower = s.localizedLowercase + return m.users.filter { u in + if (u.user.activeUser || u.user.viewPwdHash == nil) && (s == "" || u.user.chatViewName.localizedLowercase.contains(lower)) { + return true + } + if let ph = u.user.viewPwdHash { + return s != "" && chatPasswordHash(s, ph.salt) == ph.hash + } + return false + } + } + + private var visibleUsersCount: Int { + m.users.filter({ u in !u.user.hidden }).count + } + + private func userViewPassword(_ user: User) -> String? { + user.activeUser || !user.hidden ? nil : searchTextOrPassword + } + private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View { Button(title, role: .destructive) { - if let i = userToDelete { - alert = .deleteUser(index: i, delSMPQueues: delSMPQueues) + if let userInfo = userToDelete { + alert = .deleteUser(userInfo: userInfo, delSMPQueues: delSMPQueues) } } } - private func removeUser(_ index: Int, _ delSMPQueues: Bool) { - if index >= m.users.count { return } + private func removeUser(_ userInfo: UserInfo, _ delSMPQueues: Bool) async { do { - let u = m.users[index].user + let u = userInfo.user if u.activeUser { - if let newActive = m.users.first(where: { !$0.user.activeUser }) { - try changeActiveUser_(newActive.user.userId) - try deleteUser(u.userId) + if let newActive = m.users.first(where: { u in !u.user.activeUser && !u.user.hidden }) { + try await changeActiveUserAsync_(newActive.user.userId, viewPwd: nil) + try await deleteUser(u) } } else { - try deleteUser(u.userId) + try await deleteUser(u) } } catch let error { let a = getErrorAlert(error, "Error deleting user profile") alert = .error(title: a.title, error: a.message) } - func deleteUser(_ userId: Int64) throws { - try apiDeleteUser(userId, delSMPQueues) - m.users.remove(at: index) + func deleteUser(_ user: User) async throws { + try await apiDeleteUser(user.userId, delSMPQueues, viewPwd: userViewPassword(user)) + await MainActor.run { withAnimation { m.removeUser(user) } } } } private func userView(_ user: User) -> some View { Button { - do { - try changeActiveUser_(user.userId) - } catch { - alert = .activateUserError(error: responseError(error)) + Task { + do { + try await changeActiveUserAsync_(user.userId, viewPwd: userViewPassword(user)) + } catch { + await MainActor.run { alert = .activateUserError(error: responseError(error)) } + } } } label: { HStack { @@ -137,14 +230,75 @@ struct UserProfilesView: View { .padding(.trailing, 12) Text(user.chatViewName) Spacer() - Image(systemName: "checkmark") - .foregroundColor(user.activeUser ? .primary : .clear) + if user.activeUser { + Image(systemName: "checkmark").foregroundColor(.primary) + } else if user.hidden { + Image(systemName: "lock").foregroundColor(.secondary) + } else if !user.showNtfs { + Image(systemName: "speaker.slash").foregroundColor(.secondary) + } else { + Image(systemName: "checkmark").foregroundColor(.clear) + } } } .disabled(user.activeUser) .foregroundColor(.primary) .deleteDisabled(m.users.count <= 1) + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if user.hidden { + Button("Unhide") { + setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: userViewPassword(user)) } + } + .tint(.green) + } else { + if visibleUsersCount > 1 && prefPerformLA { + Button("Hide") { + selectedUser = user + } + .tint(.gray) + } + Group { + if user.showNtfs { + Button("Mute") { + setUserPrivacy(user, successAlert: showMuteProfileAlert ? .muteProfileAlert : nil) { + try await apiMuteUser(user.userId, viewPwd: userViewPassword(user)) + } + } + } else { + Button("Unmute") { + setUserPrivacy(user) { try await apiUnmuteUser(user.userId, viewPwd: userViewPassword(user)) } + } + } + } + .tint(.accentColor) + } + } } + + private func setUserPrivacy(_ user: User, successAlert: UserProfilesAlert? = nil, _ api: @escaping () async throws -> User) { + Task { + do { + let u = try await api() + await MainActor.run { + withAnimation { m.updateUser(u) } + if successAlert != nil { + alert = successAlert + } + } + } catch let error { + let a = getErrorAlert(error, "Error updating user privacy") + alert = .error(title: a.title, error: a.message) + } + } + } +} + +public func chatPasswordHash(_ pwd: String, _ salt: String) -> String { + var cPwd = pwd.cString(using: .utf8)! + var cSalt = salt.cString(using: .utf8)! + let cHash = chat_password_hash(&cPwd, &cSalt)! + let hash = fromCString(cHash) + return hash } struct UserProfilesView_Previews: PreviewProvider { diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index d31a32e11..5eda201f2 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -16,7 +16,7 @@ let logger = Logger() let suspendingDelay: UInt64 = 2_000_000_000 -typealias NtfStream = AsyncStream +typealias NtfStream = AsyncStream actor PendingNtfs { static let shared = PendingNtfs() @@ -33,13 +33,13 @@ actor PendingNtfs { } } - func readStream(_ id: String, for nse: NotificationService, msgCount: Int = 1) async { + func readStream(_ id: String, for nse: NotificationService, msgCount: Int = 1, showNotifications: Bool) async { logger.debug("PendingNtfs.readStream: \(id, privacy: .public) \(msgCount, privacy: .public)") if let s = ntfStreams[id] { logger.debug("PendingNtfs.readStream: has stream") var rcvCount = max(1, msgCount) for await ntf in s { - nse.setBestAttemptNtf(ntf) + nse.setBestAttemptNtf(showNotifications ? ntf : .empty) rcvCount -= 1 if rcvCount == 0 || ntf.categoryIdentifier == ntfCategoryCallInvitation { break } } @@ -47,7 +47,7 @@ actor PendingNtfs { } } - func writeStream(_ id: String, _ ntf: UNMutableNotificationContent) { + func writeStream(_ id: String, _ ntf: NSENotification) { logger.debug("PendingNtfs.writeStream: \(id, privacy: .public)") if let cont = ntfConts[id] { logger.debug("PendingNtfs.writeStream: writing ntf") @@ -56,16 +56,30 @@ actor PendingNtfs { } } +enum NSENotification { + case nse(notification: UNMutableNotificationContent) + case callkit(invitation: RcvCallInvitation) + case empty + var categoryIdentifier: String? { + switch self { + case let .nse(ntf): return ntf.categoryIdentifier + case .callkit: return ntfCategoryCallInvitation + case .empty: return nil + } + } +} class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? - var bestAttemptNtf: UNMutableNotificationContent? + var bestAttemptNtf: NSENotification? var badgeCount: Int = 0 override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("NotificationService.didReceive") - setBestAttemptNtf(request.content.mutableCopy() as? UNMutableNotificationContent) + if let ntf = request.content.mutableCopy() as? UNMutableNotificationContent { + setBestAttemptNtf(ntf) + } self.contentHandler = contentHandler registerGroupDefaults() let appState = appStateGroupDefault.get() @@ -112,12 +126,16 @@ class NotificationService: UNNotificationServiceExtension { let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)") if let connEntity = ntfMsgInfo.connEntity { - setBestAttemptNtf(createConnectionEventNtf(ntfMsgInfo.user, connEntity)) + setBestAttemptNtf( + ntfMsgInfo.user.showNotifications + ? .nse(notification: createConnectionEventNtf(ntfMsgInfo.user, connEntity)) + : .empty + ) if let id = connEntity.id { Task { logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") await PendingNtfs.shared.createStream(id) - await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count) + await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count, showNotifications: ntfMsgInfo.user.showNotifications) deliverBestAttemptNtf() } } @@ -140,16 +158,40 @@ class NotificationService: UNNotificationServiceExtension { ntfBadgeCountGroupDefault.set(badgeCount) } - func setBestAttemptNtf(_ ntf: UNMutableNotificationContent?) { + func setBestAttemptNtf(_ ntf: UNMutableNotificationContent) { + setBestAttemptNtf(.nse(notification: ntf)) + } + + func setBestAttemptNtf(_ ntf: NSENotification) { logger.debug("NotificationService.setBestAttemptNtf") - bestAttemptNtf = ntf - bestAttemptNtf?.badge = badgeCount as NSNumber + if case let .nse(notification) = ntf { + notification.badge = badgeCount as NSNumber + bestAttemptNtf = .nse(notification: notification) + } else { + bestAttemptNtf = ntf + } } private func deliverBestAttemptNtf() { logger.debug("NotificationService.deliverBestAttemptNtf") - if let handler = contentHandler, let content = bestAttemptNtf { - handler(content) + if let handler = contentHandler, let ntf = bestAttemptNtf { + switch ntf { + case let .nse(content): handler(content) + case let .callkit(invitation): + CXProvider.reportNewIncomingVoIPPushPayload([ + "displayName": invitation.contact.displayName, + "contactId": invitation.contact.id, + "media": invitation.callType.media.rawValue + ]) { error in + if error == nil { + handler(UNMutableNotificationContent()) + } else { + logger.debug("reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)") + handler(createCallInvitationNtf(invitation)) + } + } + case .empty: handler(UNMutableNotificationContent()) + } bestAttemptNtf = nil } } @@ -211,15 +253,15 @@ func chatRecvMsg() async -> ChatResponse? { private let isInChina = SKStorefront().countryCode == "CHN" private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } -func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotificationContent)? { +func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { logger.debug("NotificationService processReceivedMsg: \(res.responseType)") switch res { case let .contactConnected(user, contact, _): - return (contact.id, createContactConnectedNtf(user, contact)) + return (contact.id, .nse(notification: createContactConnectedNtf(user, contact))) // case let .contactConnecting(contact): // TODO profile update case let .receivedContactRequest(user, contactRequest): - return (UserContact(contactRequest: contactRequest).id, createContactRequestNtf(user, contactRequest)) + return (UserContact(contactRequest: contactRequest).id, .nse(notification: createContactRequestNtf(user, contactRequest))) case let .newChatItem(user, aChatItem): let cInfo = aChatItem.chatInfo var cItem = aChatItem.chatItem @@ -240,23 +282,13 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification cItem = apiReceiveFile(fileId: file.fileId)?.chatItem ?? cItem } } - return cItem.showMutableNotification ? (aChatItem.chatId, createMessageReceivedNtf(user, cInfo, cItem)) : nil + return cItem.showMutableNotification ? (aChatItem.chatId, .nse(notification: createMessageReceivedNtf(user, cInfo, cItem))) : nil case let .callInvitation(invitation): // Do not post it without CallKit support, iOS will stop launching the app without showing CallKit - if useCallKit() { - do { - try await CXProvider.reportNewIncomingVoIPPushPayload([ - "displayName": invitation.contact.displayName, - "contactId": invitation.contact.id, - "media": invitation.callType.media.rawValue - ]) - logger.debug("reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)") - return (invitation.contact.id, (UNNotificationContent().mutableCopy() as! UNMutableNotificationContent)) - } catch let error { - logger.error("reportNewIncomingVoIPPushPayload error \(String(describing: error), privacy: .public)") - } - } - return (invitation.contact.id, createCallInvitationNtf(invitation)) + return ( + invitation.contact.id, + useCallKit() ? .callkit(invitation: invitation) : .nse(notification: createCallInvitationNtf(invitation)) + ) default: logger.debug("NotificationService processReceivedMsg ignored event: \(res.responseType)") return nil diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 0c8ba46df..13d7df8bd 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -109,6 +109,7 @@ 5CBD285C29575B8E00EC2CF4 /* WhatsNewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */; }; 5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */; }; 5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */; }; + 5CC036E029C488D500C0EF20 /* HiddenProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC036DF29C488D500C0EF20 /* HiddenProfileView.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 */; }; @@ -361,6 +362,7 @@ 5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewView.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 = ""; }; + 5CC036DF29C488D500C0EF20 /* HiddenProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenProfileView.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 = ""; }; @@ -675,6 +677,7 @@ 5CB924E327A8683A00ACCCDD /* UserAddress.swift */, 5CCA7DF22905735700C8FEBA /* AcceptRequestsView.swift */, 5CB924E027A867BA00ACCCDD /* UserProfile.swift */, + 5CC036DF29C488D500C0EF20 /* HiddenProfileView.swift */, 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */, 5C93292E29239A170090FFF9 /* SMPServersView.swift */, 5C93293029239BED0090FFF9 /* SMPServerView.swift */, @@ -1028,6 +1031,7 @@ 5C029EAA283942EA004A9677 /* CallController.swift in Sources */, 5CCA7DF32905735700C8FEBA /* AcceptRequestsView.swift in Sources */, 5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */, + 5CC036E029C488D500C0EF20 /* HiddenProfileView.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, 5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */, 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */, @@ -1585,6 +1589,10 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries", + ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = ( "$(inherited)", "$(PROJECT_DIR)/Libraries/ios", @@ -1631,6 +1639,10 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Libraries", + ); "LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = ( "$(inherited)", "$(PROJECT_DIR)/Libraries/ios", diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 54cdfb2d3..11eec7f87 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -151,6 +151,11 @@ public func chatResponse(_ s: String) -> ChatResponse { let chat = try? parseChatData(jChat) { return .apiChat(user: user, chat: chat) } + } else if type == "chatCmdError" { + if let jError = jResp["chatCmdError"] as? NSDictionary { + let user: User? = try? decodeObject(jError["user_"] as Any) + return .chatCmdError(user_: user, chatError: .invalidJSON(json: prettyJSON(jError) ?? "")) + } } } json = prettyJSON(j) @@ -185,12 +190,21 @@ func prettyJSON(_ obj: Any) -> String? { public func responseError(_ err: Error) -> String { if let r = err as? ChatResponse { - return String(describing: r) + switch r { + case let .chatCmdError(_, chatError): return chatErrorString(chatError) + case let .chatError(_, chatError): return chatErrorString(chatError) + default: return String(describing: r) + } } else { - return err.localizedDescription + return String(describing: err) } } +func chatErrorString(_ err: ChatError) -> String { + if case let .invalidJSON(json) = err { return json } + return String(describing: err) +} + public enum DBMigrationResult: Decodable, Equatable { case ok case errorNotADatabase(dbFile: String) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 6cd1a5a67..b7eb67736 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -16,8 +16,12 @@ public enum ChatCommand { case showActiveUser case createActiveUser(profile: Profile) case listUsers - case apiSetActiveUser(userId: Int64) - case apiDeleteUser(userId: Int64, delSMPQueues: Bool) + case apiSetActiveUser(userId: Int64, viewPwd: String?) + case apiHideUser(userId: Int64, viewPwd: String) + case apiUnhideUser(userId: Int64, viewPwd: String?) + case apiMuteUser(userId: Int64, viewPwd: String?) + case apiUnmuteUser(userId: Int64, viewPwd: String?) + case apiDeleteUser(userId: Int64, delSMPQueues: Bool, viewPwd: String?) case startChat(subscribe: Bool, expire: Bool) case apiStopChat case apiActivateChat @@ -103,8 +107,12 @@ public enum ChatCommand { case .showActiveUser: return "/u" case let .createActiveUser(profile): return "/create user \(profile.displayName) \(profile.fullName)" case .listUsers: return "/users" - case let .apiSetActiveUser(userId): return "/_user \(userId)" - case let .apiDeleteUser(userId, delSMPQueues): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))" + case let .apiSetActiveUser(userId, viewPwd): return "/_user \(userId)\(maybePwd(viewPwd))" + case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))" + case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId)\(maybePwd(viewPwd))" + case let .apiMuteUser(userId, viewPwd): return "/_mute user \(userId)\(maybePwd(viewPwd))" + case let .apiUnmuteUser(userId, viewPwd): return "/_unmute user \(userId)\(maybePwd(viewPwd))" + case let .apiDeleteUser(userId, delSMPQueues, viewPwd): return "/_delete user \(userId) del_smp=\(onOff(delSMPQueues))\(maybePwd(viewPwd))" case let .startChat(subscribe, expire): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire))" case .apiStopChat: return "/_stop" case .apiActivateChat: return "/_app activate" @@ -202,6 +210,10 @@ public enum ChatCommand { case .createActiveUser: return "createActiveUser" case .listUsers: return "listUsers" case .apiSetActiveUser: return "apiSetActiveUser" + case .apiHideUser: return "apiHideUser" + case .apiUnhideUser: return "apiUnhideUser" + case .apiMuteUser: return "apiMuteUser" + case .apiUnmuteUser: return "apiUnmuteUser" case .apiDeleteUser: return "apiDeleteUser" case .startChat: return "startChat" case .apiStopChat: return "apiStopChat" @@ -304,6 +316,18 @@ public enum ChatCommand { switch self { case let .apiStorageEncryption(cfg): return .apiStorageEncryption(config: DBEncryptionConfig(currentKey: obfuscate(cfg.currentKey), newKey: obfuscate(cfg.newKey))) + case let .apiSetActiveUser(userId, viewPwd): + return .apiSetActiveUser(userId: userId, viewPwd: obfuscate(viewPwd)) + case let .apiHideUser(userId, viewPwd): + return .apiHideUser(userId: userId, viewPwd: obfuscate(viewPwd)) + case let .apiUnhideUser(userId, viewPwd): + return .apiUnhideUser(userId: userId, viewPwd: obfuscate(viewPwd)) + case let .apiMuteUser(userId, viewPwd): + return .apiMuteUser(userId: userId, viewPwd: obfuscate(viewPwd)) + case let .apiUnmuteUser(userId, viewPwd): + return .apiUnmuteUser(userId: userId, viewPwd: obfuscate(viewPwd)) + case let .apiDeleteUser(userId, delSMPQueues, viewPwd): + return .apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: obfuscate(viewPwd)) default: return self } } @@ -312,9 +336,21 @@ public enum ChatCommand { s == "" ? "" : "***" } + private func obfuscate(_ s: String?) -> String? { + if let s = s { + return obfuscate(s) + } else { + return nil + } + } + private func onOff(_ b: Bool) -> String { b ? "on" : "off" } + + private func maybePwd(_ pwd: String?) -> String { + pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd) + } } struct APIResponse: Decodable { @@ -348,6 +384,7 @@ public enum ChatResponse: Decodable, Error { case chatCleared(user: User, chatInfo: ChatInfo) case userProfileNoChange(user: User) case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile) + case userPrivacy(user: User) case contactAliasUpdated(user: User, toContact: Contact) case connectionAliasUpdated(user: User, toConnection: PendingContactConnection) case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact) @@ -424,8 +461,8 @@ public enum ChatResponse: Decodable, Error { case contactConnectionDeleted(user: User, connection: PendingContactConnection) case versionInfo(versionInfo: CoreVersionInfo) case cmdOk(user: User?) - case chatCmdError(user: User?, chatError: ChatError) - case chatError(user: User?, chatError: ChatError) + case chatCmdError(user_: User?, chatError: ChatError) + case chatError(user_: User?, chatError: ChatError) public var responseType: String { get { @@ -456,6 +493,7 @@ public enum ChatResponse: Decodable, Error { case .chatCleared: return "chatCleared" case .userProfileNoChange: return "userProfileNoChange" case .userProfileUpdated: return "userProfileUpdated" + case .userPrivacy: return "userPrivacy" case .contactAliasUpdated: return "contactAliasUpdated" case .connectionAliasUpdated: return "connectionAliasUpdated" case .contactPrefsUpdated: return "contactPrefsUpdated" @@ -564,6 +602,7 @@ public enum ChatResponse: Decodable, Error { case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo)) case .userProfileNoChange: return noDetails case let .userProfileUpdated(u, _, toProfile): return withUser(u, String(describing: toProfile)) + case let .userPrivacy(u): return withUser(u, "") case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact)) 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))") @@ -653,6 +692,14 @@ public enum ChatResponse: Decodable, Error { } } +public struct UserPrivacyCfg: Encodable { + var currViewPwd: String + var showNtfs: Bool + var forceIncognito: Bool + var viewPwd: String + var wipePwd: String +} + public enum ChatPagination { case last(count: Int) case after(chatItemId: Int64, count: Int) @@ -1083,6 +1130,7 @@ public enum ChatError: Decodable { case errorAgent(agentError: AgentErrorType) case errorStore(storeError: StoreError) case errorDatabase(databaseError: DatabaseError) + case invalidJSON(json: String) } public enum ChatErrorType: Decodable { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index d88c4c581..a6a6c6895 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -22,18 +22,33 @@ public struct User: Decodable, NamedChat, Identifiable { public var image: String? { get { profile.image } } public var localAlias: String { get { "" } } + public var showNtfs: Bool + public var viewPwdHash: UserPwdHash? + public var id: Int64 { userId } + public var hidden: Bool { viewPwdHash != nil } + + public var showNotifications: Bool { + activeUser || showNtfs + } + public static let sampleData = User( userId: 1, userContactId: 1, localDisplayName: "alice", profile: LocalProfile.sampleData, fullPreferences: FullPreferences.sampleData, - activeUser: true + activeUser: true, + showNtfs: true ) } +public struct UserPwdHash: Decodable { + public var hash: String + public var salt: String +} + public struct UserInfo: Decodable, Identifiable { public var user: User public var unreadCount: Int diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 36f716223..5d5f1e355 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -22,5 +22,6 @@ extern char *chat_recv_msg(chat_ctrl ctl); extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait); extern char *chat_parse_markdown(char *str); extern char *chat_parse_server(char *str); +extern char *chat_password_hash(char *pwd, char *salt); extern char *chat_encrypt_media(char *key, char *frame, int len); extern char *chat_decrypt_media(char *key, char *frame, int len); diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f25ff03e6..00f3cc327 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -86,6 +86,7 @@ library Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id Simplex.Chat.Migrations.M20230303_group_link_role Simplex.Chat.Migrations.M20230304_file_description + Simplex.Chat.Migrations.M20230317_hidden_profiles Simplex.Chat.Mobile Simplex.Chat.Mobile.WebRTC Simplex.Chat.Options diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index d40e7ab30..a5b2f1bab 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -41,6 +41,7 @@ import qualified Data.Map.Strict as M import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList) import Data.Text (Text) import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime) import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDiffTimeToSeconds) import Data.Time.Clock.System (SystemTime, systemToUTCTime) @@ -195,7 +196,7 @@ activeAgentServers ChatConfig {defaultServers} srvSel = . map (\ServerCfg {server} -> server) . filter (\ServerCfg {enabled} -> enabled) -startChatController :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => Bool -> Bool -> m (Async ()) +startChatController :: forall m. ChatMonad' m => Bool -> Bool -> m (Async ()) startChatController subConns enableExpireCIs = do asks smpAgent >>= resumeAgentClient users <- fromRight [] <$> runExceptT (withStore' getUsers) @@ -227,7 +228,7 @@ startChatController subConns enableExpireCIs = do startExpireCIThread user setExpireCIFlag user True -subscribeUsers :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => [User] -> m () +subscribeUsers :: forall m. ChatMonad' m => [User] -> m () subscribeUsers users = do let (us, us') = partition activeUser users subscribe us @@ -236,7 +237,7 @@ subscribeUsers users = do subscribe :: [User] -> m () subscribe = mapM_ $ runExceptT . subscribeUserConnections Agent.subscribeConnections -restoreCalls :: (MonadUnliftIO m, MonadReader ChatController m) => m () +restoreCalls :: ChatMonad' m => m () restoreCalls = do savedCalls <- fromRight [] <$> runExceptT (withStore' $ \db -> getCalls db) let callsMap = M.fromList $ map (\call@Call {contactId} -> (contactId, call)) savedCalls @@ -260,7 +261,7 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, mapM_ hClose fs atomically $ writeTVar files M.empty -execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse +execChatCommand :: ChatMonad' m => ByteString -> m ChatResponse execChatCommand s = do u <- readTVarIO =<< asks currentUser case parseChatCommand s of @@ -308,27 +309,61 @@ processChatCommand = \case DefaultAgentServers {smp} <- asks $ defaultServers . config pure (smp, []) ListUsers -> CRUsersList <$> withStore' getUsersInfo - APISetActiveUser userId -> do - u <- asks currentUser - user <- withStore $ \db -> getSetActiveUser db userId + APISetActiveUser userId' viewPwd_ -> withUser $ \user -> do + user' <- privateGetUser userId' + validateUserPassword user user' viewPwd_ + withStore' $ \db -> setActiveUser db userId' setActive ActiveNone - atomically . writeTVar u $ Just user - pure $ CRActiveUser user - SetActiveUser uName -> withUserName uName APISetActiveUser - APIDeleteUser userId delSMPQueues -> do - user <- withStore (`getUser` userId) - when (activeUser user) $ throwChatError (CECantDeleteActiveUser userId) - users <- withStore' getUsers - -- shouldn't happen - last user should be active - when (length users == 1) $ throwChatError (CECantDeleteLastUser userId) - filesInfo <- withStore' (`getUserFileInfo` user) - withChatLock "deleteUser" . procCmd $ do - forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo - withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues - withStore' (`deleteUserRecord` user) - setActive ActiveNone - ok_ - DeleteUser uName delSMPQueues -> withUserName uName $ \uId -> APIDeleteUser uId delSMPQueues + let user'' = user' {activeUser = True} + asks currentUser >>= atomically . (`writeTVar` Just user'') + pure $ CRActiveUser user'' + SetActiveUser uName viewPwd_ -> do + tryError (withStore (`getUserIdByName` uName)) >>= \case + Left _ -> throwChatError CEUserUnknown + Right userId -> processChatCommand $ APISetActiveUser userId viewPwd_ + APIHideUser userId' (UserPwd viewPwd) -> withUser $ \_ -> do + user' <- privateGetUser userId' + case viewPwdHash user' of + Just _ -> throwChatError $ CEUserAlreadyHidden userId' + _ -> do + when (T.null viewPwd) $ throwChatError $ CEEmptyUserPassword userId' + users <- withStore' getUsers + unless (length (filter (isNothing . viewPwdHash) users) > 1) $ throwChatError $ CECantHideLastUser userId' + viewPwdHash' <- hashPassword + setUserPrivacy user' {viewPwdHash = viewPwdHash', showNtfs = False} + where + hashPassword = do + salt <- drgRandomBytes 16 + let hash = B64UrlByteString $ C.sha512Hash $ encodeUtf8 viewPwd <> salt + pure $ Just UserPwdHash {hash, salt = B64UrlByteString salt} + APIUnhideUser userId' viewPwd_ -> withUser $ \user -> do + user' <- privateGetUser userId' + case viewPwdHash user' of + Nothing -> throwChatError $ CEUserNotHidden userId' + _ -> do + validateUserPassword user user' viewPwd_ + setUserPrivacy user' {viewPwdHash = Nothing, showNtfs = True} + APIMuteUser userId' viewPwd_ -> withUser $ \user -> do + user' <- privateGetUser userId' + validateUserPassword user user' viewPwd_ + setUserPrivacy user' {showNtfs = False} + APIUnmuteUser userId' viewPwd_ -> withUser $ \user -> do + user' <- privateGetUser userId' + case viewPwdHash user' of + Just _ -> throwChatError $ CECantUnmuteHiddenUser userId' + _ -> do + validateUserPassword user user' viewPwd_ + setUserPrivacy user' {showNtfs = True} + HideUser viewPwd -> withUser $ \User {userId} -> processChatCommand $ APIHideUser userId viewPwd + UnhideUser -> withUser $ \User {userId} -> processChatCommand $ APIUnhideUser userId Nothing + MuteUser -> withUser $ \User {userId} -> processChatCommand $ APIMuteUser userId Nothing + UnmuteUser -> withUser $ \User {userId} -> processChatCommand $ APIUnmuteUser userId Nothing + APIDeleteUser userId' delSMPQueues viewPwd_ -> withUser $ \user -> do + user' <- privateGetUser userId' + validateUserPassword user user' viewPwd_ + checkDeleteChatUser user' + withChatLock "deleteUser" . procCmd $ deleteChatUser user' delSMPQueues + DeleteUser uName delSMPQueues viewPwd_ -> withUserName uName $ \userId -> APIDeleteUser userId delSMPQueues viewPwd_ StartChat subConns enableExpireCIs -> withUser' $ \_ -> asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning @@ -708,7 +743,7 @@ processChatCommand = \case assertDirectAllowed user MDSnd ct XCallInv_ calls <- asks currentCalls withChatLock "sendCallInvitation" $ do - callId <- CallId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) + callId <- CallId <$> drgRandomBytes 16 dhKeyPair <- if encryptedCall callType then Just <$> liftIO C.generateKeyPair' else pure Nothing let invitation = CallInvitation {callType, callDhPubKey = fst <$> dhKeyPair} callState = CallInvitationSent {localCallType = callType, localDhPrivKey = snd <$> dhKeyPair} @@ -1210,7 +1245,7 @@ processChatCommand = \case gInfo <- withStore $ \db -> getGroupInfo db user groupId assertUserGroupRole gInfo GRAdmin when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole - groupLinkId <- GroupLinkId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) + groupLinkId <- GroupLinkId <$> drgRandomBytes 16 let crClientData = encodeJSON $ CRDataGroup groupLinkId (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact $ Just crClientData withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole @@ -1426,7 +1461,7 @@ processChatCommand = \case withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case (Just contact, _) -> pure $ CRContactAlreadyExists user contact (_, xContactId_) -> procCmd $ do - let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) + let randomXContactId = XContactId <$> drgRandomBytes 16 xContactId <- maybe randomXContactId pure xContactId_ -- [incognito] generate profile to send -- if user makes a contact request using main profile, then turns on incognito mode and repeats the request, @@ -1584,6 +1619,42 @@ processChatCommand = \case <$> if live then pure Nothing else Just . addUTCTime (realToFrac ttl) <$> liftIO getCurrentTime + drgRandomBytes :: Int -> m ByteString + drgRandomBytes n = asks idsDrg >>= liftIO . (`randomBytes` n) + privateGetUser :: UserId -> m User + privateGetUser userId = + tryError (withStore (`getUser` userId)) >>= \case + Left _ -> throwChatError CEUserUnknown + Right user -> pure user + validateUserPassword :: User -> User -> Maybe UserPwd -> m () + validateUserPassword User {userId} User {userId = userId', viewPwdHash} viewPwd_ = + forM_ viewPwdHash $ \pwdHash -> + let pwdOk = case viewPwd_ of + Nothing -> userId == userId' + Just (UserPwd viewPwd) -> validPassword viewPwd pwdHash + in unless pwdOk $ throwChatError CEUserUnknown + validPassword :: Text -> UserPwdHash -> Bool + validPassword pwd UserPwdHash {hash = B64UrlByteString hash, salt = B64UrlByteString salt} = + hash == C.sha512Hash (encodeUtf8 pwd <> salt) + setUserPrivacy :: User -> m ChatResponse + setUserPrivacy user = do + asks currentUser >>= atomically . (`writeTVar` Just user) + withStore' (`updateUserPrivacy` user) + pure $ CRUserPrivacy user + checkDeleteChatUser :: User -> m () + checkDeleteChatUser user@User {userId} = do + when (activeUser user) $ throwChatError (CECantDeleteActiveUser userId) + users <- withStore' getUsers + unless (length users > 1 && (isJust (viewPwdHash user) || length (filter (isNothing . viewPwdHash) users) > 1)) $ + throwChatError (CECantDeleteLastUser userId) + setActive ActiveNone + deleteChatUser :: User -> Bool -> m ChatResponse + deleteChatUser user delSMPQueues = do + filesInfo <- withStore' (`getUserFileInfo` user) + forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo + withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues + withStore' (`deleteUserRecord` user) + ok_ assertDirectAllowed :: ChatMonad m => User -> MsgDirection -> Contact -> CMEventTag e -> m () assertDirectAllowed user dir ct event = @@ -1600,7 +1671,7 @@ assertDirectAllowed user dir ct event = XCallInv_ -> False _ -> True -startExpireCIThread :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => User -> m () +startExpireCIThread :: forall m. ChatMonad' m => User -> m () startExpireCIThread user@User {userId} = do expireThreads <- asks expireCIThreads atomically (TM.lookup userId expireThreads) >>= \case @@ -1619,12 +1690,12 @@ startExpireCIThread user@User {userId} = do forM_ ttl $ \t -> expireChatItems user t False threadDelay interval -setExpireCIFlag :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> m () +setExpireCIFlag :: ChatMonad' m => User -> Bool -> m () setExpireCIFlag User {userId} b = do expireFlags <- asks expireCIFlags atomically $ TM.insert userId b expireFlags -setAllExpireCIFlags :: (MonadUnliftIO m, MonadReader ChatController m) => Bool -> m () +setAllExpireCIFlags :: ChatMonad' m => Bool -> m () setAllExpireCIFlags b = do expireFlags <- asks expireCIFlags atomically $ do @@ -1841,7 +1912,7 @@ deleteGroupLink_ user gInfo conn = do deleteAgentConnectionAsync user $ aConnId conn withStore' $ \db -> deleteGroupLink db user gInfo -agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m () +agentSubscriber :: ChatMonad' m => m () agentSubscriber = do q <- asks $ subQ . smpAgent l <- asks chatLock @@ -2104,7 +2175,7 @@ processAgentMessageConn user _ agentConnId END = withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= \case RcvDirectMsgConnection _ (Just ct@Contact {localDisplayName = c}) -> do toView $ CRContactAnotherClient user ct - showToast (c <> "> ") "connected to another client" + whenUserNtfs user $ showToast (c <> "> ") "connected to another client" unsetActive $ ActiveC c entity -> toView $ CRSubscriptionEnd user entity processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do @@ -2237,8 +2308,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile) when (directOrUsed ct) $ createFeatureEnabledItems ct - setActive $ ActiveC c - showToast (c <> "> ") "connected" + whenUserNtfs user $ do + setActive $ ActiveC c + showToast (c <> "> ") "connected" forM_ groupLinkId $ \_ -> probeMatchingContacts ct $ contactConnIncognito ct forM_ viaUserContactLink $ \userContactLinkId -> withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case @@ -2368,13 +2440,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do let GroupInfo {groupProfile = GroupProfile {description}} = gInfo memberConnectedChatItem gInfo m forM_ description $ groupDescriptionChatItem gInfo m - setActive $ ActiveG gName - showToast ("#" <> gName) "you are connected to group" + whenUserNtfs user $ do + setActive $ ActiveG gName + showToast ("#" <> gName) "you are connected to group" GCInviteeMember -> do memberConnectedChatItem gInfo m toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected} - setActive $ ActiveG gName - showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected" + whenGroupNtfs user gInfo $ do + setActive $ ActiveG gName + showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected" intros <- withStore' $ \db -> createIntroductions db members m void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m forM_ intros $ \intro -> @@ -2622,7 +2696,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRAcceptingGroupJoinRequest user gInfo ct _ -> do toView $ CRReceivedContactRequest user cReq - showToast (localDisplayName <> "> ") "wants to connect to you" + whenUserNtfs user $ + showToast (localDisplayName <> "> ") "wants to connect to you" _ -> pure () incAuthErrCounter :: ConnectionEntity -> Connection -> AgentErrorType -> m () @@ -2703,8 +2778,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do memberConnectedChatItem gInfo m toView $ CRConnectedToGroupMember user gInfo m let g = groupName' gInfo - setActive $ ActiveG g - showToast ("#" <> g) $ "member " <> c <> " is connected" + whenGroupNtfs user gInfo $ do + setActive $ ActiveG g + showToast ("#" <> g) $ "member " <> c <> " is connected" probeMatchingContacts :: Contact -> Bool -> m () probeMatchingContacts ct connectedIncognito = do @@ -2730,7 +2806,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do messageError = toView . CRMessageError user "error" newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m () - newContentMessage ct@Contact {localDisplayName = c, contactUsed, chatSettings} mc msg@RcvMessage {sharedMsgId_} msgMeta = do + newContentMessage ct@Contact {localDisplayName = c, contactUsed} mc msg@RcvMessage {sharedMsgId_} msgMeta = do unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct checkIntegrityCreateItem (CDDirectRcv ct) msgMeta let ExtMsgContent content fileInvitation_ _ _ = mcExtMsgContent mc @@ -2744,7 +2820,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do live = fromMaybe False live_ ciFile_ <- processFileInvitation fileInvitation_ content $ \db -> createRcvFileTransfer db userId ct ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) ciFile_ timed_ live - when (enableNtfs chatSettings) $ do + whenContactNtfs user ct $ do showMsgToast (c <> "> ") content formattedText setActive $ ActiveC c where @@ -2811,7 +2887,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete" newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m () - newGroupContentMessage gInfo@GroupInfo {chatSettings} m@GroupMember {localDisplayName = c} mc msg@RcvMessage {sharedMsgId_} msgMeta = do + newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg@RcvMessage {sharedMsgId_} msgMeta = do let (ExtMsgContent content fInv_ _ _) = mcExtMsgContent mc if isVoice content && not (groupFeatureAllowed SGFVoice gInfo) then void $ newChatItem (CIRcvGroupFeatureRejected GFVoice) Nothing Nothing False @@ -2822,7 +2898,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ciFile_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) ciFile_ timed_ live let g = groupName' gInfo - when (enableNtfs chatSettings) $ do + whenGroupNtfs user gInfo $ do showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText setActive $ ActiveG g where @@ -2896,8 +2972,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) - showToast (c <> "> ") "wants to send a file" - setActive $ ActiveC c + whenContactNtfs user ct $ do + showToast (c <> "> ") "wants to send a file" + setActive $ ActiveC c -- TODO remove once XFile is discontinued processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m () @@ -2909,8 +2986,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False groupMsgToView gInfo m ci msgMeta let g = groupName' gInfo - showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file" - setActive $ ActiveG g + whenGroupNtfs user gInfo $ do + showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file" + setActive $ ActiveG g receiveInlineMode :: FileInvitation -> Maybe MsgContent -> Integer -> m (Maybe InlineFileMode) receiveInlineMode FileInvitation {fileSize, fileInline} mc_ chSize = case fileInline of @@ -3041,7 +3119,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRNewChatItem user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m () - processGroupInvitation ct@Contact {localDisplayName = c, activeConn = Connection {customUserProfileId, groupLinkId = groupLinkId'}} inv@GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} msg msgMeta = do + processGroupInvitation ct inv msg msgMeta = do + let Contact {localDisplayName = c, activeConn = Connection {customUserProfileId, groupLinkId = groupLinkId'}} = ct + GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv checkIntegrityCreateItem (CDDirectRcv ct) msgMeta when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId @@ -3061,7 +3141,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) toView $ CRReceivedGroupInvitation user gInfo ct memRole - showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group" + whenContactNtfs user ct $ + showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group" where sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool sameGroupLinkId (Just gli) (Just gli') = gli == gli' @@ -3888,17 +3969,26 @@ getCreateActiveUser st = do getWithPrompt :: String -> IO String getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine -showMsgToast :: (MonadUnliftIO m, MonadReader ChatController m) => Text -> MsgContent -> Maybe MarkdownList -> m () +whenUserNtfs :: ChatMonad' m => User -> m () -> m () +whenUserNtfs User {showNtfs, activeUser} = when $ showNtfs || activeUser + +whenContactNtfs :: ChatMonad' m => User -> Contact -> m () -> m () +whenContactNtfs user Contact {chatSettings} = whenUserNtfs user . when (enableNtfs chatSettings) + +whenGroupNtfs :: ChatMonad' m => User -> GroupInfo -> m () -> m () +whenGroupNtfs user GroupInfo {chatSettings} = whenUserNtfs user . when (enableNtfs chatSettings) + +showMsgToast :: ChatMonad' m => Text -> MsgContent -> Maybe MarkdownList -> m () showMsgToast from mc md_ = showToast from $ maybe (msgContentText mc) (mconcat . map hideSecret) md_ where hideSecret :: FormattedText -> Text hideSecret FormattedText {format = Just Secret} = "..." hideSecret FormattedText {text} = text -showToast :: (MonadUnliftIO m, MonadReader ChatController m) => Text -> Text -> m () +showToast :: ChatMonad' m => Text -> Text -> m () showToast title text = atomically . (`writeTBQueue` Notification {title, text}) =<< asks notifyQ -notificationSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m () +notificationSubscriber :: ChatMonad' m => m () notificationSubscriber = do ChatController {notifyQ, sendNotification} <- ask forever $ atomically (readTBQueue notifyQ) >>= liftIO . sendNotification @@ -3958,8 +4048,8 @@ withStoreCtx ctx_ action = do chatCommandP :: Parser ChatCommand chatCommandP = choice - [ "/mute " *> ((`ShowMessages` False) <$> chatNameP'), - "/unmute " *> ((`ShowMessages` True) <$> chatNameP'), + [ "/mute " *> ((`ShowMessages` False) <$> chatNameP), + "/unmute " *> ((`ShowMessages` True) <$> chatNameP), "/create user" *> ( do sameSmp <- (A.space *> "same_smp=" *> onOffP) <|> pure False @@ -3967,10 +4057,18 @@ chatCommandP = pure $ CreateActiveUser uProfile sameSmp ), "/users" $> ListUsers, - "/_user " *> (APISetActiveUser <$> A.decimal), - ("/user " <|> "/u ") *> (SetActiveUser <$> displayName), - "/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP), - "/delete user " *> (DeleteUser <$> displayName <*> pure True), + "/_user " *> (APISetActiveUser <$> A.decimal <*> optional (A.space *> jsonP)), + ("/user " <|> "/u ") *> (SetActiveUser <$> displayName <*> optional (A.space *> pwdP)), + "/_hide user " *> (APIHideUser <$> A.decimal <* A.space <*> jsonP), + "/_unhide user " *> (APIUnhideUser <$> A.decimal <*> optional (A.space *> jsonP)), + "/_mute user " *> (APIMuteUser <$> A.decimal <*> optional (A.space *> jsonP)), + "/_unmute user " *> (APIUnmuteUser <$> A.decimal <*> optional (A.space *> jsonP)), + "/hide user " *> (HideUser <$> pwdP), + "/unhide user" $> UnhideUser, + "/mute user" $> MuteUser, + "/unmute user" $> UnmuteUser, + "/_delete user " *> (APIDeleteUser <$> A.decimal <* " del_smp=" <*> onOffP <*> optional (A.space *> jsonP)), + "/delete user " *> (DeleteUser <$> displayName <*> pure True <*> optional (A.space *> pwdP)), ("/user" <|> "/u") $> ShowActiveUser, "/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP), "/_start" $> StartChat True True, @@ -4199,6 +4297,7 @@ chatCommandP = n <- (A.space *> A.takeByteString) <|> pure "" pure $ if B.null n then name else safeDecodeUtf8 n textP = safeDecodeUtf8 <$> A.takeByteString + pwdP = jsonP <|> (UserPwd . safeDecodeUtf8 <$> A.takeTill (== ' ')) msgTextP = jsonP <|> textP stringP = T.unpack . safeDecodeUtf8 <$> A.takeByteString filePath = stringP diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 103fbb057..6bb8169db 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -19,7 +19,7 @@ import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader import Crypto.Random (ChaChaDRG) -import Data.Aeson (FromJSON, ToJSON) +import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson as J import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) @@ -182,10 +182,18 @@ data ChatCommand = ShowActiveUser | CreateActiveUser Profile Bool | ListUsers - | APISetActiveUser UserId - | SetActiveUser UserName - | APIDeleteUser UserId Bool - | DeleteUser UserName Bool + | APISetActiveUser UserId (Maybe UserPwd) + | SetActiveUser UserName (Maybe UserPwd) + | APIHideUser UserId UserPwd + | APIUnhideUser UserId (Maybe UserPwd) + | APIMuteUser UserId (Maybe UserPwd) + | APIUnmuteUser UserId (Maybe UserPwd) + | HideUser UserPwd + | UnhideUser + | MuteUser + | UnmuteUser + | APIDeleteUser UserId Bool (Maybe UserPwd) + | DeleteUser UserName Bool (Maybe UserPwd) | StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool} | APIStopChat | APIActivateChat @@ -406,6 +414,7 @@ data ChatResponse | CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus | CRUserProfile {user :: User, profile :: Profile} | CRUserProfileNoChange {user :: User} + | CRUserPrivacy {user :: User} | CRVersionInfo {versionInfo :: CoreVersionInfo} | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation} | CRSentConfirmation {user :: User} @@ -522,6 +531,16 @@ instance ToJSON ChatResponse where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" +newtype UserPwd = UserPwd {unUserPwd :: Text} + deriving (Eq, Show) + +instance FromJSON UserPwd where + parseJSON v = UserPwd <$> parseJSON v + +instance ToJSON UserPwd where + toJSON (UserPwd p) = toJSON p + toEncoding (UserPwd p) = toEncoding p + newtype AgentQueueId = AgentQueueId QueueId deriving (Eq, Show) @@ -683,11 +702,17 @@ instance ToJSON ChatError where data ChatErrorType = CENoActiveUser | CENoConnectionUser {agentConnId :: AgentConnId} + | CEUserUnknown | CEActiveUserExists -- TODO delete | CEUserExists {contactName :: ContactName} | CEDifferentActiveUser {commandUserId :: UserId, activeUserId :: UserId} | CECantDeleteActiveUser {userId :: UserId} | CECantDeleteLastUser {userId :: UserId} + | CECantHideLastUser {userId :: UserId} + | CECantUnmuteHiddenUser {userId :: UserId} + | CEEmptyUserPassword {userId :: UserId} + | CEUserAlreadyHidden {userId :: UserId} + | CEUserNotHidden {userId :: UserId} | CEChatNotStarted | CEChatNotStopped | CEChatStoreChanged @@ -764,7 +789,9 @@ instance ToJSON SQLiteError where throwDBError :: ChatMonad m => DatabaseError -> m () throwDBError = throwError . ChatErrorDatabase -type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m) +type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) + +type ChatMonad m = (ChatMonad' m, MonadError ChatError m) chatCmdError :: Maybe User -> String -> ChatResponse chatCmdError user = CRChatCmdError user . ChatError . CECommandError diff --git a/src/Simplex/Chat/Migrations/M20230317_hidden_profiles.hs b/src/Simplex/Chat/Migrations/M20230317_hidden_profiles.hs new file mode 100644 index 000000000..27ae711a0 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230317_hidden_profiles.hs @@ -0,0 +1,14 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230317_hidden_profiles where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230317_hidden_profiles :: Query +m20230317_hidden_profiles = + [sql| +ALTER TABLE users ADD COLUMN view_pwd_hash BLOB; +ALTER TABLE users ADD COLUMN view_pwd_salt BLOB; +ALTER TABLE users ADD COLUMN show_ntfs INTEGER NOT NULL DEFAULT 1; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 5562bce7d..1cb392f0e 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -30,7 +30,10 @@ CREATE TABLE users( active_user INTEGER NOT NULL DEFAULT 0, created_at TEXT CHECK(created_at NOT NULL), updated_at TEXT CHECK(updated_at NOT NULL), - agent_user_id INTEGER CHECK(agent_user_id NOT NULL), -- 1 for active user + agent_user_id INTEGER CHECK(agent_user_id NOT NULL), + view_pwd_hash BLOB, + view_pwd_salt BLOB, + show_ntfs INTEGER NOT NULL DEFAULT 1, -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 172a0c179..bbd5a475e 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -12,12 +12,15 @@ import Control.Monad.Except import Control.Monad.Reader import Data.Aeson (ToJSON (..)) import qualified Data.Aeson as J +import qualified Data.ByteString.Base64.URL as U import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.Functor (($>)) import Data.List (find) import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Data.Word (Word8) import Database.SQLite.Simple (SQLError (..)) import qualified Database.SQLite.Simple as DB @@ -65,6 +68,8 @@ foreign export ccall "chat_parse_markdown" cChatParseMarkdown :: CString -> IO C foreign export ccall "chat_parse_server" cChatParseServer :: CString -> IO CJSONString +foreign export ccall "chat_password_hash" cChatPasswordHash :: CString -> CString -> IO CString + foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString @@ -122,6 +127,12 @@ cChatParseMarkdown s = newCAString . chatParseMarkdown =<< peekCAString s cChatParseServer :: CString -> IO CJSONString cChatParseServer s = newCAString . chatParseServer =<< peekCAString s +cChatPasswordHash :: CString -> CString -> IO CString +cChatPasswordHash cPwd cSalt = do + pwd <- peekCAString cPwd + salt <- peekCAString cSalt + newCAString $ chatPasswordHash pwd salt + mobileChatOpts :: String -> String -> ChatOpts mobileChatOpts dbFilePrefix dbKey = ChatOpts @@ -241,6 +252,12 @@ chatParseServer = LB.unpack . J.encode . toServerAddress . strDecode . B.pack enc :: StrEncoding a => a -> String enc = B.unpack . strEncode +chatPasswordHash :: String -> String -> String +chatPasswordHash pwd salt = either (const "") passwordHash salt' + where + salt' = U.decode $ B.pack salt + passwordHash = B.unpack . U.encode . C.sha512Hash . (encodeUtf8 (T.pack pwd) <>) + data APIResponse = APIResponse {corr :: Maybe CorrId, resp :: ChatResponse} deriving (Generic) diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 5abb876d5..56c8faf7b 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -39,6 +39,7 @@ module Simplex.Chat.Store getUserByContactRequestId, getUserFileInfo, deleteUserRecord, + updateUserPrivacy, createDirectConnection, createConnReqConnection, getProfileById, @@ -277,6 +278,7 @@ import Data.Functor (($>)) import Data.Int (Int64) import Data.List (sortBy, sortOn) import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, mapMaybe) import Data.Ord (Down (..)) import Data.Text (Text) @@ -345,6 +347,7 @@ import Simplex.Chat.Migrations.M20230118_recreate_smp_servers import Simplex.Chat.Migrations.M20230129_drop_chat_items_group_idx import Simplex.Chat.Migrations.M20230206_item_deleted_by_group_member_id import Simplex.Chat.Migrations.M20230303_group_link_role +import Simplex.Chat.Migrations.M20230317_hidden_profiles -- import Simplex.Chat.Migrations.M20230304_file_description import Simplex.Chat.Protocol import Simplex.Chat.Types @@ -412,7 +415,8 @@ schemaMigrations = ("20230118_recreate_smp_servers", m20230118_recreate_smp_servers), ("20230129_drop_chat_items_group_idx", m20230129_drop_chat_items_group_idx), ("20230206_item_deleted_by_group_member_id", m20230206_item_deleted_by_group_member_id), - ("20230303_group_link_role", m20230303_group_link_role) + ("20230303_group_link_role", m20230303_group_link_role), + ("20230317_hidden_profiles", m20230317_hidden_profiles) -- ("20230304_file_description", m20230304_file_description) ] @@ -449,8 +453,8 @@ createUserRecord db (AgentUserId auId) Profile {displayName, fullName, image, pr when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0" DB.execute db - "INSERT INTO users (agent_user_id, local_display_name, active_user, contact_id, created_at, updated_at) VALUES (?,?,?,0,?,?)" - (auId, displayName, activeUser, currentTs, currentTs) + "INSERT INTO users (agent_user_id, local_display_name, active_user, contact_id, show_ntfs, created_at, updated_at) VALUES (?,?,?,0,?,?,?)" + (auId, displayName, activeUser, True, currentTs, currentTs) userId <- insertedRowId db DB.execute db @@ -467,7 +471,7 @@ createUserRecord db (AgentUserId auId) Profile {displayName, fullName, image, pr (profileId, displayName, userId, True, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser (userId, auId, contactId, profileId, activeUser, displayName, fullName, image, userPreferences) + pure $ toUser $ (userId, auId, contactId, profileId, activeUser, displayName, fullName, image, userPreferences, True) :. (Nothing, Nothing) getUsersInfo :: DB.Connection -> IO [UserInfo] getUsersInfo db = getUsers db >>= mapM getUserInfo @@ -505,16 +509,19 @@ getUsers db = userQuery :: Query userQuery = [sql| - SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.local_display_name, ucp.full_name, ucp.image, ucp.preferences + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.local_display_name, ucp.full_name, ucp.image, ucp.preferences, u.show_ntfs, u.view_pwd_hash, u.view_pwd_salt 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 |] -toUser :: (UserId, UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences) -> User -toUser (userId, auId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences) = - let profile = LocalProfile {profileId, displayName, fullName, image, preferences = userPreferences, localAlias = ""} - in User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences = mergePreferences Nothing userPreferences} +toUser :: (UserId, UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences, Bool) :. (Maybe B64UrlByteString, Maybe B64UrlByteString) -> User +toUser ((userId, auId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences, showNtfs) :. (viewPwdHash_, viewPwdSalt_)) = + User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences, showNtfs, viewPwdHash} + where + profile = LocalProfile {profileId, displayName, fullName, image, preferences = userPreferences, localAlias = ""} + fullPreferences = mergePreferences Nothing userPreferences + viewPwdHash = UserPwdHash <$> viewPwdHash_ <*> viewPwdSalt_ setActiveUser :: DB.Connection -> UserId -> IO () setActiveUser db userId = do @@ -581,6 +588,19 @@ deleteUserRecord :: DB.Connection -> User -> IO () deleteUserRecord db User {userId} = DB.execute db "DELETE FROM users WHERE user_id = ?" (Only userId) +updateUserPrivacy :: DB.Connection -> User -> IO () +updateUserPrivacy db User {userId, showNtfs, viewPwdHash} = + DB.execute + db + [sql| + UPDATE users + SET view_pwd_hash = ?, view_pwd_salt = ?, show_ntfs = ? + WHERE user_id = ? + |] + (hashSalt viewPwdHash :. (showNtfs, userId)) + where + hashSalt = L.unzip . fmap (\UserPwdHash {hash, salt} -> (hash, salt)) + createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> IO PendingContactConnection createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId = do createdAt <- getCurrentTime diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 345688f1f..ba02c6034 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -110,11 +110,38 @@ data User = User localDisplayName :: ContactName, profile :: LocalProfile, fullPreferences :: FullPreferences, - activeUser :: Bool + activeUser :: Bool, + viewPwdHash :: Maybe UserPwdHash, + showNtfs :: Bool } deriving (Show, Generic, FromJSON) -instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON User where + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + +newtype B64UrlByteString = B64UrlByteString ByteString + deriving (Eq, Show) + +instance FromField B64UrlByteString where fromField f = B64UrlByteString <$> fromField f + +instance ToField B64UrlByteString where toField (B64UrlByteString m) = toField m + +instance StrEncoding B64UrlByteString where + strEncode (B64UrlByteString m) = strEncode m + strP = B64UrlByteString <$> strP + +instance FromJSON B64UrlByteString where + parseJSON = strParseJSON "B64UrlByteString" + +instance ToJSON B64UrlByteString where + toJSON = strToJSON + toEncoding = strToJEncoding + +data UserPwdHash = UserPwdHash {hash :: B64UrlByteString, salt :: B64UrlByteString} + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON UserPwdHash where toEncoding = J.genericToEncoding J.defaultOptions data UserInfo = UserInfo { user :: User, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 9f9524ccf..de787424a 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -116,6 +116,7 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus CRUserProfile u p -> ttyUser u $ viewUserProfile p CRUserProfileNoChange u -> ttyUser u ["user profile did not change"] + CRUserPrivacy u -> ttyUserPrefix u $ viewUserPrivacy u CRVersionInfo info -> viewVersionInfo logLevel info CRInvitation u cReq -> ttyUser u $ viewConnReqInvitation cReq CRSentConfirmation u -> ttyUser u ["confirmation sent!"] @@ -229,12 +230,16 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case CRAgentConnDeleted acId -> ["completed deleting connection, agent connection id: " <> sShow acId | logLevel <= CLLInfo] CRAgentUserDeleted auId -> ["completed deleting user" <> if logLevel <= CLLInfo then ", agent user id: " <> sShow auId else ""] CRMessageError u prefix err -> ttyUser u [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning] - CRChatCmdError u e -> ttyUser' u $ viewChatError logLevel e + CRChatCmdError u e -> ttyUserPrefix' u $ viewChatError logLevel e CRChatError u e -> ttyUser' u $ viewChatError logLevel e where ttyUser :: User -> [StyledString] -> [StyledString] - ttyUser _ [] = [] - ttyUser User {userId, localDisplayName = u} ss = prependFirst userPrefix ss + ttyUser user@User {showNtfs, activeUser} ss + | showNtfs || activeUser = ttyUserPrefix user ss + | otherwise = [] + ttyUserPrefix :: User -> [StyledString] -> [StyledString] + ttyUserPrefix _ [] = [] + ttyUserPrefix User {userId, localDisplayName = u} ss = prependFirst userPrefix ss where userPrefix = case user_ of Just User {userId = activeUserId} -> if userId /= activeUserId then prefix else "" @@ -242,6 +247,8 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case prefix = "[user: " <> highlight u <> "] " ttyUser' :: Maybe User -> [StyledString] -> [StyledString] ttyUser' = maybe id ttyUser + ttyUserPrefix' :: Maybe User -> [StyledString] -> [StyledString] + ttyUserPrefix' = maybe id ttyUserPrefix testViewChats :: [AChat] -> [StyledString] testViewChats chats = [sShow $ map toChatView chats] where @@ -293,14 +300,19 @@ chatItemDeletedText ci membership_ = deletedStateToText <$> chatItemDeletedState _ -> "" viewUsersList :: [UserInfo] -> [StyledString] -viewUsersList = map userInfo . sortOn ldn +viewUsersList = mapMaybe userInfo . sortOn ldn where ldn (UserInfo User {localDisplayName = n} _) = T.toLower n - userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName}, activeUser} count) = - ttyFullName n fullName <> active <> unread + userInfo (UserInfo User {localDisplayName = n, profile = LocalProfile {fullName}, activeUser, showNtfs, viewPwdHash} count) + | activeUser || isNothing viewPwdHash = Just $ ttyFullName n fullName <> infoStr + | otherwise = Nothing where - active = if activeUser then highlight' " (active)" else "" - unread = if count /= 0 then plain $ " (unread: " <> show count <> ")" else "" + infoStr = if null info then "" else " (" <> mconcat (intersperse ", " info) <> ")" + info = + [highlight' "active" | activeUser] + <> [highlight' "hidden" | isJust viewPwdHash] + <> ["muted" | not showNtfs] + <> [plain ("unread: " <> show count) | count /= 0] muted :: ChatInfo c -> ChatItem c d -> Bool muted chat ChatItem {chatDir} = case (chat, chatDir) of @@ -722,6 +734,12 @@ viewUserProfile Profile {displayName, fullName} = "(the updated profile will be sent to all your contacts)" ] +viewUserPrivacy :: User -> [StyledString] +viewUserPrivacy User {showNtfs, viewPwdHash} = + [ "user messages are " <> if showNtfs then "shown" else "hidden (use /tail to view)", + "user profile is " <> if isJust viewPwdHash then "hidden" else "visible" + ] + -- TODO make more generic messages or split viewSMPServers :: ProtocolTypeI p => [ServerCfg p] -> Bool -> [StyledString] viewSMPServers servers testView = @@ -1210,9 +1228,15 @@ viewChatError logLevel = \case CENoConnectionUser agentConnId -> ["error: message user not found, conn id: " <> sShow agentConnId | logLevel <= CLLError] CEActiveUserExists -> ["error: active user already exists"] CEUserExists name -> ["user with the name " <> ttyContact name <> " already exists"] + CEUserUnknown -> ["user does not exist or incorrect password"] CEDifferentActiveUser commandUserId activeUserId -> ["error: different active user, command user id: " <> sShow commandUserId <> ", active user id: " <> sShow activeUserId] CECantDeleteActiveUser _ -> ["cannot delete active user"] CECantDeleteLastUser _ -> ["cannot delete last user"] + CECantHideLastUser _ -> ["cannot hide the only not hidden user"] + CECantUnmuteHiddenUser _ -> ["cannot unmute hidden user"] + CEEmptyUserPassword _ -> ["cannot set empty password"] + CEUserAlreadyHidden _ -> ["user is already hidden"] + CEUserNotHidden _ -> ["user is not hidden"] CEChatNotStarted -> ["error: chat not started"] CEChatNotStopped -> ["error: chat not stopped"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"] diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index b18b43c8d..6116eeb29 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -62,6 +62,7 @@ chatDirectTests = do it "chat items only expire for users who configured expiration" testEnableCIExpirationOnlyForOneUser it "disabling chat item expiration doesn't disable it for other users" testDisableCIExpirationOnlyForOneUser it "both users have configured timed messages with contacts, messages expire, restart" testUsersTimedMessages + it "user profile privacy: hide profiles and notificaitons" testUserPrivacy describe "chat item expiration" $ do it "set chat item TTL" testSetChatItemTTL describe "queue rotation" $ do @@ -787,13 +788,13 @@ testMuteContact = connectUsers alice bob alice #> "@bob hello" bob <# "alice> hello" - bob ##> "/mute alice" + bob ##> "/mute @alice" bob <## "ok" alice #> "@bob hi" (bob "/contacts" bob <## "alice (Alice) (muted, you can /unmute @alice)" - bob ##> "/unmute alice" + bob ##> "/unmute @alice" bob <## "ok" bob ##> "/contacts" bob <## "alice (Alice)" @@ -1502,6 +1503,104 @@ testUsersTimedMessages tmp = do alice <## ("Disappearing messages: enabled (you allow: yes (" <> ttl <> " sec), contact allows: yes (" <> ttl <> " sec))") alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") -- to remove feature items +testUserPrivacy :: HasCallStack => FilePath -> IO () +testUserPrivacy = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice ##> "/create user alisa" + showActiveUser alice "alisa" + -- connect using second user + connectUsers alice bob + alice #> "@bob hello" + bob <# "alisa> hello" + bob #> "@alisa hey" + alice <# "bob> hey" + -- hide user profile + alice ##> "/hide user my_password" + userHidden alice + -- shows messages when active + bob #> "@alisa hello again" + alice <# "bob> hello again" + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + -- does not show messages to user + bob #> "@alisa this won't show" + (alice "/users" + alice <## "alice (Alice) (active)" + (alice "/user alisa" + alice <## "user does not exist or incorrect password" + alice ##> "/user alisa wrong_password" + alice <## "user does not exist or incorrect password" + alice ##> "/user alisa my_password" + showActiveUser alice "alisa" + -- shows hidden user when active + alice ##> "/users" + alice <## "alice (Alice)" + alice <## "alisa (active, hidden, muted)" + -- hidden message is saved + alice ##> "/tail" + alice + <##? [ "bob> Disappearing messages: off", + "bob> Full deletion: off", + "bob> Voice messages: enabled", + "@bob hello", + "bob> hey", + "bob> hello again", + "bob> this won't show" + ] + -- change profile password + alice ##> "/unmute user" + alice <## "cannot unmute hidden user" + alice ##> "/hide user password" + alice <## "user is already hidden" + alice ##> "/unhide user" + userVisible alice + alice ##> "/hide user new_password" + userHidden alice + alice ##> "/_delete user 1 del_smp=on" + alice <## "cannot delete last user" + alice ##> "/_hide user 1 \"password\"" + alice <## "cannot hide the only not hidden user" + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + -- change profile privacy for inactive user via API requires correct password + alice ##> "/_unmute user 2" + alice <## "cannot unmute hidden user" + alice ##> "/_hide user 2 \"password\"" + alice <## "user is already hidden" + alice ##> "/_unhide user 2" + alice <## "user does not exist or incorrect password" + alice ##> "/_unhide user 2 \"wrong_password\"" + alice <## "user does not exist or incorrect password" + alice ##> "/_unhide user 2 \"new_password\"" + userVisible alice + alice ##> "/_hide user 2 \"another_password\"" + userHidden alice + -- check new password + alice ##> "/user alisa another_password" + showActiveUser alice "alisa" + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice ##> "/_delete user 2 del_smp=on" + alice <## "user does not exist or incorrect password" + alice ##> "/_delete user 2 del_smp=on \"wrong_password\"" + alice <## "user does not exist or incorrect password" + alice ##> "/_delete user 2 del_smp=on \"another_password\"" + alice <## "ok" + alice <## "completed deleting user" + where + userHidden alice = do + alice <## "user messages are hidden (use /tail to view)" + alice <## "user profile is hidden" + userVisible alice = do + alice <## "user messages are shown" + alice <## "user profile is visible" + testSetChatItemTTL :: HasCallStack => FilePath -> IO () testSetChatItemTTL = testChat2 aliceProfile bobProfile $ diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 83e91f044..432f2c024 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -25,16 +25,16 @@ noActiveUser = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"e activeUserExists :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUserExists = "{\"resp\":{\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true},\"chatError\":{\"error\":{\"errorType\":{\"userExists\":{\"contactName\":\"alice\"}}}}}}}" +activeUserExists = "{\"resp\":{\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true},\"chatError\":{\"error\":{\"errorType\":{\"userExists\":{\"contactName\":\"alice\"}}}}}}}" #else -activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" +activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" #endif activeUser :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}}}}" +activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true}}}}" #else -activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}}}" +activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true}}}" #endif chatStarted :: String @@ -73,7 +73,7 @@ pendingSubSummary = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <> #endif userJSON :: String -userJSON = "\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true}" +userJSON = "\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"no\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true}" parsedMarkdown :: String #if defined(darwin_HOST_OS) && defined(swiftJSON)