diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 600678b0e6..3c2d3ef6fe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,7 +5,7 @@ on: branches: - master - stable - - sqlcipher + - users tags: - "v*" pull_request: diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 438e5ddd50..d8411ecc3d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -35,6 +35,7 @@ import kotlin.time.* class ChatModel(val controller: ChatController) { val onboardingStage = mutableStateOf(null) val currentUser = mutableStateOf(null) + val users = mutableStateListOf() val userCreated = mutableStateOf(null) val chatRunning = mutableStateOf(null) val chatDbChanged = mutableStateOf(false) @@ -42,6 +43,8 @@ class ChatModel(val controller: ChatController) { val chatDbStatus = mutableStateOf(null) val chatDbDeleted = mutableStateOf(false) val chats = mutableStateListOf() + // map of connections network statuses, key is agent connection id + val networkStatuses = mutableStateMapOf() // current chat val chatId = mutableStateOf(null) @@ -87,13 +90,6 @@ class ChatModel(val controller: ChatController) { val filesToDelete = mutableSetOf() val simplexLinkMode = mutableStateOf(controller.appPrefs.simplexLinkMode.get()) - fun updateUserProfile(profile: LocalProfile) { - val user = currentUser.value - if (user != null) { - currentUser.value = user.copy(profile = profile) - } - } - fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id } fun getContactChat(contactId: Long): Chat? = chats.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } @@ -120,17 +116,8 @@ class ChatModel(val controller: ChatController) { } fun updateChats(newChats: List) { - val mergedChats = arrayListOf() - for (newChat in newChats) { - val i = getChatIndex(newChat.chatInfo.id) - if (i >= 0) { - mergedChats.add(newChat.copy(serverInfo = chats[i].serverInfo)) - } else { - mergedChats.add(newChat) - } - } chats.clear() - chats.addAll(mergedChats) + chats.addAll(newChats) val cId = chatId.value // If chat is null, it was deleted in background after apiGetChats call @@ -139,14 +126,6 @@ class ChatModel(val controller: ChatController) { } } - fun updateNetworkStatus(id: ChatId, status: Chat.NetworkStatus) { - val i = getChatIndex(id) - if (i >= 0) { - val chat = chats[i] - chats[i] = chat.copy(serverInfo = chat.serverInfo.copy(networkStatus = status)) - } - } - fun replaceChat(id: String, chat: Chat) { val i = getChatIndex(id) if (i >= 0) { @@ -168,6 +147,7 @@ class ChatModel(val controller: ChatController) { chatStats = if (cItem.meta.itemStatus is CIStatus.RcvNew) { val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId + increaseUnreadCounter(currentUser.value!!) chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, minUnreadItemId = minUnreadId) } else @@ -256,6 +236,7 @@ class ChatModel(val controller: ChatController) { // clear preview val i = getChatIndex(cInfo.id) if (i >= 0) { + decreaseUnreadCounter(currentUser.value!!, chats[i].chatStats.unreadCount) chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo) } // clear current chat @@ -264,6 +245,19 @@ class ChatModel(val controller: ChatController) { } } + fun updateCurrentUser(newProfile: Profile, preferences: FullChatPreferences? = null) { + val current = currentUser.value ?: return + val updated = current.copy( + profile = newProfile.toLocalProfile(current.profile.profileId), + fullPreferences = preferences ?: current.fullPreferences + ) + val indexInUsers = users.indexOfFirst { it.user.userId == current.userId } + if (indexInUsers != -1) { + users[indexInUsers] = UserInfo(updated, users[indexInUsers].unreadCount) + } + currentUser.value = updated + } + suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem { val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct) withContext(Dispatchers.Main) { @@ -286,9 +280,11 @@ class ChatModel(val controller: ChatController) { val chat = chats[chatIdx] val lastId = chat.chatItems.lastOrNull()?.id if (lastId != null) { + val unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0 + decreaseUnreadCounter(currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIdx] = chat.copy( chatStats = chat.chatStats.copy( - unreadCount = unreadCountAfter ?: if (range != null) chat.chatStats.unreadCount - markedRead else 0, + unreadCount = unreadCount, // Can't use minUnreadItemId currently since chat items can have unread items between read items //minUnreadItemId = if (range != null) kotlin.math.max(chat.chatStats.minUnreadItemId, range.to + 1) else lastId + 1 ) @@ -324,13 +320,30 @@ class ChatModel(val controller: ChatController) { if (chatIndex == -1) return val chat = chats[chatIndex] + val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0) + decreaseUnreadCounter(currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIndex] = chat.copy( chatStats = chat.chatStats.copy( - unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0), + unreadCount = unreadCount, ) ) } + fun increaseUnreadCounter(user: User) { + changeUnreadCounter(user, 1) + } + + fun decreaseUnreadCounter(user: User, by: Int = 1) { + changeUnreadCounter(user, -by) + } + + private fun changeUnreadCounter(user: User, by: Int) { + val i = users.indexOfFirst { it.user.userId == user.userId } + if (i != -1) { + users[i] = UserInfo(user, users[i].unreadCount + by) + } + } + // func popChat(_ id: String) { // if let i = getChatIndex(id) { // popChat_(i) @@ -375,6 +388,13 @@ class ChatModel(val controller: ChatController) { false } } + + fun setContactNetworkStatus(contact: Contact, status: NetworkStatus) { + networkStatuses[contact.activeConn.agentConnId] = status + } + + fun contactNetworkStatus(contact: Contact): NetworkStatus = + networkStatuses[contact.activeConn.agentConnId] ?: NetworkStatus.Unknown() } enum class ChatType(val type: String) { @@ -410,6 +430,19 @@ data class User( } } +@Serializable +data class UserInfo( + val user: User, + val unreadCount: Int +) { + companion object { + val sampleData = UserInfo( + user = User.sampleData, + unreadCount = 1 + ) + } +} + typealias ChatId = String interface NamedChat { @@ -441,37 +474,12 @@ data class Chat ( val chatInfo: ChatInfo, val chatItems: List, val chatStats: ChatStats = ChatStats(), - val serverInfo: ServerInfo = ServerInfo(NetworkStatus.Unknown()) ) { val id: String get() = chatInfo.id @Serializable data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false) - @Serializable - data class ServerInfo(val networkStatus: NetworkStatus) - - @Serializable - sealed class NetworkStatus { - val statusString: String get() = - when (this) { - is Connected -> generalGetString(R.string.server_connected) - is Error -> generalGetString(R.string.server_error) - else -> generalGetString(R.string.server_connecting) - } - val statusExplanation: String get() = - when (this) { - is Connected -> generalGetString(R.string.connected_to_server_to_receive_messages_from_contact) - is Error -> String.format(generalGetString(R.string.trying_to_connect_to_server_to_receive_messages_with_error), error) - else -> generalGetString(R.string.trying_to_connect_to_server_to_receive_messages) - } - - @Serializable @SerialName("unknown") class Unknown: NetworkStatus() - @Serializable @SerialName("connected") class Connected: NetworkStatus() - @Serializable @SerialName("disconnected") class Disconnected: NetworkStatus() - @Serializable @SerialName("error") class Error(val error: String): NetworkStatus() - } - companion object { val sampleData = Chat( chatInfo = ChatInfo.Direct.sampleData, @@ -605,6 +613,27 @@ sealed class ChatInfo: SomeChat, NamedChat { } } +@Serializable +sealed class NetworkStatus { + val statusString: String get() = + when (this) { + is Connected -> generalGetString(R.string.server_connected) + is Error -> generalGetString(R.string.server_error) + else -> generalGetString(R.string.server_connecting) + } + val statusExplanation: String get() = + when (this) { + is Connected -> generalGetString(R.string.connected_to_server_to_receive_messages_from_contact) + is Error -> String.format(generalGetString(R.string.trying_to_connect_to_server_to_receive_messages_with_error), error) + else -> generalGetString(R.string.trying_to_connect_to_server_to_receive_messages) + } + + @Serializable @SerialName("unknown") class Unknown: NetworkStatus() + @Serializable @SerialName("connected") class Connected: NetworkStatus() + @Serializable @SerialName("disconnected") class Disconnected: NetworkStatus() + @Serializable @SerialName("error") class Error(val error: String): NetworkStatus() +} + @Serializable data class Contact( val contactId: Long, @@ -675,6 +704,8 @@ data class Contact( @Serializable class ContactRef( val contactId: Long, + val agentConnId: String, + val connId: Long, var localDisplayName: String ) { val id: ChatId get() = "@$contactId" @@ -689,6 +720,7 @@ class ContactSubStatus( @Serializable data class Connection( val connId: Long, + val agentConnId: String, val connStatus: ConnStatus, val connLevel: Int, val viaGroupLink: Boolean, @@ -697,7 +729,7 @@ data class Connection( ) { val id: ChatId get() = ":$connId" companion object { - val sampleData = Connection(connId = 1, connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, customUserProfileId = null) + val sampleData = Connection(connId = 1, agentConnId = "abc", connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, customUserProfileId = null) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt index 91d3948a9b..7330db3651 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -77,8 +77,9 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference } } - fun notifyContactRequestReceived(cInfo: ChatInfo.ContactRequest) { + fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) { notifyMessageReceived( + user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = generalGetString(R.string.notification_new_contact_request), @@ -87,21 +88,22 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference ) } - fun notifyContactConnected(contact: Contact) { + fun notifyContactConnected(user: User, contact: Contact) { notifyMessageReceived( + user = user, chatId = contact.id, displayName = contact.displayName, msgText = generalGetString(R.string.notification_contact_connected) ) } - fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) { + fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) { if (!cInfo.ntfsEnabled) return - notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem)) + notifyMessageReceived(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem)) } - fun notifyMessageReceived(chatId: String, displayName: String, msgText: String, image: String? = null, actions: List = emptyList()) { + fun notifyMessageReceived(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List = emptyList()) { Log.d(TAG, "notifyMessageReceived $chatId") val now = Clock.System.now().toEpochMilliseconds() val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index bd49ec5bdf..0518ac819e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -33,8 +33,6 @@ import kotlinx.coroutines.* import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.* -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.nullable import kotlinx.serialization.json.* import java.util.Date @@ -111,6 +109,18 @@ class AppPreferences(val context: Context) { val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null) val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false) val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false) + private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name) + val networkSessionMode: SharedPreference = SharedPreference( + get = fun(): TransportSessionMode { + val value = _networkSessionMode.get() ?: return TransportSessionMode.default + return try { + TransportSessionMode.valueOf(value) + } catch (e: Error) { + TransportSessionMode.default + } + }, + set = fun(mode: TransportSessionMode) { _networkSessionMode.set(mode.name) } + ) val networkHostMode = mkStrPreference(SHARED_PREFS_NETWORK_HOST_MODE, HostMode.OnionViaSocks.name) val networkRequiredHostMode = mkBoolPreference(SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE, false) val networkTCPConnectTimeout = mkTimeoutPreference(SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT, NetCfg.defaults.tcpConnectTimeout, NetCfg.proxyDefaults.tcpConnectTimeout) @@ -208,6 +218,7 @@ class AppPreferences(val context: Context) { private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart" private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools" private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy" + private const val SHARED_PREFS_NETWORK_SESSION_MODE = "NetworkSessionMode" private const val SHARED_PREFS_NETWORK_HOST_MODE = "NetworkHostMode" private const val SHARED_PREFS_NETWORK_REQUIRED_HOST_MODE = "NetworkRequiredHostMode" private const val SHARED_PREFS_NETWORK_TCP_CONNECT_TIMEOUT = "NetworkTCPConnectTimeout" @@ -255,18 +266,15 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a if (chatModel.chatRunning.value == true) return apiSetNetworkConfig(getNetCfg()) val justStarted = apiStartChat() + val users = listUsers() + chatModel.users.clear() + chatModel.users.addAll(users) if (justStarted) { - apiSetFilesFolder(getAppFilesDirectory(appContext)) - apiSetIncognito(chatModel.incognito.value) - chatModel.userAddress.value = apiGetUserAddress() - val smpServers = getUserSMPServers() - chatModel.userSMPServers.value = smpServers?.first - chatModel.presetSMPServers.value = smpServers?.second - chatModel.chatItemTTL.value = getChatItemTTL() - val chats = apiGetChats() - chatModel.updateChats(chats) chatModel.currentUser.value = user chatModel.userCreated.value = true + apiSetFilesFolder(getAppFilesDirectory(appContext)) + apiSetIncognito(chatModel.incognito.value) + getUserChatData() chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete chatModel.controller.appPrefs.chatLastStart.set(Clock.System.now()) chatModel.chatRunning.value = true @@ -283,6 +291,28 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } + suspend fun changeActiveUser(toUserId: Long) { + try { + chatModel.currentUser.value = apiSetActiveUser(toUserId) + val users = listUsers() + chatModel.users.clear() + chatModel.users.addAll(users) + getUserChatData() + } catch (e: Exception) { + Log.e(TAG, "Unable to set active user: ${e.stackTraceToString()}") + } + } + + suspend fun getUserChatData() { + chatModel.userAddress.value = apiGetUserAddress() + val smpServers = getUserSMPServers() + chatModel.userSMPServers.value = smpServers?.first + chatModel.presetSMPServers.value = smpServers?.second + chatModel.chatItemTTL.value = getChatItemTTL() + val chats = apiGetChats() + chatModel.updateChats(chats) + } + private fun startReceiver() { Log.d(TAG, "ChatController startReceiver") if (receiverStarted) return @@ -352,6 +382,27 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a throw Error("user not created ${r.responseType} ${r.details}") } + suspend fun listUsers(): List { + val r = sendCmd(CC.ListUsers()) + if (r is CR.UsersList) return r.users.sortedBy { it.user.chatViewName } + Log.d(TAG, "listUsers: ${r.responseType} ${r.details}") + throw Exception("failed to list users ${r.responseType} ${r.details}") + } + + suspend fun apiSetActiveUser(userId: Long): User { + val r = sendCmd(CC.ApiSetActiveUser(userId)) + if (r is CR.ActiveUser) return r.user + Log.d(TAG, "apiSetActiveUser: ${r.responseType} ${r.details}") + throw Exception("failed to set the user as active ${r.responseType} ${r.details}") + } + + suspend fun apiDeleteUser(userId: Long) { + val r = sendCmd(CC.ApiDeleteUser(userId)) + if (r is CR.CmdOk) return + Log.d(TAG, "apiDeleteUser: ${r.responseType} ${r.details}") + throw Exception("failed to delete the user ${r.responseType} ${r.details}") + } + suspend fun apiStartChat(): Boolean { val r = sendCmd(CC.StartChat(expire = true)) when (r) { @@ -407,7 +458,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun apiGetChats(): List { - val r = sendCmd(CC.ApiGetChats()) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiGetChats: no current user") + return emptyList() + } + val r = sendCmd(CC.ApiGetChats(userId)) if (r is CR.ApiChats) return r.chats Log.e(TAG, "failed getting the list of chats: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(R.string.failed_to_parse_chats_title), generalGetString(R.string.contact_developers)) @@ -451,14 +506,22 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } private suspend fun getUserSMPServers(): Pair, List>? { - val r = sendCmd(CC.GetUserSMPServers()) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "getUserSMPServers: no current user") + return null + } + val r = sendCmd(CC.APIGetUserSMPServers(userId)) if (r is CR.UserSMPServers) return r.smpServers to r.presetSMPServers Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}") return null } suspend fun setUserSMPServers(smpServers: List): Boolean { - val r = sendCmd(CC.SetUserSMPServers(smpServers)) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "setUserSMPServers: no current user") + return false + } + val r = sendCmd(CC.APISetUserSMPServers(userId, smpServers)) return when (r) { is CR.CmdOk -> true else -> { @@ -473,7 +536,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun testSMPServer(smpServer: String): SMPTestFailure? { - val r = sendCmd(CC.TestSMPServer(smpServer)) + val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("testSMPServer: no current user") } + val r = sendCmd(CC.TestSMPServer(userId, smpServer)) return when (r) { is CR.SmpTestResult -> r.smpTestFailure else -> { @@ -484,13 +548,15 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun getChatItemTTL(): ChatItemTTL { - val r = sendCmd(CC.APIGetChatItemTTL()) + val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("getChatItemTTL: no current user") } + val r = sendCmd(CC.APIGetChatItemTTL(userId)) if (r is CR.ChatItemTTL) return ChatItemTTL.fromSeconds(r.chatItemTTL) throw Exception("failed to get chat item TTL: ${r.responseType} ${r.details}") } suspend fun setChatItemTTL(chatItemTTL: ChatItemTTL) { - val r = sendCmd(CC.APISetChatItemTTL(chatItemTTL.seconds)) + val userId = chatModel.currentUser.value?.userId ?: run { throw Exception("setChatItemTTL: no current user") } + val r = sendCmd(CC.APISetChatItemTTL(userId, chatItemTTL.seconds)) if (r is CR.CmdOk) return throw Exception("failed to set chat item TTL: ${r.responseType} ${r.details}") } @@ -589,7 +655,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a suspend fun apiAddContact(): String? { - val r = sendCmd(CC.AddContact()) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiAddContact: no current user") + return null + } + val r = sendCmd(CC.APIAddContact(userId)) return when (r) { is CR.Invitation -> r.connReqInvitation else -> { @@ -602,7 +672,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun apiConnect(connReq: String): Boolean { - val r = sendCmd(CC.Connect(connReq)) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiConnect: no current user") + return false + } + val r = sendCmd(CC.APIConnect(userId, connReq)) when { r is CR.SentConfirmation || r is CR.SentInvitation -> return true r is CR.ContactAlreadyExists -> { @@ -665,27 +739,28 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun apiListContacts(): List? { - val r = sendCmd(CC.ListContacts()) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiListContacts: no current user") + return null + } + val r = sendCmd(CC.ApiListContacts(userId)) if (r is CR.ContactsList) return r.contacts Log.e(TAG, "apiListContacts bad response: ${r.responseType} ${r.details}") return null } suspend fun apiUpdateProfile(profile: Profile): Profile? { - val r = sendCmd(CC.ApiUpdateProfile(profile)) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiUpdateProfile: no current user") + return null + } + val r = sendCmd(CC.ApiUpdateProfile(userId, profile)) if (r is CR.UserProfileNoChange) return profile if (r is CR.UserProfileUpdated) return r.toProfile Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}") return null } - suspend fun apiParseMarkdown(text: String): List? { - val r = sendCmd(CC.ApiParseMarkdown(text)) - if (r is CR.ParsedMarkdown) return r.formattedText - Log.e(TAG, "apiParseMarkdown bad response: ${r.responseType} ${r.details}") - return null - } - suspend fun apiSetContactPrefs(contactId: Long, prefs: ChatPreferences): Contact? { val r = sendCmd(CC.ApiSetContactPrefs(contactId, prefs)) if (r is CR.ContactPrefsUpdated) return r.toContact @@ -708,7 +783,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun apiCreateUserAddress(): String? { - val r = sendCmd(CC.CreateMyAddress()) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiCreateUserAddress: no current user") + return null + } + val r = sendCmd(CC.ApiCreateMyAddress(userId)) return when (r) { is CR.UserContactLinkCreated -> r.connReqContact else -> { @@ -721,14 +800,22 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun apiDeleteUserAddress(): Boolean { - val r = sendCmd(CC.DeleteMyAddress()) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiDeleteUserAddress: no current user") + return false + } + val r = sendCmd(CC.ApiDeleteMyAddress(userId)) if (r is CR.UserContactLinkDeleted) return true Log.e(TAG, "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}") return false } private suspend fun apiGetUserAddress(): UserContactLinkRec? { - val r = sendCmd(CC.ShowMyAddress()) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiGetUserAddress: no current user") + return null + } + val r = sendCmd(CC.ApiShowMyAddress(userId)) if (r is CR.UserContactLink) return r.contactLink if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.UserContactLinkNotFound) { @@ -739,7 +826,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun userAddressAutoAccept(autoAccept: AutoAccept?): UserContactLinkRec? { - val r = sendCmd(CC.AddressAutoAccept(autoAccept)) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "userAddressAutoAccept: no current user") + return null + } + val r = sendCmd(CC.ApiAddressAutoAccept(userId, autoAccept)) if (r is CR.UserContactLinkUpdated) return r.contactLink if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.UserContactLinkNotFound) { @@ -858,7 +949,11 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } suspend fun apiNewGroup(p: GroupProfile): GroupInfo? { - val r = sendCmd(CC.NewGroup(p)) + val userId = chatModel.currentUser.value?.userId ?: run { + Log.e(TAG, "apiNewGroup: no current user") + return null + } + val r = sendCmd(CC.ApiNewGroup(userId, p)) if (r is CR.GroupCreated) return r.groupInfo Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}") return null @@ -1005,6 +1100,13 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } + suspend fun apiParseMarkdown(text: String): List? { + val r = sendCmd(CC.ApiParseMarkdown(text)) + if (r is CR.ParsedMarkdown) return r.formattedText + Log.e(TAG, "apiParseMarkdown bad response: ${r.responseType} ${r.details}") + return null + } + private fun networkErrorAlert(r: CR): Boolean { return when { r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent @@ -1040,62 +1142,81 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a chatModel.terminalItems.add(TerminalItem.resp(r)) when (r) { is CR.NewContactConnection -> { - chatModel.updateContactConnection(r.connection) + if (active(r.user)) { + chatModel.updateContactConnection(r.connection) + } } is CR.ContactConnectionDeleted -> { - chatModel.removeChat(r.connection.id) + if (active(r.user)) { + chatModel.removeChat(r.connection.id) + } } is CR.ContactConnected -> { - if (r.contact.directOrUsed) { + if (active(r.user) && r.contact.directOrUsed) { chatModel.updateContact(r.contact) chatModel.dismissConnReqView(r.contact.activeConn.id) chatModel.removeChat(r.contact.activeConn.id) - chatModel.updateNetworkStatus(r.contact.id, Chat.NetworkStatus.Connected()) - ntfManager.notifyContactConnected(r.contact) + ntfManager.notifyContactConnected(r.user, r.contact) } + chatModel.setContactNetworkStatus(r.contact, NetworkStatus.Connected()) } is CR.ContactConnecting -> { - if (r.contact.directOrUsed) { + if (active(r.user) && r.contact.directOrUsed) { chatModel.updateContact(r.contact) chatModel.dismissConnReqView(r.contact.activeConn.id) chatModel.removeChat(r.contact.activeConn.id) } } is CR.ReceivedContactRequest -> { + if (!active(r.user)) return + val contactRequest = r.contactRequest val cInfo = ChatInfo.ContactRequest(contactRequest) chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf())) - ntfManager.notifyContactRequestReceived(cInfo) + ntfManager.notifyContactRequestReceived(r.user, cInfo) } is CR.ContactUpdated -> { - val cInfo = ChatInfo.Direct(r.toContact) - if (chatModel.hasChat(r.toContact.id)) { + if (active(r.user) && chatModel.hasChat(r.toContact.id)) { + val cInfo = ChatInfo.Direct(r.toContact) chatModel.updateChatInfo(cInfo) } } is CR.ContactsMerged -> { - if (chatModel.hasChat(r.mergedContact.id)) { + if (active(r.user) && chatModel.hasChat(r.mergedContact.id)) { if (chatModel.chatId.value == r.mergedContact.id) { chatModel.chatId.value = r.intoContact.id } chatModel.removeChat(r.mergedContact.id) } } - is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, Chat.NetworkStatus.Connected()) - is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, Chat.NetworkStatus.Disconnected()) - is CR.ContactSubError -> processContactSubError(r.contact, r.chatError) + is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected()) + is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, NetworkStatus.Disconnected()) + is CR.ContactSubError -> { + if (active(r.user)) { + chatModel.updateContact(r.contact) + } + processContactSubError(r.contact, r.chatError) + } is CR.ContactSubSummary -> { for (sub in r.contactSubscriptions) { + if (active(r.user)) { + chatModel.updateContact(sub.contact) + } val err = sub.contactError if (err == null) { - chatModel.updateContact(sub.contact) - chatModel.updateNetworkStatus(sub.contact.id, Chat.NetworkStatus.Connected()) + chatModel.setContactNetworkStatus(sub.contact, NetworkStatus.Connected()) } else { processContactSubError(sub.contact, sub.contactError) } } } is CR.NewChatItem -> { + if (!active(r.user)) { + if (r.chatItem.chatItem.isRcvNew && r.chatItem.chatInfo.ntfsEnabled) { + chatModel.increaseUnreadCounter(r.user) + } + return + } val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem chatModel.addChatItem(cInfo, cItem) @@ -1109,10 +1230,12 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } if (cItem.showNotification && (!SimplexApp.context.isAppOnForeground || chatModel.chatId.value != cInfo.id)) { - ntfManager.notifyMessageReceived(cInfo, cItem) + ntfManager.notifyMessageReceived(r.user, cInfo, cItem) } } is CR.ChatItemStatusUpdated -> { + if (!active(r.user)) return + val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem var res = false @@ -1120,12 +1243,21 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a res = chatModel.upsertChatItem(cInfo, cItem) } if (res) { - ntfManager.notifyMessageReceived(cInfo, cItem) + ntfManager.notifyMessageReceived(r.user, cInfo, cItem) } } is CR.ChatItemUpdated -> - chatItemSimpleUpdate(r.chatItem) + if (active(r.user)) { + chatItemSimpleUpdate(r.chatItem) + } is CR.ChatItemDeleted -> { + if (!active(r.user)) { + if (r.toChatItem == null && r.deletedChatItem.chatItem.isRcvNew && r.deletedChatItem.chatInfo.ntfsEnabled) { + chatModel.decreaseUnreadCounter(r.user) + } + return + } + val cInfo = r.deletedChatItem.chatInfo val cItem = r.deletedChatItem.chatItem AudioPlayer.stop(cItem) @@ -1133,6 +1265,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) { ntfManager.cancelNotificationsForChat(cInfo.id) ntfManager.notifyMessageReceived( + r.user, cInfo.id, cInfo.displayName, generalGetString(if (r.toChatItem != null) R.string.marked_deleted_description else R.string.deleted_description) @@ -1145,10 +1278,14 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } is CR.ReceivedGroupInvitation -> { - chatModel.updateGroup(r.groupInfo) // update so that repeat group invitations are not duplicated - // TODO NtfManager.shared.notifyGroupInvitation + if (active(r.user)) { + chatModel.updateGroup(r.groupInfo) // update so that repeat group invitations are not duplicated + // TODO NtfManager.shared.notifyGroupInvitation + } } is CR.UserAcceptedGroupSent -> { + if (!active(r.user)) return + chatModel.updateGroup(r.groupInfo) if (r.hostContact != null) { chatModel.dismissConnReqView(r.hostContact.activeConn.id) @@ -1156,34 +1293,64 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } is CR.JoinedGroupMemberConnecting -> - chatModel.upsertGroupMember(r.groupInfo, r.member) + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.member) + } is CR.DeletedMemberUser -> // TODO update user member - chatModel.updateGroup(r.groupInfo) + if (active(r.user)) { + chatModel.updateGroup(r.groupInfo) + } is CR.DeletedMember -> - chatModel.upsertGroupMember(r.groupInfo, r.deletedMember) + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.deletedMember) + } is CR.LeftMember -> - chatModel.upsertGroupMember(r.groupInfo, r.member) + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.member) + } is CR.MemberRole -> - chatModel.upsertGroupMember(r.groupInfo, r.member) + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.member) + } is CR.MemberRoleUser -> - chatModel.upsertGroupMember(r.groupInfo, r.member) + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.member) + } is CR.GroupDeleted -> // TODO update user member - chatModel.updateGroup(r.groupInfo) + if (active(r.user)) { + chatModel.updateGroup(r.groupInfo) + } is CR.UserJoinedGroup -> - chatModel.updateGroup(r.groupInfo) + if (active(r.user)) { + chatModel.updateGroup(r.groupInfo) + } is CR.JoinedGroupMember -> - chatModel.upsertGroupMember(r.groupInfo, r.member) + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.member) + } is CR.ConnectedToGroupMember -> - chatModel.upsertGroupMember(r.groupInfo, r.member) + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.member) + } is CR.GroupUpdated -> - chatModel.updateGroup(r.toGroup) + if (active(r.user)) { + chatModel.updateGroup(r.toGroup) + } is CR.RcvFileStart -> - chatItemSimpleUpdate(r.chatItem) + if (active(r.user)) { + chatItemSimpleUpdate(r.chatItem) + } is CR.RcvFileComplete -> - chatItemSimpleUpdate(r.chatItem) + if (active(r.user)) { + chatItemSimpleUpdate(r.chatItem) + } is CR.SndFileStart -> - chatItemSimpleUpdate(r.chatItem) + if (active(r.user)) { + chatItemSimpleUpdate(r.chatItem) + } is CR.SndFileComplete -> { + if (!active(r.user)) return + chatItemSimpleUpdate(r.chatItem) val cItem = r.chatItem.chatItem val mc = cItem.content.msgContent @@ -1245,6 +1412,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } + private fun active(user: User): Boolean = user.userId == chatModel.currentUser.value?.userId + private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) { val call = chatModel.activeCall.value if (call != null && call.contact.apiId == contact.apiId) { @@ -1273,18 +1442,17 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a val cInfo = aChatItem.chatInfo val cItem = aChatItem.chatItem if (chatModel.upsertChatItem(cInfo, cItem)) { - ntfManager.notifyMessageReceived(cInfo, cItem) + ntfManager.notifyMessageReceived(chatModel.currentUser.value!!, cInfo, cItem) } } - fun updateContactsStatus(contactRefs: List, status: Chat.NetworkStatus) { + private fun updateContactsStatus(contactRefs: List, status: NetworkStatus) { for (c in contactRefs) { - chatModel.updateNetworkStatus(c.id, status) + chatModel.networkStatuses[c.agentConnId] = status } } - fun processContactSubError(contact: Contact, chatError: ChatError) { - chatModel.updateContact(contact) + private fun processContactSubError(contact: Contact, chatError: ChatError) { val e = chatError val err: String = if (e is ChatError.ChatErrorAgent) { @@ -1296,7 +1464,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } } else e.string - chatModel.updateNetworkStatus(contact.id, Chat.NetworkStatus.Error(err)) + chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err)) } fun showBackgroundServiceNoticeIfNeeded() { @@ -1494,6 +1662,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a val socksProxy = if (useSocksProxy) ":9050" else null val hostMode = HostMode.valueOf(appPrefs.networkHostMode.get()!!) val requiredHostMode = appPrefs.networkRequiredHostMode.get() + val sessionMode = appPrefs.networkSessionMode.get() val tcpConnectTimeout = appPrefs.networkTCPConnectTimeout.get() val tcpTimeout = appPrefs.networkTCPTimeout.get() val smpPingInterval = appPrefs.networkSMPPingInterval.get() @@ -1511,6 +1680,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a socksProxy = socksProxy, hostMode = hostMode, requiredHostMode = requiredHostMode, + sessionMode = sessionMode, tcpConnectTimeout = tcpConnectTimeout, tcpTimeout = tcpTimeout, tcpKeepAlive = tcpKeepAlive, @@ -1523,6 +1693,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a appPrefs.networkUseSocksProxy.set(cfg.useSocksProxy) appPrefs.networkHostMode.set(cfg.hostMode.name) appPrefs.networkRequiredHostMode.set(cfg.requiredHostMode) + appPrefs.networkSessionMode.set(cfg.sessionMode) appPrefs.networkTCPConnectTimeout.set(cfg.tcpConnectTimeout) appPrefs.networkTCPTimeout.set(cfg.tcpTimeout) appPrefs.networkSMPPingInterval.set(cfg.smpPingInterval) @@ -1556,6 +1727,9 @@ sealed class CC { class Console(val cmd: String): CC() class ShowActiveUser: CC() class CreateActiveUser(val profile: Profile): CC() + class ListUsers: CC() + class ApiSetActiveUser(val userId: Long): CC() + class ApiDeleteUser(val userId: Long): CC() class StartChat(val expire: Boolean): CC() class ApiStopChat: CC() class SetFilesFolder(val filesFolder: String): CC() @@ -1564,12 +1738,12 @@ sealed class CC { class ApiImportArchive(val config: ArchiveConfig): CC() class ApiDeleteStorage: CC() class ApiStorageEncryption(val config: DBEncryptionConfig): CC() - class ApiGetChats: CC() + class ApiGetChats(val userId: Long): CC() class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC() class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent, val live: Boolean): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() - class NewGroup(val groupProfile: GroupProfile): CC() + class ApiNewGroup(val userId: Long, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC() @@ -1580,11 +1754,11 @@ sealed class CC { class APICreateGroupLink(val groupId: Long): CC() class APIDeleteGroupLink(val groupId: Long): CC() class APIGetGroupLink(val groupId: Long): CC() - class GetUserSMPServers: CC() - class SetUserSMPServers(val smpServers: List): CC() - class TestSMPServer(val smpServer: String): CC() - class APISetChatItemTTL(val seconds: Long?): CC() - class APIGetChatItemTTL: CC() + class APIGetUserSMPServers(val userId: Long): CC() + class APISetUserSMPServers(val userId: Long, val smpServers: List): CC() + class TestSMPServer(val userId: Long, val smpServer: String): CC() + class APISetChatItemTTL(val userId: Long, val seconds: Long?): CC() + class APIGetChatItemTTL(val userId: Long): CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC() class APIGetNetworkConfig: CC() class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC() @@ -1596,20 +1770,20 @@ sealed class CC { class APIGetGroupMemberCode(val groupId: Long, val groupMemberId: Long): CC() class APIVerifyContact(val contactId: Long, val connectionCode: String?): CC() class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC() - class AddContact: CC() - class Connect(val connReq: String): CC() + class APIAddContact(val userId: Long): CC() + class APIConnect(val userId: Long, val connReq: String): CC() class ApiDeleteChat(val type: ChatType, val id: Long): CC() class ApiClearChat(val type: ChatType, val id: Long): CC() - class ListContacts: CC() - class ApiUpdateProfile(val profile: Profile): CC() + class ApiListContacts(val userId: Long): CC() + class ApiUpdateProfile(val userId: Long, val profile: Profile): CC() class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC() class ApiParseMarkdown(val text: String): CC() class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC() class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC() - class CreateMyAddress: CC() - class DeleteMyAddress: CC() - class ShowMyAddress: CC() - class AddressAutoAccept(val autoAccept: AutoAccept?): CC() + class ApiCreateMyAddress(val userId: Long): CC() + class ApiDeleteMyAddress(val userId: Long): CC() + class ApiShowMyAddress(val userId: Long): CC() + class ApiAddressAutoAccept(val userId: Long, val autoAccept: AutoAccept?): CC() class ApiSendCallInvitation(val contact: Contact, val callType: CallType): CC() class ApiRejectCall(val contact: Contact): CC() class ApiSendCallOffer(val contact: Contact, val callOffer: WebRTCCallOffer): CC() @@ -1627,7 +1801,10 @@ sealed class CC { val cmdString: String get() = when (this) { is Console -> cmd is ShowActiveUser -> "/u" - is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}" + is CreateActiveUser -> "/create user ${profile.displayName} ${profile.fullName}" + is ListUsers -> "/users" + is ApiSetActiveUser -> "/_user $userId" + is ApiDeleteUser -> "/_delete user $userId" is StartChat -> "/_start subscribe=on expire=${onOff(expire)}" is ApiStopChat -> "/_stop" is SetFilesFolder -> "/_files_folder $filesFolder" @@ -1636,12 +1813,12 @@ sealed class CC { is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" is ApiStorageEncryption -> "/_db encryption ${json.encodeToString(config)}" - is ApiGetChats -> "/_get chats pcc=on" + is ApiGetChats -> "/_get chats $userId pcc=on" is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search") is ApiSendMessage -> "/_send ${chatRef(type, id)} live=${onOff(live)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}" is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" - is NewGroup -> "/_group ${json.encodeToString(groupProfile)}" + is ApiNewGroup -> "/_group $userId ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}" @@ -1652,11 +1829,11 @@ sealed class CC { is APICreateGroupLink -> "/_create link #$groupId" is APIDeleteGroupLink -> "/_delete link #$groupId" is APIGetGroupLink -> "/_get link #$groupId" - is GetUserSMPServers -> "/smp" - is SetUserSMPServers -> "/_smp ${smpServersStr(smpServers)}" - is TestSMPServer -> "/smp test $smpServer" - is APISetChatItemTTL -> "/_ttl ${chatItemTTLStr(seconds)}" - is APIGetChatItemTTL -> "/ttl" + is APIGetUserSMPServers -> "/_smp $userId" + is APISetUserSMPServers -> "/_smp $userId ${smpServersStr(smpServers)}" + is TestSMPServer -> "/smp test $userId $smpServer" + is APISetChatItemTTL -> "/_ttl $userId ${chatItemTTLStr(seconds)}" + is APIGetChatItemTTL -> "/_ttl $userId" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" is APIGetNetworkConfig -> "/network" is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}" @@ -1668,20 +1845,20 @@ sealed class CC { is APIGetGroupMemberCode -> "/_get code #$groupId $groupMemberId" is APIVerifyContact -> "/_verify code @$contactId" + if (connectionCode != null) " $connectionCode" else "" is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else "" - is AddContact -> "/connect" - is Connect -> "/connect $connReq" + is APIAddContact -> "/_connect $userId" + is APIConnect -> "/_connect $userId $connReq" is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" - is ListContacts -> "/contacts" - is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}" + is ApiListContacts -> "/_contacts $userId" + is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}" is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}" is ApiParseMarkdown -> "/_parse $text" is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}" is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}" - is CreateMyAddress -> "/address" - is DeleteMyAddress -> "/delete_address" - is ShowMyAddress -> "/show_address" - is AddressAutoAccept -> "/auto_accept ${AutoAccept.cmdString(autoAccept)}" + is ApiCreateMyAddress -> "/_address $userId" + is ApiDeleteMyAddress -> "/_delete_address $userId" + is ApiShowMyAddress -> "/_show_address $userId" + is ApiAddressAutoAccept -> "/_auto_accept $userId ${AutoAccept.cmdString(autoAccept)}" is ApiAcceptContact -> "/_accept $contactReqId" is ApiRejectContact -> "/_reject $contactReqId" is ApiSendCallInvitation -> "/_call invite @${contact.apiId} ${json.encodeToString(callType)}" @@ -1701,6 +1878,9 @@ sealed class CC { is Console -> "console command" is ShowActiveUser -> "showActiveUser" is CreateActiveUser -> "createActiveUser" + is ListUsers -> "listUsers" + is ApiSetActiveUser -> "apiSetActiveUser" + is ApiDeleteUser -> "apiDeleteUser" is StartChat -> "startChat" is ApiStopChat -> "apiStopChat" is SetFilesFolder -> "setFilesFolder" @@ -1714,7 +1894,7 @@ sealed class CC { is ApiSendMessage -> "apiSendMessage" is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" - is NewGroup -> "newGroup" + is ApiNewGroup -> "apiNewGroup" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" is ApiMemberRole -> "apiMemberRole" @@ -1725,8 +1905,8 @@ sealed class CC { is APICreateGroupLink -> "apiCreateGroupLink" is APIDeleteGroupLink -> "apiDeleteGroupLink" is APIGetGroupLink -> "apiGetGroupLink" - is GetUserSMPServers -> "getUserSMPServers" - is SetUserSMPServers -> "setUserSMPServers" + is APIGetUserSMPServers -> "apiGetUserSMPServers" + is APISetUserSMPServers -> "apiSetUserSMPServers" is TestSMPServer -> "testSMPServer" is APISetChatItemTTL -> "apiSetChatItemTTL" is APIGetChatItemTTL -> "apiGetChatItemTTL" @@ -1741,20 +1921,20 @@ sealed class CC { is APIGetGroupMemberCode -> "apiGetGroupMemberCode" is APIVerifyContact -> "apiVerifyContact" is APIVerifyGroupMember -> "apiVerifyGroupMember" - is AddContact -> "addContact" - is Connect -> "connect" + is APIAddContact -> "apiAddContact" + is APIConnect -> "apiConnect" is ApiDeleteChat -> "apiDeleteChat" is ApiClearChat -> "apiClearChat" - is ListContacts -> "listContacts" - is ApiUpdateProfile -> "updateProfile" + is ApiListContacts -> "apiListContacts" + is ApiUpdateProfile -> "apiUpdateProfile" is ApiSetContactPrefs -> "apiSetContactPrefs" is ApiParseMarkdown -> "apiParseMarkdown" is ApiSetContactAlias -> "apiSetContactAlias" is ApiSetConnectionAlias -> "apiSetConnectionAlias" - is CreateMyAddress -> "createMyAddress" - is DeleteMyAddress -> "deleteMyAddress" - is ShowMyAddress -> "showMyAddress" - is AddressAutoAccept -> "addressAutoAccept" + is ApiCreateMyAddress -> "apiCreateMyAddress" + is ApiDeleteMyAddress -> "apiDeleteMyAddress" + is ApiShowMyAddress -> "apiShowMyAddress" + is ApiAddressAutoAccept -> "apiAddressAutoAccept" is ApiAcceptContact -> "apiAcceptContact" is ApiRejectContact -> "apiRejectContact" is ApiSendCallInvitation -> "apiSendCallInvitation" @@ -1964,9 +2144,10 @@ data class ParsedServerAddress ( @Serializable data class NetCfg( - val socksProxy: String? = null, - val hostMode: HostMode = HostMode.OnionViaSocks, - val requiredHostMode: Boolean = false, + val socksProxy: String?, + val hostMode: HostMode, + val requiredHostMode: Boolean, + val sessionMode: TransportSessionMode, val tcpConnectTimeout: Long, // microseconds val tcpTimeout: Long, // microseconds val tcpKeepAlive: KeepAliveOpts?, @@ -1981,6 +2162,9 @@ data class NetCfg( val defaults: NetCfg = NetCfg( socksProxy = null, + hostMode = HostMode.OnionViaSocks, + requiredHostMode = false, + sessionMode = TransportSessionMode.User, tcpConnectTimeout = 10_000_000, tcpTimeout = 7_000_000, tcpKeepAlive = KeepAliveOpts.defaults, @@ -1991,6 +2175,9 @@ data class NetCfg( val proxyDefaults: NetCfg = NetCfg( socksProxy = ":9050", + hostMode = HostMode.OnionViaSocks, + requiredHostMode = false, + sessionMode = TransportSessionMode.User, tcpConnectTimeout = 20_000_000, tcpTimeout = 15_000_000, tcpKeepAlive = KeepAliveOpts.defaults, @@ -2027,6 +2214,16 @@ enum class HostMode { @SerialName("public") Public; } +@Serializable +enum class TransportSessionMode { + @SerialName("user") User, + @SerialName("entity") Entity; + + companion object { + val default = User + } +} + @Serializable data class KeepAliveOpts( val keepIdle: Int, // seconds @@ -2607,17 +2804,19 @@ class APIResponse(val resp: CR, val corr: String? = null) { val type = resp["type"]?.jsonPrimitive?.content ?: "invalid" try { if (type == "apiChats") { + val user: User = json.decodeFromJsonElement(resp["user"]!!.jsonObject) val chats: List = resp["chats"]!!.jsonArray.map { parseChatData(it) } return APIResponse( - resp = CR.ApiChats(chats), + resp = CR.ApiChats(user, chats), corr = data["corr"]?.toString() ) } else if (type == "apiChat") { + val user: User = json.decodeFromJsonElement(resp["user"]!!.jsonObject) val chat = parseChatData(resp["chat"]!!) return APIResponse( - resp = CR.ApiChat(chat), + resp = CR.ApiChat(user, chat), corr = data["corr"]?.toString() ) } @@ -2653,108 +2852,110 @@ private fun decodeObject(deserializer: DeserializationStrategy, obj: Json @Serializable sealed class CR { @Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR() + @Serializable @SerialName("usersList") class UsersList(val users: List): CR() @Serializable @SerialName("chatStarted") class ChatStarted: CR() @Serializable @SerialName("chatRunning") class ChatRunning: CR() @Serializable @SerialName("chatStopped") class ChatStopped: CR() - @Serializable @SerialName("apiChats") class ApiChats(val chats: List): CR() - @Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR() - @Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List, val presetSMPServers: List): CR() - @Serializable @SerialName("smpTestResult") class SmpTestResult(val smpTestFailure: SMPTestFailure? = null): CR() - @Serializable @SerialName("chatItemTTL") class ChatItemTTL(val chatItemTTL: Long? = null): CR() + @Serializable @SerialName("apiChats") class ApiChats(val user: User, val chats: List): CR() + @Serializable @SerialName("apiChat") class ApiChat(val user: User, val chat: Chat): CR() + @Serializable @SerialName("userSMPServers") class UserSMPServers(val user: User, val smpServers: List, val presetSMPServers: List): CR() + @Serializable @SerialName("smpTestResult") class SmpTestResult(val user: User, val smpTestFailure: SMPTestFailure? = null): CR() + @Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: User, val chatItemTTL: Long? = null): CR() @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() - @Serializable @SerialName("contactInfo") class ContactInfo(val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR() - @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR() - @Serializable @SerialName("contactCode") class ContactCode(val contact: Contact, val connectionCode: String): CR() - @Serializable @SerialName("groupMemberCode") class GroupMemberCode(val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR() - @Serializable @SerialName("connectionVerified") class ConnectionVerified(val verified: Boolean, val expectedCode: String): CR() - @Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR() - @Serializable @SerialName("sentConfirmation") class SentConfirmation: CR() - @Serializable @SerialName("sentInvitation") class SentInvitation: CR() - @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val contact: Contact): CR() - @Serializable @SerialName("contactDeleted") class ContactDeleted(val contact: Contact): CR() - @Serializable @SerialName("chatCleared") class ChatCleared(val chatInfo: ChatInfo): CR() - @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR() - @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR() - @Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val toContact: Contact): CR() - @Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val toConnection: PendingContactConnection): CR() - @Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val fromContact: Contact, val toContact: Contact): CR() - @Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List? = null): CR() - @Serializable @SerialName("userContactLink") class UserContactLink(val contactLink: UserContactLinkRec): CR() - @Serializable @SerialName("userContactLinkUpdated") class UserContactLinkUpdated(val contactLink: UserContactLinkRec): CR() - @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR() - @Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted: CR() - @Serializable @SerialName("contactConnected") class ContactConnected(val contact: Contact, val userCustomProfile: Profile? = null): CR() - @Serializable @SerialName("contactConnecting") class ContactConnecting(val contact: Contact): CR() - @Serializable @SerialName("receivedContactRequest") class ReceivedContactRequest(val contactRequest: UserContactRequest): CR() - @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val contact: Contact): CR() - @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected: CR() - @Serializable @SerialName("contactUpdated") class ContactUpdated(val toContact: Contact): CR() + @Serializable @SerialName("contactInfo") class ContactInfo(val user: User, val contact: Contact, val connectionStats: ConnectionStats, val customUserProfile: Profile? = null): CR() + @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats?): CR() + @Serializable @SerialName("contactCode") class ContactCode(val user: User, val contact: Contact, val connectionCode: String): CR() + @Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR() + @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: User, val verified: Boolean, val expectedCode: String): CR() + @Serializable @SerialName("invitation") class Invitation(val user: User, val connReqInvitation: String): CR() + @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: User): CR() + @Serializable @SerialName("sentInvitation") class SentInvitation(val user: User): CR() + @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: User, val contact: Contact): CR() + @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: User, val contact: Contact): CR() + @Serializable @SerialName("chatCleared") class ChatCleared(val user: User, val chatInfo: ChatInfo): CR() + @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR() + @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile): CR() + @Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val user: User, val toContact: Contact): CR() + @Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val user: User, val toConnection: PendingContactConnection): CR() + @Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val user: User, val fromContact: Contact, val toContact: Contact): CR() + @Serializable @SerialName("userContactLink") class UserContactLink(val user: User, val contactLink: UserContactLinkRec): CR() + @Serializable @SerialName("userContactLinkUpdated") class UserContactLinkUpdated(val user: User, val contactLink: UserContactLinkRec): CR() + @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val user: User, val connReqContact: String): CR() + @Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted(val user: User): CR() + @Serializable @SerialName("contactConnected") class ContactConnected(val user: User, val contact: Contact, val userCustomProfile: Profile? = null): CR() + @Serializable @SerialName("contactConnecting") class ContactConnecting(val user: User, val contact: Contact): CR() + @Serializable @SerialName("receivedContactRequest") class ReceivedContactRequest(val user: User, val contactRequest: UserContactRequest): CR() + @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: User, val contact: Contact): CR() + @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: User): CR() + @Serializable @SerialName("contactUpdated") class ContactUpdated(val user: User, val toContact: Contact): CR() @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR() @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR() - @Serializable @SerialName("contactSubError") class ContactSubError(val contact: Contact, val chatError: ChatError): CR() - @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val contactSubscriptions: List): CR() - @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val group: GroupInfo): CR() - @Serializable @SerialName("memberSubErrors") class MemberSubErrors(val memberSubErrors: List): CR() - @Serializable @SerialName("groupEmpty") class GroupEmpty(val group: GroupInfo): CR() + @Serializable @SerialName("contactSubError") class ContactSubError(val user: User, val contact: Contact, val chatError: ChatError): CR() + @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: User, val contactSubscriptions: List): CR() + @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: User, val group: GroupInfo): CR() + @Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: User, val memberSubErrors: List): CR() + @Serializable @SerialName("groupEmpty") class GroupEmpty(val user: User, val group: GroupInfo): CR() @Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR() - @Serializable @SerialName("newChatItem") class NewChatItem(val chatItem: AChatItem): CR() - @Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR() - @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR() - @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR() - @Serializable @SerialName("contactsList") class ContactsList(val contacts: List): CR() + @Serializable @SerialName("newChatItem") class NewChatItem(val user: User, val chatItem: AChatItem): CR() + @Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val user: User, val chatItem: AChatItem): CR() + @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val user: User, val chatItem: AChatItem): CR() + @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val user: User, val deletedChatItem: AChatItem, val toChatItem: AChatItem? = null, val byUser: Boolean): CR() + @Serializable @SerialName("contactsList") class ContactsList(val user: User, val contacts: List): CR() // group events - @Serializable @SerialName("groupCreated") class GroupCreated(val groupInfo: GroupInfo): CR() - @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() - @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() - @Serializable @SerialName("userDeletedMember") class UserDeletedMember(val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val groupInfo: GroupInfo): CR() - @Serializable @SerialName("groupMembers") class GroupMembers(val group: Group): CR() - @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR() - @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val groupInfo: GroupInfo): CR() - @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() - @Serializable @SerialName("memberRole") class MemberRole(val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() - @Serializable @SerialName("memberRoleUser") class MemberRoleUser(val groupInfo: GroupInfo, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() - @Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("deletedMember") class DeletedMember(val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR() - @Serializable @SerialName("leftMember") class LeftMember(val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("groupDeleted") class GroupDeleted(val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("contactsMerged") class ContactsMerged(val intoContact: Contact, val mergedContact: Contact): CR() - @Serializable @SerialName("groupInvitation") class GroupInvitation(val groupInfo: GroupInfo): CR() // unused - @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val groupInfo: GroupInfo): CR() - @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("groupRemoved") class GroupRemoved(val groupInfo: GroupInfo): CR() // unused - @Serializable @SerialName("groupUpdated") class GroupUpdated(val toGroup: GroupInfo): CR() - @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val groupInfo: GroupInfo, val connReqContact: String): CR() - @Serializable @SerialName("groupLink") class GroupLink(val groupInfo: GroupInfo, val connReqContact: String): CR() - @Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val groupInfo: GroupInfo): CR() + @Serializable @SerialName("groupCreated") class GroupCreated(val user: User, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: User, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() + @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: User, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() + @Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: User, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("groupMembers") class GroupMembers(val user: User, val group: Group): CR() + @Serializable @SerialName("receivedGroupInvitation") class ReceivedGroupInvitation(val user: User, val groupInfo: GroupInfo, val contact: Contact, val memberRole: GroupMemberRole): CR() + @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: User, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: User, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() + @Serializable @SerialName("memberRole") class MemberRole(val user: User, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() + @Serializable @SerialName("memberRoleUser") class MemberRoleUser(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() + @Serializable @SerialName("deletedMemberUser") class DeletedMemberUser(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("deletedMember") class DeletedMember(val user: User, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember): CR() + @Serializable @SerialName("leftMember") class LeftMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("groupDeleted") class GroupDeleted(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("contactsMerged") class ContactsMerged(val user: User, val intoContact: Contact, val mergedContact: Contact): CR() + @Serializable @SerialName("groupInvitation") class GroupInvitation(val user: User, val groupInfo: GroupInfo): CR() // unused + @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: User, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: User, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("groupRemoved") class GroupRemoved(val user: User, val groupInfo: GroupInfo): CR() // unused + @Serializable @SerialName("groupUpdated") class GroupUpdated(val user: User, val toGroup: GroupInfo): CR() + @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: User, val groupInfo: GroupInfo, val connReqContact: String): CR() + @Serializable @SerialName("groupLink") class GroupLink(val user: User, val groupInfo: GroupInfo, val connReqContact: String): CR() + @Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: User, val groupInfo: GroupInfo): CR() // receiving file events - @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val chatItem: AChatItem): CR() - @Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val rcvFileTransfer: RcvFileTransfer): CR() - @Serializable @SerialName("rcvFileStart") class RcvFileStart(val chatItem: AChatItem): CR() - @Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val chatItem: AChatItem): CR() + @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted(val user: User, val chatItem: AChatItem): CR() + @Serializable @SerialName("rcvFileAcceptedSndCancelled") class RcvFileAcceptedSndCancelled(val user: User, val rcvFileTransfer: RcvFileTransfer): CR() + @Serializable @SerialName("rcvFileStart") class RcvFileStart(val user: User, val chatItem: AChatItem): CR() + @Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val user: User, val chatItem: AChatItem): CR() // sending file events - @Serializable @SerialName("sndFileStart") class SndFileStart(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() - @Serializable @SerialName("sndFileComplete") class SndFileComplete(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndFileStart") class SndFileStart(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndFileComplete") class SndFileComplete(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() @Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() - @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() - @Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR() + @Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val user: User, val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR() + @Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val user: User, val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List): CR() @Serializable @SerialName("callInvitation") class CallInvitation(val callInvitation: RcvCallInvitation): CR() - @Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR() - @Serializable @SerialName("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR() - @Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR() - @Serializable @SerialName("callEnded") class CallEnded(val contact: Contact): CR() - @Serializable @SerialName("newContactConnection") class NewContactConnection(val connection: PendingContactConnection): CR() - @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val connection: PendingContactConnection): CR() + @Serializable @SerialName("callOffer") class CallOffer(val user: User, val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR() + @Serializable @SerialName("callAnswer") class CallAnswer(val user: User, val contact: Contact, val answer: WebRTCSession): CR() + @Serializable @SerialName("callExtraInfo") class CallExtraInfo(val user: User, val contact: Contact, val extraInfo: WebRTCExtraInfo): CR() + @Serializable @SerialName("callEnded") class CallEnded(val user: User, val contact: Contact): CR() + @Serializable @SerialName("newContactConnection") class NewContactConnection(val user: User, val connection: PendingContactConnection): CR() + @Serializable @SerialName("contactConnectionDeleted") class ContactConnectionDeleted(val user: User, val connection: PendingContactConnection): CR() @Serializable @SerialName("versionInfo") class VersionInfo(val versionInfo: CoreVersionInfo): CR() - @Serializable @SerialName("cmdOk") class CmdOk: CR() - @Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR() - @Serializable @SerialName("chatError") class ChatRespError(val chatError: ChatError): CR() + @Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List? = null): CR() + @Serializable @SerialName("cmdOk") class CmdOk(val user: User?): CR() + @Serializable @SerialName("chatCmdError") class ChatCmdError(val user: User?, val chatError: ChatError): CR() + @Serializable @SerialName("chatError") class ChatRespError(val user: User?, val chatError: ChatError): CR() @Serializable class Response(val type: String, val json: String): CR() @Serializable class Invalid(val str: String): CR() val responseType: String get() = when(this) { is ActiveUser -> "activeUser" + is UsersList -> "usersList" is ChatStarted -> "chatStarted" is ChatRunning -> "chatRunning" is ChatStopped -> "chatStopped" @@ -2780,7 +2981,6 @@ sealed class CR { is ContactAliasUpdated -> "contactAliasUpdated" is ConnectionAliasUpdated -> "connectionAliasUpdated" is ContactPrefsUpdated -> "contactPrefsUpdated" - is ParsedMarkdown -> "apiParsedMarkdown" is UserContactLink -> "userContactLink" is UserContactLinkUpdated -> "userContactLinkUpdated" is UserContactLinkCreated -> "userContactLinkCreated" @@ -2846,6 +3046,7 @@ sealed class CR { is NewContactConnection -> "newContactConnection" is ContactConnectionDeleted -> "contactConnectionDeleted" is VersionInfo -> "versionInfo" + is ParsedMarkdown -> "apiParsedMarkdown" is CmdOk -> "cmdOk" is ChatCmdError -> "chatCmdError" is ChatRespError -> "chatError" @@ -2854,106 +3055,109 @@ sealed class CR { } val details: String get() = when(this) { - is ActiveUser -> json.encodeToString(user) + is ActiveUser -> withUser(user, json.encodeToString(user)) + is UsersList -> json.encodeToString(users) is ChatStarted -> noDetails() is ChatRunning -> noDetails() is ChatStopped -> noDetails() - is ApiChats -> json.encodeToString(chats) - is ApiChat -> json.encodeToString(chat) - is UserSMPServers -> "$smpServers: ${json.encodeToString(smpServers)}\n$presetSMPServers: ${json.encodeToString(presetSMPServers)}" - is SmpTestResult -> json.encodeToString(smpTestFailure) - is ChatItemTTL -> json.encodeToString(chatItemTTL) + is ApiChats -> withUser(user, json.encodeToString(chats)) + is ApiChat -> withUser(user, json.encodeToString(chat)) + is UserSMPServers -> withUser(user, "$smpServers: ${json.encodeToString(smpServers)}\n$presetSMPServers: ${json.encodeToString(presetSMPServers)}") + is SmpTestResult -> withUser(user, json.encodeToString(smpTestFailure)) + is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL)) is NetworkConfig -> json.encodeToString(networkConfig) - is ContactInfo -> "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}" - is GroupMemberInfo -> "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}" - is ContactCode -> "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode" - is GroupMemberCode -> "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode" - is ConnectionVerified -> "verified: $verified\nconnectionCode: $expectedCode" - is Invitation -> connReqInvitation - is SentConfirmation -> noDetails() - is SentInvitation -> noDetails() - is ContactAlreadyExists -> json.encodeToString(contact) - is ContactDeleted -> json.encodeToString(contact) - is ChatCleared -> json.encodeToString(chatInfo) - is UserProfileNoChange -> noDetails() - is UserProfileUpdated -> json.encodeToString(toProfile) - is ContactAliasUpdated -> json.encodeToString(toContact) - is ConnectionAliasUpdated -> json.encodeToString(toConnection) - is ContactPrefsUpdated -> "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}" + is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}") + is GroupMemberInfo -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}") + is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode") + is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode") + is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") + is Invitation -> withUser(user, connReqInvitation) + is SentConfirmation -> withUser(user, noDetails()) + is SentInvitation -> withUser(user, noDetails()) + is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) + is ContactDeleted -> withUser(user, json.encodeToString(contact)) + is ChatCleared -> withUser(user, json.encodeToString(chatInfo)) + is UserProfileNoChange -> withUser(user, noDetails()) + is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile)) + is ContactAliasUpdated -> withUser(user, json.encodeToString(toContact)) + is ConnectionAliasUpdated -> withUser(user, json.encodeToString(toConnection)) + is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}") is ParsedMarkdown -> json.encodeToString(formattedText) - is UserContactLink -> contactLink.responseDetails - is UserContactLinkUpdated -> contactLink.responseDetails - is UserContactLinkCreated -> connReqContact - is UserContactLinkDeleted -> noDetails() - is ContactConnected -> json.encodeToString(contact) - is ContactConnecting -> json.encodeToString(contact) - is ReceivedContactRequest -> json.encodeToString(contactRequest) - is AcceptingContactRequest -> json.encodeToString(contact) - is ContactRequestRejected -> noDetails() - is ContactUpdated -> json.encodeToString(toContact) + is UserContactLink -> withUser(user, contactLink.responseDetails) + is UserContactLinkUpdated -> withUser(user, contactLink.responseDetails) + is UserContactLinkCreated -> withUser(user, connReqContact) + is UserContactLinkDeleted -> withUser(user, noDetails()) + is ContactConnected -> withUser(user, json.encodeToString(contact)) + is ContactConnecting -> withUser(user, json.encodeToString(contact)) + is ReceivedContactRequest -> withUser(user, json.encodeToString(contactRequest)) + is AcceptingContactRequest -> withUser(user, json.encodeToString(contact)) + is ContactRequestRejected -> withUser(user, noDetails()) + is ContactUpdated -> withUser(user, json.encodeToString(toContact)) is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" - is ContactSubError -> "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}" - is ContactSubSummary -> json.encodeToString(contactSubscriptions) - is GroupSubscribed -> json.encodeToString(group) - is MemberSubErrors -> json.encodeToString(memberSubErrors) - is GroupEmpty -> json.encodeToString(group) + is ContactSubError -> withUser(user, "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}") + is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions)) + is GroupSubscribed -> withUser(user, json.encodeToString(group)) + is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors)) + is GroupEmpty -> withUser(user, json.encodeToString(group)) is UserContactLinkSubscribed -> noDetails() - is NewChatItem -> json.encodeToString(chatItem) - is ChatItemStatusUpdated -> json.encodeToString(chatItem) - is ChatItemUpdated -> json.encodeToString(chatItem) - is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}\nbyUser: $byUser" - is ContactsList -> json.encodeToString(contacts) - is GroupCreated -> json.encodeToString(groupInfo) - is SentGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact\nmember: $member" + is NewChatItem -> withUser(user, json.encodeToString(chatItem)) + is ChatItemStatusUpdated -> withUser(user, json.encodeToString(chatItem)) + is ChatItemUpdated -> withUser(user, json.encodeToString(chatItem)) + is ChatItemDeleted -> withUser(user, "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}\nbyUser: $byUser") + is ContactsList -> withUser(user, json.encodeToString(contacts)) + is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) + is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is UserAcceptedGroupSent -> json.encodeToString(groupInfo) - is UserDeletedMember -> "groupInfo: $groupInfo\nmember: $member" - is LeftMemberUser -> json.encodeToString(groupInfo) - is GroupMembers -> json.encodeToString(group) - is ReceivedGroupInvitation -> "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole" - is GroupDeletedUser -> json.encodeToString(groupInfo) - is JoinedGroupMemberConnecting -> "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member" - is MemberRole -> "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole" - is MemberRoleUser -> "groupInfo: $groupInfo\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole" - is DeletedMemberUser -> "groupInfo: $groupInfo\nmember: $member" - is DeletedMember -> "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember" - is LeftMember -> "groupInfo: $groupInfo\nmember: $member" - is GroupDeleted -> "groupInfo: $groupInfo\nmember: $member" - is ContactsMerged -> "intoContact: $intoContact\nmergedContact: $mergedContact" - is GroupInvitation -> json.encodeToString(groupInfo) - is UserJoinedGroup -> json.encodeToString(groupInfo) - is JoinedGroupMember -> "groupInfo: $groupInfo\nmember: $member" - is ConnectedToGroupMember -> "groupInfo: $groupInfo\nmember: $member" - is GroupRemoved -> json.encodeToString(groupInfo) - is GroupUpdated -> json.encodeToString(toGroup) - is GroupLinkCreated -> "groupInfo: $groupInfo\nconnReqContact: $connReqContact" - is GroupLink -> "groupInfo: $groupInfo\nconnReqContact: $connReqContact" - is GroupLinkDeleted -> json.encodeToString(groupInfo) - is RcvFileAcceptedSndCancelled -> noDetails() - is RcvFileAccepted -> json.encodeToString(chatItem) - is RcvFileStart -> json.encodeToString(chatItem) - is RcvFileComplete -> json.encodeToString(chatItem) + is UserDeletedMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo)) + is GroupMembers -> withUser(user, json.encodeToString(group)) + is ReceivedGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmemberRole: $memberRole") + is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo)) + is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member") + is MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") + is MemberRoleUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") + is DeletedMemberUser -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is DeletedMember -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember") + is LeftMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is GroupDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is ContactsMerged -> withUser(user, "intoContact: $intoContact\nmergedContact: $mergedContact") + is GroupInvitation -> withUser(user, json.encodeToString(groupInfo)) + is UserJoinedGroup -> withUser(user, json.encodeToString(groupInfo)) + is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is GroupRemoved -> withUser(user, json.encodeToString(groupInfo)) + is GroupUpdated -> withUser(user, json.encodeToString(toGroup)) + is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact") + is GroupLink -> withUser(user, "groupInfo: $groupInfo\nconnReqContact: $connReqContact") + is GroupLinkDeleted -> withUser(user, json.encodeToString(groupInfo)) + is RcvFileAcceptedSndCancelled -> withUser(user, noDetails()) + is RcvFileAccepted -> withUser(user, json.encodeToString(chatItem)) + is RcvFileStart -> withUser(user, json.encodeToString(chatItem)) + is RcvFileComplete -> withUser(user, json.encodeToString(chatItem)) is SndFileCancelled -> json.encodeToString(chatItem) - is SndFileComplete -> json.encodeToString(chatItem) - is SndFileRcvCancelled -> json.encodeToString(chatItem) - is SndFileStart -> json.encodeToString(chatItem) - is SndGroupFileCancelled -> json.encodeToString(chatItem) + is SndFileComplete -> withUser(user, json.encodeToString(chatItem)) + is SndFileRcvCancelled -> withUser(user, json.encodeToString(chatItem)) + is SndFileStart -> withUser(user, json.encodeToString(chatItem)) + is SndGroupFileCancelled -> withUser(user, json.encodeToString(chatItem)) is CallInvitation -> "contact: ${callInvitation.contact.id}\ncallType: $callInvitation.callType\nsharedKey: ${callInvitation.sharedKey ?: ""}" - is CallOffer -> "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}" - is CallAnswer -> "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}" - is CallExtraInfo -> "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}" - is CallEnded -> "contact: ${contact.id}" - is NewContactConnection -> json.encodeToString(connection) - is ContactConnectionDeleted -> json.encodeToString(connection) + is CallOffer -> withUser(user, "contact: ${contact.id}\ncallType: $callType\nsharedKey: ${sharedKey ?: ""}\naskConfirmation: $askConfirmation\noffer: ${json.encodeToString(offer)}") + is CallAnswer -> withUser(user, "contact: ${contact.id}\nanswer: ${json.encodeToString(answer)}") + is CallExtraInfo -> withUser(user, "contact: ${contact.id}\nextraInfo: ${json.encodeToString(extraInfo)}") + is CallEnded -> withUser(user, "contact: ${contact.id}") + is NewContactConnection -> withUser(user, json.encodeToString(connection)) + is ContactConnectionDeleted -> withUser(user, json.encodeToString(connection)) is VersionInfo -> json.encodeToString(versionInfo) - is CmdOk -> noDetails() - is ChatCmdError -> chatError.string - is ChatRespError -> chatError.string + is CmdOk -> withUser(user, noDetails()) + is ChatCmdError -> withUser(user, chatError.string) + is ChatRespError -> withUser(user, chatError.string) is Response -> json is Invalid -> str } fun noDetails(): String ="${responseType}: " + generalGetString(R.string.no_details) + + private fun withUser(u: User?, s: String): String = if (u != null) "userId: ${u.userId}\n$s" else s } abstract class TerminalItem { @@ -3029,11 +3233,13 @@ sealed class ChatError { sealed class ChatErrorType { val string: String get() = when (this) { is NoActiveUser -> "noActiveUser" + is DifferentActiveUser -> "differentActiveUser" is InvalidConnReq -> "invalidConnReq" is FileAlreadyReceiving -> "fileAlreadyReceiving" is СommandError -> "commandError $message" } @Serializable @SerialName("noActiveUser") class NoActiveUser: ChatErrorType() + @Serializable @SerialName("differentActiveUser") class DifferentActiveUser: ChatErrorType() @Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType() @Serializable @SerialName("fileAlreadyReceiving") class FileAlreadyReceiving: ChatErrorType() @Serializable @SerialName("commandError") class СommandError(val message: String): ChatErrorType() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index b827e92b89..18ec98fc7b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -102,7 +102,7 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index bfb3ef089f..dc5286fd6e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -54,10 +54,14 @@ fun ChatInfoView( val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } val developerTools = chatModel.controller.appPrefs.developerTools.get() if (chat != null) { + val contactNetworkStatus = remember(chatModel.networkStatuses.toMap()) { + mutableStateOf(chatModel.contactNetworkStatus(contact)) + } ChatInfoLayout( chat, contact, connStats, + contactNetworkStatus.value, customUserProfile, localAlias, connectionCode, @@ -149,6 +153,7 @@ fun ChatInfoLayout( chat: Chat, contact: Contact, connStats: ConnectionStats?, + contactNetworkStatus: NetworkStatus, customUserProfile: Profile?, localAlias: String, connectionCode: String?, @@ -200,9 +205,9 @@ fun ChatInfoLayout( SectionItemView({ AlertManager.shared.showAlertMsg( generalGetString(R.string.network_status), - chat.serverInfo.networkStatus.statusExplanation + contactNetworkStatus.statusExplanation )}) { - NetworkStatusRow(chat.serverInfo.networkStatus) + NetworkStatusRow(contactNetworkStatus) } val rcvServers = connStats.rcvServers if (rcvServers != null && rcvServers.isNotEmpty()) { @@ -314,7 +319,7 @@ fun LocalAliasEditor( } @Composable -private fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) { +private fun NetworkStatusRow(networkStatus: NetworkStatus) { Row( Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween, @@ -346,14 +351,14 @@ private fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) { } @Composable -private fun ServerImage(networkStatus: Chat.NetworkStatus) { +private fun ServerImage(networkStatus: NetworkStatus) { Box(Modifier.size(18.dp)) { when (networkStatus) { - is Chat.NetworkStatus.Connected -> + is NetworkStatus.Connected -> Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant) - is Chat.NetworkStatus.Disconnected -> + is NetworkStatus.Disconnected -> Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight) - is Chat.NetworkStatus.Error -> + is NetworkStatus.Error -> Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight) else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight) } @@ -446,14 +451,14 @@ fun PreviewChatInfoLayout() { ChatInfoLayout( chat = Chat( chatInfo = ChatInfo.Direct.sampleData, - chatItems = arrayListOf(), - serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT")) + chatItems = arrayListOf() ), Contact.sampleData, localAlias = "", connectionCode = "123", developerTools = false, connStats = null, + contactNetworkStatus = NetworkStatus.Connected(), onLocalAliasChanged = {}, customUserProfile = null, openPreferences = {}, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt index 874c0db684..24b2b9ab3f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupChatInfoView.kt @@ -427,8 +427,7 @@ fun PreviewGroupChatInfoLayout() { GroupChatInfoLayout( chat = Chat( chatInfo = ChatInfo.Direct.sampleData, - chatItems = arrayListOf(), - serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT")) + chatItems = arrayListOf() ), groupInfo = GroupInfo.sampleData, members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData), diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt index c32b52914e..3ec6d1ed38 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/group/GroupMemberInfoView.kt @@ -66,11 +66,9 @@ fun GroupMemberInfoView( withApi { val c = chatModel.controller.apiGetChat(ChatType.Direct, it) if (c != null) { - // TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend - val newChat = c.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected())) - chatModel.addChat(newChat) + chatModel.addChat(c) chatModel.chatItems.clear() - chatModel.chatId.value = newChat.id + chatModel.chatId.value = c.id closeAll() } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index ff88b3762b..0a6fd54463 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -44,17 +44,19 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { delay(500L) } when (chat.chatInfo) { - is ChatInfo.Direct -> + is ChatInfo.Direct -> { + val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) }, + chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) }, click = { directChatAction(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, showMenu, stopped ) + } is ChatInfo.Group -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped, linkMode) }, + chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) }, click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) }, showMenu, @@ -627,6 +629,7 @@ fun PreviewChatListNavLinkDirect() { ), false, null, + null, stopped = false, linkMode = SimplexLinkMode.DESCRIPTION ) @@ -665,6 +668,7 @@ fun PreviewChatListNavLinkGroup() { ), false, null, + null, stopped = false, linkMode = SimplexLinkMode.DESCRIPTION ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index 2131c63f93..d3d3d23534 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -4,6 +4,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons @@ -13,16 +14,15 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.* import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.* +import chat.simplex.app.* import chat.simplex.app.R -import chat.simplex.app.connectIfOpenedViaUri import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* @@ -37,13 +37,14 @@ import kotlinx.coroutines.launch @Composable fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { - val newChatSheetState by rememberSaveable(stateSaver = NewChatSheetState.saver()) { mutableStateOf(MutableStateFlow(NewChatSheetState.GONE)) } + val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } + val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } val showNewChatSheet = { - newChatSheetState.value = NewChatSheetState.VISIBLE + newChatSheetState.value = AnimatedViewState.VISIBLE } val hideNewChatSheet: (animated: Boolean) -> Unit = { animated -> - if (animated) newChatSheetState.value = NewChatSheetState.HIDING - else newChatSheetState.value = NewChatSheetState.GONE + if (animated) newChatSheetState.value = AnimatedViewState.HIDING + else newChatSheetState.value = AnimatedViewState.GONE } LaunchedEffect(Unit) { if (shouldShowWhatsNew(chatModel)) { @@ -63,8 +64,9 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: } var searchInList by rememberSaveable { mutableStateOf("") } val scaffoldState = rememberScaffoldState() + val scope = rememberCoroutineScope() Scaffold( - topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, stopped) { searchInList = it.trim() } }, + topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } }, scaffoldState = scaffoldState, drawerContent = { SettingsView(chatModel, setPerformLA) }, floatingActionButton = { @@ -111,6 +113,9 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: if (searchInList.isEmpty()) { NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) } + UserPicker(chatModel, userPickerState) { + scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } + } } @Composable @@ -156,7 +161,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) { } @Composable -private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stopped: Boolean, onSearchValueChanged: (String) -> Unit) { +private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, userPickerState: MutableStateFlow, stopped: Boolean, onSearchValueChanged: (String) -> Unit) { var showSearch by rememberSaveable { mutableStateOf(false) } val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false } if (showSearch) { @@ -189,10 +194,23 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop val scope = rememberCoroutineScope() DefaultTopAppBar( navigationButton = { - if (showSearch) + if (showSearch) { NavigationButtonBack(hideSearchOnBack) - else + } else if (chatModel.users.isEmpty()) { NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } } + } else { + val users by remember { derivedStateOf { chatModel.users.toList() } } + val allRead = users + .filter { !it.user.activeUser } + .all { u -> u.unreadCount == 0 } + UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) { + if (users.size == 1) { + scope.launch { drawerState.open() } + } else { + userPickerState.value = AnimatedViewState.VISIBLE + } + } + } }, title = { Row(verticalAlignment = Alignment.CenterVertically) { @@ -219,6 +237,36 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop Divider(Modifier.padding(top = AppBarHeight)) } +@Composable +private fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) { + IconButton(onClick = onButtonClicked) { + Box { + ProfileImage( + image = image, + size = 37.dp + ) + if (!allRead) { + unreadBadge() + } + } + } +} + +@Composable +private fun BoxScope.unreadBadge(text: String? = "") { + Text( + text ?: "", + color = MaterialTheme.colors.onPrimary, + fontSize = 6.sp, + modifier = Modifier + .background(MaterialTheme.colors.primary, shape = CircleShape) + .badgeLayout() + .padding(horizontal = 3.dp) + .padding(vertical = 1.dp) + .align(Alignment.TopEnd) + ) +} + private var lazyListState = 0 to 0 @Composable diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index 7bdc675a6d..a385efda46 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -25,7 +25,14 @@ import chat.simplex.app.views.chat.item.MarkdownText import chat.simplex.app.views.helpers.* @Composable -fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean, linkMode: SimplexLinkMode) { +fun ChatPreviewView( + chat: Chat, + chatModelIncognito: Boolean, + currentUserProfileDisplayName: String?, + contactNetworkStatus: NetworkStatus?, + stopped: Boolean, + linkMode: SimplexLinkMode +) { val cInfo = chat.chatInfo @Composable @@ -187,7 +194,9 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD Modifier.padding(top = 52.dp), contentAlignment = Alignment.Center ) { - ChatStatusImage(chat) + if (chat.chatInfo is ChatInfo.Direct) { + ChatStatusImage(chat, contactNetworkStatus) + } } } } @@ -210,10 +219,9 @@ fun unreadCountStr(n: Int): String { } @Composable -fun ChatStatusImage(chat: Chat) { - val s = chat.serverInfo.networkStatus - val descr = s.statusString - if (s is Chat.NetworkStatus.Error) { +fun ChatStatusImage(chat: Chat, s: NetworkStatus?) { + val descr = s?.statusString + if (s is NetworkStatus.Error) { Icon( Icons.Outlined.ErrorOutline, contentDescription = descr, @@ -221,7 +229,7 @@ fun ChatStatusImage(chat: Chat) { modifier = Modifier .size(19.dp) ) - } else if (s !is Chat.NetworkStatus.Connected) { + } else if (s !is NetworkStatus.Connected) { CircularProgressIndicator( Modifier .padding(horizontal = 2.dp) @@ -241,6 +249,6 @@ fun ChatStatusImage(chat: Chat) { @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, false, "", stopped = false, linkMode = SimplexLinkMode.DESCRIPTION) + ChatPreviewView(Chat.sampleData, false, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt new file mode 100644 index 0000000000..605dc117d7 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/UserPicker.kt @@ -0,0 +1,200 @@ +package chat.simplex.app.views.chatlist + +import SectionItemViewSpaceBetween +import android.util.Log +import androidx.compose.animation.core.* +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.capitalize +import androidx.compose.ui.text.intl.Locale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.* +import chat.simplex.app.R +import chat.simplex.app.TAG +import chat.simplex.app.model.ChatModel +import chat.simplex.app.model.UserInfo +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Composable +fun UserPicker(chatModel: ChatModel, userPickerState: MutableStateFlow, openSettings: () -> Unit) { + val scope = rememberCoroutineScope() + var newChat by remember { mutableStateOf(userPickerState.value) } + val users by remember { derivedStateOf { chatModel.users.sortedByDescending { it.user.activeUser } } } + val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) } + var progressIndicator by remember { mutableStateOf(false) } + if (progressIndicator) { + Box( + Modifier.fillMaxSize().clickable(enabled = false, onClick = {}), + contentAlignment = Alignment.Center + ) { + ProgressIndicator() + } + } + LaunchedEffect(Unit) { + launch { + userPickerState.collect { + newChat = it + launch { + animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec()) + if (newChat.isHiding()) userPickerState.value = AnimatedViewState.GONE + } + } + } + } + LaunchedEffect(Unit) { + snapshotFlow { newChat.isVisible() } + .distinctUntilChanged() + .filter { it } + .collect { + try { + val updatedUsers = chatModel.controller.listUsers().sortedByDescending { it.user.activeUser } + var same = users.size == updatedUsers.size + if (same) { + for (i in 0 until minOf(users.size, updatedUsers.size)) { + val prev = updatedUsers[i].user + val next = users[i].user + if (prev.userId != next.userId || prev.activeUser != next.activeUser || prev.chatViewName != next.chatViewName || prev.image != next.image) { + same = false + break + } + } + } + if (!same) { + chatModel.users.clear() + chatModel.users.addAll(updatedUsers) + } + } catch (e: Exception) { + Log.e(TAG, "Error updating users ${e.stackTraceToString()}") + } + } + } + val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() } + val maxWidth = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp * density } + Box(Modifier + .fillMaxSize() + .offset { IntOffset(if (newChat.isGone()) -maxWidth.roundToInt() else xOffset, 0) } + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING }) + .padding(bottom = 10.dp, top = 10.dp) + .graphicsLayer { + alpha = animatedFloat.value + translationY = (animatedFloat.value - 1) * xOffset + } + ) { + Column( + Modifier + .widthIn(min = 220.dp) + .width(IntrinsicSize.Min) + .height(IntrinsicSize.Min) + .shadow(8.dp, MaterialTheme.shapes.medium, clip = false) + .background(MaterialTheme.colors.background, MaterialTheme.shapes.medium) + ) { + Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { + users.forEachIndexed { i, u -> + UserProfilePickerItem(u) { + userPickerState.value = AnimatedViewState.HIDING + if (!u.user.activeUser) { + chatModel.chats.clear() + scope.launch { + val job = launch { + delay(500) + progressIndicator = true + } + chatModel.controller.changeActiveUser(u.user.userId) + job.cancel() + progressIndicator = false + } + } + } + if (i != users.lastIndex) { + Divider(Modifier.requiredHeight(1.dp)) + } + } + } + Divider() + SettingsPickerItem { + openSettings() + userPickerState.value = AnimatedViewState.GONE + } + } + } +} + +@Composable +private fun UserProfilePickerItem(u: UserInfo, onClick: () -> Unit) { + SectionItemViewSpaceBetween(onClick, padding = PaddingValues(start = 8.dp, end = DEFAULT_PADDING)) { + Row( + Modifier + .widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ProfileImage( + image = u.user.image, + size = 54.dp + ) + Text( + u.user.chatViewName, + modifier = Modifier + .padding(start = 8.dp, end = 8.dp) + ) + } + if (u.user.activeUser) { + Icon(Icons.Filled.Done, null, Modifier.size(20.dp), tint = MaterialTheme.colors.primary) + } else if (u.unreadCount > 0) { + Text( + unreadCountStr(u.unreadCount), + color = MaterialTheme.colors.onPrimary, + fontSize = 11.sp, + modifier = Modifier + .background(MaterialTheme.colors.primary, shape = CircleShape) + .sizeIn(minWidth = 20.dp, minHeight = 20.dp) + .padding(horizontal = 3.dp) + .padding(vertical = 1.dp), + textAlign = TextAlign.Center, + maxLines = 1 + ) + } else { + Box(Modifier.size(20.dp)) + } + } +} + +@Composable +private fun SettingsPickerItem(onClick: () -> Unit) { + SectionItemViewSpaceBetween(onClick, minHeight = 68.dp) { + val text = generalGetString(R.string.settings_section_title_settings).lowercase().capitalize(Locale.current) + Text( + text, + color = MaterialTheme.colors.onBackground, + ) + Icon(Icons.Outlined.Settings, text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground) + } +} + +@Composable +private fun ProgressIndicator() { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(30.dp), + color = HighOrLowlight, + strokeWidth = 2.5.dp + ) +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt index a7ed87d761..d6729ac68d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt @@ -1,6 +1,5 @@ package chat.simplex.app.views.helpers -import android.graphics.Bitmap import android.net.Uri import androidx.compose.runtime.saveable.Saver import kotlinx.coroutines.flow.MutableStateFlow @@ -11,7 +10,7 @@ sealed class SharedContent { data class File(val text: String, val uri: Uri): SharedContent() } -enum class NewChatSheetState { +enum class AnimatedViewState { VISIBLE, HIDING, GONE; fun isVisible(): Boolean { return this == VISIBLE @@ -23,7 +22,7 @@ enum class NewChatSheetState { return this == GONE } companion object { - fun saver(): Saver, *> = Saver( + fun saver(): Saver, *> = Saver( save = { it.value.toString() }, restore = { MutableStateFlow(valueOf(it)) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt index 5b8a198983..f0d17d5803 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt @@ -50,8 +50,8 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder { rec.setAudioEncodingBitRate(16000) rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING) rec.setMaxFileSize(recordedBytesLimit) - val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val path = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a"))) + val fileToSave = generateNewFileName(SimplexApp.context, "voice", "m4a") + val path = getAppFilePath(SimplexApp.context, fileToSave) filePath = path rec.setOutputFile(path) rec.prepare() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index a620b6725e..27e2f5220f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -331,8 +331,7 @@ fun saveImage(context: Context, image: Bitmap): String? { return try { val ext = if (image.hasAlpha()) "png" else "jpg" val dataResized = resizeImageToDataSize(image, ext == "png", maxDataSize = MAX_IMAGE_SIZE) - val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val fileToSave = uniqueCombine(context, "IMG_${timestamp}.$ext") + val fileToSave = generateNewFileName(context, "IMG", ext) val file = File(getAppFilePath(context, fileToSave)) val output = FileOutputStream(file) dataResized.writeTo(output) @@ -355,8 +354,7 @@ fun saveAnimImage(context: Context, uri: Uri): String? { } // Just in case the image has a strange extension if (ext.length < 3 || ext.length > 4) ext = "gif" - val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - val fileToSave = uniqueCombine(context, "IMG_${timestamp}.$ext") + val fileToSave = generateNewFileName(context, "IMG", ext) val file = File(getAppFilePath(context, fileToSave)) val output = FileOutputStream(file) context.contentResolver.openInputStream(uri)!!.use { input -> @@ -390,15 +388,23 @@ fun saveFileFromUri(context: Context, uri: Uri): String? { } } +fun generateNewFileName(context: Context, prefix: String, ext: String): String { + val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) + sdf.timeZone = TimeZone.getTimeZone("GMT") + val timestamp = sdf.format(Date()) + return uniqueCombine(context, "${prefix}_$timestamp.$ext") +} + fun uniqueCombine(context: Context, fileName: String): String { - fun tryCombine(fileName: String, n: Int): String { - val name = File(fileName).nameWithoutExtension - val ext = File(fileName).extension + val orig = File(fileName) + val name = orig.nameWithoutExtension + val ext = orig.extension + fun tryCombine(n: Int): String { val suffix = if (n == 0) "" else "_$n" val f = "$name$suffix.$ext" - return if (File(getAppFilePath(context, f)).exists()) tryCombine(fileName, n + 1) else f + return if (File(getAppFilePath(context, f)).exists()) tryCombine(n + 1) else f } - return tryCombine(fileName, 0) + return tryCombine(0) } fun formatBytes(bytes: Long): String { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt index a1a262a933..02ebdf6b89 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt @@ -37,7 +37,7 @@ import kotlinx.coroutines.launch import kotlin.math.roundToInt @Composable -fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { +fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow, stopped: Boolean, closeNewChatSheet: (animated: Boolean) -> Unit) { if (newChatSheetState.collectAsState().value.isVisible()) BackHandler { closeNewChatSheet(true) } NewChatSheetLayout( newChatSheetState, @@ -63,7 +63,7 @@ private val icons = listOf(Icons.Outlined.AddLink, Icons.Outlined.QrCode, Icons. @Composable private fun NewChatSheetLayout( - newChatSheetState: StateFlow, + newChatSheetState: StateFlow, stopped: Boolean, addContact: () -> Unit, connectViaLink: () -> Unit, @@ -216,7 +216,7 @@ fun ActionButton( private fun PreviewNewChatSheet() { SimpleXTheme { NewChatSheetLayout( - MutableStateFlow(NewChatSheetState.VISIBLE), + MutableStateFlow(AnimatedViewState.VISIBLE), stopped = false, addContact = {}, connectViaLink = {}, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/AdvancedNetworkSettings.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/AdvancedNetworkSettings.kt index 8f3618e4e3..45b8d3c650 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/AdvancedNetworkSettings.kt @@ -60,6 +60,9 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) { } return NetCfg( socksProxy = currentCfg.value.socksProxy, + hostMode = currentCfg.value.hostMode, + requiredHostMode = currentCfg.value.requiredHostMode, + sessionMode = currentCfg.value.sessionMode, tcpConnectTimeout = networkTCPConnectTimeout.value, tcpTimeout = networkTCPTimeout.value, tcpKeepAlive = tcpKeepAlive, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt index cc0a6de7f2..7ab7a9419d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/NetworkAndServers.kt @@ -31,6 +31,7 @@ fun NetworkAndServersView( val networkUseSocksProxy: MutableState = remember { mutableStateOf(netCfg.useSocksProxy) } val developerTools = chatModel.controller.appPrefs.developerTools.get() val onionHosts = remember { mutableStateOf(netCfg.onionHosts) } + val sessionMode = remember { mutableStateOf(netCfg.sessionMode) } LaunchedEffect(Unit) { chatModel.userSMPServersUnsaved.value = null @@ -40,6 +41,7 @@ fun NetworkAndServersView( developerTools = developerTools, networkUseSocksProxy = networkUseSocksProxy, onionHosts = onionHosts, + sessionMode = sessionMode, showModal = showModal, showSettingsModal = showSettingsModal, toggleSocksProxy = { enable -> @@ -82,9 +84,13 @@ fun NetworkAndServersView( OnionHosts.PREFER -> generalGetString(R.string.network_use_onion_hosts_prefer_desc_in_alert) OnionHosts.REQUIRED -> generalGetString(R.string.network_use_onion_hosts_required_desc_in_alert) } - updateOnionHostsDialog(startsWith, onDismiss = { - onionHosts.value = prevValue - }) { + updateNetworkSettingsDialog( + title = generalGetString(R.string.update_onion_hosts_settings_question), + startsWith, + onDismiss = { + onionHosts.value = prevValue + } + ) { withApi { val newCfg = chatModel.controller.getNetCfg().withOnionHosts(it) val res = chatModel.controller.apiSetNetworkConfig(newCfg) @@ -96,6 +102,31 @@ fun NetworkAndServersView( } } } + }, + updateSessionMode = { + if (sessionMode.value == it) return@NetworkAndServersLayout + val prevValue = sessionMode.value + sessionMode.value = it + val startsWith = when (it) { + TransportSessionMode.User -> generalGetString(R.string.network_session_mode_user_description) + TransportSessionMode.Entity -> generalGetString(R.string.network_session_mode_entity_description) + } + updateNetworkSettingsDialog( + title = generalGetString(R.string.update_network_session_mode_question), + startsWith, + onDismiss = { sessionMode.value = prevValue } + ) { + withApi { + val newCfg = chatModel.controller.getNetCfg().copy(sessionMode = it) + val res = chatModel.controller.apiSetNetworkConfig(newCfg) + if (res) { + chatModel.controller.setNetCfg(newCfg) + sessionMode.value = it + } else { + sessionMode.value = prevValue + } + } + } } ) } @@ -104,10 +135,12 @@ fun NetworkAndServersView( developerTools: Boolean, networkUseSocksProxy: MutableState, onionHosts: MutableState, + sessionMode: MutableState, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), toggleSocksProxy: (Boolean) -> Unit, useOnion: (OnionHosts) -> Unit, + updateSessionMode: (TransportSessionMode) -> Unit, ) { Column( Modifier.fillMaxWidth(), @@ -124,6 +157,10 @@ fun NetworkAndServersView( SectionDivider() UseOnionHosts(onionHosts, networkUseSocksProxy, showSettingsModal, useOnion) SectionDivider() + if (developerTools) { + SessionModePicker(sessionMode, showSettingsModal, updateSessionMode) + SectionDivider() + } SettingsActionItem(Icons.Outlined.Cable, stringResource(R.string.network_settings), showSettingsModal { AdvancedNetworkSettingsView(it) }) } Spacer(Modifier.height(8.dp)) @@ -183,7 +220,6 @@ private fun UseOnionHosts( } } val onSelected = showModal { - Column( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start, @@ -203,14 +239,47 @@ private fun UseOnionHosts( ) } -private fun updateOnionHostsDialog( +@Composable +private fun SessionModePicker( + sessionMode: MutableState, + showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), + updateSessionMode: (TransportSessionMode) -> Unit, +) { + val values = remember { + TransportSessionMode.values().map { + when (it) { + TransportSessionMode.User -> ValueTitleDesc(TransportSessionMode.User, generalGetString(R.string.network_session_mode_user), generalGetString(R.string.network_session_mode_user_description)) + TransportSessionMode.Entity -> ValueTitleDesc(TransportSessionMode.Entity, generalGetString(R.string.network_session_mode_entity), generalGetString(R.string.network_session_mode_entity_description)) + } + } + } + + SectionItemWithValue( + generalGetString(R.string.network_session_mode_transport_isolation), + sessionMode, + values, + icon = Icons.Outlined.SafetyDivider, + onSelected = showModal { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + AppBarTitle(stringResource(R.string.network_session_mode_transport_isolation)) + SectionViewSelectable(null, sessionMode, values, updateSessionMode) + } + } + ) +} + +private fun updateNetworkSettingsDialog( + title: String, startsWith: String = "", message: String = generalGetString(R.string.updating_settings_will_reconnect_client_to_all_servers), onDismiss: () -> Unit, onConfirm: () -> Unit ) { AlertManager.shared.showAlertDialog( - title = generalGetString(R.string.update_onion_hosts_settings_question), + title = title, text = startsWith + "\n\n" + message, confirmText = generalGetString(R.string.update_network_settings_confirmation), onDismiss = onDismiss, @@ -230,7 +299,9 @@ fun PreviewNetworkAndServersLayout() { showSettingsModal = { {} }, toggleSocksProxy = {}, onionHosts = remember { mutableStateOf(OnionHosts.PREFER) }, + sessionMode = remember { mutableStateOf(TransportSessionMode.User) }, useOnion = {}, + updateSessionMode = {}, ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt index b1de4d8d5f..113e454b1f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt @@ -29,12 +29,8 @@ fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) { val newProfile = user.profile.toProfile().copy(preferences = preferences.toPreferences()) val updatedProfile = m.controller.apiUpdateProfile(newProfile) if (updatedProfile != null) { - val updatedUser = user.copy( - profile = updatedProfile.toLocalProfile(user.profile.profileId), - fullPreferences = preferences - ) + m.updateCurrentUser(updatedProfile, preferences) currentPreferences = preferences - m.currentUser.value = updatedUser } afterSave() } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt index b33a9548c5..3baa54ea34 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt @@ -46,9 +46,7 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { withApi { val newProfile = chatModel.controller.apiUpdateProfile(profile.copy(displayName = displayName, fullName = fullName, image = image)) if (newProfile != null) { - chatModel.currentUser.value?.profile?.profileId?.let { - chatModel.updateUserProfile(newProfile.toLocalProfile(it)) - } + chatModel.updateCurrentUser(newProfile) profile = newProfile } editProfile.value = false diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 6e73a07e20..b00c1364e2 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -478,6 +478,12 @@ Onion hosts will be used when available. Onion hosts will not be used. Onion hosts will be required for connection. + Transport isolation + Chat profile + Connection + A separate TCP connection (and SOCKS credential) will be used for each chat profile you have in the app. + A separate TCP connection (and SOCKS credential) will be used for each contact and group member.\nPlease note: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail. + Update transport isolation mode? Appearance App version App version: v%s diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 268ebb1a75..6b70f0f054 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -50,7 +50,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { try await apiVerifyToken(token: token, nonce: nonce, code: verification) m.tokenStatus = .active } catch { - if let cr = error as? ChatResponse, case .chatCmdError(.errorAgent(.NTF(.AUTH))) = cr { + if let cr = error as? ChatResponse, case .chatCmdError(_, .errorAgent(.NTF(.AUTH))) = cr { m.tokenStatus = .expired } logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))") diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 5a15c4946a..1ca049a84a 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -16,6 +16,7 @@ final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get() @Published var currentUser: User? + @Published var users: [UserInfo] = [] @Published var chatInitialized = false @Published var chatRunning: Bool? @Published var chatDbChanged = false @@ -23,6 +24,8 @@ final class ChatModel: ObservableObject { @Published var chatDbStatus: DBMigrationResult? // list of chat "previews" @Published var chats: [Chat] = [] + // map of connections network statuses, key is agent connection id + @Published var networkStatuses: Dictionary = [:] // current chat @Published var chatId: String? @Published var reversedChatItems: [ChatItem] = [] @@ -54,6 +57,8 @@ final class ChatModel: ObservableObject { @Published var connReqInv: String? // audio recording and playback @Published var stopPreviousRecPlay: Bool = false // value is not taken into account, only the fact it switches + @Published var draft: ComposeState? + @Published var draftChatId: String? var callWebView: WKWebView? var messageDelivery: Dictionary Void> = [:] @@ -127,17 +132,9 @@ final class ChatModel: ObservableObject { } } - func updateNetworkStatus(_ id: ChatId, _ status: Chat.NetworkStatus) { - if let i = getChatIndex(id) { - chats[i].serverInfo.networkStatus = status - } - } - func replaceChat(_ id: String, _ chat: Chat) { if let i = getChatIndex(id) { - let serverInfo = chats[i].serverInfo chats[i] = chat - chats[i].serverInfo = serverInfo } else { // invalid state, correcting chats.insert(chat, at: 0) @@ -163,7 +160,7 @@ final class ChatModel: ObservableObject { addChat(Chat(c), at: i) } } - NtfManager.shared.setNtfBadgeCount(totalUnreadCount()) + NtfManager.shared.setNtfBadgeCount(totalUnreadCountForAllUsers()) } // func addGroup(_ group: SimpleXChat.Group) { @@ -176,7 +173,7 @@ final class ChatModel: ObservableObject { chats[i].chatItems = [cItem] if case .rcvNew = cItem.meta.itemStatus { chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1 - NtfManager.shared.incNtfBadgeCount() + increaseUnreadCounter(user: currentUser!) } if i > 0 { if chatId == nil { @@ -257,9 +254,6 @@ final class ChatModel: ObservableObject { // remove from current chat if chatId == cInfo.id { if let i = reversedChatItems.firstIndex(where: { $0.id == cItem.id }) { - if reversedChatItems[i].isRcvNew { - NtfManager.shared.decNtfBadgeCount() - } _ = withAnimation { self.reversedChatItems.remove(at: i) } @@ -283,6 +277,18 @@ final class ChatModel: ObservableObject { return nil } + func updateCurrentUser(_ newProfile: Profile, _ preferences: FullPreferences? = nil) { + if let current = currentUser { + currentUser?.profile = toLocalProfile(current.profile.profileId, newProfile, "") + if let preferences = preferences { + currentUser?.fullPreferences = preferences + } + if let current = currentUser, let i = users.firstIndex(where: { $0.user.userId == current.userId }) { + users[i].user = current + } + } + } + func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem { let cItem = ChatItem.liveDummy(chatInfo.chatType) withAnimation { @@ -308,7 +314,7 @@ final class ChatModel: ObservableObject { func markChatItemsRead(_ cInfo: ChatInfo) { // update preview _updateChat(cInfo.id) { chat in - NtfManager.shared.decNtfBadgeCount(by: chat.chatStats.unreadCount) + self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) chat.chatStats = ChatStats() } // update current chat @@ -341,8 +347,8 @@ final class ChatModel: ObservableObject { // update preview let markedCount = chat.chatStats.unreadCount - unreadBelow if markedCount > 0 { - NtfManager.shared.decNtfBadgeCount(by: markedCount) chat.chatStats.unreadCount -= markedCount + self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount) } } } @@ -360,7 +366,7 @@ final class ChatModel: ObservableObject { func clearChat(_ cInfo: ChatInfo) { // clear preview if let chat = getChat(cInfo.id) { - NtfManager.shared.decNtfBadgeCount(by: chat.chatStats.unreadCount) + self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) chat.chatItems = [] chat.chatStats = ChatStats() chat.chatInfo = cInfo @@ -394,11 +400,29 @@ final class ChatModel: ObservableObject { func decreaseUnreadCounter(_ cInfo: ChatInfo) { if let i = getChatIndex(cInfo.id) { chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1 + decreaseUnreadCounter(user: currentUser!) } } - func totalUnreadCount() -> Int { - chats.reduce(0, { count, chat in count + chat.chatStats.unreadCount }) + func increaseUnreadCounter(user: User) { + changeUnreadCounter(user: user, by: 1) + NtfManager.shared.incNtfBadgeCount() + } + + func decreaseUnreadCounter(user: User, by: Int = 1) { + changeUnreadCounter(user: user, by: -by) + NtfManager.shared.decNtfBadgeCount(by: by) + } + + private func changeUnreadCounter(user: User, by: Int) { + if let i = users.firstIndex(where: { $0.user.id == user.id }) { + users[i].unreadCount += by + } + } + + func totalUnreadCountForAllUsers() -> Int { + chats.filter { $0.chatInfo.ntfsEnabled }.reduce(0, { count, chat in count + chat.chatStats.unreadCount }) + + users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount }) } func getPrevChatItem(_ ci: ChatItem) -> ChatItem? { @@ -479,6 +503,14 @@ final class ChatModel: ObservableObject { while i < maxIx && inView(i) { i += 1 } return reversedChatItems[min(i - 1, maxIx)] } + + func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) { + networkStatuses[contact.activeConn.agentConnId] = status + } + + func contactNetworkStatus(_ contact: Contact) -> NetworkStatus { + networkStatuses[contact.activeConn.agentConnId] ?? .unknown + } } struct UnreadChatItemCounts { @@ -490,62 +522,18 @@ final class Chat: ObservableObject, Identifiable { @Published var chatInfo: ChatInfo @Published var chatItems: [ChatItem] @Published var chatStats: ChatStats - @Published var serverInfo = ServerInfo(networkStatus: .unknown) var created = Date.now - struct ServerInfo: Decodable { - var networkStatus: NetworkStatus - } - - enum NetworkStatus: Decodable, Equatable { - case unknown - case connected - case disconnected - case error(String) - - var statusString: LocalizedStringKey { - get { - switch self { - case .connected: return "connected" - case .error: return "error" - default: return "connecting" - } - } - } - - var statusExplanation: LocalizedStringKey { - get { - switch self { - case .connected: return "You are connected to the server used to receive messages from this contact." - case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." - default: return "Trying to connect to the server used to receive messages from this contact." - } - } - } - - var imageName: String { - get { - switch self { - case .unknown: return "circle.dotted" - case .connected: return "circle.fill" - case .disconnected: return "ellipsis.circle.fill" - case .error: return "exclamationmark.circle.fill" - } - } - } - } - init(_ cData: ChatData) { self.chatInfo = cData.chatInfo self.chatItems = cData.chatItems self.chatStats = cData.chatStats } - init(chatInfo: ChatInfo, chatItems: [ChatItem] = [], chatStats: ChatStats = ChatStats(), serverInfo: ServerInfo = ServerInfo(networkStatus: .unknown)) { + init(chatInfo: ChatInfo, chatItems: [ChatItem] = [], chatStats: ChatStats = ChatStats()) { self.chatInfo = chatInfo self.chatItems = chatItems self.chatStats = chatStats - self.serverInfo = serverInfo } var id: ChatId { get { chatInfo.id } } @@ -554,3 +542,41 @@ final class Chat: ObservableObject, Identifiable { public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) } + +enum NetworkStatus: Decodable, Equatable { + case unknown + case connected + case disconnected + case error(String) + + var statusString: LocalizedStringKey { + get { + switch self { + case .connected: return "connected" + case .error: return "error" + default: return "connecting" + } + } + } + + var statusExplanation: LocalizedStringKey { + get { + switch self { + case .connected: return "You are connected to the server used to receive messages from this contact." + case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." + default: return "Trying to connect to the server used to receive messages from this contact." + } + } + } + + var imageName: String { + get { + switch self { + case .unknown: return "circle.dotted" + case .connected: return "circle.fill" + case .disconnected: return "ellipsis.circle.fill" + case .error: return "exclamationmark.circle.fill" + } + } + } +} diff --git a/apps/ios/Shared/Model/ImageUtils.swift b/apps/ios/Shared/Model/ImageUtils.swift index 79382e4d6d..92be827dfc 100644 --- a/apps/ios/Shared/Model/ImageUtils.swift +++ b/apps/ios/Shared/Model/ImageUtils.swift @@ -165,8 +165,7 @@ func saveFileFromURL(_ url: URL) -> String? { } func generateNewFileName(_ prefix: String, _ ext: String) -> String { - let fileName = uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)") - return fileName + uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)") } private func uniqueCombine(_ fileName: String) -> String { @@ -191,6 +190,7 @@ private func getTimestamp() -> String { df = DateFormatter() df.dateFormat = "yyyyMMdd_HHmmss" df.locale = Locale(identifier: "US") + df.timeZone = TimeZone(secondsFromGMT: 0) tsFormatter = df } return df.string(from: Date()) diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 398e9eb378..97252f1ade 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -28,7 +28,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { private var granted = false private var prevNtfTime: Dictionary = [:] - // Handle notification when app is in background func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, @@ -38,8 +37,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { let chatModel = ChatModel.shared let action = response.actionIdentifier logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)") + if let userId = content.userInfo["userId"] as? Int64, + userId != chatModel.currentUser?.userId { + changeActiveUser(userId) + } if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact, - let chatId = content.userInfo["chatId"] as? String { + let chatId = content.userInfo["chatId"] as? String { if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { Task { await acceptContactRequest(contactRequest) } } else { @@ -189,20 +192,20 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { center.delegate = self } - func notifyContactRequest(_ contactRequest: UserContactRequest) { + func notifyContactRequest(_ user: User, _ contactRequest: UserContactRequest) { logger.debug("NtfManager.notifyContactRequest") - addNotification(createContactRequestNtf(contactRequest)) + addNotification(createContactRequestNtf(user, contactRequest)) } - func notifyContactConnected(_ contact: Contact) { + func notifyContactConnected(_ user: User, _ contact: Contact) { logger.debug("NtfManager.notifyContactConnected") - addNotification(createContactConnectedNtf(contact)) + addNotification(createContactConnectedNtf(user, contact)) } - func notifyMessageReceived(_ cInfo: ChatInfo, _ cItem: ChatItem) { + func notifyMessageReceived(_ user: User, _ cInfo: ChatInfo, _ cItem: ChatItem) { logger.debug("NtfManager.notifyMessageReceived") if cInfo.ntfsEnabled { - addNotification(createMessageReceivedNtf(cInfo, cItem)) + addNotification(createMessageReceivedNtf(user, cInfo, cItem)) } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 0af9c0d6c7..b171ddf25b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -120,7 +120,7 @@ func apiGetActiveUser() throws -> User? { let r = chatSendCmdSync(.showActiveUser) switch r { case let .activeUser(user): return user - case .chatCmdError(.error(.noActiveUser)): return nil + case .chatCmdError(_, .error(.noActiveUser)): return nil default: throw r } } @@ -131,6 +131,26 @@ func apiCreateActiveUser(_ p: Profile) throws -> User { throw r } +func listUsers() throws -> [UserInfo] { + let r = chatSendCmdSync(.listUsers) + 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)) + 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)) + if case .cmdOk = r { return } + throw r +} + func apiStartChat() throws -> Bool { let r = chatSendCmdSync(.startChat(subscribe: true, expire: true)) switch r { @@ -189,20 +209,21 @@ func apiStorageEncryption(currentKey: String = "", newKey: String = "") async th } func apiGetChats() throws -> [ChatData] { - let r = chatSendCmdSync(.apiGetChats) - if case let .apiChats(chats) = r { return chats } + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetChats: no current user") } + let r = chatSendCmdSync(.apiGetChats(userId: userId)) + if case let .apiChats(_, chats) = r { return chats } throw r } func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat { let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50), search: search)) - if case let .apiChat(chat) = r { return Chat.init(chat) } + if case let .apiChat(_, chat) = r { return Chat.init(chat) } throw r } func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] { let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search)) - if case let .apiChat(chat) = r { return chat.chatItems } + if case let .apiChat(_, chat) = r { return chat.chatItems } throw r } @@ -227,7 +248,7 @@ func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int6 var cItem: ChatItem! let endTask = beginBGTask({ if cItem != nil { chatModel.messageDelivery.removeValue(forKey: cItem.id) } }) r = await chatSendCmd(cmd, bgTask: false) - if case let .newChatItem(aChatItem) = r { + if case let .newChatItem(_, aChatItem) = r { cItem = aChatItem.chatItem chatModel.messageDelivery[cItem.id] = endTask return cItem @@ -239,7 +260,7 @@ func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int6 return nil } else { r = await chatSendCmd(cmd, bgDelay: msgDelay) - if case let .newChatItem(aChatItem) = r { + if case let .newChatItem(_, aChatItem) = r { return aChatItem.chatItem } sendMessageErrorAlert(r) @@ -257,13 +278,13 @@ private func sendMessageErrorAlert(_ r: ChatResponse) { func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool = false) async throws -> ChatItem { let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg, live: live), bgDelay: msgDelay) - if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem } + if case let .chatItemUpdated(_, aChatItem) = r { return aChatItem.chatItem } throw r } func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) { let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay) - if case let .chatItemDeleted(deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) } + if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) } throw r } @@ -271,7 +292,7 @@ func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode) { let r = chatSendCmdSync(.apiGetNtfToken) switch r { case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode) - case .chatCmdError(.errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off) + case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off) default: logger.debug("apiGetNtfToken response: \(String(describing: r), privacy: .public)") return (nil, nil, .off) @@ -310,18 +331,21 @@ func apiDeleteToken(token: DeviceToken) async throws { } func getUserSMPServers() throws -> ([ServerCfg], [String]) { - let r = chatSendCmdSync(.getUserSMPServers) - if case let .userSMPServers(smpServers, presetServers) = r { return (smpServers, presetServers) } + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("getUserSMPServers: no current user") } + let r = chatSendCmdSync(.apiGetUserSMPServers(userId: userId)) + if case let .userSMPServers(_, smpServers, presetServers) = r { return (smpServers, presetServers) } throw r } func setUserSMPServers(smpServers: [ServerCfg]) async throws { - try await sendCommandOkResp(.setUserSMPServers(smpServers: smpServers)) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("setUserSMPServers: no current user") } + try await sendCommandOkResp(.apiSetUserSMPServers(userId: userId, smpServers: smpServers)) } func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> { - let r = await chatSendCmd(.testSMPServer(smpServer: smpServer)) - if case let .smpTestResult(testFailure) = r { + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("testSMPServer: no current user") } + let r = await chatSendCmd(.testSMPServer(userId: userId, smpServer: smpServer)) + if case let .smpTestResult(_, testFailure) = r { if let t = testFailure { return .failure(t) } @@ -331,13 +355,15 @@ func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> } func getChatItemTTL() throws -> ChatItemTTL { - let r = chatSendCmdSync(.apiGetChatItemTTL) - if case let .chatItemTTL(chatItemTTL) = r { return ChatItemTTL(chatItemTTL) } + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("getChatItemTTL: no current user") } + let r = chatSendCmdSync(.apiGetChatItemTTL(userId: userId)) + if case let .chatItemTTL(_, chatItemTTL) = r { return ChatItemTTL(chatItemTTL) } throw r } func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws { - try await sendCommandOkResp(.apiSetChatItemTTL(seconds: chatItemTTL.seconds)) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("setChatItemTTL: no current user") } + try await sendCommandOkResp(.apiSetChatItemTTL(userId: userId, seconds: chatItemTTL.seconds)) } func getNetworkConfig() async throws -> NetCfg? { @@ -358,13 +384,13 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) { let r = await chatSendCmd(.apiContactInfo(contactId: contactId)) - if case let .contactInfo(_, connStats, customUserProfile) = r { return (connStats, customUserProfile) } + if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) } throw r } func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (ConnectionStats?) { let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) - if case let .groupMemberInfo(_, _, connStats_) = r { return (connStats_) } + if case let .groupMemberInfo(_, _, _, connStats_) = r { return (connStats_) } throw r } @@ -378,44 +404,52 @@ func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) async throws func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) { let r = await chatSendCmd(.apiGetContactCode(contactId: contactId)) - if case let .contactCode(contact, connectionCode) = r { return (contact, connectionCode) } + if case let .contactCode(_, contact, connectionCode) = r { return (contact, connectionCode) } throw r } func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, String) { let r = chatSendCmdSync(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId)) - if case let .groupMemberCode(_, member, connectionCode) = r { return (member, connectionCode) } + if case let .groupMemberCode(_, _, member, connectionCode) = r { return (member, connectionCode) } throw r } func apiVerifyContact(_ contactId: Int64, connectionCode: String?) -> (Bool, String)? { let r = chatSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode)) - if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) } + if case let .connectionVerified(_, verified, expectedCode) = r { return (verified, expectedCode) } logger.error("apiVerifyContact error: \(String(describing: r))") return nil } func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCode: String?) -> (Bool, String)? { let r = chatSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode)) - if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) } + if case let .connectionVerified(_, verified, expectedCode) = r { return (verified, expectedCode) } logger.error("apiVerifyGroupMember error: \(String(describing: r))") return nil } func apiAddContact() async -> String? { - let r = await chatSendCmd(.addContact, bgTask: false) - if case let .invitation(connReqInvitation) = r { return connReqInvitation } + guard let userId = ChatModel.shared.currentUser?.userId else { + logger.error("apiAddContact: no current user") + return nil + } + let r = await chatSendCmd(.apiAddContact(userId: userId), bgTask: false) + if case let .invitation(_, connReqInvitation) = r { return connReqInvitation } connectionErrorAlert(r) return nil } func apiConnect(connReq: String) async -> ConnReqType? { - let r = await chatSendCmd(.connect(connReq: connReq)) + guard let userId = ChatModel.shared.currentUser?.userId else { + logger.error("apiConnect: no current user") + return nil + } + let r = await chatSendCmd(.apiConnect(userId: userId, connReq: connReq)) let am = AlertManager.shared switch r { case .sentConfirmation: return .invitation case .sentInvitation: return .contact - case let .contactAlreadyExists(contact): + case let .contactAlreadyExists(_, contact): let m = ChatModel.shared if let c = m.getContactChat(contact.contactId) { await MainActor.run { m.chatId = c.id } @@ -425,19 +459,19 @@ func apiConnect(connReq: String) async -> ConnReqType? { message: "You are already connected to \(contact.displayName)." ) return nil - case .chatCmdError(.error(.invalidConnReq)): + case .chatCmdError(_, .error(.invalidConnReq)): am.showAlertMsg( title: "Invalid connection link", message: "Please check that you used the correct link or ask your contact to send you another one." ) return nil - case .chatCmdError(.errorAgent(.SMP(.AUTH))): + case .chatCmdError(_, .errorAgent(.SMP(.AUTH))): am.showAlertMsg( title: "Connection error (AUTH)", message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." ) return nil - case let .chatCmdError(.errorAgent(.INTERNAL(internalErr))): + case let .chatCmdError(_, .errorAgent(.INTERNAL(internalErr))): if internalErr == "SEUniqueID" { am.showAlertMsg( title: "Already connected?", @@ -484,7 +518,7 @@ func deleteChat(_ chat: Chat) async { func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo { let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false) - if case let .chatCleared(updatedChatInfo) = r { return updatedChatInfo } + if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo } throw r } @@ -499,64 +533,70 @@ func clearChat(_ chat: Chat) async { } func apiListContacts() throws -> [Contact] { - let r = chatSendCmdSync(.listContacts) - if case let .contactsList(contacts) = r { return contacts } + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiListContacts: no current user") } + let r = chatSendCmdSync(.apiListContacts(userId: userId)) + if case let .contactsList(_, contacts) = r { return contacts } throw r } func apiUpdateProfile(profile: Profile) async throws -> Profile? { - let r = await chatSendCmd(.apiUpdateProfile(profile: profile)) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiUpdateProfile: no current user") } + let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile)) switch r { case .userProfileNoChange: return nil - case let .userProfileUpdated(_, toProfile): return toProfile + case let .userProfileUpdated(_, _, toProfile): return toProfile default: throw r } } func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? { let r = await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences)) - if case let .contactPrefsUpdated(_, toContact) = r { return toContact } + if case let .contactPrefsUpdated(_, _, toContact) = r { return toContact } throw r } func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? { let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias)) - if case let .contactAliasUpdated(toContact) = r { return toContact } + if case let .contactAliasUpdated(_, toContact) = r { return toContact } throw r } func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> PendingContactConnection? { let r = await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias)) - if case let .connectionAliasUpdated(toConnection) = r { return toConnection } + if case let .connectionAliasUpdated(_, toConnection) = r { return toConnection } throw r } func apiCreateUserAddress() async throws -> String { - let r = await chatSendCmd(.createMyAddress) - if case let .userContactLinkCreated(connReq) = r { return connReq } + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiCreateUserAddress: no current user") } + let r = await chatSendCmd(.apiCreateMyAddress(userId: userId)) + if case let .userContactLinkCreated(_, connReq) = r { return connReq } throw r } func apiDeleteUserAddress() async throws { - let r = await chatSendCmd(.deleteMyAddress) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiDeleteUserAddress: no current user") } + let r = await chatSendCmd(.apiDeleteMyAddress(userId: userId)) if case .userContactLinkDeleted = r { return } throw r } func apiGetUserAddress() throws -> UserContactLink? { - let r = chatSendCmdSync(.showMyAddress) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetUserAddress: no current user") } + let r = chatSendCmdSync(.apiShowMyAddress(userId: userId)) switch r { - case let .userContactLink(contactLink): return contactLink - case .chatCmdError(chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil + case let .userContactLink(_, contactLink): return contactLink + case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil default: throw r } } func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContactLink? { - let r = await chatSendCmd(.addressAutoAccept(autoAccept: autoAccept)) + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("userAddressAutoAccept: no current user") } + let r = await chatSendCmd(.apiAddressAutoAccept(userId: userId, autoAccept: autoAccept)) switch r { - case let .userContactLinkUpdated(contactLink): return contactLink - case .chatCmdError(chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil + case let .userContactLinkUpdated(_, contactLink): return contactLink + case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil default: throw r } } @@ -565,8 +605,8 @@ func apiAcceptContactRequest(contactReqId: Int64) async -> Contact? { let r = await chatSendCmd(.apiAcceptContact(contactReqId: contactReqId)) let am = AlertManager.shared - if case let .acceptingContactRequest(contact) = r { return contact } - if case .chatCmdError(.errorAgent(.SMP(.AUTH))) = r { + if case let .acceptingContactRequest(_, contact) = r { return contact } + if case .chatCmdError(_, .errorAgent(.SMP(.AUTH))) = r { am.showAlertMsg( title: "Connection error (AUTH)", message: "Sender may have deleted the connection request." @@ -605,7 +645,7 @@ func receiveFile(fileId: Int64) async { func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? { let r = await chatSendCmd(.receiveFile(fileId: fileId, inline: inline)) let am = AlertManager.shared - if case let .rcvFileAccepted(chatItem) = r { return chatItem } + if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } if case .rcvFileAcceptedSndCancelled = r { am.showAlertMsg( title: "Cannot receive file", @@ -614,7 +654,7 @@ func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? { } else if !networkErrorAlert(r) { logger.error("apiReceiveFile error: \(String(describing: r))") switch r { - case .chatCmdError(.error(.fileAlreadyReceiving)): + case .chatCmdError(_, .error(.fileAlreadyReceiving)): logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error") default: am.showAlertMsg( @@ -629,13 +669,13 @@ func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? { func networkErrorAlert(_ r: ChatResponse) -> Bool { let am = AlertManager.shared switch r { - case let .chatCmdError(.errorAgent(.BROKER(addr, .TIMEOUT))): + case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))): am.showAlertMsg( title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again." ) return true - case let .chatCmdError(.errorAgent(.BROKER(addr, .NETWORK))): + case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))): am.showAlertMsg( title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again." @@ -748,14 +788,15 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { } func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo { - let r = chatSendCmdSync(.newGroup(groupProfile: p)) - if case let .groupCreated(groupInfo) = r { return groupInfo } + guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiNewGroup: no current user") } + let r = chatSendCmdSync(.apiNewGroup(userId: userId, groupProfile: p)) + if case let .groupCreated(_, groupInfo) = r { return groupInfo } throw r } func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { let r = await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole)) - if case let .sentGroupInvitation(_, _, member) = r { return member } + if case let .sentGroupInvitation(_, _, _, member) = r { return member } throw r } @@ -768,22 +809,22 @@ enum JoinGroupResult { func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult { let r = await chatSendCmd(.apiJoinGroup(groupId: groupId)) switch r { - case let .userAcceptedGroupSent(groupInfo, _): return .joined(groupInfo: groupInfo) - case .chatCmdError(.errorAgent(.SMP(.AUTH))): return .invitationRemoved - case .chatCmdError(.errorStore(.groupNotFound)): return .groupNotFound + case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo) + case .chatCmdError(_, .errorAgent(.SMP(.AUTH))): return .invitationRemoved + case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound default: throw r } } func apiRemoveMember(_ groupId: Int64, _ memberId: Int64) async throws -> GroupMember { let r = await chatSendCmd(.apiRemoveMember(groupId: groupId, memberId: memberId), bgTask: false) - if case let .userDeletedMember(_, member) = r { return member } + if case let .userDeletedMember(_, _, member) = r { return member } throw r } func apiMemberRole(_ groupId: Int64, _ memberId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { let r = await chatSendCmd(.apiMemberRole(groupId: groupId, memberId: memberId, memberRole: memberRole), bgTask: false) - if case let .memberRoleUser(_, member, _, _) = r { return member } + if case let .memberRoleUser(_, _, member, _, _) = r { return member } throw r } @@ -798,19 +839,19 @@ func leaveGroup(_ groupId: Int64) async { func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo { let r = await chatSendCmd(.apiLeaveGroup(groupId: groupId), bgTask: false) - if case let .leftMemberUser(groupInfo) = r { return groupInfo } + if case let .leftMemberUser(_, groupInfo) = r { return groupInfo } throw r } func apiListMembers(_ groupId: Int64) async -> [GroupMember] { let r = await chatSendCmd(.apiListMembers(groupId: groupId)) - if case let .groupMembers(group) = r { return group.members } + if case let .groupMembers(_, group) = r { return group.members } return [] } func apiListMembersSync(_ groupId: Int64) -> [GroupMember] { let r = chatSendCmdSync(.apiListMembers(groupId: groupId)) - if case let .groupMembers(group) = r { return group.members } + if case let .groupMembers(_, group) = r { return group.members } return [] } @@ -824,13 +865,13 @@ func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] { func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws -> GroupInfo { let r = await chatSendCmd(.apiUpdateGroupProfile(groupId: groupId, groupProfile: groupProfile)) - if case let .groupUpdated(toGroup) = r { return toGroup } + if case let .groupUpdated(_, toGroup) = r { return toGroup } throw r } func apiCreateGroupLink(_ groupId: Int64) async throws -> String { let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId)) - if case let .groupLinkCreated(_, connReq) = r { return connReq } + if case let .groupLinkCreated(_, _, connReq) = r { return connReq } throw r } @@ -843,9 +884,9 @@ func apiDeleteGroupLink(_ groupId: Int64) async throws { func apiGetGroupLink(_ groupId: Int64) throws -> String? { let r = chatSendCmdSync(.apiGetGroupLink(groupId: groupId)) switch r { - case let .groupLink(_, connReq): + case let .groupLink(_, _, connReq): return connReq - case .chatCmdError(chatError: .errorStore(storeError: .groupLinkNotFound)): + case .chatCmdError(_, chatError: .errorStore(storeError: .groupLinkNotFound)): return nil default: throw r } @@ -884,20 +925,17 @@ func startChat() throws { let m = ChatModel.shared try setNetworkConfig(getNetCfg()) let justStarted = try apiStartChat() + m.users = try listUsers() if justStarted { - m.userAddress = try apiGetUserAddress() - (m.userSMPServers, m.presetSMPServers) = try getUserSMPServers() - m.chatItemTTL = try getChatItemTTL() - let chats = try apiGetChats() - m.chats = chats.map { Chat.init($0) } - NtfManager.shared.setNtfBadgeCount(m.totalUnreadCount()) + try getUserChatData() + NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers()) try refreshCallInvitations() (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() if let token = m.deviceToken { registerToken(token: token) } withAnimation { - m.onboardingStage = m.onboardingStage == .step2_CreateProfile + m.onboardingStage = m.onboardingStage == .step2_CreateProfile && m.users.count == 1 ? .step3_SetNotificationsMode : .onboardingComplete } @@ -907,6 +945,30 @@ func startChat() throws { chatLastStartGroupDefault.set(Date.now) } +func changeActiveUser(_ userId: Int64) { + do { + try changeActiveUser_(userId) + } catch let error { + logger.error("Unable to set active user: \(responseError(error))") + } +} + +func changeActiveUser_(_ userId: Int64) throws { + let m = ChatModel.shared + m.currentUser = try apiSetActiveUser(userId) + m.users = try listUsers() + try getUserChatData() +} + +func getUserChatData() throws { + let m = ChatModel.shared + m.userAddress = try apiGetUserAddress() + (m.userSMPServers, m.presetSMPServers) = try getUserSMPServers() + m.chatItemTTL = try getChatItemTTL() + let chats = try apiGetChats() + m.chats = chats.map { Chat.init($0) } +} + class ChatReceiver { private var receiveLoop: Task? private var receiveMessages = true @@ -950,25 +1012,31 @@ func processReceivedMsg(_ res: ChatResponse) async { m.terminalItems.append(.resp(.now, res)) logger.debug("processReceivedMsg: \(res.responseType)") switch res { - case let .newContactConnection(connection): - m.updateContactConnection(connection) - case let .contactConnectionDeleted(connection): - m.removeChat(connection.id) - case let .contactConnected(contact, _): - if contact.directOrUsed { + case let .newContactConnection(user, connection): + if active(user) { + m.updateContactConnection(connection) + } + case let .contactConnectionDeleted(user, connection): + if active(user) { + m.removeChat(connection.id) + } + case let .contactConnected(user, contact, _): + if active(user) && contact.directOrUsed { m.updateContact(contact) m.dismissConnReqView(contact.activeConn.id) m.removeChat(contact.activeConn.id) - m.updateNetworkStatus(contact.id, .connected) - NtfManager.shared.notifyContactConnected(contact) + NtfManager.shared.notifyContactConnected(user, contact) } - case let .contactConnecting(contact): - if contact.directOrUsed { + m.setContactNetworkStatus(contact, .connected) + case let .contactConnecting(user, contact): + if active(user) && contact.directOrUsed { m.updateContact(contact) m.dismissConnReqView(contact.activeConn.id) m.removeChat(contact.activeConn.id) } - case let .receivedContactRequest(contactRequest): + case let .receivedContactRequest(user, contactRequest): + if !active(user) { return } + let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest) if m.hasChat(contactRequest.id) { m.updateChatInfo(cInfo) @@ -977,15 +1045,15 @@ func processReceivedMsg(_ res: ChatResponse) async { chatInfo: cInfo, chatItems: [] )) - NtfManager.shared.notifyContactRequest(contactRequest) + NtfManager.shared.notifyContactRequest(user, contactRequest) } - case let .contactUpdated(toContact): - let cInfo = ChatInfo.direct(contact: toContact) - if m.hasChat(toContact.id) { + case let .contactUpdated(user, toContact): + if active(user) && m.hasChat(toContact.id) { + let cInfo = ChatInfo.direct(contact: toContact) m.updateChatInfo(cInfo) } - case let .contactsMerged(intoContact, mergedContact): - if m.hasChat(mergedContact.id) { + case let .contactsMerged(user, intoContact, mergedContact): + if active(user) && m.hasChat(mergedContact.id) { if m.chatId == mergedContact.id { m.chatId = intoContact.id } @@ -995,18 +1063,30 @@ func processReceivedMsg(_ res: ChatResponse) async { updateContactsStatus(contactRefs, status: .connected) case let .contactsDisconnected(_, contactRefs): updateContactsStatus(contactRefs, status: .disconnected) - case let .contactSubError(contact, chatError): + case let .contactSubError(user, contact, chatError): + if active(user) { + m.updateContact(contact) + } processContactSubError(contact, chatError) - case let .contactSubSummary(contactSubscriptions): + case let .contactSubSummary(user, contactSubscriptions): for sub in contactSubscriptions { + if active(user) { + m.updateContact(sub.contact) + } if let err = sub.contactError { processContactSubError(sub.contact, err) } else { - m.updateContact(sub.contact) - m.updateNetworkStatus(sub.contact.id, .connected) + m.setContactNetworkStatus(sub.contact, .connected) } } - case let .newChatItem(aChatItem): + case let .newChatItem(user, aChatItem): + if !active(user) { + if case .rcvNew = aChatItem.chatItem.meta.itemStatus, aChatItem.chatInfo.ntfsEnabled { + m.increaseUnreadCounter(user: user) + } + return + } + let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem m.addChatItem(cInfo, cItem) @@ -1022,9 +1102,11 @@ func processReceivedMsg(_ res: ChatResponse) async { } } if cItem.showNotification { - NtfManager.shared.notifyMessageReceived(cInfo, cItem) + NtfManager.shared.notifyMessageReceived(user, cInfo, cItem) } - case let .chatItemStatusUpdated(aChatItem): + case let .chatItemStatusUpdated(user, aChatItem): + if !active(user) { return } + let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem var res = false @@ -1032,7 +1114,7 @@ func processReceivedMsg(_ res: ChatResponse) async { res = m.upsertChatItem(cInfo, cItem) } if res { - NtfManager.shared.notifyMessageReceived(cInfo, cItem) + NtfManager.shared.notifyMessageReceived(user, cInfo, cItem) } else if let endTask = m.messageDelivery[cItem.id] { switch cItem.meta.itemStatus { case .sndSent: endTask() @@ -1041,48 +1123,87 @@ func processReceivedMsg(_ res: ChatResponse) async { default: break } } - case let .chatItemUpdated(aChatItem): - chatItemSimpleUpdate(aChatItem) - case let .chatItemDeleted(deletedChatItem, toChatItem, _): + case let .chatItemUpdated(user, aChatItem): + if active(user) { + chatItemSimpleUpdate(aChatItem) + } + case let .chatItemDeleted(user, deletedChatItem, toChatItem, _): + if !active(user) { + if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled { + m.decreaseUnreadCounter(user: user) + } + return + } + if let toChatItem = toChatItem { _ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem) } else { m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem) } - case let .receivedGroupInvitation(groupInfo, _, _): - m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated - // NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation? - case let .userAcceptedGroupSent(groupInfo, hostContact): + case let .receivedGroupInvitation(user, groupInfo, _, _): + if active(user) { + m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated + // NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation? + } + case let .userAcceptedGroupSent(user, groupInfo, hostContact): + if !active(user) { return } + m.updateGroup(groupInfo) if let hostContact = hostContact { m.dismissConnReqView(hostContact.activeConn.id) m.removeChat(hostContact.activeConn.id) } - case let .joinedGroupMemberConnecting(groupInfo, _, member): - _ = m.upsertGroupMember(groupInfo, member) - case let .deletedMemberUser(groupInfo, _): // TODO update user member - m.updateGroup(groupInfo) - case let .deletedMember(groupInfo, _, deletedMember): - _ = m.upsertGroupMember(groupInfo, deletedMember) - case let .leftMember(groupInfo, member): - _ = m.upsertGroupMember(groupInfo, member) - case let .groupDeleted(groupInfo, _): // TODO update user member - m.updateGroup(groupInfo) - case let .userJoinedGroup(groupInfo): - m.updateGroup(groupInfo) - case let .joinedGroupMember(groupInfo, member): - _ = m.upsertGroupMember(groupInfo, member) - case let .connectedToGroupMember(groupInfo, member): - _ = m.upsertGroupMember(groupInfo, member) - case let .groupUpdated(toGroup): - m.updateGroup(toGroup) - case let .rcvFileStart(aChatItem): - chatItemSimpleUpdate(aChatItem) - case let .rcvFileComplete(aChatItem): - chatItemSimpleUpdate(aChatItem) - case let .sndFileStart(aChatItem, _): - chatItemSimpleUpdate(aChatItem) - case let .sndFileComplete(aChatItem, _): + case let .joinedGroupMemberConnecting(user, groupInfo, _, member): + if active(user) { + _ = m.upsertGroupMember(groupInfo, member) + } + case let .deletedMemberUser(user, groupInfo, _): // TODO update user member + if active(user) { + m.updateGroup(groupInfo) + } + case let .deletedMember(user, groupInfo, _, deletedMember): + if active(user) { + _ = m.upsertGroupMember(groupInfo, deletedMember) + } + case let .leftMember(user, groupInfo, member): + if active(user) { + _ = m.upsertGroupMember(groupInfo, member) + } + case let .groupDeleted(user, groupInfo, _): // TODO update user member + if active(user) { + m.updateGroup(groupInfo) + } + case let .userJoinedGroup(user, groupInfo): + if active(user) { + m.updateGroup(groupInfo) + } + case let .joinedGroupMember(user, groupInfo, member): + if active(user) { + _ = m.upsertGroupMember(groupInfo, member) + } + case let .connectedToGroupMember(user, groupInfo, member): + if active(user) { + _ = m.upsertGroupMember(groupInfo, member) + } + case let .groupUpdated(user, toGroup): + if active(user) { + m.updateGroup(toGroup) + } + case let .rcvFileStart(user, aChatItem): + if active(user) { + chatItemSimpleUpdate(aChatItem) + } + case let .rcvFileComplete(user, aChatItem): + if active(user) { + chatItemSimpleUpdate(aChatItem) + } + case let .sndFileStart(user, aChatItem, _): + if active(user) { + chatItemSimpleUpdate(aChatItem) + } + case let .sndFileComplete(user, aChatItem, _): + if !active(user) { return } + chatItemSimpleUpdate(aChatItem) let cItem = aChatItem.chatItem let mc = cItem.content.msgContent @@ -1107,7 +1228,7 @@ func processReceivedMsg(_ res: ChatResponse) async { // logger.debug("reportNewIncomingVoIPPushPayload success for \(contact.id)") // } // } - case let .callOffer(contact, callType, offer, sharedKey, _): + case let .callOffer(_, contact, callType, offer, sharedKey, _): withCall(contact) { call in call.callState = .offerReceived call.peerMedia = callType.media @@ -1125,16 +1246,16 @@ func processReceivedMsg(_ res: ChatResponse) async { relay: useRelay ) } - case let .callAnswer(contact, answer): + case let .callAnswer(_, contact, answer): withCall(contact) { call in call.callState = .answerReceived m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates) } - case let .callExtraInfo(contact, extraInfo): + case let .callExtraInfo(_, contact, extraInfo): withCall(contact) { _ in m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates) } - case let .callEnded(contact): + case let .callEnded(_, contact): if let invitation = m.callInvitations.removeValue(forKey: contact.id) { CallController.shared.reportCallRemoteEnded(invitation: invitation) } @@ -1148,6 +1269,10 @@ func processReceivedMsg(_ res: ChatResponse) async { logger.debug("unsupported event: \(res.responseType)") } + func active(_ user: User) -> Bool { + user.id == m.currentUser?.id + } + func withCall(_ contact: Contact, _ perform: (Call) -> Void) { if let call = m.activeCall, call.contact.apiId == contact.apiId { perform(call) @@ -1163,27 +1288,26 @@ func chatItemSimpleUpdate(_ aChatItem: AChatItem) { let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem if m.upsertChatItem(cInfo, cItem) { - NtfManager.shared.notifyMessageReceived(cInfo, cItem) + NtfManager.shared.notifyMessageReceived(m.currentUser!, cInfo, cItem) } } -func updateContactsStatus(_ contactRefs: [ContactRef], status: Chat.NetworkStatus) { +func updateContactsStatus(_ contactRefs: [ContactRef], status: NetworkStatus) { let m = ChatModel.shared for c in contactRefs { - m.updateNetworkStatus(c.id, status) + m.networkStatuses[c.agentConnId] = status } } func processContactSubError(_ contact: Contact, _ chatError: ChatError) { let m = ChatModel.shared - m.updateContact(contact) var err: String switch chatError { case .errorAgent(agentError: .BROKER(_, .NETWORK)): err = "network" case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted" default: err = String(describing: chatError) } - m.updateNetworkStatus(contact.id, .error(err)) + m.setContactNetworkStatus(contact, .error(err)) } func refreshCallInvitations() throws { @@ -1215,3 +1339,15 @@ private struct UserResponse: Decodable { var user: User? var error: String? } + +struct RuntimeError: Error { + let message: String + + init(_ message: String) { + self.message = message + } + + public var localizedDescription: String { + return message + } +} diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 459cab70de..c8b641d20b 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -60,7 +60,7 @@ struct SimpleXApp: App { enteredBackground = ProcessInfo.processInfo.systemUptime } doAuthenticate = false - NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCount()) + NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) case .active: if chatModel.chatRunning == true { ChatReceiver.shared.start() diff --git a/apps/ios/Shared/Views/Call/IncomingCallView.swift b/apps/ios/Shared/Views/Call/IncomingCallView.swift index 22a14e4d5b..0044434efd 100644 --- a/apps/ios/Shared/Views/Call/IncomingCallView.swift +++ b/apps/ios/Shared/Views/Call/IncomingCallView.swift @@ -29,6 +29,10 @@ struct IncomingCallView: View { private func incomingCall(_ invitation: RcvCallInvitation) -> some View { VStack(alignment: .leading, spacing: 6) { HStack { + if m.users.count > 1 { + ProfileImage(imageStr: invitation.user.image, color: .white) + .frame(width: 24, height: 24) + } Image(systemName: invitation.callType.media == .video ? "video.fill" : "phone.fill").foregroundColor(.green) Text(invitation.callTypeText) } @@ -82,6 +86,8 @@ struct IncomingCallView: View { struct IncomingCallView_Previews: PreviewProvider { static var previews: some View { CallController.shared.activeCallInvitation = RcvCallInvitation.sampleData - return IncomingCallView() + let m = ChatModel() + m.users = [UserInfo.sampleData, UserInfo.sampleData] + return IncomingCallView().environmentObject(m) } } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index ed33256f06..5cab211e89 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -263,14 +263,14 @@ struct ChatInfoView: View { .foregroundColor(.accentColor) .font(.system(size: 14)) Spacer() - Text(chat.serverInfo.networkStatus.statusString) + Text(chatModel.contactNetworkStatus(contact).statusString) .foregroundColor(.secondary) serverImage() } } private func serverImage() -> some View { - let status = chat.serverInfo.networkStatus + let status = chatModel.contactNetworkStatus(contact) return Image(systemName: status.imageName) .foregroundColor(status == .connected ? .green : .secondary) .font(.system(size: 12)) @@ -337,7 +337,7 @@ struct ChatInfoView: View { private func networkStatusAlert() -> Alert { Alert( title: Text("Network status"), - message: Text(chat.serverInfo.networkStatus.statusExplanation) + message: Text(chatModel.contactNetworkStatus(contact).statusExplanation) ) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index fef3c59375..18e0f3ff75 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -17,25 +17,28 @@ struct CIVoiceView: View { @State var playbackTime: TimeInterval? var body: some View { - VStack ( - alignment: chatItem.chatDir.sent ? .trailing : .leading, - spacing: 6 - ) { - HStack { - if chatItem.chatDir.sent { - playerTime() - .frame(width: 50, alignment: .leading) - player() - } else { - player() - playerTime() - .frame(width: 50, alignment: .leading) + Group { + if chatItem.chatDir.sent { + VStack (alignment: .trailing, spacing: 6) { + HStack { + playerTime() + player() + } + .frame(alignment: .trailing) + metaView().padding(.trailing, 10) + } + } else { + VStack (alignment: .leading, spacing: 6) { + HStack { + player() + playerTime() + } + .frame(alignment: .leading) + metaView().padding(.leading, -6) } } - CIMetaView(chatItem: chatItem) - .padding(.leading, chatItem.chatDir.sent ? 0 : 12) - .padding(.trailing, chatItem.chatDir.sent ? 12 : 0) } + .padding([.top, .horizontal], 4) .padding(.bottom, 8) } @@ -58,6 +61,10 @@ struct CIVoiceView: View { ) .foregroundColor(.secondary) } + + private func metaView() -> some View { + CIMetaView(chatItem: chatItem) + } } struct VoiceMessagePlayerTime: View { @@ -66,13 +73,16 @@ struct VoiceMessagePlayerTime: View { @Binding var playbackTime: TimeInterval? var body: some View { - switch playbackState { - case .noPlayback: - Text(voiceMessageTime(recordingTime)) - case .playing: - Text(voiceMessageTime_(playbackTime)) - case .paused: - Text(voiceMessageTime_(playbackTime)) + ZStack(alignment: .leading) { + Text(String("66:66")).foregroundColor(.clear) + switch playbackState { + case .noPlayback: + Text(voiceMessageTime(recordingTime)) + case .playing: + Text(voiceMessageTime_(playbackTime)) + case .paused: + Text(voiceMessageTime_(playbackTime)) + } } } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 68ab7c191d..96d0874f0e 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -63,33 +63,42 @@ struct ChatView: View { .padding(.top, 1) .navigationTitle(cInfo.chatViewName) .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(true) .onAppear { + if chatModel.draftChatId == cInfo.id, let draft = chatModel.draft { + composeState = draft + } if chat.chatStats.unreadChat { Task { await markChatUnread(chat, unreadChat: false) } } } - .onChange(of: chatModel.chatId) { _ in - if chatModel.chatId == nil { dismiss() } - } - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { - chatModel.chatId = nil - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { - if chatModel.chatId == nil { - chatModel.reversedChatItems = [] - } - } - } label: { - HStack(spacing: 0) { - Image(systemName: "chevron.backward") - Text("Chats") + .onChange(of: chatModel.chatId) { _ in + if chatModel.chatId == nil { dismiss() } + } + .onChange(of: "\(composeState.empty) \(composeState.noPreview) \(composeState.message)") { _ in + if !composeState.empty { + chatModel.draft = composeState + chatModel.draftChatId = chat.id + } else if chatModel.draftChatId == chat.id { + chatModel.draft = nil + chatModel.draftChatId = nil + } + } + .onDisappear { + if chatModel.chatId == cInfo.id { + chatModel.chatId = nil + if chatModel.draftChatId == cInfo.id { + chatModel.draft = composeState + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + if chatModel.chatId == nil { + chatModel.reversedChatItems = [] } } } + } + .toolbar { ToolbarItem(placement: .principal) { if case let .direct(contact) = cInfo { Button { @@ -181,7 +190,7 @@ struct ChatView: View { } } } - + private func searchToolbar() -> some View { HStack { HStack { @@ -237,7 +246,6 @@ struct ChatView: View { if chatModel.chatId == cInfo.id && itemsInView.contains(ci.viewId) { Task { await apiMarkChatItemRead(cInfo, ci) - NtfManager.shared.decNtfBadgeCount() } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 5ca4627917..0136159ed4 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -161,6 +161,10 @@ struct ComposeState { default: return true } } + + var empty: Bool { + message == "" && liveMessage == nil && noPreview + } } func chatItemPreview(chatItem: ChatItem) -> ComposePreview { @@ -549,14 +553,16 @@ struct ComposeView: View { sent = await send(checkLinkPreview(), quoted: quoted, live: live) case let .imagePreviews(imagePreviews: images): let last = min(chosenImages.count, images.count) - 1 - for i in 0..= 0 { + for i in 0.. String { - let min = Int(time / 60) - let sec = Int(time.truncatingRemainder(dividingBy: 60)) - return String(format: "%02d:%02d", min, sec) + durationText(Int(time)) } func voiceMessageTime_(_ time: TimeInterval?) -> String { - return voiceMessageTime(time ?? TimeInterval(0)) + durationText(Int(time ?? 0)) } struct ComposeVoiceView: View { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index b5f8e9b5fd..8088f82fa3 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -76,45 +76,20 @@ struct SendMessageView: View { } } - if (composeState.inProgress) { + if composeState.inProgress { ProgressView() .scaleEffect(1.4) .frame(width: 31, height: 31, alignment: .center) .padding([.bottom, .trailing], 3) } else { - let vmrs = composeState.voiceMessageRecordingState - if showVoiceMessageButton - && composeState.message.isEmpty - && !composeState.editing - && composeState.liveMessage == nil - && ((composeState.noPreview && vmrs == .noRecording) - || (vmrs == .recording && holdingVMR)) { - HStack { - if voiceMessageAllowed { - RecordVoiceMessageButton( - startVoiceMessageRecording: startVoiceMessageRecording, - finishVoiceMessageRecording: finishVoiceMessageRecording, - holdingVMR: $holdingVMR, - disabled: composeState.disabled - ) - } else { - voiceMessageNotAllowedButton() - } - if let send = sendLiveMessage, - let update = updateLiveMessage, - case .noContextItem = composeState.contextItem { - startLiveMessageButton(send: send, update: update) - } + VStack(alignment: .trailing) { + if teHeight > 100 { + deleteTextButton() + Spacer() } - } else if vmrs == .recording && !holdingVMR { - finishVoiceMessageRecordingButton() - } else if composeState.liveMessage != nil && composeState.liveMessage?.sentMsg == nil && composeState.message.isEmpty { - cancelLiveMessageButton { - cancelLiveMessage?() - } - } else { - sendMessageButton() + composeActionButtons() } + .frame(height: teHeight, alignment: .bottom) } } @@ -125,6 +100,52 @@ struct SendMessageView: View { .padding(.vertical, 8) } + @ViewBuilder private func composeActionButtons() -> some View { + let vmrs = composeState.voiceMessageRecordingState + if showVoiceMessageButton + && composeState.message.isEmpty + && !composeState.editing + && composeState.liveMessage == nil + && ((composeState.noPreview && vmrs == .noRecording) + || (vmrs == .recording && holdingVMR)) { + HStack { + if voiceMessageAllowed { + RecordVoiceMessageButton( + startVoiceMessageRecording: startVoiceMessageRecording, + finishVoiceMessageRecording: finishVoiceMessageRecording, + holdingVMR: $holdingVMR, + disabled: composeState.disabled + ) + } else { + voiceMessageNotAllowedButton() + } + if let send = sendLiveMessage, + let update = updateLiveMessage, + case .noContextItem = composeState.contextItem { + startLiveMessageButton(send: send, update: update) + } + } + } else if vmrs == .recording && !holdingVMR { + finishVoiceMessageRecordingButton() + } else if composeState.liveMessage != nil && composeState.liveMessage?.sentMsg == nil && composeState.message.isEmpty { + cancelLiveMessageButton { + cancelLiveMessage?() + } + } else { + sendMessageButton() + } + } + + private func deleteTextButton() -> some View { + Button { + composeState.message = "" + } label: { + Image(systemName: "multiply.circle.fill") + } + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .padding([.top, .trailing], 4) + } + @ViewBuilder private func sendMessageButton() -> some View { let v = Button(action: sendMessage) { Image(systemName: composeState.editing || composeState.liveMessage != nil diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 20b70fb0dc..bea381bdc5 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -145,12 +145,6 @@ struct GroupChatInfoView: View { } } - private func serverImage() -> some View { - let status = chat.serverInfo.networkStatus - return Image(systemName: status.imageName) - .foregroundColor(status == .connected ? .green : .secondary) - } - private func memberView(_ member: GroupMember, user: Bool = false) -> some View { HStack{ ProfileImage(imageStr: member.image) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 5bcfa72ea2..9f367fb472 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -155,8 +155,6 @@ struct GroupMemberInfoView: View { Button { do { let chat = try apiGetChat(type: .direct, id: contactId) - // TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend - chat.serverInfo = Chat.ServerInfo(networkStatus: .connected) chatModel.addChat(chat) dismissAllSheets(animated: true) DispatchQueue.main.async { diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 81c642625b..59ecad36e7 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -416,9 +416,9 @@ struct ErrorAlert { func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert { switch error as? ChatResponse { - case let .chatCmdError(.errorAgent(.BROKER(addr, .TIMEOUT))): + case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))): return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.") - case let .chatCmdError(.errorAgent(.BROKER(addr, .NETWORK))): + case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))): return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.") default: return ErrorAlert(title: title, message: "Error: \(responseError(error))") diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 4b66f53d91..d23418101f 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -11,29 +11,39 @@ import SimpleXChat struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel - // not really used in this view @State private var showSettings = false @State private var searchText = "" @State private var showAddChat = false + @State var userPickerVisible = false var body: some View { - NavStackCompat( - isActive: Binding( - get: { ChatModel.shared.chatId != nil }, - set: { _ in } - ), - destination: chatView - ) { - VStack { - if chatModel.chats.isEmpty { - onboardingButtons() - } - if chatModel.chats.count > 8 { - chatList.searchable(text: $searchText) - } else { - chatList + ZStack(alignment: .topLeading) { + NavStackCompat( + isActive: Binding( + get: { ChatModel.shared.chatId != nil }, + set: { _ in } + ), + destination: chatView + ) { + VStack { + if chatModel.chats.isEmpty { + onboardingButtons() + } + if chatModel.chats.count > 8 { + chatList.searchable(text: $searchText) + } else { + chatList + } } } + if userPickerVisible { + Rectangle().fill(.white.opacity(0.001)).onTapGesture { + withAnimation { + userPickerVisible.toggle() + } + } + } + UserPicker(showSettings: $showSettings, userPickerVisible: $userPickerVisible) } } @@ -53,13 +63,35 @@ struct ChatListView: View { } .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } .onAppear() { connectViaUrl() } + .onDisappear() { withAnimation { userPickerVisible = false } } .offset(x: -8) .listStyle(.plain) .navigationTitle("Your chats") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - SettingsButton() + Button { + if chatModel.users.count > 1 { + withAnimation { + userPickerVisible.toggle() + } + } else { + showSettings = true + } + } label: { + let user = chatModel.currentUser ?? User.sampleData + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: user.image, color: Color(uiColor: .quaternaryLabel)) + .frame(width: 32, height: 32) + .padding(.trailing, 4) + let allRead = chatModel.users + .filter { !$0.user.activeUser } + .allSatisfy { u in u.unreadCount == 0 } + if !allRead { + unreadBadge(size: 12) + } + } + } } ToolbarItem(placement: .principal) { if (chatModel.incognito) { @@ -70,6 +102,8 @@ struct ChatListView: View { } Image(systemName: "theatermasks").frame(maxWidth: 24, maxHeight: 24, alignment: .center).foregroundColor(.indigo) } + } else { + Text("Your chats").font(.headline) } } ToolbarItem(placement: .navigationBarTrailing) { @@ -80,6 +114,15 @@ struct ChatListView: View { } } } + .sheet(isPresented: $showSettings) { + SettingsView(showSettings: $showSettings) + } + } + + private func unreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View { + Circle() + .frame(width: size, height: size) + .foregroundColor(.accentColor) } private func onboardingButtons() -> some View { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 00afb65aad..f006ebf13c 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -106,31 +106,57 @@ struct ChatPreviewView: View { .kerning(-2) } + private func chatPreviewLayout(_ text: Text) -> some View { + ZStack(alignment: .topTrailing) { + text + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.leading, 8) + .padding(.trailing, 36) + let s = chat.chatStats + if s.unreadCount > 0 || s.unreadChat { + unreadCountText(s.unreadCount) + .font(.caption) + .foregroundColor(.white) + .padding(.horizontal, 4) + .frame(minWidth: 18, minHeight: 18) + .background(chat.chatInfo.ntfsEnabled ? Color.accentColor : Color.secondary) + .cornerRadius(10) + } else if !chat.chatInfo.ntfsEnabled { + Image(systemName: "speaker.slash.fill") + .foregroundColor(.secondary) + } + } + } + + private func messageDraft(_ draft: ComposeState) -> Text { + let msg = draft.message + return image("rectangle.and.pencil.and.ellipsis", color: .accentColor) + + attachment() + + messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true) + + func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text { + Text(Image(systemName: s)).foregroundColor(color) + Text(" ") + } + + func attachment() -> Text { + switch draft.preview { + case .filePreview: return image("doc.fill") + case .imagePreviews: return image("photo") + case let .voicePreview(_, duration): return image("play.fill") + Text(voiceMessageTime(TimeInterval(duration))) + default: return Text("") + } + } + } + @ViewBuilder private func chatPreviewText(_ cItem: ChatItem?) -> some View { - if let cItem = cItem { + if chatModel.draftChatId == chat.id, let draft = chatModel.draft { + chatPreviewLayout(messageDraft(draft)) + } else if let cItem = cItem { let itemText = !cItem.meta.itemDeleted ? cItem.text : NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text") let itemFormattedText = !cItem.meta.itemDeleted ? cItem.formattedText : nil - ZStack(alignment: .topTrailing) { - (itemStatusMark(cItem) + messageText(itemText, itemFormattedText, cItem.memberDisplayName, preview: true)) - .lineLimit(2) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(.leading, 8) - .padding(.trailing, 36) - let s = chat.chatStats - if s.unreadCount > 0 || s.unreadChat { - unreadCountText(s.unreadCount) - .font(.caption) - .foregroundColor(.white) - .padding(.horizontal, 4) - .frame(minWidth: 18, minHeight: 18) - .background(chat.chatInfo.ntfsEnabled ? Color.accentColor : Color.secondary) - .cornerRadius(10) - } else if !chat.chatInfo.ntfsEnabled { - Image(systemName: "speaker.slash.fill") - .foregroundColor(.secondary) - } - } + chatPreviewLayout(itemStatusMark(cItem) + messageText(itemText, itemFormattedText, cItem.memberDisplayName, preview: true)) } else { switch (chat.chatInfo) { case let .direct(contact): @@ -179,16 +205,21 @@ struct ChatPreviewView: View { } @ViewBuilder private func chatStatusImage() -> some View { - switch chat.serverInfo.networkStatus { - case .connected: EmptyView() - case .error: - Image(systemName: "exclamationmark.circle") - .resizable() - .scaledToFit() - .frame(width: 17, height: 17) - .foregroundColor(.secondary) + switch chat.chatInfo { + case let .direct(contact): + switch (chatModel.contactNetworkStatus(contact)) { + case .connected: EmptyView() + case .error: + Image(systemName: "exclamationmark.circle") + .resizable() + .scaledToFit() + .frame(width: 17, height: 17) + .foregroundColor(.secondary) + default: + ProgressView() + } default: - ProgressView() + EmptyView() } } } @@ -220,8 +251,7 @@ struct ChatPreviewView_Previews: PreviewProvider { ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent)], - chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0), - serverInfo: Chat.ServerInfo(networkStatus: .error("status")) + chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0) )) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.group, diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift new file mode 100644 index 0000000000..8d50bff6b0 --- /dev/null +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -0,0 +1,156 @@ +// +// Created by Avently on 16.01.2023. +// Copyright (c) 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +private let fillColorDark = Color(uiColor: UIColor(red: 0.11, green: 0.11, blue: 0.11, alpha: 255)) +private let fillColorLight = Color(uiColor: UIColor(red: 0.99, green: 0.99, blue: 0.99, alpha: 255)) + +struct UserPicker: View { + @EnvironmentObject var m: ChatModel + @Environment(\.colorScheme) var colorScheme + @Binding var showSettings: Bool + @Binding var userPickerVisible: Bool + @State var scrollViewContentSize: CGSize = .zero + @State var disableScrolling: Bool = true + private let menuButtonHeight: CGFloat = 68 + @State var chatViewNameWidth: CGFloat = 0 + + var fillColor: Color { + colorScheme == .dark ? fillColorDark : fillColorLight + } + + var body: some View { + VStack { + Spacer().frame(height: 1) + VStack(spacing: 0) { + ScrollView { + ScrollViewReader { sp in + let users = m.users.sorted { u, _ in u.user.activeUser } + VStack(spacing: 0) { + ForEach(users) { u in + userView(u) + Divider() + } + } + .overlay { + GeometryReader { geo -> Color in + DispatchQueue.main.async { + scrollViewContentSize = geo.size + let scenes = UIApplication.shared.connectedScenes + if let windowScene = scenes.first as? UIWindowScene { + let layoutFrame = windowScene.windows[0].safeAreaLayoutGuide.layoutFrame + disableScrolling = scrollViewContentSize.height + menuButtonHeight + 10 < layoutFrame.height + } + } + return Color.clear + } + } + .onChange(of: userPickerVisible) { visible in + if visible, let u = users.first { + sp.scrollTo(u.id) + } + } + } + } + .simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000)) + .frame(maxHeight: scrollViewContentSize.height) + + menuButton("Settings", icon: "gearshape") { + showSettings = true + withAnimation { + userPickerVisible.toggle() + } + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 16)) + .background( + Rectangle() + .fill(fillColor) + .cornerRadius(16) + .shadow(color: .black.opacity(0.12), radius: 24, x: 0, y: 0) + ) + .onPreferenceChange(DetermineWidth.Key.self) { chatViewNameWidth = $0 } + .frame(maxWidth: chatViewNameWidth > 0 ? min(300, chatViewNameWidth + 130) : 300) + .padding(8) + .opacity(userPickerVisible ? 1.0 : 0.0) + .onAppear { + do { + m.users = try listUsers() + } catch let error { + logger.error("Error updating users \(responseError(error))") + } + } + } + + private func userView(_ u: UserInfo) -> some View { + let user = u.user + return Button(action: { + if !user.activeUser { + changeActiveUser(user.userId) + userPickerVisible = false + } + }, label: { + HStack(spacing: 0) { + ProfileImage(imageStr: user.image, color: Color(uiColor: .tertiarySystemFill)) + .frame(width: 44, height: 44) + .padding(.trailing, 12) + Text(user.chatViewName) + .fontWeight(user.activeUser ? .medium : .regular) + .foregroundColor(.primary) + .overlay(DetermineWidth()) + Spacer() + if user.activeUser { + Image(systemName: "checkmark") + } else if u.unreadCount > 0 { + unreadCounter(u.unreadCount) + } + } + .padding(.trailing) + .padding([.leading, .vertical], 12) + }) + .buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill))) + } + + private func menuButton(_ title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 0) { + Text(title) + .overlay(DetermineWidth()) + Spacer() + Image(systemName: icon) +// .frame(width: 24, alignment: .center) + } + .padding(.horizontal) + .padding(.vertical, 22) + .frame(height: menuButtonHeight) + } + .buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill))) + } +} + +func unreadCounter(_ unread: Int) -> some View { + unreadCountText(unread) + .font(.caption) + .foregroundColor(.white) + .padding(.horizontal, 4) + .frame(minWidth: 18, minHeight: 18) + .background(Color.accentColor) + .cornerRadius(10) +} + +struct UserPicker_Previews: PreviewProvider { + static var previews: some View { + let m = ChatModel() + m.users = [UserInfo.sampleData, UserInfo.sampleData] + return UserPicker( + showSettings: Binding.constant(false), + userPickerVisible: Binding.constant(true) + ) + .environmentObject(m) + } +} diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index 52cf600767..22ab2a4edf 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -152,7 +152,7 @@ struct DatabaseEncryptionView: View { await operationEnded(.databaseEncrypted) } } catch let error { - if case .chatCmdError(.errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse { + if case .chatCmdError(_, .errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse { await operationEnded(.currentPassphraseError) } else { await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))")) diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 701374f662..11b5ef25db 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -67,6 +67,23 @@ struct DatabaseView: View { private func chatDatabaseView() -> some View { List { let stopped = m.chatRunning == false + Section { + Picker("Delete messages after", selection: $chatItemTTL) { + ForEach(ChatItemTTL.values) { ttl in + Text(ttl.deleteAfterText).tag(ttl) + } + if case .seconds = chatItemTTL { + Text(chatItemTTL.deleteAfterText).tag(chatItemTTL) + } + } + .frame(height: 36) + .disabled(m.chatDbChanged || progressIndicator) + } header: { + Text("Messages") + } footer: { + Text("This setting applies to messages in your current chat profile **\(m.currentUser?.displayName ?? "")**.") + } + Section { settingsRow( stopped ? "exclamationmark.octagon.fill" : "play.fill", @@ -157,22 +174,12 @@ struct DatabaseView: View { } Section { - Picker("Delete messages after", selection: $chatItemTTL) { - ForEach(ChatItemTTL.values) { ttl in - Text(ttl.deleteAfterText).tag(ttl) - } - if case .seconds = chatItemTTL { - Text(chatItemTTL.deleteAfterText).tag(chatItemTTL) - } - } - .frame(height: 36) - .disabled(m.chatDbChanged || progressIndicator) - Button("Delete files & media", role: .destructive) { + Button(m.users.count > 1 ? "Delete files for all chat profiles" : "Delete all files", role: .destructive) { alert = .deleteFilesAndMedia } .disabled(!stopped || appFilesCountAndSize?.0 == 0) } header: { - Text("Data") + Text("Files & media") } footer: { if let (fileCount, size) = appFilesCountAndSize { if fileCount == 0 { diff --git a/apps/ios/Shared/Views/Helpers/PressedButtonStyle.swift b/apps/ios/Shared/Views/Helpers/PressedButtonStyle.swift new file mode 100644 index 0000000000..d8c7cd4ce6 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/PressedButtonStyle.swift @@ -0,0 +1,15 @@ +// +// Created by Avently on 16.01.2023. +// Copyright (c) 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct PressedButtonStyle: ButtonStyle { + var defaultColor: Color + var pressedColor: Color + func makeBody(configuration: Self.Configuration) -> some View { + configuration.label + .background(configuration.isPressed ? pressedColor : defaultColor) + } +} diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index c6ecb21eb9..ce0a6160b8 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -11,6 +11,7 @@ import SimpleXChat struct CreateProfile: View { @EnvironmentObject var m: ChatModel + @Environment(\.dismiss) var dismiss @State private var displayName: String = "" @State private var fullName: String = "" @FocusState private var focusDisplayName @@ -50,13 +51,17 @@ struct CreateProfile: View { Spacer() HStack { - Button { - hideKeyboard() - withAnimation { m.onboardingStage = .step1_SimpleXInfo } - } label: { - HStack { - Image(systemName: "lessthan") - Text("About SimpleX") + if m.users.isEmpty { + Button { + hideKeyboard() + withAnimation { + m.onboardingStage = .step1_SimpleXInfo + } + } label: { + HStack { + Image(systemName: "lessthan") + Text("About SimpleX") + } } } @@ -96,9 +101,14 @@ struct CreateProfile: View { ) do { m.currentUser = try apiCreateActiveUser(profile) - try startChat() - withAnimation { m.onboardingStage = .step3_SetNotificationsMode } - + if m.users.isEmpty { + try startChat() + withAnimation { m.onboardingStage = .step3_SetNotificationsMode } + } else { + dismiss() + m.users = try listUsers() + try getUserChatData() + } } catch { fatalError("Failed to create user or start chat: \(responseError(error))") } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index f50b87a695..0274e6fca8 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -108,7 +108,7 @@ struct TerminalView: View { func sendMessage() { let cmd = ChatCommand.string(composeState.message) if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) { - let resp = ChatResponse.chatCmdError(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.terminalItems.append(.cmd(.now, cmd)) ChatModel.shared.terminalItems.append(.resp(.now, resp)) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift index b88a18e170..598255a0bd 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift @@ -9,13 +9,15 @@ import SwiftUI import SimpleXChat -enum OnionHostsAlert: Identifiable { - case update(hosts: OnionHosts) +private enum NetworkAlert: Identifiable { + case updateOnionHosts(hosts: OnionHosts) + case updateSessionMode(mode: TransportSessionMode) case error(err: String) var id: String { switch self { - case let .update(hosts): return "update \(hosts)" + case let .updateOnionHosts(hosts): return "updateOnionHosts \(hosts)" + case let .updateSessionMode(mode): return "updateSessionMode \(mode)" case let .error(err): return "error \(err)" } } @@ -27,7 +29,8 @@ struct NetworkAndServers: View { @State private var currentNetCfg = NetCfg.defaults @State private var netCfg = NetCfg.defaults @State private var onionHosts: OnionHosts = .no - @State private var showOnionHostsAlert: OnionHostsAlert? + @State private var sessionMode: TransportSessionMode = .user + @State private var alert: NetworkAlert? var body: some View { VStack { @@ -45,6 +48,12 @@ struct NetworkAndServers: View { } .frame(height: 36) + if developerTools { + Picker("Transport isolation", selection: $sessionMode) { + ForEach(TransportSessionMode.values, id: \.self) { Text($0.text) } + } + } + NavigationLink { AdvancedNetworkSettings() .navigationTitle("Network settings") @@ -75,17 +84,37 @@ struct NetworkAndServers: View { } .onChange(of: onionHosts) { _ in if onionHosts != OnionHosts(netCfg: currentNetCfg) { - showOnionHostsAlert = .update(hosts: onionHosts) + alert = .updateOnionHosts(hosts: onionHosts) } } - .alert(item: $showOnionHostsAlert) { a in + .onChange(of: sessionMode) { _ in + if sessionMode != netCfg.sessionMode { + alert = .updateSessionMode(mode: sessionMode) + } + } + .alert(item: $alert) { a in switch a { - case let .update(hosts): + case let .updateOnionHosts(hosts): return Alert( title: Text("Update .onion hosts setting?"), - message: Text(onionHostsInfo()) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."), + message: Text(onionHostsInfo(hosts)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."), primaryButton: .default(Text("Ok")) { - saveNetCfg(hosts) + let (hostMode, requiredHostMode) = hosts.hostMode + netCfg.hostMode = hostMode + netCfg.requiredHostMode = requiredHostMode + saveNetCfg() + }, + secondaryButton: .cancel() { + resetNetCfgView() + } + ) + case let .updateSessionMode(mode): + return Alert( + title: Text("Update transport isolation mode?"), + message: Text(sessionModeInfo(mode)) + Text("\n") + Text("Updating this setting will re-connect the client to all servers."), + primaryButton: .default(Text("Ok")) { + netCfg.sessionMode = mode + saveNetCfg() }, secondaryButton: .cancel() { resetNetCfgView() @@ -100,11 +129,8 @@ struct NetworkAndServers: View { } } - private func saveNetCfg(_ hosts: OnionHosts) { + private func saveNetCfg() { do { - let (hostMode, requiredHostMode) = hosts.hostMode - netCfg.hostMode = hostMode - netCfg.requiredHostMode = requiredHostMode let def = netCfg.hostMode == .onionHost ? NetCfg.proxyDefaults : NetCfg.defaults netCfg.tcpConnectTimeout = def.tcpConnectTimeout netCfg.tcpTimeout = def.tcpTimeout @@ -114,7 +140,7 @@ struct NetworkAndServers: View { } catch let error { let err = responseError(error) resetNetCfgView() - showOnionHostsAlert = .error(err: err) + alert = .error(err: err) logger.error("\(err)") } } @@ -122,15 +148,23 @@ struct NetworkAndServers: View { private func resetNetCfgView() { netCfg = currentNetCfg onionHosts = OnionHosts(netCfg: netCfg) + sessionMode = netCfg.sessionMode } - private func onionHostsInfo() -> LocalizedStringKey { - switch onionHosts { + private func onionHostsInfo(_ hosts: OnionHosts) -> LocalizedStringKey { + switch hosts { case .no: return "Onion hosts will not be used." case .prefer: return "Onion hosts will be used when available. Requires enabling VPN." case .require: return "Onion hosts will be required for connection. Requires enabling VPN." } } + + private func sessionModeInfo(_ mode: TransportSessionMode) -> LocalizedStringKey { + switch mode { + case .user: return "A separate TCP connection will be used **for each chat profile you have in the app**." + case .entity: return "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." + } + } } struct NetworkServersView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift index dc6da64ebd..fbce7193fa 100644 --- a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift +++ b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift @@ -71,10 +71,7 @@ struct PreferencesView: View { p.preferences = fullPreferencesToPreferences(preferences) if let newProfile = try await apiUpdateProfile(profile: p) { await MainActor.run { - if let profileId = chatModel.currentUser?.profile.profileId { - chatModel.currentUser?.profile = toLocalProfile(profileId, newProfile, "") - chatModel.currentUser?.fullPreferences = preferences - } + chatModel.updateCurrentUser(newProfile, preferences) currentPreferences = preferences } } diff --git a/apps/ios/Shared/Views/UserSettings/SMPServersView.swift b/apps/ios/Shared/Views/UserSettings/SMPServersView.swift index 372c5bb34a..7cea87cb6a 100644 --- a/apps/ios/Shared/Views/UserSettings/SMPServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/SMPServersView.swift @@ -44,7 +44,7 @@ struct SMPServersView: View { private func smpServersView() -> some View { List { - Section("SMP servers") { + Section { ForEach($servers) { srv in smpServerView(srv) } @@ -57,6 +57,11 @@ struct SMPServersView: View { Button("Add server…") { showAddServer = true } + } header: { + Text("SMP servers") + } footer: { + Text("The servers for new connections of your current chat profile **\(m.currentUser?.displayName ?? "")**.") + .lineLimit(10) } Section { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift deleted file mode 100644 index 7292fd4373..0000000000 --- a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// SettingsButton.swift -// SimpleX -// -// Created by Evgeny Poberezkin on 31/01/2022. -// Copyright © 2022 SimpleX Chat. All rights reserved. -// - -import SwiftUI - -struct SettingsButton: View { - @EnvironmentObject var chatModel: ChatModel - @State private var showSettings = false - - var body: some View { - Button { showSettings = true } label: { - Image(systemName: "gearshape") - } - .sheet(isPresented: $showSettings, content: { - SettingsView(showSettings: $showSettings) - }) - } -} - -struct SettingsButton_Previews: PreviewProvider { - static var previews: some View { - SettingsButton() - } -} diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 2180a36c7d..a05a43ad21 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -113,12 +113,19 @@ struct SettingsView: View { Section("You") { NavigationLink { UserProfile() - .navigationTitle("Your chat profile") + .navigationTitle("Your current profile") } label: { ProfilePreview(profileOf: user) .padding(.leading, -8) } + NavigationLink { + UserProfilesView() + .navigationTitle("Your chat profiles") + } label: { + settingsRow("person.crop.rectangle.stack") { Text("Your chat profiles") } + } + incognitoRow() NavigationLink { @@ -387,7 +394,7 @@ func settingsRow(_ icon: String, color: Color = .secondary, cont struct ProfilePreview: View { var profileOf: NamedChat - var color = Color(uiColor: .tertiarySystemGroupedBackground) + var color = Color(uiColor: .tertiarySystemFill) var body: some View { HStack { diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index de7a19c8bd..5388cc3cfb 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -141,9 +141,7 @@ struct UserProfile: View { do { if let newProfile = try await apiUpdateProfile(profile: profile) { DispatchQueue.main.async { - if let profileId = chatModel.currentUser?.profile.profileId { - chatModel.currentUser?.profile = toLocalProfile(profileId, newProfile, "") - } + chatModel.updateCurrentUser(newProfile) profile = newProfile } } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift new file mode 100644 index 0000000000..6752dab748 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -0,0 +1,130 @@ +// +// Created by Avently on 17.01.2023. +// Copyright (c) 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct UserProfilesView: View { + @EnvironmentObject private var m: ChatModel + @Environment(\.editMode) private var editMode + @State private var showDeleteConfirmation = false + @State private var userToDelete: Int? + @State private var alert: UserProfilesAlert? + + private enum UserProfilesAlert: Identifiable { + case deleteUser(index: Int, delSMPQueues: Bool) + case error(title: LocalizedStringKey, error: LocalizedStringKey = "") + + var id: String { + switch self { + case let .deleteUser(index, delSMPQueues): return "deleteUser \(index) \(delSMPQueues)" + case let .error(title, _): return "error \(title)" + } + } + } + + var body: some View { + List { + Section { + ForEach(m.users) { u in + userView(u.user) + } + .onDelete { indexSet in + if let i = indexSet.first { + showDeleteConfirmation = true + userToDelete = i + } + } + + NavigationLink { + CreateProfile() + } label: { + Label("Add profile", systemImage: "plus") + } + .frame(height: 44) + .padding(.vertical, 4) + } footer: { + Text("Your chat profiles are stored locally, only on your device.") + } + } + .toolbar { EditButton() } + .confirmationDialog("Delete chat profile?", isPresented: $showDeleteConfirmation, titleVisibility: .visible) { + deleteModeButton("Profile and server connections", true) + deleteModeButton("Local profile data only", false) + } + .alert(item: $alert) { alert in + switch alert { + case let .deleteUser(index, 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) + }, + secondaryButton: .cancel() + ) + case let .error(title, error): + return Alert(title: Text(title), message: Text(error)) + } + } + } + + private func deleteModeButton(_ title: LocalizedStringKey, _ delSMPQueues: Bool) -> some View { + Button(title, role: .destructive) { + if let i = userToDelete { + alert = .deleteUser(index: i, delSMPQueues: delSMPQueues) + } + } + } + + private func removeUser(_ index: Int, _ delSMPQueues: Bool) { + if index >= m.users.count { return } + do { + let u = m.users[index].user + if u.activeUser { + if let newActive = m.users.first(where: { !$0.user.activeUser }) { + try changeActiveUser_(newActive.user.userId) + try deleteUser(u.userId) + } + } else { + try deleteUser(u.userId) + } + } 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) + } + } + + @ViewBuilder private func userView(_ user: User) -> some View { + Button { + changeActiveUser(user.userId) + } label: { + HStack { + ProfileImage(imageStr: user.image, color: Color(uiColor: .tertiarySystemFill)) + .frame(width: 44, height: 44) + .padding(.vertical, 4) + .padding(.trailing, 12) + Text(user.chatViewName) + Spacer() + Image(systemName: "checkmark") + .foregroundColor(user.activeUser ? .primary : .clear) + } + } + .disabled(user.activeUser) + .foregroundColor(.primary) + .deleteDisabled(m.users.count <= 1) + } +} + +struct UserProfilesView_Previews: PreviewProvider { + static var previews: some View { + UserProfilesView() + } +} diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 0c5f9308e6..9f1ffb731b 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -110,7 +110,7 @@ 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(connEntity)) + setBestAttemptNtf(createConnectionEventNtf(ntfMsgInfo.user, connEntity)) if let id = connEntity.id { Task { logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") @@ -118,9 +118,9 @@ class NotificationService: UNNotificationServiceExtension { await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count) deliverBestAttemptNtf() } - return } } + return } else { setBestAttemptNtf(createErrorNtf(dbStatus)) } @@ -209,15 +209,18 @@ func chatRecvMsg() async -> ChatResponse? { func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotificationContent)? { logger.debug("NotificationService processReceivedMsg: \(res.responseType)") switch res { - case let .contactConnected(contact, _): - return (contact.id, createContactConnectedNtf(contact)) + case let .contactConnected(user, contact, _): + return (contact.id, createContactConnectedNtf(user, contact)) // case let .contactConnecting(contact): // TODO profile update - case let .receivedContactRequest(contactRequest): - return (UserContact(contactRequest: contactRequest).id, createContactRequestNtf(contactRequest)) - case let .newChatItem(aChatItem): + case let .receivedContactRequest(user, contactRequest): + return (UserContact(contactRequest: contactRequest).id, createContactRequestNtf(user, contactRequest)) + case let .newChatItem(user, aChatItem): let cInfo = aChatItem.chatInfo var cItem = aChatItem.chatItem + if !cInfo.ntfsEnabled { + ntfBadgeCountGroupDefault.set(max(0, ntfBadgeCountGroupDefault.get() - 1)) + } if case .image = cItem.content.msgContent { if let file = cItem.file, file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV, @@ -234,7 +237,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification cItem = apiReceiveFile(fileId: file.fileId, inline: inline)?.chatItem ?? cItem } } - return cItem.showMutableNotification ? (aChatItem.chatId, createMessageReceivedNtf(cInfo, cItem)) : nil + return cItem.showMutableNotification ? (aChatItem.chatId, createMessageReceivedNtf(user, cInfo, cItem)) : nil case let .callInvitation(invitation): return (invitation.contact.id, createCallInvitationNtf(invitation)) default: @@ -258,10 +261,10 @@ func updateNetCfg() { func apiGetActiveUser() -> User? { let r = sendSimpleXCmd(.showActiveUser) - logger.debug("apiGetActiveUser sendSimpleXCmd responce: \(String(describing: r))") + logger.debug("apiGetActiveUser sendSimpleXCmd response: \(String(describing: r))") switch r { case let .activeUser(user): return user - case .chatCmdError(.error(.noActiveUser)): return nil + case .chatCmdError(_, .error(.noActiveUser)): return nil default: logger.error("NotificationService apiGetActiveUser unexpected response: \(String(describing: r))") return nil @@ -290,17 +293,24 @@ func apiSetIncognito(incognito: Bool) throws { } func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { - let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo)) - if case let .ntfMessages(connEntity, msgTs, ntfMessages) = r { - return NtfMessages(connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages) + guard apiGetActiveUser() != nil else { + logger.debug("no active user") + return nil + } + let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo)) + if case let .ntfMessages(user, connEntity, msgTs, ntfMessages) = r, let user = user { + return NtfMessages(user: user, connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages) + } else if case let .chatCmdError(_, error) = r { + logger.debug("apiGetNtfMessage error response: \(String.init(describing: error))") + } else { + logger.debug("apiGetNtfMessage ignored response: \(r.responseType, privacy: .public) \(String.init(describing: r), privacy: .private)") } - logger.debug("apiGetNtfMessage ignored response: \(String.init(describing: r), privacy: .public)") return nil } func apiReceiveFile(fileId: Int64, inline: Bool) -> AChatItem? { let r = sendSimpleXCmd(.receiveFile(fileId: fileId, inline: inline)) - if case let .rcvFileAccepted(chatItem) = r { return chatItem } + if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } logger.error("receiveFile error: \(responseError(r))") return nil } @@ -312,6 +322,7 @@ func setNetworkConfig(_ cfg: NetCfg) throws { } struct NtfMessages { + var user: User var connEntity: ConnectionEntity? var msgTs: Date? var ntfMessages: [NtfMsgInfo] diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 094792f7f9..812b8a6f32 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -7,7 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 1841538E296606C74533367C /* UserPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415835CBD939A9ABDC108A /* UserPicker.swift */; }; + 1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415845648CA4F5A8BCA272 /* UserProfilesView.swift */; }; 1841594C978674A7B42EF0C0 /* AnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1841511920742C6E152E469F /* AnimatedImageView.swift */; }; + 18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415A7F0F189D87DEFEABCA /* PressedButtonStyle.swift */; }; 3C714777281C081000CB4D4B /* WebRTCView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C714776281C081000CB4D4B /* WebRTCView.swift */; }; 3C71477A281C0F6800CB4D4B /* www in Resources */ = {isa = PBXBuildFile; fileRef = 3C714779281C0F6800CB4D4B /* www */; }; 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; }; @@ -38,6 +41,11 @@ 5C3F1D5A2844B4DE00EC8A82 /* ExperimentalFeaturesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */; }; 5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B3B09285FB130003915F2 /* DatabaseView.swift */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; + 5C54F6F2297DF8A40054C4E2 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C54F6ED297DF8A40054C4E2 /* libffi.a */; }; + 5C54F6F3297DF8A40054C4E2 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C54F6EE297DF8A40054C4E2 /* libgmp.a */; }; + 5C54F6F4297DF8A40054C4E2 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C54F6EF297DF8A40054C4E2 /* libgmpxx.a */; }; + 5C54F6F5297DF8A40054C4E2 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C54F6F0297DF8A40054C4E2 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r-ghc8.10.7.a */; }; + 5C54F6F6297DF8A40054C4E2 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C54F6F1297DF8A40054C4E2 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r.a */; }; 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A91E283AD0E400C4E99E /* CallManager.swift */; }; 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */; }; @@ -48,11 +56,6 @@ 5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5E5D3A2824468B00B0488A /* ActiveCallView.swift */; }; 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; }; 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; }; - 5C65F33C297D3D9700B67AF3 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C65F337297D3D9700B67AF3 /* libgmpxx.a */; }; - 5C65F33D297D3D9700B67AF3 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C65F338297D3D9700B67AF3 /* libgmp.a */; }; - 5C65F33E297D3D9700B67AF3 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C65F339297D3D9700B67AF3 /* libffi.a */; }; - 5C65F33F297D3D9700B67AF3 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C65F33A297D3D9700B67AF3 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r-ghc8.10.7.a */; }; - 5C65F340297D3D9700B67AF3 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C65F33B297D3D9700B67AF3 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r.a */; }; 5C65F343297D45E100B67AF3 /* VersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C65F341297D3F3600B67AF3 /* VersionView.swift */; }; 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; 5C6BA667289BD954009B8ECC /* DismissSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6BA666289BD954009B8ECC /* DismissSheets.swift */; }; @@ -96,7 +99,6 @@ 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */; }; 5CB346E72868D76D001FD2EF /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E62868D76D001FD2EF /* NotificationsView.swift */; }; 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */; }; - 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; }; 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; }; 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E327A8683A00ACCCDD /* UserAddress.swift */; }; @@ -224,6 +226,9 @@ /* Begin PBXFileReference section */ 1841511920742C6E152E469F /* AnimatedImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimatedImageView.swift; sourceTree = ""; }; + 18415835CBD939A9ABDC108A /* UserPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserPicker.swift; sourceTree = ""; }; + 18415845648CA4F5A8BCA272 /* UserProfilesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserProfilesView.swift; sourceTree = ""; }; + 18415A7F0F189D87DEFEABCA /* PressedButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PressedButtonStyle.swift; sourceTree = ""; }; 3C714776281C081000CB4D4B /* WebRTCView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCView.swift; sourceTree = ""; }; 3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../android/app/src/main/assets/www; sourceTree = ""; }; 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = ""; }; @@ -256,6 +261,11 @@ 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C4B3B09285FB130003915F2 /* DatabaseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseView.swift; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; + 5C54F6ED297DF8A40054C4E2 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C54F6EE297DF8A40054C4E2 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C54F6EF297DF8A40054C4E2 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C54F6F0297DF8A40054C4E2 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r-ghc8.10.7.a"; sourceTree = ""; }; + 5C54F6F1297DF8A40054C4E2 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r.a"; sourceTree = ""; }; 5C55A91E283AD0E400C4E99E /* CallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallManager.swift; sourceTree = ""; }; 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = ""; }; 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPlayer.swift; sourceTree = ""; }; @@ -267,11 +277,6 @@ 5C5E5D3C282447AB00B0488A /* CallTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallTypes.swift; sourceTree = ""; }; 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImage.swift; sourceTree = ""; }; - 5C65F337297D3D9700B67AF3 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C65F338297D3D9700B67AF3 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C65F339297D3D9700B67AF3 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C65F33A297D3D9700B67AF3 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r-ghc8.10.7.a"; sourceTree = ""; }; - 5C65F33B297D3D9700B67AF3 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r.a"; sourceTree = ""; }; 5C65F341297D3F3600B67AF3 /* VersionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionView.swift; sourceTree = ""; }; 5C6AD81227A834E300348BD7 /* NewChatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatButton.swift; sourceTree = ""; }; 5C6BA666289BD954009B8ECC /* DismissSheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissSheets.swift; sourceTree = ""; }; @@ -324,7 +329,6 @@ 5CB346E42868AA7F001FD2EF /* SuspendChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuspendChat.swift; sourceTree = ""; }; 5CB346E62868D76D001FD2EF /* NotificationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsView.swift; sourceTree = ""; }; 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushEnvironment.swift; sourceTree = ""; }; - 5CB924D327A853F100ACCCDD /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; }; 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; 5CB924E327A8683A00ACCCDD /* UserAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddress.swift; sourceTree = ""; }; @@ -428,12 +432,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5C65F33C297D3D9700B67AF3 /* libgmpxx.a in Frameworks */, - 5C65F340297D3D9700B67AF3 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C65F33D297D3D9700B67AF3 /* libgmp.a in Frameworks */, - 5C65F33F297D3D9700B67AF3 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r-ghc8.10.7.a in Frameworks */, - 5C65F33E297D3D9700B67AF3 /* libffi.a in Frameworks */, + 5C54F6F6297DF8A40054C4E2 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r.a in Frameworks */, + 5C54F6F4297DF8A40054C4E2 /* libgmpxx.a in Frameworks */, + 5C54F6F2297DF8A40054C4E2 /* libffi.a in Frameworks */, + 5C54F6F5297DF8A40054C4E2 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r-ghc8.10.7.a in Frameworks */, + 5C54F6F3297DF8A40054C4E2 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -492,11 +496,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C65F339297D3D9700B67AF3 /* libffi.a */, - 5C65F338297D3D9700B67AF3 /* libgmp.a */, - 5C65F337297D3D9700B67AF3 /* libgmpxx.a */, - 5C65F33A297D3D9700B67AF3 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r-ghc8.10.7.a */, - 5C65F33B297D3D9700B67AF3 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r.a */, + 5C54F6ED297DF8A40054C4E2 /* libffi.a */, + 5C54F6EE297DF8A40054C4E2 /* libgmp.a */, + 5C54F6EF297DF8A40054C4E2 /* libgmpxx.a */, + 5C54F6F0297DF8A40054C4E2 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r-ghc8.10.7.a */, + 5C54F6F1297DF8A40054C4E2 /* libHSsimplex-chat-4.4.4-696z0wvJHN7Hvtog9MKb6r.a */, ); path = Libraries; sourceTree = ""; @@ -540,6 +544,7 @@ 5C6BA666289BD954009B8ECC /* DismissSheets.swift */, 5C00164328A26FBC0094D739 /* ContextMenu.swift */, 5CA7DFC229302AF000F7FDDE /* AppSheet.swift */, + 18415A7F0F189D87DEFEABCA /* PressedButtonStyle.swift */, 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */, ); path = Helpers; @@ -628,7 +633,6 @@ 5CB924DF27A8678B00ACCCDD /* UserSettings */ = { isa = PBXGroup; children = ( - 5CB924D327A853F100ACCCDD /* SettingsButton.swift */, 5CB924D627A8563F00ACCCDD /* SettingsView.swift */, 5CB346E62868D76D001FD2EF /* NotificationsView.swift */, 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */, @@ -647,6 +651,7 @@ 5CB2084E28DA4B4800D024EC /* RTCServers.swift */, 5C3F1D592844B4DE00EC8A82 /* ExperimentalFeaturesView.swift */, 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */, + 18415845648CA4F5A8BCA272 /* UserProfilesView.swift */, 5C65F341297D3F3600B67AF3 /* VersionView.swift */, ); path = UserSettings; @@ -662,6 +667,7 @@ 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */, 5C13730A28156D2700F43030 /* ContactConnectionView.swift */, 5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */, + 18415835CBD939A9ABDC108A /* UserPicker.swift */, ); path = ChatList; sourceTree = ""; @@ -1061,7 +1067,6 @@ 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, 5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */, 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, - 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */, 3C714777281C081000CB4D4B /* WebRTCView.swift in Sources */, 6440CA00288857A10062C672 /* CIEventView.swift in Sources */, 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */, @@ -1081,6 +1086,9 @@ 644EFFE42937BE9700525D5B /* MarkedDeletedItemView.swift in Sources */, 1841594C978674A7B42EF0C0 /* AnimatedImageView.swift in Sources */, 5C7031162953C97F00150A12 /* CIFeaturePreferenceView.swift in Sources */, + 1841538E296606C74533367C /* UserPicker.swift in Sources */, + 18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */, + 1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index cbb296e829..b6b7d55f3a 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -134,6 +134,7 @@ public func chatResponse(_ s: String) -> ChatResponse { type = jResp.allKeys[0] as? String if type == "apiChats" { if let jApiChats = jResp["apiChats"] as? NSDictionary, + let user: User = try? decodeObject(jApiChats["user"] as Any), let jChats = jApiChats["chats"] as? NSArray { let chats = jChats.map { jChat in if let chatData = try? parseChatData(jChat) { @@ -141,13 +142,14 @@ public func chatResponse(_ s: String) -> ChatResponse { } return ChatData.invalidJSON(prettyJSON(jChat) ?? "") } - return .apiChats(chats: chats) + return .apiChats(user: user, chats: chats) } } else if type == "apiChat" { if let jApiChat = jResp["apiChat"] as? NSDictionary, + let user: User = try? decodeObject(jApiChat["user"] as Any), let jChat = jApiChat["chat"] as? NSDictionary, let chat = try? parseChatData(jChat) { - return .apiChat(chat: chat) + return .apiChat(user: user, chat: chat) } } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 66d3cd138b..23c2958c17 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -15,6 +15,9 @@ let jsonEncoder = getJSONEncoder() public enum ChatCommand { case showActiveUser case createActiveUser(profile: Profile) + case listUsers + case apiSetActiveUser(userId: Int64) + case apiDeleteUser(userId: Int64, delSMPQueues: Bool) case startChat(subscribe: Bool, expire: Bool) case apiStopChat case apiActivateChat @@ -25,7 +28,7 @@ public enum ChatCommand { case apiImportArchive(config: ArchiveConfig) case apiDeleteStorage case apiStorageEncryption(config: DBEncryptionConfig) - case apiGetChats + case apiGetChats(userId: Int64) case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String) case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) @@ -35,7 +38,7 @@ public enum ChatCommand { case apiVerifyToken(token: DeviceToken, nonce: String, code: String) case apiDeleteToken(token: DeviceToken) case apiGetNtfMessage(nonce: String, encNtfInfo: String) - case newGroup(groupProfile: GroupProfile) + case apiNewGroup(userId: Int64, groupProfile: GroupProfile) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) case apiMemberRole(groupId: Int64, memberId: Int64, memberRole: GroupMemberRole) @@ -46,11 +49,11 @@ public enum ChatCommand { case apiCreateGroupLink(groupId: Int64) case apiDeleteGroupLink(groupId: Int64) case apiGetGroupLink(groupId: Int64) - case getUserSMPServers - case setUserSMPServers(smpServers: [ServerCfg]) - case testSMPServer(smpServer: String) - case apiSetChatItemTTL(seconds: Int64?) - case apiGetChatItemTTL + case apiGetUserSMPServers(userId: Int64) + case apiSetUserSMPServers(userId: Int64, smpServers: [ServerCfg]) + case testSMPServer(userId: Int64, smpServer: String) + case apiSetChatItemTTL(userId: Int64, seconds: Int64?) + case apiGetChatItemTTL(userId: Int64) case apiSetNetworkConfig(networkConfig: NetCfg) case apiGetNetworkConfig case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) @@ -62,19 +65,19 @@ public enum ChatCommand { case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64) case apiVerifyContact(contactId: Int64, connectionCode: String?) case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) - case addContact - case connect(connReq: String) + case apiAddContact(userId: Int64) + case apiConnect(userId: Int64, connReq: String) case apiDeleteChat(type: ChatType, id: Int64) case apiClearChat(type: ChatType, id: Int64) - case listContacts - case apiUpdateProfile(profile: Profile) + case apiListContacts(userId: Int64) + case apiUpdateProfile(userId: Int64, profile: Profile) case apiSetContactPrefs(contactId: Int64, preferences: Preferences) case apiSetContactAlias(contactId: Int64, localAlias: String) case apiSetConnectionAlias(connId: Int64, localAlias: String) - case createMyAddress - case deleteMyAddress - case showMyAddress - case addressAutoAccept(autoAccept: AutoAccept?) + case apiCreateMyAddress(userId: Int64) + case apiDeleteMyAddress(userId: Int64) + case apiShowMyAddress(userId: Int64) + case apiAddressAutoAccept(userId: Int64, autoAccept: AutoAccept?) case apiAcceptContact(contactReqId: Int64) case apiRejectContact(contactReqId: Int64) // WebRTC calls @@ -96,7 +99,10 @@ public enum ChatCommand { get { switch self { case .showActiveUser: return "/u" - case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" + 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 .startChat(subscribe, expire): return "/_start subscribe=\(onOff(subscribe)) expire=\(onOff(expire))" case .apiStopChat: return "/_stop" case .apiActivateChat: return "/_app activate" @@ -107,7 +113,7 @@ public enum ChatCommand { case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case .apiDeleteStorage: return "/_db delete" case let .apiStorageEncryption(cfg): return "/_db encryption \(encodeJSON(cfg))" - case .apiGetChats: return "/_get chats pcc=on" + case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on" case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") case let .apiSendMessage(type, id, file, quotedItemId, mc, live): @@ -120,7 +126,7 @@ public enum ChatCommand { case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)" - case let .newGroup(groupProfile): return "/_group \(encodeJSON(groupProfile))" + case let .apiNewGroup(userId, groupProfile): return "/_group \(userId) \(encodeJSON(groupProfile))" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" case let .apiMemberRole(groupId, memberId, memberRole): return "/_member role #\(groupId) \(memberId) \(memberRole.rawValue)" @@ -131,11 +137,11 @@ public enum ChatCommand { case let .apiCreateGroupLink(groupId): return "/_create link #\(groupId)" case let .apiDeleteGroupLink(groupId): return "/_delete link #\(groupId)" case let .apiGetGroupLink(groupId): return "/_get link #\(groupId)" - case .getUserSMPServers: return "/smp" - case let .setUserSMPServers(smpServers): return "/_smp \(smpServersStr(smpServers: smpServers))" - case let .testSMPServer(smpServer): return "/smp test \(smpServer)" - case let .apiSetChatItemTTL(seconds): return "/_ttl \(chatItemTTLStr(seconds: seconds))" - case .apiGetChatItemTTL: return "/ttl" + case let .apiGetUserSMPServers(userId): return "/_smp \(userId)" + case let .apiSetUserSMPServers(userId, smpServers): return "/_smp \(userId) \(smpServersStr(smpServers: smpServers))" + case let .testSMPServer(userId, smpServer): return "/smp test \(userId) \(smpServer)" + case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))" + case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)" case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" case .apiGetNetworkConfig: return "/network" case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))" @@ -149,19 +155,19 @@ public enum ChatCommand { case let .apiVerifyContact(contactId, .none): return "/_verify code @\(contactId)" case let .apiVerifyGroupMember(groupId, groupMemberId, .some(connectionCode)): return "/_verify code #\(groupId) \(groupMemberId) \(connectionCode)" case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)" - case .addContact: return "/connect" - case let .connect(connReq): return "/connect \(connReq)" + case let .apiAddContact(userId): return "/_connect \(userId)" + case let .apiConnect(userId, connReq): return "/_connect \(userId) \(connReq)" case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" - case .listContacts: return "/contacts" - case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" + case let .apiListContacts(userId): return "/_contacts \(userId)" + case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))" case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))" case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))" - case .createMyAddress: return "/address" - case .deleteMyAddress: return "/delete_address" - case .showMyAddress: return "/show_address" - case let .addressAutoAccept(autoAccept): return "/auto_accept \(AutoAccept.cmdString(autoAccept))" + case let .apiCreateMyAddress(userId): return "/_address \(userId)" + case let .apiDeleteMyAddress(userId): return "/_delete_address \(userId)" + case let .apiShowMyAddress(userId): return "/_show_address \(userId)" + case let .apiAddressAutoAccept(userId, autoAccept): return "/_auto_accept \(userId) \(AutoAccept.cmdString(autoAccept))" case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)" case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))" @@ -186,6 +192,9 @@ public enum ChatCommand { switch self { case .showActiveUser: return "showActiveUser" case .createActiveUser: return "createActiveUser" + case .listUsers: return "listUsers" + case .apiSetActiveUser: return "apiSetActiveUser" + case .apiDeleteUser: return "apiDeleteUser" case .startChat: return "startChat" case .apiStopChat: return "apiStopChat" case .apiActivateChat: return "apiActivateChat" @@ -206,7 +215,7 @@ public enum ChatCommand { case .apiVerifyToken: return "apiVerifyToken" case .apiDeleteToken: return "apiDeleteToken" case .apiGetNtfMessage: return "apiGetNtfMessage" - case .newGroup: return "newGroup" + case .apiNewGroup: return "apiNewGroup" case .apiAddMember: return "apiAddMember" case .apiJoinGroup: return "apiJoinGroup" case .apiMemberRole: return "apiMemberRole" @@ -217,8 +226,8 @@ public enum ChatCommand { case .apiCreateGroupLink: return "apiCreateGroupLink" case .apiDeleteGroupLink: return "apiDeleteGroupLink" case .apiGetGroupLink: return "apiGetGroupLink" - case .getUserSMPServers: return "getUserSMPServers" - case .setUserSMPServers: return "setUserSMPServers" + case .apiGetUserSMPServers: return "apiGetUserSMPServers" + case .apiSetUserSMPServers: return "apiSetUserSMPServers" case .testSMPServer: return "testSMPServer" case .apiSetChatItemTTL: return "apiSetChatItemTTL" case .apiGetChatItemTTL: return "apiGetChatItemTTL" @@ -233,19 +242,19 @@ public enum ChatCommand { case .apiGetGroupMemberCode: return "apiGetGroupMemberCode" case .apiVerifyContact: return "apiVerifyContact" case .apiVerifyGroupMember: return "apiVerifyGroupMember" - case .addContact: return "addContact" - case .connect: return "connect" + case .apiAddContact: return "apiAddContact" + case .apiConnect: return "apiConnect" case .apiDeleteChat: return "apiDeleteChat" case .apiClearChat: return "apiClearChat" - case .listContacts: return "listContacts" + case .apiListContacts: return "apiListContacts" case .apiUpdateProfile: return "apiUpdateProfile" case .apiSetContactPrefs: return "apiSetContactPrefs" case .apiSetContactAlias: return "apiSetContactAlias" case .apiSetConnectionAlias: return "apiSetConnectionAlias" - case .createMyAddress: return "createMyAddress" - case .deleteMyAddress: return "deleteMyAddress" - case .showMyAddress: return "showMyAddress" - case .addressAutoAccept: return "addressAutoAccept" + case .apiCreateMyAddress: return "apiCreateMyAddress" + case .apiDeleteMyAddress: return "apiDeleteMyAddress" + case .apiShowMyAddress: return "apiShowMyAddress" + case .apiAddressAutoAccept: return "apiAddressAutoAccept" case .apiAcceptContact: return "apiAcceptContact" case .apiRejectContact: return "apiRejectContact" case .apiSendCallInvitation: return "apiSendCallInvitation" @@ -305,113 +314,115 @@ struct APIResponse: Decodable { public enum ChatResponse: Decodable, Error { case response(type: String, json: String) case activeUser(user: User) + case usersList(users: [UserInfo]) case chatStarted case chatRunning case chatStopped case chatSuspended - case apiChats(chats: [ChatData]) - case apiChat(chat: ChatData) - case userSMPServers(smpServers: [ServerCfg], presetSMPServers: [String]) - case smpTestResult(smpTestFailure: SMPTestFailure?) - case chatItemTTL(chatItemTTL: Int64?) + case apiChats(user: User, chats: [ChatData]) + case apiChat(user: User, chat: ChatData) + case userSMPServers(user: User, smpServers: [ServerCfg], presetSMPServers: [String]) + case smpTestResult(user: User, smpTestFailure: SMPTestFailure?) + case chatItemTTL(user: User, chatItemTTL: Int64?) case networkConfig(networkConfig: NetCfg) - case contactInfo(contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?) - case groupMemberInfo(groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) - case contactCode(contact: Contact, connectionCode: String) - case groupMemberCode(groupInfo: GroupInfo, member: GroupMember, connectionCode: String) - case connectionVerified(verified: Bool, expectedCode: String) - case invitation(connReqInvitation: String) - case sentConfirmation - case sentInvitation - case contactAlreadyExists(contact: Contact) - case contactDeleted(contact: Contact) - case chatCleared(chatInfo: ChatInfo) - case userProfileNoChange - case userProfileUpdated(fromProfile: Profile, toProfile: Profile) - case contactAliasUpdated(toContact: Contact) - case connectionAliasUpdated(toConnection: PendingContactConnection) - case contactPrefsUpdated(fromContact: Contact, toContact: Contact) - case userContactLink(contactLink: UserContactLink) - case userContactLinkUpdated(contactLink: UserContactLink) - case userContactLinkCreated(connReqContact: String) - case userContactLinkDeleted - case contactConnected(contact: Contact, userCustomProfile: Profile?) - case contactConnecting(contact: Contact) - case receivedContactRequest(contactRequest: UserContactRequest) - case acceptingContactRequest(contact: Contact) - case contactRequestRejected - case contactUpdated(toContact: Contact) + case contactInfo(user: User, contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?) + case groupMemberInfo(user: User, groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) + case contactCode(user: User, contact: Contact, connectionCode: String) + case groupMemberCode(user: User, groupInfo: GroupInfo, member: GroupMember, connectionCode: String) + case connectionVerified(user: User, verified: Bool, expectedCode: String) + case invitation(user: User, connReqInvitation: String) + case sentConfirmation(user: User) + case sentInvitation(user: User) + case contactAlreadyExists(user: User, contact: Contact) + case contactDeleted(user: User, contact: Contact) + case chatCleared(user: User, chatInfo: ChatInfo) + case userProfileNoChange(user: User) + case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile) + case contactAliasUpdated(user: User, toContact: Contact) + case connectionAliasUpdated(user: User, toConnection: PendingContactConnection) + case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact) + case userContactLink(user: User, contactLink: UserContactLink) + case userContactLinkUpdated(user: User, contactLink: UserContactLink) + case userContactLinkCreated(user: User, connReqContact: String) + case userContactLinkDeleted(user: User) + case contactConnected(user: User, contact: Contact, userCustomProfile: Profile?) + case contactConnecting(user: User, contact: Contact) + case receivedContactRequest(user: User, contactRequest: UserContactRequest) + case acceptingContactRequest(user: User, contact: Contact) + case contactRequestRejected(user: User) + case contactUpdated(user: User, toContact: Contact) case contactsSubscribed(server: String, contactRefs: [ContactRef]) case contactsDisconnected(server: String, contactRefs: [ContactRef]) - case contactSubError(contact: Contact, chatError: ChatError) - case contactSubSummary(contactSubscriptions: [ContactSubStatus]) - case groupSubscribed(groupInfo: GroupInfo) - case memberSubErrors(memberSubErrors: [MemberSubError]) - case groupEmpty(groupInfo: GroupInfo) + case contactSubError(user: User, contact: Contact, chatError: ChatError) + case contactSubSummary(user: User, contactSubscriptions: [ContactSubStatus]) + case groupSubscribed(user: User, groupInfo: GroupInfo) + case memberSubErrors(user: User, memberSubErrors: [MemberSubError]) + case groupEmpty(user: User, groupInfo: GroupInfo) case userContactLinkSubscribed - case newChatItem(chatItem: AChatItem) - case chatItemStatusUpdated(chatItem: AChatItem) - case chatItemUpdated(chatItem: AChatItem) - case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem?, byUser: Bool) - case contactsList(contacts: [Contact]) + case newChatItem(user: User, chatItem: AChatItem) + case chatItemStatusUpdated(user: User, chatItem: AChatItem) + case chatItemUpdated(user: User, chatItem: AChatItem) + case chatItemDeleted(user: User, deletedChatItem: AChatItem, toChatItem: AChatItem?, byUser: Bool) + case contactsList(user: User, contacts: [Contact]) // group events - case groupCreated(groupInfo: GroupInfo) - case sentGroupInvitation(groupInfo: GroupInfo, contact: Contact, member: GroupMember) - case userAcceptedGroupSent(groupInfo: GroupInfo, hostContact: Contact?) - case userDeletedMember(groupInfo: GroupInfo, member: GroupMember) - case leftMemberUser(groupInfo: GroupInfo) - case groupMembers(group: Group) - case receivedGroupInvitation(groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) - case groupDeletedUser(groupInfo: GroupInfo) - case joinedGroupMemberConnecting(groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember) - case memberRole(groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) - case memberRoleUser(groupInfo: GroupInfo, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) - case deletedMemberUser(groupInfo: GroupInfo, member: GroupMember) - case deletedMember(groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember) - case leftMember(groupInfo: GroupInfo, member: GroupMember) - case groupDeleted(groupInfo: GroupInfo, member: GroupMember) - case contactsMerged(intoContact: Contact, mergedContact: Contact) - case groupInvitation(groupInfo: GroupInfo) // unused - case userJoinedGroup(groupInfo: GroupInfo) - case joinedGroupMember(groupInfo: GroupInfo, member: GroupMember) - case connectedToGroupMember(groupInfo: GroupInfo, member: GroupMember) - case groupRemoved(groupInfo: GroupInfo) // unused - case groupUpdated(toGroup: GroupInfo) - case groupLinkCreated(groupInfo: GroupInfo, connReqContact: String) - case groupLink(groupInfo: GroupInfo, connReqContact: String) - case groupLinkDeleted(groupInfo: GroupInfo) + case groupCreated(user: User, groupInfo: GroupInfo) + case sentGroupInvitation(user: User, groupInfo: GroupInfo, contact: Contact, member: GroupMember) + case userAcceptedGroupSent(user: User, groupInfo: GroupInfo, hostContact: Contact?) + case userDeletedMember(user: User, groupInfo: GroupInfo, member: GroupMember) + case leftMemberUser(user: User, groupInfo: GroupInfo) + case groupMembers(user: User, group: Group) + case receivedGroupInvitation(user: User, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) + case groupDeletedUser(user: User, groupInfo: GroupInfo) + case joinedGroupMemberConnecting(user: User, groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember) + case memberRole(user: User, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) + case memberRoleUser(user: User, groupInfo: GroupInfo, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) + case deletedMemberUser(user: User, groupInfo: GroupInfo, member: GroupMember) + case deletedMember(user: User, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember) + case leftMember(user: User, groupInfo: GroupInfo, member: GroupMember) + case groupDeleted(user: User, groupInfo: GroupInfo, member: GroupMember) + case contactsMerged(user: User, intoContact: Contact, mergedContact: Contact) + case groupInvitation(user: User, groupInfo: GroupInfo) // unused + case userJoinedGroup(user: User, groupInfo: GroupInfo) + case joinedGroupMember(user: User, groupInfo: GroupInfo, member: GroupMember) + case connectedToGroupMember(user: User, groupInfo: GroupInfo, member: GroupMember) + case groupRemoved(user: User, groupInfo: GroupInfo) // unused + case groupUpdated(user: User, toGroup: GroupInfo) + case groupLinkCreated(user: User, groupInfo: GroupInfo, connReqContact: String) + case groupLink(user: User, groupInfo: GroupInfo, connReqContact: String) + case groupLinkDeleted(user: User, groupInfo: GroupInfo) // receiving file events - case rcvFileAccepted(chatItem: AChatItem) - case rcvFileAcceptedSndCancelled(rcvFileTransfer: RcvFileTransfer) - case rcvFileStart(chatItem: AChatItem) - case rcvFileComplete(chatItem: AChatItem) + case rcvFileAccepted(user: User, chatItem: AChatItem) + case rcvFileAcceptedSndCancelled(user: User, rcvFileTransfer: RcvFileTransfer) + case rcvFileStart(user: User, chatItem: AChatItem) + case rcvFileComplete(user: User, chatItem: AChatItem) // sending file events - case sndFileStart(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileComplete(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileStart(user: User, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileComplete(user: User, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileRcvCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndGroupFileCancelled(chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) + case sndFileRcvCancelled(user: User, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndGroupFileCancelled(user: User, chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) case callInvitation(callInvitation: RcvCallInvitation) - case callOffer(contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) - case callAnswer(contact: Contact, answer: WebRTCSession) - case callExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) - case callEnded(contact: Contact) + case callOffer(user: User, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) + case callAnswer(user: User, contact: Contact, answer: WebRTCSession) + case callExtraInfo(user: User, contact: Contact, extraInfo: WebRTCExtraInfo) + case callEnded(user: User, contact: Contact) case callInvitations(callInvitations: [RcvCallInvitation]) case ntfTokenStatus(status: NtfTknStatus) case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode) - case ntfMessages(connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) - case newContactConnection(connection: PendingContactConnection) - case contactConnectionDeleted(connection: PendingContactConnection) + case ntfMessages(user_: User?, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) + case newContactConnection(user: User, connection: PendingContactConnection) + case contactConnectionDeleted(user: User, connection: PendingContactConnection) case versionInfo(versionInfo: CoreVersionInfo) - case cmdOk - case chatCmdError(chatError: ChatError) - case chatError(chatError: ChatError) + case cmdOk(user: User?) + case chatCmdError(user: User?, chatError: ChatError) + case chatError(user: User?, chatError: ChatError) public var responseType: String { get { switch self { case let .response(type, _): return "* \(type)" case .activeUser: return "activeUser" + case .usersList: return "usersList" case .chatStarted: return "chatStarted" case .chatRunning: return "chatRunning" case .chatStopped: return "chatStopped" @@ -519,109 +530,117 @@ public enum ChatResponse: Decodable, Error { switch self { case let .response(_, json): return json case let .activeUser(user): return String(describing: user) + case let .usersList(users): return String(describing: users) case .chatStarted: return noDetails case .chatRunning: return noDetails case .chatStopped: return noDetails case .chatSuspended: return noDetails - case let .apiChats(chats): return String(describing: chats) - case let .apiChat(chat): return String(describing: chat) - case let .userSMPServers(smpServers, presetServers): return "smpServers: \(String(describing: smpServers))\npresetServers: \(String(describing: presetServers))" - case let .smpTestResult(smpTestFailure): return String(describing: smpTestFailure) - case let .chatItemTTL(chatItemTTL): return String(describing: chatItemTTL) + case let .apiChats(u, chats): return withUser(u, String(describing: chats)) + case let .apiChat(u, chat): return withUser(u, String(describing: chat)) + case let .userSMPServers(u, smpServers, presetServers): return withUser(u, "smpServers: \(String(describing: smpServers))\npresetServers: \(String(describing: presetServers))") + case let .smpTestResult(u, smpTestFailure): return withUser(u, String(describing: smpTestFailure)) + case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) case let .networkConfig(networkConfig): return String(describing: networkConfig) - case let .contactInfo(contact, connectionStats, customUserProfile): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))" - case let .groupMemberInfo(groupInfo, member, connectionStats_): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_)))" - case let .contactCode(contact, connectionCode): return "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)" - case let .groupMemberCode(groupInfo, member, connectionCode): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)" - case let .connectionVerified(verified, expectedCode): return "verified: \(verified)\nconnectionCode: \(expectedCode)" - case let .invitation(connReqInvitation): return connReqInvitation + case let .contactInfo(u, contact, connectionStats, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))") + case let .groupMemberInfo(u, groupInfo, member, connectionStats_): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_)))") + case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") + case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") + case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") + case let .invitation(u, connReqInvitation): return withUser(u, connReqInvitation) case .sentConfirmation: return noDetails case .sentInvitation: return noDetails - case let .contactAlreadyExists(contact): return String(describing: contact) - case let .contactDeleted(contact): return String(describing: contact) - case let .chatCleared(chatInfo): return String(describing: chatInfo) + case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) + case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) + case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo)) case .userProfileNoChange: return noDetails - case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) - case let .contactAliasUpdated(toContact): return String(describing: toContact) - case let .connectionAliasUpdated(toConnection): return String(describing: toConnection) - case let .contactPrefsUpdated(fromContact, toContact): return "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))" - case let .userContactLink(contactLink): return contactLink.responseDetails - case let .userContactLinkUpdated(contactLink): return contactLink.responseDetails - case let .userContactLinkCreated(connReq): return connReq + case let .userProfileUpdated(u, _, toProfile): return withUser(u, String(describing: toProfile)) + 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))") + case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails) + case let .userContactLinkUpdated(u, contactLink): return withUser(u, contactLink.responseDetails) + case let .userContactLinkCreated(u, connReq): return withUser(u, connReq) case .userContactLinkDeleted: return noDetails - case let .contactConnected(contact, _): return String(describing: contact) - case let .contactConnecting(contact): return String(describing: contact) - case let .receivedContactRequest(contactRequest): return String(describing: contactRequest) - case let .acceptingContactRequest(contact): return String(describing: contact) + case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact)) + case let .contactConnecting(u, contact): return withUser(u, String(describing: contact)) + case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest)) + case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) case .contactRequestRejected: return noDetails - case let .contactUpdated(toContact): return String(describing: toContact) + case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" - case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))" - case let .contactSubSummary(contactSubscriptions): return String(describing: contactSubscriptions) - case let .groupSubscribed(groupInfo): return String(describing: groupInfo) - case let .memberSubErrors(memberSubErrors): return String(describing: memberSubErrors) - case let .groupEmpty(groupInfo): return String(describing: groupInfo) + case let .contactSubError(u, contact, chatError): return withUser(u, "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))") + case let .contactSubSummary(u, contactSubscriptions): return withUser(u, String(describing: contactSubscriptions)) + case let .groupSubscribed(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors)) + case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo)) case .userContactLinkSubscribed: return noDetails - case let .newChatItem(chatItem): return String(describing: chatItem) - case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem) - case let .chatItemUpdated(chatItem): return String(describing: chatItem) - case let .chatItemDeleted(deletedChatItem, toChatItem, byUser): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))\nbyUser: \(byUser)" - case let .contactsList(contacts): return String(describing: contacts) - case let .groupCreated(groupInfo): return String(describing: groupInfo) - case let .sentGroupInvitation(groupInfo, contact, member): return "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)" - case let .userAcceptedGroupSent(groupInfo, hostContact): return "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))" - case let .userDeletedMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .leftMemberUser(groupInfo): return String(describing: groupInfo) - case let .groupMembers(group): return String(describing: group) - case let .receivedGroupInvitation(groupInfo, contact, memberRole): return "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)" - case let .groupDeletedUser(groupInfo): return String(describing: groupInfo) - case let .joinedGroupMemberConnecting(groupInfo, hostMember, member): return "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\nmember: \(member)" - case let .memberRole(groupInfo, byMember, member, fromRole, toRole): return "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)" - case let .memberRoleUser(groupInfo, member, fromRole, toRole): return "groupInfo: \(groupInfo)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)" - case let .deletedMemberUser(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .deletedMember(groupInfo, byMember, deletedMember): return "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)" - case let .leftMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .groupDeleted(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .contactsMerged(intoContact, mergedContact): return "intoContact: \(intoContact)\nmergedContact: \(mergedContact)" - case let .groupInvitation(groupInfo): return String(describing: groupInfo) - case let .userJoinedGroup(groupInfo): return String(describing: groupInfo) - case let .joinedGroupMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .connectedToGroupMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .groupRemoved(groupInfo): return String(describing: groupInfo) - case let .groupUpdated(toGroup): return String(describing: toGroup) - case let .groupLinkCreated(groupInfo, connReqContact): return "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)" - case let .groupLink(groupInfo, connReqContact): return "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)" - case let .groupLinkDeleted(groupInfo): return String(describing: groupInfo) - case let .rcvFileAccepted(chatItem): return String(describing: chatItem) + case let .newChatItem(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemStatusUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemDeleted(u, deletedChatItem, toChatItem, byUser): return withUser(u, "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))\nbyUser: \(byUser)") + case let .contactsList(u, contacts): return withUser(u, String(describing: contacts)) + case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") + case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") + case let .userDeletedMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .groupMembers(u, group): return withUser(u, String(describing: group)) + case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)") + case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .joinedGroupMemberConnecting(u, groupInfo, hostMember, member): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\nmember: \(member)") + case let .memberRole(u, groupInfo, byMember, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)") + case let .memberRoleUser(u, groupInfo, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)") + case let .deletedMemberUser(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .deletedMember(u, groupInfo, byMember, deletedMember): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)") + case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .contactsMerged(u, intoContact, mergedContact): return withUser(u, "intoContact: \(intoContact)\nmergedContact: \(mergedContact)") + case let .groupInvitation(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .connectedToGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .groupRemoved(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) + case let .groupLinkCreated(u, groupInfo, connReqContact): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)") + case let .groupLink(u, groupInfo, connReqContact): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)") + case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) case .rcvFileAcceptedSndCancelled: return noDetails - case let .rcvFileStart(chatItem): return String(describing: chatItem) - case let .rcvFileComplete(chatItem): return String(describing: chatItem) - case let .sndFileStart(chatItem, _): return String(describing: chatItem) - case let .sndFileComplete(chatItem, _): return String(describing: chatItem) + case let .rcvFileStart(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .sndFileStart(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileCancelled(chatItem, _): return String(describing: chatItem) - case let .sndFileRcvCancelled(chatItem, _): return String(describing: chatItem) - case let .sndGroupFileCancelled(chatItem, _, _): return String(describing: chatItem) + case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndGroupFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) case let .callInvitation(inv): return String(describing: inv) - case let .callOffer(contact, callType, offer, sharedKey, askConfirmation): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))" - case let .callAnswer(contact, answer): return "contact: \(contact.id)\nanswer: \(String(describing: answer))" - case let .callExtraInfo(contact, extraInfo): return "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))" - case let .callEnded(contact): return "contact: \(contact.id)" + case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))") + case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))") + case let .callExtraInfo(u, contact, extraInfo): return withUser(u, "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))") + case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)") case let .callInvitations(invs): return String(describing: invs) case let .ntfTokenStatus(status): return String(describing: status) case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)" - case let .ntfMessages(connEntity, msgTs, ntfMessages): return "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))" - case let .newContactConnection(connection): return String(describing: connection) - case let .contactConnectionDeleted(connection): return String(describing: connection) + case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))") + case let .newContactConnection(u, connection): return withUser(u, String(describing: connection)) + case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case let .versionInfo(versionInfo): return String(describing: versionInfo) case .cmdOk: return noDetails - case let .chatCmdError(chatError): return String(describing: chatError) - case let .chatError(chatError): return String(describing: chatError) + case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) + case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) } } } private var noDetails: String { get { "\(responseType): no details" } } + + private func withUser(_ u: User?, _ s: String) -> String { + if let id = u?.userId { + return "userId: \(id)\n\(s)" + } + return s + } } public enum ChatPagination { @@ -811,6 +830,7 @@ public struct NetCfg: Codable, Equatable { public var socksProxy: String? = nil public var hostMode: HostMode = .publicHost public var requiredHostMode = true + public var sessionMode: TransportSessionMode public var tcpConnectTimeout: Int // microseconds public var tcpTimeout: Int // microseconds public var tcpKeepAlive: KeepAliveOpts? @@ -820,6 +840,7 @@ public struct NetCfg: Codable, Equatable { public static let defaults: NetCfg = NetCfg( socksProxy: nil, + sessionMode: TransportSessionMode.user, tcpConnectTimeout: 10_000_000, tcpTimeout: 7_000_000, tcpKeepAlive: KeepAliveOpts.defaults, @@ -830,6 +851,7 @@ public struct NetCfg: Codable, Equatable { public static let proxyDefaults: NetCfg = NetCfg( socksProxy: nil, + sessionMode: TransportSessionMode.user, tcpConnectTimeout: 20_000_000, tcpTimeout: 15_000_000, tcpKeepAlive: KeepAliveOpts.defaults, @@ -881,6 +903,22 @@ public enum OnionHosts: String, Identifiable { public static let values: [OnionHosts] = [.no, .prefer, .require] } +public enum TransportSessionMode: String, Codable, Identifiable { + case user + case entity + + public var text: LocalizedStringKey { + switch self { + case .user: return "User profile" + case .entity: return "Connection" + } + } + + public var id: TransportSessionMode { self } + + public static let values: [TransportSessionMode] = [.user, .entity] +} + public struct KeepAliveOpts: Codable, Equatable { public var keepIdle: Int // seconds public var keepIntvl: Int // seconds @@ -1040,6 +1078,7 @@ public enum ChatError: Decodable { public enum ChatErrorType: Decodable { case noActiveUser case activeUserExists + case differentActiveUser case chatNotStarted case invalidConnReq case invalidChatMessage(message: String) diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 0dd43a2fca..d9135a3128 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -17,6 +17,7 @@ let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" public let GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE = "privacyTransferImagesInline" let GROUP_DEFAULT_NTF_BADGE_COUNT = "ntgBadgeCount" let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts" +let GROUP_DEFAULT_NETWORK_SESSION_MODE = "networkSessionMode" let GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT = "networkTCPConnectTimeout" let GROUP_DEFAULT_NETWORK_TCP_TIMEOUT = "networkTCPTimeout" let GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL = "networkSMPPingInterval" @@ -36,6 +37,7 @@ public let groupDefaults = UserDefaults(suiteName: APP_GROUP_NAME)! public func registerGroupDefaults() { groupDefaults.register(defaults: [ GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS: OnionHosts.no.rawValue, + GROUP_DEFAULT_NETWORK_SESSION_MODE: TransportSessionMode.user.rawValue, GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT: NetCfg.defaults.tcpConnectTimeout, GROUP_DEFAULT_NETWORK_TCP_TIMEOUT: NetCfg.defaults.tcpTimeout, GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL: NetCfg.defaults.smpPingInterval, @@ -107,6 +109,12 @@ public let networkUseOnionHostsGroupDefault = EnumDefault( withDefault: .no ) +public let networkSessionModeGroupDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_NETWORK_SESSION_MODE, + withDefault: .user +) + public let storeDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_STORE_DB_PASSPHRASE) public let initialRandomDBPassphraseGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE) @@ -186,6 +194,7 @@ public class Default { public func getNetCfg() -> NetCfg { let onionHosts = networkUseOnionHostsGroupDefault.get() let (hostMode, requiredHostMode) = onionHosts.hostMode + let sessionMode = networkSessionModeGroupDefault.get() let tcpConnectTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) let tcpTimeout = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) let smpPingInterval = groupDefaults.integer(forKey: GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL) @@ -203,6 +212,7 @@ public func getNetCfg() -> NetCfg { return NetCfg( hostMode: hostMode, requiredHostMode: requiredHostMode, + sessionMode: sessionMode, tcpConnectTimeout: tcpConnectTimeout, tcpTimeout: tcpTimeout, tcpKeepAlive: tcpKeepAlive, @@ -214,6 +224,7 @@ public func getNetCfg() -> NetCfg { public func setNetCfg(_ cfg: NetCfg) { networkUseOnionHostsGroupDefault.set(OnionHosts(netCfg: cfg)) + networkSessionModeGroupDefault.set(cfg.sessionMode) groupDefaults.set(cfg.tcpConnectTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT) groupDefaults.set(cfg.tcpTimeout, forKey: GROUP_DEFAULT_NETWORK_TCP_TIMEOUT) groupDefaults.set(cfg.smpPingInterval, forKey: GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL) diff --git a/apps/ios/SimpleXChat/CallTypes.swift b/apps/ios/SimpleXChat/CallTypes.swift index 1d5fa360ca..4a041f784e 100644 --- a/apps/ios/SimpleXChat/CallTypes.swift +++ b/apps/ios/SimpleXChat/CallTypes.swift @@ -38,6 +38,7 @@ public struct WebRTCExtraInfo: Codable { } public struct RcvCallInvitation: Decodable { + public var user: User public var contact: Contact public var callkitUUID: UUID? = UUID() public var callType: CallType @@ -53,6 +54,7 @@ public struct RcvCallInvitation: Decodable { } public static let sampleData = RcvCallInvitation( + user: User.sampleData, contact: Contact.sampleData, callType: CallType(media: .audio, capabilities: CallCapabilities(encryption: false)), callTs: .now diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index a007694e65..8ae256cdda 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -9,19 +9,21 @@ import Foundation import SwiftUI -public struct User: Decodable, NamedChat { - var userId: Int64 +public struct User: Decodable, NamedChat, Identifiable { + public var userId: Int64 var userContactId: Int64 var localDisplayName: ContactName public var profile: LocalProfile public var fullPreferences: FullPreferences - var activeUser: Bool + public var activeUser: Bool public var displayName: String { get { profile.displayName } } public var fullName: String { get { profile.fullName } } public var image: String? { get { profile.image } } public var localAlias: String { get { "" } } + public var id: Int64 { userId } + public static let sampleData = User( userId: 1, userContactId: 1, @@ -32,6 +34,23 @@ public struct User: Decodable, NamedChat { ) } +public struct UserInfo: Decodable, Identifiable { + public var user: User + public var unreadCount: Int + + public init(user: User, unreadCount: Int) { + self.user = user + self.unreadCount = unreadCount + } + + public var id: Int64 { user.userId } + + public static let sampleData = UserInfo( + user: User.sampleData, + unreadCount: 1 + ) +} + public typealias ContactName = String public typealias GroupName = String @@ -1137,6 +1156,8 @@ public struct Contact: Identifiable, Decodable, NamedChat { public struct ContactRef: Decodable, Equatable { var contactId: Int64 + public var agentConnId: String + var connId: Int64 var localDisplayName: ContactName public var id: ChatId { get { "@\(contactId)" } } @@ -1148,7 +1169,8 @@ public struct ContactSubStatus: Decodable { } public struct Connection: Decodable { - var connId: Int64 + public var connId: Int64 + public var agentConnId: String var connStatus: ConnStatus public var connLevel: Int public var viaGroupLink: Bool @@ -1159,6 +1181,7 @@ public struct Connection: Decodable { static let sampleData = Connection( connId: 1, + agentConnId: "abc", connStatus: .ready, connLevel: 0, viaGroupLink: false diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index e859d880a8..035e849ff5 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -21,7 +21,7 @@ public let appNotificationId = "chat.simplex.app.notification" let contactHidden = NSLocalizedString("Contact hidden:", comment: "notification") -public func createContactRequestNtf(_ contactRequest: UserContactRequest) -> UNMutableNotificationContent { +public func createContactRequestNtf(_ user: User, _ contactRequest: UserContactRequest) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden return createNotification( categoryIdentifier: ntfCategoryContactRequest, @@ -34,11 +34,11 @@ public func createContactRequestNtf(_ contactRequest: UserContactRequest) -> UNM hideContent ? NSLocalizedString("this contact", comment: "notification title") : contactRequest.chatViewName ), targetContentIdentifier: nil, - userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId] + userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId, "userId": user.userId] ) } -public func createContactConnectedNtf(_ contact: Contact) -> UNMutableNotificationContent { +public func createContactConnectedNtf(_ user: User, _ contact: Contact) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden return createNotification( categoryIdentifier: ntfCategoryContactConnected, @@ -50,12 +50,13 @@ public func createContactConnectedNtf(_ contact: Contact) -> UNMutableNotificati NSLocalizedString("You can now send messages to %@", comment: "notification body"), hideContent ? NSLocalizedString("this contact", comment: "notification title") : contact.chatViewName ), - targetContentIdentifier: contact.id + targetContentIdentifier: contact.id, + userInfo: ["userId": user.userId] // userInfo: ["chatId": contact.id, "contactId": contact.apiId] ) } -public func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent { +public func createMessageReceivedNtf(_ user: User, _ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent { let previewMode = ntfPreviewModeGroupDefault.get() var title: String if case let .group(groupInfo) = cInfo, case let .groupRcv(groupMember) = cItem.chatDir { @@ -67,7 +68,8 @@ public func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UN categoryIdentifier: ntfCategoryMessageReceived, title: title, body: previewMode == .message ? hideSecrets(cItem) : NSLocalizedString("new message", comment: "notification"), - targetContentIdentifier: cInfo.id + targetContentIdentifier: cInfo.id, + userInfo: ["userId": user.userId] // userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] ) } @@ -82,11 +84,11 @@ public func createCallInvitationNtf(_ invitation: RcvCallInvitation) -> UNMutabl title: hideContent ? contactHidden : "\(invitation.contact.chatViewName):", body: text, targetContentIdentifier: nil, - userInfo: ["chatId": invitation.contact.id] + userInfo: ["chatId": invitation.contact.id, "userId": invitation.user.userId] ) } -public func createConnectionEventNtf(_ connEntity: ConnectionEntity) -> UNMutableNotificationContent { +public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntity) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden var title: String var body: String? = nil @@ -115,7 +117,8 @@ public func createConnectionEventNtf(_ connEntity: ConnectionEntity) -> UNMutabl categoryIdentifier: ntfCategoryConnectionEvent, title: title, body: body, - targetContentIdentifier: targetContentIdentifier + targetContentIdentifier: targetContentIdentifier, + userInfo: ["userId": user.userId] ) } diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index f5acebe96a..db370cdfa0 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -39,10 +39,10 @@ mySquaringBot _user cc = do race_ (forever $ void getLine) . forever $ do (_, resp) <- atomically . readTBQueue $ outputQ cc case resp of - CRContactConnected contact _ -> do + CRContactConnected _ contact _ -> do contactConnected contact void . sendMsg contact $ "Hello! I am a simple squaring bot - if you send me a number, I will calculate its square" - CRNewChatItem (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do + CRNewChatItem _ (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do let msg = T.unpack $ ciContentToText content number_ = readMaybe msg :: Maybe Integer void . sendMsg contact $ case number_ of diff --git a/apps/simplex-chat/Server.hs b/apps/simplex-chat/Server.hs index 481e03b01e..d59adc04e7 100644 --- a/apps/simplex-chat/Server.hs +++ b/apps/simplex-chat/Server.hs @@ -95,11 +95,11 @@ runChatServer ChatServerConfig {chatPort, clientQSize} cc = do Left e -> sendError (Just corrId) e Nothing -> sendError Nothing "invalid request" where - sendError corrId e = atomically $ writeTBQueue sndQ ChatSrvResponse {corrId, resp = chatCmdError e} + sendError corrId e = atomically $ writeTBQueue sndQ ChatSrvResponse {corrId, resp = chatCmdError Nothing e} processCommand (corrId, cmd) = runReaderT (runExceptT $ processChatCommand cmd) cc >>= \case Right resp -> response resp - Left e -> response $ CRChatCmdError e + Left e -> response $ CRChatCmdError Nothing e where response resp = pure ChatSrvResponse {corrId = Just corrId, resp} clientDisconnected _ = pure () diff --git a/cabal.project b/cabal.project index e5fce6aaff..bef9e0a9a8 100644 --- a/cabal.project +++ b/cabal.project @@ -7,7 +7,12 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 56eea29ec36ba723fda6ae27a18e03a4e5aad6e8 + tag: b59669a65eafeb92d4a986a03e481ecb343ee307 + +source-repository-package + type: git + location: https://github.com/simplex-chat/hs-socks.git + tag: a30cc7a79a08d8108316094f8f2f82a0c5e1ac51 source-repository-package type: git diff --git a/packages/simplex-chat-client/typescript/src/command.ts b/packages/simplex-chat-client/typescript/src/command.ts index e3b017284f..2694e4d25d 100644 --- a/packages/simplex-chat-client/typescript/src/command.ts +++ b/packages/simplex-chat-client/typescript/src/command.ts @@ -450,7 +450,7 @@ export function cmdString(cmd: ChatCommand): string { case "showActiveUser": return "/u" case "createActiveUser": - return `/u ${JSON.stringify(cmd.profile)}` + return `/create user ${JSON.stringify(cmd.profile)}` case "startChat": return `/_start subscribe=${cmd.subscribeConnections ? "on" : "off"} expire=${cmd.expireChatItems ? "on" : "off"}` case "apiStopChat": diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 232bdb580a..958d856221 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,6 @@ { - "https://github.com/simplex-chat/simplexmq.git"."56eea29ec36ba723fda6ae27a18e03a4e5aad6e8" = "107vy6147vf282w41kmm6jhhiykcg4jz9napav5m0kcfinzbkqpg"; + "https://github.com/simplex-chat/simplexmq.git"."b59669a65eafeb92d4a986a03e481ecb343ee307" = "0h4kgnlg0hy5dy5svshj6fhz277rqls5w1h95b1vg719xxcc2n8m"; + "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; "https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0"; "https://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 420e8a3af0..f3adeeac61 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -79,6 +79,9 @@ library Simplex.Chat.Migrations.M20221223_idx_chat_items_item_status Simplex.Chat.Migrations.M20221230_idxs Simplex.Chat.Migrations.M20230107_connections_auth_err_counter + Simplex.Chat.Migrations.M20230111_users_agent_user_id + Simplex.Chat.Migrations.M20230117_fkey_indexes + Simplex.Chat.Migrations.M20230118_recreate_smp_servers Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.ProfileGenerator diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index fc948314a5..605306663a 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -29,16 +29,16 @@ import qualified Data.ByteString.Base64 as B64 import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (isSpace) -import Data.Either (fromRight) +import Data.Either (fromRight, rights) import Data.Fixed (div') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, isSuffixOf, sortOn) +import Data.List (find, isSuffixOf, partition, sortOn) import Data.List.NonEmpty (NonEmpty, nonEmpty) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, mapMaybe) +import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe, maybeToList) import Data.Text (Text) import qualified Data.Text as T import Data.Time (NominalDiffTime, addUTCTime) @@ -96,7 +96,7 @@ defaultChatConfig = }, yesToMigrations = False, defaultServers = - InitialAgentServers + DefaultAgentServers { smp = _defaultSMPServers, ntf = _defaultNtfServers, netCfg = defaultNetworkConfig @@ -108,7 +108,8 @@ defaultChatConfig = subscriptionConcurrency = 16, subscriptionEvents = False, hostEvents = False, - testView = False + testView = False, + ciExpirationInterval = 1800 * 1000000 -- 30 minutes } _defaultSMPServers :: NonEmpty SMPServerWithAuth @@ -158,86 +159,94 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen filesFolder <- newTVarIO optFilesFolder incognitoMode <- newTVarIO False chatStoreChanged <- newTVarIO False - expireCIsAsync <- newTVarIO Nothing - expireCIs <- newTVarIO False + expireCIThreads <- newTVarIO M.empty + expireCIFlags <- newTVarIO M.empty cleanupManagerAsync <- newTVarIO Nothing timedItemThreads <- atomically TM.empty showLiveItems <- newTVarIO False - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder, expireCIsAsync, expireCIs, cleanupManagerAsync, timedItemThreads, showLiveItems} + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems} where - configServers :: InitialAgentServers + configServers :: DefaultAgentServers configServers = - let smp' = fromMaybe (smp defaultServers) (nonEmpty smpServers) + let smp' = fromMaybe (smp (defaultServers :: DefaultAgentServers)) (nonEmpty smpServers) in defaultServers {smp = smp', netCfg = networkConfig} agentServers :: ChatConfig -> IO InitialAgentServers - agentServers config@ChatConfig {defaultServers = ss@InitialAgentServers {smp}} = do - smp' <- maybe (pure smp) userServers user - pure ss {smp = smp'} + agentServers config@ChatConfig {defaultServers = DefaultAgentServers {smp, ntf, netCfg}} = do + users <- withTransaction chatStore getUsers + smp' <- case users of + [] -> pure $ M.fromList [(1, smp)] + _ -> M.fromList <$> initialServers users + pure InitialAgentServers {smp = smp', ntf, netCfg} where + initialServers :: [User] -> IO [(UserId, NonEmpty SMPServerWithAuth)] + initialServers = mapM $ \u -> (aUserId u,) <$> userServers u + userServers :: User -> IO (NonEmpty SMPServerWithAuth) userServers user' = activeAgentServers config <$> withTransaction chatStore (`getSMPServers` user') activeAgentServers :: ChatConfig -> [ServerCfg] -> NonEmpty SMPServerWithAuth -activeAgentServers ChatConfig {defaultServers = InitialAgentServers {smp}} = +activeAgentServers ChatConfig {defaultServers = DefaultAgentServers {smp}} = fromMaybe smp . nonEmpty . map (\ServerCfg {server} -> server) . filter (\ServerCfg {enabled} -> enabled) -startChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> Bool -> Bool -> m (Async ()) -startChatController user subConns enableExpireCIs = do +startChatController :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => Bool -> Bool -> m (Async ()) +startChatController subConns enableExpireCIs = do asks smpAgent >>= resumeAgentClient - restoreCalls user + users <- fromRight [] <$> runExceptT (withStore' getUsers) + restoreCalls s <- asks agentAsync - readTVarIO s >>= maybe (start s) (pure . fst) + readTVarIO s >>= maybe (start s users) (pure . fst) where - start s = do + start s users = do a1 <- async $ race_ notificationSubscriber agentSubscriber a2 <- if subConns - then Just <$> async (void . runExceptT $ subscribeUserConnections Agent.subscribeConnections user) + then Just <$> async (subscribeUsers users) else pure Nothing atomically . writeTVar s $ Just (a1, a2) startCleanupManager - when enableExpireCIs startExpireCIs + when enableExpireCIs $ startExpireCIs users pure a1 startCleanupManager = do cleanupAsync <- asks cleanupManagerAsync readTVarIO cleanupAsync >>= \case Nothing -> do - a <- Just <$> async (void . runExceptT $ cleanupManager user) + a <- Just <$> async (void $ runExceptT cleanupManager) atomically $ writeTVar cleanupAsync a _ -> pure () - startExpireCIs = do - expireAsync <- asks expireCIsAsync - readTVarIO expireAsync >>= \case - Nothing -> do - a <- Just <$> async (void $ runExceptT runExpireCIs) - atomically $ writeTVar expireAsync a - setExpireCIs True - _ -> setExpireCIs True - runExpireCIs = forever $ do - flip catchError (toView . CRChatError) $ do - expire <- asks expireCIs - atomically $ readTVar expire >>= \b -> unless b retry - ttl <- withStore' (`getChatItemTTL` user) - forM_ ttl $ \t -> expireChatItems user t False - threadDelay $ 1800 * 1000000 -- 30 minutes + startExpireCIs users = + forM_ users $ \user -> do + ttl <- fromRight Nothing <$> runExceptT (withStore' (`getChatItemTTL` user)) + forM_ ttl $ \_ -> do + startExpireCIThread user + setExpireCIFlag user True -restoreCalls :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m () -restoreCalls user = do - savedCalls <- fromRight [] <$> runExceptT (withStore' $ \db -> getCalls db user) +subscribeUsers :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => [User] -> m () +subscribeUsers users = do + let (us, us') = partition activeUser users + subscribe us + subscribe us' + where + subscribe :: [User] -> m () + subscribe = mapM_ $ runExceptT . subscribeUserConnections Agent.subscribeConnections + +restoreCalls :: (MonadUnliftIO m, MonadReader ChatController m) => m () +restoreCalls = do + savedCalls <- fromRight [] <$> runExceptT (withStore' $ \db -> getCalls db) let callsMap = M.fromList $ map (\call@Call {contactId} -> (contactId, call)) savedCalls calls <- asks currentCalls atomically $ writeTVar calls callsMap stopChatController :: forall m. MonadUnliftIO m => ChatController -> m () -stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIs} = do +stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags} = do disconnectAgentClient smpAgent readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2) closeFiles sndFiles closeFiles rcvFiles atomically $ do - writeTVar expireCIs False + keys <- M.keys <$> readTVar expireCIFlags + forM_ keys $ \k -> TM.insert k False expireCIFlags writeTVar s Nothing where closeFiles :: TVar (Map Int64 Handle) -> m () @@ -247,9 +256,11 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, atomically $ writeTVar files M.empty execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse -execChatCommand s = case parseChatCommand s of - Left e -> pure $ chatCmdError e - Right cmd -> either CRChatCmdError id <$> runExceptT (processChatCommand cmd) +execChatCommand s = do + u <- readTVarIO =<< asks currentUser + case parseChatCommand s of + Left e -> pure $ chatCmdError u e + Right cmd -> either (CRChatCmdError u) id <$> runExceptT (processChatCommand cmd) parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace @@ -262,61 +273,104 @@ toView event = do processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse processChatCommand = \case ShowActiveUser -> withUser' $ pure . CRActiveUser - CreateActiveUser p -> do + CreateActiveUser p sameServers -> do u <- asks currentUser - whenM (isJust <$> readTVarIO u) $ throwChatError CEActiveUserExists - user <- withStore $ \db -> createUser db p True + (smp, smpServers) <- chooseServers + auId <- + withStore' getUsers >>= \case + [] -> pure 1 + _ -> withAgent (`createUser` smp) + user <- withStore $ \db -> createUserRecord db (AgentUserId auId) p True + unless (null smpServers) $ + withStore $ \db -> overwriteSMPServers db user smpServers + setActive ActiveNone atomically . writeTVar u $ Just user pure $ CRActiveUser user - StartChat subConns enableExpireCIs -> withUser' $ \user -> + where + chooseServers :: m (NonEmpty SMPServerWithAuth, [ServerCfg]) + chooseServers + | sameServers = + asks currentUser >>= readTVarIO >>= \case + Nothing -> throwChatError CENoActiveUser + Just user -> do + smpServers <- withStore' (`getSMPServers` user) + cfg <- asks config + pure (activeAgentServers cfg smpServers, smpServers) + | otherwise = do + DefaultAgentServers {smp} <- asks $ defaultServers . config + pure (smp, []) + ListUsers -> CRUsersList <$> withStore' getUsersInfo + APISetActiveUser userId -> do + u <- asks currentUser + user <- withStore $ \db -> getSetActiveUser db userId + setActive ActiveNone + atomically . writeTVar u $ Just user + pure $ CRActiveUser user + SetActiveUser uName -> withUserName uName APISetActiveUser + APIDeleteUser userId delSMPQueues -> do + user <- withStore (`getUser` userId) + when (activeUser user) $ throwChatError (CECantDeleteActiveUser userId) + users <- withStore' getUsers + -- shouldn't happen - last user should be active + when (length users == 1) $ throwChatError (CECantDeleteLastUser userId) + filesInfo <- withStore' (`getUserFileInfo` user) + withChatLock "deleteUser" . procCmd $ do + forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo + withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues + withStore' (`deleteUserRecord` user) + setActive ActiveNone + ok_ + DeleteUser uName delSMPQueues -> withUserName uName $ \uId -> APIDeleteUser uId delSMPQueues + StartChat subConns enableExpireCIs -> withUser' $ \_ -> asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning - _ -> checkStoreNotChanged $ startChatController user subConns enableExpireCIs $> CRChatStarted + _ -> checkStoreNotChanged $ startChatController subConns enableExpireCIs $> CRChatStarted APIStopChat -> do ask >>= stopChatController pure CRChatStopped - APIActivateChat -> do - withUser $ \user -> restoreCalls user + APIActivateChat -> withUser $ \_ -> do + restoreCalls withAgent activateAgent - setExpireCIs True - pure CRCmdOk + setAllExpireCIFlags True + ok_ APISuspendChat t -> do - setExpireCIs False + setAllExpireCIFlags False withAgent (`suspendAgent` t) - pure CRCmdOk - ResubscribeAllConnections -> withUser (subscribeUserConnections Agent.resubscribeConnections) $> CRCmdOk - SetFilesFolder filesFolder' -> do - createDirectoryIfMissing True filesFolder' - ff <- asks filesFolder - atomically . writeTVar ff $ Just filesFolder' - pure CRCmdOk + ok_ + ResubscribeAllConnections -> withStore' getUsers >>= subscribeUsers >> ok_ + SetFilesFolder ff -> do + createDirectoryIfMissing True ff + asks filesFolder >>= atomically . (`writeTVar` Just ff) + ok_ SetIncognito onOff -> do - incognito <- asks incognitoMode - atomically . writeTVar incognito $ onOff - pure CRCmdOk - APIExportArchive cfg -> checkChatStopped $ exportArchive cfg $> CRCmdOk + asks incognitoMode >>= atomically . (`writeTVar` onOff) + ok_ + APIExportArchive cfg -> checkChatStopped $ exportArchive cfg >> ok_ APIImportArchive cfg -> withStoreChanged $ importArchive cfg APIDeleteStorage -> withStoreChanged deleteStorage APIStorageEncryption cfg -> withStoreChanged $ sqlCipherExport cfg ExecChatStoreSQL query -> CRSQLResult <$> withStore' (`execSQL` query) ExecAgentStoreSQL query -> CRSQLResult <$> withAgent (`execAgentStoreSQL` query) - APIGetChats withPCC -> CRApiChats <$> withUser' (\user -> withStore' $ \db -> getChatPreviews db user withPCC) + APIGetChats userId withPCC -> withUserId userId $ \user -> + CRApiChats user <$> withStore' (\db -> getChatPreviews db user withPCC) APIGetChat (ChatRef cType cId) pagination search -> withUser $ \user -> case cType of -- TODO optimize queries calculating ChatStats, currently they're disabled CTDirect -> do directChat <- withStore (\db -> getDirectChat db user cId pagination search) - pure . CRApiChat $ AChat SCTDirect directChat - CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\db -> getGroupChat db user cId pagination search) - CTContactRequest -> pure $ chatCmdError "not implemented" - CTContactConnection -> pure $ chatCmdError "not supported" - APIGetChatItems _pagination -> pure $ chatCmdError "not implemented" + pure $ CRApiChat user (AChat SCTDirect directChat) + CTGroup -> do + groupChat <- withStore (\db -> getGroupChat db user cId pagination search) + pure $ CRApiChat user (AChat SCTGroup groupChat) + CTContactRequest -> pure $ chatCmdError (Just user) "not implemented" + CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + APIGetChatItems _pagination -> pure $ chatCmdError Nothing "not implemented" APISendMessage (ChatRef cType chatId) live (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of CTDirect -> do ct@Contact {contactId, localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db user chatId assertDirectAllowed user MDSnd ct XMsgNew_ unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct if isVoice mc && not (featureAllowed SCFVoice forUser ct) - then pure $ chatCmdError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFVoice) + then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (chatFeatureNameText CFVoice)) else do (fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer ct timed_ <- sndContactCITimed live ct @@ -330,7 +384,7 @@ processChatCommand = \case forM_ (timed_ >>= deleteAt) $ startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) setActive $ ActiveC c - pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + pure $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) where setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) setupSndFileTransfer ct = forM file_ $ \file -> do @@ -338,7 +392,7 @@ processChatCommand = \case (agentConnId_, fileConnReq) <- if isJust fileInline then pure (Nothing, Nothing) - else bimap Just Just <$> withAgent (\a -> createConnection a True SCMInvitation Nothing) + else bimap Just Just <$> withAgent (\a -> createConnection a (aUserId user) True SCMInvitation Nothing) let fileName = takeFileName file fileInvitation = FileInvitation {fileName, fileSize, fileConnReq, fileInline} withStore' $ \db -> do @@ -369,18 +423,18 @@ processChatCommand = \case Group gInfo@GroupInfo {groupId, membership, localDisplayName = gName} ms <- withStore $ \db -> getGroup db user chatId unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved if isVoice mc && not (groupFeatureAllowed SGFVoice gInfo) - then pure $ chatCmdError $ "feature not allowed " <> T.unpack (groupFeatureNameText GFVoice) + then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText GFVoice)) else do (fileInvitation_, ciFile_, ft_) <- unzipMaybe3 <$> setupSndFileTransfer gInfo (length $ filter memberCurrent ms) timed_ <- sndGroupCITimed live gInfo (msgContainer, quotedItem_) <- prepareMsg fileInvitation_ timed_ membership - msg@SndMessage {sharedMsgId} <- sendGroupMessage gInfo ms (XMsgNew msgContainer) + msg@SndMessage {sharedMsgId} <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) mapM_ (sendGroupFileInline ms sharedMsgId) ft_ ci <- saveSndChatItem' user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live forM_ (timed_ >>= deleteAt) $ startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) setActive $ ActiveG gName - pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci + pure $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) where setupSndFileTransfer :: GroupInfo -> Int -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) setupSndFileTransfer gInfo n = forM file_ $ \file -> do @@ -395,7 +449,7 @@ processChatCommand = \case sendGroupFileInline :: [GroupMember] -> SharedMsgId -> FileTransferMeta -> m () sendGroupFileInline ms sharedMsgId ft@FileTransferMeta {fileInline} = when (fileInline == Just IFMSent) . forM_ ms $ \m -> - processMember m `catchError` (toView . CRChatError) + processMember m `catchError` (toView . CRChatError (Just user)) where processMember m@GroupMember {activeConn = Just conn@Connection {connStatus}} = when (connStatus == ConnReady || connStatus == ConnSndReady) $ do @@ -419,8 +473,8 @@ processChatCommand = \case quoteData ChatItem {chatDir = CIGroupSnd, content = CISndMsgContent qmc} membership' = pure (qmc, CIQGroupSnd, True, membership') quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, m) quoteData _ _ = throwChatError CEInvalidQuote - CTContactRequest -> pure $ chatCmdError "not supported" - CTContactConnection -> pure $ chatCmdError "not supported" + CTContactRequest -> pure $ chatCmdError (Just user) "not supported" + CTContactConnection -> pure $ chatCmdError (Just user) "not supported" where quoteContent :: forall d. MsgContent -> Maybe (CIFile d) -> MsgContent quoteContent qmc ciFile_ @@ -459,7 +513,7 @@ processChatCommand = \case ci' <- withStore' $ \db -> updateDirectChatItem' db user contactId ci (CISndMsgContent mc) live $ Just msgId startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' setActive $ ActiveC c - pure . CRChatItemUpdated $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci' + pure $ CRChatItemUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci') _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTGroup -> do @@ -470,15 +524,15 @@ processChatCommand = \case CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive}, content = ciContent} -> do case (ciContent, itemSharedMsgId) of (CISndMsgContent _, Just itemSharedMId) -> do - SndMessage {msgId} <- sendGroupMessage gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) + SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive)) ci' <- withStore' $ \db -> updateGroupChatItem db user groupId ci (CISndMsgContent mc) live $ Just msgId startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' setActive $ ActiveG gName - pure . CRChatItemUpdated $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci' + pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci') _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate - CTContactRequest -> pure $ chatCmdError "not supported" - CTContactConnection -> pure $ chatCmdError "not supported" + CTContactRequest -> pure $ chatCmdError (Just user) "not supported" + CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user -> withChatLock "deleteChatItem" $ case cType of CTDirect -> do (ct@Contact {localDisplayName = c}, ci@(CChatItem msgDir ChatItem {meta = CIMeta {itemSharedMsgId}})) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId @@ -499,16 +553,17 @@ processChatCommand = \case case (mode, msgDir, itemSharedMsgId) of (CIDMInternal, _, _) -> deleteGroupCI user gInfo ci True False (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do - SndMessage {msgId} <- sendGroupMessage gInfo ms (XMsgDel itemSharedMId) + SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgDel itemSharedMId) setActive $ ActiveG gName if groupFeatureAllowed SGFFullDelete gInfo then deleteGroupCI user gInfo ci True False else markGroupCIDeleted user gInfo ci msgId True (CIDMBroadcast, _, _) -> throwChatError CEInvalidChatItemDelete - CTContactRequest -> pure $ chatCmdError "not supported" - CTContactConnection -> pure $ chatCmdError "not supported" - APIChatRead (ChatRef cType chatId) fromToIds -> withUser $ \user@User {userId} -> case cType of + CTContactRequest -> pure $ chatCmdError (Just user) "not supported" + CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + APIChatRead (ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of CTDirect -> do + user <- withStore $ \db -> getUserByContactId db chatId timedItems <- withStore' $ \db -> getDirectUnreadTimedItems db user chatId fromToIds ts <- liftIO getCurrentTime forM_ timedItems $ \(itemId, ttl) -> do @@ -516,8 +571,9 @@ processChatCommand = \case withStore' $ \db -> setDirectChatItemDeleteAt db user chatId itemId deleteAt startProximateTimedItemThread user (ChatRef CTDirect chatId, itemId) deleteAt withStore' $ \db -> updateDirectChatItemsRead db user chatId fromToIds - pure CRCmdOk + ok user CTGroup -> do + user@User {userId} <- withStore $ \db -> getUserByGroupId db chatId timedItems <- withStore' $ \db -> getGroupUnreadTimedItems db user chatId fromToIds ts <- liftIO getCurrentTime forM_ timedItems $ \(itemId, ttl) -> do @@ -525,116 +581,106 @@ processChatCommand = \case withStore' $ \db -> setGroupChatItemDeleteAt db user chatId itemId deleteAt startProximateTimedItemThread user (ChatRef CTGroup chatId, itemId) deleteAt withStore' $ \db -> updateGroupChatItemsRead db userId chatId fromToIds - pure CRCmdOk - CTContactRequest -> pure $ chatCmdError "not supported" - CTContactConnection -> pure $ chatCmdError "not supported" + ok user + CTContactRequest -> pure $ chatCmdError Nothing "not supported" + CTContactConnection -> pure $ chatCmdError Nothing "not supported" APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of CTDirect -> do withStore $ \db -> do ct <- getContact db user chatId liftIO $ updateContactUnreadChat db user ct unreadChat - pure CRCmdOk + ok user CTGroup -> do withStore $ \db -> do Group {groupInfo} <- getGroup db user chatId liftIO $ updateGroupUnreadChat db user groupInfo unreadChat - pure CRCmdOk - _ -> pure $ chatCmdError "not supported" + ok user + _ -> pure $ chatCmdError (Just user) "not supported" APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do ct@Contact {localDisplayName} <- withStore $ \db -> getContact db user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct - conns <- withStore $ \db -> getContactConnections db userId ct + contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct) withChatLock "deleteChat direct" . procCmd $ do - forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo - forM_ conns $ \conn -> deleteAgentConnectionAsync user conn `catchError` \_ -> pure () + fileAgentConnIds <- concat <$> forM filesInfo (deleteFile user) + deleteAgentConnectionsAsync user $ fileAgentConnIds <> contactConnIds -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct withStore' $ \db -> deleteContact db user ct unsetActive $ ActiveC localDisplayName - pure $ CRContactDeleted ct + pure $ CRContactDeleted user ct CTContactConnection -> withChatLock "deleteChat contactConnection" . procCmd $ do - conn@PendingContactConnection {pccConnId, pccAgentConnId} <- withStore $ \db -> getPendingContactConnection db userId chatId - deleteAgentConnectionAsync' user pccConnId pccAgentConnId + conn@PendingContactConnection {pccAgentConnId = AgentConnId acId} <- withStore $ \db -> getPendingContactConnection db userId chatId + deleteAgentConnectionAsync user acId withStore' $ \db -> deletePendingContactConnection db userId chatId - pure $ CRContactConnectionDeleted conn + pure $ CRContactConnectionDeleted user conn CTGroup -> do Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user chatId let canDelete = memberRole (membership :: GroupMember) == GROwner || not (memberCurrent membership) unless canDelete $ throwChatError CEGroupUserRole filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo withChatLock "deleteChat group" . procCmd $ do - forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo - when (memberActive membership) . void $ sendGroupMessage gInfo members XGrpDel + deleteFilesAndConns user filesInfo + when (memberActive membership) . void $ sendGroupMessage user gInfo members XGrpDel deleteGroupLink' user gInfo `catchError` \_ -> pure () - forM_ members $ deleteMemberConnection user + deleteMembersConnections user members -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) withStore' $ \db -> deleteGroupConnectionsAndFiles db user gInfo members withStore' $ \db -> deleteGroupItemsAndMembers db user gInfo members withStore' $ \db -> deleteGroup db user gInfo let contactIds = mapMaybe memberContactId members - forM_ contactIds $ \ctId -> - deleteUnusedContact ctId `catchError` (toView . CRChatError) - pure $ CRGroupDeletedUser gInfo + deleteAgentConnectionsAsync user . concat =<< mapM deleteUnusedContact contactIds + pure $ CRGroupDeletedUser user gInfo where - deleteUnusedContact contactId = do - ct <- withStore $ \db -> getContact db user contactId - unless (directOrUsed ct) $ do - ctGroupId <- withStore' $ \db -> checkContactHasGroups db user ct - when (isNothing ctGroupId) $ do - conns <- withStore $ \db -> getContactConnections db userId ct - forM_ conns $ \conn -> deleteAgentConnectionAsync user conn `catchError` \_ -> pure () - withStore' $ \db -> deleteContactWithoutGroups db user ct - CTContactRequest -> pure $ chatCmdError "not supported" + deleteUnusedContact :: ContactId -> m [ConnId] + deleteUnusedContact contactId = + (withStore (\db -> getContact db user contactId) >>= delete) + `catchError` (\e -> toView (CRChatError (Just user) e) >> pure []) + where + delete ct + | directOrUsed ct = pure [] + | otherwise = + withStore' (\db -> checkContactHasGroups db user ct) >>= \case + Just _ -> pure [] + Nothing -> do + conns <- withStore $ \db -> getContactConnections db userId ct + withStore' (\db -> deleteContactWithoutGroups db user ct) + `catchError` (toView . CRChatError (Just user)) + pure $ map aConnId conns + CTContactRequest -> pure $ chatCmdError (Just user) "not supported" APIClearChat (ChatRef cType chatId) -> withUser $ \user -> case cType of CTDirect -> do ct <- withStore $ \db -> getContact db user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct - -- TODO delete - maxItemTs_ <- withStore' $ \db -> getContactMaxItemTs db user ct - forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo + deleteFilesAndConns user filesInfo withStore' $ \db -> deleteContactCIs db user ct - -- TODO delete - ct' <- case maxItemTs_ of - Just ts -> do - withStore' $ \db -> updateContactTs db user ct ts - pure (ct :: Contact) {updatedAt = ts} - _ -> pure ct - pure $ CRChatCleared (AChatInfo SCTDirect (DirectChat ct')) + pure $ CRChatCleared user (AChatInfo SCTDirect $ DirectChat ct) CTGroup -> do gInfo <- withStore $ \db -> getGroupInfo db user chatId filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo - -- TODO delete - maxItemTs_ <- withStore' $ \db -> getGroupMaxItemTs db user gInfo - forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo + deleteFilesAndConns user filesInfo withStore' $ \db -> deleteGroupCIs db user gInfo membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db user gInfo forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m - -- TODO delete - gInfo' <- case maxItemTs_ of - Just ts -> do - withStore' $ \db -> updateGroupTs db user gInfo ts - pure (gInfo :: GroupInfo) {updatedAt = ts} - _ -> pure gInfo - pure $ CRChatCleared (AChatInfo SCTGroup (GroupChat gInfo')) - CTContactConnection -> pure $ chatCmdError "not supported" - CTContactRequest -> pure $ chatCmdError "not supported" - APIAcceptContact connReqId -> withUser $ \user@User {userId} -> withChatLock "acceptContact" $ do - cReq <- withStore $ \db -> getContactRequest db userId connReqId + pure $ CRChatCleared user (AChatInfo SCTGroup $ GroupChat gInfo) + CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + CTContactRequest -> pure $ chatCmdError (Just user) "not supported" + APIAcceptContact connReqId -> withUser $ \_ -> withChatLock "acceptContact" $ do + (user, cReq) <- withStore $ \db -> getContactRequest' db connReqId -- [incognito] generate profile to send, create connection with incognito profile incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing ct <- acceptContactRequest user cReq incognitoProfile - pure $ CRAcceptingContactRequest ct - APIRejectContact connReqId -> withUser $ \User {userId} -> withChatLock "rejectContact" $ do + pure $ CRAcceptingContactRequest user ct + APIRejectContact connReqId -> withUser $ \user -> withChatLock "rejectContact" $ do cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- withStore $ \db -> - getContactRequest db userId connReqId - `E.finally` liftIO (deleteContactRequest db userId connReqId) + getContactRequest db user connReqId + `E.finally` liftIO (deleteContactRequest db user connReqId) withAgent $ \a -> rejectContact a connId invId - pure $ CRContactRequestRejected cReq + pure $ CRContactRequestRejected user cReq APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call ct <- withStore $ \db -> getContact db user contactId @@ -650,8 +696,8 @@ processChatCommand = \case let call' = Call {contactId, callId, chatItemId = chatItemId' ci, callState, callTs = chatItemTs' ci} call_ <- atomically $ TM.lookupInsert contactId call' calls forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing - toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci - pure CRCmdOk + toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) + ok user SendCallInvitation cName callType -> withUser $ \user -> do contactId <- withStore $ \db -> getContactIdByName db user cName processChatCommand $ APISendCallInvitation contactId callType @@ -665,25 +711,25 @@ processChatCommand = \case _ -> throwChatError . CECallState $ callStateTag callState APISendCallOffer contactId WebRTCCallOffer {callType, rtcSession} -> -- party accepting call - withCurrentCall contactId $ \userId ct call@Call {callId, chatItemId, callState} -> case callState of + withCurrentCall contactId $ \user ct call@Call {callId, chatItemId, callState} -> case callState of CallInvitationReceived {peerCallType, localDhPubKey, sharedKey} -> do let callDhPubKey = if encryptedCall callType then localDhPubKey else Nothing offer = CallOffer {callType, rtcSession, callDhPubKey} callState' = CallOfferSent {localCallType = callType, peerCallType, localCallSession = rtcSession, sharedKey} aciContent = ACIContent SMDRcv $ CIRcvCall CISCallAccepted 0 (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallOffer callId offer) - withStore' $ \db -> updateDirectChatItemsRead db userId contactId $ Just (chatItemId, chatItemId) - updateDirectChatItemView userId ct chatItemId aciContent False $ Just msgId + withStore' $ \db -> updateDirectChatItemsRead db user contactId $ Just (chatItemId, chatItemId) + updateDirectChatItemView user ct chatItemId aciContent False $ Just msgId pure $ Just call {callState = callState'} _ -> throwChatError . CECallState $ callStateTag callState APISendCallAnswer contactId rtcSession -> -- party initiating call - withCurrentCall contactId $ \userId ct call@Call {callId, chatItemId, callState} -> case callState of + withCurrentCall contactId $ \user ct call@Call {callId, chatItemId, callState} -> case callState of CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} -> do let callState' = CallNegotiated {localCallType, peerCallType, localCallSession = rtcSession, peerCallSession, sharedKey} aciContent = ACIContent SMDSnd $ CISndCall CISCallNegotiated 0 (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallAnswer callId CallAnswer {rtcSession}) - updateDirectChatItemView userId ct chatItemId aciContent False $ Just msgId + updateDirectChatItemView user ct chatItemId aciContent False $ Just msgId pure $ Just call {callState = callState'} _ -> throwChatError . CECallState $ callStateTag callState APISendCallExtraInfo contactId rtcExtraInfo -> @@ -702,25 +748,27 @@ processChatCommand = \case _ -> throwChatError . CECallState $ callStateTag callState APIEndCall contactId -> -- any call party - withCurrentCall contactId $ \userId ct call@Call {callId} -> do + withCurrentCall contactId $ \user ct call@Call {callId} -> do (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XCallEnd callId) - updateCallItemStatus userId ct call WCSDisconnected $ Just msgId + updateCallItemStatus user ct call WCSDisconnected $ Just msgId pure Nothing - APIGetCallInvitations -> withUser $ \user -> do + APIGetCallInvitations -> withUser $ \_ -> do calls <- asks currentCalls >>= readTVarIO let invs = mapMaybe callInvitation $ M.elems calls - CRCallInvitations <$> mapM (rcvCallInvitation user) invs + rcvCallInvitations <- rights <$> mapM rcvCallInvitation invs + pure $ CRCallInvitations rcvCallInvitations where callInvitation Call {contactId, callState, callTs} = case callState of CallInvitationReceived {peerCallType, sharedKey} -> Just (contactId, callTs, peerCallType, sharedKey) _ -> Nothing - rcvCallInvitation user (contactId, callTs, peerCallType, sharedKey) = do - contact <- withStore (\db -> getContact db user contactId) - pure RcvCallInvitation {contact, callType = peerCallType, sharedKey, callTs} + rcvCallInvitation (contactId, callTs, peerCallType, sharedKey) = runExceptT . withStore $ \db -> do + user <- getUserByContactId db contactId + contact <- getContact db user contactId + pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callTs} APICallStatus contactId receivedStatus -> - withCurrentCall contactId $ \userId ct call -> - updateCallItemStatus userId ct call receivedStatus Nothing $> Just call - APIUpdateProfile profile -> withUser (`updateProfile` profile) + withCurrentCall contactId $ \user ct call -> + updateCallItemStatus user ct call receivedStatus Nothing $> Just call + APIUpdateProfile userId profile -> withUserId userId (`updateProfile` profile) APISetContactPrefs contactId prefs' -> withUser $ \user -> do ct <- withStore $ \db -> getContact db user contactId updateContactPrefs user ct prefs' @@ -728,54 +776,73 @@ processChatCommand = \case ct' <- withStore $ \db -> do ct <- getContact db user contactId liftIO $ updateContactAlias db userId ct localAlias - pure $ CRContactAliasUpdated ct' - APISetConnectionAlias connId localAlias -> withUser $ \User {userId} -> do + pure $ CRContactAliasUpdated user ct' + APISetConnectionAlias connId localAlias -> withUser $ \user@User {userId} -> do conn' <- withStore $ \db -> do conn <- getPendingContactConnection db userId connId liftIO $ updateContactConnectionAlias db userId conn localAlias - pure $ CRConnectionAliasUpdated conn' + pure $ CRConnectionAliasUpdated user conn' APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text APIGetNtfToken -> withUser $ \_ -> crNtfToken <$> withAgent getNtfToken - APIRegisterToken token mode -> CRNtfTokenStatus <$> withUser (\_ -> withAgent $ \a -> registerNtfToken a token mode) - APIVerifyToken token nonce code -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token nonce code) $> CRCmdOk - APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) $> CRCmdOk - APIGetNtfMessage nonce encNtfInfo -> withUser $ \user -> do + APIRegisterToken token mode -> withUser $ \_ -> + CRNtfTokenStatus <$> withAgent (\a -> registerNtfToken a token mode) + APIVerifyToken token nonce code -> withUser $ \_ -> withAgent (\a -> verifyNtfToken a token nonce code) >> ok_ + APIDeleteToken token -> withUser $ \_ -> withAgent (`deleteNtfToken` token) >> ok_ + APIGetNtfMessage nonce encNtfInfo -> withUser $ \_ -> do (NotificationInfo {ntfConnId, ntfMsgMeta}, msgs) <- withAgent $ \a -> getNotificationMessage a nonce encNtfInfo let ntfMessages = map (\SMP.SMPMsgMeta {msgTs, msgFlags} -> NtfMsgInfo {msgTs = systemToUTCTime msgTs, msgFlags}) msgs msgTs' = systemToUTCTime . (SMP.msgTs :: SMP.NMsgMeta -> SystemTime) <$> ntfMsgMeta - connEntity <- withStore (\db -> Just <$> getConnectionEntity db user (AgentConnId ntfConnId)) `catchError` \_ -> pure Nothing - pure CRNtfMessages {connEntity, msgTs = msgTs', ntfMessages} - GetUserSMPServers -> do - ChatConfig {defaultServers = InitialAgentServers {smp = defaultSMPServers}} <- asks config - smpServers <- withUser (\user -> withStore' (`getSMPServers` user)) + agentConnId = AgentConnId ntfConnId + user_ <- withStore' (`getUserByAConnId` agentConnId) + connEntity <- + pure user_ $>>= \user -> + withStore (\db -> Just <$> getConnectionEntity db user agentConnId) `catchError` \_ -> pure Nothing + pure CRNtfMessages {user_, connEntity, msgTs = msgTs', ntfMessages} + APIGetUserSMPServers userId -> withUserId userId $ \user -> do + ChatConfig {defaultServers = DefaultAgentServers {smp = defaultSMPServers}} <- asks config + smpServers <- withStore' (`getSMPServers` user) let smpServers' = fromMaybe (L.map toServerCfg defaultSMPServers) $ nonEmpty smpServers - pure $ CRUserSMPServers smpServers' defaultSMPServers + pure $ CRUserSMPServers user smpServers' defaultSMPServers where toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True} - SetUserSMPServers (SMPServersConfig smpServers) -> withUser $ \user -> withChatLock "setUserSMPServers" $ do + GetUserSMPServers -> withUser $ \User {userId} -> + processChatCommand $ APIGetUserSMPServers userId + APISetUserSMPServers userId (SMPServersConfig smpServers) -> withUserId userId $ \user -> withChatLock "setUserSMPServers" $ do withStore $ \db -> overwriteSMPServers db user smpServers cfg <- asks config - withAgent $ \a -> setSMPServers a $ activeAgentServers cfg smpServers - pure CRCmdOk - TestSMPServer smpServer -> CRSmpTestResult <$> withAgent (`testSMPServerConnection` smpServer) - APISetChatItemTTL newTTL_ -> withUser' $ \user -> + withAgent $ \a -> setSMPServers a (aUserId user) $ activeAgentServers cfg smpServers + ok user + SetUserSMPServers smpServersConfig -> withUser $ \User {userId} -> + processChatCommand $ APISetUserSMPServers userId smpServersConfig + TestSMPServer userId smpServer -> withUserId userId $ \user -> + CRSmpTestResult user <$> withAgent (\a -> testSMPServerConnection a (aUserId user) smpServer) + APISetChatItemTTL userId newTTL_ -> withUser' $ \user -> do + checkSameUser userId user checkStoreNotChanged $ withChatLock "setChatItemTTL" $ do case newTTL_ of Nothing -> do withStore' $ \db -> setChatItemTTL db user newTTL_ - setExpireCIs False + setExpireCIFlag user False Just newTTL -> do oldTTL <- withStore' (`getChatItemTTL` user) when (maybe True (newTTL <) oldTTL) $ do - setExpireCIs False + setExpireCIFlag user False expireChatItems user newTTL True withStore' $ \db -> setChatItemTTL db user newTTL_ - whenM chatStarted $ setExpireCIs True - pure CRCmdOk - APIGetChatItemTTL -> CRChatItemTTL <$> withUser (\user -> withStore' (`getChatItemTTL` user)) - APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) $> CRCmdOk - APIGetNetworkConfig -> CRNetworkConfig <$> withUser' (\_ -> withAgent getNetworkConfig) + startExpireCIThread user + whenM chatStarted $ setExpireCIFlag user True + ok user + SetChatItemTTL newTTL_ -> withUser' $ \User {userId} -> do + processChatCommand $ APISetChatItemTTL userId newTTL_ + APIGetChatItemTTL userId -> withUserId userId $ \user -> do + ttl <- withStore' (`getChatItemTTL` user) + pure $ CRChatItemTTL user ttl + GetChatItemTTL -> withUser' $ \User {userId} -> do + processChatCommand $ APIGetChatItemTTL userId + APISetNetworkConfig cfg -> withUser' $ \_ -> withAgent (`setNetworkConfig` cfg) >> ok_ + APIGetNetworkConfig -> withUser' $ \_ -> + CRNetworkConfig <$> withAgent getNetworkConfig APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user -> case cType of CTDirect -> do ct <- withStore $ \db -> do @@ -783,34 +850,34 @@ processChatCommand = \case liftIO $ updateContactSettings db user chatId chatSettings pure ct withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (enableNtfs chatSettings) - pure CRCmdOk + ok user CTGroup -> do ms <- withStore $ \db -> do Group _ ms <- getGroup db user chatId liftIO $ updateGroupSettings db user chatId chatSettings pure ms forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchError` (toView . CRChatError) - pure CRCmdOk - _ -> pure $ chatCmdError "not supported" + withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchError` (toView . CRChatError (Just user)) + ok user + _ -> pure $ chatCmdError (Just user) "not supported" APIContactInfo contactId -> withUser $ \user@User {userId} -> do -- [incognito] print user's incognito profile for this contact ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db user contactId incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) connectionStats <- withAgent (`getConnectionServers` contactConnId ct) - pure $ CRContactInfo ct connectionStats (fmap fromLocalProfile incognitoProfile) + pure $ CRContactInfo user ct connectionStats (fmap fromLocalProfile incognitoProfile) APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m) - pure $ CRGroupMemberInfo g m connectionStats + pure $ CRGroupMemberInfo user g m connectionStats APISwitchContact contactId -> withUser $ \user -> do ct <- withStore $ \db -> getContact db user contactId withAgent $ \a -> switchConnectionAsync a "" $ contactConnId ct - pure CRCmdOk + ok user APISwitchGroupMember gId gMemberId -> withUser $ \user -> do m <- withStore $ \db -> getGroupMember db user gId gMemberId case memberConnId m of - Just connId -> withAgent (\a -> switchConnectionAsync a "" connId) $> CRCmdOk + Just connId -> withAgent (\a -> switchConnectionAsync a "" connId) >> ok user _ -> throwChatError CEGroupMemberNotActive APIGetContactCode contactId -> withUser $ \user -> do ct@Contact {activeConn = conn@Connection {connId}} <- withStore $ \db -> getContact db user contactId @@ -822,7 +889,7 @@ processChatCommand = \case withStore' $ \db -> setConnectionVerified db user connId Nothing pure (ct :: Contact) {activeConn = conn {connectionCode = Nothing}} _ -> pure ct - pure $ CRContactCode ct' code + pure $ CRContactCode user ct' code APIGetGroupMemberCode gId gMemberId -> withUser $ \user -> do (g, m@GroupMember {activeConn}) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId case activeConn of @@ -835,7 +902,7 @@ processChatCommand = \case withStore' $ \db -> setConnectionVerified db user connId Nothing pure (m :: GroupMember) {activeConn = Just $ (conn :: Connection) {connectionCode = Nothing}} _ -> pure m - pure $ CRGroupMemberCode g m' code + pure $ CRGroupMemberCode user g m' code _ -> throwChatError CEGroupMemberNotActive APIVerifyContact contactId code -> withUser $ \user -> do Contact {activeConn} <- withStore $ \db -> getContact db user contactId @@ -848,13 +915,13 @@ processChatCommand = \case APIEnableContact contactId -> withUser $ \user -> do Contact {activeConn} <- withStore $ \db -> getContact db user contactId withStore' $ \db -> setConnectionAuthErrCounter db user activeConn 0 - pure CRCmdOk + ok user APIEnableGroupMember gId gMemberId -> withUser $ \user -> do GroupMember {activeConn} <- withStore $ \db -> getGroupMember db user gId gMemberId case activeConn of Just conn -> do withStore' $ \db -> setConnectionAuthErrCounter db user conn 0 - pure CRCmdOk + ok user _ -> throwChatError CEGroupMemberNotActive ShowMessages (ChatName cType name) ntfOn -> withUser $ \user -> do chatId <- case cType of @@ -874,47 +941,61 @@ processChatCommand = \case EnableGroupMember gName mName -> withMemberName gName mName $ \gId mId -> APIEnableGroupMember gId mId ChatHelp section -> pure $ CRChatHelp section Welcome -> withUser $ pure . CRWelcome - AddContact -> withUser $ \User {userId} -> withChatLock "addContact" . procCmd $ do + APIAddContact userId -> withUserId userId $ \user -> withChatLock "addContact" . procCmd $ do -- [incognito] generate profile for connection incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing - (connId, cReq) <- withAgent $ \a -> createConnection a True SCMInvitation Nothing - conn <- withStore' $ \db -> createDirectConnection db userId connId cReq ConnNew incognitoProfile - toView $ CRNewContactConnection conn - pure $ CRInvitation cReq - Connect (Just (ACR SCMInvitation cReq)) -> withUser $ \user@User {userId} -> withChatLock "connect" . procCmd $ do + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile + toView $ CRNewContactConnection user conn + pure $ CRInvitation user cReq + AddContact -> withUser $ \User {userId} -> + processChatCommand $ APIAddContact userId + APIConnect userId (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do -- [incognito] generate profile to send incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing - connId <- withAgent $ \a -> joinConnection a True cReq . directMessage $ XInfo profileToSend - conn <- withStore' $ \db -> createDirectConnection db userId connId cReq ConnJoined $ incognitoProfile $> profileToSend - toView $ CRNewContactConnection conn - pure CRSentConfirmation - Connect (Just (ACR SCMContact cReq)) -> withUser $ \user -> - -- [incognito] generate profile to send - connectViaContact user cReq - Connect Nothing -> throwChatError CEInvalidConnReq + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq . directMessage $ XInfo profileToSend + conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined $ incognitoProfile $> profileToSend + toView $ CRNewContactConnection user conn + pure $ CRSentConfirmation user + APIConnect userId (Just (ACR SCMContact cReq)) -> withUserId userId (`connectViaContact` cReq) + APIConnect _ Nothing -> throwChatError CEInvalidConnReq + Connect cReqUri -> withUser $ \User {userId} -> + processChatCommand $ APIConnect userId cReqUri ConnectSimplex -> withUser $ \user -> -- [incognito] generate profile to send connectViaContact user adminContactReq DeleteContact cName -> withContactName cName $ APIDeleteChat . ChatRef CTDirect ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect - ListContacts -> withUser $ \user -> CRContactsList <$> withStore' (`getUserContacts` user) - CreateMyAddress -> withUser $ \User {userId} -> withChatLock "createMyAddress" . procCmd $ do - (connId, cReq) <- withAgent $ \a -> createConnection a True SCMContact Nothing - withStore $ \db -> createUserContactLink db userId connId cReq - pure $ CRUserContactLinkCreated cReq - DeleteMyAddress -> withUser $ \user -> withChatLock "deleteMyAddress" $ do + APIListContacts userId -> withUserId userId $ \user -> + CRContactsList user <$> withStore' (`getUserContacts` user) + ListContacts -> withUser $ \User {userId} -> + processChatCommand $ APIListContacts userId + APICreateMyAddress userId -> withUserId userId $ \user -> withChatLock "createMyAddress" . procCmd $ do + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing + withStore $ \db -> createUserContactLink db user connId cReq + pure $ CRUserContactLinkCreated user cReq + CreateMyAddress -> withUser $ \User {userId} -> + processChatCommand $ APICreateMyAddress userId + APIDeleteMyAddress userId -> withUserId userId $ \user -> withChatLock "deleteMyAddress" $ do conns <- withStore (`getUserAddressConnections` user) procCmd $ do - forM_ conns $ \conn -> deleteAgentConnectionAsync user conn `catchError` \_ -> pure () + deleteAgentConnectionsAsync user $ map aConnId conns withStore' (`deleteUserAddress` user) - pure CRUserContactLinkDeleted + pure $ CRUserContactLinkDeleted user + DeleteMyAddress -> withUser $ \User {userId} -> + processChatCommand $ APIDeleteMyAddress userId + APIShowMyAddress userId -> withUserId userId $ \user -> + CRUserContactLink user <$> withStore (`getUserAddress` user) ShowMyAddress -> withUser $ \User {userId} -> - CRUserContactLink <$> withStore (`getUserAddress` userId) - AddressAutoAccept autoAccept_ -> withUser $ \User {userId} -> do - CRUserContactLinkUpdated <$> withStore (\db -> updateUserAddressAutoAccept db userId autoAccept_) + processChatCommand $ APIShowMyAddress userId + APIAddressAutoAccept userId autoAccept_ -> withUserId userId $ \user -> do + contactLink <- withStore (\db -> updateUserAddressAutoAccept db user autoAccept_) + pure $ CRUserContactLinkUpdated user contactLink + AddressAutoAccept autoAccept_ -> withUser $ \User {userId} -> + processChatCommand $ APIAddressAutoAccept userId autoAccept_ AcceptContact cName -> withUser $ \User {userId} -> do connReqId <- withStore $ \db -> getContactRequestIdByName db userId cName processChatCommand $ APIAcceptContact connReqId @@ -934,8 +1015,8 @@ processChatCommand = \case (sndMsg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) saveSndChatItem user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) ) - `catchError` (toView . CRChatError) - CRBroadcastSent mc (length cts) <$> liftIO getZonedTime + `catchError` (toView . CRChatError (Just user)) + CRBroadcastSent user mc (length cts) <$> liftIO getZonedTime SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do contactId <- withStore $ \db -> getContactIdByName db user cName quotedItemId <- withStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir (safeDecodeUtf8 quotedMsg) @@ -954,10 +1035,12 @@ processChatCommand = \case chatRef <- getChatRef user chatName let mc = MCText $ safeDecodeUtf8 msg processChatCommand $ APIUpdateChatItem chatRef chatItemId live mc - NewGroup gProfile -> withUser $ \user -> do + APINewGroup userId gProfile -> withUserId userId $ \user -> do gVar <- asks idsDrg - groupInfo <- withStore (\db -> createNewGroup db gVar user gProfile) - pure $ CRGroupCreated groupInfo + groupInfo <- withStore $ \db -> createNewGroup db gVar user gProfile + pure $ CRGroupCreated user groupInfo + NewGroup gProfile -> withUser $ \User {userId} -> + processChatCommand $ APINewGroup userId gProfile APIAddMember groupId contactId memRole -> withUser $ \user -> withChatLock "addMember" $ do -- TODO for large groups: no need to load all members to determine if contact is a member (group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db user contactId @@ -976,27 +1059,29 @@ processChatCommand = \case case contactMember contact members of Nothing -> do gVar <- asks idsDrg - (agentConnId, cReq) <- withAgent $ \a -> createConnection a True SCMInvitation Nothing + (agentConnId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing member <- withStore $ \db -> createNewContactMember db gVar user groupId contact memRole agentConnId cReq sendInvitation member cReq - pure $ CRSentGroupInvitation gInfo contact member + pure $ CRSentGroupInvitation user gInfo contact member Just member@GroupMember {groupMemberId, memberStatus, memberRole = mRole} | memberStatus == GSMemInvited -> do unless (mRole == memRole) $ withStore' $ \db -> updateGroupMemberRole db user member memRole withStore' (\db -> getMemberInvitation db user groupMemberId) >>= \case - Just cReq -> sendInvitation member {memberRole = memRole} cReq $> CRSentGroupInvitation gInfo contact member {memberRole = memRole} + Just cReq -> do + sendInvitation member {memberRole = memRole} cReq + pure $ CRSentGroupInvitation user gInfo contact member {memberRole = memRole} Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName APIJoinGroup groupId -> withUser $ \user@User {userId} -> do ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} <- withStore $ \db -> getGroupInvitation db user groupId withChatLock "joinGroup" . procCmd $ do - agentConnId <- withAgent $ \a -> joinConnection a True connRequest . directMessage $ XGrpAcpt (memberId (membership :: GroupMember)) + agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest . directMessage $ XGrpAcpt (memberId (membership :: GroupMember)) withStore' $ \db -> do createMemberConnection db userId fromMember agentConnId updateGroupMemberStatus db userId fromMember GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted updateCIGroupInvitationStatus user - pure $ CRUserAcceptedGroupSent g {membership = membership {memberStatus = GSMemAccepted}} Nothing + pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing where updateCIGroupInvitationStatus user = do AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db user groupId @@ -1027,10 +1112,10 @@ processChatCommand = \case (Just ct, Just cReq) -> sendGrpInvitation user ct gInfo (m :: GroupMember) {memberRole = memRole} cReq _ -> throwChatError $ CEGroupCantResendInvitation gInfo cName _ -> do - msg <- sendGroupMessage gInfo members $ XGrpMemRole mId memRole + msg <- sendGroupMessage user gInfo members $ XGrpMemRole mId memRole ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent gEvent) - toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci - pure CRMemberRoleUser {groupInfo = gInfo, member = m {memberRole = memRole}, fromRole = mRole, toRole = memRole} + toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) + pure CRMemberRoleUser {user, groupInfo = gInfo, member = m {memberRole = memRole}, fromRole = mRole, toRole = memRole} APIRemoveMember groupId memberId -> withUser $ \user -> do Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user groupId case find ((== memberId) . groupMemberId') members of @@ -1045,26 +1130,27 @@ processChatCommand = \case deleteMemberConnection user m withStore' $ \db -> deleteGroupMember db user m _ -> do - msg <- sendGroupMessage gInfo members $ XGrpMemDel mId + msg <- sendGroupMessage user gInfo members $ XGrpMemDel mId ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent $ SGEMemberDeleted memberId (fromLocalProfile memberProfile)) - toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci + toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) deleteMemberConnection user m -- undeleted "member connected" chat item will prevent deletion of member record deleteOrUpdateMemberRecord user m - pure $ CRUserDeletedMember gInfo m {memberStatus = GSMemRemoved} + pure $ CRUserDeletedMember user gInfo m {memberStatus = GSMemRemoved} APILeaveGroup groupId -> withUser $ \user@User {userId} -> do Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db user groupId withChatLock "leaveGroup" . procCmd $ do - msg <- sendGroupMessage gInfo members XGrpLeave + msg <- sendGroupMessage user gInfo members XGrpLeave ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft) - toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci + toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) -- TODO delete direct connections that were unused deleteGroupLink' user gInfo `catchError` \_ -> pure () -- member records are not deleted to keep history - forM_ members $ deleteMemberConnection user + deleteMembersConnections user members withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft - pure $ CRLeftMemberUser gInfo {membership = membership {memberStatus = GSMemLeft}} - APIListMembers groupId -> CRGroupMembers <$> withUser (\user -> withStore (\db -> getGroup db user groupId)) + pure $ CRLeftMemberUser user gInfo {membership = membership {memberStatus = GSMemLeft}} + APIListMembers groupId -> withUser $ \user -> + CRGroupMembers user <$> withStore (\db -> getGroup db user groupId) AddMember gName cName memRole -> withUser $ \user -> do (groupId, contactId) <- withStore $ \db -> (,) <$> getGroupIdByName db user gName <*> getContactIdByName db user cName processChatCommand $ APIAddMember groupId contactId memRole @@ -1085,14 +1171,15 @@ processChatCommand = \case ListMembers gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIListMembers groupId - ListGroups -> CRGroupsList <$> withUser (\user -> withStore' (`getUserGroupDetails` user)) + ListGroups -> withUser $ \user -> + CRGroupsList user <$> withStore' (`getUserGroupDetails` user) APIUpdateGroupProfile groupId p' -> withUser $ \user -> do g <- withStore $ \db -> getGroup db user groupId runUpdateGroupProfile user g p' UpdateGroupNames gName GroupProfile {displayName, fullName} -> updateGroupProfileByName gName $ \p -> p {displayName, fullName} ShowGroupProfile gName -> withUser $ \user -> - CRGroupProfile <$> withStore (\db -> getGroupInfoByName db user gName) + CRGroupProfile user <$> withStore (\db -> getGroupInfoByName db user gName) UpdateGroupDescription gName description -> updateGroupProfileByName gName $ \p -> p {description} APICreateGroupLink groupId -> withUser $ \user -> withChatLock "createGroupLink" $ do @@ -1102,16 +1189,17 @@ processChatCommand = \case unless (memberActive membership) $ throwChatError CEGroupMemberNotActive groupLinkId <- GroupLinkId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) let crClientData = encodeJSON $ CRDataGroup groupLinkId - (connId, cReq) <- withAgent $ \a -> createConnection a True SCMContact $ Just crClientData + (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact $ Just crClientData withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId - pure $ CRGroupLinkCreated gInfo cReq + pure $ CRGroupLinkCreated user gInfo cReq APIDeleteGroupLink groupId -> withUser $ \user -> withChatLock "deleteGroupLink" $ do gInfo <- withStore $ \db -> getGroupInfo db user groupId deleteGroupLink' user gInfo - pure $ CRGroupLinkDeleted gInfo + pure $ CRGroupLinkDeleted user gInfo APIGetGroupLink groupId -> withUser $ \user -> do gInfo <- withStore $ \db -> getGroupInfo db user groupId - CRGroupLink gInfo <$> withStore (\db -> getGroupLink db user gInfo) + groupLink <- withStore $ \db -> getGroupLink db user gInfo + pure $ CRGroupLink user gInfo groupLink CreateGroupLink gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName processChatCommand $ APICreateGroupLink groupId @@ -1131,21 +1219,26 @@ processChatCommand = \case pure $ CRChats $ maybe id take count_ chats LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName - CRChatItems . aChatItems . chat <$> processChatCommand (APIGetChat chatRef (CPLast count) search) - LastMessages Nothing count search -> withUser $ \user -> withStore $ \db -> - CRChatItems <$> getAllChatItems db user (CPLast count) search + chatResp <- processChatCommand $ APIGetChat chatRef (CPLast count) search + pure $ CRChatItems user (aChatItems . chat $ chatResp) + LastMessages Nothing count search -> withUser $ \user -> do + chatItems <- withStore $ \db -> getAllChatItems db user (CPLast count) search + pure $ CRChatItems user chatItems LastChatItemId (Just chatName) index -> withUser $ \user -> do chatRef <- getChatRef user chatName - CRChatItemId . fmap aChatItemId . listToMaybe . aChatItems . chat <$> processChatCommand (APIGetChat chatRef (CPLast $ index + 1) Nothing) - LastChatItemId Nothing index -> withUser $ \user -> withStore $ \db -> - CRChatItemId . fmap aChatItemId . listToMaybe <$> getAllChatItems db user (CPLast $ index + 1) Nothing - ShowChatItem (Just itemId) -> withUser $ \user -> withStore $ \db -> - CRChatItems . (: []) <$> getAChatItem db user itemId - ShowChatItem Nothing -> withUser $ \user -> withStore $ \db -> - CRChatItems <$> getAllChatItems db user (CPLast 1) Nothing - ShowLiveItems on -> withUser $ \_ -> do - asks showLiveItems >>= atomically . (`writeTVar` on) - pure CRCmdOk + chatResp <- processChatCommand (APIGetChat chatRef (CPLast $ index + 1) Nothing) + pure $ CRChatItemId user (fmap aChatItemId . listToMaybe . aChatItems . chat $ chatResp) + LastChatItemId Nothing index -> withUser $ \user -> do + chatItems <- withStore $ \db -> getAllChatItems db user (CPLast $ index + 1) Nothing + pure $ CRChatItemId user (fmap aChatItemId . listToMaybe $ chatItems) + ShowChatItem (Just itemId) -> withUser $ \user -> do + chatItem <- withStore $ \db -> getAChatItem db user itemId + pure $ CRChatItems user ((: []) chatItem) + ShowChatItem Nothing -> withUser $ \user -> do + chatItems <- withStore $ \db -> getAllChatItems db user (CPLast 1) Nothing + pure $ CRChatItems user chatItems + ShowLiveItems on -> withUser $ \_ -> + asks showLiveItems >>= atomically . (`writeTVar` on) >> ok_ SendFile chatName f -> withUser $ \user -> do chatRef <- getChatRef user chatName processChatCommand . APISendMessage chatRef False $ ComposedMessage (Just f) Nothing (MCFile "") @@ -1158,22 +1251,23 @@ processChatCommand = \case processChatCommand . APISendMessage chatRef False $ ComposedMessage (Just f) Nothing (MCImage "" fixedImagePreview) ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage - ReceiveFile fileId rcvInline_ filePath_ -> withUser $ \user -> + ReceiveFile fileId rcvInline_ filePath_ -> withUser $ \_ -> withChatLock "receiveFile" . procCmd $ do - ft <- withStore $ \db -> getRcvFileTransfer db user fileId - (CRRcvFileAccepted <$> acceptFileReceive user ft rcvInline_ filePath_) `catchError` processError ft + (user, ft) <- withStore $ \db -> getRcvFileTransferById db fileId + (CRRcvFileAccepted user <$> acceptFileReceive user ft rcvInline_ filePath_) `catchError` processError user ft where - processError ft = \case + processError user ft = \case -- TODO AChatItem in Cancelled events - ChatErrorAgent (SMP SMP.AUTH) _ -> pure $ CRRcvFileAcceptedSndCancelled ft - ChatErrorAgent (CONN DUPLICATE) _ -> pure $ CRRcvFileAcceptedSndCancelled ft + ChatErrorAgent (SMP SMP.AUTH) _ -> pure $ CRRcvFileAcceptedSndCancelled user ft + ChatErrorAgent (CONN DUPLICATE) _ -> pure $ CRRcvFileAcceptedSndCancelled user ft e -> throwError e CancelFile fileId -> withUser $ \user@User {userId} -> withChatLock "cancelFile" . procCmd $ withStore (\db -> getFileTransfer db user fileId) >>= \case FTSnd ftm@FileTransferMeta {cancelled} fts -> do unless cancelled $ do - cancelSndFile user ftm fts + fileAgentConnIds <- cancelSndFile user ftm fts True + deleteAgentConnectionsAsync user fileAgentConnIds sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId withStore (\db -> getChatRefByFileId db user fileId) >>= \case ChatRef CTDirect contactId -> do @@ -1181,16 +1275,18 @@ processChatCommand = \case void . sendDirectContactMessage contact $ XFileCancel sharedMsgId ChatRef CTGroup groupId -> do Group gInfo ms <- withStore $ \db -> getGroup db user groupId - void . sendGroupMessage gInfo ms $ XFileCancel sharedMsgId + void . sendGroupMessage user gInfo ms $ XFileCancel sharedMsgId _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" ci <- withStore $ \db -> getChatItemByFileId db user fileId - pure $ CRSndGroupFileCancelled ci ftm fts + pure $ CRSndGroupFileCancelled user ci ftm fts FTRcv ftr@RcvFileTransfer {cancelled} -> do - unless cancelled $ cancelRcvFileTransfer user ftr - pure $ CRRcvFileCancelled ftr - FileStatus fileId -> - CRFileTransferStatus <$> withUser (\user -> withStore $ \db -> getFileTransferProgress db user fileId) - ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile (fromLocalProfile profile) + unless cancelled $ + cancelRcvFileTransfer user ftr >>= mapM_ (deleteAgentConnectionAsync user) + pure $ CRRcvFileCancelled user ftr + FileStatus fileId -> withUser $ \user -> do + fileStatus <- withStore $ \db -> getFileTransferProgress db user fileId + pure $ CRFileTransferStatus user fileStatus + ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile) UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do let p = (fromLocalProfile profile :: Profile) {displayName = displayName, fullName = fullName} updateProfile user p @@ -1232,7 +1328,7 @@ processChatCommand = \case where stat (AgentStatsKey {host, clientTs, cmd, res}, count) = map B.unpack [host, clientTs, cmd, res, bshow count] - ResetAgentStats -> CRCmdOk <$ withAgent resetAgentStats + ResetAgentStats -> withAgent resetAgentStats >> ok_ where withChatLock name action = asks chatLock >>= \l -> withLock l name action -- below code would make command responses asynchronous where they can be slow @@ -1248,6 +1344,8 @@ processChatCommand = \case -- use function below to make commands "synchronous" procCmd :: m ChatResponse -> m ChatResponse procCmd = id + ok_ = pure $ CRCmdOk Nothing + ok = pure . CRCmdOk . Just getChatRef :: User -> ChatName -> m ChatRef getChatRef user (ChatName cType name) = ChatRef cType <$> case cType of @@ -1259,9 +1357,11 @@ processChatCommand = \case setStoreChanged :: m () setStoreChanged = asks chatStoreChanged >>= atomically . (`writeTVar` True) withStoreChanged :: m () -> m ChatResponse - withStoreChanged a = checkChatStopped $ a >> setStoreChanged $> CRCmdOk + withStoreChanged a = checkChatStopped $ a >> setStoreChanged >> ok_ checkStoreNotChanged :: m ChatResponse -> m ChatResponse checkStoreNotChanged = ifM (asks chatStoreChanged >>= readTVarIO) (throwChatError CEChatStoreChanged) + withUserName :: UserName -> (UserId -> ChatCommand) -> m ChatResponse + withUserName uName cmd = withStore (`getUserIdByName` uName) >>= processChatCommand . cmd withContactName :: ContactName -> (ContactId -> ChatCommand) -> m ChatResponse withContactName cName cmd = withUser $ \user -> withStore (\db -> getContactIdByName db user cName) >>= processChatCommand . cmd @@ -1275,11 +1375,11 @@ processChatCommand = \case code' <- getConnectionCode $ aConnId conn let verified = sameVerificationCode code code' when verified . withStore' $ \db -> setConnectionVerified db user connId $ Just code' - pure $ CRConnectionVerified verified code' + pure $ CRConnectionVerified user verified code' verifyConnectionCode user conn@Connection {connId} _ = do code' <- getConnectionCode $ aConnId conn withStore' $ \db -> setConnectionVerified db user connId Nothing - pure $ CRConnectionVerified False code' + pure $ CRConnectionVerified user False code' getSentChatItemIdByText :: User -> ChatRef -> ByteString -> m Int64 getSentChatItemIdByText user@User {userId, localDisplayName} (ChatRef cType cId) msg = case cType of CTDirect -> withStore $ \db -> getDirectChatItemIdByText db userId cId SMDSnd (safeDecodeUtf8 msg) @@ -1289,7 +1389,7 @@ processChatCommand = \case connectViaContact user@User {userId} cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case - (Just contact, _) -> pure $ CRContactAlreadyExists contact + (Just contact, _) -> pure $ CRContactAlreadyExists user contact (_, xContactId_) -> procCmd $ do let randomXContactId = XContactId <$> (asks idsDrg >>= liftIO . (`randomBytes` 16)) xContactId <- maybe randomXContactId pure xContactId_ @@ -1301,11 +1401,11 @@ processChatCommand = \case incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing - connId <- withAgent $ \a -> joinConnection a True cReq $ directMessage (XContact profileToSend $ Just xContactId) + connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq $ directMessage (XContact profileToSend $ Just xContactId) let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId - toView $ CRNewContactConnection conn - pure $ CRSentInvitation incognitoProfile + toView $ CRNewContactConnection user conn + pure $ CRSentInvitation user incognitoProfile contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> @@ -1324,7 +1424,7 @@ processChatCommand = \case | otherwise = Just IFMOffer updateProfile :: User -> Profile -> m ChatResponse updateProfile user@User {profile = p} p' - | p' == fromLocalProfile p = pure CRUserProfileNoChange + | p' == fromLocalProfile p = pure $ CRUserProfileNoChange user | otherwise = do -- read contacts before user update to correctly merge preferences -- [incognito] filter out contacts with whom user has incognito connections @@ -1335,8 +1435,8 @@ processChatCommand = \case asks currentUser >>= atomically . (`writeTVar` Just user') withChatLock "updateProfile" . procCmd $ do forM_ contacts $ \ct -> do - processContact user' ct `catchError` (toView . CRChatError) - pure $ CRUserProfileUpdated (fromLocalProfile p) p' + processContact user' ct `catchError` (toView . CRChatError (Just user)) + pure $ CRUserProfileUpdated user' (fromLocalProfile p) p' where processContact user' ct = do let mergedProfile = userProfileToSend user Nothing $ Just ct @@ -1347,7 +1447,7 @@ processChatCommand = \case when (directOrUsed ct') $ createSndFeatureItems user' ct ct' updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse updateContactPrefs user@User {userId} ct@Contact {activeConn = Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs' - | contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated ct ct + | contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated user ct ct | otherwise = do assertDirectAllowed user MDSnd ct XInfo_ ct' <- withStore' $ \db -> updateContactUserPreferences db user ct contactUserPrefs' @@ -1356,9 +1456,9 @@ processChatCommand = \case mergedProfile' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct') when (mergedProfile' /= mergedProfile) $ withChatLock "updateProfile" $ do - void (sendDirectContactMessage ct' $ XInfo mergedProfile') `catchError` (toView . CRChatError) + void (sendDirectContactMessage ct' $ XInfo mergedProfile') `catchError` (toView . CRChatError (Just user)) when (directOrUsed ct') $ createSndFeatureItems user ct ct' - pure $ CRContactPrefsUpdated ct ct' + pure $ CRContactPrefsUpdated user ct ct' runUpdateGroupProfile :: User -> Group -> GroupProfile -> m ChatResponse runUpdateGroupProfile user (Group g@GroupInfo {groupProfile = p} ms) p' = do let s = memberStatus $ membership g @@ -1367,13 +1467,13 @@ processChatCommand = \case || (s == GSMemRemoved || s == GSMemLeft || s == GSMemGroupDeleted || s == GSMemInvited) unless canUpdate $ throwChatError CEGroupUserRole g' <- withStore $ \db -> updateGroupProfile db user g p' - msg <- sendGroupMessage g' ms (XGrpInfo p') + msg <- sendGroupMessage user g' ms (XGrpInfo p') let cd = CDGroupSnd g' unless (sameGroupProfileInfo p p') $ do ci <- saveSndChatItem user cd msg (CISndGroupEvent $ SGEGroupUpdated p') - toView . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat g') ci + toView $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat g') ci) createGroupFeatureChangedItems user cd CISndGroupFeature g g' - pure $ CRGroupUpdated g g' Nothing + pure $ CRGroupUpdated user g g' Nothing updateGroupProfileByName :: GroupName -> (GroupProfile -> GroupProfile) -> m ChatResponse updateGroupProfileByName gName update = withUser $ \user -> do g@(Group GroupInfo {groupProfile = p} _) <- withStore $ \db -> @@ -1384,8 +1484,10 @@ processChatCommand = \case let s = connStatus $ activeConn (ct :: Contact) in s == ConnReady || s == ConnSndReady withCurrentCall :: ContactId -> (User -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse - withCurrentCall ctId action = withUser $ \user -> do - ct <- withStore $ \db -> getContact db user ctId + withCurrentCall ctId action = do + (user, ct) <- withStore $ \db -> do + user <- getUserByContactId db ctId + (user,) <$> getContact db user ctId calls <- asks currentCalls withChatLock "currentCall" $ atomically (TM.lookup ctId calls) >>= \case @@ -1400,7 +1502,7 @@ processChatCommand = \case _ -> do withStore' $ \db -> deleteCalls db user ctId atomically $ TM.delete ctId calls - pure CRCmdOk + ok user | otherwise -> throwChatError $ CECallContact contactId forwardFile :: ChatName -> FileTransferId -> (ChatName -> FilePath -> ChatCommand) -> m ChatResponse forwardFile chatName fileId sendCommand = withUser $ \user -> do @@ -1423,7 +1525,7 @@ processChatCommand = \case (msg, _) <- sendDirectContactMessage ct $ XGrpInv groupInv let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole ci <- saveSndChatItem user (CDDirectSnd ct) msg content - toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) setActive $ ActiveG localDisplayName sendTextMessage chatName msg live = withUser $ \user -> do chatRef <- getChatRef user chatName @@ -1455,24 +1557,65 @@ assertDirectAllowed user dir ct event = XCallInv_ -> False _ -> True -setExpireCIs :: (MonadUnliftIO m, MonadReader ChatController m) => Bool -> m () -setExpireCIs b = do - expire <- asks expireCIs - atomically $ writeTVar expire b - -deleteFile :: forall m. ChatMonad m => User -> CIFileInfo -> m () -deleteFile user CIFileInfo {filePath, fileId, fileStatus} = - (cancel' >> delete) `catchError` (toView . CRChatError) +startExpireCIThread :: forall m. (MonadUnliftIO m, MonadReader ChatController m) => User -> m () +startExpireCIThread user@User {userId} = do + expireThreads <- asks expireCIThreads + atomically (TM.lookup userId expireThreads) >>= \case + Nothing -> do + a <- Just <$> async (void $ runExceptT runExpireCIs) + atomically $ TM.insert userId a expireThreads + _ -> pure () where - cancel' = forM_ fileStatus $ \(AFS dir status) -> - unless (ciFileEnded status) $ - case dir of + runExpireCIs = do + interval <- asks $ ciExpirationInterval . config + forever $ do + flip catchError (toView . CRChatError (Just user)) $ do + expireFlags <- asks expireCIFlags + atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry + ttl <- withStore' (`getChatItemTTL` user) + forM_ ttl $ \t -> expireChatItems user t False + threadDelay interval + +setExpireCIFlag :: (MonadUnliftIO m, MonadReader ChatController 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 b = do + expireFlags <- asks expireCIFlags + atomically $ do + keys <- M.keys <$> readTVar expireFlags + forM_ keys $ \k -> TM.insert k b expireFlags + +deleteFilesAndConns :: forall m. ChatMonad m => User -> [CIFileInfo] -> m () +deleteFilesAndConns user filesInfo = do + connIds <- mapM (deleteFile user) filesInfo + deleteAgentConnectionsAsync user $ concat connIds + +deleteFile :: forall m. ChatMonad m => User -> CIFileInfo -> m [ConnId] +deleteFile user fileInfo = deleteFile' user fileInfo False + +deleteFile' :: forall m. ChatMonad m => User -> CIFileInfo -> Bool -> m [ConnId] +deleteFile' user CIFileInfo {filePath, fileId, fileStatus} sendCancel = do + aConnIds <- case fileStatus of + Just fStatus -> cancel' fStatus `catchError` (\e -> toView (CRChatError (Just user) e) >> pure []) + Nothing -> pure [] + delete `catchError` (toView . CRChatError (Just user)) + pure aConnIds + where + cancel' :: ACIFileStatus -> m [ConnId] + cancel' (AFS dir status) = + if ciFileEnded status + then pure [] + else case dir of SMDSnd -> do (ftm@FileTransferMeta {cancelled}, fts) <- withStore (\db -> getSndFileTransfer db user fileId) - unless cancelled $ cancelSndFile user ftm fts + if cancelled then pure [] else cancelSndFile user ftm fts sendCancel SMDRcv -> do ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) - unless cancelled $ cancelRcvFileTransfer user ft + if cancelled then pure [] else maybeToList <$> cancelRcvFileTransfer user ft + delete :: m () delete = withFilesFolder $ \filesFolder -> forM_ filePath $ \fPath -> do let fsFilePath = filesFolder <> "/" <> fPath @@ -1490,7 +1633,7 @@ updateCallItemStatus user ct Call {chatItemId} receivedStatus msgId_ = do updateDirectChatItemView :: ChatMonad m => User -> Contact -> ChatItemId -> ACIContent -> Bool -> Maybe MessageId -> m () updateDirectChatItemView user ct@Contact {contactId} chatItemId (ACIContent msgDir ciContent) live msgId_ = do ci' <- withStore $ \db -> updateDirectChatItem db user contactId chatItemId ciContent live msgId_ - toView . CRChatItemUpdated $ AChatItem SCTDirect msgDir (DirectChat ct) ci' + toView $ CRChatItemUpdated user (AChatItem SCTDirect msgDir (DirectChat ct) ci') callStatusItemContent :: ChatMonad m => User -> Contact -> ChatItemId -> WebRTCCallStatus -> m (Maybe ACIContent) callStatusItemContent user Contact {contactId} chatItemId receivedStatus = do @@ -1643,7 +1786,7 @@ profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> deleteGroupLink' :: ChatMonad m => User -> GroupInfo -> m () deleteGroupLink' user gInfo = do conn <- withStore $ \db -> getGroupLinkConnection db user gInfo - deleteAgentConnectionAsync user conn `catchError` \_ -> pure () + deleteAgentConnectionAsync user $ aConnId conn withStore' $ \db -> deleteGroupLink db user gInfo agentSubscriber :: (MonadUnliftIO m, MonadReader ChatController m) => m () @@ -1652,10 +1795,9 @@ agentSubscriber = do l <- asks chatLock forever $ do (corrId, connId, msg) <- atomically $ readTBQueue q - u <- readTVarIO =<< asks currentUser let name = "agentSubscriber connId=" <> str connId <> " corrId=" <> str corrId <> " msg=" <> str (aCommandTag msg) withLock l name . void . runExceptT $ - processAgentMessage u corrId connId msg `catchError` (toView . CRChatError) + processAgentMessage corrId connId msg `catchError` (toView . CRChatError Nothing) where str :: StrEncoding a => a -> String str = B.unpack . strEncode @@ -1713,19 +1855,19 @@ subscribeUserConnections agentBatchSubscribe user = do let connIds = map aConnId' pcs pure (connIds, M.fromList $ zip connIds pcs) contactSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId Contact -> m () - contactSubsToView rs = toView . CRContactSubSummary . map (uncurry ContactSubStatus) . resultsFor rs + contactSubsToView rs = toView . CRContactSubSummary user . map (uncurry ContactSubStatus) . resultsFor rs contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> m () - contactLinkSubsToView rs = toView . CRUserContactSubSummary . map (uncurry UserContactSubStatus) . resultsFor rs + contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> m () groupSubsToView rs gs ms ce = do mapM_ groupSub $ sortOn (\(Group GroupInfo {localDisplayName = g} _) -> g) gs - toView . CRMemberSubSummary $ map (uncurry MemberSubStatus) mRs + toView . CRMemberSubSummary user $ map (uncurry MemberSubStatus) mRs where mRs = resultsFor rs ms groupSub :: Group -> m () groupSub (Group g@GroupInfo {membership, groupId = gId} members) = do - when ce $ mapM_ (toView . uncurry (CRMemberSubError g)) mErrors + when ce $ mapM_ (toView . uncurry (CRMemberSubError user g)) mErrors toView groupEvent where mErrors :: [(GroupMember, ChatError)] @@ -1735,28 +1877,28 @@ subscribeUserConnections agentBatchSubscribe user = do $ filter (\(GroupMember {groupId}, _) -> groupId == gId) mRs groupEvent :: ChatResponse groupEvent - | memberStatus membership == GSMemInvited = CRGroupInvitation g + | memberStatus membership == GSMemInvited = CRGroupInvitation user g | all (\GroupMember {activeConn} -> isNothing activeConn) members = if memberActive membership - then CRGroupEmpty g - else CRGroupRemoved g - | otherwise = CRGroupSubscribed g + then CRGroupEmpty user g + else CRGroupRemoved user g + | otherwise = CRGroupSubscribed user g sndFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId SndFileTransfer -> m () sndFileSubsToView rs sfts = do let sftRs = resultsFor rs sfts forM_ sftRs $ \(ft@SndFileTransfer {fileId, fileStatus}, err_) -> do - forM_ err_ $ toView . CRSndFileSubError ft + forM_ err_ $ toView . CRSndFileSubError user ft void . forkIO $ do threadDelay 1000000 l <- asks chatLock when (fileStatus == FSConnected) . unlessM (isFileActive fileId sndFiles) . withLock l "subscribe sendFileChunk" $ sendFileChunk user ft rcvFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId RcvFileTransfer -> m () - rcvFileSubsToView rs = mapM_ (toView . uncurry CRRcvFileSubError) . filterErrors . resultsFor rs + rcvFileSubsToView rs = mapM_ (toView . uncurry (CRRcvFileSubError user)) . filterErrors . resultsFor rs pendingConnSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId PendingContactConnection -> m () - pendingConnSubsToView rs = toView . CRPendingSubSummary . map (uncurry PendingSubStatus) . resultsFor rs + pendingConnSubsToView rs = toView . CRPendingSubSummary user . map (uncurry PendingSubStatus) . resultsFor rs withStore_ :: (DB.Connection -> User -> IO [a]) -> m [a] - withStore_ a = withStore' (`a` user) `catchError` \e -> toView (CRChatError e) >> pure [] + withStore_ a = withStore' (`a` user) `catchError` \e -> toView (CRChatError (Just user) e) >> pure [] filterErrors :: [(a, Maybe ChatError)] -> [(a, ChatError)] filterErrors = mapMaybe (\(a, e_) -> (a,) <$> e_) resultsFor :: Map ConnId (Either AgentErrorType ()) -> Map ConnId a -> [(a, Maybe ChatError)] @@ -1773,15 +1915,20 @@ subscribeUserConnections agentBatchSubscribe user = do cleanupManagerInterval :: Int cleanupManagerInterval = 1800 -- 30 minutes -cleanupManager :: forall m. ChatMonad m => User -> m () -cleanupManager user = do +cleanupManager :: forall m. ChatMonad m => m () +cleanupManager = do forever $ do - flip catchError (toView . CRChatError) $ do + flip catchError (toView . CRChatError Nothing) $ do waitChatStarted - cleanupTimedItems + users <- withStore' getUsers + let (us, us') = partition activeUser users + forM_ us cleanupUser + forM_ us' cleanupUser threadDelay $ cleanupManagerInterval * 1000000 where - cleanupTimedItems = do + cleanupUser user = + cleanupTimedItems user `catchError` (toView . CRChatError (Just user)) + cleanupTimedItems user = do ts <- liftIO getCurrentTime let startTimedThreadCutoff = addUTCTime (realToFrac cleanupManagerInterval) ts timedItems <- withStore' $ \db -> getTimedItems db user startTimedThreadCutoff @@ -1820,7 +1967,7 @@ deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do CTGroup -> do (gInfo, ci) <- withStore $ \db -> (,) <$> getGroupInfo db user chatId <*> getGroupChatItem db user chatId itemId deleteGroupCI user gInfo ci True True >>= toView - _ -> toView . CRChatError . ChatError $ CEInternalError "bad deleteTimedItem cType" + _ -> toView . CRChatError (Just user) . ChatError $ CEInternalError "bad deleteTimedItem cType" startUpdatedTimedItemThread :: ChatMonad m => User -> ChatRef -> ChatItem c d -> ChatItem c d -> m () startUpdatedTimedItemThread user chatRef ci ci' = @@ -1830,76 +1977,79 @@ startUpdatedTimedItemThread user chatRef ci ci' = _ -> pure () expireChatItems :: forall m. ChatMonad m => User -> Int64 -> Bool -> m () -expireChatItems user ttl sync = do +expireChatItems user@User {userId} ttl sync = do currentTs <- liftIO getCurrentTime let expirationDate = addUTCTime (-1 * fromIntegral ttl) currentTs -- this is to keep group messages created during last 12 hours even if they're expired according to item_ts createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs - expire <- asks expireCIs contacts <- withStore' (`getUserContacts` user) - loop expire contacts $ processContact expirationDate + loop contacts $ processContact expirationDate groups <- withStore' (`getUserGroupDetails` user) - loop expire groups $ processGroup expirationDate createdAtCutoff + loop groups $ processGroup expirationDate createdAtCutoff where - loop :: TVar Bool -> [a] -> (a -> m ()) -> m () - loop _ [] _ = pure () - loop expire (a : as) process = continue expire $ do - process a `catchError` (toView . CRChatError) - loop expire as process - continue :: TVar Bool -> m () -> m () - continue expire = if sync then id else \a -> whenM (readTVarIO expire) $ threadDelay 100000 >> a + loop :: [a] -> (a -> m ()) -> m () + loop [] _ = pure () + loop (a : as) process = continue $ do + process a `catchError` (toView . CRChatError (Just user)) + loop as process + continue :: m () -> m () + continue a = + if sync + then a + else do + expireFlags <- asks expireCIFlags + expire <- atomically $ TM.lookup userId expireFlags + when (expire == Just True) $ threadDelay 100000 >> a processContact :: UTCTime -> Contact -> m () processContact expirationDate ct = do filesInfo <- withStore' $ \db -> getContactExpiredFileInfo db user ct expirationDate - -- TODO delete - maxItemTs_ <- withStore' $ \db -> getContactMaxItemTs db user ct - forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo + deleteFilesAndConns user filesInfo withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate - -- TODO delete - withStore' $ \db -> do - ciCount_ <- getContactCICount db user ct - case (maxItemTs_, ciCount_) of - (Just ts, Just count) -> when (count == 0) $ updateContactTs db user ct ts - _ -> pure () processGroup :: UTCTime -> UTCTime -> GroupInfo -> m () processGroup expirationDate createdAtCutoff gInfo = do filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff - -- TODO delete - maxItemTs_ <- withStore' $ \db -> getGroupMaxItemTs db user gInfo - forM_ filesInfo $ \fileInfo -> deleteFile user fileInfo + deleteFilesAndConns user filesInfo withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db user gInfo forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m - -- TODO delete - withStore' $ \db -> do - ciCount_ <- getGroupCICount db user gInfo - case (maxItemTs_, ciCount_) of - (Just ts, Just count) -> when (count == 0) $ updateGroupTs db user gInfo ts - _ -> pure () -processAgentMessage :: forall m. ChatMonad m => Maybe User -> ACorrId -> ConnId -> ACommand 'Agent -> m () -processAgentMessage Nothing _ _ _ = throwChatError CENoActiveUser -processAgentMessage (Just User {userId}) _ "" agentMessage = case agentMessage of +processAgentMessage :: forall m. ChatMonad m => ACorrId -> ConnId -> ACommand 'Agent -> m () +processAgentMessage _ "" msg = + processAgentMessageNoConn msg `catchError` (toView . CRChatError Nothing) +processAgentMessage _ connId (DEL_RCVQ srv qId err_) = + toView $ CRAgentRcvQueueDeleted (AgentConnId connId) srv (AgentQueueId qId) err_ +processAgentMessage _ connId DEL_CONN = + toView $ CRAgentConnDeleted (AgentConnId connId) +processAgentMessage corrId connId msg = + withStore' (`getUserByAConnId` AgentConnId connId) >>= \case + Just user -> processAgentMessageConn user corrId connId msg `catchError` (toView . CRChatError (Just user)) + _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) + +processAgentMessageNoConn :: forall m. ChatMonad m => ACommand 'Agent -> m () +processAgentMessageNoConn = \case CONNECT p h -> hostEvent $ CRHostConnected p h DISCONNECT p h -> hostEvent $ CRHostDisconnected p h DOWN srv conns -> serverEvent srv conns CRContactsDisconnected "disconnected" UP srv conns -> serverEvent srv conns CRContactsSubscribed "connected" SUSPENDED -> toView CRChatSuspended + DEL_USER agentUserId -> toView $ CRAgentUserDeleted agentUserId _ -> pure () where hostEvent = whenM (asks $ hostEvents . config) . toView serverEvent srv@(SMPServer host _ _) conns event str = do - cs <- withStore' $ \db -> getConnectionsContacts db userId conns + cs <- withStore' $ \db -> getConnectionsContacts db conns toView $ event srv cs showToast ("server " <> str) (safeDecodeUtf8 $ strEncode host) -processAgentMessage (Just user) _ agentConnId END = + +processAgentMessageConn :: forall m. ChatMonad m => User -> ACorrId -> ConnId -> ACommand 'Agent -> m () +processAgentMessageConn user _ agentConnId END = withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= \case RcvDirectMsgConnection _ (Just ct@Contact {localDisplayName = c}) -> do - toView $ CRContactAnotherClient ct + toView $ CRContactAnotherClient user ct showToast (c <> "> ") "connected to another client" unsetActive $ ActiveC c - entity -> toView $ CRSubscriptionEnd entity -processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = do + entity -> toView $ CRSubscriptionEnd user entity +processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do entity <- withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= updateConnStatus case entity of RcvDirectMsgConnection conn contact_ -> @@ -1955,10 +2105,10 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withCompletedCommand conn agentMsg $ \CommandData {cmdFunction, cmdId} -> when (cmdFunction == CFAckMessage) $ ackMsgDeliveryEvent conn cmdId MERR _ err -> do - toView . CRChatError $ ChatErrorAgent err (Just connEntity) + toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) incAuthErrCounter connEntity conn err ERR err -> do - toView . CRChatError $ ChatErrorAgent err (Just connEntity) + toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -2027,7 +2177,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = Nothing -> do -- [incognito] print incognito profile used for this contact incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - toView $ CRContactConnected ct (fmap fromLocalProfile incognitoProfile) + toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile) when (directOrUsed ct) $ createFeatureEnabledItems ct setActive $ ActiveC c showToast (c <> "> ") "connected" @@ -2038,7 +2188,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = forM_ mc_ $ \mc -> do (msg, _) <- sendDirectContactMessage ct (XMsgNew $ MCSimple (extMsgContent mc Nothing)) ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) - toView . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) forM_ groupId_ $ \groupId -> do gVar <- asks idsDrg groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation @@ -2055,10 +2205,10 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withStore' (\db -> getDirectChatItemByAgentMsgId db user contactId connId msgId) >>= \case Just (CChatItem SMDSnd ci) -> do chatItem <- withStore $ \db -> updateDirectChatItemStatus db user contactId (chatItemId' ci) CISSndSent - toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) + toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) _ -> pure () SWITCH qd phase cStats -> do - toView . CRContactSwitch ct $ SwitchProgress qd phase cStats + toView $ CRContactSwitch user ct (SwitchProgress qd phase cStats) when (phase /= SPConfirmed) $ case qd of QDRcv -> createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCESwitchQueue phase Nothing) Nothing QDSnd -> createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing @@ -2070,11 +2220,11 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = chatItemId_ <- withStore' $ \db -> getChatItemIdByAgentMsgId db connId msgId forM_ chatItemId_ $ \chatItemId -> do chatItem <- withStore $ \db -> updateDirectChatItemStatus db user contactId chatItemId (agentErrToItemStatus err) - toView $ CRChatItemStatusUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) - toView . CRChatError $ ChatErrorAgent err (Just connEntity) + toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) + toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) incAuthErrCounter connEntity conn err ERR err -> do - toView . CRChatError $ ChatErrorAgent err (Just connEntity) + toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -2101,7 +2251,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withStore' $ \db -> setNewContactMemberConnRequest db user m cReq groupLinkId <- withStore' $ \db -> getGroupLinkId db user gInfo sendGrpInvitation ct m groupLinkId - toView $ CRSentGroupInvitation gInfo ct m + toView $ CRSentGroupInvitation user gInfo ct m where sendGrpInvitation :: Contact -> GroupMember -> Maybe GroupLinkId -> m () sendGrpInvitation ct GroupMember {memberId, memberRole = memRole} groupLinkId = do @@ -2151,11 +2301,11 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = unless (memberActive membership) $ updateGroupMemberStatus db userId membership GSMemConnected -- possible improvement: check for each pending message, requires keeping track of connection state - unless (connDisabled conn) $ sendPendingGroupMessages m conn + unless (connDisabled conn) $ sendPendingGroupMessages user m conn withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) $ enableNtfs chatSettings case memberCategory m of GCHostMember -> do - toView $ CRUserJoinedGroup gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} + toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} createGroupFeatureItems gInfo m let GroupInfo {groupProfile = GroupProfile {description}} = gInfo memberConnectedChatItem gInfo m @@ -2164,13 +2314,13 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = showToast ("#" <> gName) "you are connected to group" GCInviteeMember -> do memberConnectedChatItem gInfo m - toView $ CRJoinedGroupMember gInfo m {memberStatus = GSMemConnected} + toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected} setActive $ ActiveG gName showToast ("#" <> gName) $ "member " <> localDisplayName (m :: GroupMember) <> " is connected" intros <- withStore' $ \db -> createIntroductions db members m - void . sendGroupMessage gInfo members . XGrpMemNew $ memberInfo m + void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m forM_ intros $ \intro -> - processIntro intro `catchError` (toView . CRChatError) + processIntro intro `catchError` (toView . CRChatError (Just user)) where processIntro intro@GroupMemberIntro {introId} = do void $ sendDirectMessage conn (XGrpMemIntro . memberInfo $ reMember intro) (GroupId groupId) @@ -2215,7 +2365,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = sentMsgDeliveryEvent conn msgId checkSndInlineFTComplete conn msgId SWITCH qd phase cStats -> do - toView . CRGroupMemberSwitch gInfo m $ SwitchProgress qd phase cStats + toView $ CRGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats) when (phase /= SPConfirmed) $ case qd of QDRcv -> createInternalChatItem user (CDGroupSnd gInfo) (CISndConnEvent . SCESwitchQueue phase . Just $ groupMemberRef m) Nothing QDSnd -> createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvConnEvent $ RCESwitchQueue phase) Nothing @@ -2224,10 +2374,10 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withCompletedCommand conn agentMsg $ \CommandData {cmdFunction, cmdId} -> when (cmdFunction == CFAckMessage) $ ackMsgDeliveryEvent conn cmdId MERR _ err -> do - toView . CRChatError $ ChatErrorAgent err (Just connEntity) + toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) incAuthErrCounter connEntity conn err ERR err -> do - toView . CRChatError $ ChatErrorAgent err (Just connEntity) + toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -2252,17 +2402,17 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = ci <- withStore $ \db -> do liftIO $ updateSndFileStatus db ft FSConnected updateDirectCIFileStatus db user fileId CIFSSndTransfer - toView $ CRSndFileStart ci ft + toView $ CRSndFileStart user ci ft sendFileChunk user ft SENT msgId -> do withStore' $ \db -> updateSndFileChunkSent db ft msgId unless (fileStatus == FSCancelled) $ sendFileChunk user ft MERR _ err -> do - cancelSndFileTransfer user ft + cancelSndFileTransfer user ft True >>= mapM_ (deleteAgentConnectionAsync user) case err of SMP SMP.AUTH -> unless (fileStatus == FSCancelled) $ do ci <- withStore $ \db -> getChatItemByFileId db user fileId - toView $ CRSndFileRcvCancelled ci ft + toView $ CRSndFileRcvCancelled user ci ft _ -> throwChatError $ CEFileSend fileId err MSG meta _ _ -> do cmdId <- createAckCmd conn @@ -2271,7 +2421,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = -- [async agent commands] continuation on receiving OK withCompletedCommand conn agentMsg $ \_cmdData -> pure () ERR err -> do - toView . CRChatError $ ChatErrorAgent err (Just connEntity) + toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -2317,10 +2467,10 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = -- [async agent commands] continuation on receiving OK withCompletedCommand conn agentMsg $ \_cmdData -> pure () MERR _ err -> do - toView . CRChatError $ ChatErrorAgent err (Just connEntity) + toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) incAuthErrCounter connEntity conn err ERR err -> do - toView . CRChatError $ ChatErrorAgent err (Just connEntity) + toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -2331,14 +2481,14 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = liftIO $ updateRcvFileStatus db ft FSConnected liftIO $ updateCIFileStatus db user fileId CIFSRcvTransfer getChatItemByFileId db user fileId - toView $ CRRcvFileStart ci + toView $ CRRcvFileStart user ci receiveFileChunk :: RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> m () receiveFileChunk ft@RcvFileTransfer {fileId, chunkSize, cancelled} conn_ MsgMeta {recipient = (msgId, _), integrity} = \case FileChunkCancel -> unless cancelled $ do - cancelRcvFileTransfer user ft - toView (CRRcvFileSndCancelled ft) + cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) + toView $ CRRcvFileSndCancelled user ft FileChunk {chunkNo, chunkBytes = chunk} -> do case integrity of MsgOk -> pure () @@ -2361,9 +2511,9 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = updateCIFileStatus db user fileId CIFSRcvComplete deleteRcvFileChunks db ft getChatItemByFileId db user fileId - toView $ CRRcvFileComplete ci + toView $ CRRcvFileComplete user ci closeFileHandle fileId rcvFiles - mapM_ (deleteAgentConnectionAsync user) conn_ + forM_ conn_ $ \conn -> deleteAgentConnectionAsync user (aConnId conn) RcvChunkDuplicate -> pure () RcvChunkError -> badRcvFileChunk ft $ "incorrect chunk number " <> show chunkNo @@ -2377,10 +2527,10 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = -- TODO show/log error, other events in contact request _ -> pure () MERR _ err -> do - toView . CRChatError $ ChatErrorAgent err (Just connEntity) + toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) incAuthErrCounter connEntity conn err ERR err -> do - toView . CRChatError $ ChatErrorAgent err (Just connEntity) + toView $ CRChatError (Just user) (ChatErrorAgent err $ Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -2388,7 +2538,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = profileContactRequest :: InvitationId -> Profile -> Maybe XContactId -> m () profileContactRequest invId p xContactId_ = do withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId p xContactId_) >>= \case - CORContact contact -> toView $ CRContactRequestAlreadyAccepted contact + CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact CORRequest cReq@UserContactRequest {localDisplayName} -> do withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case Just (UserContactLink {autoAccept}, groupId_) -> @@ -2398,14 +2548,14 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = -- [incognito] generate profile to send, create connection with incognito profile incognitoProfile <- if acceptIncognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing ct <- acceptContactRequestAsync user cReq incognitoProfile - toView $ CRAcceptingContactRequest ct + toView $ CRAcceptingContactRequest user ct Just groupId -> do gInfo@GroupInfo {membership = membership@GroupMember {memberProfile}} <- withStore $ \db -> getGroupInfo db user groupId let profileMode = if memberIncognito membership then Just $ ExistingIncognito memberProfile else Nothing ct <- acceptContactRequestAsync user cReq profileMode - toView $ CRAcceptingGroupJoinRequest gInfo ct + toView $ CRAcceptingGroupJoinRequest user gInfo ct _ -> do - toView $ CRReceivedContactRequest cReq + toView $ CRReceivedContactRequest user cReq showToast (localDisplayName <> "> ") "wants to connect to you" _ -> pure () @@ -2470,7 +2620,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = badRcvFileChunk :: RcvFileTransfer -> String -> m () badRcvFileChunk ft@RcvFileTransfer {cancelled} err = unless cancelled $ do - cancelRcvFileTransfer user ft + cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) throwChatError $ CEFileRcvChunk err memberConnectedChatItem :: GroupInfo -> GroupMember -> m () @@ -2485,7 +2635,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = notifyMemberConnected :: GroupInfo -> GroupMember -> m () notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} = do memberConnectedChatItem gInfo m - toView $ CRConnectedToGroupMember gInfo m + toView $ CRConnectedToGroupMember user gInfo m let g = groupName' gInfo setActive $ ActiveG g showToast ("#" <> g) $ "member " <> c <> " is connected" @@ -2508,10 +2658,10 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withStore' $ \db -> createSentProbeHash db userId probeId c messageWarning :: Text -> m () - messageWarning = toView . CRMessageError "warning" + messageWarning = toView . CRMessageError user "warning" messageError :: Text -> m () - messageError = toView . CRMessageError "error" + messageError = toView . CRMessageError user "error" newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m () newContentMessage ct@Contact {localDisplayName = c, contactUsed, chatSettings} mc msg@RcvMessage {sharedMsgId_} msgMeta = do @@ -2534,7 +2684,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = where newChatItem ciContent ciFile_ timed_ live = do ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live - toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci + toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) pure ci processFileInvitation :: Maybe FileInvitation -> MsgContent -> (DB.Connection -> FileInvitation -> Maybe InlineFileMode -> Integer -> IO RcvFileTransfer) -> m (Maybe (CIFile 'MDRcv)) @@ -2562,7 +2712,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = let timed_ = rcvContactCITimed ct ttl ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) msgMeta content Nothing timed_ live ci' <- withStore' $ \db -> updateDirectChatItem' db user contactId ci content live Nothing - toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci' + toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') setActive $ ActiveC c _ -> throwError e where @@ -2573,7 +2723,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = case msgDir of SMDRcv -> do ci' <- withStore' $ \db -> updateDirectChatItem' db user contactId ci content live $ Just msgId - toView . CRChatItemUpdated $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci' + toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' SMDSnd -> messageError "x.msg.update: contact attempted invalid message update" @@ -2582,7 +2732,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = checkIntegrityCreateItem (CDDirectRcv ct) msgMeta deleteRcvChatItem `catchError` \e -> case e of - (ChatErrorStore (SEChatItemSharedMsgIdNotFound sMsgId)) -> toView $ CRChatItemDeletedNotFound ct sMsgId + (ChatErrorStore (SEChatItemSharedMsgIdNotFound sMsgId)) -> toView $ CRChatItemDeletedNotFound user ct sMsgId _ -> throwError e where deleteRcvChatItem = do @@ -2626,7 +2776,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = let timed_ = rcvGroupCITimed gInfo ttl_ ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) msgMeta content Nothing timed_ live ci' <- withStore' $ \db -> updateGroupChatItem db user groupId ci content live Nothing - toView . CRChatItemUpdated $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci' + toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') setActive $ ActiveG g _ -> throwError e where @@ -2639,7 +2789,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = if sameMemberId memberId m' then do ci' <- withStore' $ \db -> updateGroupChatItem db user groupId ci content live $ Just msgId - toView . CRChatItemUpdated $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci' + toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') setActive $ ActiveG g startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' else messageError "x.msg.update: group member attempted to update a message of another member" -- shouldn't happen now that query includes group member id @@ -2667,7 +2817,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = RcvFileTransfer {fileId} <- withStore' $ \db -> createRcvFileTransfer db userId ct fInv inline chSize 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 $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci + toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) showToast (c <> "> ") "wants to send a file" setActive $ ActiveC c @@ -2699,8 +2849,8 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = fileId <- withStore $ \db -> getFileIdBySharedMsgId db userId contactId sharedMsgId ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) unless cancelled $ do - cancelRcvFileTransfer user ft - toView $ CRRcvFileSndCancelled ft + cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) + toView $ CRRcvFileSndCancelled user ft xFileAcptInv :: Contact -> SharedMsgId -> Maybe ConnReqInvitation -> String -> MsgMeta -> m () xFileAcptInv ct sharedMsgId fileConnReq_ fName msgMeta = do @@ -2719,7 +2869,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = event <- withStore $ \db -> do ci <- updateDirectCIFileStatus db user fileId CIFSSndTransfer sft <- liftIO $ createSndDirectInlineFT db ct ft - pure $ CRSndFileStart ci sft + pure $ CRSndFileStart user ci sft toView event ifM (allowSendInline fileSize fileInline) @@ -2735,7 +2885,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = liftIO $ updateSndFileStatus db ft FSComplete liftIO $ deleteSndFileChunks db ft updateDirectCIFileStatus db user fileId CIFSSndComplete - toView $ CRSndFileComplete ci ft + toView $ CRSndFileComplete user ci ft allowSendInline :: Integer -> Maybe InlineFileMode -> m Bool allowSendInline fileSize = \case @@ -2775,8 +2925,8 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = then do ft@RcvFileTransfer {cancelled} <- withStore (\db -> getRcvFileTransfer db user fileId) unless cancelled $ do - cancelRcvFileTransfer user ft - toView $ CRRcvFileSndCancelled ft + cancelRcvFileTransfer user ft >>= mapM_ (deleteAgentConnectionAsync user) + toView $ CRRcvFileSndCancelled user ft else messageError "x.file.cancel: group member attempted to cancel file of another member" -- shouldn't happen now that query includes group member id (SMDSnd, _) -> messageError "x.file.cancel: group member attempted invalid file cancel" @@ -2798,7 +2948,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = event <- withStore $ \db -> do ci <- updateDirectCIFileStatus db user fileId CIFSSndTransfer sft <- liftIO $ createSndGroupInlineFT db m conn ft - pure $ CRSndFileStart ci sft + pure $ CRSndFileStart user ci sft toView event ifM (allowSendInline fileSize fileInline) @@ -2810,7 +2960,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = groupMsgToView :: GroupInfo -> GroupMember -> ChatItem 'CTGroup 'MDRcv -> MsgMeta -> m () groupMsgToView gInfo m ci msgMeta = do checkIntegrityCreateItem (CDGroupRcv gInfo m) msgMeta - toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci + 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 @@ -2826,13 +2976,13 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = createMemberConnectionAsync db user hostId connIds updateGroupMemberStatusById db userId hostId GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted - toView $ CRUserAcceptedGroupSent gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) + toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) else do let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) - toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci - toView $ CRReceivedGroupInvitation gInfo ct memRole + toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) + toView $ CRReceivedGroupInvitation user gInfo ct memRole showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group" where sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool @@ -2844,7 +2994,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = MsgOk -> pure () MsgError e -> case e of MsgSkipped {} -> createInternalChatItem user cd (CIRcvIntegrityError e) (Just brokerTs) - _ -> toView $ CRMsgIntegrityError e + _ -> toView $ CRMsgIntegrityError user e xInfo :: Contact -> Profile -> m () xInfo c@Contact {profile = p} p' = unless (fromLocalProfile p == p') $ do @@ -2855,7 +3005,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = c' <- liftIO $ updateContactUserPreferences db user c ctUserPrefs' updateContactProfile db user c' p' when (directOrUsed c') $ createRcvFeatureItems user c c' - toView $ CRContactUpdated c c' + toView $ CRContactUpdated user c c' where Contact {userPreferences = ctUserPrefs@Preferences {timedMessages = ctUserTMPref}} = c userTTL = prefParam $ getPreference SCFTimedMessages ctUserPrefs @@ -2930,8 +3080,8 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withStore' $ \db -> createCall db user call' $ chatItemTs' ci call_ <- atomically (TM.lookupInsert contactId call' calls) forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing - toView . CRCallInvitation $ RcvCallInvitation {contact = ct, callType, sharedKey, callTs = chatItemTs' ci} - toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci + toView $ CRCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callTs = chatItemTs' ci} + toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) where saveCallItem status = saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvCall status 0) @@ -2944,7 +3094,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> localDhPrivKey) callState' = CallOfferReceived {localCallType, peerCallType = callType, peerCallSession = rtcSession, sharedKey} askConfirmation = encryptedCall localCallType && not (encryptedCall callType) - toView CRCallOffer {contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation} + toView CRCallOffer {user, contact = ct, callType, offer = rtcSession, sharedKey, askConfirmation} pure (Just call {callState = callState'}, Just . ACIContent SMDSnd $ CISndCall CISCallAccepted 0) _ -> do msgCallStateError "x.call.offer" call @@ -2957,7 +3107,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = \call -> case callState call of CallOfferSent {localCallType, peerCallType, localCallSession, sharedKey} -> do let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession = rtcSession, sharedKey} - toView $ CRCallAnswer ct rtcSession + toView $ CRCallAnswer user ct rtcSession pure (Just call {callState = callState'}, Just . ACIContent SMDRcv $ CIRcvCall CISCallNegotiated 0) _ -> do msgCallStateError "x.call.answer" call @@ -2971,12 +3121,12 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} -> do -- TODO update the list of ice servers in peerCallSession let callState' = CallOfferReceived {localCallType, peerCallType, peerCallSession, sharedKey} - toView $ CRCallExtraInfo ct rtcExtraInfo + toView $ CRCallExtraInfo user ct rtcExtraInfo pure (Just call {callState = callState'}, Nothing) CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} -> do -- TODO update the list of ice servers in peerCallSession let callState' = CallNegotiated {localCallType, peerCallType, localCallSession, peerCallSession, sharedKey} - toView $ CRCallExtraInfo ct rtcExtraInfo + toView $ CRCallExtraInfo user ct rtcExtraInfo pure (Just call {callState = callState'}, Nothing) _ -> do msgCallStateError "x.call.extra" call @@ -2986,7 +3136,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = xCallEnd :: Contact -> CallId -> RcvMessage -> MsgMeta -> m () xCallEnd ct callId msg msgMeta = msgCurrentCall ct callId "x.call.end" msg msgMeta $ \Call {chatItemId} -> do - toView $ CRCallEnded ct + toView $ CRCallEnded user ct (Nothing,) <$> callStatusItemContent user ct chatItemId WCSDisconnected msgCurrentCall :: Contact -> CallId -> Text -> RcvMessage -> MsgMeta -> (Call -> m (Maybe Call, Maybe ACIContent)) -> m () @@ -3016,7 +3166,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = mergeContacts :: Contact -> Contact -> m () mergeContacts to from = do withStore' $ \db -> mergeContactRecords db userId to from - toView $ CRContactsMerged to from + toView $ CRContactsMerged user to from saveConnInfo :: Connection -> ConnInfo -> m () saveConnInfo activeConn connInfo = do @@ -3024,7 +3174,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = case chatMsgEvent of XInfo p -> do ct <- withStore $ \db -> createDirectContact db user activeConn p - toView $ CRContactConnecting ct + toView $ CRContactConnecting user ct -- TODO show/log error, other events in SMP confirmation _ -> pure () @@ -3039,7 +3189,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = newMember@GroupMember {groupMemberId} <- withStore $ \db -> createNewGroupMember db user gInfo memInfo GCPostMember GSMemAnnounced ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent $ RGEMemberAdded groupMemberId memberProfile) groupMsgToView gInfo m ci msgMeta - toView $ CRJoinedGroupMemberConnecting gInfo m newMember + toView $ CRJoinedGroupMemberConnecting user gInfo m newMember xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> m () xGrpMemIntro gInfo@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ _) = do @@ -3074,7 +3224,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = Nothing -> messageError "x.grp.mem.inv error: referenced member does not exist" Just reMember -> do GroupMemberIntro {introId} <- withStore $ \db -> saveIntroInvitation db reMember m introInv - void . sendGroupMessage' [reMember] (XGrpMemFwd (memberInfo m) introInv) groupId (Just introId) $ + void . sendGroupMessage' user [reMember] (XGrpMemFwd (memberInfo m) introInv) groupId (Just introId) $ withStore' $ \db -> updateIntroStatus db introId GMIntroInvForwarded _ -> messageError "x.grp.mem.inv can be only sent by invitee member" @@ -3115,7 +3265,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withStore' $ \db -> updateGroupMemberRole db user member memRole ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent gEvent) groupMsgToView gInfo m ci msgMeta - toView CRMemberRole {groupInfo = gInfo', byMember = m, member = member {memberRole = memRole}, fromRole, toRole = memRole} + toView CRMemberRole {user, groupInfo = gInfo', byMember = m, member = member {memberRole = memRole}, fromRole, toRole = memRole} checkHostRole :: GroupMember -> GroupMemberRole -> m () checkHostRole GroupMember {memberRole, localDisplayName} memRole = @@ -3128,10 +3278,10 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = then checkRole membership $ do deleteGroupLink' user gInfo `catchError` \_ -> pure () -- member records are not deleted to keep history - forM_ members $ deleteMemberConnection user + deleteMembersConnections user members withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved deleteMemberItem RGEUserDeleted - toView $ CRDeletedMemberUser gInfo {membership = membership {memberStatus = GSMemRemoved}} m + toView $ CRDeletedMemberUser user gInfo {membership = membership {memberStatus = GSMemRemoved}} m else case find (sameMemberId memId) members of Nothing -> messageError "x.grp.mem.del with unknown member ID" Just member@GroupMember {groupMemberId, memberProfile} -> @@ -3140,7 +3290,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = -- undeleted "member connected" chat item will prevent deletion of member record deleteOrUpdateMemberRecord user member deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) - toView $ CRDeletedMember gInfo m member {memberStatus = GSMemRemoved} + toView $ CRDeletedMember user gInfo m member {memberStatus = GSMemRemoved} where checkRole GroupMember {memberRole} a | senderRole < GRAdmin || senderRole < memberRole = @@ -3160,7 +3310,7 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = withStore' $ \db -> updateGroupMemberStatus db userId m GSMemLeft ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent RGEMemberLeft) groupMsgToView gInfo m ci msgMeta - toView $ CRLeftMember gInfo m {memberStatus = GSMemLeft} + toView $ CRLeftMember user gInfo m {memberStatus = GSMemLeft} xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> MsgMeta -> m () xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg msgMeta = do @@ -3170,17 +3320,17 @@ processAgentMessage (Just user@User {userId}) corrId agentConnId agentMessage = updateGroupMemberStatus db userId membership GSMemGroupDeleted pure members -- member records are not deleted to keep history - forM_ ms $ deleteMemberConnection user + deleteMembersConnections user ms ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvGroupEvent RGEGroupDeleted) groupMsgToView gInfo m ci msgMeta - toView $ CRGroupDeleted gInfo {membership = membership {memberStatus = GSMemGroupDeleted}} m + toView $ CRGroupDeleted user gInfo {membership = membership {memberStatus = GSMemGroupDeleted}} m xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> MsgMeta -> m () xGrpInfo g@GroupInfo {groupProfile = p} m@GroupMember {memberRole} p' msg msgMeta | memberRole < GROwner = messageError "x.grp.info with insufficient member permissions" | otherwise = unless (p == p') $ do g' <- withStore $ \db -> updateGroupProfile db user g p' - toView . CRGroupUpdated g g' $ Just m + toView $ CRGroupUpdated user g g' (Just m) let cd = CDGroupRcv g' m unless (sameGroupProfileInfo p p') $ do ci <- saveRcvChatItem user cd msg msgMeta (CIRcvGroupEvent $ RGEGroupUpdated p') @@ -3216,7 +3366,7 @@ parseAChatMessage :: ChatMonad m => ByteString -> m AChatMessage parseAChatMessage = liftEither . first (ChatError . CEInvalidChatMessage) . strDecode sendFileChunk :: ChatMonad m => User -> SndFileTransfer -> m () -sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, connId, agentConnId} = +sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ withStore' (`createSndFileChunk` ft) >>= \case Just chunkNo -> sendFileChunkNo ft chunkNo @@ -3225,9 +3375,9 @@ sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, connId, agentConnId} liftIO $ updateSndFileStatus db ft FSComplete liftIO $ deleteSndFileChunks db ft updateDirectCIFileStatus db user fileId CIFSSndComplete - toView $ CRSndFileComplete ci ft + toView $ CRSndFileComplete user ci ft closeFileHandle fileId sndFiles - deleteAgentConnectionAsync' user connId agentConnId + deleteAgentConnectionAsync user acId sendFileChunkNo :: ChatMonad m => SndFileTransfer -> Integer -> m () sendFileChunkNo ft@SndFileTransfer {agentConnId = AgentConnId acId} chunkNo = do @@ -3283,33 +3433,39 @@ isFileActive fileId files = do fs <- asks files isJust . M.lookup fileId <$> readTVarIO fs -cancelRcvFileTransfer :: ChatMonad m => User -> RcvFileTransfer -> m () -cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, fileStatus, rcvFileInline} = do - closeFileHandle fileId rcvFiles - withStore' $ \db -> do - updateFileCancelled db user fileId CIFSRcvCancelled - updateRcvFileStatus db ft FSCancelled - deleteRcvFileChunks db ft - when (isNothing rcvFileInline) $ case fileStatus of - RFSAccepted RcvFileInfo {connId = Just connId, agentConnId = Just agentConnId} -> - deleteAgentConnectionAsync' user connId agentConnId - RFSConnected RcvFileInfo {connId = Just connId, agentConnId = Just agentConnId} -> - deleteAgentConnectionAsync' user connId agentConnId - _ -> pure () +cancelRcvFileTransfer :: ChatMonad m => User -> RcvFileTransfer -> m (Maybe ConnId) +cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, rcvFileInline} = + cancel' `catchError` (\e -> toView (CRChatError (Just user) e) >> pure fileConnId) + where + cancel' = do + closeFileHandle fileId rcvFiles + withStore' $ \db -> do + updateFileCancelled db user fileId CIFSRcvCancelled + updateRcvFileStatus db ft FSCancelled + deleteRcvFileChunks db ft + pure fileConnId + fileConnId = if isNothing rcvFileInline then liveRcvFileTransferConnId ft else Nothing -cancelSndFile :: ChatMonad m => User -> FileTransferMeta -> [SndFileTransfer] -> m () -cancelSndFile user FileTransferMeta {fileId} fts = do - withStore' $ \db -> updateFileCancelled db user fileId CIFSSndCancelled - forM_ fts $ \ft' -> cancelSndFileTransfer user ft' +cancelSndFile :: ChatMonad m => User -> FileTransferMeta -> [SndFileTransfer] -> Bool -> m [ConnId] +cancelSndFile user FileTransferMeta {fileId} fts sendCancel = do + withStore' (\db -> updateFileCancelled db user fileId CIFSSndCancelled) + `catchError` (toView . CRChatError (Just user)) + catMaybes <$> forM fts (\ft -> cancelSndFileTransfer user ft sendCancel) -cancelSndFileTransfer :: ChatMonad m => User -> SndFileTransfer -> m () -cancelSndFileTransfer user ft@SndFileTransfer {connId, agentConnId = agentConnId@(AgentConnId acId), fileStatus, fileInline} = - unless (fileStatus == FSCancelled || fileStatus == FSComplete) $ do - withStore' $ \db -> do - updateSndFileStatus db ft FSCancelled - deleteSndFileChunks db ft - withAgent $ \a -> void (sendMessage a acId SMP.noMsgFlags $ smpEncode FileChunkCancel) `catchError` \_ -> pure () - when (isNothing fileInline) $ deleteAgentConnectionAsync' user connId agentConnId +cancelSndFileTransfer :: ChatMonad m => User -> SndFileTransfer -> Bool -> m (Maybe ConnId) +cancelSndFileTransfer user ft@SndFileTransfer {agentConnId = AgentConnId acId, fileStatus, fileInline} sendCancel = + if fileStatus == FSCancelled || fileStatus == FSComplete + then pure Nothing + else cancel' `catchError` (\e -> toView (CRChatError (Just user) e) >> pure fileConnId) + where + cancel' = do + withStore' $ \db -> do + updateSndFileStatus db ft FSCancelled + deleteSndFileChunks db ft + when sendCancel $ + withAgent (\a -> void (sendMessage a acId SMP.noMsgFlags $ smpEncode FileChunkCancel)) + pure fileConnId + fileConnId = if isNothing fileInline then Just acId else Nothing closeFileHandle :: ChatMonad m => Int64 -> (ChatController -> TVar (Map Int64 Handle)) -> m () closeFileHandle fileId files = do @@ -3320,10 +3476,16 @@ closeFileHandle fileId files = do throwChatError :: ChatMonad m => ChatErrorType -> m a throwChatError = throwError . ChatError +deleteMembersConnections :: ChatMonad m => User -> [GroupMember] -> m () +deleteMembersConnections user members = do + let memberConns = mapMaybe (\GroupMember {activeConn} -> activeConn) members + deleteAgentConnectionsAsync user $ map aConnId memberConns + forM_ memberConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted + deleteMemberConnection :: ChatMonad m => User -> GroupMember -> m () deleteMemberConnection user GroupMember {activeConn} = do forM_ activeConn $ \conn -> do - deleteAgentConnectionAsync user conn `catchError` \_ -> pure () + deleteAgentConnectionAsync user $ aConnId conn withStore' $ \db -> updateConnectionStatus db conn ConnDeleted deleteOrUpdateMemberRecord :: ChatMonad m => User -> GroupMember -> m () @@ -3364,16 +3526,16 @@ deliverMessage conn@Connection {connId} cmEventTag msgBody msgId = do (Just $ "createSndMsgDelivery, sndMsgDelivery: " <> show sndMsgDelivery <> ", msgId: " <> show msgId <> ", cmEventTag: " <> show cmEventTag <> ", msgDeliveryStatus: MDSSndAgent") $ \db -> createSndMsgDelivery db sndMsgDelivery msgId -sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m SndMessage -sendGroupMessage GroupInfo {groupId} members chatMsgEvent = - sendGroupMessage' members chatMsgEvent groupId Nothing $ pure () +sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m SndMessage +sendGroupMessage user GroupInfo {groupId} members chatMsgEvent = + sendGroupMessage' user members chatMsgEvent groupId Nothing $ pure () -sendGroupMessage' :: (MsgEncodingI e, ChatMonad m) => [GroupMember] -> ChatMsgEvent e -> Int64 -> Maybe Int64 -> m () -> m SndMessage -sendGroupMessage' members chatMsgEvent groupId introId_ postDeliver = do +sendGroupMessage' :: (MsgEncodingI e, ChatMonad m) => User -> [GroupMember] -> ChatMsgEvent e -> Int64 -> Maybe Int64 -> m () -> m SndMessage +sendGroupMessage' user members chatMsgEvent groupId introId_ postDeliver = do msg <- createSndMessage chatMsgEvent (GroupId groupId) -- TODO collect failed deliveries into a single error forM_ (filter memberCurrent members) $ \m -> - messageMember m msg `catchError` (toView . CRChatError) + messageMember m msg `catchError` (toView . CRChatError (Just user)) pure msg where messageMember m@GroupMember {groupMemberId} SndMessage {msgId, msgBody} = case memberConn m of @@ -3385,12 +3547,12 @@ sendGroupMessage' members chatMsgEvent groupId introId_ postDeliver = do deliverMessage conn tag msgBody msgId >> postDeliver | otherwise -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ -sendPendingGroupMessages :: ChatMonad m => GroupMember -> Connection -> m () -sendPendingGroupMessages GroupMember {groupMemberId, localDisplayName} conn = do +sendPendingGroupMessages :: ChatMonad m => User -> GroupMember -> Connection -> m () +sendPendingGroupMessages user GroupMember {groupMemberId, localDisplayName} conn = do pendingMessages <- withStore' $ \db -> getPendingGroupMessages db groupMemberId -- TODO ensure order - pending messages interleave with user input messages forM_ pendingMessages $ \pgm -> - processPendingMessage pgm `catchError` (toView . CRChatError) + processPendingMessage pgm `catchError` (toView . CRChatError (Just user)) where processPendingMessage PendingGroupMessage {msgId, cmEventTag = ACMEventTag _ tag, msgBody, introId_} = do void $ deliverMessage conn tag msgBody msgId @@ -3450,40 +3612,41 @@ deleteDirectCI :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> Bool deleteDirectCI user ct ci@(CChatItem msgDir deletedItem@ChatItem {file}) byUser timed = do deleteCIFile user file withStore' $ \db -> deleteDirectChatItem db user ct ci - pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) Nothing byUser timed + pure $ CRChatItemDeleted user (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) Nothing byUser timed deleteGroupCI :: ChatMonad m => User -> GroupInfo -> CChatItem 'CTGroup -> Bool -> Bool -> m ChatResponse deleteGroupCI user gInfo ci@(CChatItem msgDir deletedItem@ChatItem {file}) byUser timed = do deleteCIFile user file withStore' $ \db -> deleteGroupChatItem db user gInfo ci - pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) Nothing byUser timed + pure $ CRChatItemDeleted user (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) Nothing byUser timed deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () deleteCIFile user file = forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do let fileInfo = CIFileInfo {fileId, fileStatus = Just $ AFS msgDirection fileStatus, filePath} - deleteFile user fileInfo + fileAgentConnIds <- deleteFile' user fileInfo True + deleteAgentConnectionsAsync user fileAgentConnIds markDirectCIDeleted :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> MessageId -> Bool -> m ChatResponse markDirectCIDeleted user ct ci@(CChatItem msgDir deletedItem) msgId byUser = do toCi <- withStore' $ \db -> markDirectChatItemDeleted db user ct ci msgId - pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) (Just toCi) byUser False + pure $ CRChatItemDeleted user (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) (Just toCi) byUser False markGroupCIDeleted :: ChatMonad m => User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Bool -> m ChatResponse markGroupCIDeleted user gInfo ci@(CChatItem msgDir deletedItem) msgId byUser = do toCi <- withStore' $ \db -> markGroupChatItemDeleted db user gInfo ci msgId - pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) (Just toCi) byUser False + pure $ CRChatItemDeleted user (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) (Just toCi) byUser False createAgentConnectionAsync :: forall m c. (ChatMonad m, ConnectionModeI c) => User -> CommandFunction -> Bool -> SConnectionMode c -> m (CommandId, ConnId) createAgentConnectionAsync user cmdFunction enableNtfs cMode = do cmdId <- withStore' $ \db -> createCommand db user Nothing cmdFunction - connId <- withAgent $ \a -> createConnectionAsync a (aCorrId cmdId) enableNtfs cMode + connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode pure (cmdId, connId) joinAgentConnectionAsync :: ChatMonad m => User -> Bool -> ConnectionRequestUri c -> ConnInfo -> m (CommandId, ConnId) joinAgentConnectionAsync user enableNtfs cReqUri cInfo = do cmdId <- withStore' $ \db -> createCommand db user Nothing CFJoinConn - connId <- withAgent $ \a -> joinConnectionAsync a (aCorrId cmdId) enableNtfs cReqUri cInfo + connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo pure (cmdId, connId) allowAgentConnectionAsync :: (MsgEncodingI e, ChatMonad m) => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> m () @@ -3498,14 +3661,14 @@ agentAcceptContactAsync user enableNtfs invId msg = do connId <- withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) enableNtfs invId $ directMessage msg pure (cmdId, connId) -deleteAgentConnectionAsync :: ChatMonad m => User -> Connection -> m () -deleteAgentConnectionAsync user Connection {agentConnId, connId} = - deleteAgentConnectionAsync' user connId agentConnId +deleteAgentConnectionAsync :: ChatMonad m => User -> ConnId -> m () +deleteAgentConnectionAsync user acId = + withAgent (`deleteConnectionAsync` acId) `catchError` (toView . CRChatError (Just user)) -deleteAgentConnectionAsync' :: ChatMonad m => User -> Int64 -> AgentConnId -> m () -deleteAgentConnectionAsync' user connId (AgentConnId acId) = do - cmdId <- withStore' $ \db -> createCommand db user (Just connId) CFDeleteConn - withAgent $ \a -> deleteConnectionAsync a (aCorrId cmdId) acId +deleteAgentConnectionsAsync :: ChatMonad m => User -> [ConnId] -> m () +deleteAgentConnectionsAsync _ [] = pure () +deleteAgentConnectionsAsync user acIds = + withAgent (`deleteConnectionsAsync` acIds) `catchError` (toView . CRChatError (Just user)) userProfileToSend :: User -> Maybe Profile -> Maybe Contact -> Profile userProfileToSend user@User {profile = p} incognitoProfile ct = @@ -3575,7 +3738,7 @@ createInternalChatItem user cd content itemTs_ = do when (ciRequiresAttention content) $ updateChatTs db user cd createdAt createNewChatItemNoMsg db user cd content itemTs createdAt ci <- liftIO $ mkChatItem cd ciId content Nothing Nothing Nothing Nothing False itemTs createdAt - toView $ CRNewChatItem $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci + toView $ CRNewChatItem user (AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci) getCreateActiveUser :: SQLiteStore -> IO User getCreateActiveUser st = do @@ -3598,7 +3761,7 @@ getCreateActiveUser st = do loop = do displayName <- getContactName fullName <- T.pack <$> getWithPrompt "full name (optional)" - withTransaction st (\db -> runExceptT $ createUser db Profile {displayName, fullName, image = Nothing, preferences = Nothing} True) >>= \case + withTransaction st (\db -> runExceptT $ createUserRecord db (AgentUserId 1) Profile {displayName, fullName, image = Nothing, preferences = Nothing} True) >>= \case Left SEDuplicateName -> do putStrLn "chosen display name is already used by another profile on this device, choose another one" loop @@ -3606,7 +3769,7 @@ getCreateActiveUser st = do Right user -> pure user selectUser :: [User] -> IO User selectUser [user] = do - withTransaction st (`setActiveUser` userId user) + withTransaction st (`setActiveUser` userId (user :: User)) pure user selectUser users = do putStrLn "Select user profile:" @@ -3621,7 +3784,7 @@ getCreateActiveUser st = do | n <= 0 || n > length users -> putStrLn "invalid user number" >> loop | otherwise -> do let user = users !! (n - 1) - withTransaction st (`setActiveUser` userId user) + withTransaction st (`setActiveUser` userId (user :: User)) pure user userStr :: User -> String userStr User {localDisplayName, profile = LocalProfile {fullName}} = @@ -3650,16 +3813,26 @@ notificationSubscriber = do ChatController {notifyQ, sendNotification} <- ask forever $ atomically (readTBQueue notifyQ) >>= liftIO . sendNotification -withUser' :: ChatMonad m => (User -> m a) -> m a +withUser' :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser' action = asks currentUser >>= readTVarIO - >>= maybe (throwChatError CENoActiveUser) action + >>= maybe (throwChatError CENoActiveUser) run + where + run u = action u `catchError` (pure . CRChatCmdError (Just u)) -withUser :: ChatMonad m => (User -> m a) -> m a +withUser :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser action = withUser' $ \user -> ifM chatStarted (action user) (throwChatError CEChatNotStarted) +withUserId :: ChatMonad m => UserId -> (User -> m ChatResponse) -> m ChatResponse +withUserId userId action = withUser $ \user -> do + checkSameUser userId user + action user + +checkSameUser :: ChatMonad m => UserId -> User -> m () +checkSameUser userId User {userId = activeUserId} = when (userId /= activeUserId) $ throwChatError (CEDifferentActiveUser userId activeUserId) + chatStarted :: ChatMonad m => m Bool chatStarted = fmap isJust . readTVarIO =<< asks agentAsync @@ -3697,7 +3870,17 @@ chatCommandP = choice [ "/mute " *> ((`ShowMessages` False) <$> chatNameP'), "/unmute " *> ((`ShowMessages` True) <$> chatNameP'), - ("/user " <|> "/u ") *> (CreateActiveUser <$> userProfile), + "/create user" + *> ( do + sameSmp <- (A.space *> "same_smp=" *> onOffP) <|> pure False + uProfile <- A.space *> userProfile + 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" <|> "/u") $> ShowActiveUser, "/_start subscribe=" *> (StartChat <$> onOffP <* " expire=" <*> onOffP), "/_start" $> StartChat True True, @@ -3715,7 +3898,7 @@ chatCommandP = "/db decrypt " *> (APIStorageEncryption . (`DBEncryptionConfig` "") <$> dbKeyP), "/sql chat " *> (ExecChatStoreSQL <$> textP), "/sql agent " *> (ExecAgentStoreSQL <$> textP), - "/_get chats" *> (APIGetChats <$> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)), + "/_get chats " *> (APIGetChats <$> A.decimal <*> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)), "/_get chat " *> (APIGetChat <$> chatRefP <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)), "/_get items count=" *> (APIGetChatItems <$> A.decimal), "/_send " *> (APISendMessage <$> chatRefP <*> liveMessageP <*> (" json " *> jsonP <|> " text " *> (ComposedMessage Nothing Nothing <$> mcTextP))), @@ -3736,7 +3919,7 @@ chatCommandP = "/_call end @" *> (APIEndCall <$> A.decimal), "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP), "/_call get" $> APIGetCallInvitations, - "/_profile " *> (APIUpdateProfile <$> jsonP), + "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set prefs @" *> (APISetContactPrefs <$> A.decimal <* A.space <*> jsonP), @@ -3757,13 +3940,15 @@ chatCommandP = "/smp_servers " *> (SetUserSMPServers . SMPServersConfig . map toServerCfg <$> smpServersP), "/smp_servers" $> GetUserSMPServers, "/smp default" $> SetUserSMPServers (SMPServersConfig []), - "/smp test " *> (TestSMPServer <$> strP), - "/_smp " *> (SetUserSMPServers <$> jsonP), + "/smp test " *> (TestSMPServer <$> A.decimal <* A.space <*> strP), + "/_smp " *> (APISetUserSMPServers <$> A.decimal <* A.space <*> jsonP), "/smp " *> (SetUserSMPServers . SMPServersConfig . map toServerCfg <$> smpServersP), + "/_smp " *> (APIGetUserSMPServers <$> A.decimal), "/smp" $> GetUserSMPServers, - "/_ttl " *> (APISetChatItemTTL <$> ciTTLDecimal), - "/ttl " *> (APISetChatItemTTL <$> ciTTL), - "/ttl" $> APIGetChatItemTTL, + "/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> ciTTLDecimal), + "/ttl " *> (SetChatItemTTL <$> ciTTL), + "/_ttl " *> (APIGetChatItemTTL <$> A.decimal), + "/ttl" $> GetChatItemTTL, "/_network " *> (APISetNetworkConfig <$> jsonP), ("/network " <|> "/net ") *> (APISetNetworkConfig <$> netCfgP), ("/network" <|> "/net") $> APIGetNetworkConfig, @@ -3795,7 +3980,7 @@ chatCommandP = ("/help settings" <|> "/hs") $> ChatHelp HSSettings, ("/help" <|> "/h") $> ChatHelp HSMain, ("/group " <|> "/g ") *> char_ '#' *> (NewGroup <$> groupProfile), - "/_group " *> (NewGroup <$> jsonP), + "/_group " *> (APINewGroup <$> A.decimal <* A.space <*> jsonP), ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole), ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName), ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole), @@ -3819,7 +4004,10 @@ chatCommandP = "/show link #" *> (ShowGroupLink <$> displayName), (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> A.takeByteString), (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* char_ '@' <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> A.takeByteString), + "/_contacts " *> (APIListContacts <$> A.decimal), "/contacts" $> ListContacts, + "/_connect " *> (APIConnect <$> A.decimal <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), + "/_connect " *> (APIAddContact <$> A.decimal), ("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)), ("/connect" <|> "/c") $> AddContact, SendMessage <$> chatNameP <* A.space <*> A.takeByteString, @@ -3843,9 +4031,13 @@ chatCommandP = ("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal), ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal), "/simplex" $> ConnectSimplex, + "/_address " *> (APICreateMyAddress <$> A.decimal), ("/address" <|> "/ad") $> CreateMyAddress, + "/_delete_address " *> (APIDeleteMyAddress <$> A.decimal), ("/delete_address" <|> "/da") $> DeleteMyAddress, + "/_show_address " *> (APIShowMyAddress <$> A.decimal), ("/show_address" <|> "/sa") $> ShowMyAddress, + "/_auto_accept " *> (APIAddressAutoAccept <$> A.decimal <* A.space <*> autoAcceptP), "/auto_accept " *> (AddressAutoAccept <$> autoAcceptP), ("/accept " <|> "/ac ") *> char_ '@' *> (AcceptContact <$> displayName), ("/reject " <|> "/rc ") *> char_ '@' *> (RejectContact <$> displayName), diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 9368b36450..e930bfb7c4 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -24,10 +24,10 @@ chatBotRepl welcome answer _user cc = do race_ (forever $ void getLine) . forever $ do (_, resp) <- atomically . readTBQueue $ outputQ cc case resp of - CRContactConnected contact _ -> do + CRContactConnected _ contact _ -> do contactConnected contact void $ sendMsg contact welcome - CRNewChatItem (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do + CRNewChatItem _ (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content}) -> do let msg = T.unpack $ ciContentToText content void . sendMsg contact $ answer msg _ -> pure () @@ -38,11 +38,11 @@ chatBotRepl welcome answer _user cc = do initializeBotAddress :: ChatController -> IO () initializeBotAddress cc = do sendChatCmd cc "/show_address" >>= \case - CRUserContactLink UserContactLink {connReqContact} -> showBotAddress connReqContact - CRChatCmdError (ChatErrorStore SEUserContactLinkNotFound) -> do + CRUserContactLink _ UserContactLink {connReqContact} -> showBotAddress connReqContact + CRChatCmdError _ (ChatErrorStore SEUserContactLinkNotFound) -> do putStrLn "No bot address, creating..." sendChatCmd cc "/address" >>= \case - CRUserContactLinkCreated uri -> showBotAddress uri + CRUserContactLinkCreated _ uri -> showBotAddress uri _ -> putStrLn "can't create bot address" >> exitFailure _ -> putStrLn "unexpected response" >> exitFailure where diff --git a/src/Simplex/Chat/Call.hs b/src/Simplex/Chat/Call.hs index 8c4379d0ee..c56ec68cb2 100644 --- a/src/Simplex/Chat/Call.hs +++ b/src/Simplex/Chat/Call.hs @@ -21,7 +21,7 @@ import Data.Time.Clock (UTCTime) import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) -import Simplex.Chat.Types (Contact, ContactId, decodeJSON, encodeJSON) +import Simplex.Chat.Types (Contact, ContactId, decodeJSON, encodeJSON, User) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fromTextField_, fstToLower, singleFieldJSON) @@ -125,7 +125,8 @@ instance FromField CallId where fromField f = CallId <$> fromField f instance ToField CallId where toField (CallId m) = toField m data RcvCallInvitation = RcvCallInvitation - { contact :: Contact, + { user :: User, + contact :: Contact, callType :: CallType, sharedKey :: Maybe C.Key, callTs :: UTCTime diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 96ff99b9b7..c48de9332b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -45,7 +45,7 @@ import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink) import Simplex.Chat.Types import Simplex.Messaging.Agent (AgentClient) import Simplex.Messaging.Agent.Client (AgentLocks, SMPTestFailure) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, InitialAgentServers, NetworkConfig) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore) @@ -53,7 +53,7 @@ import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Notifications.Protocol (DeviceToken (..), NtfTknStatus) import Simplex.Messaging.Parsers (dropPrefix, enumJSON, parseAll, parseString, sumTypeJSON) -import Simplex.Messaging.Protocol (AProtocolType, CorrId, MsgFlags) +import Simplex.Messaging.Protocol (AProtocolType, CorrId, MsgFlags, NtfServer, QueueId) import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (simplexMQVersion) import Simplex.Messaging.Transport.Client (TransportHost) @@ -101,7 +101,7 @@ coreVersionInfo buildTimestamp simplexmqCommit = data ChatConfig = ChatConfig { agentConfig :: AgentConfig, yesToMigrations :: Bool, - defaultServers :: InitialAgentServers, + defaultServers :: DefaultAgentServers, tbqSize :: Natural, fileChunkSize :: Integer, inlineFiles :: InlineFilesConfig, @@ -109,7 +109,14 @@ data ChatConfig = ChatConfig subscriptionEvents :: Bool, hostEvents :: Bool, logLevel :: ChatLogLevel, - testView :: Bool + testView :: Bool, + ciExpirationInterval :: Int -- microseconds + } + +data DefaultAgentServers = DefaultAgentServers + { smp :: NonEmpty SMPServerWithAuth, + ntf :: [NtfServer], + netCfg :: NetworkConfig } data InlineFilesConfig = InlineFilesConfig @@ -155,8 +162,8 @@ data ChatController = ChatController config :: ChatConfig, filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, incognitoMode :: TVar Bool, - expireCIsAsync :: TVar (Maybe (Async ())), - expireCIs :: TVar Bool, + expireCIThreads :: TMap UserId (Maybe (Async ())), + expireCIFlags :: TMap UserId Bool, cleanupManagerAsync :: TVar (Maybe (Async ())), timedItemThreads :: TMap (ChatRef, ChatItemId) (TVar (Maybe (Weak ThreadId))), showLiveItems :: TVar Bool @@ -171,7 +178,12 @@ instance ToJSON HelpSection where data ChatCommand = ShowActiveUser - | CreateActiveUser Profile + | CreateActiveUser Profile Bool + | ListUsers + | APISetActiveUser UserId + | SetActiveUser UserName + | APIDeleteUser UserId Bool + | DeleteUser UserName Bool | StartChat {subscribeConnections :: Bool, enableExpireChatItems :: Bool} | APIStopChat | APIActivateChat @@ -185,7 +197,7 @@ data ChatCommand | APIStorageEncryption DBEncryptionConfig | ExecChatStoreSQL Text | ExecAgentStoreSQL Text - | APIGetChats {pendingConnections :: Bool} + | APIGetChats {userId :: UserId, pendingConnections :: Bool} | APIGetChat ChatRef ChatPagination (Maybe String) | APIGetChatItems Int | APISendMessage {chatRef :: ChatRef, liveMessage :: Bool, composedMessage :: ComposedMessage} @@ -206,7 +218,7 @@ data ChatCommand | APIEndCall ContactId | APIGetCallInvitations | APICallStatus ContactId WebRTCCallStatus - | APIUpdateProfile Profile + | APIUpdateProfile UserId Profile | APISetContactPrefs ContactId Preferences | APISetContactAlias ContactId LocalAlias | APISetConnectionAlias Int64 LocalAlias @@ -226,11 +238,15 @@ data ChatCommand | APICreateGroupLink GroupId | APIDeleteGroupLink GroupId | APIGetGroupLink GroupId + | APIGetUserSMPServers UserId | GetUserSMPServers + | APISetUserSMPServers UserId SMPServersConfig | SetUserSMPServers SMPServersConfig - | TestSMPServer SMPServerWithAuth - | APISetChatItemTTL (Maybe Int64) - | APIGetChatItemTTL + | TestSMPServer UserId SMPServerWithAuth + | APISetChatItemTTL UserId (Maybe Int64) + | SetChatItemTTL (Maybe Int64) + | APIGetChatItemTTL UserId + | GetChatItemTTL | APISetNetworkConfig NetworkConfig | APIGetNetworkConfig | APISetChatSettings ChatRef ChatSettings @@ -257,25 +273,33 @@ data ChatCommand | EnableGroupMember GroupName ContactName | ChatHelp HelpSection | Welcome + | APIAddContact UserId | AddContact + | APIConnect UserId (Maybe AConnectionRequestUri) | Connect (Maybe AConnectionRequestUri) - | ConnectSimplex + | ConnectSimplex -- UserId (not used in UI) | DeleteContact ContactName | ClearContact ContactName + | APIListContacts UserId | ListContacts + | APICreateMyAddress UserId | CreateMyAddress + | APIDeleteMyAddress UserId | DeleteMyAddress + | APIShowMyAddress UserId | ShowMyAddress + | APIAddressAutoAccept UserId (Maybe AutoAccept) | AddressAutoAccept (Maybe AutoAccept) | AcceptContact ContactName | RejectContact ContactName | SendMessage ChatName ByteString | SendLiveMessage ChatName ByteString | SendMessageQuote {contactName :: ContactName, msgDir :: AMsgDirection, quotedMsg :: ByteString, message :: ByteString} - | SendMessageBroadcast ByteString + | SendMessageBroadcast ByteString -- UserId (not used in UI) | DeleteMessage ChatName ByteString | EditMessage {chatName :: ChatName, editedMsg :: ByteString, message :: ByteString} | UpdateLiveMessage {chatName :: ChatName, chatItemId :: ChatItemId, liveMessage :: Bool, message :: ByteString} + | APINewGroup UserId GroupProfile | NewGroup GroupProfile | AddMember GroupName ContactName GroupMemberRole | JoinGroup GroupName @@ -285,7 +309,7 @@ data ChatCommand | DeleteGroup GroupName | ClearGroup GroupName | ListMembers GroupName - | ListGroups + | ListGroups -- UserId (not used in UI) | UpdateGroupNames GroupName GroupProfile | ShowGroupProfile GroupName | UpdateGroupDescription GroupName (Maybe Text) @@ -293,10 +317,10 @@ data ChatCommand | DeleteGroupLink GroupName | ShowGroupLink GroupName | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString} - | LastChats (Maybe Int) - | LastMessages (Maybe ChatName) Int (Maybe String) - | LastChatItemId (Maybe ChatName) Int - | ShowChatItem (Maybe ChatItemId) + | LastChats (Maybe Int) -- UserId (not used in UI) + | LastMessages (Maybe ChatName) Int (Maybe String) -- UserId (not used in UI) + | LastChatItemId (Maybe ChatName) Int -- UserId (not used in UI) + | ShowChatItem (Maybe ChatItemId) -- UserId (not used in UI) | ShowLiveItems Bool | SendFile ChatName FilePath | SendImage ChatName FilePath @@ -305,13 +329,13 @@ data ChatCommand | ReceiveFile {fileId :: FileTransferId, fileInline :: Maybe Bool, filePath :: Maybe FilePath} | CancelFile FileTransferId | FileStatus FileTransferId - | ShowProfile - | UpdateProfile ContactName Text - | UpdateProfileImage (Maybe ImageData) - | SetUserFeature AChatFeature FeatureAllowed + | ShowProfile -- UserId (not used in UI) + | UpdateProfile ContactName Text -- UserId (not used in UI) + | UpdateProfileImage (Maybe ImageData) -- UserId (not used in UI) + | SetUserFeature AChatFeature FeatureAllowed -- UserId (not used in UI) | SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed) | SetGroupFeature AGroupFeature GroupName GroupFeatureEnabled - | SetUserTimedMessages Bool + | SetUserTimedMessages Bool -- UserId (not used in UI) | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) | QuitChat @@ -323,145 +347,161 @@ data ChatCommand data ChatResponse = CRActiveUser {user :: User} + | CRUsersList {users :: [UserInfo]} | CRChatStarted | CRChatRunning | CRChatStopped | CRChatSuspended - | CRApiChats {chats :: [AChat]} + | CRApiChats {user :: User, chats :: [AChat]} | CRChats {chats :: [AChat]} - | CRApiChat {chat :: AChat} - | CRChatItems {chatItems :: [AChatItem]} - | CRChatItemId (Maybe ChatItemId) + | CRApiChat {user :: User, chat :: AChat} + | CRChatItems {user :: User, chatItems :: [AChatItem]} + | CRChatItemId User (Maybe ChatItemId) | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} - | CRUserSMPServers {smpServers :: NonEmpty ServerCfg, presetSMPServers :: NonEmpty SMPServerWithAuth} - | CRSmpTestResult {smpTestFailure :: Maybe SMPTestFailure} - | CRChatItemTTL {chatItemTTL :: Maybe Int64} + | CRUserSMPServers {user :: User, smpServers :: NonEmpty ServerCfg, presetSMPServers :: NonEmpty SMPServerWithAuth} + | CRSmpTestResult {user :: User, smpTestFailure :: Maybe SMPTestFailure} + | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64} | CRNetworkConfig {networkConfig :: NetworkConfig} - | CRContactInfo {contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile} - | CRGroupMemberInfo {groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats} - | CRContactSwitch {contact :: Contact, switchProgress :: SwitchProgress} - | CRGroupMemberSwitch {groupInfo :: GroupInfo, member :: GroupMember, switchProgress :: SwitchProgress} - | CRContactCode {contact :: Contact, connectionCode :: Text} - | CRGroupMemberCode {groupInfo :: GroupInfo, member :: GroupMember, connectionCode :: Text} - | CRConnectionVerified {verified :: Bool, expectedCode :: Text} - | CRNewChatItem {chatItem :: AChatItem} - | CRChatItemStatusUpdated {chatItem :: AChatItem} - | CRChatItemUpdated {chatItem :: AChatItem} - | CRChatItemDeleted {deletedChatItem :: AChatItem, toChatItem :: Maybe AChatItem, byUser :: Bool, timed :: Bool} - | CRChatItemDeletedNotFound {contact :: Contact, sharedMsgId :: SharedMsgId} - | CRBroadcastSent MsgContent Int ZonedTime - | CRMsgIntegrityError {msgError :: MsgErrorType} + | CRContactInfo {user :: User, contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile} + | CRGroupMemberInfo {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats} + | CRContactSwitch {user :: User, contact :: Contact, switchProgress :: SwitchProgress} + | CRGroupMemberSwitch {user :: User, groupInfo :: GroupInfo, member :: GroupMember, switchProgress :: SwitchProgress} + | CRContactCode {user :: User, contact :: Contact, connectionCode :: Text} + | CRGroupMemberCode {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionCode :: Text} + | CRConnectionVerified {user :: User, verified :: Bool, expectedCode :: Text} + | CRNewChatItem {user :: User, chatItem :: AChatItem} + | CRChatItemStatusUpdated {user :: User, chatItem :: AChatItem} + | CRChatItemUpdated {user :: User, chatItem :: AChatItem} + | CRChatItemDeleted {user :: User, deletedChatItem :: AChatItem, toChatItem :: Maybe AChatItem, byUser :: Bool, timed :: Bool} + | CRChatItemDeletedNotFound {user :: User, contact :: Contact, sharedMsgId :: SharedMsgId} + | CRBroadcastSent User MsgContent Int ZonedTime + | CRMsgIntegrityError {user :: User, msgError :: MsgErrorType} | CRCmdAccepted {corr :: CorrId} - | CRCmdOk + | CRCmdOk {user_ :: Maybe User} | CRChatHelp {helpSection :: HelpSection} | CRWelcome {user :: User} - | CRGroupCreated {groupInfo :: GroupInfo} - | CRGroupMembers {group :: Group} - | CRContactsList {contacts :: [Contact]} - | CRUserContactLink {contactLink :: UserContactLink} - | CRUserContactLinkUpdated {contactLink :: UserContactLink} - | CRContactRequestRejected {contactRequest :: UserContactRequest} - | CRUserAcceptedGroupSent {groupInfo :: GroupInfo, hostContact :: Maybe Contact} - | CRUserDeletedMember {groupInfo :: GroupInfo, member :: GroupMember} - | CRGroupsList {groups :: [GroupInfo]} - | CRSentGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} - | CRFileTransferStatus (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus - | CRUserProfile {profile :: Profile} - | CRUserProfileNoChange + | CRGroupCreated {user :: User, groupInfo :: GroupInfo} + | CRGroupMembers {user :: User, group :: Group} + | CRContactsList {user :: User, contacts :: [Contact]} + | CRUserContactLink {user :: User, contactLink :: UserContactLink} + | CRUserContactLinkUpdated {user :: User, contactLink :: UserContactLink} + | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest} + | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} + | CRUserDeletedMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRGroupsList {user :: User, groups :: [GroupInfo]} + | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} + | CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus + | CRUserProfile {user :: User, profile :: Profile} + | CRUserProfileNoChange {user :: User} | CRVersionInfo {versionInfo :: CoreVersionInfo} - | CRInvitation {connReqInvitation :: ConnReqInvitation} - | CRSentConfirmation - | CRSentInvitation {customUserProfile :: Maybe Profile} - | CRContactUpdated {fromContact :: Contact, toContact :: Contact} - | CRContactsMerged {intoContact :: Contact, mergedContact :: Contact} - | CRContactDeleted {contact :: Contact} - | CRChatCleared {chatInfo :: AChatInfo} - | CRUserContactLinkCreated {connReqContact :: ConnReqContact} - | CRUserContactLinkDeleted - | CRReceivedContactRequest {contactRequest :: UserContactRequest} - | CRAcceptingContactRequest {contact :: Contact} - | CRContactAlreadyExists {contact :: Contact} - | CRContactRequestAlreadyAccepted {contact :: Contact} - | CRLeftMemberUser {groupInfo :: GroupInfo} - | CRGroupDeletedUser {groupInfo :: GroupInfo} - | CRRcvFileAccepted {chatItem :: AChatItem} - | CRRcvFileAcceptedSndCancelled {rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileStart {chatItem :: AChatItem} - | CRRcvFileComplete {chatItem :: AChatItem} - | CRRcvFileCancelled {rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileSndCancelled {rcvFileTransfer :: RcvFileTransfer} - | CRSndFileStart {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} - | CRSndFileComplete {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation} + | CRSentConfirmation {user :: User} + | CRSentInvitation {user :: User, customUserProfile :: Maybe Profile} + | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} + | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact} + | CRContactDeleted {user :: User, contact :: Contact} + | CRChatCleared {user :: User, chatInfo :: AChatInfo} + | CRUserContactLinkCreated {user :: User, connReqContact :: ConnReqContact} + | CRUserContactLinkDeleted {user :: User} + | CRReceivedContactRequest {user :: User, contactRequest :: UserContactRequest} + | CRAcceptingContactRequest {user :: User, contact :: Contact} + | CRContactAlreadyExists {user :: User, contact :: Contact} + | CRContactRequestAlreadyAccepted {user :: User, contact :: Contact} + | CRLeftMemberUser {user :: User, groupInfo :: GroupInfo} + | CRGroupDeletedUser {user :: User, groupInfo :: GroupInfo} + | CRRcvFileAccepted {user :: User, chatItem :: AChatItem} + | CRRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileStart {user :: User, chatItem :: AChatItem} + | CRRcvFileComplete {user :: User, chatItem :: AChatItem} + | CRRcvFileCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer} + | CRSndFileStart {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CRSndFileComplete {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} | CRSndFileCancelled {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} - | CRSndFileRcvCancelled {chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} - | CRSndGroupFileCancelled {chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} - | CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile} - | CRContactAliasUpdated {toContact :: Contact} - | CRConnectionAliasUpdated {toConnection :: PendingContactConnection} - | CRContactPrefsUpdated {fromContact :: Contact, toContact :: Contact} - | CRContactConnecting {contact :: Contact} - | CRContactConnected {contact :: Contact, userCustomProfile :: Maybe Profile} - | CRContactAnotherClient {contact :: Contact} - | CRSubscriptionEnd {connectionEntity :: ConnectionEntity} + | CRSndFileRcvCancelled {user :: User, chatItem :: AChatItem, sndFileTransfer :: SndFileTransfer} + | CRSndGroupFileCancelled {user :: User, chatItem :: AChatItem, fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} + | CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile} + | CRContactAliasUpdated {user :: User, toContact :: Contact} + | CRConnectionAliasUpdated {user :: User, toConnection :: PendingContactConnection} + | CRContactPrefsUpdated {user :: User, fromContact :: Contact, toContact :: Contact} + | CRContactConnecting {user :: User, contact :: Contact} + | CRContactConnected {user :: User, contact :: Contact, userCustomProfile :: Maybe Profile} + | CRContactAnotherClient {user :: User, contact :: Contact} + | CRSubscriptionEnd {user :: User, connectionEntity :: ConnectionEntity} | CRContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]} | CRContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]} - | CRContactSubError {contact :: Contact, chatError :: ChatError} - | CRContactSubSummary {contactSubscriptions :: [ContactSubStatus]} - | CRUserContactSubSummary {userContactSubscriptions :: [UserContactSubStatus]} + | CRContactSubError {contact :: Contact, chatError :: ChatError} -- TODO delete + | CRContactSubSummary {user :: User, contactSubscriptions :: [ContactSubStatus]} + | CRUserContactSubSummary {user :: User, userContactSubscriptions :: [UserContactSubStatus]} | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} - | CRGroupInvitation {groupInfo :: GroupInfo} - | CRReceivedGroupInvitation {groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole} - | CRUserJoinedGroup {groupInfo :: GroupInfo, hostMember :: GroupMember} - | CRJoinedGroupMember {groupInfo :: GroupInfo, member :: GroupMember} - | CRJoinedGroupMemberConnecting {groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} - | CRMemberRole {groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} - | CRMemberRoleUser {groupInfo :: GroupInfo, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} - | CRConnectedToGroupMember {groupInfo :: GroupInfo, member :: GroupMember} - | CRDeletedMember {groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember} - | CRDeletedMemberUser {groupInfo :: GroupInfo, member :: GroupMember} - | CRLeftMember {groupInfo :: GroupInfo, member :: GroupMember} - | CRGroupEmpty {groupInfo :: GroupInfo} - | CRGroupRemoved {groupInfo :: GroupInfo} - | CRGroupDeleted {groupInfo :: GroupInfo, member :: GroupMember} - | CRGroupUpdated {fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} - | CRGroupProfile {groupInfo :: GroupInfo} - | CRGroupLinkCreated {groupInfo :: GroupInfo, connReqContact :: ConnReqContact} - | CRGroupLink {groupInfo :: GroupInfo, connReqContact :: ConnReqContact} - | CRGroupLinkDeleted {groupInfo :: GroupInfo} - | CRAcceptingGroupJoinRequest {groupInfo :: GroupInfo, contact :: Contact} - | CRMemberSubError {groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError} - | CRMemberSubSummary {memberSubscriptions :: [MemberSubStatus]} - | CRGroupSubscribed {groupInfo :: GroupInfo} - | CRPendingSubSummary {pendingSubscriptions :: [PendingSubStatus]} - | CRSndFileSubError {sndFileTransfer :: SndFileTransfer, chatError :: ChatError} - | CRRcvFileSubError {rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} + | CRGroupInvitation {user :: User, groupInfo :: GroupInfo} + | CRReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole} + | CRUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} + | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} + | CRMemberRole {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} + | CRMemberRoleUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember, fromRole :: GroupMemberRole, toRole :: GroupMemberRole} + | CRConnectedToGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRDeletedMember {user :: User, groupInfo :: GroupInfo, byMember :: GroupMember, deletedMember :: GroupMember} + | CRDeletedMemberUser {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRLeftMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRGroupEmpty {user :: User, groupInfo :: GroupInfo} + | CRGroupRemoved {user :: User, groupInfo :: GroupInfo} + | CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} + | CRGroupProfile {user :: User, groupInfo :: GroupInfo} + | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact} + | CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact} + | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} + | CRAcceptingGroupJoinRequest {user :: User, groupInfo :: GroupInfo, contact :: Contact} + | CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError} + | CRMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]} + | CRGroupSubscribed {user :: User, groupInfo :: GroupInfo} + | CRPendingSubSummary {user :: User, pendingSubscriptions :: [PendingSubStatus]} + | CRSndFileSubError {user :: User, sndFileTransfer :: SndFileTransfer, chatError :: ChatError} + | CRRcvFileSubError {user :: User, rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} | CRCallInvitation {callInvitation :: RcvCallInvitation} - | CRCallOffer {contact :: Contact, callType :: CallType, offer :: WebRTCSession, sharedKey :: Maybe C.Key, askConfirmation :: Bool} - | CRCallAnswer {contact :: Contact, answer :: WebRTCSession} - | CRCallExtraInfo {contact :: Contact, extraInfo :: WebRTCExtraInfo} - | CRCallEnded {contact :: Contact} + | CRCallOffer {user :: User, contact :: Contact, callType :: CallType, offer :: WebRTCSession, sharedKey :: Maybe C.Key, askConfirmation :: Bool} + | CRCallAnswer {user :: User, contact :: Contact, answer :: WebRTCSession} + | CRCallExtraInfo {user :: User, contact :: Contact, extraInfo :: WebRTCExtraInfo} + | CRCallEnded {user :: User, contact :: Contact} | CRCallInvitations {callInvitations :: [RcvCallInvitation]} - | CRUserContactLinkSubscribed - | CRUserContactLinkSubError {chatError :: ChatError} + | CRUserContactLinkSubscribed -- TODO delete + | CRUserContactLinkSubError {chatError :: ChatError} -- TODO delete | CRNtfTokenStatus {status :: NtfTknStatus} | CRNtfToken {token :: DeviceToken, status :: NtfTknStatus, ntfMode :: NotificationsMode} - | CRNtfMessages {connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} - | CRNewContactConnection {connection :: PendingContactConnection} - | CRContactConnectionDeleted {connection :: PendingContactConnection} + | CRNtfMessages {user_ :: Maybe User, connEntity :: Maybe ConnectionEntity, msgTs :: Maybe UTCTime, ntfMessages :: [NtfMsgInfo]} + | CRNewContactConnection {user :: User, connection :: PendingContactConnection} + | CRContactConnectionDeleted {user :: User, connection :: PendingContactConnection} | CRSQLResult {rows :: [Text]} | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} | CRAgentStats {agentStats :: [[String]]} | CRConnectionDisabled {connectionEntity :: ConnectionEntity} - | CRMessageError {severity :: Text, errorMessage :: Text} - | CRChatCmdError {chatError :: ChatError} - | CRChatError {chatError :: ChatError} + | CRAgentRcvQueueDeleted {agentConnId :: AgentConnId, server :: SMPServer, agentQueueId :: AgentQueueId, agentError_ :: Maybe AgentErrorType} + | CRAgentConnDeleted {agentConnId :: AgentConnId} + | CRAgentUserDeleted {agentUserId :: Int64} + | CRMessageError {user :: User, severity :: Text, errorMessage :: Text} + | CRChatCmdError {user_ :: Maybe User, chatError :: ChatError} + | CRChatError {user_ :: Maybe User, chatError :: ChatError} deriving (Show, Generic) instance ToJSON ChatResponse where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" +newtype AgentQueueId = AgentQueueId QueueId + deriving (Eq, Show) + +instance StrEncoding AgentQueueId where + strEncode (AgentQueueId qId) = strEncode qId + strDecode s = AgentQueueId <$> strDecode s + strP = AgentQueueId <$> strP + +instance ToJSON AgentQueueId where + toJSON = strToJSON + toEncoding = strToJEncoding + data SMPServersConfig = SMPServersConfig {smpServers :: [ServerCfg]} deriving (Show, Generic, FromJSON) @@ -603,7 +643,11 @@ instance ToJSON ChatError where data ChatErrorType = CENoActiveUser - | CEActiveUserExists + | CENoConnectionUser {agentConnId :: AgentConnId} + | CEActiveUserExists -- TODO delete + | CEDifferentActiveUser {commandUserId :: UserId, activeUserId :: UserId} + | CECantDeleteActiveUser {userId :: UserId} + | CECantDeleteLastUser {userId :: UserId} | CEChatNotStarted | CEChatNotStopped | CEChatStoreChanged @@ -681,8 +725,8 @@ throwDBError = throwError . ChatErrorDatabase type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m) -chatCmdError :: String -> ChatResponse -chatCmdError = CRChatCmdError . ChatError . CECommandError +chatCmdError :: Maybe User -> String -> ChatResponse +chatCmdError user = CRChatCmdError user . ChatError . CECommandError setActive :: (MonadUnliftIO m, MonadReader ChatController m) => ActiveTo -> m () setActive to = asks activeTo >>= atomically . (`writeTVar` to) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index b1bf9b1f55..a458c0f76f 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -30,7 +30,7 @@ runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController runSimplexChat ChatOpts {maintenance} u cc chat | maintenance = wait =<< async (chat u cc) | otherwise = do - a1 <- runReaderT (startChatController u True True) cc + a1 <- runReaderT (startChatController True True) cc a2 <- async $ chat u cc waitEither_ a1 a2 diff --git a/src/Simplex/Chat/Migrations/M20230111_users_agent_user_id.hs b/src/Simplex/Chat/Migrations/M20230111_users_agent_user_id.hs new file mode 100644 index 0000000000..531c776a33 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230111_users_agent_user_id.hs @@ -0,0 +1,17 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230111_users_agent_user_id where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230111_users_agent_user_id :: Query +m20230111_users_agent_user_id = + [sql| +PRAGMA ignore_check_constraints=ON; + +ALTER TABLE users ADD COLUMN agent_user_id INTEGER CHECK (agent_user_id NOT NULL); +UPDATE users SET agent_user_id = 1; + +PRAGMA ignore_check_constraints=OFF; +|] diff --git a/src/Simplex/Chat/Migrations/M20230117_fkey_indexes.hs b/src/Simplex/Chat/Migrations/M20230117_fkey_indexes.hs new file mode 100644 index 0000000000..5986863093 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230117_fkey_indexes.hs @@ -0,0 +1,59 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230117_fkey_indexes where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- .lint fkey-indexes +m20230117_fkey_indexes :: Query +m20230117_fkey_indexes = + [sql| +CREATE INDEX idx_calls_user_id ON calls(user_id); +CREATE INDEX idx_calls_chat_item_id ON calls(chat_item_id); +CREATE INDEX idx_calls_contact_id ON calls(contact_id); +CREATE INDEX idx_chat_items_group_id ON chat_items(group_id); +CREATE INDEX idx_commands_user_id ON commands(user_id); +CREATE INDEX idx_connections_custom_user_profile_id ON connections(custom_user_profile_id); +CREATE INDEX idx_connections_via_user_contact_link ON connections(via_user_contact_link); +CREATE INDEX idx_connections_rcv_file_id ON connections(rcv_file_id); +CREATE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE INDEX idx_connections_user_contact_link_id ON connections(user_contact_link_id); +CREATE INDEX idx_connections_via_contact ON connections(via_contact); +CREATE INDEX idx_contact_profiles_user_id ON contact_profiles(user_id); +CREATE INDEX idx_contact_requests_contact_profile_id ON contact_requests(contact_profile_id); +CREATE INDEX idx_contact_requests_user_contact_link_id ON contact_requests(user_contact_link_id); +CREATE INDEX idx_contacts_via_group ON contacts(via_group); +CREATE INDEX idx_contacts_contact_profile_id ON contacts(contact_profile_id); +CREATE INDEX idx_files_chat_item_id ON files(chat_item_id); +CREATE INDEX idx_files_user_id ON files(user_id); +CREATE INDEX idx_files_group_id ON files(group_id); +CREATE INDEX idx_files_contact_id ON files(contact_id); +CREATE INDEX idx_group_member_intros_to_group_member_id ON group_member_intros(to_group_member_id); +CREATE INDEX idx_group_members_user_id_local_display_name ON group_members(user_id, local_display_name); +CREATE INDEX idx_group_members_member_profile_id ON group_members(member_profile_id); +CREATE INDEX idx_group_members_contact_id ON group_members(contact_id); +CREATE INDEX idx_group_members_contact_profile_id ON group_members(contact_profile_id); +CREATE INDEX idx_group_members_user_id ON group_members(user_id); +CREATE INDEX idx_group_members_invited_by ON group_members(invited_by); +CREATE INDEX idx_group_profiles_user_id ON group_profiles(user_id); +CREATE INDEX idx_groups_host_conn_custom_user_profile_id ON groups(host_conn_custom_user_profile_id); +CREATE INDEX idx_groups_chat_item_id ON groups(chat_item_id); +CREATE INDEX idx_groups_group_profile_id ON groups(group_profile_id); +CREATE INDEX idx_messages_group_id ON messages(group_id); +CREATE INDEX idx_pending_group_messages_group_member_intro_id ON pending_group_messages(group_member_intro_id); +CREATE INDEX idx_pending_group_messages_message_id ON pending_group_messages(message_id); +CREATE INDEX idx_pending_group_messages_group_member_id ON pending_group_messages(group_member_id); +CREATE INDEX idx_rcv_file_chunks_file_id ON rcv_file_chunks(file_id); +CREATE INDEX idx_rcv_files_group_member_id ON rcv_files(group_member_id); +CREATE INDEX idx_received_probes_user_id ON received_probes(user_id); +CREATE INDEX idx_received_probes_contact_id ON received_probes(contact_id); +CREATE INDEX idx_sent_probe_hashes_user_id ON sent_probe_hashes(user_id); +CREATE INDEX idx_sent_probe_hashes_contact_id ON sent_probe_hashes(contact_id); +CREATE INDEX idx_settings_user_id ON settings(user_id); +CREATE INDEX idx_smp_servers_user_id ON smp_servers(user_id); +CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON snd_file_chunks(file_id, connection_id); +CREATE INDEX idx_snd_files_group_member_id ON snd_files(group_member_id); +CREATE INDEX idx_snd_files_connection_id ON snd_files(connection_id); +CREATE INDEX idx_snd_files_file_id ON snd_files(file_id); +|] diff --git a/src/Simplex/Chat/Migrations/M20230118_recreate_smp_servers.hs b/src/Simplex/Chat/Migrations/M20230118_recreate_smp_servers.hs new file mode 100644 index 0000000000..3098c31d7a --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230118_recreate_smp_servers.hs @@ -0,0 +1,39 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230118_recreate_smp_servers where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- UNIQUE constraint includes user_id +m20230118_recreate_smp_servers :: Query +m20230118_recreate_smp_servers = + [sql| +DROP INDEX idx_smp_servers_user_id; + +CREATE TABLE new_smp_servers ( + smp_server_id INTEGER PRIMARY KEY, + host TEXT NOT NULL, + port TEXT NOT NULL, + key_hash BLOB NOT NULL, + basic_auth TEXT, + preset INTEGER NOT NULL DEFAULT 0, + tested INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (user_id, host, port) +); + +INSERT INTO new_smp_servers + (smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) +SELECT + smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at + FROM smp_servers; + +DROP TABLE smp_servers; +ALTER TABLE new_smp_servers RENAME TO smp_servers; + +CREATE INDEX idx_smp_servers_user_id ON smp_servers(user_id); +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 84059e9ebf..adbb190cab 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -29,7 +29,8 @@ CREATE TABLE users( local_display_name TEXT NOT NULL UNIQUE, active_user INTEGER NOT NULL DEFAULT 0, created_at TEXT CHECK(created_at NOT NULL), - updated_at TEXT CHECK(updated_at NOT NULL), -- 1 for active user + updated_at TEXT CHECK(updated_at NOT NULL), + agent_user_id INTEGER CHECK(agent_user_id NOT NULL), -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -385,20 +386,6 @@ CREATE INDEX idx_connections_via_contact_uri_hash ON connections( ); CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests(xcontact_id); CREATE INDEX idx_contacts_xcontact_id ON contacts(xcontact_id); -CREATE TABLE smp_servers( - smp_server_id INTEGER PRIMARY KEY, - host TEXT NOT NULL, - port TEXT NOT NULL, - key_hash BLOB NOT NULL, - user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, - created_at TEXT NOT NULL DEFAULT(datetime('now')), - updated_at TEXT NOT NULL DEFAULT(datetime('now')), - basic_auth TEXT, - preset INTEGER DEFAULT 0 CHECK(preset NOT NULL), - tested INTEGER, - enabled INTEGER DEFAULT 1 CHECK(enabled NOT NULL), - UNIQUE(host, port) -); CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); CREATE INDEX idx_chat_items_shared_msg_id ON chat_items(shared_msg_id); CREATE TABLE calls( @@ -469,3 +456,94 @@ CREATE INDEX idx_connections_group_member ON connections( group_member_id ); CREATE INDEX idx_commands_connection_id ON commands(connection_id); +CREATE INDEX idx_calls_user_id ON calls(user_id); +CREATE INDEX idx_calls_chat_item_id ON calls(chat_item_id); +CREATE INDEX idx_calls_contact_id ON calls(contact_id); +CREATE INDEX idx_chat_items_group_id ON chat_items(group_id); +CREATE INDEX idx_commands_user_id ON commands(user_id); +CREATE INDEX idx_connections_custom_user_profile_id ON connections( + custom_user_profile_id +); +CREATE INDEX idx_connections_via_user_contact_link ON connections( + via_user_contact_link +); +CREATE INDEX idx_connections_rcv_file_id ON connections(rcv_file_id); +CREATE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE INDEX idx_connections_user_contact_link_id ON connections( + user_contact_link_id +); +CREATE INDEX idx_connections_via_contact ON connections(via_contact); +CREATE INDEX idx_contact_profiles_user_id ON contact_profiles(user_id); +CREATE INDEX idx_contact_requests_contact_profile_id ON contact_requests( + contact_profile_id +); +CREATE INDEX idx_contact_requests_user_contact_link_id ON contact_requests( + user_contact_link_id +); +CREATE INDEX idx_contacts_via_group ON contacts(via_group); +CREATE INDEX idx_contacts_contact_profile_id ON contacts(contact_profile_id); +CREATE INDEX idx_files_chat_item_id ON files(chat_item_id); +CREATE INDEX idx_files_user_id ON files(user_id); +CREATE INDEX idx_files_group_id ON files(group_id); +CREATE INDEX idx_files_contact_id ON files(contact_id); +CREATE INDEX idx_group_member_intros_to_group_member_id ON group_member_intros( + to_group_member_id +); +CREATE INDEX idx_group_members_user_id_local_display_name ON group_members( + user_id, + local_display_name +); +CREATE INDEX idx_group_members_member_profile_id ON group_members( + member_profile_id +); +CREATE INDEX idx_group_members_contact_id ON group_members(contact_id); +CREATE INDEX idx_group_members_contact_profile_id ON group_members( + contact_profile_id +); +CREATE INDEX idx_group_members_user_id ON group_members(user_id); +CREATE INDEX idx_group_members_invited_by ON group_members(invited_by); +CREATE INDEX idx_group_profiles_user_id ON group_profiles(user_id); +CREATE INDEX idx_groups_host_conn_custom_user_profile_id ON groups( + host_conn_custom_user_profile_id +); +CREATE INDEX idx_groups_chat_item_id ON groups(chat_item_id); +CREATE INDEX idx_groups_group_profile_id ON groups(group_profile_id); +CREATE INDEX idx_messages_group_id ON messages(group_id); +CREATE INDEX idx_pending_group_messages_group_member_intro_id ON pending_group_messages( + group_member_intro_id +); +CREATE INDEX idx_pending_group_messages_message_id ON pending_group_messages( + message_id +); +CREATE INDEX idx_pending_group_messages_group_member_id ON pending_group_messages( + group_member_id +); +CREATE INDEX idx_rcv_file_chunks_file_id ON rcv_file_chunks(file_id); +CREATE INDEX idx_rcv_files_group_member_id ON rcv_files(group_member_id); +CREATE INDEX idx_received_probes_user_id ON received_probes(user_id); +CREATE INDEX idx_received_probes_contact_id ON received_probes(contact_id); +CREATE INDEX idx_sent_probe_hashes_user_id ON sent_probe_hashes(user_id); +CREATE INDEX idx_sent_probe_hashes_contact_id ON sent_probe_hashes(contact_id); +CREATE INDEX idx_settings_user_id ON settings(user_id); +CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON snd_file_chunks( + file_id, + connection_id +); +CREATE INDEX idx_snd_files_group_member_id ON snd_files(group_member_id); +CREATE INDEX idx_snd_files_connection_id ON snd_files(connection_id); +CREATE INDEX idx_snd_files_file_id ON snd_files(file_id); +CREATE TABLE IF NOT EXISTS "smp_servers"( + smp_server_id INTEGER PRIMARY KEY, + host TEXT NOT NULL, + port TEXT NOT NULL, + key_hash BLOB NOT NULL, + basic_auth TEXT, + preset INTEGER NOT NULL DEFAULT 0, + tested INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')), + UNIQUE(user_id, host, port) +); +CREATE INDEX idx_smp_servers_user_id ON smp_servers(user_id); diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 3787c40f6c..b6f24eed23 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -25,9 +25,20 @@ module Simplex.Chat.Store createChatStore, chatStoreFile, agentStoreFile, - createUser, + createUserRecord, + getUsersInfo, getUsers, setActiveUser, + getSetActiveUser, + getUser, + getUserIdByName, + getUserByAConnId, + getUserByContactId, + getUserByGroupId, + getUserByFileId, + getUserByContactRequestId, + getUserFileInfo, + deleteUserRecord, createDirectConnection, createConnReqConnection, getProfileById, @@ -65,6 +76,7 @@ module Simplex.Chat.Store getGroupLink, getGroupLinkId, createOrUpdateContactRequest, + getContactRequest', getContactRequest, getContactRequestIdByName, deleteContactRequest, @@ -154,6 +166,7 @@ module Simplex.Chat.Store deleteSndFileChunks, createRcvFileTransfer, createRcvGroupFileTransfer, + getRcvFileTransferById, getRcvFileTransfer, acceptRcvFileTransfer, getContactByFileId, @@ -169,13 +182,9 @@ module Simplex.Chat.Store getFileTransferMeta, getSndFileTransfer, getContactFileInfo, - getContactMaxItemTs, -- TODO delete deleteContactCIs, - updateContactTs, -- TODO delete getGroupFileInfo, - getGroupMaxItemTs, -- TODO delete deleteGroupCIs, - updateGroupTs, -- TODO delete createNewSndMessage, createSndMsgDelivery, createNewMessageAndRcvMsgDelivery, @@ -236,10 +245,8 @@ module Simplex.Chat.Store setChatItemTTL, getContactExpiredFileInfo, deleteContactExpiredCIs, - getContactCICount, -- TODO delete getGroupExpiredFileInfo, deleteGroupExpiredCIs, - getGroupCICount, -- TODO delete getPendingContactConnection, deletePendingContactConnection, updateContactSettings, @@ -327,6 +334,9 @@ import Simplex.Chat.Migrations.M20221222_chat_ts import Simplex.Chat.Migrations.M20221223_idx_chat_items_item_status import Simplex.Chat.Migrations.M20221230_idxs import Simplex.Chat.Migrations.M20230107_connections_auth_err_counter +import Simplex.Chat.Migrations.M20230111_users_agent_user_id +import Simplex.Chat.Migrations.M20230117_fkey_indexes +import Simplex.Chat.Migrations.M20230118_recreate_smp_servers import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (week) @@ -387,7 +397,10 @@ schemaMigrations = ("20221222_chat_ts", m20221222_chat_ts), ("20221223_idx_chat_items_item_status", m20221223_idx_chat_items_item_status), ("20221230_idxs", m20221230_idxs), - ("20230107_connections_auth_err_counter", m20230107_connections_auth_err_counter) + ("20230107_connections_auth_err_counter", m20230107_connections_auth_err_counter), + ("20230111_users_agent_user_id", m20230111_users_agent_user_id), + ("20230117_fkey_indexes", m20230117_fkey_indexes), + ("20230118_recreate_smp_servers", m20230118_recreate_smp_servers) ] -- | The list of migrations in ascending order by date @@ -416,14 +429,15 @@ handleSQLError err e insertedRowId :: DB.Connection -> IO Int64 insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()" -createUser :: DB.Connection -> Profile -> Bool -> ExceptT StoreError IO User -createUser db Profile {displayName, fullName, image, preferences = userPreferences} activeUser = +createUserRecord :: DB.Connection -> AgentUserId -> Profile -> Bool -> ExceptT StoreError IO User +createUserRecord db (AgentUserId auId) Profile {displayName, fullName, image, preferences = userPreferences} activeUser = checkConstraint SEDuplicateName . liftIO $ do currentTs <- getCurrentTime + when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0" DB.execute db - "INSERT INTO users (local_display_name, active_user, contact_id, created_at, updated_at) VALUES (?,?,0,?,?)" - (displayName, activeUser, currentTs, currentTs) + "INSERT INTO users (agent_user_id, local_display_name, active_user, contact_id, created_at, updated_at) VALUES (?,?,?,0,?,?)" + (auId, displayName, activeUser, currentTs, currentTs) userId <- insertedRowId db DB.execute db @@ -440,30 +454,120 @@ createUser db Profile {displayName, fullName, image, preferences = userPreferenc (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, contactId, profileId, activeUser, displayName, fullName, image, userPreferences) + pure $ toUser (userId, auId, contactId, profileId, activeUser, displayName, fullName, image, userPreferences) + +getUsersInfo :: DB.Connection -> IO [UserInfo] +getUsersInfo db = getUsers db >>= mapM getUserInfo + where + getUserInfo :: User -> IO UserInfo + getUserInfo user@User {userId} = do + ctCount <- + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT COUNT(1) + FROM chat_items i + JOIN contacts ct USING (contact_id) + WHERE i.user_id = ? AND i.item_status = ? AND (ct.enable_ntfs = 1 OR ct.enable_ntfs IS NULL) + |] + (userId, CISRcvNew) + gCount <- + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT COUNT(1) + FROM chat_items i + JOIN groups g USING (group_id) + WHERE i.user_id = ? AND i.item_status = ? AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL) + |] + (userId, CISRcvNew) + pure UserInfo {user, unreadCount = fromMaybe 0 ctCount + fromMaybe 0 gCount} getUsers :: DB.Connection -> IO [User] getUsers db = - map toUser - <$> DB.query_ - db - [sql| - SELECT u.user_id, u.contact_id, p.contact_profile_id, u.active_user, u.local_display_name, p.full_name, p.image, p.preferences - FROM users u - JOIN contacts c ON u.contact_id = c.contact_id - JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id - |] + map toUser <$> DB.query_ db userQuery -toUser :: (UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe Preferences) -> User -toUser (userId, userContactId, profileId, activeUser, displayName, fullName, image, userPreferences) = +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 + 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, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences = mergePreferences Nothing userPreferences} + in User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences = mergePreferences Nothing userPreferences} setActiveUser :: DB.Connection -> UserId -> IO () setActiveUser db userId = do DB.execute_ db "UPDATE users SET active_user = 0" DB.execute db "UPDATE users SET active_user = 1 WHERE user_id = ?" (Only userId) +getSetActiveUser :: DB.Connection -> UserId -> ExceptT StoreError IO User +getSetActiveUser db userId = do + liftIO $ setActiveUser db userId + getUser db userId + +getUser :: DB.Connection -> UserId -> ExceptT StoreError IO User +getUser db userId = + ExceptT . firstRow toUser (SEUserNotFound userId) $ + DB.query db (userQuery <> " WHERE u.user_id = ?") (Only userId) + +getUserIdByName :: DB.Connection -> UserName -> ExceptT StoreError IO Int64 +getUserIdByName db uName = + ExceptT . firstRow fromOnly (SEUserNotFoundByName uName) $ + DB.query db "SELECT user_id FROM users WHERE local_display_name = ?" (Only uName) + +getUserByAConnId :: DB.Connection -> AgentConnId -> IO (Maybe User) +getUserByAConnId db agentConnId = + maybeFirstRow toUser $ + DB.query db (userQuery <> " JOIN connections c ON c.user_id = u.user_id WHERE c.agent_conn_id = ?") (Only agentConnId) + +getUserByContactId :: DB.Connection -> ContactId -> ExceptT StoreError IO User +getUserByContactId db contactId = + ExceptT . firstRow toUser (SEUserNotFoundByContactId contactId) $ + DB.query db (userQuery <> " JOIN contacts ct ON ct.user_id = u.user_id WHERE ct.contact_id = ?") (Only contactId) + +getUserByGroupId :: DB.Connection -> GroupId -> ExceptT StoreError IO User +getUserByGroupId db groupId = + ExceptT . firstRow toUser (SEUserNotFoundByGroupId groupId) $ + DB.query db (userQuery <> " JOIN groups g ON g.user_id = u.user_id WHERE g.group_id = ?") (Only groupId) + +getUserByFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO User +getUserByFileId db fileId = + ExceptT . firstRow toUser (SEUserNotFoundByFileId fileId) $ + DB.query db (userQuery <> " JOIN files f ON f.user_id = u.user_id WHERE f.file_id = ?") (Only fileId) + +getUserByContactRequestId :: DB.Connection -> Int64 -> ExceptT StoreError IO User +getUserByContactRequestId db contactRequestId = + ExceptT . firstRow toUser (SEUserNotFoundByContactRequestId contactRequestId) $ + DB.query db (userQuery <> " JOIN contact_requests cr ON cr.user_id = u.user_id WHERE cr.contact_request_id = ?") (Only contactRequestId) + +getUserFileInfo :: DB.Connection -> User -> IO [CIFileInfo] +getUserFileInfo db User {userId} = + map toFileInfo + <$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ?") (Only userId) + +fileInfoQuery :: Query +fileInfoQuery = + [sql| + SELECT f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + |] + +toFileInfo :: (Int64, Maybe ACIFileStatus, Maybe FilePath) -> CIFileInfo +toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, filePath} + +deleteUserRecord :: DB.Connection -> User -> IO () +deleteUserRecord db User {userId} = + DB.execute db "DELETE FROM users WHERE user_id = ?" (Only userId) + createConnReqConnection :: DB.Connection -> UserId -> ConnId -> ConnReqUriHash -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> IO PendingContactConnection createConnReqConnection db userId acId cReqHash xContactId incognitoProfile groupLinkId = do createdAt <- getCurrentTime @@ -516,8 +620,8 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do "SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1" (userId, cReqHash) -createDirectConnection :: DB.Connection -> UserId -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> IO PendingContactConnection -createDirectConnection db userId acId cReq pccConnStatus incognitoProfile = do +createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> IO PendingContactConnection +createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile DB.execute @@ -855,8 +959,8 @@ getUserContactProfiles db User {userId} = toContactProfile :: (ContactName, Text, Maybe ImageData, Maybe Preferences) -> (Profile) toContactProfile (displayName, fullName, image, preferences) = Profile {displayName, fullName, image, preferences} -createUserContactLink :: DB.Connection -> UserId -> ConnId -> ConnReqContact -> ExceptT StoreError IO () -createUserContactLink db userId agentConnId cReq = +createUserContactLink :: DB.Connection -> User -> ConnId -> ConnReqContact -> ExceptT StoreError IO () +createUserContactLink db User {userId} agentConnId cReq = checkConstraint SEDuplicateContactLink . liftIO $ do currentTs <- getCurrentTime DB.execute @@ -964,8 +1068,8 @@ toUserContactLink (connReq, autoAccept, acceptIncognito, autoReply) = UserContactLink connReq $ if autoAccept then Just AutoAccept {acceptIncognito, autoReply} else Nothing -getUserAddress :: DB.Connection -> UserId -> ExceptT StoreError IO UserContactLink -getUserAddress db userId = +getUserAddress :: DB.Connection -> User -> ExceptT StoreError IO UserContactLink +getUserAddress db User {userId} = ExceptT . firstRow toUserContactLink SEUserContactLinkNotFound $ DB.query db @@ -989,9 +1093,9 @@ getUserContactLinkById db userId userContactLinkId = |] (userId, userContactLinkId) -updateUserAddressAutoAccept :: DB.Connection -> UserId -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink -updateUserAddressAutoAccept db userId autoAccept = do - link <- getUserAddress db userId +updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink +updateUserAddressAutoAccept db user@User {userId} autoAccept = do + link <- getUserAddress db user liftIO updateUserAddressAutoAccept_ $> link {autoAccept} where updateUserAddressAutoAccept_ = @@ -1093,10 +1197,10 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi createOrUpdate_ = do cReqId <- ExceptT $ - maybeM getContactRequest' xContactId_ >>= \case + maybeM getContactRequestByXContactId xContactId_ >>= \case Nothing -> createContactRequest Just cr -> updateContactRequest cr $> Right (contactRequestId (cr :: UserContactRequest)) - getContactRequest db userId cReqId + getContactRequest db user cReqId createContactRequest :: IO (Either StoreError Int64) createContactRequest = do currentTs <- getCurrentTime @@ -1138,8 +1242,8 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi LIMIT 1 |] (userId, xContactId) - getContactRequest' :: XContactId -> IO (Maybe UserContactRequest) - getContactRequest' xContactId = + getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) + getContactRequestByXContactId xContactId = maybeFirstRow toContactRequest $ DB.query db @@ -1184,8 +1288,13 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId Profi |] (displayName, fullName, image, currentTs, userId, cReqId) -getContactRequest :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO UserContactRequest -getContactRequest db userId contactRequestId = +getContactRequest' :: DB.Connection -> Int64 -> ExceptT StoreError IO (User, UserContactRequest) +getContactRequest' db contactRequestId = do + user <- getUserByContactRequestId db contactRequestId + (user,) <$> getContactRequest db user contactRequestId + +getContactRequest :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO UserContactRequest +getContactRequest db User {userId} contactRequestId = ExceptT . firstRow toContactRequest (SEContactRequestNotFound contactRequestId) $ DB.query db @@ -1213,8 +1322,8 @@ getContactRequestIdByName db userId cName = ExceptT . firstRow fromOnly (SEContactRequestNotFoundByName cName) $ DB.query db "SELECT contact_request_id FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, cName) -deleteContactRequest :: DB.Connection -> UserId -> Int64 -> IO () -deleteContactRequest db userId contactRequestId = do +deleteContactRequest :: DB.Connection -> User -> Int64 -> IO () +deleteContactRequest db User {userId} contactRequestId = do DB.execute db [sql| @@ -1635,26 +1744,28 @@ getConnectionById db User {userId} connId = ExceptT $ do |] (userId, connId) -getConnectionsContacts :: DB.Connection -> UserId -> [ConnId] -> IO [ContactRef] -getConnectionsContacts db userId agentConnIds = do +getConnectionsContacts :: DB.Connection -> [ConnId] -> IO [ContactRef] +getConnectionsContacts db agentConnIds = do DB.execute_ db "DROP TABLE IF EXISTS temp.conn_ids" DB.execute_ db "CREATE TABLE temp.conn_ids (conn_id BLOB)" DB.executeMany db "INSERT INTO temp.conn_ids (conn_id) VALUES (?)" $ map Only agentConnIds conns <- - map (uncurry ContactRef) + map toContactRef <$> DB.query db [sql| - SELECT ct.contact_id, ct.local_display_name + SELECT ct.contact_id, c.connection_id, c.agent_conn_id, ct.local_display_name FROM contacts ct JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? - AND c.agent_conn_id IN (SELECT conn_id FROM temp.conn_ids) + WHERE c.agent_conn_id IN (SELECT conn_id FROM temp.conn_ids) AND c.conn_type = ? |] - (userId, ConnContact) + (Only ConnContact) DB.execute_ db "DROP TABLE temp.conn_ids" pure conns + where + toContactRef :: (ContactId, Int64, ConnId, ContactName) -> ContactRef + toContactRef (contactId, connId, acId, localDisplayName) = ContactRef {contactId, connId, agentConnId = AgentConnId acId, localDisplayName} getGroupAndMember :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember db User {userId, userContactId} groupMemberId = @@ -2742,7 +2853,12 @@ createRcvGroupFileTransfer db userId GroupMember {groupId, groupMemberId, localD (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, groupMemberId, currentTs, currentTs) pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Just groupMemberId} -getRcvFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO RcvFileTransfer +getRcvFileTransferById :: DB.Connection -> FileTransferId -> ExceptT StoreError IO (User, RcvFileTransfer) +getRcvFileTransferById db fileId = do + user <- getUserByFileId db fileId + (user,) <$> getRcvFileTransfer db user fileId + +getRcvFileTransfer :: DB.Connection -> User -> FileTransferId -> ExceptT StoreError IO RcvFileTransfer getRcvFileTransfer db User {userId} fileId = do rftRow <- ExceptT . firstRow id (SERcvFileNotFound fileId) $ @@ -2963,24 +3079,7 @@ getFileTransferMeta db User {userId} fileId = getContactFileInfo :: DB.Connection -> User -> Contact -> IO [CIFileInfo] getContactFileInfo db User {userId} Contact {contactId} = map toFileInfo - <$> DB.query - db - [sql| - SELECT f.file_id, f.ci_file_status, f.file_path - FROM chat_items i - JOIN files f ON f.chat_item_id = i.chat_item_id - WHERE i.user_id = ? AND i.contact_id = ? - |] - (userId, contactId) - -toFileInfo :: (Int64, Maybe ACIFileStatus, Maybe FilePath) -> CIFileInfo -toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, filePath} - --- TODO delete -getContactMaxItemTs :: DB.Connection -> User -> Contact -> IO (Maybe UTCTime) -getContactMaxItemTs db User {userId} Contact {contactId} = - fmap join . maybeFirstRow fromOnly $ - DB.query db "SELECT MAX(item_ts) FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) + <$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ? AND i.contact_id = ?") (userId, contactId) deleteContactCIs :: DB.Connection -> User -> Contact -> IO () deleteContactCIs db user@User {userId} ct@Contact {contactId} = do @@ -2994,46 +3093,16 @@ getContactConnIds_ db User {userId} Contact {contactId} = map fromOnly <$> DB.query db "SELECT connection_id FROM connections WHERE user_id = ? AND contact_id = ?" (userId, contactId) --- TODO delete -updateContactTs :: DB.Connection -> User -> Contact -> UTCTime -> IO () -updateContactTs db User {userId} Contact {contactId} updatedAt = - DB.execute - db - "UPDATE contacts SET chat_ts = ? WHERE user_id = ? AND contact_id = ?" - (updatedAt, userId, contactId) - getGroupFileInfo :: DB.Connection -> User -> GroupInfo -> IO [CIFileInfo] getGroupFileInfo db User {userId} GroupInfo {groupId} = map toFileInfo - <$> DB.query - db - [sql| - SELECT f.file_id, f.ci_file_status, f.file_path - FROM chat_items i - JOIN files f ON f.chat_item_id = i.chat_item_id - WHERE i.user_id = ? AND i.group_id = ? - |] - (userId, groupId) - --- TODO delete -getGroupMaxItemTs :: DB.Connection -> User -> GroupInfo -> IO (Maybe UTCTime) -getGroupMaxItemTs db User {userId} GroupInfo {groupId} = - fmap join . maybeFirstRow fromOnly $ - DB.query db "SELECT MAX(item_ts) FROM chat_items WHERE user_id = ? AND group_id = ?" (userId, groupId) + <$> DB.query db (fileInfoQuery <> " WHERE i.user_id = ? AND i.group_id = ?") (userId, groupId) deleteGroupCIs :: DB.Connection -> User -> GroupInfo -> IO () deleteGroupCIs db User {userId} GroupInfo {groupId} = do DB.execute db "DELETE FROM messages WHERE group_id = ?" (Only groupId) DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ?" (userId, groupId) --- TODO delete -updateGroupTs :: DB.Connection -> User -> GroupInfo -> UTCTime -> IO () -updateGroupTs db User {userId} GroupInfo {groupId} updatedAt = - DB.execute - db - "UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ?" - (updatedAt, userId, groupId) - createNewSndMessage :: MsgEncodingI e => DB.Connection -> TVar ChaChaDRG -> ConnOrGroupId -> (SharedMsgId -> NewMessage e) -> ExceptT StoreError IO SndMessage createNewSndMessage db gVar connOrGroupId mkMessage = createWithRandomId gVar $ \sharedMsgId -> do @@ -4502,8 +4571,9 @@ overwriteSMPServers db User {userId} servers = pure $ Right () createCall :: DB.Connection -> User -> Call -> UTCTime -> IO () -createCall db User {userId} Call {contactId, callId, chatItemId, callState} callTs = do +createCall db user@User {userId} Call {contactId, callId, chatItemId, callState} callTs = do currentTs <- getCurrentTime + deleteCalls db user contactId DB.execute db [sql| @@ -4517,19 +4587,17 @@ deleteCalls :: DB.Connection -> User -> ContactId -> IO () deleteCalls db User {userId} contactId = do DB.execute db "DELETE FROM calls WHERE user_id = ? AND contact_id = ?" (userId, contactId) -getCalls :: DB.Connection -> User -> IO [Call] -getCalls db User {userId} = do +getCalls :: DB.Connection -> IO [Call] +getCalls db = map toCall - <$> DB.query + <$> DB.query_ db [sql| SELECT contact_id, shared_call_id, chat_item_id, call_state, call_ts FROM calls - WHERE user_id = ? ORDER BY call_ts ASC |] - (Only userId) where toCall :: (ContactId, CallId, ChatItemId, CallState, UTCTime) -> Call toCall (contactId, callId, chatItemId, callState, callTs) = Call {contactId, callId, chatItemId, callState, callTs} @@ -4704,12 +4772,7 @@ getContactExpiredFileInfo db User {userId} Contact {contactId} expirationDate = map toFileInfo <$> DB.query db - [sql| - SELECT f.file_id, f.ci_file_status, f.file_path - FROM chat_items i - JOIN files f ON f.chat_item_id = i.chat_item_id - WHERE i.user_id = ? AND i.contact_id = ? AND i.created_at <= ? - |] + (fileInfoQuery <> " WHERE i.user_id = ? AND i.contact_id = ? AND i.created_at <= ?") (userId, contactId, expirationDate) deleteContactExpiredCIs :: DB.Connection -> User -> Contact -> UTCTime -> IO () @@ -4719,23 +4782,12 @@ deleteContactExpiredCIs db user@User {userId} ct@Contact {contactId} expirationD DB.execute db "DELETE FROM messages WHERE connection_id = ? AND created_at <= ?" (connId, expirationDate) DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? AND created_at <= ?" (userId, contactId, expirationDate) --- TODO delete -getContactCICount :: DB.Connection -> User -> Contact -> IO (Maybe Int64) -getContactCICount db User {userId} Contact {contactId} = - fmap join . maybeFirstRow fromOnly $ - DB.query db "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) - getGroupExpiredFileInfo :: DB.Connection -> User -> GroupInfo -> UTCTime -> UTCTime -> IO [CIFileInfo] getGroupExpiredFileInfo db User {userId} GroupInfo {groupId} expirationDate createdAtCutoff = map toFileInfo <$> DB.query db - [sql| - SELECT f.file_id, f.ci_file_status, f.file_path - FROM chat_items i - JOIN files f ON f.chat_item_id = i.chat_item_id - WHERE i.user_id = ? AND i.group_id = ? AND i.item_ts <= ? AND i.created_at <= ? - |] + (fileInfoQuery <> " WHERE i.user_id = ? AND i.group_id = ? AND i.item_ts <= ? AND i.created_at <= ?") (userId, groupId, expirationDate, createdAtCutoff) deleteGroupExpiredCIs :: DB.Connection -> User -> GroupInfo -> UTCTime -> UTCTime -> IO () @@ -4743,12 +4795,6 @@ deleteGroupExpiredCIs db User {userId} GroupInfo {groupId} expirationDate create DB.execute db "DELETE FROM messages WHERE group_id = ? AND created_at <= ?" (groupId, min expirationDate createdAtCutoff) DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ? AND item_ts <= ? AND created_at <= ?" (userId, groupId, expirationDate, createdAtCutoff) --- TODO delete -getGroupCICount :: DB.Connection -> User -> GroupInfo -> IO (Maybe Int64) -getGroupCICount db User {userId} GroupInfo {groupId} = - fmap join . maybeFirstRow fromOnly $ - DB.query db "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ?" (userId, groupId) - -- | Saves unique local display name based on passed displayName, suffixed with _N if required. -- This function should be called inside transaction. withLocalDisplayName :: forall a. DB.Connection -> UserId -> Text -> (Text -> IO (Either StoreError a)) -> IO (Either StoreError a) @@ -4812,7 +4858,13 @@ randomBytes gVar = atomically . stateTVar gVar . randomBytesGenerate -- These error type constructors must be added to mobile apps data StoreError = SEDuplicateName - | SEContactNotFound {contactId :: Int64} + | SEUserNotFound {userId :: UserId} + | SEUserNotFoundByName {contactName :: ContactName} + | SEUserNotFoundByContactId {contactId :: ContactId} + | SEUserNotFoundByGroupId {groupId :: GroupId} + | SEUserNotFoundByFileId {fileId :: FileTransferId} + | SEUserNotFoundByContactRequestId {contactRequestId :: Int64} + | SEContactNotFound {contactId :: ContactId} | SEContactNotFoundByName {contactName :: ContactName} | SEContactNotReady {contactName :: ContactName} | SEDuplicateContactLink diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index dd777ff793..f7cac37534 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -17,7 +17,6 @@ import Simplex.Chat.Options import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Notification import Simplex.Chat.Terminal.Output -import Simplex.Messaging.Agent.Env.SQLite (InitialAgentServers (..)) import Simplex.Messaging.Client (defaultNetworkConfig) import Simplex.Messaging.Util (raceAny_) import System.Exit (exitFailure) @@ -26,7 +25,7 @@ terminalChatConfig :: ChatConfig terminalChatConfig = defaultChatConfig { defaultServers = - InitialAgentServers + DefaultAgentServers { smp = L.fromList [ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im,o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion", diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index e5b5317620..1ea3dc0cd0 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -43,7 +43,8 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do unless (isMessage cmd) $ echo s r <- runReaderT (execChatCommand bs) cc case r of - CRChatCmdError _ -> when (isMessage cmd) $ echo s + CRChatCmdError _ _ -> when (isMessage cmd) $ echo s + CRChatError _ _ -> when (isMessage cmd) $ echo s _ -> pure () printRespToTerminal ct cc False r startLiveMessage cmd r @@ -58,7 +59,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do Right SendMessageBroadcast {} -> True _ -> False startLiveMessage :: Either a ChatCommand -> ChatResponse -> IO () - startLiveMessage (Right (SendLiveMessage chatName msg)) (CRNewChatItem (AChatItem cType SMDSnd _ ChatItem {meta = CIMeta {itemId}})) = do + startLiveMessage (Right (SendLiveMessage chatName msg)) (CRNewChatItem _ (AChatItem cType SMDSnd _ ChatItem {meta = CIMeta {itemId}})) = do whenM (isNothing <$> readTVarIO liveMessageState) $ do let s = T.unpack $ safeDecodeUtf8 msg int = case cType of SCTGroup -> 5000000; _ -> 3000000 :: Int @@ -111,7 +112,7 @@ sendUpdatedLiveMessage :: ChatController -> String -> LiveMessage -> Bool -> IO sendUpdatedLiveMessage cc sentMsg LiveMessage {chatName, chatItemId} live = do let bs = encodeUtf8 $ T.pack sentMsg cmd = UpdateLiveMessage chatName chatItemId live bs - either CRChatCmdError id <$> runExceptT (processChatCommand cmd) `runReaderT` cc + either (CRChatCmdError Nothing) id <$> runExceptT (processChatCommand cmd) `runReaderT` cc runTerminalInput :: ChatTerminal -> ChatController -> IO () runTerminalInput ct cc = withChatTerm ct $ do diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 65881e4bc4..faf7a974f3 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -95,8 +95,8 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems} = do forever $ do (_, r) <- atomically $ readTBQueue outputQ case r of - CRNewChatItem ci -> markChatItemRead ci - CRChatItemUpdated ci -> markChatItemRead ci + CRNewChatItem _ ci -> markChatItemRead ci + CRChatItemUpdated _ ci -> markChatItemRead ci _ -> pure () liveItems <- readTVarIO showLiveItems printRespToTerminal ct cc liveItems r diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 2fc46aa11b..15717672e0 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -80,8 +80,31 @@ instance IsContact Contact where preferences' Contact {profile = LocalProfile {preferences}} = preferences {-# INLINE preferences' #-} +newtype AgentUserId = AgentUserId UserId + deriving (Eq, Show) + +instance StrEncoding AgentUserId where + strEncode (AgentUserId uId) = strEncode uId + strDecode s = AgentUserId <$> strDecode s + strP = AgentUserId <$> strP + +instance FromJSON AgentUserId where + parseJSON = strParseJSON "AgentUserId" + +instance ToJSON AgentUserId where + toJSON = strToJSON + toEncoding = strToJEncoding + +instance FromField AgentUserId where fromField f = AgentUserId <$> fromField f + +instance ToField AgentUserId where toField (AgentUserId uId) = toField uId + +aUserId :: User -> UserId +aUserId User {agentUserId = AgentUserId uId} = uId + data User = User { userId :: UserId, + agentUserId :: AgentUserId, userContactId :: ContactId, localDisplayName :: ContactName, profile :: LocalProfile, @@ -92,7 +115,17 @@ data User = User instance ToJSON User where toEncoding = J.genericToEncoding J.defaultOptions -type UserId = ContactId +data UserInfo = UserInfo + { user :: User, + unreadCount :: Int + } + deriving (Show, Generic, FromJSON) + +instance ToJSON UserInfo where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + +type UserId = Int64 type ContactId = Int64 @@ -139,6 +172,8 @@ contactSecurityCode Contact {activeConn} = connectionCode activeConn data ContactRef = ContactRef { contactId :: ContactId, + connId :: Int64, + agentConnId :: AgentConnId, localDisplayName :: ContactName } deriving (Eq, Show, Generic) @@ -216,6 +251,8 @@ instance ToJSON ConnReqUriHash where data ContactOrRequest = CORContact Contact | CORRequest UserContactRequest +type UserName = Text + type ContactName = Text type GroupName = Text @@ -1828,7 +1865,7 @@ data CommandFunction | CFAllowConn | CFAcceptContact | CFAckMessage - | CFDeleteConn + | CFDeleteConn -- not used deriving (Eq, Show, Generic) instance FromField CommandFunction where fromField = fromTextField_ textDecode diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 8e577b3635..802a4cebb4 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -59,36 +59,37 @@ serializeChatResponse user_ ts = unlines . map unStyle . responseToView user_ de responseToView :: Maybe User -> ChatConfig -> Bool -> CurrentTime -> ChatResponse -> [StyledString] responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile + CRUsersList users -> viewUsersList users CRChatStarted -> ["chat started"] CRChatRunning -> ["chat is running"] CRChatStopped -> ["chat stopped"] CRChatSuspended -> ["chat suspended"] - CRApiChats chats -> if testView then testViewChats chats else [plain . bshow $ J.encode chats] + CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [plain . bshow $ J.encode chats] CRChats chats -> viewChats ts chats - CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat] + CRApiChat u chat -> ttyUser u $ if testView then testViewChat chat else [plain . bshow $ J.encode chat] CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft] - CRUserSMPServers smpServers _ -> viewSMPServers (L.toList smpServers) testView - CRSmpTestResult testFailure -> viewSMPTestResult testFailure - CRChatItemTTL ttl -> viewChatItemTTL ttl + CRUserSMPServers u smpServers _ -> ttyUser u $ viewSMPServers (L.toList smpServers) testView + CRSmpTestResult u testFailure -> ttyUser u $ viewSMPTestResult testFailure + CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl CRNetworkConfig cfg -> viewNetworkConfig cfg - CRContactInfo ct cStats customUserProfile -> viewContactInfo ct cStats customUserProfile - CRGroupMemberInfo g m cStats -> viewGroupMemberInfo g m cStats - CRContactSwitch ct progress -> viewContactSwitch ct progress - CRGroupMemberSwitch g m progress -> viewGroupMemberSwitch g m progress - CRConnectionVerified verified code -> [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] - CRContactCode ct code -> viewContactCode ct code testView - CRGroupMemberCode g m code -> viewGroupMemberCode g m code testView - CRNewChatItem (AChatItem _ _ chat item) -> unmuted chat item $ viewChatItem chat item False ts - CRChatItems chatItems -> concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems - CRChatItemId itemId -> [plain $ maybe "no item" show itemId] - CRChatItemStatusUpdated _ -> [] - CRChatItemUpdated (AChatItem _ _ chat item) -> unmuted chat item $ viewItemUpdate chat item liveItems ts - CRChatItemDeleted (AChatItem _ _ chat deletedItem) toItem byUser timed -> unmuted chat deletedItem $ viewItemDelete chat deletedItem (isJust toItem) byUser timed ts - CRChatItemDeletedNotFound Contact {localDisplayName = c} _ -> [ttyFrom $ c <> "> [deleted - original message not found]"] - CRBroadcastSent mc n t -> viewSentBroadcast mc n ts t - CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr + CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile + CRGroupMemberInfo u g m cStats -> ttyUser u $ viewGroupMemberInfo g m cStats + CRContactSwitch u ct progress -> ttyUser u $ viewContactSwitch ct progress + CRGroupMemberSwitch u g m progress -> ttyUser u $ viewGroupMemberSwitch g m progress + CRConnectionVerified u verified code -> ttyUser u [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] + CRContactCode u ct code -> ttyUser u $ viewContactCode ct code testView + CRGroupMemberCode u g m code -> ttyUser u $ viewGroupMemberCode g m code testView + CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewChatItem chat item False ts + CRChatItems u chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems + CRChatItemId u itemId -> ttyUser u [plain $ maybe "no item" show itemId] + CRChatItemStatusUpdated u _ -> ttyUser u [] + CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewItemUpdate chat item liveItems ts + CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted chat deletedItem $ viewItemDelete chat deletedItem (isJust toItem) byUser timed ts + CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"] + CRBroadcastSent u mc n t -> ttyUser u $ viewSentBroadcast mc n ts t + CRMsgIntegrityError u mErr -> ttyUser u $ viewMsgIntegrityError mErr CRCmdAccepted _ -> [] - CRCmdOk -> ["ok"] + CRCmdOk u_ -> ttyUser' u_ ["ok"] CRChatHelp section -> case section of HSMain -> chatHelpInfo HSFiles -> filesHelpInfo @@ -98,117 +99,114 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case HSMarkdown -> markdownInfo HSSettings -> settingsInfo CRWelcome user -> chatWelcome user - CRContactsList cs -> viewContactsList cs - CRUserContactLink UserContactLink {connReqContact, autoAccept} -> connReqContact_ "Your chat address:" connReqContact <> autoAcceptStatus_ autoAccept - CRUserContactLinkUpdated UserContactLink {autoAccept} -> autoAcceptStatus_ autoAccept - CRContactRequestRejected UserContactRequest {localDisplayName = c} -> [ttyContact c <> ": contact request rejected"] - CRGroupCreated g -> viewGroupCreated g - CRGroupMembers g -> viewGroupMembers g - CRGroupsList gs -> viewGroupsList gs - CRSentGroupInvitation g c _ -> - if viaGroupLink . contactConn $ c - then [ttyContact' c <> " invited to group " <> ttyGroup' g <> " via your group link"] - else ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] - CRFileTransferStatus ftStatus -> viewFileTransferStatus ftStatus - CRUserProfile p -> viewUserProfile p - CRUserProfileNoChange -> ["user profile did not change"] + CRContactsList u cs -> ttyUser u $ viewContactsList cs + CRUserContactLink u UserContactLink {connReqContact, autoAccept} -> ttyUser u $ connReqContact_ "Your chat address:" connReqContact <> autoAcceptStatus_ autoAccept + CRUserContactLinkUpdated u UserContactLink {autoAccept} -> ttyUser u $ autoAcceptStatus_ autoAccept + CRContactRequestRejected u UserContactRequest {localDisplayName = c} -> ttyUser u [ttyContact c <> ": contact request rejected"] + CRGroupCreated u g -> ttyUser u $ viewGroupCreated g + CRGroupMembers u g -> ttyUser u $ viewGroupMembers g + CRGroupsList u gs -> ttyUser u $ viewGroupsList gs + CRSentGroupInvitation u g c _ -> + ttyUser u $ + if viaGroupLink . contactConn $ c + then [ttyContact' c <> " invited to group " <> ttyGroup' g <> " via your group link"] + else ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c] + CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus + CRUserProfile u p -> ttyUser u $ viewUserProfile p + CRUserProfileNoChange u -> ttyUser u ["user profile did not change"] CRVersionInfo info -> viewVersionInfo logLevel info - CRChatCmdError e -> viewChatError logLevel e - CRInvitation cReq -> viewConnReqInvitation cReq - CRSentConfirmation -> ["confirmation sent!"] - CRSentInvitation customUserProfile -> viewSentInvitation customUserProfile testView - CRContactDeleted c -> [ttyContact' c <> ": contact is deleted"] - CRChatCleared chatInfo -> viewChatCleared chatInfo - CRAcceptingContactRequest c -> [ttyFullContact c <> ": accepting contact request..."] - CRContactAlreadyExists c -> [ttyFullContact c <> ": contact already exists"] - CRContactRequestAlreadyAccepted c -> [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] - CRUserContactLinkCreated cReq -> connReqContact_ "Your new chat address is created!" cReq - CRUserContactLinkDeleted -> viewUserContactLinkDeleted - CRUserAcceptedGroupSent _g _ -> [] -- [ttyGroup' g <> ": joining the group..."] - CRUserDeletedMember g m -> [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] - CRLeftMemberUser g -> [ttyGroup' g <> ": you left the group"] <> groupPreserved g - CRGroupDeletedUser g -> [ttyGroup' g <> ": you deleted the group"] - CRRcvFileAccepted ci -> savingFile' ci - CRRcvFileAcceptedSndCancelled ft -> viewRcvFileSndCancelled ft - CRSndGroupFileCancelled _ ftm fts -> viewSndGroupFileCancelled ftm fts - CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft - CRUserProfileUpdated p p' -> viewUserProfileUpdated p p' - CRContactPrefsUpdated {fromContact, toContact} -> case user_ of - Just user -> viewUserContactPrefsUpdated user fromContact toContact - _ -> ["unexpected chat event CRContactPrefsUpdated without current user"] - CRContactAliasUpdated c -> viewContactAliasUpdated c - CRConnectionAliasUpdated c -> viewConnectionAliasUpdated c - CRContactUpdated {fromContact = c, toContact = c'} -> case user_ of - Just user -> viewContactUpdated c c' <> viewContactPrefsUpdated user c c' - _ -> ["unexpected chat event CRContactUpdated without current user"] - CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt - CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile - CRRcvFileStart ci -> receivingFile_' "started" ci - CRRcvFileComplete ci -> receivingFile_' "completed" ci - CRRcvFileSndCancelled ft -> viewRcvFileSndCancelled ft - CRSndFileStart _ ft -> sendingFile_ "started" ft - CRSndFileComplete _ ft -> sendingFile_ "completed" ft + CRInvitation u cReq -> ttyUser u $ viewConnReqInvitation cReq + CRSentConfirmation u -> ttyUser u ["confirmation sent!"] + CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView + CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] + CRChatCleared u chatInfo -> ttyUser u $ viewChatCleared chatInfo + CRAcceptingContactRequest u c -> ttyUser u [ttyFullContact c <> ": accepting contact request..."] + CRContactAlreadyExists u c -> ttyUser u [ttyFullContact c <> ": contact already exists"] + CRContactRequestAlreadyAccepted u c -> ttyUser u [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] + CRUserContactLinkCreated u cReq -> ttyUser u $ connReqContact_ "Your new chat address is created!" cReq + CRUserContactLinkDeleted u -> ttyUser u viewUserContactLinkDeleted + CRUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."] + CRUserDeletedMember u g m -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] + CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g + CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"] + CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci + CRRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft + CRSndGroupFileCancelled u _ ftm fts -> ttyUser u $ viewSndGroupFileCancelled ftm fts + CRRcvFileCancelled u ft -> ttyUser u $ receivingFile_ "cancelled" ft + CRUserProfileUpdated u p p' -> ttyUser u $ viewUserProfileUpdated p p' + CRContactPrefsUpdated {user = u, fromContact, toContact} -> ttyUser u $ viewUserContactPrefsUpdated u fromContact toContact + CRContactAliasUpdated u c -> ttyUser u $ viewContactAliasUpdated c + CRConnectionAliasUpdated u c -> ttyUser u $ viewConnectionAliasUpdated c + CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' + CRContactsMerged u intoCt mergedCt -> ttyUser u $ viewContactsMerged intoCt mergedCt + CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile + CRRcvFileStart u ci -> ttyUser u $ receivingFile_' "started" ci + CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' "completed" ci + CRRcvFileSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft + CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft + CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft CRSndFileCancelled _ ft -> sendingFile_ "cancelled" ft - CRSndFileRcvCancelled _ ft@SndFileTransfer {recipientDisplayName = c} -> - [ttyContact c <> " cancelled receiving " <> sndFile ft] - CRContactConnecting _ -> [] - CRContactConnected ct userCustomProfile -> viewContactConnected ct userCustomProfile testView - CRContactAnotherClient c -> [ttyContact' c <> ": contact is connected to another client"] - CRSubscriptionEnd acEntity -> [sShow (connId (entityConnection acEntity :: Connection)) <> ": END"] + CRSndFileRcvCancelled u _ ft@SndFileTransfer {recipientDisplayName = c} -> + ttyUser u [ttyContact c <> " cancelled receiving " <> sndFile ft] + CRContactConnecting u _ -> ttyUser u [] + CRContactConnected u ct userCustomProfile -> ttyUser u $ viewContactConnected ct userCustomProfile testView + CRContactAnotherClient u c -> ttyUser u [ttyContact' c <> ": contact is connected to another client"] + CRSubscriptionEnd u acEntity -> ttyUser u [sShow (connId (entityConnection acEntity :: Connection)) <> ": END"] CRContactsDisconnected srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] CRContactsSubscribed srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] CRContactSubError c e -> [ttyContact' c <> ": contact error " <> sShow e] - CRContactSubSummary summary -> - [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" + CRContactSubSummary u summary -> + ttyUser u $ [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" where (errors, subscribed) = partition (isJust . contactError) summary - CRUserContactSubSummary summary -> - map addressSS addresses - <> ([sShow (length groupLinksSubscribed) <> " group links active" | not (null groupLinksSubscribed)] <> viewErrorsSummary groupLinkErrors " group link errors") + CRUserContactSubSummary u summary -> + ttyUser u $ + map addressSS addresses + <> ([sShow (length groupLinksSubscribed) <> " group links active" | not (null groupLinksSubscribed)] <> viewErrorsSummary groupLinkErrors " group link errors") where (addresses, groupLinks) = partition (\UserContactSubStatus {userContact} -> isNothing . userContactGroupId $ userContact) summary addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks - CRGroupInvitation g -> [groupInvitation' g] - CRReceivedGroupInvitation g c role -> viewReceivedGroupInvitation g c role - CRUserJoinedGroup g _ -> viewUserJoinedGroup g - CRJoinedGroupMember g m -> viewJoinedGroupMember g m + CRGroupInvitation u g -> ttyUser u [groupInvitation' g] + CRReceivedGroupInvitation u g c role -> ttyUser u $ viewReceivedGroupInvitation g c role + CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g + CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m CRHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h] CRHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h] - CRJoinedGroupMemberConnecting g host m -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] - CRConnectedToGroupMember g m -> [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] - CRMemberRole g by m r r' -> viewMemberRoleChanged g by m r r' - CRMemberRoleUser g m r r' -> viewMemberRoleUserChanged g m r r' - CRDeletedMemberUser g by -> [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g - CRDeletedMember g by m -> [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] - CRLeftMember g m -> [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] - CRGroupEmpty g -> [ttyFullGroup g <> ": group is empty"] - CRGroupRemoved g -> [ttyFullGroup g <> ": you are no longer a member or group deleted"] - CRGroupDeleted g m -> [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"] - CRGroupUpdated g g' m -> viewGroupUpdated g g' m - CRGroupProfile g -> viewGroupProfile g - CRGroupLinkCreated g cReq -> groupLink_ "Group link is created!" g cReq - CRGroupLink g cReq -> groupLink_ "Group link:" g cReq - CRGroupLinkDeleted g -> viewGroupLinkDeleted g - CRAcceptingGroupJoinRequest g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' g <> "..."] - CRMemberSubError g m e -> [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] - CRMemberSubSummary summary -> viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" - CRGroupSubscribed g -> viewGroupSubscribed g - CRPendingSubSummary _ -> [] - CRSndFileSubError SndFileTransfer {fileId, fileName} e -> - ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - CRRcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> - ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - CRCallInvitation RcvCallInvitation {contact, callType, sharedKey} -> viewCallInvitation contact callType sharedKey - CRCallOffer {contact, callType, offer, sharedKey} -> viewCallOffer contact callType offer sharedKey - CRCallAnswer {contact, answer} -> viewCallAnswer contact answer - CRCallExtraInfo {contact} -> ["call extra info from " <> ttyContact' contact] - CRCallEnded {contact} -> ["call with " <> ttyContact' contact <> " ended"] + CRJoinedGroupMemberConnecting u g host m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] + CRConnectedToGroupMember u g m -> ttyUser u [ttyGroup' g <> ": " <> connectedMember m <> " is connected"] + CRMemberRole u g by m r r' -> ttyUser u $ viewMemberRoleChanged g by m r r' + CRMemberRoleUser u g m r r' -> ttyUser u $ viewMemberRoleUserChanged g m r r' + CRDeletedMemberUser u g by -> ttyUser u $ [ttyGroup' g <> ": " <> ttyMember by <> " removed you from the group"] <> groupPreserved g + CRDeletedMember u g by m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember by <> " removed " <> ttyMember m <> " from the group"] + CRLeftMember u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " left the group"] + CRGroupEmpty u g -> ttyUser u [ttyFullGroup g <> ": group is empty"] + CRGroupRemoved u g -> ttyUser u [ttyFullGroup g <> ": you are no longer a member or group deleted"] + CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"] + CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m + CRGroupProfile u g -> ttyUser u $ viewGroupProfile g + CRGroupLinkCreated u g cReq -> ttyUser u $ groupLink_ "Group link is created!" g cReq + CRGroupLink u g cReq -> ttyUser u $ groupLink_ "Group link:" g cReq + CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g + CRAcceptingGroupJoinRequest _ g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' g <> "..."] + CRMemberSubError u g m e -> ttyUser u [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] + CRMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" + CRGroupSubscribed u g -> ttyUser u $ viewGroupSubscribed g + CRPendingSubSummary u _ -> ttyUser u [] + CRSndFileSubError u SndFileTransfer {fileId, fileName} e -> + ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] + CRRcvFileSubError u RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> + ttyUser u ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] + CRCallInvitation RcvCallInvitation {user, contact, callType, sharedKey} -> ttyUser user $ viewCallInvitation contact callType sharedKey + CRCallOffer {user = u, contact, callType, offer, sharedKey} -> ttyUser u $ viewCallOffer contact callType offer sharedKey + CRCallAnswer {user = u, contact, answer} -> ttyUser u $ viewCallAnswer contact answer + CRCallExtraInfo {user = u, contact} -> ttyUser u ["call extra info from " <> ttyContact' contact] + CRCallEnded {user = u, contact} -> ttyUser u ["call with " <> ttyContact' contact <> " ended"] CRCallInvitations _ -> [] CRUserContactLinkSubscribed -> ["Your address is active! To show: " <> highlight' "/sa"] CRUserContactLinkSubError e -> ["user address error: " <> sShow e, "to delete your address: " <> highlight' "/da"] - CRNewContactConnection _ -> [] - CRContactConnectionDeleted PendingContactConnection {pccConnId} -> ["connection :" <> sShow pccConnId <> " deleted"] + CRNewContactConnection u _ -> ttyUser u [] + CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"] CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)] CRNtfToken _ status mode -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode)] CRNtfMessages {} -> [] @@ -219,9 +217,29 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case ] CRAgentStats stats -> map (plain . intercalate ",") stats CRConnectionDisabled entity -> viewConnectionEntityDisabled entity - CRMessageError prefix err -> [plain prefix <> ": " <> plain err | prefix == "error" || logLevel <= CLLWarning] - CRChatError e -> viewChatError logLevel e + CRAgentRcvQueueDeleted acId srv aqId err_ -> + [ "completed deleting rcv queue, agent connection id: " <> sShow acId + <> (", server: " <> sShow srv) + <> (", agent queue id: " <> sShow aqId) + <> maybe "" (\e -> ", error: " <> sShow e) err_ + | logLevel <= CLLInfo + ] + 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 + CRChatError u e -> ttyUser' u $ viewChatError logLevel e where + ttyUser :: User -> [StyledString] -> [StyledString] + ttyUser _ [] = [] + ttyUser User {userId, localDisplayName = u} ss = prependFirst userPrefix ss + where + userPrefix = case user_ of + Just User {userId = activeUserId} -> if userId /= activeUserId then prefix else "" + _ -> prefix + prefix = "[user: " <> highlight u <> "] " + ttyUser' :: Maybe User -> [StyledString] -> [StyledString] + ttyUser' = maybe id ttyUser testViewChats :: [AChat] -> [StyledString] testViewChats chats = [sShow $ map toChatView chats] where @@ -258,6 +276,16 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts = \case | muted chat chatItem = [] | otherwise = s +viewUsersList :: [UserInfo] -> [StyledString] +viewUsersList = map 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 + where + active = if activeUser then highlight' " (active)" else "" + unread = if count /= 0 then plain $ " (unread: " <> show count <> ")" else "" + muted :: ChatInfo c -> ChatItem c d -> Bool muted chat ChatItem {chatDir} = case (chat, chatDir) of (DirectChat Contact {chatSettings = DisableNtfs}, CIDirectRcv) -> True @@ -1155,7 +1183,11 @@ viewChatError :: ChatLogLevel -> ChatError -> [StyledString] viewChatError logLevel = \case ChatError err -> case err of CENoActiveUser -> ["error: active user is required"] + CENoConnectionUser agentConnId -> ["error: message user not found, conn id: " <> sShow agentConnId | logLevel <= CLLError] CEActiveUserExists -> ["error: active user already exists"] + 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"] CEChatNotStarted -> ["error: chat not started"] CEChatNotStopped -> ["error: chat not stopped"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"] @@ -1206,6 +1238,7 @@ viewChatError logLevel = \case -- e -> ["chat error: " <> sShow e] ChatErrorStore err -> case err of SEDuplicateName -> ["this display name is already used by user, contact or group"] + SEUserNotFoundByName u -> ["no user " <> ttyContact u] SEContactNotFoundByName c -> ["no contact " <> ttyContact c] SEContactNotReady c -> ["contact " <> ttyContact c <> " is not active yet"] SEGroupNotFoundByName g -> ["no group " <> ttyGroup g] diff --git a/stack.yaml b/stack.yaml index e4fcbd8de1..20253b58b8 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 56eea29ec36ba723fda6ae27a18e03a4e5aad6e8 + commit: b59669a65eafeb92d4a986a03e481ecb343ee307 # - ../direct-sqlcipher - github: simplex-chat/direct-sqlcipher commit: 34309410eb2069b029b8fc1872deb1e0db123294 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index e7353d985e..4c568153ba 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -25,7 +25,7 @@ import Simplex.Chat.Options import Simplex.Chat.Store import Simplex.Chat.Terminal import Simplex.Chat.Terminal.Output (newChatTerminal) -import Simplex.Chat.Types (Profile, User (..)) +import Simplex.Chat.Types (AgentUserId (..), Profile, User (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Client (ProtocolClientConfig (..), defaultNetworkConfig) @@ -110,7 +110,7 @@ testCfgV1 = testCfg {agentConfig = testAgentCfgV1} createTestChat :: ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC createTestChat cfg opts@ChatOpts {dbKey} dbPrefix profile = do db@ChatDatabase {chatStore} <- createChatDatabase (testDBPrefix <> dbPrefix) dbKey False - Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUser db' profile True + Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile True startTestChat_ db cfg opts user startTestChat :: ChatConfig -> ChatOpts -> String -> IO TestCC diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 5c4c3efee6..5fc0ed9596 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -175,6 +175,18 @@ chatTests = do describe "mute/unmute messages" $ do it "mute/unmute contact" testMuteContact it "mute/unmute group" testMuteGroup + describe "multiple users" $ do + it "create second user" testCreateSecondUser + it "multiple users subscribe and receive messages after restart" testUsersSubscribeAfterRestart + it "both users have contact link" testMultipleUserAddresses + it "create user with default servers" testCreateUserDefaultServers + it "create user with same servers" testCreateUserSameServers + it "delete user" testDeleteUser + it "users have different chat item TTL configuration, chat items expire" testUsersDifferentCIExpirationTTL + it "chat items expire after restart for all users according to per user configuration" testUsersRestartCIExpiration + 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 describe "chat item expiration" $ do it "set chat item TTL" testSetChatItemTTL describe "queue rotation" $ do @@ -247,13 +259,14 @@ testAddContact :: Spec testAddContact = versionTestMatrix2 runTestAddContact where runTestAddContact alice bob = do - alice ##> "/c" + alice ##> "/_connect 1" inv <- getInvitation alice - bob ##> ("/c " <> inv) + bob ##> ("/_connect 1 " <> inv) bob <## "confirmation sent!" concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") + threadDelay 100000 chatsEmpty alice bob alice #> "@bob hello there 🙂" bob <# "alice> hello there 🙂" @@ -330,7 +343,7 @@ testDeleteContactDeletesProfile = -- alice deletes contact, profile is deleted alice ##> "/d bob" alice <## "bob: contact is deleted" - alice ##> "/contacts" + alice ##> "/_contacts 1" (alice "/profile_image" alice <## "profile image removed" - alice ##> "/_profile {\"displayName\": \"alice2\", \"fullName\": \"\"}" + alice ##> "/_profile 1 {\"displayName\": \"alice2\", \"fullName\": \"\"}" alice <## "user profile is changed to alice2 (your contacts are notified)" bob <## "contact alice changed to alice2" bob <## "use @alice2 to send messages" @@ -2385,9 +2398,8 @@ testFilesFoldersImageSndDelete = checkActionDeletesFile "./tests/tmp/alice_app_files/test_1MB.pdf" $ do alice ##> "/d bob" alice <## "bob: contact is deleted" - bob <## "alice cancelled sending file 1 (test_1MB.pdf)" bob ##> "/fs 1" - bob <## "receiving file 1 (test_1MB.pdf) cancelled, received part path: test_1MB.pdf" + bob <##. "receiving file 1 (test_1MB.pdf) progress" -- deleting contact should remove cancelled file checkActionDeletesFile "./tests/tmp/bob_app_files/test_1MB.pdf" $ do bob ##> "/d alice" @@ -2608,6 +2620,7 @@ testUserContactLink = versionTestMatrix3 $ \alice bob cath -> do concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") + threadDelay 100000 alice @@@ [("@bob", "Voice messages: enabled")] alice <##> bob @@ -2619,6 +2632,7 @@ testUserContactLink = versionTestMatrix3 $ \alice bob cath -> do concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") + threadDelay 100000 alice @@@ [("@cath", "Voice messages: enabled"), ("@bob", "hey")] alice <##> cath @@ -2721,6 +2735,7 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") + threadDelay 100000 alice @@@ [("@cath", "Voice messages: enabled"), ("@bob", "hey")] alice <##> cath @@ -2793,13 +2808,14 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile concurrently_ (cath <## "alice (Alice): contact is connected") (alice <## "cath (Catherine): contact is connected") + threadDelay 100000 alice @@@ [("@cath", "Voice messages: enabled"), ("@robert", "hey")] alice <##> cath testRejectContactAndDeleteUserContact :: IO () testRejectContactAndDeleteUserContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - alice ##> "/ad" + alice ##> "/_address 1" cLink <- getContactLink alice True bob ##> ("/c " <> cLink) alice <#? bob @@ -2807,12 +2823,12 @@ testRejectContactAndDeleteUserContact = testChat3 aliceProfile bobProfile cathPr alice <## "bob: contact request rejected" (bob "/sa" + alice ##> "/_show_address 1" cLink' <- getContactLink alice False alice <## "auto_accept off" cLink' `shouldBe` cLink - alice ##> "/da" + alice ##> "/_delete_address 1" alice <## "Your chat address is deleted - accepted contacts will remain connected." alice <## "To create a new chat address use /ad" @@ -2846,7 +2862,7 @@ testAutoReplyMessage = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/ad" cLink <- getContactLink alice True - alice ##> "/auto_accept on incognito=off text hello!" + alice ##> "/_auto_accept 1 on incognito=off text hello!" alice <## "auto_accept on" alice <## "auto reply:" alice <## "hello!" @@ -3281,7 +3297,7 @@ testCantSeeGlobalPrefsUpdateIncognito = testChat3 aliceProfile bobProfile cathPr cath <## "alice (Alice): contact is connected" ] alice <## "cath (Catherine): contact is connected" - alice ##> "/_profile {\"displayName\": \"alice\", \"fullName\": \"\", \"preferences\": {\"fullDelete\": {\"allow\": \"always\"}}}" + alice ##> "/_profile 1 {\"displayName\": \"alice\", \"fullName\": \"\", \"preferences\": {\"fullDelete\": {\"allow\": \"always\"}}}" alice <## "user full name removed (your contacts are notified)" alice <## "updated preferences:" alice <## "Full deletion allowed: always" @@ -3439,6 +3455,7 @@ testSetConnectionAlias = testChat2 aliceProfile bobProfile $ concurrently_ (alice <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") + threadDelay 100000 alice @@@ [("@bob", "Voice messages: enabled")] alice ##> "/contacts" alice <## "bob (Bob) (alias: friend)" @@ -3452,7 +3469,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ createDirectoryIfMissing True "./tests/tmp/bob" copyFile "./tests/fixtures/test.txt" "./tests/tmp/alice/test.txt" copyFile "./tests/fixtures/test.txt" "./tests/tmp/bob/test.txt" - bob ##> "/_profile {\"displayName\": \"bob\", \"fullName\": \"Bob\", \"preferences\": {\"voice\": {\"allow\": \"no\"}}}" + bob ##> "/_profile 1 {\"displayName\": \"bob\", \"fullName\": \"Bob\", \"preferences\": {\"voice\": {\"allow\": \"no\"}}}" bob <## "profile image removed" bob <## "updated preferences:" bob <## "Voice messages allowed: no" @@ -3489,7 +3506,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ alice <## "started receiving file 1 (test.txt) from bob" alice <## "completed receiving file 1 (test.txt) from bob" (bob "/_profile {\"displayName\": \"alice\", \"fullName\": \"Alice\", \"preferences\": {\"voice\": {\"allow\": \"no\"}}}" + -- alice ##> "/_profile 1 {\"displayName\": \"alice\", \"fullName\": \"Alice\", \"preferences\": {\"voice\": {\"allow\": \"no\"}}}" alice ##> "/set voice no" alice <## "updated preferences:" alice <## "Voice messages allowed: no" @@ -3502,7 +3519,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ bob <## "Voice messages: off (you allow: default (no), contact allows: yes)" bob #$> ("/_get chat @2 count=100", chat, startFeatures <> [(0, "Voice messages: enabled for you"), (1, "voice message (00:10)"), (0, "Voice messages: off")]) (bob "/_profile {\"displayName\": \"bob\", \"fullName\": \"\", \"preferences\": {\"voice\": {\"allow\": \"yes\"}}}" + bob ##> "/_profile 1 {\"displayName\": \"bob\", \"fullName\": \"\", \"preferences\": {\"voice\": {\"allow\": \"yes\"}}}" bob <## "user full name removed (your contacts are notified)" bob <## "updated preferences:" bob <## "Voice messages allowed: yes" @@ -3815,7 +3832,7 @@ testGetSetSMPServers :: IO () testGetSetSMPServers = testChat2 aliceProfile bobProfile $ \alice _ -> do - alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001") + alice #$> ("/_smp 1", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001") alice #$> ("/smp smp://1234-w==@smp1.example.im", id, "ok") alice #$> ("/smp", id, "smp://1234-w==@smp1.example.im") alice #$> ("/smp smp://1234-w==:password@smp1.example.im", id, "ok") @@ -3829,14 +3846,14 @@ testTestSMPServerConnection :: IO () testTestSMPServerConnection = testChat2 aliceProfile bobProfile $ \alice _ -> do - alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001" + alice ##> "/smp test 1 smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001" alice <## "SMP server test passed" -- to test with password: -- alice <## "SMP server test failed at CreateQueue, error: SMP AUTH" -- alice <## "Server requires authorization to create queues, check password" - alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001" + alice ##> "/smp test 1 smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001" alice <## "SMP server test passed" - alice ##> "/smp test smp://LcJU@localhost:5001" + alice ##> "/smp test 1 smp://LcJU@localhost:5001" alice <## "SMP server test failed at Connect, error: BROKER smp://LcJU@localhost:5001 NETWORK" alice <## "Possibly, certificate fingerprint in server address is incorrect" @@ -3844,6 +3861,7 @@ testAsyncInitiatingOffline :: IO () testAsyncInitiatingOffline = withTmpFiles $ do putStrLn "testAsyncInitiatingOffline" inv <- withNewTestChat "alice" aliceProfile $ \alice -> do + threadDelay 250000 putStrLn "1" alice ##> "/c" putStrLn "2" @@ -4410,6 +4428,660 @@ testMuteGroup = bob ##> "/gs" bob <## "#team" +testCreateSecondUser :: IO () +testCreateSecondUser = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> 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" + + alice ##> "/user" + showActiveUser alice "alisa" + + alice ##> "/users" + alice <## "alice (Alice)" + alice <## "alisa (active)" + + -- receive message to first user + bob #> "@alice hey alice" + (alice, "alice") $<# "bob> hey alice" + + connectUsers alice cath + + -- set active user to first user + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + alice ##> "/user" + showActiveUser alice "alice (Alice)" + + alice ##> "/users" + alice <## "alice (Alice) (active)" + alice <## "alisa" + + alice <##> bob + + cath #> "@alisa hey alisa" + (alice, "alisa") $<# "cath> hey alisa" + alice ##> "@cath hi cath" + alice <## "no contact cath" + + -- set active user to second user + alice ##> "/_user 2" + showActiveUser alice "alisa" + +testUsersSubscribeAfterRestart :: IO () +testUsersSubscribeAfterRestart = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChat "alice" aliceProfile $ \alice -> do + connectUsers alice bob + alice <##> bob + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + connectUsers alice bob + alice <##> bob + + withTestChat "alice" $ \alice -> do + -- second user is active + alice <## "1 contacts connected (use /cs for the list)" + alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + + -- second user receives message + alice <##> bob + + -- first user receives message + bob #> "@alice hey alice" + (alice, "alice") $<# "bob> hey alice" + +testMultipleUserAddresses :: IO () +testMultipleUserAddresses = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + alice ##> "/ad" + cLinkAlice <- getContactLink alice True + bob ##> ("/c " <> cLinkAlice) + alice <#? bob + alice @@@ [("<@bob", "")] + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + threadDelay 100000 + alice @@@ [("@bob", "Voice messages: enabled")] + alice <##> bob + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + + -- connect using second user address + alice ##> "/ad" + cLinkAlisa <- getContactLink alice True + bob ##> ("/c " <> cLinkAlisa) + alice <#? bob + alice #$> ("/_get chats 2 pcc=on", chats, [("<@bob", "")]) + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alisa: contact is connected") + (alice <## "bob (Bob): contact is connected") + threadDelay 100000 + alice #$> ("/_get chats 2 pcc=on", chats, [("@bob", "Voice messages: enabled")]) + alice <##> bob + + bob #> "@alice hey alice" + (alice, "alice") $<# "bob> hey alice" + + -- delete first user address + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + alice ##> "/da" + alice <## "Your chat address is deleted - accepted contacts will remain connected." + alice <## "To create a new chat address use /ad" + + -- second user receives request when not active + cath ##> ("/c " <> cLinkAlisa) + cath <## "connection request sent!" + alice <## "[user: alisa] cath (Catherine) wants to connect to you!" + alice <## "to accept: /ac cath" + alice <## "to reject: /rc cath (the sender will NOT be notified)" + + -- accept request + alice ##> "/user alisa" + showActiveUser alice "alisa" + + alice ##> "/ac cath" + alice <## "cath (Catherine): accepting contact request..." + concurrently_ + (cath <## "alisa: contact is connected") + (alice <## "cath (Catherine): contact is connected") + threadDelay 100000 + alice #$> ("/_get chats 2 pcc=on", chats, [("@cath", "Voice messages: enabled"), ("@bob", "hey")]) + alice <##> cath + + -- first user doesn't have cath as contact + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice @@@ [("@bob", "hey alice")] + +testCreateUserDefaultServers :: IO () +testCreateUserDefaultServers = + testChat2 aliceProfile bobProfile $ + \alice _ -> do + alice #$> ("/smp smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok") + alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224") + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + + alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001") + + -- with same_smp=off + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224") + + alice ##> "/create user same_smp=off alisa2" + showActiveUser alice "alisa2" + + alice #$> ("/smp", id, "smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:5001") + +testCreateUserSameServers :: IO () +testCreateUserSameServers = + testChat2 aliceProfile bobProfile $ + \alice _ -> do + alice #$> ("/smp smp://2345-w==@smp2.example.im;smp://3456-w==@smp3.example.im:5224", id, "ok") + alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224") + + alice ##> "/create user same_smp=on alisa" + showActiveUser alice "alisa" + + alice #$> ("/smp", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224") + +testDeleteUser :: IO () +testDeleteUser = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + connectUsers alice bob + + -- cannot delete active user + + alice ##> "/_delete user 1 del_smp=off" + alice <## "cannot delete active user" + + -- delete user without deleting SMP queues + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + + connectUsers alice cath + alice <##> cath + + alice ##> "/users" + alice <## "alice (Alice)" + alice <## "alisa (active)" + + alice ##> "/_delete user 1 del_smp=off" + alice <## "ok" + + alice ##> "/users" + alice <## "alisa (active)" + + bob #> "@alice hey" + -- no connection authorization error - connection wasn't deleted + (alice "/delete user alisa" + alice <## "cannot delete active user" + + alice ##> "/users" + alice <## "alisa (active)" + + alice <##> cath + + -- delete user deleting SMP queues + + alice ##> "/create user alisa2" + showActiveUser alice "alisa2" + + connectUsers alice dan + alice <##> dan + + alice ##> "/users" + alice <## "alisa" + alice <## "alisa2 (active)" + + alice ##> "/delete user alisa" + alice <### ["ok", "completed deleting user"] + + alice ##> "/users" + alice <## "alisa2 (active)" + + cath #> "@alisa hey" + cath <## "[alisa, contactId: 2, connId: 1] error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" + (alice dan + +testUsersDifferentCIExpirationTTL :: IO () +testUsersDifferentCIExpirationTTL = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChatCfg cfg "alice" aliceProfile $ \alice -> do + -- first user messages + connectUsers alice bob + + alice #> "@bob alice 1" + bob <# "alice> alice 1" + bob #> "@alice alice 2" + alice <# "bob> alice 2" + + -- second user messages + alice ##> "/create user alisa" + showActiveUser alice "alisa" + connectUsers alice bob + + alice #> "@bob alisa 1" + bob <# "alisa> alisa 1" + bob #> "@alisa alisa 2" + alice <# "bob> alisa 2" + + -- set ttl for first user + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_ttl 1 1", id, "ok") + + -- set ttl for second user + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_ttl 2 3", id, "ok") + + -- first user messages + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/ttl", id, "old messages are set to be deleted after: 1 second(s)") + + alice #> "@bob alice 3" + bob <# "alice> alice 3" + bob #> "@alice alice 4" + alice <# "bob> alice 4" + + alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "alice 1"), (0, "alice 2"), (1, "alice 3"), (0, "alice 4")]) + + -- second user messages + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/ttl", id, "old messages are set to be deleted after: 3 second(s)") + + alice #> "@bob alisa 3" + bob <# "alisa> alisa 3" + bob #> "@alisa alisa 4" + alice <# "bob> alisa 4" + + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + + threadDelay 2000000 + + -- messages both before and after setting chat item ttl are deleted + -- first user messages + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_get chat @2 count=100", chat, []) + + -- second user messages + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + + threadDelay 2000000 + + alice #$> ("/_get chat @4 count=100", chat, []) + where + cfg = testCfg {ciExpirationInterval = 500000} + +testUsersRestartCIExpiration :: IO () +testUsersRestartCIExpiration = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChatCfg cfg "alice" aliceProfile $ \alice -> do + -- set ttl for first user + alice #$> ("/_ttl 1 1", id, "ok") + connectUsers alice bob + + -- create second user and set ttl + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice #$> ("/_ttl 2 3", id, "ok") + connectUsers alice bob + + -- first user messages + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + alice #> "@bob alice 1" + bob <# "alice> alice 1" + bob #> "@alice alice 2" + alice <# "bob> alice 2" + + -- second user messages + alice ##> "/user alisa" + showActiveUser alice "alisa" + + alice #> "@bob alisa 1" + bob <# "alisa> alisa 1" + bob #> "@alisa alisa 2" + alice <# "bob> alisa 2" + + -- first user will be active on restart + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + withTestChatCfg cfg "alice" $ \alice -> do + alice <## "1 contacts connected (use /cs for the list)" + alice <## "[user: alisa] 1 contacts connected (use /cs for the list)" + + -- first user messages + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/ttl", id, "old messages are set to be deleted after: 1 second(s)") + + alice #> "@bob alice 3" + bob <# "alice> alice 3" + bob #> "@alice alice 4" + alice <# "bob> alice 4" + + alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "alice 1"), (0, "alice 2"), (1, "alice 3"), (0, "alice 4")]) + + -- second user messages + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/ttl", id, "old messages are set to be deleted after: 3 second(s)") + + alice #> "@bob alisa 3" + bob <# "alisa> alisa 3" + bob #> "@alisa alisa 4" + alice <# "bob> alisa 4" + + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + + threadDelay 2000000 + + -- messages both before and after restart are deleted + -- first user messages + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_get chat @2 count=100", chat, []) + + -- second user messages + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + + threadDelay 2000000 + + alice #$> ("/_get chat @4 count=100", chat, []) + where + cfg = testCfg {ciExpirationInterval = 500000} + +testEnableCIExpirationOnlyForOneUser :: IO () +testEnableCIExpirationOnlyForOneUser = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChatCfg cfg "alice" aliceProfile $ \alice -> do + -- first user messages + connectUsers alice bob + + alice #> "@bob alice 1" + bob <# "alice> alice 1" + bob #> "@alice alice 2" + alice <# "bob> alice 2" + + alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "alice 1"), (0, "alice 2")]) + + -- second user messages before first user sets ttl + alice ##> "/create user alisa" + showActiveUser alice "alisa" + connectUsers alice bob + + alice #> "@bob alisa 1" + bob <# "alisa> alisa 1" + bob #> "@alisa alisa 2" + alice <# "bob> alisa 2" + + -- set ttl for first user + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_ttl 1 1", id, "ok") + + -- second user messages after first user sets ttl + alice ##> "/user alisa" + showActiveUser alice "alisa" + + alice #> "@bob alisa 3" + bob <# "alisa> alisa 3" + bob #> "@alisa alisa 4" + alice <# "bob> alisa 4" + + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + + threadDelay 2000000 + + -- messages are deleted for first user + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_get chat @2 count=100", chat, []) + + -- messages are not deleted for second user + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + + withTestChatCfg cfg "alice" $ \alice -> do + alice <## "1 contacts connected (use /cs for the list)" + alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + + -- messages are not deleted for second user after restart + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) + + alice #> "@bob alisa 5" + bob <# "alisa> alisa 5" + bob #> "@alisa alisa 6" + alice <# "bob> alisa 6" + + threadDelay 2000000 + + -- new messages are not deleted for second user + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4"), (1, "alisa 5"), (0, "alisa 6")]) + where + cfg = testCfg {ciExpirationInterval = 500000} + +testDisableCIExpirationOnlyForOneUser :: IO () +testDisableCIExpirationOnlyForOneUser = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChatCfg cfg "alice" aliceProfile $ \alice -> do + -- set ttl for first user + alice #$> ("/_ttl 1 1", id, "ok") + connectUsers alice bob + + -- create second user and set ttl + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice #$> ("/_ttl 2 1", id, "ok") + connectUsers alice bob + + -- first user disables expiration + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/ttl none", id, "ok") + alice #$> ("/ttl", id, "old messages are not being deleted") + + -- second user still has ttl configured + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/ttl", id, "old messages are set to be deleted after: 1 second(s)") + + alice #> "@bob alisa 1" + bob <# "alisa> alisa 1" + bob #> "@alisa alisa 2" + alice <# "bob> alisa 2" + + alice #$> ("/_get chat @4 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2")]) + + threadDelay 2000000 + + -- second user messages are deleted + alice #$> ("/_get chat @4 count=100", chat, []) + + withTestChatCfg cfg "alice" $ \alice -> do + alice <## "1 contacts connected (use /cs for the list)" + alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + + -- second user still has ttl configured after restart + alice #$> ("/ttl", id, "old messages are set to be deleted after: 1 second(s)") + + alice #> "@bob alisa 3" + bob <# "alisa> alisa 3" + bob #> "@alisa alisa 4" + alice <# "bob> alisa 4" + + alice #$> ("/_get chat @4 count=100", chat, [(1, "alisa 3"), (0, "alisa 4")]) + + threadDelay 2000000 + + -- second user messages are deleted + alice #$> ("/_get chat @4 count=100", chat, []) + where + cfg = testCfg {ciExpirationInterval = 500000} + +testUsersTimedMessages :: IO () +testUsersTimedMessages = withTmpFiles $ do + withNewTestChat "bob" bobProfile $ \bob -> do + withNewTestChat "alice" aliceProfile $ \alice -> do + connectUsers alice bob + configureTimedMessages alice bob "2" "1" + + -- create second user and configure timed messages for contact + alice ##> "/create user alisa" + showActiveUser alice "alisa" + connectUsers alice bob + configureTimedMessages alice bob "4" "2" + + -- first user messages + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + alice #> "@bob alice 1" + bob <# "alice> alice 1" + bob #> "@alice alice 2" + alice <# "bob> alice 2" + + -- second user messages + alice ##> "/user alisa" + showActiveUser alice "alisa" + + alice #> "@bob alisa 1" + bob <# "alisa> alisa 1" + bob #> "@alisa alisa 2" + alice <# "bob> alisa 2" + + -- messages are deleted after ttl + threadDelay 500000 + + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_get chat @2 count=100", chat, [(1, "alice 1"), (0, "alice 2")]) + + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, [(1, "alisa 1"), (0, "alisa 2")]) + + threadDelay 1000000 + + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_get chat @2 count=100", chat, []) + + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, [(1, "alisa 1"), (0, "alisa 2")]) + + threadDelay 1000000 + + alice ##> "/user" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, []) + + -- first user messages + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + alice #> "@bob alice 3" + bob <# "alice> alice 3" + bob #> "@alice alice 4" + alice <# "bob> alice 4" + + -- second user messages + alice ##> "/user alisa" + showActiveUser alice "alisa" + + alice #> "@bob alisa 3" + bob <# "alisa> alisa 3" + bob #> "@alisa alisa 4" + alice <# "bob> alisa 4" + + withTestChat "alice" $ \alice -> do + alice <## "1 contacts connected (use /cs for the list)" + alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_get chat @2 count=100", chat, [(1, "alice 3"), (0, "alice 4")]) + + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, [(1, "alisa 3"), (0, "alisa 4")]) + + -- messages are deleted after restart + threadDelay 1500000 + + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice #$> ("/_get chat @2 count=100", chat, []) + + alice ##> "/user alisa" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, [(1, "alisa 3"), (0, "alisa 4")]) + + threadDelay 1000000 + + alice ##> "/user" + showActiveUser alice "alisa" + alice #$> ("/_get chat @4 count=100", chat, []) + where + configureTimedMessages alice bob bobId ttl = do + aliceName <- userName alice + alice ##> ("/_set prefs @" <> bobId <> " {\"timedMessages\": {\"allow\": \"yes\", \"ttl\": " <> ttl <> "}}") + alice <## "you updated preferences for bob:" + alice <## ("Disappearing messages: off (you allow: yes (" <> ttl <> " sec), contact allows: no)") + bob <## (aliceName <> " updated preferences for you:") + bob <## ("Disappearing messages: off (you allow: no, contact allows: yes (" <> ttl <> " sec))") + bob ##> ("/set disappear @" <> aliceName <> " yes") + bob <## ("you updated preferences for " <> aliceName <> ":") + bob <## ("Disappearing messages: enabled (you allow: yes (" <> ttl <> " sec), contact allows: yes (" <> ttl <> " sec))") + alice <## "bob updated preferences for you:" + 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 + testSetChatItemTTL :: IO () testSetChatItemTTL = testChat2 aliceProfile bobProfile $ @@ -4435,10 +5107,10 @@ testSetChatItemTTL = alice <# "bob> 4" alice #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((1, "1"), Nothing), ((0, "2"), Nothing), ((1, ""), Just "test.jpg"), ((1, "3"), Nothing), ((0, "4"), Nothing)]) checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ - alice #$> ("/_ttl 2", id, "ok") + alice #$> ("/_ttl 1 2", id, "ok") alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4")]) -- when expiration is turned on, first cycle is synchronous bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "1"), (1, "2"), (0, ""), (0, "3"), (1, "4")]) - alice #$> ("/ttl", id, "old messages are set to be deleted after: 2 second(s)") + alice #$> ("/_ttl 1", id, "old messages are set to be deleted after: 2 second(s)") alice #$> ("/ttl week", id, "ok") alice #$> ("/ttl", id, "old messages are set to be deleted after: one week") alice #$> ("/ttl none", id, "ok") @@ -4476,6 +5148,7 @@ testGroupLink = bob <## "alice (Alice): contact is connected" bob <## "#team: you joined the group" ] + threadDelay 100000 alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) -- contacts connected via group link are not in chat previews alice @@@ [("#team", "connected")] @@ -4622,6 +5295,7 @@ testGroupLinkContactUsed = bob <## "#team: you joined the group" ] -- sending/receiving a message marks contact as used + threadDelay 100000 alice @@@ [("#team", "connected")] bob @@@ [("#team", "connected")] alice #> "@bob hello" @@ -5052,7 +5726,7 @@ connectUsers cc1 cc2 = do showName :: TestCC -> IO String showName (TestCC ChatController {currentUser} _ _ _ _) = do Just User {localDisplayName, profile = LocalProfile {fullName}} <- readTVarIO currentUser - pure . T.unpack $ localDisplayName <> " (" <> fullName <> ")" + pure . T.unpack $ localDisplayName <> optionalFullName localDisplayName fullName createGroup2 :: String -> TestCC -> TestCC -> IO () createGroup2 gName cc1 cc2 = do @@ -5162,14 +5836,20 @@ itemId :: Int -> String itemId i = show $ length chatFeatures + i (@@@) :: TestCC -> [(String, String)] -> Expectation -(@@@) = getChats . map $ \(ldn, msg, _) -> (ldn, msg) +(@@@) = getChats mapChats + +mapChats :: [(String, String, Maybe ConnStatus)] -> [(String, String)] +mapChats = map $ \(ldn, msg, _) -> (ldn, msg) + +chats :: String -> [(String, String)] +chats = mapChats . read (@@@!) :: TestCC -> [(String, String, Maybe ConnStatus)] -> Expectation (@@@!) = getChats id getChats :: (Eq a, Show a) => ([(String, String, Maybe ConnStatus)] -> [a]) -> TestCC -> [a] -> Expectation getChats f cc res = do - cc ##> "/_get chats pcc=on" + cc ##> "/_get chats 1 pcc=on" line <- getTermLine cc f (read line) `shouldMatchList` res @@ -5236,6 +5916,9 @@ cc <# line = (dropTime <$> getTermLine cc) `shouldReturn` line (?<#) :: TestCC -> String -> Expectation cc ?<# line = (dropTime <$> getTermLine cc) `shouldReturn` "i " <> line +($<#) :: (TestCC, String) -> String -> Expectation +(cc, uName) $<# line = (dropTime . dropUser uName <$> getTermLine cc) `shouldReturn` line + ( Expectation ( name) cc1 <## ("to reject: /rc " <> name <> " (the sender will NOT be notified)") +dropUser :: String -> String -> String +dropUser uName msg = fromMaybe err $ dropUser_ uName msg + where + err = error $ "invalid user: " <> msg + +dropUser_ :: String -> String -> Maybe String +dropUser_ uName msg = do + let userPrefix = "[user: " <> uName <> "] " + if userPrefix `isPrefixOf` msg + then Just $ drop (length userPrefix) msg + else Nothing + dropTime :: String -> String dropTime msg = fromMaybe err $ dropTime_ msg where @@ -5307,3 +6002,9 @@ lastItemId :: TestCC -> IO String lastItemId cc = do cc ##> "/last_item_id" getTermLine cc + +showActiveUser :: TestCC -> String -> Expectation +showActiveUser cc name = do + cc <## ("user profile: " <> name) + cc <## "use /p [] to change it" + cc <## "(the updated profile will be sent to all your contacts)" diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 1f43b8c6ae..c19e6069e4 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -7,7 +7,7 @@ import ChatTests import Control.Monad.Except import Simplex.Chat.Mobile import Simplex.Chat.Store -import Simplex.Chat.Types (Profile (..)) +import Simplex.Chat.Types (AgentUserId (..), Profile (..)) import Test.Hspec mobileTests :: Spec @@ -25,16 +25,16 @@ noActiveUser = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"e activeUserExists :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUserExists = "{\"resp\":{\"chatCmdError\":{\"chatError\":{\"error\":{\"errorType\":{\"activeUserExists\":{}}}}}}}" +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\":{\"errorStore\":{\"storeError\":{\"duplicateName\":{}}}}}}}" #else -activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"activeUserExists\"}}}}" +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\":\"errorStore\",\"storeError\":{\"type\":\"duplicateName\"}}}}" #endif activeUser :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":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}}}}" #else -activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":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}}}" #endif chatStarted :: String @@ -46,32 +46,35 @@ chatStarted = "{\"resp\":{\"type\":\"chatStarted\"}}" contactSubSummary :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -contactSubSummary = "{\"resp\":{\"contactSubSummary\":{\"contactSubscriptions\":[]}}}" +contactSubSummary = "{\"resp\":{\"contactSubSummary\":{" <> userJSON <> ",\"contactSubscriptions\":[]}}}" #else -contactSubSummary = "{\"resp\":{\"type\":\"contactSubSummary\",\"contactSubscriptions\":[]}}" +contactSubSummary = "{\"resp\":{\"type\":\"contactSubSummary\"," <> userJSON <> ",\"contactSubscriptions\":[]}}" #endif memberSubSummary :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -memberSubSummary = "{\"resp\":{\"memberSubSummary\":{\"memberSubscriptions\":[]}}}" +memberSubSummary = "{\"resp\":{\"memberSubSummary\":{" <> userJSON <> ",\"memberSubscriptions\":[]}}}" #else -memberSubSummary = "{\"resp\":{\"type\":\"memberSubSummary\",\"memberSubscriptions\":[]}}" +memberSubSummary = "{\"resp\":{\"type\":\"memberSubSummary\"," <> userJSON <> ",\"memberSubscriptions\":[]}}" #endif userContactSubSummary :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -userContactSubSummary = "{\"resp\":{\"userContactSubSummary\":{\"userContactSubscriptions\":[]}}}" +userContactSubSummary = "{\"resp\":{\"userContactSubSummary\":{" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" #else -userContactSubSummary = "{\"resp\":{\"type\":\"userContactSubSummary\",\"userContactSubscriptions\":[]}}" +userContactSubSummary = "{\"resp\":{\"type\":\"userContactSubSummary\"," <> userJSON <> ",\"userContactSubscriptions\":[]}}" #endif pendingSubSummary :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -pendingSubSummary = "{\"resp\":{\"pendingSubSummary\":{\"pendingSubscriptions\":[]}}}" +pendingSubSummary = "{\"resp\":{\"pendingSubSummary\":{" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" #else -pendingSubSummary = "{\"resp\":{\"type\":\"pendingSubSummary\",\"pendingSubscriptions\":[]}}" +pendingSubSummary = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <> ",\"pendingSubscriptions\":[]}}" #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\":false}" + parsedMarkdown :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) parsedMarkdown = "{\"formattedText\":[{\"format\":{\"bold\":{}},\"text\":\"hello\"}]}" @@ -85,7 +88,7 @@ testChatApiNoUser = withTmpFiles $ do Left (DBMErrorNotADatabase _) <- chatMigrateInit testDBPrefix "myKey" chatSendCmd cc "/u" `shouldReturn` noActiveUser chatSendCmd cc "/_start" `shouldReturn` noActiveUser - chatSendCmd cc "/u alice Alice" `shouldReturn` activeUser + chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUser chatSendCmd cc "/_start" `shouldReturn` chatStarted testChatApi :: IO () @@ -93,12 +96,12 @@ testChatApi = withTmpFiles $ do let dbPrefix = testDBPrefix <> "1" f = chatStoreFile dbPrefix st <- createChatStore f "myKey" True - Right _ <- withTransaction st $ \db -> runExceptT $ createUser db aliceProfile {preferences = Nothing} True + Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True Right cc <- chatMigrateInit dbPrefix "myKey" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "anotherKey" chatSendCmd cc "/u" `shouldReturn` activeUser - chatSendCmd cc "/u alice Alice" `shouldReturn` activeUserExists + chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted chatRecvMsg cc `shouldReturn` contactSubSummary chatRecvMsg cc `shouldReturn` userContactSubSummary