core, iOS: hidden and muted user profiles (#2025)

* core, ios: profile privacy design

* migration

* core: user profile privacy

* update nix dependencies

* update simplexmq

* import stateTVar

* update core library

* update UI

* update hide/show user profile

* update API, UI, fix test

* update api, UI, test

* update api call

* fix api

* update UI for hidden profiles

* filter notifications on hidden/muted profiles when inactive, alerts

* updates

* update schema, test, icon
This commit is contained in:
Evgeny Poberezkin 2023-03-22 15:58:01 +00:00 committed by GitHub
parent bcdf502ce6
commit 06a0dbd0f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1067 additions and 228 deletions

View File

@ -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
}

View File

@ -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 {

View File

@ -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<Void, Never>?
private var receiveMessages = true
@ -1050,8 +1155,7 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.removeChat(contact.activeConn.id)
}
case let .receivedContactRequest(user, contactRequest):
if !active(user) { return }
if active(user) {
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
if m.hasChat(contactRequest.id) {
m.updateChatInfo(cInfo)
@ -1060,8 +1164,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
chatInfo: cInfo,
chatItems: []
))
NtfManager.shared.notifyContactRequest(user, contactRequest)
}
}
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 {

View File

@ -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 {
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 {

View File

@ -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,16 +99,20 @@ struct UserPicker: View {
userPickerVisible.toggle()
}
} else {
Task {
do {
try changeActiveUser_(user.userId)
userPickerVisible = false
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: {
HStack(spacing: 0) {
ProfileImage(imageStr: user.image, color: Color(uiColor: .tertiarySystemFill))

View File

@ -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

View File

@ -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 {

View File

@ -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))

View File

@ -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))
}
}

View File

@ -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

View File

@ -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 {

View File

@ -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,17 +53,30 @@ 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 {
if m.users.count > 1 && (m.users[i].user.hidden || visibleUsersCount > 1) {
showDeleteConfirmation = true
userToDelete = i
userToDelete = users[i]
} else {
alert = .cantDeleteLastUser
}
}
}
if searchTextOrPassword == "" {
NavigationLink {
CreateProfile()
} label: {
@ -59,27 +84,72 @@ struct UserProfilesView: View {
}
.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 {
Task {
do {
try changeActiveUser_(user.userId)
try await changeActiveUserAsync_(user.userId, viewPwd: userViewPassword(user))
} catch {
alert = .activateUserError(error: responseError(error))
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 {

View File

@ -16,7 +16,7 @@ let logger = Logger()
let suspendingDelay: UInt64 = 2_000_000_000
typealias NtfStream = AsyncStream<UNMutableNotificationContent>
typealias NtfStream = AsyncStream<NSENotification>
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")
if case let .nse(notification) = ntf {
notification.badge = badgeCount as NSNumber
bestAttemptNtf = .nse(notification: notification)
} else {
bestAttemptNtf = ntf
bestAttemptNtf?.badge = badgeCount as NSNumber
}
}
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

View File

@ -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 = "<group>"; };
5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyCodeView.swift; sourceTree = "<group>"; };
5CBE6C132944CC12002D9531 /* ScanCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanCodeView.swift; sourceTree = "<group>"; };
5CC036DF29C488D500C0EF20 /* HiddenProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenProfileView.swift; sourceTree = "<group>"; };
5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = "<group>"; };
5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = "<group>"; };
5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
@ -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",

View File

@ -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,10 +190,19 @@ func prettyJSON(_ obj: Any) -> String? {
public func responseError(_ err: Error) -> String {
if let r = err as? ChatResponse {
return String(describing: r)
} else {
return err.localizedDescription
switch r {
case let .chatCmdError(_, chatError): return chatErrorString(chatError)
case let .chatError(_, chatError): return chatErrorString(chatError)
default: return String(describing: r)
}
} else {
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 {

View File

@ -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 {

View File

@ -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

View File

@ -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);

View File

@ -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

View File

@ -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)
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
-- 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
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,6 +2308,7 @@ 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
whenUserNtfs user $ do
setActive $ ActiveC c
showToast (c <> "> ") "connected"
forM_ groupLinkId $ \_ -> probeMatchingContacts ct $ contactConnIncognito ct
@ -2368,11 +2440,13 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
let GroupInfo {groupProfile = GroupProfile {description}} = gInfo
memberConnectedChatItem gInfo m
forM_ description $ groupDescriptionChatItem gInfo m
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}
whenGroupNtfs user gInfo $ do
setActive $ ActiveG gName
showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected"
intros <- withStore' $ \db -> createIntroductions db members m
@ -2622,6 +2696,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
toView $ CRAcceptingGroupJoinRequest user gInfo ct
_ -> do
toView $ CRReceivedContactRequest user cReq
whenUserNtfs user $
showToast (localDisplayName <> "> ") "wants to connect to you"
_ -> pure ()
@ -2703,6 +2778,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
memberConnectedChatItem gInfo m
toView $ CRConnectedToGroupMember user gInfo m
let g = groupName' gInfo
whenGroupNtfs user gInfo $ do
setActive $ ActiveG g
showToast ("#" <> g) $ "member " <> c <> " is connected"
@ -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,6 +2972,7 @@ 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)
whenContactNtfs user ct $ do
showToast (c <> "> ") "wants to send a file"
setActive $ ActiveC c
@ -2909,6 +2986,7 @@ 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
whenGroupNtfs user gInfo $ do
showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file"
setActive $ ActiveG g
@ -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,6 +3141,7 @@ 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
whenContactNtfs user ct $
showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group"
where
sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool
@ -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

View File

@ -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

View File

@ -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;
|]

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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"]

View File

@ -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 </)
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 </)
-- does not show hidden user
alice ##> "/users"
alice <## "alice (Alice) (active)"
(alice </)
-- requires password to switch to the user
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 $

View File

@ -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)