From bd85b1063e30559b0d4b84cdd39d504cd6282f79 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 3 Jun 2024 14:13:22 +0400 Subject: [PATCH] multiplatform: contacts ui (#4214) * types * move stuff around * move more stuff around * padding * tabs * contacts list * contact nav links, previews * desktop list, better icon * info * refactor * comments * chat info buttons * call buttons * delete conversation dialogues * change remember (contacts still don't update) * contacts page refreshes on update * delete contact dialogues * move user picker to bottom left * group member info buttons * divider * center on desktop * comment --------- Co-authored-by: Avently <7953703+avently@users.noreply.github.com> --- .../Views/ChatList/ChatListNavLink.swift | 2 +- .../HomeView.android.kt} | 2 +- .../kotlin/chat/simplex/common/App.kt | 7 +- .../chat/simplex/common/model/ChatModel.kt | 16 +- .../chat/simplex/common/model/SimpleXAPI.kt | 57 ++- .../simplex/common/views/chat/ChatInfoView.kt | 334 ++++++++++++++-- .../simplex/common/views/chat/ChatView.kt | 28 +- .../views/chat/group/GroupMemberInfoView.kt | 54 ++- .../views/chatlist/ChatListNavLinkView.kt | 64 ++- .../common/views/chatlist/ChatListView.kt | 346 +--------------- .../common/views/chatlist/ChatPreviewView.kt | 2 +- .../common/views/chatlist/ShareListView.kt | 1 + .../common/views/chatlist/UserPicker.kt | 2 + .../views/contacts/ContactListNavLink.kt | 104 +++++ .../views/contacts/ContactPreviewView.kt | 117 ++++++ .../common/views/contacts/ContactsView.kt | 160 ++++++++ .../common/views/helpers/DefaultTopAppBar.kt | 3 +- .../simplex/common/views/helpers/Utils.kt | 2 +- .../simplex/common/views/home/HomeView.kt | 372 ++++++++++++++++++ .../common/views/newchat/NewChatSheet.kt | 14 +- .../commonMain/resources/MR/base/strings.xml | 22 ++ .../MR/images/ic_chat_bubble_filled.svg | 1 + .../views/chatlist/ChatListView.desktop.kt | 74 ---- .../common/views/home/HomeView.desktop.kt | 76 ++++ 24 files changed, 1364 insertions(+), 496 deletions(-) rename apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/{chatlist/ChatListView.android.kt => home/HomeView.android.kt} (98%) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavLink.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactsView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/home/HomeView.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_bubble_filled.svg delete mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt create mode 100644 apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/home/HomeView.desktop.kt diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 0e894b7fa0..cf6a6d3f49 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -485,7 +485,7 @@ struct ChatListNavLink: View { private func deleteConversationNotice(_ contact: Contact) -> Alert { return Alert( title: Text("Conversation deleted!"), - message: Text("You can still send messages to \(contact.displayName) from the Contacts tab. "), + message: Text("You can still send messages to \(contact.displayName) from the Contacts tab."), primaryButton: .default(Text("Don't show again")) { showDeleteConversationNotice = false }, diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/home/HomeView.android.kt similarity index 98% rename from apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt rename to apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/home/HomeView.android.kt index 4a8b912cdd..5b3f2fdabe 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/home/HomeView.android.kt @@ -1,4 +1,4 @@ -package chat.simplex.common.views.chatlist +package chat.simplex.common.views.home import android.app.Activity import androidx.compose.foundation.* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index e7dda42ade..cfaa8dd97d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -4,16 +4,12 @@ import androidx.compose.animation.core.Animatable 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.ripple.rememberRipple import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView @@ -28,6 +24,7 @@ import chat.simplex.common.views.chat.ChatView import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.database.DatabaseErrorView import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.home.* import chat.simplex.common.views.localauth.VerticalDivider import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.* @@ -287,7 +284,7 @@ fun StartPartOfScreen(settingsState: SettingsViewState) { } else { val stopped = chatModel.chatRunning.value == false if (chatModel.sharedContent.value == null) - ChatListView(chatModel, settingsState, AppLock::setPerformLA, stopped) + HomeView(chatModel, settingsState, AppLock::setPerformLA, stopped) else ShareListView(chatModel, settingsState, stopped) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 74a54e54cf..c3b0c5a59f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -779,6 +779,7 @@ interface SomeChat { val id: ChatId val apiId: Long val ready: Boolean + val chatDeleted: Boolean val sendMsgEnabled: Boolean val ntfsEnabled: Boolean val incognito: Boolean @@ -859,6 +860,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = contact.id override val apiId get() = contact.apiId override val ready get() = contact.ready + override val chatDeleted get() = contact.chatDeleted override val sendMsgEnabled get() = contact.sendMsgEnabled override val ntfsEnabled get() = contact.ntfsEnabled override val incognito get() = contact.incognito @@ -883,6 +885,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = groupInfo.id override val apiId get() = groupInfo.apiId override val ready get() = groupInfo.ready + override val chatDeleted get() = groupInfo.chatDeleted override val sendMsgEnabled get() = groupInfo.sendMsgEnabled override val ntfsEnabled get() = groupInfo.ntfsEnabled override val incognito get() = groupInfo.incognito @@ -907,6 +910,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = noteFolder.id override val apiId get() = noteFolder.apiId override val ready get() = noteFolder.ready + override val chatDeleted get() = noteFolder.chatDeleted override val sendMsgEnabled get() = noteFolder.sendMsgEnabled override val ntfsEnabled get() = noteFolder.ntfsEnabled override val incognito get() = noteFolder.incognito @@ -931,6 +935,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = contactRequest.id override val apiId get() = contactRequest.apiId override val ready get() = contactRequest.ready + override val chatDeleted get() = contactRequest.chatDeleted override val sendMsgEnabled get() = contactRequest.sendMsgEnabled override val ntfsEnabled get() = contactRequest.ntfsEnabled override val incognito get() = contactRequest.incognito @@ -955,6 +960,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = contactConnection.id override val apiId get() = contactConnection.apiId override val ready get() = contactConnection.ready + override val chatDeleted get() = contactConnection.chatDeleted override val sendMsgEnabled get() = contactConnection.sendMsgEnabled override val ntfsEnabled get() = false override val incognito get() = contactConnection.incognito @@ -980,6 +986,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val id get() = "" override val apiId get() = 0L override val ready get() = false + override val chatDeleted get() = false override val sendMsgEnabled get() = false override val ntfsEnabled get() = false override val incognito get() = false @@ -1056,6 +1063,7 @@ data class Contact( val chatTs: Instant?, val contactGroupMemberId: Long? = null, val contactGrpInvSent: Boolean, + override val chatDeleted: Boolean, val uiThemes: ThemeModeOverrides? = null, ): SomeChat, NamedChat { override val chatType get() = ChatType.Direct @@ -1129,6 +1137,7 @@ data class Contact( updatedAt = Clock.System.now(), chatTs = Clock.System.now(), contactGrpInvSent = false, + chatDeleted = false, uiThemes = null, ) } @@ -1137,7 +1146,8 @@ data class Contact( @Serializable enum class ContactStatus { @SerialName("active") Active, - @SerialName("deleted") Deleted; + @SerialName("deleted") Deleted, + @SerialName("deletedByUser") DeletedByUser; } @Serializable @@ -1276,6 +1286,7 @@ data class GroupInfo ( override val id get() = "#$groupId" override val apiId get() = groupId override val ready get() = membership.memberActive + override val chatDeleted get() = false override val sendMsgEnabled get() = membership.memberActive override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = membership.memberIncognito @@ -1581,6 +1592,7 @@ class NoteFolder( override val chatType get() = ChatType.Local override val id get() = "*$noteFolderId" override val apiId get() = noteFolderId + override val chatDeleted get() = false override val ready get() = true override val sendMsgEnabled get() = true override val ntfsEnabled get() = false @@ -1617,6 +1629,7 @@ class UserContactRequest ( override val chatType get() = ChatType.ContactRequest override val id get() = "<@$contactRequestId" override val apiId get() = contactRequestId + override val chatDeleted get() = false override val ready get() = true override val sendMsgEnabled get() = false override val ntfsEnabled get() = false @@ -1656,6 +1669,7 @@ class PendingContactConnection( override val chatType get() = ChatType.ContactConnection override val id get () = ":$pccConnId" override val apiId get() = pccConnId + override val chatDeleted get() = false override val ready get() = false override val sendMsgEnabled get() = false override val ntfsEnabled get() = false diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 1f7f55fb53..26fddaf0d1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -195,6 +195,8 @@ class AppPreferences { val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null) + val showDeleteConversationNotice = mkBoolPreference(SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE, true) + val showDeleteContactNotice = mkBoolPreference(SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE, true) val showSentViaProxy = mkBoolPreference(SHARED_PREFS_SHOW_SENT_VIA_RPOXY, false) @@ -371,6 +373,8 @@ class AppPreferences { private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto" private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState" + private const val SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice" + private const val SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice" private const val SHARED_PREFS_SHOW_SENT_VIA_RPOXY = "showSentViaProxy" private const val SHARED_PREFS_IOS_CALL_KIT_ENABLED = "iOSCallKitEnabled" @@ -1099,16 +1103,16 @@ object ChatController { } } - suspend fun deleteChat(chat: Chat, notify: Boolean? = null) { + suspend fun deleteChat(chat: Chat, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) { val cInfo = chat.chatInfo - if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, notify = notify)) { + if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, chatDeleteMode = chatDeleteMode)) { chatModel.removeChat(chat.remoteHostId, cInfo.id) } } - suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, notify: Boolean? = null): Boolean { + suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Boolean { chatModel.deletedChats.value += rh to type.type + id - val r = sendCmd(rh, CC.ApiDeleteChat(type, id, notify)) + val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode)) val success = when { r is CR.ContactDeleted && type == ChatType.Direct -> true r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> true @@ -1129,6 +1133,22 @@ object ChatController { return success } + suspend fun apiDeleteContact(rh: Long?, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Contact? { + val type = ChatType.Direct + chatModel.deletedChats.value += rh to type.type + id + val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode)) + val contact = when { + r is CR.ContactDeleted -> r.contact + else -> { + val titleId = MR.strings.error_deleting_contact + apiErrorAlert("apiDeleteChat", generalGetString(titleId), r) + null + } + } + chatModel.deletedChats.value -= rh to type.type + id + return contact + } + fun clearChat(chat: Chat, close: (() -> Unit)? = null) { withBGApi { val updatedChatInfo = apiClearChat(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId) @@ -1867,6 +1887,10 @@ object ChatController { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem if (active(r.user)) { + if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) { + val updatedContact = cInfo.contact.copy(chatDeleted = false) + chatModel.updateContact(rhId, updatedContact) + } chatModel.addChatItem(rhId, cInfo, cItem) } else if (cItem.isRcvNew && cInfo.ntfsEnabled) { chatModel.increaseUnreadCounter(rhId, r.user) @@ -2538,7 +2562,7 @@ sealed class CC { class APIConnectPlan(val userId: Long, val connReq: String): CC() class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC() class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC() - class ApiDeleteChat(val type: ChatType, val id: Long, val notify: Boolean?): CC() + class ApiDeleteChat(val type: ChatType, val id: Long, val chatDeleteMode: ChatDeleteMode): CC() class ApiClearChat(val type: ChatType, val id: Long): CC() class ApiListContacts(val userId: Long): CC() class ApiUpdateProfile(val userId: Long, val profile: Profile): CC() @@ -2685,11 +2709,7 @@ sealed class CC { is APIConnectPlan -> "/_connect plan $userId $connReq" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq" is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId" - is ApiDeleteChat -> if (notify != null) { - "/_delete ${chatRef(type, id)} notify=${onOff(notify)}" - } else { - "/_delete ${chatRef(type, id)}" - } + is ApiDeleteChat -> "/_delete ${chatRef(type, id)} ${chatDeleteMode.cmdString}" is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" is ApiListContacts -> "/_contacts $userId" is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}" @@ -2902,8 +2922,6 @@ sealed class CC { null } - private fun onOff(b: Boolean): String = if (b) "on" else "off" - private fun maybePwd(pwd: String?): String = if (pwd == "" || pwd == null) "" else " " + json.encodeToString(pwd) companion object { @@ -2913,6 +2931,8 @@ sealed class CC { } } +fun onOff(b: Boolean): String = if (b) "on" else "off" + @Serializable data class NewUser( val profile: Profile?, @@ -4728,6 +4748,19 @@ fun chatError(r: CR): ChatErrorType? { ) } +@Serializable +sealed class ChatDeleteMode { + @Serializable @SerialName("full") class Full(val notify: Boolean): ChatDeleteMode() + @Serializable @SerialName("entity") class Entity(val notify: Boolean): ChatDeleteMode() + @Serializable @SerialName("messages") class Messages: ChatDeleteMode() + + val cmdString: String get() = when (this) { + is ChatDeleteMode.Full -> "full notify=${onOff(notify)}" + is ChatDeleteMode.Entity -> "entity notify=${onOff(notify)}" + is ChatDeleteMode.Messages -> "messages" + } +} + @Serializable sealed class ConnectionPlan { @Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 48ed0570a7..58614883ab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -12,12 +12,14 @@ import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.* import androidx.compose.ui.text.* import dev.icerock.moko.resources.compose.painterResource @@ -35,7 +37,8 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* -import chat.simplex.common.views.chatlist.updateChatSettings +import chat.simplex.common.views.call.CallMediaType +import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.delay @@ -45,9 +48,21 @@ import kotlinx.datetime.Clock import kotlinx.serialization.encodeToString import java.io.File +sealed class ContactDeleteMode { + class Full: ContactDeleteMode() + class Entity: ContactDeleteMode() + + fun toChatDeleteMode(notify: Boolean): ChatDeleteMode = + when (this) { + is Full -> ChatDeleteMode.Full(notify) + is Entity -> ChatDeleteMode.Entity(notify) + } +} + @Composable fun ChatInfoView( chatModel: ChatModel, + openedFromChatView: Boolean, contact: Contact, connectionStats: ConnectionStats?, customUserProfile: Profile?, @@ -68,6 +83,7 @@ fun ChatInfoView( val chatRh = chat.remoteHostId val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) } ChatInfoLayout( + openedFromChatView = openedFromChatView, chat, contact, currentUser, @@ -166,7 +182,8 @@ fun ChatInfoView( ) } } - } + }, + close = close, ) } } @@ -201,53 +218,127 @@ sealed class SendReceipts { fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) { val chatInfo = chat.chatInfo - AlertManager.shared.showAlertDialogButtonsColumn( - title = generalGetString(MR.strings.delete_contact_question), - text = AnnotatedString(generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)), - buttons = { - Column { - if (chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) { - // Delete and notify contact + if (chatInfo is ChatInfo.Direct) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.delete_contact_question), + buttons = { + Column { + // Delete contact SectionItemView({ AlertManager.shared.hideAlert() - deleteContact(chat, chatModel, close, notify = true) + notifyDeleteContactDialog(chat, chatModel, close, contactDeleteMode = ContactDeleteMode.Full()) }) { - Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + Text(generalGetString(MR.strings.button_delete_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) } - // Delete + if (!chatInfo.contact.chatDeleted) { + // Delete contact, keep conversation + SectionItemView({ + AlertManager.shared.hideAlert() + notifyDeleteContactDialog(chat, chatModel, close, contactDeleteMode = ContactDeleteMode.Entity()) + }) { + Text(generalGetString(MR.strings.delete_contact_keep_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } + // Cancel SectionItemView({ AlertManager.shared.hideAlert() - deleteContact(chat, chatModel, close, notify = false) }) { - Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } - } else { - // Delete - SectionItemView({ - AlertManager.shared.hideAlert() - deleteContact(chat, chatModel, close) - }) { - Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) - } - } - // Cancel - SectionItemView({ - AlertManager.shared.hideAlert() - }) { - Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } - } + ) + } +} + +fun notifyDeleteContactDialog( + chat: Chat, + chatModel: ChatModel, + close: (() -> Unit)? = null, + contactDeleteMode: ContactDeleteMode = ContactDeleteMode.Full() +) { + val chatInfo = chat.chatInfo + if (chatInfo is ChatInfo.Direct) { + val contactActive = chatInfo.contact.ready && chatInfo.contact.active + AlertManager.shared.showAlertDialogButtonsColumn( + title = if (contactActive) generalGetString(MR.strings.notify_delete_contact_question) else generalGetString(MR.strings.confirm_delete_contact_question), + text = when (contactDeleteMode) { + is ContactDeleteMode.Full -> generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning) + is ContactDeleteMode.Entity -> generalGetString(MR.strings.delete_contact_cannot_undo_warning) + }, + buttons = { + Column { + if (contactActive) { + // Delete and notify contact + SectionItemView({ + AlertManager.shared.hideAlert() + deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.toChatDeleteMode(notify = true)) + if (contactDeleteMode is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) { + showDeleteContactNotice(chatInfo.contact) + } + }) { + Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Delete without notification + SectionItemView({ + AlertManager.shared.hideAlert() + deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.toChatDeleteMode(notify = false)) + if (contactDeleteMode is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) { + showDeleteContactNotice(chatInfo.contact) + } + }) { + Text(generalGetString(MR.strings.delete_without_notification), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } else { + // Delete + SectionItemView({ + AlertManager.shared.hideAlert() + deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.toChatDeleteMode(notify = false)) + if (contactDeleteMode is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) { + showDeleteContactNotice(chatInfo.contact) + } + }) { + Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } + // Cancel + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) + } +} + +private fun showDeleteContactNotice(contact: Contact) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.contact_deleted), + text = String.format(generalGetString(MR.strings.you_can_still_view_conversation_with_contact), contact.displayName), + confirmText = generalGetString(MR.strings.ok), + dismissText = generalGetString(MR.strings.dont_show_again), + onDismiss = { + chatModel.controller.appPrefs.showDeleteContactNotice.set(false) + }, ) } -fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) { +fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) { val chatInfo = chat.chatInfo withBGApi { val chatRh = chat.remoteHostId - val r = chatModel.controller.apiDeleteChat(chatRh, chatInfo.chatType, chatInfo.apiId, notify) - if (r) { - chatModel.removeChat(chatRh, chatInfo.id) + val ct = chatModel.controller.apiDeleteContact(chatRh, chatInfo.apiId, chatDeleteMode) + if (ct != null) { + when (chatDeleteMode) { + is ChatDeleteMode.Full -> + chatModel.removeChat(chatRh, chatInfo.id) + is ChatDeleteMode.Entity -> + chatModel.updateContact(chatRh, ct) + is ChatDeleteMode.Messages -> + chatModel.clearChat(chatRh, ChatInfo.Direct(ct)) + } if (chatModel.chatId.value == chatInfo.id) { chatModel.chatId.value = null ModalManager.end.closeModals() @@ -280,6 +371,7 @@ fun clearNoteFolderDialog(chat: Chat, close: (() -> Unit)? = null) { @Composable fun ChatInfoLayout( + openedFromChatView: Boolean, chat: Chat, contact: Contact, currentUser: User, @@ -300,6 +392,7 @@ fun ChatInfoLayout( syncContactConnection: () -> Unit, syncContactConnectionForce: () -> Unit, verifyClicked: () -> Unit, + close: () -> Unit, ) { val cStats = connStats.value val scrollState = rememberScrollState() @@ -319,7 +412,31 @@ fun ChatInfoLayout( } LocalAliasEditor(chat.id, localAlias, updateValue = onLocalAliasChanged) + SectionSpacer() + + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + if (contact.activeConn == null && contact.profile.contactLink != null && contact.active) { + ConnectButton(openedFromChatView, chat, contact, close) + } else if (!contact.active && !contact.chatDeleted) { + OpenButton(openedFromChatView, chat, contact, close) + } else { + MessageButton(openedFromChatView, chat, contact, close) + } + Spacer(Modifier.width(10.dp)) + CallButton(chat, contact) + Spacer(Modifier.width(10.dp)) + VideoButton(chat, contact) + } + + SectionSpacer() + if (customUserProfile != null) { SectionView(generalGetString(MR.strings.incognito).uppercase()) { SectionItemViewSpaceBetween { @@ -535,6 +652,155 @@ fun LocalAliasEditor( } } +// when contact is a "contact card" +@Composable +private fun ConnectButton(openedFromChatView: Boolean, chat: Chat, contact: Contact, close: () -> Unit) { + InfoViewActionButton( + icon = painterResource(MR.images.ic_chat_bubble_filled), + title = generalGetString(MR.strings.info_view_connect_button), + disabled = false, + onClick = { + AlertManager.privacySensitive.showAlertDialogButtonsColumn( + title = String.format(generalGetString(MR.strings.connect_with_contact_name_question), contact.chatViewName), + buttons = { + Column { + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + infoConnectContactViaAddress(openedFromChatView, chat, contact, incognito = false, close) + }) { + Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + infoConnectContactViaAddress(openedFromChatView, chat, contact, incognito = true, close) + }) { + Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }, + hostDevice = hostDevice(chat.remoteHostId), + ) + } + ) +} + +private fun infoConnectContactViaAddress(openedFromChatView: Boolean, chat: Chat, contact: Contact, incognito: Boolean, close: () -> Unit) { + withBGApi { + val ok = connectContactViaAddress(chatModel, chat.remoteHostId, contact.contactId, incognito = incognito) + if (ok) { + if (openedFromChatView) { + close.invoke() + } else { + if (contact.chatDeleted) { + chatModel.updateContact(chat.remoteHostId, contact.copy(chatDeleted = false)) + } + close.invoke() + openDirectChat(chat.remoteHostId, contact.contactId, chatModel) + } + } + } +} + +@Composable +private fun OpenButton(openedFromChatView: Boolean, chat: Chat, contact: Contact, close: () -> Unit) { + InfoViewActionButton( + icon = painterResource(MR.images.ic_chat_bubble_filled), + title = generalGetString(MR.strings.info_view_open_button), + disabled = false, + onClick = { + if (openedFromChatView) { + close.invoke() + } else { + close.invoke() + withBGApi { + openDirectChat(chat.remoteHostId, contact.contactId, chatModel) + } + } + } + ) +} + +@Composable +private fun MessageButton(openedFromChatView: Boolean, chat: Chat, contact: Contact, close: () -> Unit) { + InfoViewActionButton( + icon = painterResource(MR.images.ic_chat_bubble_filled), + title = generalGetString(MR.strings.info_view_message_button), + disabled = !contact.sendMsgEnabled, + onClick = { + if (openedFromChatView) { + close.invoke() + } else { + if (contact.chatDeleted) { + chatModel.updateContact(chat.remoteHostId, contact.copy(chatDeleted = false)) + } + close.invoke() + withBGApi { + openDirectChat(chat.remoteHostId, contact.contactId, chatModel) + } + } + } + ) +} + +@Composable +fun CallButton(chat: Chat, contact: Contact) { + InfoViewActionButton( + icon = painterResource(MR.images.ic_call_filled), + title = generalGetString(MR.strings.info_view_call_button), + disabled = !contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall.value != null, + onClick = { + startChatCall(chat, CallMediaType.Audio) + } + ) +} + +@Composable +fun VideoButton(chat: Chat, contact: Contact) { + InfoViewActionButton( + icon = painterResource(MR.images.ic_videocam_filled), + title = generalGetString(MR.strings.info_view_video_button), + disabled = !contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall.value != null, + onClick = { + startChatCall(chat, CallMediaType.Video) + } + ) +} + +@Composable +fun InfoViewActionButton(icon: Painter, title: String, disabled: Boolean, onClick: () -> Unit) { + Surface( + Modifier + .width(96.dp) + .height(66.dp), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colors.secondaryVariant, + ) { + val modifier = if (disabled) Modifier else Modifier.clickable { onClick () } + Column( + modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + icon, + contentDescription = null, + Modifier.size(26.dp), + tint = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + Text( + title, + style = MaterialTheme.typography.subtitle2.copy(fontWeight = FontWeight.Normal), + color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + } + } +} + @Composable private fun NetworkStatusRow(networkStatus: NetworkStatus) { Row( @@ -823,6 +1089,7 @@ fun queueInfoText(info: Pair): String { fun PreviewChatInfoLayout() { SimpleXTheme { ChatInfoLayout( + openedFromChatView = false, chat = Chat( remoteHostId = null, chatInfo = ChatInfo.Direct.sampleData, @@ -847,6 +1114,7 @@ fun PreviewChatInfoLayout() { syncContactConnection = {}, syncContactConnectionForce = {}, verifyClicked = {}, + close = {}, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 71d82b5691..fbef070ae0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -189,7 +189,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second preloadedCode = code } - ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close) + ChatInfoView(chatModel, openedFromChatView = true, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close) } else if (chat?.chatInfo is ChatInfo.Group) { var link: Pair? by remember(chat.id) { mutableStateOf(preloadedLink) } KeyChangeEffect(chat.id) { @@ -306,18 +306,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: onComplete.invoke() } }, - startCall = out@{ media -> - withBGApi { - val cInfo = chat.chatInfo - if (cInfo is ChatInfo.Direct) { - val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId) - val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi - chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile) - chatModel.showCallView.value = true - chatModel.callCommand.add(WCallCommand.Capabilities(media)) - } - } - }, + startCall = out@{ media -> startChatCall(chat, media) }, endCall = { val call = chatModel.activeCall.value if (call != null) withBGApi { chatModel.callManager.endCall(call) } @@ -521,6 +510,19 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } +fun startChatCall(chat: Chat, media: CallMediaType) { + withBGApi { + val cInfo = chat.chatInfo + if (cInfo is ChatInfo.Direct) { + val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId) + val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi + chatModel.activeCall.value = Call(remoteHostId = chat.remoteHostId, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile) + chatModel.showCallView.value = true + chatModel.callCommand.add(WCallCommand.Capabilities(media)) + } + } +} + @Composable fun ChatLayout( chat: Chat, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 2799904b71..fd94e25c7c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -36,6 +36,7 @@ import chat.simplex.common.views.newchat.* import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* +import chat.simplex.common.views.call.CallMediaType import chat.simplex.common.views.chatlist.openLoadedChat import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -246,10 +247,10 @@ fun GroupMemberInfoLayout( verifyClicked: () -> Unit, ) { val cStats = connStats.value - fun knownDirectChat(contactId: Long): Chat? { + fun knownDirectChat(contactId: Long): Pair? { val chat = getContactChat(contactId) return if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) { - chat + chat to chat.chatInfo.contact } else { null } @@ -309,17 +310,37 @@ fun GroupMemberInfoLayout( val contactId = member.memberContactId + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + val knownChat = if (contactId != null) knownDirectChat(contactId) else null + if (knownChat != null) { + val (chat, contact) = knownChat + OpenChatButton(onClick = { openDirectChat(contact.contactId) }) + Spacer(Modifier.width(10.dp)) + CallButton(chat, contact) + Spacer(Modifier.width(10.dp)) + VideoButton(chat, contact) + } else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { + if (contactId != null) { + OpenChatButton(onClick = { openDirectChat(contactId) }) + } else { + OpenChatButton(onClick = { createMemberContact() }) + } + Spacer(Modifier.width(10.dp)) + InfoViewActionButton(painterResource(MR.images.ic_call_filled), generalGetString(MR.strings.info_view_call_button), disabled = true, onClick = {}) + Spacer(Modifier.width(10.dp)) + InfoViewActionButton(painterResource(MR.images.ic_videocam_filled), generalGetString(MR.strings.info_view_video_button), disabled = true, onClick = {}) + } + } + SectionSpacer() + if (member.memberActive) { SectionView { - if (contactId != null && knownDirectChat(contactId) != null) { - OpenChatButton(onClick = { openDirectChat(contactId) }) - } else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { - if (contactId != null) { - OpenChatButton(onClick = { openDirectChat(contactId) }) - } else if (member.activeConn?.peerChatVRange?.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) == true) { - OpenChatButton(onClick = { createMemberContact() }) - } - } if (connectionCode != null) { VerifyCodeButton(member.verified, verifyClicked) } @@ -513,12 +534,11 @@ fun RemoveMemberButton(onClick: () -> Unit) { @Composable fun OpenChatButton(onClick: () -> Unit) { - SettingsActionItem( - painterResource(MR.images.ic_chat), - stringResource(MR.strings.button_send_direct_message), - click = onClick, - textColor = MaterialTheme.colors.primary, - iconColor = MaterialTheme.colors.primary, + InfoViewActionButton( + icon = painterResource(MR.images.ic_chat_bubble_filled), + title = generalGetString(MR.strings.info_view_message_button), + disabled = false, + onClick = onClick ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 2324d62ea3..f3d25c896b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -172,7 +172,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { } @Composable -private fun ErrorChatListItem() { +fun ErrorChatListItem() { Box(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp)) { Text(stringResource(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic) } @@ -407,13 +407,73 @@ fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState stringResource(MR.strings.delete_contact_menu_action), painterResource(MR.images.ic_delete), onClick = { - deleteContactDialog(chat, chatModel) + deleteContactConversationDialog(chat, chatModel) showMenu.value = false }, color = Color.Red ) } +fun deleteContactConversationDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) { + val chatInfo = chat.chatInfo + if (chatInfo is ChatInfo.Direct) { + val contactDeletedByUser = chatInfo.contact.contactStatus == ContactStatus.DeletedByUser + AlertManager.shared.showAlertDialogButtonsColumn( + title = if (contactDeletedByUser) generalGetString(MR.strings.delete_conversation_question) else generalGetString(MR.strings.delete_contact_question), + text = if (contactDeletedByUser) generalGetString(MR.strings.delete_conversation_all_messages_deleted_cannot_undo_warning) else null, + buttons = { + Column { + if (contactDeletedByUser) { + // Delete conversation + SectionItemView({ + AlertManager.shared.hideAlert() + deleteContact(chat, chatModel, close, chatDeleteMode = ChatDeleteMode.Full(notify = false)) + }) { + Text(generalGetString(MR.strings.delete_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } else { + // Delete contact + SectionItemView({ + AlertManager.shared.hideAlert() + notifyDeleteContactDialog(chat, chatModel, close) + }) { + Text(generalGetString(MR.strings.button_delete_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Only delete conversation + SectionItemView({ + AlertManager.shared.hideAlert() + deleteContact(chat, chatModel, close, chatDeleteMode = ChatDeleteMode.Messages()) + if (chatModel.controller.appPrefs.showDeleteConversationNotice.get()) { + showDeleteConversationNotice(chatInfo.contact) + } + }) { + Text(generalGetString(MR.strings.only_delete_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } + // Cancel + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) + } +} + +private fun showDeleteConversationNotice(contact: Contact) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.conversation_deleted), + text = String.format(generalGetString(MR.strings.you_can_still_send_messages_to_contact), contact.displayName), + confirmText = generalGetString(MR.strings.ok), + dismissText = generalGetString(MR.strings.dont_show_again), + onDismiss = { + chatModel.controller.appPrefs.showDeleteConversationNotice.set(false) + }, + ) +} + @Composable fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) { ItemAction( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index d03b8a708d..7e51082a7e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -3,336 +3,46 @@ package chat.simplex.common.views.chatlist 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.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.* import androidx.compose.ui.graphics.* -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.platform.* import androidx.compose.ui.text.TextRange import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.* -import chat.simplex.common.SettingsViewState import chat.simplex.common.model.* -import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.onboarding.WhatsNewView -import chat.simplex.common.views.onboarding.shouldShowWhatsNew -import chat.simplex.common.views.usersettings.SettingsView import chat.simplex.common.platform.* -import chat.simplex.common.views.call.Call import chat.simplex.common.views.newchat.* import chat.simplex.res.MR -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import java.net.URI @Composable -fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { - val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } - val showNewChatSheet = { - newChatSheetState.value = AnimatedViewState.VISIBLE - } - val hideNewChatSheet: (animated: Boolean) -> Unit = { animated -> - if (animated) newChatSheetState.value = AnimatedViewState.HIDING - else newChatSheetState.value = AnimatedViewState.GONE - } - LaunchedEffect(Unit) { - if (shouldShowWhatsNew(chatModel)) { - delay(1000L) - ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) } - } - } - LaunchedEffect(chatModel.clearOverlays.value) { - if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false) - } - if (appPlatform.isDesktop) { - KeyChangeEffect(chatModel.chatId.value) { - if (chatModel.chatId.value != null) { - ModalManager.end.closeModalsExceptFirst() - } - AudioPlayer.stop() - VideoPlayerHolder.stopAll() - } - } - val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp - val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } - val scope = rememberCoroutineScope() - val (userPickerState, scaffoldState ) = settingsState - Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(searchText, scaffoldState.drawerState, userPickerState, stopped)} }, - scaffoldState = scaffoldState, - drawerContent = { - tryOrShowError("Settings", error = { ErrorSettingsView() }) { - SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) - } - }, - contentColor = LocalContentColor.current, - drawerContentColor = LocalContentColor.current, - drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f), - drawerGesturesEnabled = appPlatform.isAndroid, - floatingActionButton = { - if (searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) { - FloatingActionButton( - onClick = { - if (!stopped) { - if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet() - } - }, - Modifier.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp), - elevation = FloatingActionButtonDefaults.elevation( - defaultElevation = 0.dp, - pressedElevation = 0.dp, - hoveredElevation = 0.dp, - focusedElevation = 0.dp, - ), - backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - contentColor = Color.White - ) { - Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(MR.images.ic_edit_filled) else painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group)) - } - } - } - ) { - Box(Modifier.padding(it).padding(end = endPadding)) { - Box( - modifier = Modifier - .fillMaxSize() - ) { - if (!chatModel.desktopNoUserNoRemote) { - ChatList(chatModel, searchText = searchText) - } - if (chatModel.chats.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { - Text(stringResource( - if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) - if (!stopped && !newChatSheetState.collectAsState().value.isVisible() && chatModel.chatRunning.value == true && searchText.value.text.isEmpty()) { - OnboardingButtons(showNewChatSheet) - } - } - } - } - } - if (searchText.value.text.isEmpty()) { - if (appPlatform.isDesktop) { - val call = remember { chatModel.activeCall }.value - if (call != null) { - ActiveCallInteractiveArea(call, newChatSheetState) - } - } - // TODO disable this button and sheet for the duration of the switch - tryOrShowError("NewChatSheet", error = {}) { - NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) - } - } - if (appPlatform.isAndroid) { - tryOrShowError("UserPicker", error = {}) { - UserPicker(chatModel, userPickerState) { - scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } - userPickerState.value = AnimatedViewState.GONE - } - } - } -} - -@Composable -private fun OnboardingButtons(openNewChatSheet: () -> Unit) { - Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) { - ConnectButton(generalGetString(MR.strings.tap_to_start_new_chat), openNewChatSheet) - val color = MaterialTheme.colors.primaryVariant - Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = { - val trianglePath = Path().apply { - moveTo(0.dp.toPx(), 0f) - lineTo(16.dp.toPx(), 0.dp.toPx()) - lineTo(8.dp.toPx(), 10.dp.toPx()) - lineTo(0.dp.toPx(), 0.dp.toPx()) - } - drawPath( - color = color, - path = trianglePath - ) - }) - Spacer(Modifier.height(62.dp)) - } -} - -@Composable -private fun ConnectButton(text: String, onClick: () -> Unit) { - Button( - onClick, - shape = RoundedCornerShape(21.dp), - colors = ButtonDefaults.textButtonColors( - backgroundColor = MaterialTheme.colors.primaryVariant - ), - elevation = null, - contentPadding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), - modifier = Modifier.height(42.dp) - ) { - Text(text, color = Color.White) - } -} - -@Composable -private fun ChatListToolbar(searchInList: State, drawerState: DrawerState, userPickerState: MutableStateFlow, stopped: Boolean) { - val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() - if (stopped) { - barButtons.add { - IconButton(onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.chat_is_stopped_indication), - generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app) - ) - }) { - Icon( - painterResource(MR.images.ic_report_filled), - generalGetString(MR.strings.chat_is_stopped_indication), - tint = Color.Red, - ) - } - } - } - val scope = rememberCoroutineScope() - DefaultTopAppBar( - navigationButton = { - if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) { - NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } } - } else { - val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } } - val allRead = users - .filter { u -> !u.user.activeUser && !u.user.hidden } - .all { u -> u.unreadCount == 0 } - UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) { - if (users.size == 1 && chatModel.remoteHosts.isEmpty()) { - scope.launch { drawerState.open() } - } else { - userPickerState.value = AnimatedViewState.VISIBLE - } - } - } - }, - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - stringResource(MR.strings.your_chats), - color = MaterialTheme.colors.onBackground, - fontWeight = FontWeight.SemiBold, - ) - if (chatModel.chats.size > 0) { - val enabled = remember { derivedStateOf { searchInList.value.text.isEmpty() } } - if (enabled.value) { - ToggleFilterEnabledButton() - } else { - ToggleFilterDisabledButton() - } - } - } - }, - onTitleClick = null, - showSearch = false, - onSearchValueChanged = {}, - buttons = barButtons - ) - Divider(Modifier.padding(top = AppBarHeight)) -} - -@Composable -fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onButtonClicked) { - Box { - ProfileImage( - image = image, - size = 37.dp, - color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f) - ) - if (!allRead) { - unreadBadge() - } - } - } - if (appPlatform.isDesktop) { - val h by remember { chatModel.currentRemoteHost } - if (h != null) { - Spacer(Modifier.width(12.dp)) - HostDisconnectButton { - stopRemoteHostAndReloadHosts(h!!, true) - } - } - } - } -} - -@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) - ) -} - -@Composable -private fun ToggleFilterEnabledButton() { +fun ToggleFilterButton() { val pref = remember { ChatController.appPrefs.showUnreadAndFavorites } IconButton(onClick = { pref.set(!pref.get()) }) { Icon( painterResource(MR.images.ic_filter_list), null, - tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.primary, + tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.secondary, modifier = Modifier .padding(3.dp) .background(color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50)) - .border(width = 1.dp, color = MaterialTheme.colors.primary, shape = RoundedCornerShape(50)) + .border(width = 1.dp, color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50)) .padding(3.dp) .size(16.dp) ) } } -@Composable -private fun ToggleFilterDisabledButton() { - IconButton({}, enabled = false) { - Icon( - painterResource(MR.images.ic_filter_list), - null, - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .padding(3.dp) - .border(width = 1.dp, color = MaterialTheme.colors.secondary, shape = RoundedCornerShape(50)) - .padding(3.dp) - .size(16.dp) - ) - } -} - -@Composable -expect fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow) - -fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) { - Log.d(TAG, "connectIfOpenedViaUri: opened via link") - if (chatModel.currentUser.value == null) { - chatModel.appOpenUrl.value = rhId to uri - } else { - withBGApi { - planAndConnect(rhId, uri, incognito = null, close = null) - } - } -} - @Composable private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState, searchShowingSimplexLink: MutableState, searchChatFilteredBySimplexLink: MutableState) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -359,28 +69,8 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState } else { Row { val padding = if (appPlatform.isDesktop) 0.dp else 7.dp - val clipboard = LocalClipboardManager.current - val clipboardHasText = remember(focused) { chatModel.clipboardHasText }.value - if (clipboardHasText) { - IconButton( - onClick = { searchText.value = searchText.value.copy(clipboard.getText()?.text ?: return@IconButton) }, - Modifier.size(30.dp).desktopPointerHoverIconHand() - ) { - Icon(painterResource(MR.images.ic_article), null, tint = MaterialTheme.colors.secondary) - } - } - Spacer(Modifier.width(padding)) - IconButton( - onClick = { - val fixedRhId = chatModel.currentRemoteHost.value - ModalManager.center.closeModals() - ModalManager.center.showModalCloseable { close -> - NewChatView(fixedRhId, selection = NewChatOption.CONNECT, showQRCodeScanner = true, close = close) - } - }, - Modifier.size(30.dp).desktopPointerHoverIconHand() - ) { - Icon(painterResource(MR.images.ic_qr_code), null, tint = MaterialTheme.colors.secondary) + if (chatModel.chats.size > 0) { + ToggleFilterButton() } Spacer(Modifier.width(padding)) } @@ -437,17 +127,10 @@ private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState< } } -@Composable -private fun ErrorSettingsView() { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(generalGetString(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic) - } -} - private var lazyListState = 0 to 0 @Composable -private fun ChatList(chatModel: ChatModel, searchText: MutableState) { +fun ChatList(chatModel: ChatModel, searchText: MutableState) { val listState = rememberLazyListState(lazyListState.first, lazyListState.second) DisposableEffect(Unit) { onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } @@ -508,17 +191,18 @@ private fun filteredChats( } else { val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase() if (s.isEmpty() && !showUnreadAndFavorites) - chats + chats.filter { chat -> !chat.chatInfo.chatDeleted } else { chats.filter { chat -> when (val cInfo = chat.chatInfo) { - is ChatInfo.Direct -> if (s.isEmpty()) { - chat.id == chatModel.chatId.value || filtered(chat) - } else { - (viewNameContains(cInfo, s) || - cInfo.contact.profile.displayName.lowercase().contains(s) || - cInfo.contact.fullName.lowercase().contains(s)) - } + is ChatInfo.Direct -> !chat.chatInfo.chatDeleted && ( + if (s.isEmpty()) { + chat.id == chatModel.chatId.value || filtered(chat) + } else { + (viewNameContains(cInfo, s) || + cInfo.contact.profile.displayName.lowercase().contains(s) || + cInfo.contact.fullName.lowercase().contains(s)) + }) is ChatInfo.Group -> if (s.isEmpty()) { chat.id == chatModel.chatId.value || filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 336d104d2d..8dea5d0072 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -198,7 +198,7 @@ fun ChatPreviewView( } else { when (cInfo) { is ChatInfo.Direct -> - if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null) { + if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null && cInfo.contact.active) { Text(stringResource(MR.strings.contact_tap_to_connect), color = MaterialTheme.colors.primary) } else if (!cInfo.ready && cInfo.contact.activeConn != null) { if (cInfo.contact.nextSendGrpInv) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index a36930f5ce..07869b640f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -16,6 +16,7 @@ import chat.simplex.common.SettingsViewState import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* import chat.simplex.common.platform.* +import chat.simplex.common.views.home.UserProfileButton import chat.simplex.res.MR import kotlinx.coroutines.flow.MutableStateFlow diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 23272fcc0c..ed1500e0ba 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -153,6 +153,8 @@ fun UserPicker( ) { Column( Modifier + .align(Alignment.BottomStart) + .padding(bottom = BottomAppBarHeight) .widthIn(min = 260.dp) .width(IntrinsicSize.Min) .height(IntrinsicSize.Min) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavLink.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavLink.kt new file mode 100644 index 0000000000..300f21beb8 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavLink.kt @@ -0,0 +1,104 @@ +package chat.simplex.common.views.contacts + +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.Color +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import kotlinx.coroutines.delay + +@Composable +fun ContactListNavLinkView(chat: Chat, nextChatSelected: State) { + val showMenu = remember { mutableStateOf(false) } + val disabled = chatModel.chatRunning.value == false || chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id) + LaunchedEffect(chat.id) { + showMenu.value = false + delay(500L) + } + val selectedChat = remember(chat.id) { derivedStateOf { chat.id == chatModel.chatId.value } } + val view = LocalMultiplatformView() + + when (chat.chatInfo) { + is ChatInfo.Direct -> { + ChatListNavLinkLayout( + chatLinkPreview = { + tryOrShowError("${chat.id}ContactListNavLink", error = { ErrorChatListItem() }) { + ContactPreviewView(chat, disabled) + } + }, + click = { + val chatRh = chat.remoteHostId + if (ModalManager.end.hasModalsOpen()) { + ModalManager.end.closeModals() + // return@ChatLayout -- TODO what's this for? + } + hideKeyboard(view) + withBGApi { + var preloadedContactInfo: Pair? = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) + var preloadedCode: String? = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second + ModalManager.end.showModalCloseable(true) { close -> + var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } + var code: String? by remember { mutableStateOf(preloadedCode) } + KeyChangeEffect(chat.id, ChatModel.networkStatuses.toMap()) { + contactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId) + preloadedContactInfo = contactInfo + code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second + preloadedCode = code + } + ChatInfoView(chatModel, openedFromChatView = false, chat.chatInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close) + } + } + }, + dropdownMenuItems = { + tryOrShowError("${chat.id}ContactListNavLinkDropdown", error = {}) { + ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu) + } + }, + showMenu, + disabled, + selectedChat, + nextChatSelected, + ) + } + else -> {} + } +} + +@Composable +fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMenu: MutableState) { + if (contact.activeConn != null) { + ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu) + } + DeleteContactAction(chat, chatModel, showMenu) +} + +@Composable +fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolean, showMenu: MutableState) { + ItemAction( + if (favorite) stringResource(MR.strings.unfavorite_chat) else stringResource(MR.strings.favorite_chat), + if (favorite) painterResource(MR.images.ic_star_off) else painterResource(MR.images.ic_star), + onClick = { + toggleChatFavorite(chat, !favorite, chatModel) + showMenu.value = false + } + ) +} + +@Composable +fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.delete_contact_menu_action), + painterResource(MR.images.ic_delete), + onClick = { + deleteContactDialog(chat, chatModel) + showMenu.value = false + }, + color = Color.Red + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt new file mode 100644 index 0000000000..082c2491ba --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactPreviewView.kt @@ -0,0 +1,117 @@ +package chat.simplex.common.views.contacts + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.theme.DEFAULT_SPACE_AFTER_ICON +import chat.simplex.res.MR + +@Composable +fun ContactPreviewView( + chat: Chat, + disabled: Boolean, +) { + val cInfo = chat.chatInfo + + @Composable + fun inactiveIcon() { + Icon( + painterResource(MR.images.ic_cancel_filled), + stringResource(MR.strings.icon_descr_group_inactive), + Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape), + tint = MaterialTheme.colors.secondary + ) + } + + @Composable + fun chatPreviewImageOverlayIcon() { + when (cInfo) { + is ChatInfo.Direct -> + if (!cInfo.contact.active) { + inactiveIcon() + } + + else -> {} + } + } + + @Composable + fun VerifiedIcon() { + Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary) + } + + @Composable + fun chatPreviewTitle() { + val deleting by remember(disabled, chat.id) { mutableStateOf(chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)) } + when (cInfo) { + is ChatInfo.Direct -> + Row(verticalAlignment = Alignment.CenterVertically) { + if (cInfo.contact.verified) { + VerifiedIcon() + } + Text( + cInfo.chatViewName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (deleting) MaterialTheme.colors.secondary else Color.Unspecified + ) + } + + else -> {} + } + } + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Box(contentAlignment = Alignment.BottomEnd) { + ChatInfoImage(cInfo, size = 42.dp) + Box(Modifier.padding(end = 2.dp, bottom = 2.dp)) { + chatPreviewImageOverlayIcon() + } + } + + Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) + + Box(modifier = Modifier.weight(10f, fill = true)) { + chatPreviewTitle() + } + + Spacer(Modifier.fillMaxWidth().weight(1f)) + + if (chat.chatInfo.chatSettings?.favorite == true) { + Icon( + painterResource(MR.images.ic_star_filled), + contentDescription = generalGetString(MR.strings.favorite_chat), + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(17.dp) + ) + if (chat.chatInfo.incognito) { + Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON)) + } + } + + if (chat.chatInfo.incognito) { + Icon( + painterResource(MR.images.ic_theater_comedy), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(21.dp) + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactsView.kt new file mode 100644 index 0000000000..cb25a76932 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactsView.kt @@ -0,0 +1,160 @@ +package chat.simplex.common.views.contacts + + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.* +import androidx.compose.ui.platform.* +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.* +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.chatlist.ToggleFilterButton +import chat.simplex.common.views.home.contactChats +import chat.simplex.res.MR +import kotlinx.coroutines.flow.distinctUntilChanged + +@Composable +private fun ContactsSearchBar(listState: LazyListState, searchText: MutableState) { + Row(verticalAlignment = Alignment.CenterVertically) { + val focusRequester = remember { FocusRequester() } + var focused by remember { mutableStateOf(false) } + Icon(painterResource(MR.images.ic_search), null, Modifier.padding(horizontal = DEFAULT_PADDING_HALF), tint = MaterialTheme.colors.secondary) + SearchTextField( + Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester), + placeholder = stringResource(MR.strings.search_verb), + alwaysVisible = true, + searchText = searchText, + trailingContent = null, + ) { + searchText.value = searchText.value.copy(it) + } + val hasText = remember { derivedStateOf { searchText.value.text.isNotEmpty() } } + if (hasText.value) { + val hideSearchOnBack: () -> Unit = { searchText.value = TextFieldValue() } + BackHandler(onBack = hideSearchOnBack) + KeyChangeEffect(chatModel.currentRemoteHost.value) { + hideSearchOnBack() + } + } else { + Row { + val padding = if (appPlatform.isDesktop) 0.dp else 7.dp + if (chatModel.chats.size > 0) { + ToggleFilterButton() + } + Spacer(Modifier.width(padding)) + } + } + val focusManager = LocalFocusManager.current + val keyboardState = getKeyboardState() + LaunchedEffect(keyboardState.value) { + if (keyboardState.value == KeyboardState.Closed && focused) { + focusManager.clearFocus() + } + } + LaunchedEffect(Unit) { + snapshotFlow { searchText.value.text } + .distinctUntilChanged() + .collect { + if (it.isNotEmpty()) { + focusRequester.requestFocus() + } else if (listState.layoutInfo.totalItemsCount > 0) { + listState.scrollToItem(0) + } + } + } + } +} + +private var lazyListState = 0 to 0 + +@Composable +fun ContactsList(chatModel: ChatModel, searchText: MutableState) { + val listState = rememberLazyListState(lazyListState.first, lazyListState.second) + DisposableEffect(Unit) { + onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } + } + val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value + val allContactChats = remember(chatModel.chats.toList()) { contactChats(chatModel.chats) } + // See "IndexOutOfBoundsException" comment in ChatListView + val filteredContactChats = filteredContactChats(showUnreadAndFavorites, searchText.value.text, allContactChats.toList()) + LazyColumnWithScrollBar( + Modifier.fillMaxWidth(), + listState + ) { + stickyHeader { + Column( + Modifier + .offset { + val y = if (searchText.value.text.isEmpty()) { + if (listState.firstVisibleItemIndex == 0) -listState.firstVisibleItemScrollOffset else -1000 + } else { + 0 + } + IntOffset(0, y) + } + .background(MaterialTheme.colors.background) + ) { + ContactsSearchBar(listState, searchText) + Divider() + } + } + itemsIndexed(filteredContactChats) { index, chat -> + val nextChatSelected = remember(chat.id, filteredContactChats) { derivedStateOf { + chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value + } } + ContactListNavLinkView(chat, nextChatSelected) + } + } + if (filteredContactChats.isEmpty() && allContactChats.isNotEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(generalGetString(MR.strings.no_filtered_contacts), color = MaterialTheme.colors.secondary) + } + } +} + +private fun filteredContactChats( + showUnreadAndFavorites: Boolean, + searchText: String, + contactChats: List +): List { + val s = searchText.trim().lowercase() + return ( + if (s.isEmpty() && !showUnreadAndFavorites) + contactChats.filter { chat -> + when (val cInfo = chat.chatInfo) { + is ChatInfo.Direct -> cInfo.contact.contactStatus != ContactStatus.DeletedByUser + else -> false + } + } + else { + contactChats.filter { chat -> + when (val cInfo = chat.chatInfo) { + is ChatInfo.Direct -> cInfo.contact.contactStatus != ContactStatus.DeletedByUser && ( + if (s.isEmpty()) { + chat.id == chatModel.chatId.value || + (cInfo.chatSettings?.favorite ?: false) + } else { + (viewNameContains(cInfo, s) || + cInfo.contact.profile.displayName.lowercase().contains(s) || + cInfo.contact.fullName.lowercase().contains(s)) + }) + + else -> false + } + } + } + ).sortedBy { it.chatInfo.displayName.lowercase() } +} + +private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean = + cInfo.chatViewName.lowercase().contains(s.lowercase()) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index 577411c7e3..4e6b5b4df6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -15,7 +15,7 @@ import chat.simplex.res.MR @Composable fun DefaultTopAppBar( - navigationButton: @Composable RowScope.() -> Unit, + navigationButton: (@Composable RowScope.() -> Unit)? = null, title: (@Composable () -> Unit)?, onTitleClick: (() -> Unit)? = null, showSearch: Boolean, @@ -125,5 +125,6 @@ private fun TopAppBar( val AppBarHeight = 56.dp val AppBarHorizontalPadding = 4.dp +val BottomAppBarHeight = 60.dp private val TitleInsetWithoutIcon = DEFAULT_PADDING - AppBarHorizontalPadding val TitleInsetWithIcon = 72.dp diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 884551f600..cd002d220a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.ThemeOverrides -import chat.simplex.common.views.chatlist.connectIfOpenedViaUri +import chat.simplex.common.views.home.connectIfOpenedViaUri import chat.simplex.res.MR import com.charleskorn.kaml.decodeFromStream import dev.icerock.moko.resources.StringResource diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/home/HomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/home/HomeView.kt new file mode 100644 index 0000000000..5963b0b393 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/home/HomeView.kt @@ -0,0 +1,372 @@ +package chat.simplex.common.views.home + + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +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.* +import androidx.compose.ui.text.font.FontStyle +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.* +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import chat.simplex.common.SettingsViewState +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.chatModel +import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.WhatsNewView +import chat.simplex.common.views.onboarding.shouldShowWhatsNew +import chat.simplex.common.views.usersettings.SettingsView +import chat.simplex.common.platform.* +import chat.simplex.common.views.call.Call +import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.contacts.ContactsList +import chat.simplex.common.views.newchat.* +import chat.simplex.res.MR +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import java.net.URI + +sealed class HomeTab { + class Chats: HomeTab() + class Contacts: HomeTab() +} + +@Composable +fun HomeView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { + val homeTab = remember { mutableStateOf(HomeTab.Chats()) } + val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) } + val showNewChatSheet = { + newChatSheetState.value = AnimatedViewState.VISIBLE + } + val hideNewChatSheet: (animated: Boolean) -> Unit = { animated -> + if (animated) newChatSheetState.value = AnimatedViewState.HIDING + else newChatSheetState.value = AnimatedViewState.GONE + } + LaunchedEffect(Unit) { + if (shouldShowWhatsNew(chatModel)) { + delay(1000L) + ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) } + } + } + LaunchedEffect(chatModel.clearOverlays.value) { + if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false) + } + if (appPlatform.isDesktop) { + KeyChangeEffect(chatModel.chatId.value) { + if (chatModel.chatId.value != null) { + ModalManager.end.closeModalsExceptFirst() + } + AudioPlayer.stop() + VideoPlayerHolder.stopAll() + } + } + val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp + val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } + val scope = rememberCoroutineScope() + val (userPickerState, scaffoldState ) = settingsState + Scaffold( + topBar = { Box(Modifier.padding(end = endPadding)) { HomeTopBar(homeTab, stopped) } }, + bottomBar = { Box(Modifier.padding(end = endPadding)) { HomeBottomBar(scaffoldState.drawerState, userPickerState, homeTab, stopped) } }, + scaffoldState = scaffoldState, + drawerContent = { + tryOrShowError("Settings", error = { ErrorSettingsView() }) { + SettingsView(chatModel, setPerformLA, scaffoldState.drawerState) + } + }, + contentColor = LocalContentColor.current, + drawerContentColor = LocalContentColor.current, + drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f), + drawerGesturesEnabled = appPlatform.isAndroid, + floatingActionButton = { + if (searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) { + FloatingActionButton( + onClick = { + if (!stopped) { + if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet() + } + }, + Modifier.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp), + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + hoveredElevation = 0.dp, + focusedElevation = 0.dp, + ), + backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + contentColor = Color.White + ) { + Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(MR.images.ic_edit_filled) else painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group)) + } + } + } + ) { + Box(Modifier.padding(it).padding(end = endPadding)) { + when (homeTab.value) { + is HomeTab.Chats -> ChatsView(searchText) + is HomeTab.Contacts -> ContactsView(searchText) + } + } + } + if (searchText.value.text.isEmpty()) { + if (appPlatform.isDesktop) { + val call = remember { chatModel.activeCall }.value + if (call != null) { + ActiveCallInteractiveArea(call, newChatSheetState) + } + } + // TODO disable this button and sheet for the duration of the switch + tryOrShowError("NewChatSheet", error = {}) { + NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) + } + } + if (appPlatform.isAndroid) { + tryOrShowError("UserPicker", error = {}) { + UserPicker(chatModel, userPickerState) { + scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() } + userPickerState.value = AnimatedViewState.GONE + } + } + } +} + +@Composable +private fun ChatsView(searchText: MutableState) { + Box( + modifier = Modifier + .fillMaxSize() + ) { + if (!chatModel.desktopNoUserNoRemote) { + ChatList(chatModel, searchText = searchText) + } + if (chatModel.chats.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { + Text(stringResource( + if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats), + Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary + ) + } + } +} + +@Composable +private fun ContactsView(searchText: MutableState) { + Box( + modifier = Modifier + .fillMaxSize() + ) { + if (!chatModel.desktopNoUserNoRemote) { + ContactsList(chatModel, searchText = searchText) + } + if (remember(chatModel.chats.toList()) { contactChats(chatModel.chats) }.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { + Text(stringResource( + if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.no_contacts), + Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary + ) + } + } +} + +fun contactChats(c: List): List { + return c.filter { it.chatInfo is ChatInfo.Direct } +} + +@Composable +private fun HomeTopBar(homeTab: MutableState, stopped: Boolean) { + val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() + if (stopped) { + barButtons.add { + IconButton(onClick = { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.chat_is_stopped_indication), + generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app) + ) + }) { + Icon( + painterResource(MR.images.ic_report_filled), + generalGetString(MR.strings.chat_is_stopped_indication), + tint = Color.Red, + ) + } + } + } + val title = when (homeTab.value) { + is HomeTab.Chats -> stringResource(MR.strings.your_chats) + is HomeTab.Contacts -> stringResource(MR.strings.contacts) + } + DefaultTopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + title, + color = MaterialTheme.colors.onBackground, + fontWeight = FontWeight.SemiBold, + ) + } + }, + onTitleClick = null, + showSearch = false, + onSearchValueChanged = {}, + buttons = barButtons + ) + Divider(Modifier.padding(top = AppBarHeight)) +} + +@Composable +private fun HomeBottomBar( + drawerState: DrawerState, + userPickerState: MutableStateFlow, + homeTab: MutableState, + stopped: Boolean) { + Box( + Modifier + .fillMaxWidth() + .height(BottomAppBarHeight) + .background(MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f)) + ) { + Divider() + Row( + Modifier + .fillMaxHeight() + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + SettingsButton(drawerState, userPickerState, stopped) + + HomeTabButton( + icon = painterResource(if (homeTab.value is HomeTab.Chats) MR.images.ic_chat_bubble_filled else MR.images.ic_chat_bubble), + title = generalGetString(MR.strings.your_chats), + onClick = { homeTab.value = HomeTab.Chats() } + ) + + HomeTabButton( + icon = if (homeTab.value is HomeTab.Contacts) rememberVectorPainter(AccountCircleFilled) else painterResource(MR.images.ic_account_circle_filled), + title = generalGetString(MR.strings.contacts), + onClick = { homeTab.value = HomeTab.Contacts() } + ) + } + } +} + +@Composable +fun SettingsButton(drawerState: DrawerState, userPickerState: MutableStateFlow, stopped: Boolean) { + val scope = rememberCoroutineScope() + if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) { + NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } } + } else { + val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } } + val allRead = users + .filter { u -> !u.user.activeUser && !u.user.hidden } + .all { u -> u.unreadCount == 0 } + UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) { + if (users.size == 1 && chatModel.remoteHosts.isEmpty()) { + scope.launch { drawerState.open() } + } else { + userPickerState.value = AnimatedViewState.VISIBLE + } + } + } +} + +@Composable +fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onButtonClicked) { + Box { + ProfileImage( + image = image, + size = 56.dp, + color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f) + ) + if (!allRead) { + unreadBadge() + } + } + } + if (appPlatform.isDesktop) { + val h by remember { chatModel.currentRemoteHost } + if (h != null) { + Spacer(Modifier.width(12.dp)) + HostDisconnectButton { + stopRemoteHostAndReloadHosts(h!!, true) + } + } + } + } +} + +@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) + ) +} + +@Composable +fun HomeTabButton(icon: Painter, title: String, onClick: () -> Unit) { + Surface( + Modifier + .size(56.dp), + shape = RoundedCornerShape(10.dp), + color = Color.Transparent, + ) { + Column( + Modifier + .clickable { onClick () }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + icon, + contentDescription = null, + Modifier.size(24.dp), + tint = MaterialTheme.colors.secondary + ) + Text( + title, + style = MaterialTheme.typography.subtitle2.copy(fontWeight = FontWeight.Normal, fontSize = 12.sp), + color = MaterialTheme.colors.secondary + ) + } + } +} + +@Composable +expect fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow) + +fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) { + Log.d(TAG, "connectIfOpenedViaUri: opened via link") + if (chatModel.currentUser.value == null) { + chatModel.appOpenUrl.value = rhId to uri + } else { + withBGApi { + planAndConnect(rhId, uri, incognito = null, close = null) + } + } +} + +@Composable +private fun ErrorSettingsView() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(generalGetString(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 26c5422623..4c891169be 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -44,6 +44,11 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) } }, + scanPaste = { + closeNewChatSheet(false) + ModalManager.center.closeModals() + ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = true, close = close) } + }, createGroup = { closeNewChatSheet(false) ModalManager.center.closeModals() @@ -55,15 +60,17 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow, stopped: Boolean, addContact: () -> Unit, + scanPaste: () -> Unit, createGroup: () -> Unit, closeNewChatSheet: (animated: Boolean) -> Unit, ) { @@ -102,7 +109,7 @@ private fun NewChatSheetLayout( verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.End ) { - val actions = remember { listOf(addContact, createGroup) } + val actions = remember { listOf(addContact, scanPaste, createGroup) } val backgroundColor = if (isInDarkTheme()) blendARGB(MaterialTheme.colors.primary, Color.Black, 0.7F) else @@ -145,7 +152,7 @@ private fun NewChatSheetLayout( } FloatingActionButton( onClick = { if (!stopped) closeNewChatSheet(true) }, - Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING), + Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + BottomAppBarHeight), elevation = FloatingActionButtonDefaults.elevation( defaultElevation = 0.dp, pressedElevation = 0.dp, @@ -264,6 +271,7 @@ private fun PreviewNewChatSheet() { MutableStateFlow(AnimatedViewState.VISIBLE), stopped = false, addContact = {}, + scanPaste = {}, createGroup = {}, closeNewChatSheet = {}, ) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index df43d1459f..e31d156255 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -331,6 +331,7 @@ Welcome! This text is available in settings Chats + Contacts connecting… send direct message you are invited to group @@ -341,6 +342,8 @@ You have no chats Loading chats… No filtered chats + No contacts + No filtered contacts Tap to Connect Connect with %1$s? Search or paste SimpleX link @@ -418,10 +421,28 @@ Notifications + connect + open + message + call + video Delete contact? Contact and all messages will be deleted - this cannot be undone! + Contact will be deleted - this cannot be undone! + Delete conversation? + Conversation and all messages will be deleted - this cannot be undone! + Delete conversation + Only delete conversation + Delete contact, keep conversation + Notify contact? + Confirm contact deletion? Delete and notify contact + Delete without notification Delete contact + Conversation deleted! + You can still send messages to %1$s from the Contacts tab. + Contact deleted! + You can still view conversation with %1$s in the Chats tab. Set contact name… Connected Disconnected @@ -608,6 +629,7 @@ New chat Add contact + Scan / Paste link One-time invitation link 1-time link SimpleX address diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_bubble_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_bubble_filled.svg new file mode 100644 index 0000000000..15d4ef80a9 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_chat_bubble_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt deleted file mode 100644 index c2333393e5..0000000000 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt +++ /dev/null @@ -1,74 +0,0 @@ -package chat.simplex.common.views.chatlist - -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import chat.simplex.common.platform.* -import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.call.Call -import chat.simplex.common.views.call.CallMediaType -import chat.simplex.common.views.chat.item.ItemAction -import chat.simplex.common.views.helpers.* -import chat.simplex.res.MR -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource -import kotlinx.coroutines.flow.MutableStateFlow - -@Composable -actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow) { - // if (call.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) { - if (!newChatSheetState.collectAsState().value.isVisible()) { - val showMenu = remember { mutableStateOf(false) } - val media = call.peerMedia ?: call.localMedia - CompositionLocalProvider( - LocalIndication provides NoIndication - ) { - Box( - Modifier - .fillMaxSize(), - contentAlignment = Alignment.BottomEnd - ) { - Box( - Modifier - .padding(end = 71.dp, bottom = 92.dp) - .size(67.dp) - .combinedClickable(onClick = { - val chat = chatModel.getChat(call.contact.id) - if (chat != null) { - withBGApi { - openChat(chat.remoteHostId, chat.chatInfo, chatModel) - } - } - }, - onLongClick = { showMenu.value = true }) - .onRightClick { showMenu.value = true }, - contentAlignment = Alignment.Center - ) { - Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) { - ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image) - } - Box(Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp).align(Alignment.TopEnd)) { - if (media == CallMediaType.Video) { - Icon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call), Modifier.size(18.dp), tint = Color.White) - } else { - Icon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call), Modifier.size(18.dp), tint = Color.White) - } - } - DefaultDropdownMenu(showMenu) { - ItemAction(stringResource(MR.strings.icon_descr_hang_up), painterResource(MR.images.ic_call_end_filled), color = MaterialTheme.colors.error, onClick = { - withBGApi { chatModel.callManager.endCall(call) } - showMenu.value = false - }) - } - } - } - } - } -} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/home/HomeView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/home/HomeView.desktop.kt new file mode 100644 index 0000000000..a66a50675c --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/home/HomeView.desktop.kt @@ -0,0 +1,76 @@ +package chat.simplex.common.views.home + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.call.Call +import chat.simplex.common.views.call.CallMediaType +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.chatlist.NoIndication +import chat.simplex.common.views.chatlist.openChat +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +actual fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow) { + // if (call.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) { + if (!newChatSheetState.collectAsState().value.isVisible()) { + val showMenu = remember { mutableStateOf(false) } + val media = call.peerMedia ?: call.localMedia + CompositionLocalProvider( + LocalIndication provides NoIndication + ) { + Box( + Modifier + .fillMaxSize(), + contentAlignment = Alignment.BottomEnd + ) { + Box( + Modifier + .padding(end = 71.dp, bottom = 92.dp) + .size(67.dp) + .combinedClickable(onClick = { + val chat = chatModel.getChat(call.contact.id) + if (chat != null) { + withBGApi { + openChat(chat.remoteHostId, chat.chatInfo, chatModel) + } + } + }, + onLongClick = { showMenu.value = true }) + .onRightClick { showMenu.value = true }, + contentAlignment = Alignment.Center + ) { + Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) { + ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image) + } + Box(Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp).align(Alignment.TopEnd)) { + if (media == CallMediaType.Video) { + Icon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call), Modifier.size(18.dp), tint = Color.White) + } else { + Icon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call), Modifier.size(18.dp), tint = Color.White) + } + } + DefaultDropdownMenu(showMenu) { + ItemAction(stringResource(MR.strings.icon_descr_hang_up), painterResource(MR.images.ic_call_end_filled), color = MaterialTheme.colors.error, onClick = { + withBGApi { chatModel.callManager.endCall(call) } + showMenu.value = false + }) + } + } + } + } + } +}