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
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,18 +1155,18 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.removeChat(contact.activeConn.id)
}
case let .receivedContactRequest(user, contactRequest):
if !active(user) { return }
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
if m.hasChat(contactRequest.id) {
m.updateChatInfo(cInfo)
} else {
m.addChat(Chat(
chatInfo: cInfo,
chatItems: []
))
NtfManager.shared.notifyContactRequest(user, contactRequest)
if active(user) {
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
if m.hasChat(contactRequest.id) {
m.updateChatInfo(cInfo)
} else {
m.addChat(Chat(
chatInfo: cInfo,
chatItems: []
))
}
}
NtfManager.shared.notifyContactRequest(user, contactRequest)
case let .contactUpdated(user, toContact):
if active(user) && m.hasChat(toContact.id) {
let cInfo = ChatInfo.direct(contact: toContact)
@@ -1304,7 +1409,7 @@ func refreshCallInvitations() throws {
let invitation = m.callInvitations.removeValue(forKey: chatId) {
m.ntfCallInvitationAction = nil
CallController.shared.callAction(invitation: invitation, action: ntfAction)
} else if let invitation = callInvitations.last {
} else if let invitation = callInvitations.last(where: { $0.user.showNotifications }) {
activateCall(invitation)
}
}
@@ -1317,6 +1422,7 @@ func justRefreshCallInvitations() throws -> [RcvCallInvitation] {
}
func activateCall(_ callInvitation: RcvCallInvitation) {
if !callInvitation.user.showNotifications { return }
let m = ChatModel.shared
CallController.shared.reportNewIncomingCall(invitation: callInvitation) { error in
if let error = error {

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 {
let update = cxCallUpdate(invitation: invitation)
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
if invitation.callTs.timeIntervalSinceNow >= -180 {
let update = cxCallUpdate(invitation: invitation)
provider.reportNewIncomingCall(with: uuid, update: update, completion: completion)
}
} else {
NtfManager.shared.notifyCallInvitation(invitation)
if invitation.callTs.timeIntervalSinceNow >= -180 {

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,14 +99,18 @@ struct UserPicker: View {
userPickerVisible.toggle()
}
} else {
do {
try changeActiveUser_(user.userId)
userPickerVisible = false
} catch {
AlertManager.shared.showAlertMsg(
title: "Error switching profile!",
message: "Error: \(responseError(error))"
)
Task {
do {
try await changeActiveUserAsync_(user.userId, viewPwd: nil)
await MainActor.run { userPickerVisible = false }
} catch {
await MainActor.run {
AlertManager.shared.showAlertMsg(
title: "Error switching profile!",
message: "Error: \(responseError(error))"
)
}
}
}
}
}, label: {

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,45 +53,103 @@ struct UserProfilesView: View {
private func userProfilesView() -> some View {
List {
if profileHidden {
Button {
withAnimation { profileHidden = false }
} label: {
Label("Enter password above to show!", systemImage: "lock.open")
}
}
Section {
ForEach(m.users) { u in
let users = filteredUsers()
ForEach(users) { u in
userView(u.user)
}
.onDelete { indexSet in
if let i = indexSet.first {
showDeleteConfirmation = true
userToDelete = i
if m.users.count > 1 && (m.users[i].user.hidden || visibleUsersCount > 1) {
showDeleteConfirmation = true
userToDelete = users[i]
} else {
alert = .cantDeleteLastUser
}
}
}
NavigationLink {
CreateProfile()
} label: {
Label("Add profile", systemImage: "plus")
if searchTextOrPassword == "" {
NavigationLink {
CreateProfile()
} label: {
Label("Add profile", systemImage: "plus")
}
.frame(height: 44)
.padding(.vertical, 4)
}
.frame(height: 44)
.padding(.vertical, 4)
} footer: {
Text("Your chat profiles are stored locally, only on your device.")
Text("Tap to activate profile.")
.font(.body)
.padding(.top, 8)
}
}
.toolbar { EditButton() }
.navigationTitle("Your chat profiles")
.searchable(text: $searchTextOrPassword, placement: .navigationBarDrawer(displayMode: .always))
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.onAppear {
if showHiddenProfilesNotice && m.users.count > 1 {
alert = .hiddenProfilesNotice
}
}
.confirmationDialog("Delete chat profile?", isPresented: $showDeleteConfirmation, titleVisibility: .visible) {
deleteModeButton("Profile and server connections", true)
deleteModeButton("Local profile data only", false)
}
.sheet(item: $selectedUser) { user in
HiddenProfileView(user: user, profileHidden: $profileHidden)
}
.onChange(of: profileHidden) { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
withAnimation { profileHidden = false }
}
}
.alert(item: $alert) { alert in
switch alert {
case let .deleteUser(index, delSMPQueues):
case let .deleteUser(userInfo, delSMPQueues):
return Alert(
title: Text("Delete user profile?"),
message: Text("All chats and messages will be deleted - this cannot be undone!"),
primaryButton: .destructive(Text("Delete")) {
removeUser(index, delSMPQueues)
Task { await removeUser(userInfo, delSMPQueues) }
},
secondaryButton: .cancel()
)
case .cantDeleteLastUser:
return Alert(
title: Text("Can't delete user profile!"),
message: m.users.count > 1
? Text("There should be at least one visible user profile.")
: Text("There should be at least use user profile.")
)
case .hiddenProfilesNotice:
return Alert(
title: Text("Make profile private!"),
message: Text("You can hide or mute a user profile - swipe it to the right.\nSimpleX Lock must be enabled."),
primaryButton: .default(Text("Don't show again")) {
showHiddenProfilesNotice = false
},
secondaryButton: .default(Text("Ok"))
)
case .muteProfileAlert:
return Alert(
title: Text("Muted when inactive!"),
message: Text("You will still receive calls and notifications from muted profiles when they are active."),
primaryButton: .default(Text("Don't show again")) {
showMuteProfileAlert = false
},
secondaryButton: .default(Text("Ok"))
)
case let .activateUserError(error: err):
return Alert(
title: Text("Error switching profile!"),
@@ -91,43 +161,66 @@ struct UserProfilesView: View {
}
}
private func filteredUsers() -> [UserInfo] {
let s = searchTextOrPassword.trimmingCharacters(in: .whitespaces)
let lower = s.localizedLowercase
return m.users.filter { u in
if (u.user.activeUser || u.user.viewPwdHash == nil) && (s == "" || u.user.chatViewName.localizedLowercase.contains(lower)) {
return true
}
if let ph = u.user.viewPwdHash {
return s != "" && chatPasswordHash(s, ph.salt) == ph.hash
}
return false
}
}
private var visibleUsersCount: Int {
m.users.filter({ u in !u.user.hidden }).count
}
private func userViewPassword(_ user: User) -> String? {
user.activeUser || !user.hidden ? nil : searchTextOrPassword
}
private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View {
Button(title, role: .destructive) {
if let i = userToDelete {
alert = .deleteUser(index: i, delSMPQueues: delSMPQueues)
if let userInfo = userToDelete {
alert = .deleteUser(userInfo: userInfo, delSMPQueues: delSMPQueues)
}
}
}
private func removeUser(_ index: Int, _ delSMPQueues: Bool) {
if index >= m.users.count { return }
private func removeUser(_ userInfo: UserInfo, _ delSMPQueues: Bool) async {
do {
let u = m.users[index].user
let u = userInfo.user
if u.activeUser {
if let newActive = m.users.first(where: { !$0.user.activeUser }) {
try changeActiveUser_(newActive.user.userId)
try deleteUser(u.userId)
if let newActive = m.users.first(where: { u in !u.user.activeUser && !u.user.hidden }) {
try await changeActiveUserAsync_(newActive.user.userId, viewPwd: nil)
try await deleteUser(u)
}
} else {
try deleteUser(u.userId)
try await deleteUser(u)
}
} catch let error {
let a = getErrorAlert(error, "Error deleting user profile")
alert = .error(title: a.title, error: a.message)
}
func deleteUser(_ userId: Int64) throws {
try apiDeleteUser(userId, delSMPQueues)
m.users.remove(at: index)
func deleteUser(_ user: User) async throws {
try await apiDeleteUser(user.userId, delSMPQueues, viewPwd: userViewPassword(user))
await MainActor.run { withAnimation { m.removeUser(user) } }
}
}
private func userView(_ user: User) -> some View {
Button {
do {
try changeActiveUser_(user.userId)
} catch {
alert = .activateUserError(error: responseError(error))
Task {
do {
try await changeActiveUserAsync_(user.userId, viewPwd: userViewPassword(user))
} catch {
await MainActor.run { alert = .activateUserError(error: responseError(error)) }
}
}
} label: {
HStack {
@@ -137,14 +230,75 @@ struct UserProfilesView: View {
.padding(.trailing, 12)
Text(user.chatViewName)
Spacer()
Image(systemName: "checkmark")
.foregroundColor(user.activeUser ? .primary : .clear)
if user.activeUser {
Image(systemName: "checkmark").foregroundColor(.primary)
} else if user.hidden {
Image(systemName: "lock").foregroundColor(.secondary)
} else if !user.showNtfs {
Image(systemName: "speaker.slash").foregroundColor(.secondary)
} else {
Image(systemName: "checkmark").foregroundColor(.clear)
}
}
}
.disabled(user.activeUser)
.foregroundColor(.primary)
.deleteDisabled(m.users.count <= 1)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
if user.hidden {
Button("Unhide") {
setUserPrivacy(user) { try await apiUnhideUser(user.userId, viewPwd: userViewPassword(user)) }
}
.tint(.green)
} else {
if visibleUsersCount > 1 && prefPerformLA {
Button("Hide") {
selectedUser = user
}
.tint(.gray)
}
Group {
if user.showNtfs {
Button("Mute") {
setUserPrivacy(user, successAlert: showMuteProfileAlert ? .muteProfileAlert : nil) {
try await apiMuteUser(user.userId, viewPwd: userViewPassword(user))
}
}
} else {
Button("Unmute") {
setUserPrivacy(user) { try await apiUnmuteUser(user.userId, viewPwd: userViewPassword(user)) }
}
}
}
.tint(.accentColor)
}
}
}
private func setUserPrivacy(_ user: User, successAlert: UserProfilesAlert? = nil, _ api: @escaping () async throws -> User) {
Task {
do {
let u = try await api()
await MainActor.run {
withAnimation { m.updateUser(u) }
if successAlert != nil {
alert = successAlert
}
}
} catch let error {
let a = getErrorAlert(error, "Error updating user privacy")
alert = .error(title: a.title, error: a.message)
}
}
}
}
public func chatPasswordHash(_ pwd: String, _ salt: String) -> String {
var cPwd = pwd.cString(using: .utf8)!
var cSalt = salt.cString(using: .utf8)!
let cHash = chat_password_hash(&cPwd, &cSalt)!
let hash = fromCString(cHash)
return hash
}
struct UserProfilesView_Previews: PreviewProvider {

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")
bestAttemptNtf = ntf
bestAttemptNtf?.badge = badgeCount as NSNumber
if case let .nse(notification) = ntf {
notification.badge = badgeCount as NSNumber
bestAttemptNtf = .nse(notification: notification)
} else {
bestAttemptNtf = ntf
}
}
private func deliverBestAttemptNtf() {
logger.debug("NotificationService.deliverBestAttemptNtf")
if let handler = contentHandler, let content = bestAttemptNtf {
handler(content)
if let handler = contentHandler, let ntf = bestAttemptNtf {
switch ntf {
case let .nse(content): handler(content)
case let .callkit(invitation):
CXProvider.reportNewIncomingVoIPPushPayload([
"displayName": invitation.contact.displayName,
"contactId": invitation.contact.id,
"media": invitation.callType.media.rawValue
]) { error in
if error == nil {
handler(UNMutableNotificationContent())
} else {
logger.debug("reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)")
handler(createCallInvitationNtf(invitation))
}
}
case .empty: handler(UNMutableNotificationContent())
}
bestAttemptNtf = nil
}
}
@@ -211,15 +253,15 @@ func chatRecvMsg() async -> ChatResponse? {
private let isInChina = SKStorefront().countryCode == "CHN"
private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() }
func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotificationContent)? {
func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
logger.debug("NotificationService processReceivedMsg: \(res.responseType)")
switch res {
case let .contactConnected(user, contact, _):
return (contact.id, createContactConnectedNtf(user, contact))
return (contact.id, .nse(notification: createContactConnectedNtf(user, contact)))
// case let .contactConnecting(contact):
// TODO profile update
case let .receivedContactRequest(user, contactRequest):
return (UserContact(contactRequest: contactRequest).id, createContactRequestNtf(user, contactRequest))
return (UserContact(contactRequest: contactRequest).id, .nse(notification: createContactRequestNtf(user, contactRequest)))
case let .newChatItem(user, aChatItem):
let cInfo = aChatItem.chatInfo
var cItem = aChatItem.chatItem
@@ -240,23 +282,13 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification
cItem = apiReceiveFile(fileId: file.fileId)?.chatItem ?? cItem
}
}
return cItem.showMutableNotification ? (aChatItem.chatId, createMessageReceivedNtf(user, cInfo, cItem)) : nil
return cItem.showMutableNotification ? (aChatItem.chatId, .nse(notification: createMessageReceivedNtf(user, cInfo, cItem))) : nil
case let .callInvitation(invitation):
// Do not post it without CallKit support, iOS will stop launching the app without showing CallKit
if useCallKit() {
do {
try await CXProvider.reportNewIncomingVoIPPushPayload([
"displayName": invitation.contact.displayName,
"contactId": invitation.contact.id,
"media": invitation.callType.media.rawValue
])
logger.debug("reportNewIncomingVoIPPushPayload success to CallController for \(invitation.contact.id)")
return (invitation.contact.id, (UNNotificationContent().mutableCopy() as! UNMutableNotificationContent))
} catch let error {
logger.error("reportNewIncomingVoIPPushPayload error \(String(describing: error), privacy: .public)")
}
}
return (invitation.contact.id, createCallInvitationNtf(invitation))
return (
invitation.contact.id,
useCallKit() ? .callkit(invitation: invitation) : .nse(notification: createCallInvitationNtf(invitation))
)
default:
logger.debug("NotificationService processReceivedMsg ignored event: \(res.responseType)")
return nil

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,12 +190,21 @@ func prettyJSON(_ obj: Any) -> String? {
public func responseError(_ err: Error) -> String {
if let r = err as? ChatResponse {
return String(describing: r)
switch r {
case let .chatCmdError(_, chatError): return chatErrorString(chatError)
case let .chatError(_, chatError): return chatErrorString(chatError)
default: return String(describing: r)
}
} else {
return err.localizedDescription
return String(describing: err)
}
}
func chatErrorString(_ err: ChatError) -> String {
if case let .invalidJSON(json) = err { return json }
return String(describing: err)
}
public enum DBMigrationResult: Decodable, Equatable {
case ok
case errorNotADatabase(dbFile: String)

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