From d37f493c6a6fcca20213c1b28f32393e588a9fb7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 20 Feb 2022 21:17:24 +0000 Subject: [PATCH] android: add chat info page, delete contacts, show network connection status for contacts, improve error handling --- .../java/chat/simplex/app/MainActivity.kt | 83 +++--- .../java/chat/simplex/app/model/ChatModel.kt | 74 ++--- .../java/chat/simplex/app/model/SimpleXAPI.kt | 278 +++++++++++++++--- .../chat/simplex/app/views/TerminalView.kt | 2 +- .../simplex/app/views/chat/ChatInfoView.kt | 130 ++++++++ .../chat/simplex/app/views/chat/ChatView.kt | 71 +++-- .../simplex/app/views/chat/item/CIMetaView.kt | 6 +- .../app/views/chatlist/ChatListView.kt | 2 +- .../app/views/chatlist/ChatPreviewView.kt | 31 +- .../app/views/helpers/ChatInfoImage.kt | 44 +++ .../simplex/app/views/helpers/SimpleButton.kt | 13 +- apps/ios/Shared/Model/SimpleXAPI.swift | 8 +- 12 files changed, 558 insertions(+), 184 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index 64d2a26e9f..ac74e93a0d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -16,6 +16,7 @@ import androidx.navigation.compose.* import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.* +import chat.simplex.app.views.chat.ChatInfoView import chat.simplex.app.views.chat.ChatView import chat.simplex.app.views.chatlist.ChatListView import chat.simplex.app.views.helpers.withApi @@ -50,14 +51,10 @@ class SimplexViewModel(application: Application) : AndroidViewModel(application) @ExperimentalMaterialApi @Composable fun MainPage(chatModel: ChatModel, nav: NavController) { - Box { - if (chatModel.currentUser.value == null) WelcomeView(chatModel) { - nav.navigate(Pages.ChatList.route) - } else { - ChatListView(chatModel, nav) - } - val am = chatModel.alertManager - if (am.presentAlert.value) am.alertView.value?.invoke() + if (chatModel.currentUser.value == null) WelcomeView(chatModel) { + nav.navigate(Pages.ChatList.route) + } else { + ChatListView(chatModel, nav) } } @@ -68,40 +65,47 @@ fun MainPage(chatModel: ChatModel, nav: NavController) { fun Navigation(chatModel: ChatModel) { val nav = rememberNavController() - NavHost(navController = nav, startDestination=Pages.Home.route){ - composable(route=Pages.Home.route){ - MainPage(chatModel, nav) - } - composable(route = Pages.Welcome.route) { - WelcomeView(chatModel) { - nav.navigate(Pages.Home.route) { - popUpTo(Pages.Home.route) { inclusive = true } + Box { + NavHost(navController = nav, startDestination = Pages.Home.route) { + composable(route = Pages.Home.route) { + MainPage(chatModel, nav) + } + composable(route = Pages.Welcome.route) { + WelcomeView(chatModel) { + nav.navigate(Pages.Home.route) { + popUpTo(Pages.Home.route) { inclusive = true } + } } } + composable(route = Pages.ChatList.route) { + ChatListView(chatModel, nav) + } + composable(route = Pages.Chat.route) { + ChatView(chatModel, nav) + } + composable(route = Pages.AddContact.route) { + AddContactView(chatModel, nav) + } + composable(route = Pages.Connect.route) { + ConnectContactView(chatModel, nav) + } + composable(route = Pages.ChatInfo.route) { + ChatInfoView(chatModel, nav) + } + composable(route = Pages.Terminal.route) { + TerminalView(chatModel, nav) + } + composable( + Pages.TerminalItemDetails.route + "/{identifier}", + arguments = listOf( + navArgument("identifier") { + type = NavType.LongType + } + ) + ) { entry -> DetailView(entry.arguments!!.getLong("identifier"), chatModel.terminalItems, nav) } } - composable(route = Pages.ChatList.route) { - ChatListView(chatModel, nav) - } - composable(route = Pages.Chat.route) { - ChatView(chatModel, nav) - } - composable(route = Pages.AddContact.route) { - AddContactView(chatModel, nav) - } - composable(route = Pages.Connect.route) { - ConnectContactView(chatModel, nav) - } - composable(route = Pages.Terminal.route) { - TerminalView(chatModel, nav) - } - composable( - Pages.TerminalItemDetails.route + "/{identifier}", - arguments = listOf( - navArgument("identifier"){ - type = NavType.LongType - } - ) - ) { entry -> DetailView( entry.arguments!!.getLong("identifier"), chatModel.terminalItems, nav) } + val am = chatModel.alertManager + if (am.presentAlert.value) am.alertView.value?.invoke() } } @@ -114,6 +118,7 @@ sealed class Pages(val route: String) { object Chat: Pages("chat") object AddContact: Pages("add_contact") object Connect: Pages("connect") + object ChatInfo: Pages("chat_info") } @DelicateCoroutinesApi 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 2f661fa973..cddc588055 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 @@ -5,9 +5,12 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import chat.simplex.app.SimplexApp import kotlinx.datetime.* -import kotlinx.datetime.TimeZone import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlin.Boolean +import kotlin.Int +import kotlin.Long +import kotlin.String class ChatModel(val controller: ChatController, val alertManager: SimplexApp.AlertManager) { var currentUser = mutableStateOf(null) @@ -39,12 +42,14 @@ class ChatModel(val controller: ChatController, val alertManager: SimplexApp.Ale } } -// func updateNetworkStatus(_ contact: Contact, _ status: Chat.NetworkStatus) { -// if let ix = getChatIndex(contact.id) { -// chats[ix].serverInfo.networkStatus = status -// } -// } -// + fun updateNetworkStatus(contact: Contact, status: Chat.NetworkStatus) { + val i = getChatIndex(contact.id) + if (i >= 0) { + val chat = chats[i] + chats[i] = chat.copy(serverInfo = chat.serverInfo.copy(networkStatus = status)) + } + } + // func replaceChat(_ id: String, _ chat: Chat) { // if let i = getChatIndex(id) { // chats[i] = chat @@ -59,7 +64,7 @@ class ChatModel(val controller: ChatController, val alertManager: SimplexApp.Ale val i = getChatIndex(cInfo.id) if (i >= 0) { val chat = chats[i] - val updatedChat = chat.copy( + chats[i] = chat.copy( chatItems = arrayListOf(cItem), chatStats = if (cItem.meta.itemStatus is CIStatus.RcvNew) @@ -67,7 +72,6 @@ class ChatModel(val controller: ChatController, val alertManager: SimplexApp.Ale else chat.chatStats ) - chats[i] = updatedChat if (i > 0) { popChat_(i) } @@ -154,18 +158,23 @@ class ChatModel(val controller: ChatController, val alertManager: SimplexApp.Ale val chat = chats.removeAt(i) chats.add(index = 0, chat) } -// -// func removeChat(_ id: String) { -// withAnimation { -// chats.removeAll(where: { $0.id == id }) -// } -// } + + fun removeChat(id: String) { + chats.removeAll { it.id == id } + } } enum class ChatType(val type: String) { Direct("@"), Group("#"), - ContactRequest("<@") + ContactRequest("<@"); + + val chatTypeName: String get () = + when (this) { + Direct -> "contact" + Group -> "group" + ContactRequest -> "contact request" + } } @Serializable @@ -221,20 +230,22 @@ data class Chat ( data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0) @Serializable - class ServerInfo(val networkStatus: NetworkStatus) + data class ServerInfo(val networkStatus: NetworkStatus) @Serializable sealed class NetworkStatus { - abstract val statusString: String - abstract val statusExplanation: String - abstract val imageName: String + val statusString: String get() = if (this is Connected) "Server connected" else "Connecting server…" + val statusExplanation: String get() = + when { + this is Connected -> "You are connected to the server you use to receve messages from this contact." + this is Error -> "Trying to connect to the server you use to receve messages from this contact (error: $error)." + else -> "Trying to connect to the server you use to receve messages from this contact." + } - @Serializable - class Unknown: NetworkStatus() { - override val statusString get() = "Server connected" - override val statusExplanation get() = "You are connected to the server you use to receve messages from this contact." - override val imageName get() = "circle.dotted" // ? - } + @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() } } @@ -249,7 +260,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = contact.ready override val createdAt get() = contact.createdAt override val displayName get() = contact.displayName - override val fullName get() = contact.displayName + override val fullName get() = contact.fullName companion object { val sampleData = Direct(Contact.sampleData) @@ -265,7 +276,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = groupInfo.ready override val createdAt get() = groupInfo.createdAt override val displayName get() = groupInfo.displayName - override val fullName get() = groupInfo.displayName + override val fullName get() = groupInfo.fullName companion object { val sampleData = Group(GroupInfo.sampleData) @@ -281,7 +292,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val ready get() = contactRequest.ready override val createdAt get() = contactRequest.createdAt override val displayName get() = contactRequest.displayName - override val fullName get() = contactRequest.displayName + override val fullName get() = contactRequest.fullName companion object { val sampleData = ContactRequest(UserContactRequest.sampleData) @@ -565,8 +576,3 @@ sealed class MsgContent { class RcvFileTransfer { } - -@Serializable -class AgentErrorType { - -} 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 94361b56fd..9771af6041 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 @@ -124,9 +124,7 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert return false } else -> { - val errMsg = "${r.responseType}: ${r.details}" - Log.e("SIMPLEX", "apiConnect bad response: $errMsg") - alertManager.showAlertMsg("Connection error", errMsg) + apiErrorAlert("apiConnect", "Connection error", r) return false } } @@ -134,8 +132,20 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert suspend fun apiDeleteChat(type: ChatType, id: Long): Boolean { val r = sendCmd(CC.ApiDeleteChat(type, id)) - if (r is CR.ContactDeleted) return true // TODO groups - Log.d("SIMPLEX", "apiDeleteChat bad response: ${r.responseType} ${r.details}") + when { + r is CR.ContactDeleted -> return true // TODO groups + r is CR.ChatCmdError -> { + val e = r.chatError + if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) { + alertManager.showAlertMsg( + "Can't delete contact!", + "Contact ${e.errorType.contact.displayName} cannot be deleted, it is a member of the group(s) ${e.errorType.groupNames}" + ) + return false + } + } + } + apiErrorAlert("apiDeleteChat", "Error deleting ${type.chatTypeName}", r) return false } @@ -193,22 +203,53 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert return false } + fun apiErrorAlert(method: String, title: String, r: CR) { + val errMsg = "${r.responseType}: ${r.details}" + Log.e("SIMPLEX", "$method bad response: $errMsg") + alertManager.showAlertMsg(title, errMsg) + } + fun processReceivedMsg(r: CR) { chatModel.terminalItems.add(TerminalItem.resp(r)) when { r is CR.ContactConnected -> chatModel.updateContact(r.contact) -// r is CR.UpdateNetworkStatus -> return // r is CR.ReceivedContactRequest -> return -// r is CR.ContactUpdated -> return -// r is CR.ContactSubscribed -> return -// r is CR.ContactSubError -> return -// r is CR.UpdateContact -> return + r is CR.ContactUpdated -> { + val cInfo = ChatInfo.Direct(r.toContact) + if (chatModel.hasChat(r.toContact.id)) { + chatModel.updateChatInfo(cInfo) + } + } + + r is CR.ContactSubscribed -> { + chatModel.updateContact(r.contact) + chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Connected()) + } + r is CR.ContactDisconnected -> { + chatModel.updateContact(r.contact) + chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Disconnected()) + } + r is CR.ContactSubError -> { + chatModel.updateContact(r.contact) + val e = r.chatError + val err: String = + if (e is ChatError.ChatErrorAgent) { + val a = e.agentError + when { + a is AgentErrorType.BROKER && a.brokerErr is BrokerErrorType.NETWORK -> "network" + a is AgentErrorType.SMP && a.smpErr is SMPErrorType.AUTH -> "contact deleted" + else -> e.string + } + } + else e.string + chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Error(err)) + } r is CR.NewChatItem -> { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem chatModel.addChatItem(cInfo, cItem) - } // NtfManager.shared.notifyMessageReceived(cInfo, cItem) + } // switch res { // chatModel.updateNetworkStatus(contact, .connected) @@ -219,31 +260,7 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert // chatItems: [] // )) // NtfManager.shared.notifyContactRequest(contactRequest) -// case let .contactUpdated(toContact): - // let cInfo = ChatInfo.direct(contact: toContact) - // if chatModel.hasChat(toContact.id) { - // chatModel.updateChatInfo(cInfo) - // } -// case let .contactSubscribed(contact): - // chatModel.updateContact(contact) - // chatModel.updateNetworkStatus(contact, .connected) - // case let .contactDisconnected(contact): - // chatModel.updateContact(contact) - // chatModel.updateNetworkStatus(contact, .disconnected) -// case let .contactSubError(contact, chatError): -// chatModel.updateContact(contact) -//// var err: String -//// switch chatError { -//// case .errorAgent(agentError: .BROKER(brokerErr: .NETWORK)): err = "network" -//// case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted" -//// default: err = String(describing: chatError) -//// } -//// chatModel.updateNetworkStatus(contact, .error(err)) -// case let .newChatItem(aChatItem): - // let cInfo = aChatItem.chatInfo - // let cItem = aChatItem.chatItem - // chatModel.addChatItem(cInfo, cItem) - // NtfManager.shared.notifyMessageReceived(cInfo, cItem) +// // case let .chatItemUpdated(aChatItem): // let cInfo = aChatItem.chatInfo // let cItem = aChatItem.chatItem @@ -557,13 +574,13 @@ sealed class CR { @Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR() { override val responseType get() = "chatCmdError" - override val details get() = chatError.toString() + override val details get() = chatError.string } @Serializable @SerialName("chatError") class ChatRespError(val chatError: ChatError): CR() { override val responseType get() = "chatError" - override val details get() = chatError.toString() + override val details get() = chatError.string } @Serializable @@ -610,21 +627,186 @@ abstract class TerminalItem { @Serializable sealed class ChatError { - @Serializable @SerialName("error") - class ChatErrorChat(val errorType: ChatErrorType): ChatError() - - @Serializable @SerialName("errorStore") - class ChatErrorStore(val storeError: StoreError): ChatError() + val string: String get() = when { + this is ChatErrorChat -> "chat ${errorType.string}" + this is ChatErrorAgent -> "agent ${agentError.string}" + this is ChatErrorStore -> "store ${storeError.string}" + else -> "ChatError" + } + @Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError() + @Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError() + @Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError() } @Serializable sealed class ChatErrorType { - @Serializable @SerialName("invalidConnReq") - class InvalidConnReq: ChatErrorType() + val string: String get() = when { + this is InvalidConnReq -> "invalidConnReq" + this is ContactGroups -> "groupNames $groupNames" + else -> "ChatErrorType" + } + @Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType() + @Serializable @SerialName("contactGroups") class ContactGroups(val contact: Contact, val groupNames: List): ChatErrorType() } @Serializable sealed class StoreError { - @Serializable @SerialName("userContactLinkNotFound") - class UserContactLinkNotFound: StoreError() -} \ No newline at end of file + val string: String get() = when { + this is UserContactLinkNotFound -> "userContactLinkNotFound" + else -> "StoreError" + } + @Serializable @SerialName("userContactLinkNotFound") class UserContactLinkNotFound: StoreError() +} + +@Serializable +sealed class AgentErrorType { + val string: String get() = when { + this is CMD -> "CMD ${cmdErr.string}" + this is CONN -> "CONN ${connErr.string}" + this is SMP -> "SMP ${smpErr.string}" + this is BROKER -> "BROKER ${brokerErr.string}" + this is AGENT -> "AGENT ${agentErr.string}" + this is INTERNAL -> "INTERNAL $internalErr" + else -> "AgentErrorType" + } + @Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType): AgentErrorType() + @Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType): AgentErrorType() + @Serializable @SerialName("SMP") class SMP(val smpErr: SMPErrorType): AgentErrorType() + @Serializable @SerialName("BROKER") class BROKER(val brokerErr: BrokerErrorType): AgentErrorType() + @Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType() + @Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType() +} + +@Serializable +sealed class CommandErrorType { + val string: String get() = when { + this is PROHIBITED -> "PROHIBITED" + this is SYNTAX -> "SYNTAX" + this is NO_CONN -> "NO_CONN" + this is SIZE -> "SIZE" + this is LARGE -> "LARGE" + else -> "CommandErrorType" + } + @Serializable @SerialName("PROHIBITED") class PROHIBITED: CommandErrorType() + @Serializable @SerialName("SYNTAX") class SYNTAX: CommandErrorType() + @Serializable @SerialName("NO_CONN") class NO_CONN: CommandErrorType() + @Serializable @SerialName("SIZE") class SIZE: CommandErrorType() + @Serializable @SerialName("LARGE") class LARGE: CommandErrorType() +} + +@Serializable +sealed class ConnectionErrorType { + val string: String get() = when { + this is NOT_FOUND -> "NOT_FOUND" + this is DUPLICATE -> "DUPLICATE" + this is SIMPLEX -> "SIMPLEX" + this is NOT_ACCEPTED -> "NOT_ACCEPTED" + this is NOT_AVAILABLE -> "NOT_AVAILABLE" + else -> "ConnectionErrorType" + } + @Serializable @SerialName("NOT_FOUND") class NOT_FOUND: ConnectionErrorType() + @Serializable @SerialName("DUPLICATE") class DUPLICATE: ConnectionErrorType() + @Serializable @SerialName("SIMPLEX") class SIMPLEX: ConnectionErrorType() + @Serializable @SerialName("NOT_ACCEPTED") class NOT_ACCEPTED: ConnectionErrorType() + @Serializable @SerialName("NOT_AVAILABLE") class NOT_AVAILABLE: ConnectionErrorType() +} + +@Serializable +sealed class BrokerErrorType { + val string: String get() = when { + this is RESPONSE -> "RESPONSE ${smpErr.string}" + this is UNEXPECTED -> "UNEXPECTED" + this is NETWORK -> "NETWORK" + this is TRANSPORT -> "TRANSPORT ${transportErr.string}" + this is TIMEOUT -> "TIMEOUT" + else -> "BrokerErrorType" + } + @Serializable @SerialName("RESPONSE") class RESPONSE(val smpErr: SMPErrorType): BrokerErrorType() + @Serializable @SerialName("UNEXPECTED") class UNEXPECTED: BrokerErrorType() + @Serializable @SerialName("NETWORK") class NETWORK: BrokerErrorType() + @Serializable @SerialName("TRANSPORT") class TRANSPORT(val transportErr: SMPTransportError): BrokerErrorType() + @Serializable @SerialName("TIMEOUT") class TIMEOUT: BrokerErrorType() +} + +@Serializable +sealed class SMPErrorType { + val string: String get() = when { + this is BLOCK -> "BLOCK" + this is SESSION -> "SESSION" + this is CMD -> "CMD ${cmdErr.string}" + this is AUTH -> "AUTH" + this is QUOTA -> "QUOTA" + this is NO_MSG -> "NO_MSG" + this is LARGE_MSG -> "LARGE_MSG" + this is INTERNAL -> "INTERNAL" + else -> "SMPErrorType" + } + @Serializable @SerialName("BLOCK") class BLOCK: SMPErrorType() + @Serializable @SerialName("SESSION") class SESSION: SMPErrorType() + @Serializable @SerialName("CMD") class CMD(val cmdErr: SMPCommandError): SMPErrorType() + @Serializable @SerialName("AUTH") class AUTH: SMPErrorType() + @Serializable @SerialName("QUOTA") class QUOTA: SMPErrorType() + @Serializable @SerialName("NO_MSG") class NO_MSG: SMPErrorType() + @Serializable @SerialName("LARGE_MSG") class LARGE_MSG: SMPErrorType() + @Serializable @SerialName("INTERNAL") class INTERNAL: SMPErrorType() +} + +@Serializable +sealed class SMPCommandError { + val string: String get() = when { + this is UNKNOWN -> "UNKNOWN" + this is SYNTAX -> "SYNTAX" + this is NO_AUTH -> "NO_AUTH" + this is HAS_AUTH -> "HAS_AUTH" + this is NO_QUEUE -> "NO_QUEUE" + else -> "SMPCommandError" + } + @Serializable @SerialName("UNKNOWN") class UNKNOWN: SMPCommandError() + @Serializable @SerialName("SYNTAX") class SYNTAX: SMPCommandError() + @Serializable @SerialName("NO_AUTH") class NO_AUTH: SMPCommandError() + @Serializable @SerialName("HAS_AUTH") class HAS_AUTH: SMPCommandError() + @Serializable @SerialName("NO_QUEUE") class NO_QUEUE: SMPCommandError() +} + +@Serializable +sealed class SMPTransportError { + val string: String get() = when { + this is BadBlock -> "badBlock" + this is LargeMsg -> "largeMsg" + this is BadSession -> "badSession" + this is Handshake -> "handshake ${handshakeErr.string}" + else -> "SMPTransportError" + } + @Serializable @SerialName("badBlock") class BadBlock: SMPTransportError() + @Serializable @SerialName("largeMsg") class LargeMsg: SMPTransportError() + @Serializable @SerialName("badSession") class BadSession: SMPTransportError() + @Serializable @SerialName("handshake") class Handshake(val handshakeErr: SMPHandshakeError): SMPTransportError() +} + +@Serializable +sealed class SMPHandshakeError { + val string: String get() = when { + this is PARSE -> "PARSE" + this is VERSION -> "VERSION" + this is IDENTITY -> "IDENTITY" + else -> "SMPHandshakeError" + } + @Serializable @SerialName("PARSE") class PARSE: SMPHandshakeError() + @Serializable @SerialName("VERSION") class VERSION: SMPHandshakeError() + @Serializable @SerialName("IDENTITY") class IDENTITY: SMPHandshakeError() +} + +@Serializable +sealed class SMPAgentError { + val string: String get() = when { + this is A_MESSAGE -> "A_MESSAGE" + this is A_PROHIBITED -> "A_PROHIBITED" + this is A_VERSION -> "A_VERSION" + this is A_ENCRYPTION -> "A_ENCRYPTION" + else -> "SMPAgentError" + } + @Serializable @SerialName("A_MESSAGE") class A_MESSAGE: SMPAgentError() + @Serializable @SerialName("A_PROHIBITED") class A_PROHIBITED: SMPAgentError() + @Serializable @SerialName("A_VERSION") class A_VERSION: SMPAgentError() + @Serializable @SerialName("A_ENCRYPTION") class A_ENCRYPTION: SMPAgentError() +} 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 480339e0b8..8dfdd66316 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 @@ -57,7 +57,7 @@ fun DetailView(identifier: Long, terminalItems: List, navControlle Text("Back") } SelectionContainer { - Text((terminalItems.filter { it.id == identifier }).first().details) + Text((terminalItems.firstOrNull { it.id == identifier })?.details ?: "") } } } 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 new file mode 100644 index 0000000000..bb9036b428 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -0,0 +1,130 @@ +package chat.simplex.app.views.chat + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.Circle +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import chat.simplex.app.Pages +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.DelicateCoroutinesApi + +@DelicateCoroutinesApi +@Composable +fun ChatInfoView(chatModel: ChatModel, nav: NavController) { + val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value } + if (chat != null) { + ChatInfoLayout(chat, + close = { nav.popBackStack() }, + deleteContact = { + chatModel.alertManager.showAlertMsg( + title = "Delete contact?", + text = "Contact and all messages will be deleted - this cannot be undone!", + confirmText = "Delete", + onConfirm = { + val cInfo = chat.chatInfo + withApi { + val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId) + if (r) { + chatModel.removeChat(cInfo.id) + nav.navigate(Pages.ChatList.route) + } + } + } + ) + } + ) + } +} + +@Composable +fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) { + Column(Modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CloseSheetBar(close) + Spacer(Modifier.size(48.dp)) + ChatInfoImage(chat, size = 192.dp) + val cInfo = chat.chatInfo + Text( + cInfo.displayName, style = MaterialTheme.typography.h1, + modifier = Modifier.padding(top = 32.dp).padding(bottom = 8.dp) + ) + Text( + cInfo.fullName, style = MaterialTheme.typography.h2, + modifier = Modifier.padding(bottom = 16.dp) + ) + + if (cInfo is ChatInfo.Direct) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row(Modifier.padding(horizontal = 32.dp)) { + ServerImage(chat) + Text( + chat.serverInfo.networkStatus.statusString, + textAlign = TextAlign.Center, + modifier = Modifier.padding(start = 8.dp) + ) + } + Text( + chat.serverInfo.networkStatus.statusExplanation, + style = MaterialTheme.typography.body2, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 16.dp).padding(horizontal = 16.dp) + ) + } + + Spacer(Modifier.weight(1F)) + + Box(Modifier.padding(24.dp)) { + SimpleButton( + "Delete contact", icon = Icons.Outlined.Delete, + color = Color.Red, + click = deleteContact + ) + } + } + } +} + +@Composable +fun ServerImage(chat: Chat) { + val status = chat.serverInfo.networkStatus + when { + status is Chat.NetworkStatus.Connected -> + Icon(Icons.Filled.Circle, "Connected", tint = MaterialTheme.colors.primaryVariant) + status is Chat.NetworkStatus.Disconnected -> + Icon(Icons.Filled.Pending, "Disconnected", tint = HighOrLowlight) + status is Chat.NetworkStatus.Error -> + Icon(Icons.Filled.Error, "Error", tint = HighOrLowlight) + else -> + Icon(Icons.Outlined.Circle, "Pending", tint = HighOrLowlight) + } +} + +@Preview +@Composable +fun PreviewChatInfoLayout() { + SimpleXTheme { + ChatInfoLayout( + chat = Chat( + chatInfo = ChatInfo.Direct.sampleData, + chatItems = arrayListOf(), + serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT")) + ), + close = {}, deleteContact = {} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 9b7ab104f8..f5e93b8258 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -11,12 +11,15 @@ import androidx.compose.material.icons.outlined.ArrowBack import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import chat.simplex.app.Pages import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.ChatItemView +import chat.simplex.app.views.helpers.ChatInfoImage import chat.simplex.app.views.helpers.withApi import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.datetime.Clock @@ -25,20 +28,26 @@ import kotlinx.datetime.Clock @Composable fun ChatView(chatModel: ChatModel, nav: NavController) { if (chatModel.chatId.value != null && chatModel.chats.count() > 0) { - val chat: Chat = chatModel.chats.first { chat -> chat.chatInfo.id == chatModel.chatId.value } - ChatLayout(chat, chatModel.chatItems, back = { nav.popBackStack() }, sendMessage = { msg -> - withApi { - // show "in progress" - val cInfo = chat.chatInfo - val newItem = chatModel.controller.apiSendMessage( - type = cInfo.chatType, - id = cInfo.apiId, - mc = MsgContent.MCText(msg) - ) - // hide "in progress" - if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem) - } - }) + val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value } + if (chat != null) { + ChatLayout(chat, chatModel.chatItems, + back = { nav.popBackStack() }, + info = { nav.navigate(Pages.ChatInfo.route) }, + sendMessage = { msg -> + withApi { + // show "in progress" + val cInfo = chat.chatInfo + val newItem = chatModel.controller.apiSendMessage( + type = cInfo.chatType, + id = cInfo.apiId, + mc = MsgContent.MCText(msg) + ) + // hide "in progress" + if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem) + } + } + ) + } } } @@ -46,10 +55,11 @@ fun ChatView(chatModel: ChatModel, nav: NavController) { fun ChatLayout( chat: Chat, chatItems: List, back: () -> Unit, + info: () -> Unit, sendMessage: (String) -> Unit ) { Scaffold( - topBar = { ChatInfoToolbar(chat, back) }, + topBar = { ChatInfoToolbar(chat, back, info) }, bottomBar = { SendMsgView(sendMessage) } ) { contentPadding -> Box( @@ -64,9 +74,10 @@ fun ChatLayout( } @Composable -fun ChatInfoToolbar(chat: Chat, back: () -> Unit) { - Box( - modifier = Modifier.fillMaxWidth(), +fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) { + Box(Modifier + .fillMaxWidth() + .height(60.dp), contentAlignment = Alignment.CenterStart ) { Icon( @@ -77,14 +88,23 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit) { .clickable(onClick = back) .padding(start = 16.dp) ) - Column( - Modifier - .padding(horizontal = 40.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + Row(Modifier + .padding(horizontal = 40.dp) + .fillMaxWidth() + .clickable(onClick = info), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically ) { - Text(chat.chatInfo.displayName) - Text(chat.chatInfo.fullName) + val cInfo = chat.chatInfo + ChatInfoImage(chat, size = 40.dp) + Column(Modifier.padding(start = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(cInfo.displayName, fontWeight = FontWeight.Bold) + if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) { + Text(cInfo.fullName) + } + } } } } @@ -127,6 +147,7 @@ fun PreviewChatViewLayout() { ), chatItems = chatItems, back = {}, + info = {}, sendMessage = {} ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt index aa2551efa6..eb5c59572f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt @@ -3,18 +3,18 @@ package chat.simplex.app.views.chat.item import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import chat.simplex.app.model.CIDirection import chat.simplex.app.model.ChatItem +import chat.simplex.app.ui.theme.HighOrLowlight import kotlinx.datetime.Clock @Composable fun CIMetaView(chatItem: ChatItem) { Text( chatItem.timestampText, - color = Color.Gray, - style = MaterialTheme.typography.caption + color = HighOrLowlight, + style = MaterialTheme.typography.body2 ) } 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 10d36a6663..38a8736056 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 @@ -64,13 +64,13 @@ fun ChatListView(chatModel: ChatModel, nav: NavController) { .background(MaterialTheme.colors.background) ) { ChatListToolbar(newChatCtrl) - ChatList(chatModel, nav) Button( onClick = { nav.navigate(Pages.Terminal.route) }, modifier = Modifier.padding(14.dp) ) { Text("Terminal") } + ChatList(chatModel, nav) } if (newChatCtrl.state.bottomSheetState.isExpanded) { Surface(Modifier 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 a064d5e518..f48ddeba93 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 @@ -1,14 +1,11 @@ package chat.simplex.app.views.chatlist -import androidx.compose.foundation.* +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable 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.Person import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -16,6 +13,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.ChatInfoImage @Composable fun ChatPreviewView(chat: Chat, goToChat: () -> Unit) { @@ -27,30 +25,19 @@ fun ChatPreviewView(chat: Chat, goToChat: () -> Unit) { .height(80.dp) ) { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp), ) { Column(verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxHeight()) { - Icon( - Icons.Filled.Person, - contentDescription = "Avatar Placeholder", - tint = MaterialTheme.colors.background, - modifier = Modifier - .size(55.dp) - .clip(CircleShape) - .border(1.5.dp, MaterialTheme.colors.secondary, CircleShape) - .background(MaterialTheme.colors.secondary) - ) + ChatInfoImage(chat, size = 60.dp) } Spacer(modifier = Modifier.width(6.dp)) Column(modifier = Modifier.padding(all = 8.dp)) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Text(chat.chatInfo.chatViewName, fontWeight = FontWeight.Bold) - ( - if (chat.chatItems.count() > 0) { - Text(getTimestampText(chat.chatItems.last().meta.itemTs), color = HighOrLowlight) - } - else Text(getTimestampText(chat.chatInfo.createdAt), color = HighOrLowlight) - ) + val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt) + Text(ts, color = HighOrLowlight, style = MaterialTheme.typography.body2) } if (chat.chatItems.count() > 0) { Text( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt new file mode 100644 index 0000000000..4bcd4352b8 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt @@ -0,0 +1,44 @@ +package chat.simplex.app.views.helpers + +import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.SupervisedUserCircle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import chat.simplex.app.model.Chat +import chat.simplex.app.model.ChatInfo +import chat.simplex.app.ui.theme.SimpleXTheme + +@Composable +fun ChatInfoImage(chat: Chat, size: Dp) { + val icon = + if (chat.chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle + else Icons.Filled.AccountCircle + Box(Modifier.size(size)) { + Icon(icon, + contentDescription = "Avatar Placeholder", + tint = MaterialTheme.colors.secondary, + modifier = Modifier.fillMaxSize(), +// .clip(CircleShape) +// .border(1.5.dp, MaterialTheme.colors.secondary, CircleShape) +// .background(MaterialTheme.colors.secondary) + ) + } +} + +@Preview +@Composable +fun PreviewChatInfoImage() { + SimpleXTheme { + ChatInfoImage( + chat = Chat(chatInfo = ChatInfo.Direct.sampleData, chatItems = arrayListOf()), + size = 55.dp + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt index bd2f037c47..c507d8ae2e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/SimpleButton.kt @@ -9,24 +9,23 @@ import androidx.compose.material.icons.outlined.Share import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @Composable -fun SimpleButton(text: String, icon: ImageVector, click: () -> Unit) { +fun SimpleButton(text: String, icon: ImageVector, + color: Color = MaterialTheme.colors.primary, + click: () -> Unit) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { click() } ) { - Icon(icon, text, - tint = MaterialTheme.colors.primary, + Icon(icon, text, tint = color, modifier = Modifier.padding(horizontal = 10.dp) ) - Text(text, - style = MaterialTheme.typography.caption, - color = MaterialTheme.colors.primary - ) + Text(text, style = MaterialTheme.typography.caption, color = color) } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 7f5f50876c..0cccd292e4 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -685,10 +685,10 @@ enum SMPCommandError: Decodable { } enum SMPTransportError: Decodable { - case TEBadBlock - case TELargeMsg - case TEBadSession - case TEHandshake(handshakeErr: SMPHandshakeError) + case badBlock + case largeMsg + case badSession + case handshake(handshakeErr: SMPHandshakeError) } enum SMPHandshakeError: Decodable {