From ce51411c52825e50f83c1c426d863b5087a3c6f1 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Fri, 20 Mar 2026 21:44:33 +0100 Subject: [PATCH 01/21] chore(conv-list): Migrate status banner to Composable AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../ConversationsListActivity.kt | 44 ++++++---- .../talk/conversationlist/ui/StatusBanner.kt | 87 +++++++++++++++++++ .../res/layout/activity_conversations.xml | 26 +----- 3 files changed, 116 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ui/StatusBanner.kt diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index a79c7b78631..c84474a5bfa 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -1,12 +1,7 @@ /* * Nextcloud Talk - Android Client * - * SPDX-FileCopyrightText: 2022-2024 Marcel Hibbe - * SPDX-FileCopyrightText: 2022-2023 Andy Scherzinger - * SPDX-FileCopyrightText: 2023 Tobias Kaminsky - * SPDX-FileCopyrightText: 2023 Ezhil Shanmugham - * SPDX-FileCopyrightText: 2022 Álvaro Brey - * SPDX-FileCopyrightText: 2017-2020 Mario Danic + * SPDX-FileCopyrightText: 2017-2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package com.nextcloud.talk.conversationlist @@ -38,7 +33,9 @@ import androidx.activity.OnBackPressedCallback import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat @@ -49,6 +46,7 @@ import androidx.core.view.MenuItemCompat import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import androidx.work.Data @@ -90,6 +88,7 @@ import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.contextchat.ContextChatView import com.nextcloud.talk.contextchat.ContextChatViewModel +import com.nextcloud.talk.conversationlist.ui.StatusBannerRow import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User @@ -109,22 +108,21 @@ import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import com.nextcloud.talk.settings.SettingsActivity import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity import com.nextcloud.talk.ui.BackgroundVoiceMessageCard -import com.nextcloud.talk.ui.dialog.ChooseAccountDialogCompose import com.nextcloud.talk.ui.chooseaccount.ChooseAccountShareToDialogFragment +import com.nextcloud.talk.ui.dialog.ChooseAccountDialogCompose import com.nextcloud.talk.ui.dialog.ConversationsListBottomDialog import com.nextcloud.talk.ui.dialog.FilterConversationFragment import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.ARCHIVE import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.MENTION import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.UNREAD import com.nextcloud.talk.users.UserManager -import androidx.compose.ui.platform.LocalContext import com.nextcloud.talk.utils.ApiUtils -import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.BrandingUtils import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.CapabilitiesUtil.isServerEOL import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.ConversationUtils +import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.NotificationUtils @@ -152,6 +150,7 @@ import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import io.reactivex.subjects.BehaviorSubject +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -207,6 +206,7 @@ class ConversationsListActivity : get() = AppBarLayoutType.SEARCH_BAR private var currentUser: User? = null + private val _isMaintenanceMode = MutableStateFlow(false) private var roomsQueryDisposable: Disposable? = null private var openConversationsQueryDisposable: Disposable? = null private var adapter: FlexibleAdapter>? = null @@ -267,6 +267,7 @@ class ConversationsListActivity : binding = ActivityConversationsBinding.inflate(layoutInflater) setupActionBar() setContentView(binding.root) + setupStatusBanner() initSystemBars() viewThemeUtils.material.themeSearchCardView(binding.searchToolbarContainer) viewThemeUtils.material.colorMaterialButtonContent(binding.menuButton, ColorRole.ON_SURFACE_VARIANT) @@ -371,7 +372,6 @@ class ConversationsListActivity : private fun initObservers() { this.lifecycleScope.launch { networkMonitor.isOnline.onEach { isOnline -> - showNetworkErrorDialog(!isOnline) handleUI(isOnline) }.collect() } @@ -1147,12 +1147,18 @@ class ConversationsListActivity : } } - private fun showNetworkErrorDialog(show: Boolean) { - binding.chatListConnectionLost.visibility = if (show) View.VISIBLE else View.GONE - } - - private fun showMaintenanceModeWarning(show: Boolean) { - binding.chatListMaintenanceWarning.visibility = if (show) View.VISIBLE else View.GONE + private fun setupStatusBanner() { + binding.statusBannerComposeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() + val isMaintenanceMode by _isMaintenanceMode.collectAsStateWithLifecycle() + StatusBannerRow( + isOffline = !isOnline, + isMaintenanceMode = isMaintenanceMode + ) + } + } } private fun handleUI(show: Boolean) { @@ -1205,7 +1211,7 @@ class ConversationsListActivity : private fun prepareViews() { hideLogoForBrandedClients() - showMaintenanceModeWarning(false) + _isMaintenanceMode.value = false layoutManager = SmoothScrollLinearLayoutManager(this) binding.recyclerView.layoutManager = layoutManager @@ -1230,7 +1236,7 @@ class ConversationsListActivity : false } binding.swipeRefreshLayoutView.setOnRefreshListener { - showMaintenanceModeWarning(false) + _isMaintenanceMode.value = false fetchRooms() fetchPendingInvitations() } @@ -2022,7 +2028,7 @@ class ConversationsListActivity : private fun showServiceUnavailableDialog(httpException: HttpException) { if (httpException.response()?.headers()?.get(MAINTENANCE_MODE_HEADER_KEY) == "1") { - showMaintenanceModeWarning(true) + _isMaintenanceMode.value = true } else { showErrorDialog() } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/StatusBanner.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/StatusBanner.kt new file mode 100644 index 00000000000..6aec84d2351 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/StatusBanner.kt @@ -0,0 +1,87 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationlist.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextcloud.talk.R + +@Composable +fun StatusBannerRow(isOffline: Boolean, isMaintenanceMode: Boolean) { + Column { + AnimatedVisibility( + visible = isOffline, + enter = expandVertically(), + exit = shrinkVertically() + ) { + Text( + text = stringResource(R.string.connection_lost), + modifier = Modifier + .fillMaxWidth() + .background(colorResource(R.color.nc_darkRed)) + .padding(4.dp), + color = Color.White, + textAlign = TextAlign.Center + ) + } + + AnimatedVisibility( + visible = isMaintenanceMode, + enter = expandVertically(), + exit = shrinkVertically() + ) { + Text( + text = stringResource(R.string.nc_dialog_maintenance_mode_description), + modifier = Modifier + .fillMaxWidth() + .background(colorResource(R.color.nc_darkRed)) + .padding(4.dp), + color = Color.White, + textAlign = TextAlign.Center + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun StatusBannerOfflinePreview() { + StatusBannerRow(isOffline = true, isMaintenanceMode = false) +} + +@Preview(showBackground = true) +@Composable +fun StatusBannerMaintenancePreview() { + StatusBannerRow(isOffline = false, isMaintenanceMode = true) +} + +@Preview(showBackground = true) +@Composable +fun StatusBannerBothPreview() { + StatusBannerRow(isOffline = true, isMaintenanceMode = true) +} + +@Preview(showBackground = true) +@Composable +fun StatusBannerNonePreview() { + StatusBannerRow(isOffline = false, isMaintenanceMode = false) +} diff --git a/app/src/main/res/layout/activity_conversations.xml b/app/src/main/res/layout/activity_conversations.xml index bc99de77bae..301531cdaf6 100644 --- a/app/src/main/res/layout/activity_conversations.xml +++ b/app/src/main/res/layout/activity_conversations.xml @@ -2,8 +2,7 @@ - - - + android:layout_height="wrap_content" /> Date: Fri, 20 Mar 2026 22:20:55 +0100 Subject: [PATCH 02/21] chore(conv-list): Migrate empty state to Composable AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../ConversationsListActivity.kt | 51 +++--- .../ui/ConversationsEmptyState.kt | 145 ++++++++++++++++++ .../res/layout/activity_conversations.xml | 80 +--------- 3 files changed, 173 insertions(+), 103 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index c84474a5bfa..e467b175a02 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -88,6 +88,7 @@ import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.contextchat.ContextChatView import com.nextcloud.talk.contextchat.ContextChatViewModel +import com.nextcloud.talk.conversationlist.ui.ConversationsEmptyStateView import com.nextcloud.talk.conversationlist.ui.StatusBannerRow import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel import com.nextcloud.talk.data.network.NetworkMonitor @@ -207,6 +208,8 @@ class ConversationsListActivity : private var currentUser: User? = null private val _isMaintenanceMode = MutableStateFlow(false) + private val _isListEmpty = MutableStateFlow(false) + private val _showNoArchivedView = MutableStateFlow(false) private var roomsQueryDisposable: Disposable? = null private var openConversationsQueryDisposable: Disposable? = null private var adapter: FlexibleAdapter>? = null @@ -268,6 +271,7 @@ class ConversationsListActivity : setupActionBar() setContentView(binding.root) setupStatusBanner() + setupEmptyStateView() initSystemBars() viewThemeUtils.material.themeSearchCardView(binding.searchToolbarContainer) viewThemeUtils.material.colorMaterialButtonContent(binding.menuButton, ColorRole.ON_SURFACE_VARIANT) @@ -671,11 +675,7 @@ class ConversationsListActivity : } val archiveFilterOn = filterState[ARCHIVE] == true - if (archiveFilterOn && newItems.isEmpty()) { - binding.noArchivedConversationLayout.visibility = View.VISIBLE - } else { - binding.noArchivedConversationLayout.visibility = View.GONE - } + _showNoArchivedView.value = archiveFilterOn && newItems.isEmpty() adapter?.updateDataSet(newItems, true) setFilterableItems(newItems) @@ -1056,17 +1056,12 @@ class ConversationsListActivity : } private fun initOverallLayout(isConversationListNotEmpty: Boolean) { + _isListEmpty.value = !isConversationListNotEmpty if (isConversationListNotEmpty) { - if (binding.emptyLayout.visibility != View.GONE) { - binding.emptyLayout.visibility = View.GONE - } if (binding.swipeRefreshLayoutView.visibility != View.VISIBLE) { binding.swipeRefreshLayoutView.visibility = View.VISIBLE } } else { - if (binding.emptyLayout.visibility != View.VISIBLE) { - binding.emptyLayout.visibility = View.VISIBLE - } if (binding.swipeRefreshLayoutView.visibility != View.GONE) { binding.swipeRefreshLayoutView.visibility = View.GONE } @@ -1161,6 +1156,23 @@ class ConversationsListActivity : } } + private fun setupEmptyStateView() { + val showLogo = BrandingUtils.isOriginalNextcloudClient(applicationContext) + binding.emptyStateComposeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val isListEmpty by _isListEmpty.collectAsStateWithLifecycle() + val showNoArchivedView by _showNoArchivedView.collectAsStateWithLifecycle() + ConversationsEmptyStateView( + isListEmpty = isListEmpty, + showNoArchivedView = showNoArchivedView, + showLogo = showLogo, + onCreateNewConversation = { showNewConversationsScreen() } + ) + } + } + } + private fun handleUI(show: Boolean) { binding.floatingActionButton.isEnabled = show binding.searchText.isEnabled = show @@ -1209,8 +1221,6 @@ class ConversationsListActivity : @SuppressLint("ClickableViewAccessibility") private fun prepareViews() { - hideLogoForBrandedClients() - _isMaintenanceMode.value = false layoutManager = SmoothScrollLinearLayoutManager(this) @@ -1241,7 +1251,6 @@ class ConversationsListActivity : fetchPendingInvitations() } binding.swipeRefreshLayoutView.let { viewThemeUtils.androidx.themeSwipeRefreshLayout(it) } - binding.emptyLayout.setOnClickListener { showNewConversationsScreen() } binding.floatingActionButton.setOnClickListener { run(context) showNewConversationsScreen() @@ -1283,12 +1292,6 @@ class ConversationsListActivity : binding.newMentionPopupBubble.let { viewThemeUtils.material.colorMaterialButtonPrimaryFilled(it) } } - private fun hideLogoForBrandedClients() { - if (!BrandingUtils.isOriginalNextcloudClient(applicationContext)) { - binding.emptyListIcon.visibility = View.GONE - } - } - @SuppressLint("CheckResult") @Suppress("Detekt.TooGenericExceptionCaught") private fun checkToShowUnreadBubble() { @@ -1388,7 +1391,7 @@ class ConversationsListActivity : private fun performFilterAndSearch(filter: String?) { if (filter!!.length >= SEARCH_MIN_CHARS) { - binding.noArchivedConversationLayout.visibility = View.GONE + _showNoArchivedView.value = false adapter?.setFilter(filter) conversationsListViewModel.getSearchQuery(context, filter) } else { @@ -1401,11 +1404,7 @@ class ConversationsListActivity : adapter?.setFilter("") adapter?.filterItems() val archiveFilterOn = filterState[ARCHIVE] == true - if (archiveFilterOn && adapter!!.isEmpty) { - binding.noArchivedConversationLayout.visibility = View.VISIBLE - } else { - binding.noArchivedConversationLayout.visibility = View.GONE - } + _showNoArchivedView.value = archiveFilterOn && adapter!!.isEmpty } private fun clearMessageSearchResults() { diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt new file mode 100644 index 00000000000..4f425325716 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt @@ -0,0 +1,145 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationlist.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nextcloud.talk.R + +/** + * Top-level wrapper rendered by the empty_state_compose_view ComposeView. + * Shows either the no-archived view, the generic empty view, or nothing. + */ +@Composable +fun ConversationsEmptyStateView( + isListEmpty: Boolean, + showNoArchivedView: Boolean, + showLogo: Boolean, + onCreateNewConversation: () -> Unit +) { + when { + showNoArchivedView -> NoArchivedConversationsView() + isListEmpty -> EmptyConversationsView(showLogo = showLogo, onCreateNewConversation = onCreateNewConversation) + } +} + +@Composable +fun EmptyConversationsView(showLogo: Boolean, onCreateNewConversation: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onCreateNewConversation() } + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (showLogo) { + Image( + painter = painterResource(R.drawable.ic_logo), + contentDescription = stringResource(R.string.nc_app_product_name), + modifier = Modifier + .fillMaxWidth() + .height(72.dp), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(colorResource(R.color.grey_600), BlendMode.SrcIn) + ) + } + + Text( + text = stringResource(R.string.nc_conversations_empty), + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 8.dp), + color = colorResource(R.color.conversation_item_header), + fontSize = 22.sp, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = stringResource(R.string.nc_conversations_empty_details), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + color = colorResource(R.color.textColorMaxContrast), + fontSize = 16.sp, + textAlign = TextAlign.Center + ) + } +} + +@Composable +fun NoArchivedConversationsView() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.outline_archive_24), + contentDescription = stringResource(R.string.nc_app_product_name), + modifier = Modifier + .fillMaxWidth() + .height(72.dp), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(colorResource(R.color.grey_600), BlendMode.SrcIn) + ) + + Text( + text = stringResource(R.string.no_conversations_archived), + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 8.dp), + color = colorResource(R.color.high_emphasis_text), + fontSize = 22.sp, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} + +// --- Previews --- + +@Preview(showBackground = true) +@Composable +private fun EmptyConversationsWithLogoPreview() { + EmptyConversationsView(showLogo = true, onCreateNewConversation = {}) +} + +@Preview(showBackground = true) +@Composable +private fun EmptyConversationsNoLogoPreview() { + EmptyConversationsView(showLogo = false, onCreateNewConversation = {}) +} + +@Preview(showBackground = true) +@Composable +private fun NoArchivedConversationsPreview() { + NoArchivedConversationsView() +} diff --git a/app/src/main/res/layout/activity_conversations.xml b/app/src/main/res/layout/activity_conversations.xml index 301531cdaf6..9f36711d816 100644 --- a/app/src/main/res/layout/activity_conversations.xml +++ b/app/src/main/res/layout/activity_conversations.xml @@ -187,85 +187,11 @@ - - - - - - - - - - - - - - - + android:layout_gravity="center" /> Date: Fri, 20 Mar 2026 23:25:45 +0100 Subject: [PATCH 03/21] chore(conv-list): Migrate FAB to Composable AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../ConversationsListActivity.kt | 455 ++++++++++-------- .../ui/ConversationListFab.kt | 104 ++++ app/src/main/res/anim/popup_animation.xml | 21 - .../res/layout/activity_conversations.xml | 29 +- 4 files changed, 355 insertions(+), 254 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt delete mode 100644 app/src/main/res/anim/popup_animation.xml diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index e467b175a02..e814248ff54 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -25,7 +25,6 @@ import android.view.Menu import android.view.MenuItem import android.view.MotionEvent import android.view.View -import android.view.animation.AnimationUtils import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.Toast @@ -33,8 +32,10 @@ import androidx.activity.OnBackPressedCallback import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.pm.ShortcutInfoCompat @@ -88,8 +89,10 @@ import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.contextchat.ContextChatView import com.nextcloud.talk.contextchat.ContextChatViewModel +import com.nextcloud.talk.conversationlist.ui.ConversationListFab import com.nextcloud.talk.conversationlist.ui.ConversationsEmptyStateView import com.nextcloud.talk.conversationlist.ui.StatusBannerRow +import com.nextcloud.talk.conversationlist.ui.UnreadMentionBubble import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User @@ -207,9 +210,11 @@ class ConversationsListActivity : get() = AppBarLayoutType.SEARCH_BAR private var currentUser: User? = null - private val _isMaintenanceMode = MutableStateFlow(false) - private val _isListEmpty = MutableStateFlow(false) - private val _showNoArchivedView = MutableStateFlow(false) + private val isMaintenanceModeState = MutableStateFlow(false) + private val isListEmptyState = MutableStateFlow(false) + private val showNoArchivedViewState = MutableStateFlow(false) + private val showUnreadBubbleState = MutableStateFlow(false) + private val isFabVisibleState = MutableStateFlow(true) private var roomsQueryDisposable: Disposable? = null private var openConversationsQueryDisposable: Disposable? = null private var adapter: FlexibleAdapter>? = null @@ -272,6 +277,8 @@ class ConversationsListActivity : setContentView(binding.root) setupStatusBanner() setupEmptyStateView() + setupFab() + setupUnreadBubble() initSystemBars() viewThemeUtils.material.themeSearchCardView(binding.searchToolbarContainer) viewThemeUtils.material.colorMaterialButtonContent(binding.menuButton, ColorRole.ON_SURFACE_VARIANT) @@ -675,13 +682,13 @@ class ConversationsListActivity : } val archiveFilterOn = filterState[ARCHIVE] == true - _showNoArchivedView.value = archiveFilterOn && newItems.isEmpty() + showNoArchivedViewState.value = archiveFilterOn && newItems.isEmpty() adapter?.updateDataSet(newItems, true) setFilterableItems(newItems) if (archiveFilterOn) { // Never a notification from archived conversations - binding.newMentionPopupBubble.visibility = View.GONE + showUnreadBubbleState.value = false } layoutManager?.scrollToPositionWithOffset(0, 0) @@ -1056,7 +1063,7 @@ class ConversationsListActivity : } private fun initOverallLayout(isConversationListNotEmpty: Boolean) { - _isListEmpty.value = !isConversationListNotEmpty + isListEmptyState.value = !isConversationListNotEmpty if (isConversationListNotEmpty) { if (binding.swipeRefreshLayoutView.visibility != View.VISIBLE) { binding.swipeRefreshLayoutView.visibility = View.VISIBLE @@ -1106,52 +1113,53 @@ class ConversationsListActivity : } private fun showErrorDialog() { - binding.floatingActionButton.let { - val dialogBuilder = MaterialAlertDialogBuilder(it.context) - .setIcon( - viewThemeUtils.dialog.colorMaterialAlertDialogIcon( - context, - R.drawable.ic_baseline_error_outline_24dp - ) + val dialogBuilder = MaterialAlertDialogBuilder(this) + .setIcon( + viewThemeUtils.dialog.colorMaterialAlertDialogIcon( + context, + R.drawable.ic_baseline_error_outline_24dp ) - .setTitle(R.string.error_loading_chats) - .setCancelable(false) - .setNegativeButton(R.string.close, null) + ) + .setTitle(R.string.error_loading_chats) + .setCancelable(false) + .setNegativeButton(R.string.close, null) - if (resources!!.getBoolean(R.bool.multiaccount_support) && userManager.users.blockingGet().size > 1) { - dialogBuilder.setPositiveButton(R.string.nc_switch_account) { _, _ -> - showChooseAccountDialog() - } + if (resources!!.getBoolean(R.bool.multiaccount_support) && userManager.users.blockingGet().size > 1) { + dialogBuilder.setPositiveButton(R.string.nc_switch_account) { _, _ -> + showChooseAccountDialog() } + } - if (resources!!.getBoolean(R.bool.multiaccount_support)) { - dialogBuilder.setNeutralButton(R.string.nc_account_chooser_add_account) { _, _ -> - val intent = Intent(this, ServerSelectionActivity::class.java) - intent.putExtra(ADD_ADDITIONAL_ACCOUNT, true) - startActivity(intent) - } + if (resources!!.getBoolean(R.bool.multiaccount_support)) { + dialogBuilder.setNeutralButton(R.string.nc_account_chooser_add_account) { _, _ -> + val intent = Intent(this, ServerSelectionActivity::class.java) + intent.putExtra(ADD_ADDITIONAL_ACCOUNT, true) + startActivity(intent) } - - viewThemeUtils.dialog.colorMaterialAlertDialogBackground(it.context, dialogBuilder) - val dialog = dialogBuilder.show() - viewThemeUtils.platform.colorTextButtons( - dialog.getButton(AlertDialog.BUTTON_POSITIVE), - dialog.getButton(AlertDialog.BUTTON_NEGATIVE), - dialog.getButton(AlertDialog.BUTTON_NEUTRAL) - ) } + + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder) + val dialog = dialogBuilder.show() + viewThemeUtils.platform.colorTextButtons( + dialog.getButton(AlertDialog.BUTTON_POSITIVE), + dialog.getButton(AlertDialog.BUTTON_NEGATIVE), + dialog.getButton(AlertDialog.BUTTON_NEUTRAL) + ) } private fun setupStatusBanner() { binding.statusBannerComposeView.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { + val colorScheme = remember { viewThemeUtils.getColorScheme(context) } val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val isMaintenanceMode by _isMaintenanceMode.collectAsStateWithLifecycle() - StatusBannerRow( - isOffline = !isOnline, - isMaintenanceMode = isMaintenanceMode - ) + val isMaintenanceMode by isMaintenanceModeState.collectAsStateWithLifecycle() + MaterialTheme(colorScheme = colorScheme) { + StatusBannerRow( + isOffline = !isOnline, + isMaintenanceMode = isMaintenanceMode + ) + } } } } @@ -1161,20 +1169,66 @@ class ConversationsListActivity : binding.emptyStateComposeView.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - val isListEmpty by _isListEmpty.collectAsStateWithLifecycle() - val showNoArchivedView by _showNoArchivedView.collectAsStateWithLifecycle() - ConversationsEmptyStateView( - isListEmpty = isListEmpty, - showNoArchivedView = showNoArchivedView, - showLogo = showLogo, - onCreateNewConversation = { showNewConversationsScreen() } - ) + val colorScheme = remember { viewThemeUtils.getColorScheme(context) } + val isListEmpty by isListEmptyState.collectAsStateWithLifecycle() + val showNoArchivedView by showNoArchivedViewState.collectAsStateWithLifecycle() + MaterialTheme(colorScheme = colorScheme) { + ConversationsEmptyStateView( + isListEmpty = isListEmpty, + showNoArchivedView = showNoArchivedView, + showLogo = showLogo, + onCreateNewConversation = { showNewConversationsScreen() } + ) + } + } + } + } + + private fun setupFab() { + binding.fabComposeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val colorScheme = remember { viewThemeUtils.getColorScheme(context) } + val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() + val isFabVisible by isFabVisibleState.collectAsStateWithLifecycle() + MaterialTheme(colorScheme = colorScheme) { + ConversationListFab( + isVisible = isFabVisible, + isEnabled = isOnline, + onClick = { + run(context) + showNewConversationsScreen() + } + ) + } + } + } + } + + private fun setupUnreadBubble() { + binding.unreadBubbleComposeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val colorScheme = remember { viewThemeUtils.getColorScheme(context) } + val showBubble by showUnreadBubbleState.collectAsStateWithLifecycle() + MaterialTheme(colorScheme = colorScheme) { + UnreadMentionBubble( + visible = showBubble, + onClick = { + val lm = binding.recyclerView.layoutManager as? SmoothScrollLinearLayoutManager + lm?.scrollToPositionWithOffset( + nextUnreadConversationScrollPosition, + binding.recyclerView.height / OFFSET_HEIGHT_DIVIDER + ) + showUnreadBubbleState.value = false + } + ) + } } } } private fun handleUI(show: Boolean) { - binding.floatingActionButton.isEnabled = show binding.searchText.isEnabled = show binding.searchText.isVisible = show } @@ -1221,7 +1275,7 @@ class ConversationsListActivity : @SuppressLint("ClickableViewAccessibility") private fun prepareViews() { - _isMaintenanceMode.value = false + isMaintenanceModeState.value = false layoutManager = SmoothScrollLinearLayoutManager(this) binding.recyclerView.layoutManager = layoutManager @@ -1237,6 +1291,15 @@ class ConversationsListActivity : } } } + + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (dy > 0) { + isFabVisibleState.value = false + } else if (dy < 0) { + isFabVisibleState.value = true + } + } }) binding.recyclerView.setOnTouchListener { v: View, _: MotionEvent? -> if (!isDestroyed) { @@ -1246,16 +1309,11 @@ class ConversationsListActivity : false } binding.swipeRefreshLayoutView.setOnRefreshListener { - _isMaintenanceMode.value = false + isMaintenanceModeState.value = false fetchRooms() fetchPendingInvitations() } binding.swipeRefreshLayoutView.let { viewThemeUtils.androidx.themeSwipeRefreshLayout(it) } - binding.floatingActionButton.setOnClickListener { - run(context) - showNewConversationsScreen() - } - binding.floatingActionButton.let { viewThemeUtils.material.themeFAB(it) } binding.switchAccountButton.setOnClickListener { if (resources != null && resources!!.getBoolean(R.bool.multiaccount_support)) { @@ -1279,17 +1337,6 @@ class ConversationsListActivity : binding.threadsButton.let { viewThemeUtils.platform.colorImageView(it, ColorRole.ON_SURFACE_VARIANT) } - - binding.newMentionPopupBubble.visibility = View.GONE - binding.newMentionPopupBubble.setOnClickListener { - val layoutManager = binding.recyclerView.layoutManager as SmoothScrollLinearLayoutManager? - layoutManager?.scrollToPositionWithOffset( - nextUnreadConversationScrollPosition, - binding.recyclerView.height / OFFSET_HEIGHT_DIVIDER - ) - binding.newMentionPopupBubble.visibility = View.GONE - } - binding.newMentionPopupBubble.let { viewThemeUtils.material.colorMaterialButtonPrimaryFilled(it) } } @SuppressLint("CheckResult") @@ -1298,7 +1345,7 @@ class ConversationsListActivity : searchBehaviorSubject.subscribe { value -> if (value) { nextUnreadConversationScrollPosition = 0 - binding.newMentionPopupBubble.visibility = View.GONE + showUnreadBubbleState.value = false } else { try { val lastVisibleItem = layoutManager!!.findLastCompletelyVisibleItemPosition() @@ -1307,16 +1354,12 @@ class ConversationsListActivity : val position = adapter?.getGlobalPositionOf(flexItem) if (position != null && hasUnreadItems(conversation) && position > lastVisibleItem) { nextUnreadConversationScrollPosition = position - if (!binding.newMentionPopupBubble.isShown) { - binding.newMentionPopupBubble.visibility = View.VISIBLE - val popupAnimation = AnimationUtils.loadAnimation(this, R.anim.popup_animation) - binding.newMentionPopupBubble.startAnimation(popupAnimation) - } + showUnreadBubbleState.value = true return@subscribe } } nextUnreadConversationScrollPosition = 0 - binding.newMentionPopupBubble.visibility = View.GONE + showUnreadBubbleState.value = false } catch (e: NullPointerException) { Log.d( TAG, @@ -1391,7 +1434,7 @@ class ConversationsListActivity : private fun performFilterAndSearch(filter: String?) { if (filter!!.length >= SEARCH_MIN_CHARS) { - _showNoArchivedView.value = false + showNoArchivedViewState.value = false adapter?.setFilter(filter) conversationsListViewModel.getSearchQuery(context, filter) } else { @@ -1404,7 +1447,7 @@ class ConversationsListActivity : adapter?.setFilter("") adapter?.filterItems() val archiveFilterOn = filterState[ARCHIVE] == true - _showNoArchivedView.value = archiveFilterOn && adapter!!.isEmpty + showNoArchivedViewState.value = archiveFilterOn && adapter!!.isEmpty } private fun clearMessageSearchResults() { @@ -1580,27 +1623,25 @@ class ConversationsListActivity : selectedConversation!!.displayName ) } - binding.floatingActionButton.let { - val dialogBuilder = MaterialAlertDialogBuilder(it.context) - .setIcon(viewThemeUtils.dialog.colorMaterialAlertDialogIcon(context, R.drawable.upload)) - .setTitle(confirmationQuestion) - .setMessage(fileNamesWithLineBreaks.toString()) - .setPositiveButton(R.string.nc_yes) { _, _ -> - upload() - openConversation() - } - .setNegativeButton(R.string.nc_no) { _, _ -> - Log.d(TAG, "sharing files aborted, going back to share-to screen") - } + val dialogBuilder = MaterialAlertDialogBuilder(this) + .setIcon(viewThemeUtils.dialog.colorMaterialAlertDialogIcon(context, R.drawable.upload)) + .setTitle(confirmationQuestion) + .setMessage(fileNamesWithLineBreaks.toString()) + .setPositiveButton(R.string.nc_yes) { _, _ -> + upload() + openConversation() + } + .setNegativeButton(R.string.nc_no) { _, _ -> + Log.d(TAG, "sharing files aborted, going back to share-to screen") + } - viewThemeUtils.dialog - .colorMaterialAlertDialogBackground(it.context, dialogBuilder) - val dialog = dialogBuilder.show() - viewThemeUtils.platform.colorTextButtons( - dialog.getButton(AlertDialog.BUTTON_POSITIVE), - dialog.getButton(AlertDialog.BUTTON_NEGATIVE) - ) - } + viewThemeUtils.dialog + .colorMaterialAlertDialogBackground(this, dialogBuilder) + val dialog = dialogBuilder.show() + viewThemeUtils.platform.colorTextButtons( + dialog.getButton(AlertDialog.BUTTON_POSITIVE), + dialog.getButton(AlertDialog.BUTTON_NEGATIVE) + ) } else { UploadAndShareFilesWorker.requestStoragePermission(this) } @@ -1876,61 +1917,57 @@ class ConversationsListActivity : } fun showDeleteConversationDialog(conversation: ConversationModel) { - binding.floatingActionButton.let { - val dialogBuilder = MaterialAlertDialogBuilder(it.context) - .setIcon( - viewThemeUtils.dialog - .colorMaterialAlertDialogIcon(context, R.drawable.ic_delete_black_24dp) - ) - .setTitle(R.string.nc_delete_call) - .setMessage(R.string.nc_delete_conversation_more) - .setPositiveButton(R.string.nc_delete) { _, _ -> - deleteConversation(conversation) - } - .setNegativeButton(R.string.nc_cancel) { _, _ -> - } - - viewThemeUtils.dialog - .colorMaterialAlertDialogBackground(it.context, dialogBuilder) - val dialog = dialogBuilder.show() - viewThemeUtils.platform.colorTextButtons( - dialog.getButton(AlertDialog.BUTTON_POSITIVE), - dialog.getButton(AlertDialog.BUTTON_NEGATIVE) + val dialogBuilder = MaterialAlertDialogBuilder(this) + .setIcon( + viewThemeUtils.dialog + .colorMaterialAlertDialogIcon(context, R.drawable.ic_delete_black_24dp) ) - } + .setTitle(R.string.nc_delete_call) + .setMessage(R.string.nc_delete_conversation_more) + .setPositiveButton(R.string.nc_delete) { _, _ -> + deleteConversation(conversation) + } + .setNegativeButton(R.string.nc_cancel) { _, _ -> + } + + viewThemeUtils.dialog + .colorMaterialAlertDialogBackground(this, dialogBuilder) + val dialog = dialogBuilder.show() + viewThemeUtils.platform.colorTextButtons( + dialog.getButton(AlertDialog.BUTTON_POSITIVE), + dialog.getButton(AlertDialog.BUTTON_NEGATIVE) + ) } private fun showUnauthorizedDialog() { - binding.floatingActionButton.let { - val dialogBuilder = MaterialAlertDialogBuilder(it.context) - .setIcon( - viewThemeUtils.dialog.colorMaterialAlertDialogIcon( - context, - R.drawable.ic_delete_black_24dp - ) + val dialogBuilder = MaterialAlertDialogBuilder(this) + .setIcon( + viewThemeUtils.dialog.colorMaterialAlertDialogIcon( + context, + R.drawable.ic_delete_black_24dp ) - .setTitle(R.string.nc_dialog_invalid_password) - .setMessage(R.string.nc_dialog_reauth_or_delete) - .setCancelable(false) - .setPositiveButton(R.string.nc_settings_remove_account) { _, _ -> - deleteUserAndRestartApp() - } - .setNegativeButton(R.string.nc_settings_reauthorize) { _, _ -> - val intent = Intent(context, BrowserLoginActivity::class.java) - val bundle = Bundle() - bundle.putString(BundleKeys.KEY_BASE_URL, currentUser!!.baseUrl!!) - bundle.putBoolean(BundleKeys.KEY_REAUTHORIZE_ACCOUNT, true) - intent.putExtras(bundle) - startActivity(intent) - } - - viewThemeUtils.dialog.colorMaterialAlertDialogBackground(it.context, dialogBuilder) - val dialog = dialogBuilder.show() - viewThemeUtils.platform.colorTextButtons( - dialog.getButton(AlertDialog.BUTTON_POSITIVE), - dialog.getButton(AlertDialog.BUTTON_NEGATIVE) ) - } + .setTitle(R.string.nc_dialog_invalid_password) + .setMessage(R.string.nc_dialog_reauth_or_delete) + .setCancelable(false) + .setPositiveButton(R.string.nc_settings_remove_account) { _, _ -> + deleteUserAndRestartApp() + } + .setNegativeButton(R.string.nc_settings_reauthorize) { _, _ -> + val intent = Intent(context, BrowserLoginActivity::class.java) + val bundle = Bundle() + bundle.putString(BundleKeys.KEY_BASE_URL, currentUser!!.baseUrl!!) + bundle.putBoolean(BundleKeys.KEY_REAUTHORIZE_ACCOUNT, true) + intent.putExtras(bundle) + startActivity(intent) + } + + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder) + val dialog = dialogBuilder.show() + viewThemeUtils.platform.colorTextButtons( + dialog.getButton(AlertDialog.BUTTON_POSITIVE), + dialog.getButton(AlertDialog.BUTTON_NEGATIVE) + ) } @SuppressLint("CheckResult") @@ -1978,94 +2015,90 @@ class ConversationsListActivity : } private fun showOutdatedClientDialog() { - binding.floatingActionButton.let { - val dialogBuilder = MaterialAlertDialogBuilder(it.context) - .setIcon( - viewThemeUtils.dialog.colorMaterialAlertDialogIcon( - context, - R.drawable.ic_info_white_24dp - ) + val dialogBuilder = MaterialAlertDialogBuilder(this) + .setIcon( + viewThemeUtils.dialog.colorMaterialAlertDialogIcon( + context, + R.drawable.ic_info_white_24dp ) - .setTitle(R.string.nc_dialog_outdated_client) - .setMessage(R.string.nc_dialog_outdated_client_description) - .setCancelable(false) - .setPositiveButton(R.string.nc_dialog_outdated_client_option_update) { _, _ -> - try { - startActivity( - Intent(Intent.ACTION_VIEW, (CLIENT_UPGRADE_MARKET_LINK + packageName).toUri()) - ) - } catch (e: ActivityNotFoundException) { - startActivity( - Intent(Intent.ACTION_VIEW, (CLIENT_UPGRADE_GPLAY_LINK + packageName).toUri()) - ) - } - } - - if (resources!!.getBoolean(R.bool.multiaccount_support) && userManager.users.blockingGet().size > 1) { - dialogBuilder.setNegativeButton(R.string.nc_switch_account) { _, _ -> - showChooseAccountDialog() + ) + .setTitle(R.string.nc_dialog_outdated_client) + .setMessage(R.string.nc_dialog_outdated_client_description) + .setCancelable(false) + .setPositiveButton(R.string.nc_dialog_outdated_client_option_update) { _, _ -> + try { + startActivity( + Intent(Intent.ACTION_VIEW, (CLIENT_UPGRADE_MARKET_LINK + packageName).toUri()) + ) + } catch (e: ActivityNotFoundException) { + startActivity( + Intent(Intent.ACTION_VIEW, (CLIENT_UPGRADE_GPLAY_LINK + packageName).toUri()) + ) } } - if (resources!!.getBoolean(R.bool.multiaccount_support)) { - dialogBuilder.setNeutralButton(R.string.nc_account_chooser_add_account) { _, _ -> - val intent = Intent(this, ServerSelectionActivity::class.java) - intent.putExtra(ADD_ADDITIONAL_ACCOUNT, true) - startActivity(intent) - } + if (resources!!.getBoolean(R.bool.multiaccount_support) && userManager.users.blockingGet().size > 1) { + dialogBuilder.setNegativeButton(R.string.nc_switch_account) { _, _ -> + showChooseAccountDialog() } + } - viewThemeUtils.dialog.colorMaterialAlertDialogBackground(it.context, dialogBuilder) - val dialog = dialogBuilder.show() - viewThemeUtils.platform.colorTextButtons( - dialog.getButton(AlertDialog.BUTTON_POSITIVE), - dialog.getButton(AlertDialog.BUTTON_NEGATIVE), - dialog.getButton(AlertDialog.BUTTON_NEUTRAL) - ) + if (resources!!.getBoolean(R.bool.multiaccount_support)) { + dialogBuilder.setNeutralButton(R.string.nc_account_chooser_add_account) { _, _ -> + val intent = Intent(this, ServerSelectionActivity::class.java) + intent.putExtra(ADD_ADDITIONAL_ACCOUNT, true) + startActivity(intent) + } } + + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder) + val dialog = dialogBuilder.show() + viewThemeUtils.platform.colorTextButtons( + dialog.getButton(AlertDialog.BUTTON_POSITIVE), + dialog.getButton(AlertDialog.BUTTON_NEGATIVE), + dialog.getButton(AlertDialog.BUTTON_NEUTRAL) + ) } private fun showServiceUnavailableDialog(httpException: HttpException) { if (httpException.response()?.headers()?.get(MAINTENANCE_MODE_HEADER_KEY) == "1") { - _isMaintenanceMode.value = true + isMaintenanceModeState.value = true } else { showErrorDialog() } } private fun showServerEOLDialog() { - binding.floatingActionButton.let { - val dialogBuilder = MaterialAlertDialogBuilder(it.context) - .setIcon(viewThemeUtils.dialog.colorMaterialAlertDialogIcon(context, R.drawable.ic_warning_white)) - .setTitle(R.string.nc_settings_server_eol_title) - .setMessage(R.string.nc_settings_server_eol) - .setCancelable(false) - .setPositiveButton(R.string.nc_settings_remove_account) { _, _ -> - deleteUserAndRestartApp() - } - - if (resources!!.getBoolean(R.bool.multiaccount_support) && userManager.users.blockingGet().size > 1) { - dialogBuilder.setNegativeButton(R.string.nc_switch_account) { _, _ -> - showChooseAccountDialog() - } + val dialogBuilder = MaterialAlertDialogBuilder(this) + .setIcon(viewThemeUtils.dialog.colorMaterialAlertDialogIcon(context, R.drawable.ic_warning_white)) + .setTitle(R.string.nc_settings_server_eol_title) + .setMessage(R.string.nc_settings_server_eol) + .setCancelable(false) + .setPositiveButton(R.string.nc_settings_remove_account) { _, _ -> + deleteUserAndRestartApp() } - if (resources!!.getBoolean(R.bool.multiaccount_support)) { - dialogBuilder.setNeutralButton(R.string.nc_account_chooser_add_account) { _, _ -> - val intent = Intent(this, ServerSelectionActivity::class.java) - intent.putExtra(ADD_ADDITIONAL_ACCOUNT, true) - startActivity(intent) - } + if (resources!!.getBoolean(R.bool.multiaccount_support) && userManager.users.blockingGet().size > 1) { + dialogBuilder.setNegativeButton(R.string.nc_switch_account) { _, _ -> + showChooseAccountDialog() } + } - viewThemeUtils.dialog.colorMaterialAlertDialogBackground(it.context, dialogBuilder) - val dialog = dialogBuilder.show() - viewThemeUtils.platform.colorTextButtons( - dialog.getButton(AlertDialog.BUTTON_POSITIVE), - dialog.getButton(AlertDialog.BUTTON_NEGATIVE), - dialog.getButton(AlertDialog.BUTTON_NEUTRAL) - ) + if (resources!!.getBoolean(R.bool.multiaccount_support)) { + dialogBuilder.setNeutralButton(R.string.nc_account_chooser_add_account) { _, _ -> + val intent = Intent(this, ServerSelectionActivity::class.java) + intent.putExtra(ADD_ADDITIONAL_ACCOUNT, true) + startActivity(intent) + } } + + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder) + val dialog = dialogBuilder.show() + viewThemeUtils.platform.colorTextButtons( + dialog.getButton(AlertDialog.BUTTON_POSITIVE), + dialog.getButton(AlertDialog.BUTTON_NEGATIVE), + dialog.getButton(AlertDialog.BUTTON_NEUTRAL) + ) } private fun deleteConversation(conversation: ConversationModel) { diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt new file mode 100644 index 00000000000..9cf8e89de80 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt @@ -0,0 +1,104 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationlist.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.nextcloud.talk.R + +private const val DISABLED_ALPHA = 0.38f +private const val FAB_ANIM_DURATION = 200 + +@Composable +fun ConversationListFab(isVisible: Boolean, isEnabled: Boolean, onClick: () -> Unit) { + AnimatedVisibility( + visible = isVisible, + enter = scaleIn(animationSpec = tween(FAB_ANIM_DURATION)) + fadeIn(animationSpec = tween(FAB_ANIM_DURATION)), + exit = scaleOut(animationSpec = tween(FAB_ANIM_DURATION)) + fadeOut(animationSpec = tween(FAB_ANIM_DURATION)) + ) { + FloatingActionButton( + onClick = { if (isEnabled) onClick() }, + containerColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.alpha(if (isEnabled) 1f else DISABLED_ALPHA) + ) { + Icon( + painter = painterResource(R.drawable.ic_pencil_grey600_24dp), + contentDescription = stringResource(R.string.nc_new_conversation), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } +} + +@Composable +fun UnreadMentionBubble(visible: Boolean, onClick: () -> Unit) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ), + shape = RoundedCornerShape(dimensionResource(R.dimen.button_corner_radius)) + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_arrow_downward_24px), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.standard_padding))) + Text( + text = stringResource(R.string.nc_new_mention), + color = MaterialTheme.colorScheme.onPrimary + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ConversationListFabEnabledPreview() { + ConversationListFab(isVisible = true, isEnabled = true, onClick = {}) +} + +@Preview(showBackground = true) +@Composable +private fun ConversationListFabDisabledPreview() { + ConversationListFab(isVisible = true, isEnabled = false, onClick = {}) +} + +@Preview(showBackground = true) +@Composable +private fun UnreadMentionBubbleVisiblePreview() { + UnreadMentionBubble(visible = true, onClick = {}) +} diff --git a/app/src/main/res/anim/popup_animation.xml b/app/src/main/res/anim/popup_animation.xml deleted file mode 100644 index 246ce1b1103..00000000000 --- a/app/src/main/res/anim/popup_animation.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_conversations.xml b/app/src/main/res/layout/activity_conversations.xml index 9f36711d816..5358d934401 100644 --- a/app/src/main/res/layout/activity_conversations.xml +++ b/app/src/main/res/layout/activity_conversations.xml @@ -222,35 +222,20 @@ - - - + + + android:layout_marginBottom="36dp" /> Date: Fri, 20 Mar 2026 23:48:21 +0100 Subject: [PATCH 04/21] fix(conv-list): use searchBehaviorSubject.value ... instead of repeated subscriptions which did not get disposed AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../ConversationsListActivity.kt | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index e814248ff54..0cea1ff3c15 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -1339,38 +1339,43 @@ class ConversationsListActivity : } } - @SuppressLint("CheckResult") @Suppress("Detekt.TooGenericExceptionCaught") private fun checkToShowUnreadBubble() { - searchBehaviorSubject.subscribe { value -> - if (value) { + if (searchBehaviorSubject.value == true) { + nextUnreadConversationScrollPosition = 0 + showUnreadBubbleState.value = false + return + } + try { + val lastVisibleItem = layoutManager!!.findLastCompletelyVisibleItemPosition() + val firstUnreadPosition = findFirstOffscreenUnreadPosition(lastVisibleItem) + if (firstUnreadPosition != null) { + nextUnreadConversationScrollPosition = firstUnreadPosition + showUnreadBubbleState.value = true + } else { nextUnreadConversationScrollPosition = 0 showUnreadBubbleState.value = false - } else { - try { - val lastVisibleItem = layoutManager!!.findLastCompletelyVisibleItemPosition() - for (flexItem in conversationItems) { - val conversation: ConversationModel = (flexItem as ConversationItem).model - val position = adapter?.getGlobalPositionOf(flexItem) - if (position != null && hasUnreadItems(conversation) && position > lastVisibleItem) { - nextUnreadConversationScrollPosition = position - showUnreadBubbleState.value = true - return@subscribe - } - } - nextUnreadConversationScrollPosition = 0 - showUnreadBubbleState.value = false - } catch (e: NullPointerException) { - Log.d( - TAG, - "A NPE was caught when trying to show the unread popup bubble. This might happen when the " + - "user already left the conversations-list screen so the popup bubble is not available " + - "anymore.", - e - ) - } + } + } catch (e: NullPointerException) { + Log.d( + TAG, + "A NPE was caught when trying to show the unread popup bubble. This might happen when the " + + "user already left the conversations-list screen so the popup bubble is not available " + + "anymore.", + e + ) + } + } + + private fun findFirstOffscreenUnreadPosition(lastVisibleItem: Int): Int? { + for (flexItem in conversationItems) { + val conversation = (flexItem as ConversationItem).model + val position = adapter?.getGlobalPositionOf(flexItem) + if (position != null && hasUnreadItems(conversation) && position > lastVisibleItem) { + return position } } + return null } private fun hasUnreadItems(conversation: ConversationModel) = From 011d2ea8e9b7a56421b2be4947e9b5e892e2e819 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Fri, 20 Mar 2026 23:52:33 +0100 Subject: [PATCH 05/21] chore(conv-list): Migrate FAB to Composable AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../nextcloud/talk/conversationlist/ui/ConversationListFab.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt index 9cf8e89de80..9b1126292aa 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt @@ -45,13 +45,11 @@ fun ConversationListFab(isVisible: Boolean, isEnabled: Boolean, onClick: () -> U ) { FloatingActionButton( onClick = { if (isEnabled) onClick() }, - containerColor = MaterialTheme.colorScheme.primary, modifier = Modifier.alpha(if (isEnabled) 1f else DISABLED_ALPHA) ) { Icon( painter = painterResource(R.drawable.ic_pencil_grey600_24dp), - contentDescription = stringResource(R.string.nc_new_conversation), - tint = MaterialTheme.colorScheme.onPrimary + contentDescription = stringResource(R.string.nc_new_conversation) ) } } From 487cd520c8d00b62fe0ba6e3076658ddd64e0b10 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sat, 21 Mar 2026 00:20:11 +0100 Subject: [PATCH 06/21] chore(conv-list): Migrate Shimmer to Composable AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../ConversationsListActivity.kt | 20 ++- .../ui/ConversationShimmerList.kt | 130 ++++++++++++++++++ .../ui/ConversationsEmptyState.kt | 2 - .../res/layout/activity_conversations.xml | 17 +-- scripts/analysis/lint-results.txt | 2 +- 5 files changed, 152 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationShimmerList.kt diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 0cea1ff3c15..8f13111ba37 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -90,6 +90,7 @@ import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.contextchat.ContextChatView import com.nextcloud.talk.contextchat.ContextChatViewModel import com.nextcloud.talk.conversationlist.ui.ConversationListFab +import com.nextcloud.talk.conversationlist.ui.ConversationListSkeleton import com.nextcloud.talk.conversationlist.ui.ConversationsEmptyStateView import com.nextcloud.talk.conversationlist.ui.StatusBannerRow import com.nextcloud.talk.conversationlist.ui.UnreadMentionBubble @@ -215,6 +216,7 @@ class ConversationsListActivity : private val showNoArchivedViewState = MutableStateFlow(false) private val showUnreadBubbleState = MutableStateFlow(false) private val isFabVisibleState = MutableStateFlow(true) + private val isShimmerVisibleState = MutableStateFlow(true) private var roomsQueryDisposable: Disposable? = null private var openConversationsQueryDisposable: Disposable? = null private var adapter: FlexibleAdapter>? = null @@ -279,6 +281,7 @@ class ConversationsListActivity : setupEmptyStateView() setupFab() setupUnreadBubble() + setupShimmer() initSystemBars() viewThemeUtils.material.themeSearchCardView(binding.searchToolbarContainer) viewThemeUtils.material.colorMaterialButtonContent(binding.menuButton, ColorRole.ON_SURFACE_VARIANT) @@ -311,7 +314,7 @@ class ConversationsListActivity : adapter = FlexibleAdapter(conversationItems, this, true) addEmptyItemForEdgeToEdgeIfNecessary() } else { - binding.loadingContent.visibility = View.GONE + isShimmerVisibleState.value = false } adapter?.addListener(this) prepareViews() @@ -437,7 +440,7 @@ class ConversationsListActivity : is ConversationsListViewModel.GetRoomsSuccessState -> { if (adapterWasNull) { adapterWasNull = false - binding.loadingContent.visibility = View.GONE + isShimmerVisibleState.value = false } initOverallLayout(state.listIsNotEmpty) binding.swipeRefreshLayoutView.isRefreshing = false @@ -1228,6 +1231,19 @@ class ConversationsListActivity : } } + private fun setupShimmer() { + binding.shimmerComposeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val colorScheme = remember { viewThemeUtils.getColorScheme(context) } + val isShimmerVisible by isShimmerVisibleState.collectAsStateWithLifecycle() + MaterialTheme(colorScheme = colorScheme) { + ConversationListSkeleton(isVisible = isShimmerVisible) + } + } + } + } + private fun handleUI(show: Boolean) { binding.searchText.isEnabled = show binding.searchText.isVisible = show diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationShimmerList.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationShimmerList.kt new file mode 100644 index 00000000000..a8949ae85c8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationShimmerList.kt @@ -0,0 +1,130 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationlist.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import android.content.res.Configuration +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme + +private const val SHIMMER_ITEM_COUNT = 4 +private const val SHIMMER_ANIM_DURATION_MS = 800 +private const val SHIMMER_ALPHA_MIN = 0.2f +private const val TITLE_WIDTH = 0.6f +private const val SUBLINE_WIDTH = 0.9f + +private const val SHIMMER_ALPHA_MAX = TITLE_WIDTH + +/** + * Top-level wrapper rendered by the shimmer_compose_view ComposeView. + * Animates in/out via [AnimatedVisibility] and shows skeleton placeholder rows + * while the conversation list is loading for the first time. + */ +@Composable +fun ConversationListSkeleton(isVisible: Boolean, itemCount: Int = SHIMMER_ITEM_COUNT) { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(), + exit = fadeOut() + ) { + val infiniteTransition = rememberInfiniteTransition(label = "shimmer") + val shimmerAlpha by infiniteTransition.animateFloat( + initialValue = SHIMMER_ALPHA_MIN, + targetValue = SHIMMER_ALPHA_MAX, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = SHIMMER_ANIM_DURATION_MS), + repeatMode = RepeatMode.Reverse + ), + label = "shimmerAlpha" + ) + val shimmerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = shimmerAlpha) + + Column { + repeat(itemCount) { + ShimmerConversationItem(shimmerColor = shimmerColor) + } + } + } +} + +@Composable +private fun ShimmerConversationItem(shimmerColor: androidx.compose.ui.graphics.Color) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(shimmerColor) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Box( + modifier = Modifier + .fillMaxWidth(TITLE_WIDTH) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerColor) + ) + + Spacer(modifier = Modifier.height(6.dp)) + + Box( + modifier = Modifier + .fillMaxWidth(SUBLINE_WIDTH) + .height(12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(shimmerColor) + ) + } + } +} + +@Preview(showBackground = true, name = "Shimmer – visible") +@Preview(showBackground = true, name = "Shimmer – visible (dark)", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ShimmerVisibleDarkPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + ConversationListSkeleton(isVisible = true) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt index 4f425325716..3c0f8f883f3 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt @@ -124,8 +124,6 @@ fun NoArchivedConversationsView() { } } -// --- Previews --- - @Preview(showBackground = true) @Composable private fun EmptyConversationsWithLogoPreview() { diff --git a/app/src/main/res/layout/activity_conversations.xml b/app/src/main/res/layout/activity_conversations.xml index 5358d934401..76bec11599d 100644 --- a/app/src/main/res/layout/activity_conversations.xml +++ b/app/src/main/res/layout/activity_conversations.xml @@ -170,22 +170,11 @@ android:layout_height="wrap_content"/> - - - - - - - - - - - + android:layout_marginTop="50dp" /> Lint Report: 93 warnings + Lint Report: 95 warnings From 5d04940889073b17ee4abb710222c9203ebd2846 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sat, 21 Mar 2026 11:32:42 +0100 Subject: [PATCH 07/21] feat(conv-list): Migrate notification warning card to Composable AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../ConversationsListActivity.kt | 52 ++++----- .../ui/NotificationWarningCard.kt | 105 ++++++++++++++++++ .../ic_notification_settings_24px.xml | 16 +++ .../res/layout/activity_conversations.xml | 7 +- .../main/res/layout/notifications_warning.xml | 66 ----------- 5 files changed, 151 insertions(+), 95 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ui/NotificationWarningCard.kt create mode 100644 app/src/main/res/drawable/ic_notification_settings_24px.xml delete mode 100644 app/src/main/res/layout/notifications_warning.xml diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 8f13111ba37..45fbbec470b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -92,6 +92,7 @@ import com.nextcloud.talk.contextchat.ContextChatViewModel import com.nextcloud.talk.conversationlist.ui.ConversationListFab import com.nextcloud.talk.conversationlist.ui.ConversationListSkeleton import com.nextcloud.talk.conversationlist.ui.ConversationsEmptyStateView +import com.nextcloud.talk.conversationlist.ui.NotificationWarningCard import com.nextcloud.talk.conversationlist.ui.StatusBannerRow import com.nextcloud.talk.conversationlist.ui.UnreadMentionBubble import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel @@ -217,6 +218,7 @@ class ConversationsListActivity : private val showUnreadBubbleState = MutableStateFlow(false) private val isFabVisibleState = MutableStateFlow(true) private val isShimmerVisibleState = MutableStateFlow(true) + private val showNotificationWarningState = MutableStateFlow(false) private var roomsQueryDisposable: Disposable? = null private var openConversationsQueryDisposable: Disposable? = null private var adapter: FlexibleAdapter>? = null @@ -282,6 +284,7 @@ class ConversationsListActivity : setupFab() setupUnreadBubble() setupShimmer() + setupNotificationWarning() initSystemBars() viewThemeUtils.material.themeSearchCardView(binding.searchToolbarContainer) viewThemeUtils.material.colorMaterialButtonContent(binding.menuButton, ColorRole.ON_SURFACE_VARIANT) @@ -319,7 +322,7 @@ class ConversationsListActivity : adapter?.addListener(this) prepareViews() - showNotificationWarning() + showNotificationWarningState.value = shouldShowNotificationWarning() showShareToScreen = hasActivityActionSendIntent() @@ -345,13 +348,6 @@ class ConversationsListActivity : loadUserAvatar(binding.switchAccountButton) viewThemeUtils.material.colorMaterialTextButton(binding.switchAccountButton) viewThemeUtils.material.themeCardView(binding.conversationListHintInclude.hintLayoutCardview) - viewThemeUtils.material.themeCardView( - binding.conversationListNotificationWarning.notificationWarningCardview - ) - viewThemeUtils.material.colorMaterialButtonText(binding.conversationListNotificationWarning.notNowButton) - viewThemeUtils.material.colorMaterialButtonText( - binding.conversationListNotificationWarning.showSettingsButton - ) searchBehaviorSubject.onNext(false) fetchRooms() fetchPendingInvitations() @@ -1823,25 +1819,29 @@ class ConversationsListActivity : } } - private fun showNotificationWarning() { - if (shouldShowNotificationWarning()) { - binding.conversationListNotificationWarning.conversationListNotificationWarningLayout.visibility = - View.VISIBLE - binding.conversationListNotificationWarning.notNowButton.setOnClickListener { - binding.conversationListNotificationWarning.conversationListNotificationWarningLayout.visibility = - View.GONE - val lastWarningDate = System.currentTimeMillis() - appPreferences.setNotificationWarningLastPostponedDate(lastWarningDate) - } - binding.conversationListNotificationWarning.showSettingsButton.setOnClickListener { - val bundle = Bundle() - bundle.putBoolean(KEY_SCROLL_TO_NOTIFICATION_CATEGORY, true) - val settingsIntent = Intent(context, SettingsActivity::class.java) - settingsIntent.putExtras(bundle) - startActivity(settingsIntent) + private fun setupNotificationWarning() { + binding.notificationWarningComposeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val colorScheme = remember { viewThemeUtils.getColorScheme(context) } + val showWarning by showNotificationWarningState.collectAsStateWithLifecycle() + MaterialTheme(colorScheme = colorScheme) { + NotificationWarningCard( + visible = showWarning, + onNotNow = { + appPreferences.setNotificationWarningLastPostponedDate(System.currentTimeMillis()) + showNotificationWarningState.value = false + }, + onShowSettings = { + val bundle = Bundle() + bundle.putBoolean(KEY_SCROLL_TO_NOTIFICATION_CATEGORY, true) + val settingsIntent = Intent(context, SettingsActivity::class.java) + settingsIntent.putExtras(bundle) + startActivity(settingsIntent) + } + ) + } } - } else { - binding.conversationListNotificationWarning.conversationListNotificationWarningLayout.visibility = View.GONE } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/NotificationWarningCard.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/NotificationWarningCard.kt new file mode 100644 index 00000000000..4b6a3df8f3b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/NotificationWarningCard.kt @@ -0,0 +1,105 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationlist.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.nextcloud.talk.R + +@Composable +fun NotificationWarningCard(visible: Boolean, onNotNow: () -> Unit, onShowSettings: () -> Unit) { + AnimatedVisibility( + visible = visible, + enter = expandVertically(), + exit = shrinkVertically() + ) { + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.standard_margin), + vertical = dimensionResource(R.dimen.standard_half_margin) + ) + ) { + Column( + modifier = Modifier.padding(dimensionResource(R.dimen.margin_between_elements)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.standard_half_margin), + vertical = dimensionResource(R.dimen.standard_half_margin) + ), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_notification_settings_24px), + contentDescription = null, + modifier = Modifier.size(dimensionResource(R.dimen.iconized_single_line_item_icon_size)) + ) + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.standard_half_margin))) + Text( + text = stringResource(R.string.nc_notification_warning), + style = MaterialTheme.typography.titleMedium + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onNotNow) { + Text(text = stringResource(R.string.nc_not_now)) + } + TextButton(onClick = onShowSettings) { + Text(text = stringResource(R.string.nc_settings)) + } + } + } + } + } +} + +@Preview(showBackground = true, name = "Light") +@Preview(showBackground = true, name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(showBackground = true, name = "RTL Arabic", locale = "ar") +@Composable +private fun NotificationWarningCardVisiblePreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + NotificationWarningCard( + visible = true, + onNotNow = {}, + onShowSettings = {} + ) + } +} diff --git a/app/src/main/res/drawable/ic_notification_settings_24px.xml b/app/src/main/res/drawable/ic_notification_settings_24px.xml new file mode 100644 index 00000000000..7a65a79a1ee --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_settings_24px.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/layout/activity_conversations.xml b/app/src/main/res/layout/activity_conversations.xml index 76bec11599d..b50a3a5a333 100644 --- a/app/src/main/res/layout/activity_conversations.xml +++ b/app/src/main/res/layout/activity_conversations.xml @@ -198,9 +198,10 @@ android:id="@+id/conversation_list_hint_include" layout="@layout/federated_invitation_hint" /> - + - - - - - - - - - - - - - - - - - - - - - From 283dcd40ff0d383cc2b30818cdfe053d4106e5ec Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sat, 21 Mar 2026 12:37:37 +0100 Subject: [PATCH 08/21] feat(conv-list): Migrate federation invitation card to Composable AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../ConversationsListActivity.kt | 45 +++++---- .../ui/FederationInvitationHintCard.kt | 91 +++++++++++++++++++ .../viewmodels/ConversationsListViewModel.kt | 18 +--- .../res/layout/activity_conversations.xml | 7 +- 4 files changed, 121 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ui/FederationInvitationHintCard.kt diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 45fbbec470b..5b373213fa8 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -92,6 +92,7 @@ import com.nextcloud.talk.contextchat.ContextChatViewModel import com.nextcloud.talk.conversationlist.ui.ConversationListFab import com.nextcloud.talk.conversationlist.ui.ConversationListSkeleton import com.nextcloud.talk.conversationlist.ui.ConversationsEmptyStateView +import com.nextcloud.talk.conversationlist.ui.FederationInvitationHintCard import com.nextcloud.talk.conversationlist.ui.NotificationWarningCard import com.nextcloud.talk.conversationlist.ui.StatusBannerRow import com.nextcloud.talk.conversationlist.ui.UnreadMentionBubble @@ -285,6 +286,7 @@ class ConversationsListActivity : setupUnreadBubble() setupShimmer() setupNotificationWarning() + setupFederationHintCard() initSystemBars() viewThemeUtils.material.themeSearchCardView(binding.searchToolbarContainer) viewThemeUtils.material.colorMaterialButtonContent(binding.menuButton, ColorRole.ON_SURFACE_VARIANT) @@ -347,7 +349,6 @@ class ConversationsListActivity : loadUserAvatar(binding.switchAccountButton) viewThemeUtils.material.colorMaterialTextButton(binding.switchAccountButton) - viewThemeUtils.material.themeCardView(binding.conversationListHintInclude.hintLayoutCardview) searchBehaviorSubject.onNext(false) fetchRooms() fetchPendingInvitations() @@ -394,25 +395,6 @@ class ConversationsListActivity : } } - conversationsListViewModel.getFederationInvitationsViewState.observe(this) { state -> - when (state) { - is ConversationsListViewModel.GetFederationInvitationsStartState -> { - binding.conversationListHintInclude.conversationListHintLayout.visibility = View.GONE - } - - is ConversationsListViewModel.GetFederationInvitationsSuccessState -> { - binding.conversationListHintInclude.conversationListHintLayout.visibility = - if (state.showInvitationsHint) View.VISIBLE else View.GONE - } - - is ConversationsListViewModel.GetFederationInvitationsErrorState -> { - // do nothing - } - - else -> {} - } - } - conversationsListViewModel.showBadgeViewState.observe(this) { state -> when (state) { is ConversationsListViewModel.ShowBadgeStartState -> { @@ -1053,10 +1035,6 @@ class ConversationsListActivity : private fun fetchPendingInvitations() { if (hasSpreedFeatureCapability(currentUser?.capabilities?.spreedCapability, SpreedFeatures.FEDERATION_V1)) { - binding.conversationListHintInclude.conversationListHintLayout.setOnClickListener { - val intent = Intent(this, InvitationsActivity::class.java) - startActivity(intent) - } conversationsListViewModel.getFederationInvitations() } } @@ -1845,6 +1823,25 @@ class ConversationsListActivity : } } + private fun setupFederationHintCard() { + binding.federationHintComposeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val colorScheme = remember { viewThemeUtils.getColorScheme(context) } + val visible by conversationsListViewModel.federationInvitationHintVisible.collectAsStateWithLifecycle() + MaterialTheme(colorScheme = colorScheme) { + FederationInvitationHintCard( + visible = visible, + onClick = { + val intent = Intent(context, InvitationsActivity::class.java) + startActivity(intent) + } + ) + } + } + } + } + private fun shouldShowNotificationWarning(): Boolean { fun shouldShowWarningIfDateTooOld(date1: Long): Boolean { val currentTimeMillis = System.currentTimeMillis() diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/FederationInvitationHintCard.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/FederationInvitationHintCard.kt new file mode 100644 index 00000000000..1707365c126 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/FederationInvitationHintCard.kt @@ -0,0 +1,91 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationlist.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.nextcloud.talk.R + +/** + * Composable card that shows a hint about pending federation invitations. + * + * @param visible Whether the card should be visible. + * @param onClick Called when the user taps the card (typically navigates to InvitationsActivity). + */ +@Composable +fun FederationInvitationHintCard(visible: Boolean, onClick: () -> Unit) { + AnimatedVisibility( + visible = visible, + enter = expandVertically(), + exit = shrinkVertically() + ) { + OutlinedCard( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.standard_margin), + vertical = dimensionResource(R.dimen.standard_half_margin) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(dimensionResource(R.dimen.standard_padding)), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_info_24px), + contentDescription = null, + modifier = Modifier.size(dimensionResource(R.dimen.iconized_single_line_item_icon_size)) + ) + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.standard_half_margin))) + Text( + text = stringResource(R.string.nc_federation_pending_invitation_hint), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + modifier = Modifier.weight(1f) + ) + } + } + } +} + +@Preview(showBackground = true, name = "Light") +@Preview(showBackground = true, name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(showBackground = true, name = "RTL Arabic", locale = "ar") +@Composable +private fun FederationInvitationHintCardDarkPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + FederationInvitationHintCard(visible = true, onClick = {}) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index 316efb57afe..f39ff634a0f 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt @@ -121,15 +121,8 @@ class ConversationsListViewModel @Inject constructor( .roomListFlow .stateIn(viewModelScope, SharingStarted.Eagerly, listOf()) - object GetFederationInvitationsStartState : ViewState - object GetFederationInvitationsErrorState : ViewState - - open class GetFederationInvitationsSuccessState(val showInvitationsHint: Boolean) : ViewState - - private val _getFederationInvitationsViewState: MutableLiveData = - MutableLiveData(GetFederationInvitationsStartState) - val getFederationInvitationsViewState: LiveData - get() = _getFederationInvitationsViewState + private val _federationInvitationHintVisible = MutableStateFlow(false) + val federationInvitationHintVisible: StateFlow = _federationInvitationHintVisible.asStateFlow() object ShowBadgeStartState : ViewState object ShowBadgeErrorState : ViewState @@ -140,7 +133,7 @@ class ConversationsListViewModel @Inject constructor( get() = _showBadgeViewState fun getFederationInvitations() { - _getFederationInvitationsViewState.value = GetFederationInvitationsStartState + _federationInvitationHintVisible.value = false _showBadgeViewState.value = ShowBadgeStartState userManager.users.blockingGet()?.forEach { @@ -393,9 +386,9 @@ class ConversationsListViewModel @Inject constructor( invitationsModel.user.baseUrl?.equals(currentUser.baseUrl) == true ) { if (invitationsModel.invitations.isNotEmpty()) { - _getFederationInvitationsViewState.value = GetFederationInvitationsSuccessState(true) + _federationInvitationHintVisible.value = true } else { - _getFederationInvitationsViewState.value = GetFederationInvitationsSuccessState(false) + _federationInvitationHintVisible.value = false } } else { if (invitationsModel.invitations.isNotEmpty()) { @@ -405,7 +398,6 @@ class ConversationsListViewModel @Inject constructor( } override fun onError(e: Throwable) { - _getFederationInvitationsViewState.value = GetFederationInvitationsErrorState Log.e(TAG, "Failed to fetch pending invitations", e) } diff --git a/app/src/main/res/layout/activity_conversations.xml b/app/src/main/res/layout/activity_conversations.xml index b50a3a5a333..85eb672ebbd 100644 --- a/app/src/main/res/layout/activity_conversations.xml +++ b/app/src/main/res/layout/activity_conversations.xml @@ -194,9 +194,10 @@ android:layout_height="match_parent" android:orientation="vertical"> - + Date: Sat, 21 Mar 2026 19:38:25 +0100 Subject: [PATCH 09/21] feat(conv-list): Create conversation item Composable AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../ui/ConversationListItem.kt | 1657 +++++++++++++++++ .../main/res/drawable/ic_videocam_24px.xml | 10 + 2 files changed, 1667 insertions(+) create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt create mode 100644 app/src/main/res/drawable/ic_videocam_24px.xml diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt new file mode 100644 index 00000000000..c85f29ee90d --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt @@ -0,0 +1,1657 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationlist.ui + +import android.content.res.Configuration +import android.text.format.DateUtils +import android.widget.ImageView +import androidx.annotation.DrawableRes +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.graphics.toArgb +import coil.compose.AsyncImage +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.data.database.mappers.asModel +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.extensions.loadNoteToSelfAvatar +import com.nextcloud.talk.extensions.loadSystemAvatar +import com.nextcloud.talk.models.MessageDraft +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.chat.ChatMessageJson +import com.nextcloud.talk.models.json.conversations.ConversationEnums +import com.nextcloud.talk.models.json.participants.Participant +import com.nextcloud.talk.ui.StatusDrawable +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability +import com.nextcloud.talk.utils.DisplayUtils +import com.nextcloud.talk.utils.SpreedFeatures + +// ── Constants ───────────────────────────────────────────────────────────────── + +private const val AVATAR_SIZE_DP = 48 +private const val FAVORITE_OVERLAY_SIZE_DP = 16 +private const val STATUS_OVERLAY_SIZE_DP = 18 +private const val BADGE_OVERLAY_SIZE_DP = 18 +private const val CALL_OVERLAY_SIZE_DP = 16 +private const val STATUS_INTERNAL_SIZE_DP = 9f +private const val ICON_MSG_SIZE_DP = 14 +private const val ICON_MSG_SPACING_DP = 2 +private const val UNREAD_THRESHOLD = 1000 +private const val UNREAD_BUBBLE_STROKE_DP = 1.5f + +// ── Avatar content model ─────────────────────────────────────────────────────── + +private sealed class AvatarContent { + data class Url(val url: String) : AvatarContent() + data class Res(@param:DrawableRes val resId: Int) : AvatarContent() + object System : AvatarContent() + object NoteToSelf : AvatarContent() +} + +@Composable +private fun buildAvatarContent(model: ConversationModel, currentUser: User): AvatarContent { + val context = LocalContext.current + val isDark = DisplayUtils.isDarkModeOn(context) + return when { + model.objectType == ConversationEnums.ObjectType.SHARE_PASSWORD -> + AvatarContent.Res(R.drawable.ic_circular_lock) + + model.objectType == ConversationEnums.ObjectType.FILE -> + AvatarContent.Res(R.drawable.ic_avatar_document) + + model.type == ConversationEnums.ConversationType.ROOM_SYSTEM -> + AvatarContent.System + + model.type == ConversationEnums.ConversationType.NOTE_TO_SELF -> + AvatarContent.NoteToSelf + + model.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> + AvatarContent.Url(ApiUtils.getUrlForAvatar(currentUser.baseUrl, model.name, false, isDark)) + + else -> + AvatarContent.Url( + ApiUtils.getUrlForConversationAvatarWithVersion( + 1, + currentUser.baseUrl, + model.token, + isDark, + model.avatarVersion.takeIf { it.isNotEmpty() } + ) + ) + } +} + +// ── Main Composable ──────────────────────────────────────────────────────────── + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ConversationListItem( + model: ConversationModel, + currentUser: User, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier +) { + val chatMessage = remember(model.lastMessage, currentUser) { + model.lastMessage?.asModel()?.also { it.activeUser = currentUser } + } + + Row( + modifier = modifier + .fillMaxWidth() + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding( + horizontal = 16.dp, + vertical = 8.dp + ), + verticalAlignment = Alignment.Top + ) { + ConversationAvatar( + model = model, + currentUser = currentUser, + modifier = Modifier + .size(AVATAR_SIZE_DP.dp) + .align(Alignment.CenterVertically) + ) + Spacer(Modifier.width(16.dp)) + + if (model.hasSensitive) { + SensitiveContent(model = model, currentUser = currentUser) + } else { + FullContent(model = model, currentUser = currentUser, chatMessage = chatMessage) + } + } +} + +@Composable +private fun RowScope.SensitiveContent(model: ConversationModel, currentUser: User) { + Row( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = model.displayName, + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (model.unreadMessages > 0) FontWeight.Bold else FontWeight.Normal, + color = colorResource(R.color.conversation_item_header) + ) + UnreadBubble(model = model, currentUser = currentUser) + } +} + +@Composable +private fun RowScope.FullContent( + model: ConversationModel, + currentUser: User, + chatMessage: ChatMessage? +) { + Column(modifier = Modifier.weight(1f)) { + ConversationNameRow(model = model, chatMessage = chatMessage) + Spacer(Modifier.height(4.dp)) + ConversationLastMessageRow(model = model, currentUser = currentUser, chatMessage = chatMessage) + } +} + +// ── Sub-Composables ──────────────────────────────────────────────────────────── + +@Composable +private fun ConversationAvatar( + model: ConversationModel, + currentUser: User, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + ConversationAvatarImage( + model = model, + currentUser = currentUser, + modifier = Modifier + .size(AVATAR_SIZE_DP.dp) + .clip(CircleShape) + ) + + if (model.favorite) { + FavoriteOverlay( + modifier = Modifier + .size(FAVORITE_OVERLAY_SIZE_DP.dp) + .align(Alignment.TopEnd) + ) + } + + if (model.hasCall) { + ActiveCallOverlay( + modifier = Modifier + .size(CALL_OVERLAY_SIZE_DP.dp) + .align(Alignment.TopEnd) + ) + } + + if (model.type != ConversationEnums.ConversationType.ROOM_SYSTEM) { + StatusOverlay( + model = model, + modifier = Modifier + .size(STATUS_OVERLAY_SIZE_DP.dp) + .align(Alignment.BottomEnd) + ) + } + + PublicBadgeOverlay( + model = model, + modifier = Modifier + .size(BADGE_OVERLAY_SIZE_DP.dp) + .align(Alignment.BottomEnd) + ) + } +} + +@Composable +private fun ConversationAvatarImage( + model: ConversationModel, + currentUser: User, + modifier: Modifier = Modifier +) { + val isInPreview = LocalInspectionMode.current + val avatarContent = buildAvatarContent(model = model, currentUser = currentUser) + + when (avatarContent) { + is AvatarContent.Url -> { + AsyncImage( + model = avatarContent.url, + contentDescription = stringResource(R.string.avatar), + contentScale = ContentScale.Crop, + modifier = modifier + ) + } + + is AvatarContent.Res -> { + AsyncImage( + model = avatarContent.resId, + contentDescription = stringResource(R.string.avatar), + contentScale = ContentScale.Crop, + modifier = modifier + ) + } + + AvatarContent.System -> { + if (isInPreview) { + Box( + modifier = modifier.background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(32.dp) + ) + } + } else { + AndroidView( + factory = { ctx -> + ImageView(ctx).apply { loadSystemAvatar() } + }, + modifier = modifier + ) + } + } + + AvatarContent.NoteToSelf -> { + if (isInPreview) { + Box( + modifier = modifier.background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_note_to_self), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(32.dp) + ) + } + } else { + AndroidView( + factory = { ctx -> + ImageView(ctx).apply { loadNoteToSelfAvatar() } + }, + modifier = modifier + ) + } + } + } +} + +@Composable +private fun FavoriteOverlay(modifier: Modifier = Modifier) { + Icon( + painter = painterResource(R.drawable.ic_star_black_24dp), + contentDescription = stringResource(R.string.starred), + tint = colorResource(R.color.favorite_icon_tint), + modifier = modifier + ) +} + +@Composable +private fun ActiveCallOverlay(modifier: Modifier = Modifier) { + Icon( + painter = painterResource(R.drawable.ic_videocam_24px), + contentDescription = null, + tint = Color.Red, + modifier = modifier + ) +} + +@Composable +private fun StatusOverlay(model: ConversationModel, modifier: Modifier = Modifier) { + val isInPreview = LocalInspectionMode.current + + if (isInPreview) { + if (model.statusIcon != null) { + Text( + text = model.statusIcon!!, + modifier = modifier, + style = MaterialTheme.typography.labelSmall + ) + } else { + val drawableRes = when (model.status) { + "online" -> R.drawable.online_status + "away" -> R.drawable.ic_user_status_away + "busy" -> R.drawable.ic_user_status_busy + "dnd" -> R.drawable.ic_user_status_dnd + else -> null + } + if (drawableRes != null) { + Icon( + painter = painterResource(drawableRes), + contentDescription = null, + tint = Color.Unspecified, + modifier = modifier + ) + } + } + } else { + val context = LocalContext.current + val surfaceArgb = MaterialTheme.colorScheme.surface.toArgb() + AndroidView( + factory = { ctx -> + ImageView(ctx).apply { + val sizePx = DisplayUtils.convertDpToPixel(STATUS_INTERNAL_SIZE_DP, ctx) + setImageDrawable( + StatusDrawable(model.status, model.statusIcon, sizePx, surfaceArgb, ctx) + ) + } + }, + update = { imageView -> + val sizePx = DisplayUtils.convertDpToPixel(STATUS_INTERNAL_SIZE_DP, context) + imageView.setImageDrawable( + StatusDrawable(model.status, model.statusIcon, sizePx, surfaceArgb, context) + ) + }, + modifier = modifier + ) + } +} + +@Composable +private fun PublicBadgeOverlay(model: ConversationModel, modifier: Modifier = Modifier) { + val badgeRes = when { + model.type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL -> + R.drawable.ic_avatar_link + + model.remoteServer?.isNotEmpty() == true -> + R.drawable.ic_avatar_federation + + else -> null + } ?: return + + Icon( + painter = painterResource(badgeRes), + contentDescription = stringResource(R.string.nc_public_call_status), + tint = colorResource(R.color.no_emphasis_text), + modifier = modifier + ) +} + +@Composable +private fun ConversationNameRow(model: ConversationModel, chatMessage: ChatMessage?) { + val hasDraft = model.messageDraft?.messageText?.isNotBlank() == true + val showDate = chatMessage != null || hasDraft + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = model.displayName, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (model.unreadMessages > 0) FontWeight.Bold else FontWeight.Normal, + color = colorResource(R.color.conversation_item_header) + ) + if (showDate) { + Spacer(Modifier.width(4.dp)) + val dateText = remember(model.lastActivity) { + if (model.lastActivity > 0L) { + DateUtils.getRelativeTimeSpanString( + model.lastActivity * 1_000L, + System.currentTimeMillis(), + 0L, + DateUtils.FORMAT_ABBREV_RELATIVE + ).toString() + } else { + "" + } + } + Text( + text = dateText, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + color = colorResource(R.color.textColorMaxContrast) + ) + } + } +} + +@Composable +private fun ConversationLastMessageRow( + model: ConversationModel, + currentUser: User, + chatMessage: ChatMessage? +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + LastMessageContent( + model = model, + currentUser = currentUser, + chatMessage = chatMessage, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(4.dp)) + UnreadBubble(model = model, currentUser = currentUser) + } +} + +// ── Unread Bubble ───────────────────────────────────────────────────────────── + +@Composable +private fun UnreadBubble(model: ConversationModel, currentUser: User) { + if (model.unreadMessages <= 0) return + + val text = if (model.unreadMessages >= UNREAD_THRESHOLD) { + stringResource(R.string.tooManyUnreadMessages) + } else { + model.unreadMessages.toString() + } + + val isOneToOne = model.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + val hasDmFlag = hasSpreedFeatureCapability( + currentUser.capabilities?.spreedCapability, + SpreedFeatures.DIRECT_MENTION_FLAG + ) + val outlined = model.unreadMention && hasDmFlag && !model.unreadMentionDirect && !isOneToOne + + when { + outlined -> OutlinedUnreadChip(text = text) + isOneToOne || model.unreadMention -> FilledUnreadChip(text = text) + else -> GreyUnreadChip(text = text) + } +} + +@Composable +private fun FilledUnreadChip(text: String) { + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(12.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +private fun OutlinedUnreadChip(text: String) { + Box( + modifier = Modifier + .border(UNREAD_BUBBLE_STROKE_DP.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(12.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +private fun GreyUnreadChip(text: String) { + Box( + modifier = Modifier + .background(colorResource(R.color.conversation_unread_bubble), RoundedCornerShape(12.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = colorResource(R.color.conversation_unread_bubble_text), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } +} + +// ── Last Message Content ─────────────────────────────────────────────────────── + +@Composable +private fun LastMessageContent( + model: ConversationModel, + currentUser: User, + chatMessage: ChatMessage?, + modifier: Modifier = Modifier +) { + val isBold = model.unreadMessages > 0 + val fontWeight = if (isBold) FontWeight.Bold else FontWeight.Normal + + // ── Case 1: Draft ───────────────────────────────────────────────────────── + val draftText = model.messageDraft?.messageText?.takeIf { it.isNotBlank() } + if (draftText != null) { + val primaryColor = MaterialTheme.colorScheme.primary + val draftPrefixTemplate = stringResource(R.string.nc_draft_prefix) + val fullLabel = remember(draftText, draftPrefixTemplate) { + String.format(draftPrefixTemplate, draftText) + } + val prefixEnd = fullLabel.length - draftText.length + val annotated = buildAnnotatedString { + withStyle(SpanStyle(color = primaryColor, fontWeight = FontWeight.Bold)) { + append(fullLabel.substring(0, prefixEnd)) + } + append(draftText) + } + Text( + text = annotated, + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + fontWeight = fontWeight + ) + return + } + + // ── Case 2: No message ──────────────────────────────────────────────────── + if (chatMessage == null) { + Text(text = "", modifier = modifier) + return + } + + // ── Case 3: Deleted comment ─────────────────────────────────────────────── + if (chatMessage.isDeletedCommentMessage) { + Text( + text = chatMessage.message ?: "", + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + return + } + + val msgType = chatMessage.getCalculateMessageType() + + // ── Case 4: System message ──────────────────────────────────────────────── + if (msgType == ChatMessage.MessageType.SYSTEM_MESSAGE || + model.type == ConversationEnums.ConversationType.ROOM_SYSTEM + ) { + Text( + text = chatMessage.message ?: "", + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + fontWeight = fontWeight + ) + return + } + + // ── Case 5: Attachment / special message types ──────────────────────────── + when (msgType) { + ChatMessage.MessageType.VOICE_MESSAGE -> { + val name = chatMessage.messageParameters?.get("file")?.get("name") ?: "" + val prefix = authorPrefix(chatMessage, currentUser) + AttachmentRow(prefix, R.drawable.baseline_mic_24, name, fontWeight, modifier) + return + } + + ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE -> { + var name = chatMessage.message ?: "" + if (name == "{file}") name = chatMessage.messageParameters?.get("file")?.get("name") ?: "" + val mime = chatMessage.messageParameters?.get("file")?.get("mimetype") + val icon = attachmentIconRes(mime) + val prefix = authorPrefix(chatMessage, currentUser) + AttachmentRow(prefix, icon, name, fontWeight, modifier) + return + } + + ChatMessage.MessageType.SINGLE_NC_GEOLOCATION_MESSAGE -> { + val name = chatMessage.messageParameters?.get("object")?.get("name") ?: "" + val prefix = authorPrefix(chatMessage, currentUser) + AttachmentRow(prefix, R.drawable.baseline_location_pin_24, name, fontWeight, modifier) + return + } + + ChatMessage.MessageType.POLL_MESSAGE -> { + val name = chatMessage.messageParameters?.get("object")?.get("name") ?: "" + val prefix = authorPrefix(chatMessage, currentUser) + AttachmentRow(prefix, R.drawable.baseline_bar_chart_24, name, fontWeight, modifier) + return + } + + ChatMessage.MessageType.DECK_CARD -> { + val name = chatMessage.messageParameters?.get("object")?.get("name") ?: "" + val prefix = authorPrefix(chatMessage, currentUser) + AttachmentRow(prefix, R.drawable.baseline_article_24, name, fontWeight, modifier) + return + } + + ChatMessage.MessageType.SINGLE_LINK_GIPHY_MESSAGE, + ChatMessage.MessageType.SINGLE_LINK_TENOR_MESSAGE, + ChatMessage.MessageType.SINGLE_LINK_GIF_MESSAGE -> { + val gifSelf = stringResource(R.string.nc_sent_a_gif_you) + val gifOther = stringResource(R.string.nc_sent_a_gif, chatMessage.actorDisplayName ?: "") + Text( + text = if (chatMessage.actorId == currentUser.userId) gifSelf else gifOther, + modifier = modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, fontWeight = fontWeight + ) + return + } + + ChatMessage.MessageType.SINGLE_LINK_IMAGE_MESSAGE -> { + val imgSelf = stringResource(R.string.nc_sent_an_image_you) + val imgOther = stringResource(R.string.nc_sent_an_image, chatMessage.actorDisplayName ?: "") + Text( + text = if (chatMessage.actorId == currentUser.userId) imgSelf else imgOther, + modifier = modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, fontWeight = fontWeight + ) + return + } + + ChatMessage.MessageType.SINGLE_LINK_VIDEO_MESSAGE -> { + val vidSelf = stringResource(R.string.nc_sent_a_video_you) + val vidOther = stringResource(R.string.nc_sent_a_video, chatMessage.actorDisplayName ?: "") + Text( + text = if (chatMessage.actorId == currentUser.userId) vidSelf else vidOther, + modifier = modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, fontWeight = fontWeight + ) + return + } + + ChatMessage.MessageType.SINGLE_LINK_AUDIO_MESSAGE -> { + val audSelf = stringResource(R.string.nc_sent_an_audio_you) + val audOther = stringResource(R.string.nc_sent_an_audio, chatMessage.actorDisplayName ?: "") + Text( + text = if (chatMessage.actorId == currentUser.userId) audSelf else audOther, + modifier = modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, fontWeight = fontWeight + ) + return + } + + else -> { /* fall through to regular text */ } + } + + // ── Case 6: Regular text message ───────────────────────────────────────── + val rawText = chatMessage.message ?: "" + val youPrefix = stringResource(R.string.nc_formatted_message_you, rawText) + val groupFormat = stringResource(R.string.nc_formatted_message) + val guestLabel = stringResource(R.string.nc_guest) + val displayText = when { + chatMessage.actorId == currentUser.userId -> youPrefix + model.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> rawText + else -> { + val actorName = chatMessage.actorDisplayName?.takeIf { it.isNotBlank() } + ?: if (chatMessage.actorType == "guests" || chatMessage.actorType == "emails") { + guestLabel + } else { + "" + } + String.format(groupFormat, actorName, rawText) + } + } + Text( + text = displayText, + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + fontWeight = fontWeight + ) +} + +@Composable +private fun AttachmentRow( + authorPrefix: String, + @DrawableRes iconRes: Int?, + name: String, + fontWeight: FontWeight, + modifier: Modifier = Modifier +) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + if (authorPrefix.isNotBlank()) { + Text( + text = "$authorPrefix ", + style = MaterialTheme.typography.bodyMedium, + fontWeight = fontWeight, + maxLines = 1 + ) + } + if (iconRes != null) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(ICON_MSG_SIZE_DP.dp) + ) + Spacer(Modifier.width(ICON_MSG_SPACING_DP.dp)) + } + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = fontWeight, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } +} + +// ── Pure Helper Functions ────────────────────────────────────────────────────── + +private fun authorPrefix(chatMessage: ChatMessage, currentUser: User): String = + if (chatMessage.actorId == currentUser.userId) { + "You:" + } else { + val name = chatMessage.actorDisplayName + if (!name.isNullOrBlank()) "$name:" else "" + } + +private fun attachmentIconRes(mimetype: String?): Int? = when { + mimetype == null -> null + mimetype.contains("image") -> R.drawable.baseline_image_24 + mimetype.contains("video") -> R.drawable.baseline_video_24 + mimetype.contains("application") -> R.drawable.baseline_insert_drive_file_24 + mimetype.contains("audio") -> R.drawable.baseline_audiotrack_24 + mimetype.contains("text/vcard") -> R.drawable.baseline_contacts_24 + else -> null +} + +// ── Preview Helpers ──────────────────────────────────────────────────────────── + +private fun previewUser(userId: String = "user1") = User( + id = 1L, + userId = userId, + username = userId, + baseUrl = "https://cloud.example.com", + token = "token", + displayName = "Test User", + capabilities = null +) + +@Suppress("LongParameterList") +private fun previewModel( + token: String = "abc", + displayName: String = "Alice", + type: ConversationEnums.ConversationType = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, + objectType: ConversationEnums.ObjectType = ConversationEnums.ObjectType.DEFAULT, + unreadMessages: Int = 0, + unreadMention: Boolean = false, + unreadMentionDirect: Boolean = false, + favorite: Boolean = false, + hasCall: Boolean = false, + hasSensitive: Boolean = false, + hasArchived: Boolean = false, + status: String? = null, + statusIcon: String? = null, + remoteServer: String? = null, + lastMessage: ChatMessageJson? = null, + messageDraft: MessageDraft? = null, + lobbyState: ConversationEnums.LobbyState = ConversationEnums.LobbyState.LOBBY_STATE_ALL_PARTICIPANTS, + readOnlyState: ConversationEnums.ConversationReadOnlyState = + ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_WRITE +) = ConversationModel( + internalId = "1@$token", + accountId = 1L, + token = token, + name = "testuser", + displayName = displayName, + description = "", + type = type, + participantType = Participant.ParticipantType.USER, + sessionId = "s", + actorId = "a", + actorType = "users", + objectType = objectType, + notificationLevel = ConversationEnums.NotificationLevel.DEFAULT, + conversationReadOnlyState = readOnlyState, + lobbyState = lobbyState, + lobbyTimer = 0L, + canLeaveConversation = true, + canDeleteConversation = true, + unreadMentionDirect = unreadMentionDirect, + notificationCalls = 0, + avatarVersion = "", + hasCustomAvatar = false, + callStartTime = 0L, + unreadMessages = unreadMessages, + unreadMention = unreadMention, + favorite = favorite, + hasCall = hasCall, + hasSensitive = hasSensitive, + hasArchived = hasArchived, + status = status, + statusIcon = statusIcon, + remoteServer = remoteServer, + lastMessage = lastMessage, + messageDraft = messageDraft, + lastActivity = System.currentTimeMillis() / 1000L - 3600L +) + +private fun previewMsg( + actorId: String = "other", + actorDisplayName: String = "Bob", + message: String = "Hello there", + messageType: String = "comment", + systemMessageType: ChatMessage.SystemMessageType? = null, + messageParameters: HashMap>? = null +) = ChatMessageJson( + id = 1L, + actorId = actorId, + actorDisplayName = actorDisplayName, + message = message, + messageType = messageType, + systemMessageType = systemMessageType, + messageParameters = messageParameters +) + +@Composable +private fun PreviewWrapper(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { + val colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + content() + } + } +} + +// ── Section A – Conversation Type ───────────────────────────────────────────── + +@Preview(name = "A1 – 1:1 online") +@Composable +private fun PreviewOneToOne() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + type = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, + status = "online", + lastMessage = previewMsg(message = "Hey, how are you?") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "A2 – Group") +@Composable +private fun PreviewGroup() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Project Team", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + lastMessage = previewMsg(message = "Meeting at 3pm") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "A3 – Group no avatar") +@Composable +private fun PreviewGroupNoAvatar() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Team Chat", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + lastMessage = previewMsg(message = "Anyone free?") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "A4 – Public room") +@Composable +private fun PreviewPublicRoom() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Open Room", + type = ConversationEnums.ConversationType.ROOM_PUBLIC_CALL, + lastMessage = previewMsg(message = "Welcome everyone!") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "A5 – System room") +@Composable +private fun PreviewSystemRoom() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Nextcloud", + type = ConversationEnums.ConversationType.ROOM_SYSTEM, + lastMessage = previewMsg(message = "You joined the conversation", messageType = "system") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "A6 – Note to self") +@Composable +private fun PreviewNoteToSelf() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Personal notes", + type = ConversationEnums.ConversationType.NOTE_TO_SELF, + lastMessage = previewMsg(message = "Reminder: buy groceries") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "A7 – Former 1:1") +@Composable +private fun PreviewFormerOneToOne() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Deleted User", + type = ConversationEnums.ConversationType.FORMER_ONE_TO_ONE, + lastMessage = previewMsg(message = "Last message before leaving") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +// ── Section B – ObjectType / Special Avatar ──────────────────────────────────── + +@Preview(name = "B8 – Password protected") +@Composable +private fun PreviewPasswordProtected() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Protected room", + objectType = ConversationEnums.ObjectType.SHARE_PASSWORD, + type = ConversationEnums.ConversationType.ROOM_PUBLIC_CALL, + lastMessage = previewMsg(message = "Enter password to join") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "B9 – File room") +@Composable +private fun PreviewFileRoom() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "document.pdf", + objectType = ConversationEnums.ObjectType.FILE, + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + lastMessage = previewMsg(message = "What do you think about this file?") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "B10 – Phone temporary room") +@Composable +private fun PreviewPhoneNumberRoom() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "+49 170 1234567", + objectType = ConversationEnums.ObjectType.PHONE_TEMPORARY, + type = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, + lastMessage = previewMsg(message = "Missed call") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +// ── Section C – Federated ───────────────────────────────────────────────────── + +@Preview(name = "C11 – Federated") +@Composable +private fun PreviewFederated() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Remote Friend", + type = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, + remoteServer = "https://other.cloud.com", + lastMessage = previewMsg(message = "Hi from another server!") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +// ── Section D – Unread States ───────────────────────────────────────────────── + +@Preview(name = "D12 – No unread") +@Composable +private fun PreviewNoUnread() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + unreadMessages = 0, + lastMessage = previewMsg(message = "See you tomorrow") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "D13 – Unread few (5)") +@Composable +private fun PreviewUnreadFew() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + unreadMessages = 5, + lastMessage = previewMsg(message = "Did you see this?") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "D14 – Unread many (1500)") +@Composable +private fun PreviewUnreadMany() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Busy Channel", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + unreadMessages = 1500, + lastMessage = previewMsg(message = "So many messages!") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "D15 – Unread mention group (outlined)") +@Composable +private fun PreviewUnreadMentionGroup() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Dev Team", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + unreadMessages = 3, + unreadMention = true, + unreadMentionDirect = false, + lastMessage = previewMsg(message = "@user1 please review PR") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "D16 – Unread mention direct 1:1 (filled)") +@Composable +private fun PreviewUnreadMentionDirect1to1() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + type = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, + unreadMessages = 2, + unreadMention = true, + unreadMentionDirect = true, + lastMessage = previewMsg(message = "Did you see my message?") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "D17 – Unread mention group direct (filled)") +@Composable +private fun PreviewUnreadMentionGroupDirect() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Ops Team", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + unreadMessages = 7, + unreadMention = true, + unreadMentionDirect = true, + lastMessage = previewMsg(message = "@user1 urgent!") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +// ── Section E – Favorite ────────────────────────────────────────────────────── + +@Preview(name = "E18 – Favorite") +@Composable +private fun PreviewFavorite() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Best Friend", + favorite = true, + lastMessage = previewMsg(message = "Let's meet up!") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "E19 – Not favorite") +@Composable +private fun PreviewNotFavorite() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Colleague", + favorite = false, + lastMessage = previewMsg(message = "See the report?") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +// ── Section F – Status (1:1 only) ───────────────────────────────────────────── + +@Preview(name = "F20 – Status online") +@Composable +private fun PreviewStatusOnline() = PreviewWrapper { + ConversationListItem( + model = previewModel(displayName = "Alice", status = "online", lastMessage = previewMsg()), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "F21 – Status away") +@Composable +private fun PreviewStatusAway() = PreviewWrapper { + ConversationListItem( + model = previewModel(displayName = "Bob", status = "away", lastMessage = previewMsg()), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "F22 – Status DND") +@Composable +private fun PreviewStatusDnd() = PreviewWrapper { + ConversationListItem( + model = previewModel(displayName = "Carol", status = "dnd", lastMessage = previewMsg()), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "F23 – Status offline") +@Composable +private fun PreviewStatusOffline() = PreviewWrapper { + ConversationListItem( + model = previewModel(displayName = "Dave", status = "offline", lastMessage = previewMsg()), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "F24 – Status with emoji") +@Composable +private fun PreviewStatusWithEmoji() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Eve", + status = "online", + statusIcon = "☕", + lastMessage = previewMsg(message = "Grabbing coffee") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +// ── Section G – Last Message Types ──────────────────────────────────────────── + +@Preview(name = "G25 – Own regular text") +@Composable +private fun PreviewLastMessageOwnText() = PreviewWrapper { + ConversationListItem( + model = previewModel( + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + displayName = "Team", + lastMessage = previewMsg(actorId = "user1", actorDisplayName = "Me", message = "Good morning!") + ), + currentUser = previewUser("user1"), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "G26 – Other regular text") +@Composable +private fun PreviewLastMessageOtherText() = PreviewWrapper { + ConversationListItem( + model = previewModel( + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + displayName = "Team", + lastMessage = previewMsg(actorId = "user2", actorDisplayName = "Alice", message = "Good morning!") + ), + currentUser = previewUser("user1"), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "G27 – System message") +@Composable +private fun PreviewLastMessageSystem() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "Alice joined the call", + messageType = "system", + systemMessageType = ChatMessage.SystemMessageType.CALL_STARTED + ) + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "G28 – Voice message") +@Composable +private fun PreviewLastMessageVoice() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "voice-message.mp3", + messageType = "voice-message", + messageParameters = hashMapOf("file" to hashMapOf("name" to "voice_001.mp3")) + ) + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "G29 – Image attachment") +@Composable +private fun PreviewLastMessageImage() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "{file}", + messageParameters = hashMapOf( + "file" to hashMapOf("name" to "photo.jpg", "mimetype" to "image/jpeg") + ) + ) + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "G30 – Video attachment") +@Composable +private fun PreviewLastMessageVideo() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "{file}", + messageParameters = hashMapOf( + "file" to hashMapOf("name" to "clip.mp4", "mimetype" to "video/mp4") + ) + ) + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "G31 – Audio attachment") +@Composable +private fun PreviewLastMessageAudio() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "{file}", + messageParameters = hashMapOf( + "file" to hashMapOf("name" to "song.mp3", "mimetype" to "audio/mpeg") + ) + ) + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "G32 – File attachment") +@Composable +private fun PreviewLastMessageFile() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "{file}", + messageParameters = hashMapOf( + "file" to hashMapOf("name" to "report.pdf", "mimetype" to "application/pdf") + ) + ) + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "G33 – GIF message") +@Composable +private fun PreviewLastMessageGif() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg(message = "https://giphy.com/gif.gif", messageType = "comment") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "G34 – Location message") +@Composable +private fun PreviewLastMessageLocation() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "geo:48.8566,2.3522", + messageParameters = hashMapOf( + "object" to hashMapOf("name" to "Eiffel Tower", "type" to "geo-location") + ) + ) + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "G35 – Poll message") +@Composable +private fun PreviewLastMessagePoll() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Team", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + lastMessage = previewMsg( + message = "{object}", + messageParameters = hashMapOf( + "object" to hashMapOf("name" to "Best framework?", "type" to "talk-poll") + ) + ) + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "G36 – Deck card") +@Composable +private fun PreviewLastMessageDeck() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "{object}", + messageParameters = hashMapOf( + "object" to hashMapOf("name" to "Sprint backlog item", "type" to "deck-card") + ) + ) + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "G37 – Deleted message") +@Composable +private fun PreviewLastMessageDeleted() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "Message deleted", + messageType = "comment_deleted" + ) + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "G38 – No last message") +@Composable +private fun PreviewNoLastMessage() = PreviewWrapper { + ConversationListItem( + model = previewModel(displayName = "New Conversation", lastMessage = null), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "G39 – Draft") +@Composable +private fun PreviewDraft() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + messageDraft = MessageDraft(messageText = "I was going to say…") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "G49 – Text with emoji") +@Composable +private fun PreviewLastMessageTextWithEmoji() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg(message = "Schönes Wochenende! 🎉😊") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +// ── Section H – Call Status ──────────────────────────────────────────────────── + +@Preview(name = "H40 – Active call") +@Composable +private fun PreviewActiveCall() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Team Stand-up", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + hasCall = true, + lastMessage = previewMsg(message = "Call in progress") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +// ── Section I – Lobby / Read-only ───────────────────────────────────────────── + +@Preview(name = "I41 – Lobby active") +@Composable +private fun PreviewLobbyActive() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "VIP Room", + type = ConversationEnums.ConversationType.ROOM_PUBLIC_CALL, + lobbyState = ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY, + lastMessage = previewMsg(message = "Waiting for moderator to let you in") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "I42 – Read only") +@Composable +private fun PreviewReadOnly() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Announcements", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + readOnlyState = ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_ONLY, + lastMessage = previewMsg(message = "Important update posted") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +// ── Section J – Sensitive ───────────────────────────────────────────────────── + +@Preview(name = "J43 – Sensitive (name only)") +@Composable +private fun PreviewSensitive() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Confidential Project", + hasSensitive = true, + unreadMessages = 3, + lastMessage = previewMsg(message = "This text should be hidden") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +// ── Section K – Archived ────────────────────────────────────────────────────── + +@Preview(name = "K44 – Archived") +@Composable +private fun PreviewArchived() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Old Project", + hasArchived = true, + lastMessage = previewMsg(message = "Project completed ✓") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +// ── Section L – UI Variants ──────────────────────────────────────────────────── + +@Preview( + name = "L45 – Dark mode", + uiMode = Configuration.UI_MODE_NIGHT_YES +) +@Composable +private fun PreviewDarkMode() = PreviewWrapper(darkTheme = true) { + ConversationListItem( + model = previewModel( + displayName = "Alice", + unreadMessages = 4, + lastMessage = previewMsg(message = "Good night!") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "L46 – Long name (truncation)") +@Composable +private fun PreviewLongName() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "This Is A Very Long Conversation Name That Should Be Truncated With Ellipsis", + lastMessage = previewMsg(message = "Short message") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "L47 – Short content, no date") +@Composable +private fun PreviewShortContent() = PreviewWrapper { + ConversationListItem( + model = previewModel(displayName = "Hi", lastMessage = null), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + +@Preview(name = "L48 – RTL (Arabic)", locale = "ar") +@Composable +private fun PreviewRtl() = PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "محادثة", + unreadMessages = 2, + lastMessage = previewMsg(message = "مرحبا كيف حالك") + ), + currentUser = previewUser(), + onClick = {}, onLongClick = {} + ) +} + + + diff --git a/app/src/main/res/drawable/ic_videocam_24px.xml b/app/src/main/res/drawable/ic_videocam_24px.xml new file mode 100644 index 00000000000..80d2efe6dea --- /dev/null +++ b/app/src/main/res/drawable/ic_videocam_24px.xml @@ -0,0 +1,10 @@ + + + From 54a6b56d7be0b8216afdcda94225508327195b10 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 22 Mar 2026 13:43:17 +0100 Subject: [PATCH 10/21] feat(conv-list): Create conversation list Composable AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../ConversationsListActivity.kt | 830 +++------ .../conversationlist/ui/ConversationList.kt | 316 ++++ .../ui/ConversationListEntry.kt | 32 + .../ui/ConversationListFab.kt | 4 + .../ui/ConversationListItem.kt | 1527 +++++++++-------- .../ui/ConversationShimmerList.kt | 4 +- .../viewmodels/ConversationsListViewModel.kt | 297 ++-- .../nextcloud/talk/profile/AvatarSection.kt | 46 +- .../com/nextcloud/talk/ui/CoilAvatarCache.kt | 65 + .../main/res/drawable/ic_videocam_24px.xml | 16 +- .../res/layout/activity_conversations.xml | 38 +- 11 files changed, 1715 insertions(+), 1460 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListEntry.kt create mode 100644 app/src/main/java/com/nextcloud/talk/ui/CoilAvatarCache.kt diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 5b373213fa8..9fec173ec17 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -23,10 +23,8 @@ import android.text.TextUtils import android.util.Log import android.view.Menu import android.view.MenuItem -import android.view.MotionEvent import android.view.View import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.annotation.OptIn @@ -49,7 +47,6 @@ import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.RecyclerView import androidx.work.Data import androidx.work.OneTimeWorkRequest import androidx.work.WorkInfo @@ -73,13 +70,6 @@ import com.nextcloud.talk.account.ServerSelectionActivity import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.activities.CallActivity import com.nextcloud.talk.activities.MainActivity -import com.nextcloud.talk.adapters.items.ContactItem -import com.nextcloud.talk.adapters.items.ConversationItem -import com.nextcloud.talk.adapters.items.GenericTextHeaderItem -import com.nextcloud.talk.adapters.items.LoadMoreResultsItem -import com.nextcloud.talk.adapters.items.MessageResultItem -import com.nextcloud.talk.adapters.items.MessagesTextHeaderItem -import com.nextcloud.talk.adapters.items.SpacerItem import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager @@ -87,8 +77,8 @@ import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.contacts.ContactsViewModel -import com.nextcloud.talk.contextchat.ContextChatView import com.nextcloud.talk.contextchat.ContextChatViewModel +import com.nextcloud.talk.conversationlist.ui.ConversationList import com.nextcloud.talk.conversationlist.ui.ConversationListFab import com.nextcloud.talk.conversationlist.ui.ConversationListSkeleton import com.nextcloud.talk.conversationlist.ui.ConversationsEmptyStateView @@ -108,8 +98,8 @@ import com.nextcloud.talk.jobs.ContactAddressBookWorker.Companion.run import com.nextcloud.talk.jobs.DeleteConversationWorker import com.nextcloud.talk.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.messagesearch.MessageSearchHelper -import com.nextcloud.talk.messagesearch.MessageSearchHelper.MessageSearchResults import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.domain.SearchMessageEntry import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import com.nextcloud.talk.settings.SettingsActivity @@ -149,9 +139,6 @@ import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.power.PowerManagerUtils import com.nextcloud.talk.utils.rx.SearchViewObservable.Companion.observeSearchView import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.common.SmoothScrollLinearLayoutManager -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -161,8 +148,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import org.apache.commons.lang3.builder.CompareToBuilder import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import retrofit2.HttpException @@ -172,10 +157,8 @@ import javax.inject.Inject @SuppressLint("StringFormatInvalid") @AutoInjector(NextcloudTalkApplication::class) -class ConversationsListActivity : - BaseActivity(), - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener { +@Suppress("LargeClass", "TooManyFunctions") +class ConversationsListActivity : BaseActivity() { private lateinit var binding: ActivityConversationsBinding @@ -218,33 +201,24 @@ class ConversationsListActivity : private val showNoArchivedViewState = MutableStateFlow(false) private val showUnreadBubbleState = MutableStateFlow(false) private val isFabVisibleState = MutableStateFlow(true) - private val isShimmerVisibleState = MutableStateFlow(true) private val showNotificationWarningState = MutableStateFlow(false) - private var roomsQueryDisposable: Disposable? = null - private var openConversationsQueryDisposable: Disposable? = null - private var adapter: FlexibleAdapter>? = null - private var conversationItems: MutableList> = ArrayList() - private var conversationItemsWithHeader: MutableList> = ArrayList() - private var searchableConversationItems: MutableList> = ArrayList() - private var filterableConversationItems: MutableList> = ArrayList() - private val mutex = Mutex() - private var nearFutureEventConversationItems: MutableList> = ArrayList() + private val isRefreshingState = MutableStateFlow(false) + + // Lazy list state – set from inside setContent, read from onPause + private var conversationListLazyListState: androidx.compose.foundation.lazy.LazyListState? = null + + private var nextUnreadConversationScrollPosition = 0 private var searchItem: MenuItem? = null private var chooseAccountItem: MenuItem? = null private var searchView: SearchView? = null private var searchQuery: String? = null private var credentials: String? = null - private var adapterWasNull = true - private var isRefreshing = false private var showShareToScreen = false private var filesToShare: ArrayList? = null private var selectedConversation: ConversationModel? = null private var textToPaste: String? = "" private var selectedMessageId: String? = null private var forwardMessage: Boolean = false - private var nextUnreadConversationScrollPosition = 0 - private var layoutManager: SmoothScrollLinearLayoutManager? = null - private val callHeaderItems = HashMap() private var conversationsListBottomDialog: ConversationsListBottomDialog? = null private var searchHelper: MessageSearchHelper? = null private var searchViewDisposable: Disposable? = null @@ -287,6 +261,7 @@ class ConversationsListActivity : setupShimmer() setupNotificationWarning() setupFederationHintCard() + setupConversationList() initSystemBars() viewThemeUtils.material.themeSearchCardView(binding.searchToolbarContainer) viewThemeUtils.material.colorMaterialButtonContent(binding.menuButton, ColorRole.ON_SURFACE_VARIANT) @@ -315,17 +290,8 @@ class ConversationsListActivity : override fun onResume() { super.onResume() - if (adapter == null) { - adapter = FlexibleAdapter(conversationItems, this, true) - addEmptyItemForEdgeToEdgeIfNecessary() - } else { - isShimmerVisibleState.value = false - } - adapter?.addListener(this) - prepareViews() showNotificationWarningState.value = shouldShowNotificationWarning() - showShareToScreen = hasActivityActionSendIntent() if (!eventBus.isRegistered(this)) { @@ -349,7 +315,10 @@ class ConversationsListActivity : loadUserAvatar(binding.switchAccountButton) viewThemeUtils.material.colorMaterialTextButton(binding.switchAccountButton) - searchBehaviorSubject.onNext(false) + val isSearchCurrentlyActive = conversationsListViewModel.isSearchActiveFlow.value + searchBehaviorSubject.onNext(isSearchCurrentlyActive) + conversationsListViewModel.setIsSearchActive(isSearchCurrentlyActive) + conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) fetchRooms() fetchPendingInvitations() } else { @@ -359,27 +328,20 @@ class ConversationsListActivity : showSearchOrToolbar() conversationsListViewModel.checkIfThreadsExist() + // initialise filter state in ViewModel + getFilterStates() + conversationsListViewModel.applyFilter(filterState) } override fun onPause() { super.onPause() - val firstVisible = layoutManager?.findFirstVisibleItemPosition() ?: 0 - val firstItem = adapter?.getItem(firstVisible) - val firstTop = (firstItem as? ConversationItem)?.mHolder?.itemView?.top - val firstOffset = firstTop?.minus(CONVERSATION_ITEM_HEIGHT) ?: 0 - - appPreferences.setConversationListPositionAndOffset(firstVisible, firstOffset) - } - - // if edge to edge is used, add an empty item at the bottom of the list - @Suppress("MagicNumber") - private fun addEmptyItemForEdgeToEdgeIfNecessary() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { - adapter?.addScrollableFooter(SpacerItem(100)) + conversationListLazyListState?.let { state -> + val firstOffset = state.layoutInfo.visibleItemsInfo.firstOrNull()?.offset ?: 0 + appPreferences.setConversationListPositionAndOffset(state.firstVisibleItemIndex, firstOffset) } } - @Suppress("LongMethod") + @Suppress("LongMethod", "CyclomaticComplexMethod") private fun initObservers() { this.lifecycleScope.launch { networkMonitor.isOnline.onEach { isOnline -> @@ -387,14 +349,6 @@ class ConversationsListActivity : }.collect() } - lifecycleScope.launch { - conversationsListViewModel.searchResultFlow.collect { searchResults -> - if (adapter?.hasFilter() == true) { - adapter?.updateDataSet(searchResults) - } - } - } - conversationsListViewModel.showBadgeViewState.observe(this) { state -> when (state) { is ConversationsListViewModel.ShowBadgeStartState -> { @@ -416,15 +370,12 @@ class ConversationsListActivity : conversationsListViewModel.getRoomsViewState.observe(this) { state -> when (state) { is ConversationsListViewModel.GetRoomsSuccessState -> { - if (adapterWasNull) { - adapterWasNull = false - isShimmerVisibleState.value = false - } + isRefreshingState.value = false initOverallLayout(state.listIsNotEmpty) - binding.swipeRefreshLayoutView.isRefreshing = false } is ConversationsListViewModel.GetRoomsErrorState -> { + isRefreshingState.value = false Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_SHORT).show() } @@ -452,14 +403,15 @@ class ConversationsListActivity : lifecycleScope.launch { conversationsListViewModel.getRoomsFlow .onEach { list -> - setConversationList(list) val noteToSelf = list .firstOrNull { ConversationUtils.isNoteToSelfConversation(it) } val isNoteToSelfAvailable = noteToSelf != null handleNoteToSelfShortcut(isNoteToSelfAvailable, noteToSelf?.token ?: "") val pair = appPreferences.conversationListPositionAndOffset - layoutManager?.scrollToPositionWithOffset(pair.first, pair.second) + lifecycleScope.launch { + conversationListLazyListState?.scrollToItem(pair.first, pair.second) + } }.collect() } @@ -484,7 +436,6 @@ class ConversationsListActivity : lifecycleScope.launch { chatViewModel.backgroundPlayUIFlow.onEach { msg -> binding.composeViewForBackgroundPlay.apply { - // Dispose of the Composition when the view's LifecycleOwner is destroyed setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { msg?.let { @@ -555,159 +506,147 @@ class ConversationsListActivity : } } - private fun setConversationList(list: List) { - // Update Conversations - conversationItems.clear() - conversationItemsWithHeader.clear() - nearFutureEventConversationItems.clear() - - for (conversation in list) { - if (!isFutureEvent(conversation) && !conversation.hasArchived) { - addToNearFutureEventConversationItems(conversation) - } - addToConversationItems(conversation) - } - + fun applyFilter() { getFilterStates() - val noFiltersActive = !( - filterState[MENTION] == true || - filterState[UNREAD] == true || - filterState[ARCHIVE] == true - ) - - sortConversations(conversationItems) - sortConversations(conversationItemsWithHeader) - sortConversations(nearFutureEventConversationItems) + conversationsListViewModel.applyFilter(filterState) + updateFilterConversationButtonColor() + } - if (noFiltersActive && searchBehaviorSubject.value == false) { - adapter?.updateDataSet(nearFutureEventConversationItems, false) - } else { - applyFilter() + private fun hasFilterEnabled(): Boolean { + for ((k, v) in filterState) { + if (k != FilterConversationFragment.DEFAULT && v) return true } - - Handler().postDelayed({ checkToShowUnreadBubble() }, UNREAD_BUBBLE_DELAY.toLong()) + return false } - fun applyFilter() { - if (!hasFilterEnabled()) { - filterableConversationItems = conversationItems - } - filterConversation() - adapter?.updateDataSet(filterableConversationItems, false) + fun showOnlyNearFutureEvents() { + // Reset all filters so the ViewModel's default view (non-archived, non-future-events) is shown + filterState[MENTION] = false + filterState[UNREAD] = false + filterState[ARCHIVE] = false + filterState[FilterConversationFragment.DEFAULT] = true + conversationsListViewModel.applyFilter(filterState) + updateFilterConversationButtonColor() } - private fun hasFilterEnabled(): Boolean { - for ((k, v) in filterState) { - if (k != FilterConversationFragment.DEFAULT && v) return true + private fun setupConversationList() { + binding.conversationListComposeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val colorScheme = remember { viewThemeUtils.getColorScheme(context) } + val entries by conversationsListViewModel.conversationListEntriesFlow + .collectAsStateWithLifecycle() + val isRefreshing by isRefreshingState.collectAsStateWithLifecycle() + val searchQuery by conversationsListViewModel.currentSearchQueryFlow + .collectAsStateWithLifecycle() + val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState() + + // Store reference so Activity can read scroll position in onPause + androidx.compose.runtime.DisposableEffect(lazyListState) { + conversationListLazyListState = lazyListState + onDispose { conversationListLazyListState = null } + } + + MaterialTheme(colorScheme = colorScheme) { + ConversationList( + entries = entries, + isRefreshing = isRefreshing, + currentUser = currentUser!!, + credentials = credentials ?: "", + searchQuery = searchQuery, + onConversationClick = { handleConversation(it) }, + onConversationLongClick = { handleConversationLongClick(it) }, + onMessageResultClick = { showContextChatForMessage(it) }, + onContactClick = { + contactsViewModel.createRoom(ROOM_TYPE_ONE_ONE, null, it.actorId!!, null) + }, + onLoadMoreClick = { conversationsListViewModel.loadMoreMessages(context) }, + onRefresh = { + isMaintenanceModeState.value = false + isRefreshingState.value = true + fetchRooms() + fetchPendingInvitations() + }, + onScrollChanged = { isFabVisibleState.value = !it }, + onScrollStopped = { checkToShowUnreadBubble(it) }, + listState = lazyListState + ) + } + } } - return false + setupToolbarButtons() } - private fun isFutureEvent(conversation: ConversationModel): Boolean { - val eventTimeStart = conversation.objectId - .substringBefore("#") - .toLongOrNull() ?: return false - val currentTimeStampInSeconds = System.currentTimeMillis() / LONG_1000 - val sixteenHoursAfterTimeStamp = (eventTimeStart - currentTimeStampInSeconds) > SIXTEEN_HOURS_IN_SECONDS - return conversation.objectType == ConversationEnums.ObjectType.EVENT && sixteenHoursAfterTimeStamp + private fun handleConversationLongClick(model: ConversationModel) { + lifecycleScope.launch { + if (!showShareToScreen && networkMonitor.isOnline.value) { + conversationsListBottomDialog = ConversationsListBottomDialog( + this@ConversationsListActivity, + currentUser!!, + model + ) + conversationsListBottomDialog!!.show() + } + } } - fun showOnlyNearFutureEvents() { - sortConversations(nearFutureEventConversationItems) - adapter?.updateDataSet(nearFutureEventConversationItems, false) - adapter?.smoothScrollToPosition(0) + private fun showContextChatForMessage(result: SearchMessageEntry) { + binding.genericComposeView.apply { + setContent { + contextChatViewModel.getContextForChatMessages( + credentials = credentials!!, + baseUrl = currentUser!!.baseUrl!!, + token = result.conversationToken, + threadId = result.threadId, + messageId = result.messageId!!, + title = result.title + ) + com.nextcloud.talk.contextchat.ContextChatView(context, contextChatViewModel) + } + } } - private fun addToNearFutureEventConversationItems(conversation: ConversationModel) { - val conversationItem = ConversationItem(conversation, currentUser!!, this, null, viewThemeUtils) - nearFutureEventConversationItems.add(conversationItem) + private fun setupToolbarButtons() { + binding.switchAccountButton.setOnClickListener { + if (resources != null && resources!!.getBoolean(R.bool.multiaccount_support)) { + showChooseAccountDialog() + } else { + startActivity(Intent(context, SettingsActivity::class.java)) + } + } + updateFilterConversationButtonColor() + binding.filterConversationsButton.setOnClickListener { + val newFragment = FilterConversationFragment.newInstance(filterState) + newFragment.show(supportFragmentManager, FilterConversationFragment.TAG) + } + binding.threadsButton.setOnClickListener { openFollowedThreadsOverview() } + viewThemeUtils.platform.colorImageView(binding.threadsButton, ColorRole.ON_SURFACE_VARIANT) } fun getFilterStates() { val accountId = UserIdUtils.getIdForUser(currentUser) filterState[UNREAD] = ( - arbitraryStorageManager.getStorageSetting( - accountId, - UNREAD, - "" - ).blockingGet()?.value ?: "" + arbitraryStorageManager.getStorageSetting(accountId, UNREAD, "").blockingGet()?.value ?: "" ) == "true" - filterState[MENTION] = ( - arbitraryStorageManager.getStorageSetting( - accountId, - MENTION, - "" - ).blockingGet()?.value ?: "" + arbitraryStorageManager.getStorageSetting(accountId, MENTION, "").blockingGet()?.value ?: "" ) == "true" - filterState[ARCHIVE] = ( - arbitraryStorageManager.getStorageSetting( - accountId, - ARCHIVE, - "" - ).blockingGet()?.value ?: "" + arbitraryStorageManager.getStorageSetting(accountId, ARCHIVE, "").blockingGet()?.value ?: "" ) == "true" } fun filterConversation() { + // Delegate to ViewModel; FilterConversationFragment still calls this via cast getFilterStates() - val newItems: MutableList> = ArrayList() - val items = conversationItems - for (i in items) { - val conversation = (i as ConversationItem).model - if (filter(conversation)) { - newItems.add(i) - } - } - val archiveFilterOn = filterState[ARCHIVE] == true - showNoArchivedViewState.value = archiveFilterOn && newItems.isEmpty() - - adapter?.updateDataSet(newItems, true) - setFilterableItems(newItems) - if (archiveFilterOn) { - // Never a notification from archived conversations - showUnreadBubbleState.value = false - } - - layoutManager?.scrollToPositionWithOffset(0, 0) + showNoArchivedViewState.value = archiveFilterOn + conversationsListViewModel.applyFilter(filterState) + if (archiveFilterOn) showUnreadBubbleState.value = false updateFilterConversationButtonColor() } - private fun filter(conversation: ConversationModel): Boolean { - var result = true - for ((k, v) in filterState) { - if (v) { - when (k) { - MENTION -> result = (result && conversation.unreadMention) || - ( - result && - ( - conversation.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL || - conversation.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE - ) && - (conversation.unreadMessages > 0) - ) - - UNREAD -> result = result && (conversation.unreadMessages > 0) - - FilterConversationFragment.DEFAULT -> { - result = if (filterState[ARCHIVE] == true) { - result && conversation.hasArchived - } else { - result && !conversation.hasArchived - } - } - } - } - } - - Log.d(TAG, "Conversation: ${conversation.name} Result: $result") - return result - } - private fun setupActionBar() { setSupportActionBar(binding.conversationListToolbar) binding.conversationListToolbar.setNavigationOnClickListener { @@ -869,7 +808,9 @@ class ConversationsListActivity : override fun onPrepareOptionsMenu(menu: Menu): Boolean { super.onPrepareOptionsMenu(menu) - searchView = MenuItemCompat.getActionView(searchItem) as SearchView + if (searchItem != null) { + searchView = MenuItemCompat.getActionView(searchItem) as SearchView + } val moreAccountsAvailable = userManager.users.blockingGet().size > 1 menu.findItem(R.id.action_choose_account).isVisible = showShareToScreen && moreAccountsAvailable @@ -881,26 +822,28 @@ class ConversationsListActivity : hideSearchBar() supportActionBar?.setTitle(R.string.nc_forward_to_three_dots) } else { - searchItem!!.isVisible = conversationItems.size > 0 - if (adapter?.hasFilter() == true) { + searchItem!!.isVisible = conversationsListViewModel.conversationListEntriesFlow.value.isNotEmpty() + if (searchBehaviorSubject.value == true && searchView != null) { showSearchView(searchView, searchItem) - searchView!!.setQuery(adapter?.getFilter(String::class.java), false) + val savedQuery = conversationsListViewModel.currentSearchQueryFlow.value + if (savedQuery.isNotEmpty()) { + searchView!!.setQuery(savedQuery, false) + } } binding.searchText.setOnClickListener { showSearchView(searchView, searchItem) viewThemeUtils.platform.themeStatusBar(this) } - searchView!!.findViewById(R.id.search_close_btn).setOnClickListener { + searchView?.findViewById(R.id.search_close_btn)?.setOnClickListener { if (TextUtils.isEmpty(searchView!!.query.toString())) { searchView!!.onActionViewCollapsed() viewThemeUtils.platform.resetStatusBar(this) } else { - resetSearchResults() searchView!!.setQuery("", false) } } - searchView!!.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(p0: String?): Boolean { initSearchDisposable() searchView!!.clearFocus() @@ -916,31 +859,17 @@ class ConversationsListActivity : searchItem!!.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { override fun onMenuItemActionExpand(item: MenuItem): Boolean { initSearchDisposable() - adapter?.setHeadersShown(true) - adapter!!.showAllHeaders() - searchableConversationItems.addAll(conversationItemsWithHeader) - if (!hasFilterEnabled()) filterableConversationItems = searchableConversationItems - binding.swipeRefreshLayoutView.isEnabled = false searchBehaviorSubject.onNext(true) + conversationsListViewModel.setIsSearchActive(true) return true } override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - adapter?.setHeadersShown(false) searchBehaviorSubject.onNext(false) - if (!hasFilterEnabled()) filterableConversationItems = conversationItemsWithHeader - if (!hasFilterEnabled()) { - adapter?.updateDataSet(nearFutureEventConversationItems, false) - } else { - filterableConversationItems = conversationItems - } - adapter?.hideAllHeaders() + conversationsListViewModel.setIsSearchActive(false) if (searchHelper != null) { - // cancel any pending searches searchHelper!!.cancelSearch() } - binding.swipeRefreshLayoutView.isRefreshing = false - binding.swipeRefreshLayoutView.isEnabled = true searchView!!.onActionViewCollapsed() binding.conversationListAppbar.stateListAnimator = AnimatorInflater.loadStateListAnimator( @@ -953,8 +882,9 @@ class ConversationsListActivity : viewThemeUtils.platform.resetStatusBar(this@ConversationsListActivity) } - val layoutManager = binding.recyclerView.layoutManager as SmoothScrollLinearLayoutManager? - layoutManager?.scrollToPositionWithOffset(0, 0) + lifecycleScope.launch { + conversationListLazyListState?.scrollToItem(0) + } return true } }) @@ -963,7 +893,7 @@ class ConversationsListActivity : } private fun showSearchOrToolbar() { - if (TextUtils.isEmpty(searchQuery)) { + if (TextUtils.isEmpty(searchQuery) && !conversationsListViewModel.isSearchActiveFlow.value) { if (appBarLayoutType == AppBarLayoutType.SEARCH_BAR) { showSearchBar() } else { @@ -1041,52 +971,6 @@ class ConversationsListActivity : private fun initOverallLayout(isConversationListNotEmpty: Boolean) { isListEmptyState.value = !isConversationListNotEmpty - if (isConversationListNotEmpty) { - if (binding.swipeRefreshLayoutView.visibility != View.VISIBLE) { - binding.swipeRefreshLayoutView.visibility = View.VISIBLE - } - } else { - if (binding.swipeRefreshLayoutView.visibility != View.GONE) { - binding.swipeRefreshLayoutView.visibility = View.GONE - } - } - } - - private fun addToConversationItems(conversation: ConversationModel) { - if (intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM) != null && - intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM) == conversation.token - ) { - return - } - - if (conversation.objectType == ConversationEnums.ObjectType.ROOM && - conversation.lobbyState == ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY - ) { - return - } - - val headerTitle: String = resources!!.getString(R.string.conversations) - val genericTextHeaderItem: GenericTextHeaderItem - if (!callHeaderItems.containsKey(headerTitle)) { - genericTextHeaderItem = GenericTextHeaderItem(headerTitle, viewThemeUtils) - callHeaderItems[headerTitle] = genericTextHeaderItem - } - - val conversationItem = ConversationItem( - conversation, - currentUser!!, - this, - viewThemeUtils - ) - conversationItems.add(conversationItem) - val conversationItemWithHeader = ConversationItem( - conversation, - currentUser!!, - this, - callHeaderItems[headerTitle], - viewThemeUtils - ) - conversationItemsWithHeader.add(conversationItemWithHeader) } private fun showErrorDialog() { @@ -1168,9 +1052,10 @@ class ConversationsListActivity : val colorScheme = remember { viewThemeUtils.getColorScheme(context) } val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() val isFabVisible by isFabVisibleState.collectAsStateWithLifecycle() + val isSearchActive by conversationsListViewModel.isSearchActiveFlow.collectAsStateWithLifecycle() MaterialTheme(colorScheme = colorScheme) { ConversationListFab( - isVisible = isFabVisible, + isVisible = isFabVisible && !isSearchActive, isEnabled = isOnline, onClick = { run(context) @@ -1188,15 +1073,17 @@ class ConversationsListActivity : setContent { val colorScheme = remember { viewThemeUtils.getColorScheme(context) } val showBubble by showUnreadBubbleState.collectAsStateWithLifecycle() + val isSearchActive by conversationsListViewModel.isSearchActiveFlow.collectAsStateWithLifecycle() MaterialTheme(colorScheme = colorScheme) { UnreadMentionBubble( - visible = showBubble, + visible = showBubble && !isSearchActive, onClick = { - val lm = binding.recyclerView.layoutManager as? SmoothScrollLinearLayoutManager - lm?.scrollToPositionWithOffset( - nextUnreadConversationScrollPosition, - binding.recyclerView.height / OFFSET_HEIGHT_DIVIDER - ) + lifecycleScope.launch { + conversationListLazyListState?.scrollToItem( + nextUnreadConversationScrollPosition, + 0 + ) + } showUnreadBubbleState.value = false } ) @@ -1210,7 +1097,7 @@ class ConversationsListActivity : setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { val colorScheme = remember { viewThemeUtils.getColorScheme(context) } - val isShimmerVisible by isShimmerVisibleState.collectAsStateWithLifecycle() + val isShimmerVisible by conversationsListViewModel.isShimmerVisible.collectAsStateWithLifecycle() MaterialTheme(colorScheme = colorScheme) { ConversationListSkeleton(isVisible = isShimmerVisible) } @@ -1223,20 +1110,7 @@ class ConversationsListActivity : binding.searchText.isVisible = show } - private fun sortConversations(conversationItems: MutableList>) { - conversationItems.sortWith { o1: AbstractFlexibleItem<*>, o2: AbstractFlexibleItem<*> -> - val conversation1 = (o1 as ConversationItem).model - val conversation2 = (o2 as ConversationItem).model - CompareToBuilder() - .append(conversation2.favorite, conversation1.favorite) - .append(conversation2.lastActivity, conversation1.lastActivity) - .toComparison() - } - } - private suspend fun fetchOpenConversations(searchTerm: String) { - searchableConversationItems.clear() - searchableConversationItems.addAll(conversationItemsWithHeader) conversationsListViewModel.fetchOpenConversations(searchTerm) } @@ -1266,79 +1140,20 @@ class ConversationsListActivity : @SuppressLint("ClickableViewAccessibility") private fun prepareViews() { isMaintenanceModeState.value = false - - layoutManager = SmoothScrollLinearLayoutManager(this) - binding.recyclerView.layoutManager = layoutManager - binding.recyclerView.setHasFixedSize(true) - binding.recyclerView.adapter = adapter - binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - super.onScrollStateChanged(recyclerView, newState) - if (newState == RecyclerView.SCROLL_STATE_IDLE) { - val isSearchActive = searchBehaviorSubject.value - if (!isSearchActive!!) { - checkToShowUnreadBubble() - } - } - } - - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - if (dy > 0) { - isFabVisibleState.value = false - } else if (dy < 0) { - isFabVisibleState.value = true - } - } - }) - binding.recyclerView.setOnTouchListener { v: View, _: MotionEvent? -> - if (!isDestroyed) { - val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(v.windowToken, 0) - } - false - } - binding.swipeRefreshLayoutView.setOnRefreshListener { - isMaintenanceModeState.value = false - fetchRooms() - fetchPendingInvitations() - } - binding.swipeRefreshLayoutView.let { viewThemeUtils.androidx.themeSwipeRefreshLayout(it) } - - binding.switchAccountButton.setOnClickListener { - if (resources != null && resources!!.getBoolean(R.bool.multiaccount_support)) { - showChooseAccountDialog() - } else { - val intent = Intent(context, SettingsActivity::class.java) - startActivity(intent) - } - } - - updateFilterConversationButtonColor() - - binding.filterConversationsButton.setOnClickListener { - val newFragment = FilterConversationFragment.newInstance(filterState) - newFragment.show(supportFragmentManager, FilterConversationFragment.TAG) - } - - binding.threadsButton.setOnClickListener { - openFollowedThreadsOverview() - } - binding.threadsButton.let { - viewThemeUtils.platform.colorImageView(it, ColorRole.ON_SURFACE_VARIANT) - } + // RecyclerView setup moved to setupConversationList(). + // Only legacy view wiring that hasn't migrated to Compose yet remains here. } @Suppress("Detekt.TooGenericExceptionCaught") - private fun checkToShowUnreadBubble() { - if (searchBehaviorSubject.value == true) { + private fun checkToShowUnreadBubble(lastVisibleIndex: Int) { + if (searchBehaviorSubject.value == true || conversationsListViewModel.isSearchActiveFlow.value) { nextUnreadConversationScrollPosition = 0 showUnreadBubbleState.value = false return } try { - val lastVisibleItem = layoutManager!!.findLastCompletelyVisibleItemPosition() - val firstUnreadPosition = findFirstOffscreenUnreadPosition(lastVisibleItem) + val entries = conversationsListViewModel.conversationListEntriesFlow.value + val firstUnreadPosition = findFirstOffscreenUnreadPosition(entries, lastVisibleIndex) if (firstUnreadPosition != null) { nextUnreadConversationScrollPosition = firstUnreadPosition showUnreadBubbleState.value = true @@ -1346,33 +1161,33 @@ class ConversationsListActivity : nextUnreadConversationScrollPosition = 0 showUnreadBubbleState.value = false } - } catch (e: NullPointerException) { - Log.d( - TAG, - "A NPE was caught when trying to show the unread popup bubble. This might happen when the " + - "user already left the conversations-list screen so the popup bubble is not available " + - "anymore.", - e - ) + } catch (e: Exception) { + Log.d(TAG, "Exception in checkToShowUnreadBubble", e) } } - private fun findFirstOffscreenUnreadPosition(lastVisibleItem: Int): Int? { - for (flexItem in conversationItems) { - val conversation = (flexItem as ConversationItem).model - val position = adapter?.getGlobalPositionOf(flexItem) - if (position != null && hasUnreadItems(conversation) && position > lastVisibleItem) { - return position + private fun findFirstOffscreenUnreadPosition( + entries: List, + lastVisibleIndex: Int + ): Int? { + entries.forEachIndexed { index, entry -> + if (index > lastVisibleIndex && + entry is com.nextcloud.talk.conversationlist.ui.ConversationListEntry.ConversationEntry + ) { + val model = entry.model + if (model.unreadMention || + ( + model.unreadMessages > 0 && + model.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + ) + ) { + return index + } } } return null } - private fun hasUnreadItems(conversation: ConversationModel) = - conversation.unreadMention || - conversation.unreadMessages > 0 && - conversation.type === ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL - private fun showNewConversationsScreen() { val intent = Intent(context, ContactsActivity::class.java) startActivity(intent) @@ -1381,15 +1196,6 @@ class ConversationsListActivity : private fun dispose(disposable: Disposable?) { if (disposable != null && !disposable.isDisposed) { disposable.dispose() - } else if (disposable == null && roomsQueryDisposable != null && !roomsQueryDisposable!!.isDisposed) { - roomsQueryDisposable!!.dispose() - roomsQueryDisposable = null - } else if (disposable == null && - openConversationsQueryDisposable != null && - !openConversationsQueryDisposable!!.isDisposed - ) { - openConversationsQueryDisposable!!.dispose() - openConversationsQueryDisposable = null } } @@ -1422,122 +1228,27 @@ class ConversationsListActivity : val filter = searchQuery searchQuery = "" performFilterAndSearch(filter) - } else if (adapter?.hasNewFilter(newText) == true) { + } else { performFilterAndSearch(newText) } } private fun performFilterAndSearch(filter: String?) { - if (filter!!.length >= SEARCH_MIN_CHARS) { + if (!filter.isNullOrEmpty() && filter.length >= SEARCH_MIN_CHARS) { showNoArchivedViewState.value = false - adapter?.setFilter(filter) conversationsListViewModel.getSearchQuery(context, filter) - } else { - resetSearchResults() } - } - - private fun resetSearchResults() { - adapter?.updateDataSet(conversationItems) - adapter?.setFilter("") - adapter?.filterItems() - val archiveFilterOn = filterState[ARCHIVE] == true - showNoArchivedViewState.value = archiveFilterOn && adapter!!.isEmpty - } - - private fun clearMessageSearchResults() { - val firstHeader = adapter?.getSectionHeader(0) - if (firstHeader != null && firstHeader.itemViewType == MessagesTextHeaderItem.VIEW_TYPE) { - adapter?.removeSection(firstHeader) - } else { - adapter?.removeItemsOfType(MessageResultItem.VIEW_TYPE) - adapter?.removeItemsOfType(MessagesTextHeaderItem.VIEW_TYPE) - } - adapter?.removeItemsOfType(LoadMoreResultsItem.VIEW_TYPE) - } - - @SuppressLint("CheckResult") // handled by helper - private fun startMessageSearch(search: String?) { - binding.swipeRefreshLayoutView.isRefreshing = true - searchHelper?.startMessageSearch(search!!) - ?.subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe({ results: MessageSearchResults -> onMessageSearchResult(results) }) { throwable: Throwable -> - onMessageSearchError( - throwable - ) - } - } - - @SuppressLint("CheckResult") // handled by helper - private fun loadMoreMessages() { - binding.swipeRefreshLayoutView.isRefreshing = true - val observable = searchHelper!!.loadMore() - observable?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe({ results: MessageSearchResults -> - onMessageSearchResult(results) - binding.swipeRefreshLayoutView.isRefreshing = false - }) { throwable: Throwable -> - onMessageSearchError( - throwable - ) - } - } - - override fun onItemClick(view: View, position: Int): Boolean { - val item = adapter?.getItem(position) - if (item != null) { - when (item) { - is MessageResultItem -> { - val token = item.messageEntry.conversationToken - ( - conversationItems.first { - (it is ConversationItem) && it.model.token == token - } as ConversationItem - ).model.displayName - - binding.genericComposeView.apply { - setContent { - contextChatViewModel.getContextForChatMessages( - credentials = credentials!!, - baseUrl = currentUser!!.baseUrl!!, - token = token, - threadId = item.messageEntry.threadId, - messageId = item.messageEntry.messageId!!, - title = item.messageEntry.title - ) - ContextChatView(context, contextChatViewModel) - } - } - } - - is LoadMoreResultsItem -> { - conversationsListViewModel.loadMoreMessages(context) - } - - is ConversationItem -> { - handleConversation(item.model) - } - - is ContactItem -> { - contactsViewModel.createRoom( - ROOM_TYPE_ONE_ONE, - null, - item.model.actorId!!, - null - ) - } - } - } - return true + // Query too short: search results remain empty, ViewModel shows regular list } private fun showConversationByToken(conversationToken: String) { - for (absItem in conversationItems) { - val conversationItem = absItem as ConversationItem - if (conversationItem.model.token == conversationToken) { - val conversation = conversationItem.model - handleConversation(conversation) + val entries = conversationsListViewModel.conversationListEntriesFlow.value + for (entry in entries) { + if (entry is com.nextcloud.talk.conversationlist.ui.ConversationListEntry.ConversationEntry && + entry.model.token == conversationToken + ) { + handleConversation(entry.model) + return } } } @@ -1646,72 +1357,56 @@ class ConversationsListActivity : intent.action = "" } - override fun onItemLongClick(position: Int) { - this.lifecycleScope.launch { - if (showShareToScreen || !networkMonitor.isOnline.value) { - Log.d(TAG, "sharing to multiple rooms not yet implemented. onItemLongClick is ignored.") + @Suppress("Detekt.TooGenericExceptionCaught") + private fun collectDataFromIntent() { + filesToShare = ArrayList() + val intentAction = intent?.action ?: return + if (intentAction != Intent.ACTION_SEND && intentAction != Intent.ACTION_SEND_MULTIPLE) return + try { + val mimeType = intent.type + if (Mimetype.TEXT_PLAIN == mimeType && intent.getStringExtra(Intent.EXTRA_TEXT) != null) { + // Share from Google Chrome sets text/plain MIME type, but also provides a content:// URI + // with a *screenshot* of the current page in getClipData(). + // Here we assume that when sharing a web page the user would prefer to send the URL + // of the current page rather than a screenshot. + textToPaste = intent.getStringExtra(Intent.EXTRA_TEXT) } else { - val clickedItem: Any? = adapter?.getItem(position) - if (clickedItem != null && clickedItem is ConversationItem) { - val conversation = clickedItem.model - conversationsListBottomDialog = ConversationsListBottomDialog( - this@ConversationsListActivity, - currentUser!!, - conversation - ) - conversationsListBottomDialog!!.show() - } + extractFilesFromClipData() + } + if (filesToShare!!.isEmpty() && textToPaste!!.isEmpty()) { + Snackbar.make( + binding.root, + context.resources.getString(R.string.nc_common_error_sorry), + Snackbar.LENGTH_LONG + ).show() + Log.e(TAG, "failed to get data from intent") } + } catch (e: Exception) { + Snackbar.make( + binding.root, + context.resources.getString(R.string.nc_common_error_sorry), + Snackbar.LENGTH_LONG + ).show() + Log.e(TAG, "Something went wrong when extracting data from intent") } } - @Suppress("Detekt.TooGenericExceptionCaught") - private fun collectDataFromIntent() { - filesToShare = ArrayList() - if (intent != null) { - if (Intent.ACTION_SEND == intent.action || Intent.ACTION_SEND_MULTIPLE == intent.action) { - try { - val mimeType = intent.type - if (Mimetype.TEXT_PLAIN == mimeType && intent.getStringExtra(Intent.EXTRA_TEXT) != null) { - // Share from Google Chrome sets text/plain MIME type, but also provides a content:// URI - // with a *screenshot* of the current page in getClipData(). - // Here we assume that when sharing a web page the user would prefer to send the URL - // of the current page rather than a screenshot. - textToPaste = intent.getStringExtra(Intent.EXTRA_TEXT) - } else { - if (intent.clipData != null) { - for (i in 0 until intent.clipData!!.itemCount) { - val item = intent.clipData!!.getItemAt(i) - if (item.uri != null) { - filesToShare!!.add(item.uri.toString()) - } else if (item.text != null) { - textToPaste = item.text.toString() - break - } else { - Log.w(TAG, "datatype not yet implemented for share-to") - } - } - } else { - filesToShare!!.add(intent.data.toString()) - } + private fun extractFilesFromClipData() { + val clipData = intent.clipData + if (clipData != null) { + for (i in 0 until clipData.itemCount) { + val item = clipData.getItemAt(i) + when { + item.uri != null -> filesToShare!!.add(item.uri.toString()) + item.text != null -> { + textToPaste = item.text.toString() + return } - if (filesToShare!!.isEmpty() && textToPaste!!.isEmpty()) { - Snackbar.make( - binding.root, - context.resources.getString(R.string.nc_common_error_sorry), - Snackbar.LENGTH_LONG - ).show() - Log.e(TAG, "failed to get data from intent") - } - } catch (e: Exception) { - Snackbar.make( - binding.root, - context.resources.getString(R.string.nc_common_error_sorry), - Snackbar.LENGTH_LONG - ).show() - Log.e(TAG, "Something went wrong when extracting data from intent") + else -> Log.w(TAG, "datatype not yet implemented for share-to") } } + } else { + filesToShare!!.add(intent.data.toString()) } } @@ -1915,7 +1610,7 @@ class ConversationsListActivity : fun onMessageEvent(eventStatus: EventStatus) { if (currentUser != null && eventStatus.userId == currentUser!!.id) { when (eventStatus.eventType) { - EventStatus.EventType.CONVERSATION_UPDATE -> if (eventStatus.isAllGood && !isRefreshing) { + EventStatus.EventType.CONVERSATION_UPDATE -> if (eventStatus.isAllGood && !isRefreshingState.value) { fetchRooms() } @@ -2155,49 +1850,6 @@ class ConversationsListActivity : } } - private fun onMessageSearchResult(results: MessageSearchResults) { - if (searchView!!.query.isNotEmpty()) { - clearMessageSearchResults() - val entries = results.messages - if (entries.isNotEmpty()) { - val adapterItems: MutableList> = ArrayList(entries.size + 1) - - for (i in entries.indices) { - val showHeader = i == 0 - adapterItems.add( - MessageResultItem( - context, - currentUser!!, - entries[i], - showHeader, - viewThemeUtils = viewThemeUtils - ) - ) - } - - if (results.hasMore) { - adapterItems.add(LoadMoreResultsItem) - } - - adapter?.addItems(Int.MAX_VALUE, adapterItems) - } - } - } - - private fun onMessageSearchError(throwable: Throwable) { - handleHttpExceptions(throwable) - binding.swipeRefreshLayoutView.isRefreshing = false - } - - fun updateFilterState(mention: Boolean, unread: Boolean) { - filterState[MENTION] = mention - filterState[UNREAD] = unread - } - - fun setFilterableItems(items: MutableList>) { - filterableConversationItems = items - } - fun updateFilterConversationButtonColor() { if (hasFilterEnabled()) { binding.filterConversationsButton.let { viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt new file mode 100644 index 00000000000..4b24fdb400c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt @@ -0,0 +1,316 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.conversationlist.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import coil.transform.CircleCropTransformation +import com.nextcloud.talk.R +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.domain.SearchMessageEntry +import com.nextcloud.talk.models.json.participants.Participant +import com.nextcloud.talk.utils.ApiUtils + +private const val MSG_KEY_EXCERPT_LENGTH = 20 + +/** + * The full conversation list: pull-to-refresh + LazyColumn. + * Replaces RecyclerView + FlexibleAdapter + SwipeRefreshLayout. + */ +@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConversationList( + entries: List, + isRefreshing: Boolean, + currentUser: User, + credentials: String, + onConversationClick: (ConversationModel) -> Unit, + onConversationLongClick: (ConversationModel) -> Unit, + onMessageResultClick: (SearchMessageEntry) -> Unit, + onContactClick: (Participant) -> Unit, + onLoadMoreClick: () -> Unit, + onRefresh: () -> Unit, + searchQuery: String = "", + /** Called whenever scroll direction changes; true = scrolled down, false = scrolled up. */ + onScrollChanged: (scrolledDown: Boolean) -> Unit = {}, + /** Called when the list stops scrolling; delivers the last-visible item index. */ + onScrollStopped: (lastVisibleIndex: Int) -> Unit = {}, + listState: LazyListState = rememberLazyListState() +) { + var prevIndex by remember { mutableIntStateOf(listState.firstVisibleItemIndex) } + var prevOffset by remember { mutableIntStateOf(listState.firstVisibleItemScrollOffset) } + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } + .collect { (index, offset) -> + if (index != prevIndex || offset != prevOffset) { + val scrolledDown = index > prevIndex || (index == prevIndex && offset > prevOffset) + onScrollChanged(scrolledDown) + prevIndex = index + prevOffset = offset + } + } + } + + // Unread-bubble: notify Activity when scrolling stops + LaunchedEffect(listState) { + snapshotFlow { listState.isScrollInProgress } + .collect { isScrolling -> + if (!isScrolling) { + val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + onScrollStopped(lastVisible) + } + } + } + + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = onRefresh, + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize() + ) { + items( + items = entries, + key = { entry -> + when (entry) { + is ConversationListEntry.Header -> + "header_${entry.title}" + is ConversationListEntry.ConversationEntry -> + "conv_${entry.model.token}" + is ConversationListEntry.MessageResultEntry -> + "msg_${entry.result.conversationToken}_" + + "${entry.result.messageId ?: entry.result.messageExcerpt.take(MSG_KEY_EXCERPT_LENGTH)}" + is ConversationListEntry.ContactEntry -> + "contact_${entry.participant.actorId}_${entry.participant.actorType}" + ConversationListEntry.LoadMore -> + "load_more" + } + } + ) { entry -> + when (entry) { + is ConversationListEntry.Header -> + ConversationSectionHeader(title = entry.title) + + is ConversationListEntry.ConversationEntry -> + ConversationListItem( + model = entry.model, + currentUser = currentUser, + callbacks = ConversationListItemCallbacks( + onClick = { onConversationClick(entry.model) }, + onLongClick = { onConversationLongClick(entry.model) } + ), + searchQuery = searchQuery + ) + + is ConversationListEntry.MessageResultEntry -> + MessageResultListItem( + result = entry.result, + credentials = credentials, + onClick = { onMessageResultClick(entry.result) } + ) + + is ConversationListEntry.ContactEntry -> + ContactResultListItem( + participant = entry.participant, + currentUser = currentUser, + credentials = credentials, + searchQuery = searchQuery, + onClick = { onContactClick(entry.participant) } + ) + + ConversationListEntry.LoadMore -> + LoadMoreListItem(onClick = onLoadMoreClick) + } + } + } + } +} + +@Composable +private fun ConversationSectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) +} + +@Composable +private fun MessageResultListItem(result: SearchMessageEntry, credentials: String, onClick: () -> Unit) { + val primaryColor = MaterialTheme.colorScheme.primary + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(result.thumbnailURL) + .addHeader("Authorization", credentials) + .crossfade(true) + .transformations(CircleCropTransformation()) + .build(), + contentDescription = null, + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + placeholder = painterResource(R.drawable.ic_user), + error = painterResource(R.drawable.ic_user) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = buildHighlightedText(result.title, result.searchTerm, primaryColor), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = colorResource(R.color.conversation_item_header) + ) + Text( + text = buildHighlightedText(result.messageExcerpt, result.searchTerm, primaryColor), + style = MaterialTheme.typography.bodyMedium, + color = colorResource(R.color.textColorMaxContrast), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +internal fun buildHighlightedText(text: String, searchTerm: String, highlightColor: Color): AnnotatedString = + buildAnnotatedString { + if (searchTerm.isBlank()) { + append(text) + return@buildAnnotatedString + } + val lowerText = text.lowercase() + val lowerTerm = searchTerm.lowercase() + var lastIndex = 0 + var matchIndex = lowerText.indexOf(lowerTerm, lastIndex) + while (matchIndex != -1) { + append(text.substring(lastIndex, matchIndex)) + withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = highlightColor)) { + append(text.substring(matchIndex, matchIndex + searchTerm.length)) + } + lastIndex = matchIndex + searchTerm.length + matchIndex = lowerText.indexOf(lowerTerm, lastIndex) + } + append(text.substring(lastIndex)) + } + +@Composable +private fun ContactResultListItem( + participant: Participant, + currentUser: User, + credentials: String, + searchQuery: String, + onClick: () -> Unit +) { + val primaryColor = MaterialTheme.colorScheme.primary + val avatarUrl = remember(currentUser.baseUrl, participant.actorId) { + ApiUtils.getUrlForAvatar(currentUser.baseUrl, participant.actorId, false) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(avatarUrl) + .addHeader("Authorization", credentials) + .crossfade(true) + .transformations(CircleCropTransformation()) + .build(), + contentDescription = participant.displayName, + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + placeholder = painterResource(R.drawable.ic_user), + error = painterResource(R.drawable.ic_user) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = buildHighlightedText(participant.displayName ?: "", searchQuery, primaryColor), + style = MaterialTheme.typography.bodyLarge, + color = colorResource(R.color.conversation_item_header), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun LoadMoreListItem(onClick: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.load_more_results), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListEntry.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListEntry.kt new file mode 100644 index 00000000000..d60bbab6dbe --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListEntry.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationlist.ui + +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.domain.SearchMessageEntry +import com.nextcloud.talk.models.json.participants.Participant + +/** + * Sealed class that represents every possible entry in the conversation list LazyColumn. + */ +sealed class ConversationListEntry { + /** Section header (e.g. "Conversations", "Users", "Messages") */ + data class Header(val title: String) : ConversationListEntry() + + /** A single conversation item */ + data class ConversationEntry(val model: ConversationModel) : ConversationListEntry() + + /** A message search result */ + data class MessageResultEntry(val result: SearchMessageEntry) : ConversationListEntry() + + /** A contact / user search result */ + data class ContactEntry(val participant: Participant) : ConversationListEntry() + + /** "Load more" button at the end of message search results */ + data object LoadMore : ConversationListEntry() +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt index 9b1126292aa..eb96487b87e 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt @@ -16,6 +16,7 @@ import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button @@ -31,10 +32,12 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.nextcloud.talk.R private const val DISABLED_ALPHA = 0.38f private const val FAB_ANIM_DURATION = 200 +private const val UNREAD_MENTIONS_HORIZONTAL_SPACING = 88 @Composable fun ConversationListFab(isVisible: Boolean, isEnabled: Boolean, onClick: () -> Unit) { @@ -64,6 +67,7 @@ fun UnreadMentionBubble(visible: Boolean, onClick: () -> Unit) { ) { Button( onClick = onClick, + modifier = Modifier.padding(horizontal = UNREAD_MENTIONS_HORIZONTAL_SPACING.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary ), diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt index c85f29ee90d..8a978963239 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt @@ -1,10 +1,12 @@ -/* +/* * Nextcloud Talk - Android Client * * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ +@file:Suppress("TooManyFunctions") + package com.nextcloud.talk.conversationlist.ui import android.content.res.Configuration @@ -21,6 +23,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -40,7 +43,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.colorResource @@ -55,12 +60,13 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView -import androidx.compose.ui.graphics.toArgb import coil.compose.AsyncImage +import coil.request.ImageRequest import com.nextcloud.talk.R import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.data.database.mappers.asModel import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.json.chat.ChatUtils import com.nextcloud.talk.extensions.loadNoteToSelfAvatar import com.nextcloud.talk.extensions.loadSystemAvatar import com.nextcloud.talk.models.MessageDraft @@ -74,8 +80,6 @@ import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.SpreedFeatures -// ── Constants ───────────────────────────────────────────────────────────────── - private const val AVATAR_SIZE_DP = 48 private const val FAVORITE_OVERLAY_SIZE_DP = 16 private const val STATUS_OVERLAY_SIZE_DP = 18 @@ -86,8 +90,7 @@ private const val ICON_MSG_SIZE_DP = 14 private const val ICON_MSG_SPACING_DP = 2 private const val UNREAD_THRESHOLD = 1000 private const val UNREAD_BUBBLE_STROKE_DP = 1.5f - -// ── Avatar content model ─────────────────────────────────────────────────────── +private const val MILLIS_PER_SECOND = 1_000L private sealed class AvatarContent { data class Url(val url: String) : AvatarContent() @@ -96,10 +99,8 @@ private sealed class AvatarContent { object NoteToSelf : AvatarContent() } -@Composable -private fun buildAvatarContent(model: ConversationModel, currentUser: User): AvatarContent { - val context = LocalContext.current - val isDark = DisplayUtils.isDarkModeOn(context) +private fun buildAvatarContent(model: ConversationModel, currentUser: User, isDark: Boolean): AvatarContent { + val avatarVersion = model.avatarVersion.takeIf { it.isNotEmpty() } return when { model.objectType == ConversationEnums.ObjectType.SHARE_PASSWORD -> AvatarContent.Res(R.drawable.ic_circular_lock) @@ -123,22 +124,23 @@ private fun buildAvatarContent(model: ConversationModel, currentUser: User): Ava currentUser.baseUrl, model.token, isDark, - model.avatarVersion.takeIf { it.isNotEmpty() } + avatarVersion ) ) } } -// ── Main Composable ──────────────────────────────────────────────────────────── +/** Groups the tap callbacks for [ConversationListItem] to keep the parameter count low. */ +data class ConversationListItemCallbacks(val onClick: () -> Unit, val onLongClick: () -> Unit) @OptIn(ExperimentalFoundationApi::class) @Composable fun ConversationListItem( model: ConversationModel, currentUser: User, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier = Modifier + callbacks: ConversationListItemCallbacks, + modifier: Modifier = Modifier, + searchQuery: String = "" ) { val chatMessage = remember(model.lastMessage, currentUser) { model.lastMessage?.asModel()?.also { it.activeUser = currentUser } @@ -147,10 +149,10 @@ fun ConversationListItem( Row( modifier = modifier .fillMaxWidth() - .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .combinedClickable(onClick = callbacks.onClick, onLongClick = callbacks.onLongClick) .padding( horizontal = 16.dp, - vertical = 8.dp + vertical = 16.dp ), verticalAlignment = Alignment.Top ) { @@ -166,7 +168,7 @@ fun ConversationListItem( if (model.hasSensitive) { SensitiveContent(model = model, currentUser = currentUser) } else { - FullContent(model = model, currentUser = currentUser, chatMessage = chatMessage) + FullContent(model = model, currentUser = currentUser, chatMessage = chatMessage, searchQuery = searchQuery) } } } @@ -198,23 +200,23 @@ private fun RowScope.SensitiveContent(model: ConversationModel, currentUser: Use private fun RowScope.FullContent( model: ConversationModel, currentUser: User, - chatMessage: ChatMessage? + chatMessage: ChatMessage?, + searchQuery: String = "" ) { Column(modifier = Modifier.weight(1f)) { - ConversationNameRow(model = model, chatMessage = chatMessage) + ConversationNameRow(model = model, chatMessage = chatMessage, searchQuery = searchQuery) Spacer(Modifier.height(4.dp)) - ConversationLastMessageRow(model = model, currentUser = currentUser, chatMessage = chatMessage) + ConversationLastMessageRow( + model = model, + currentUser = currentUser, + chatMessage = chatMessage, + searchQuery = searchQuery + ) } } -// ── Sub-Composables ──────────────────────────────────────────────────────────── - @Composable -private fun ConversationAvatar( - model: ConversationModel, - currentUser: User, - modifier: Modifier = Modifier -) { +private fun ConversationAvatar(model: ConversationModel, currentUser: User, modifier: Modifier = Modifier) { Box(modifier = modifier) { ConversationAvatarImage( model = model, @@ -258,32 +260,53 @@ private fun ConversationAvatar( } } +@Suppress("LongMethod") @Composable -private fun ConversationAvatarImage( - model: ConversationModel, - currentUser: User, - modifier: Modifier = Modifier -) { +private fun ConversationAvatarImage(model: ConversationModel, currentUser: User, modifier: Modifier = Modifier) { val isInPreview = LocalInspectionMode.current - val avatarContent = buildAvatarContent(model = model, currentUser = currentUser) + val context = LocalContext.current + val isDark = LocalConfiguration.current.uiMode and Configuration.UI_MODE_NIGHT_MASK == + Configuration.UI_MODE_NIGHT_YES + val credentials = remember(currentUser.id) { + ApiUtils.getCredentials(currentUser.username, currentUser.token) ?: "" + } + val avatarContent = buildAvatarContent(model = model, currentUser = currentUser, isDark = isDark) when (avatarContent) { is AvatarContent.Url -> { - AsyncImage( - model = avatarContent.url, - contentDescription = stringResource(R.string.avatar), - contentScale = ContentScale.Crop, - modifier = modifier - ) + if (isInPreview) { + Box(modifier = modifier.background(Color.LightGray)) + } else { + val request = remember(avatarContent.url, credentials) { + ImageRequest.Builder(context) + .data(avatarContent.url) + .diskCacheKey("${avatarContent.url}#v2") + .addHeader("Authorization", credentials) + .crossfade(true) + .build() + } + AsyncImage( + model = request, + contentDescription = stringResource(R.string.avatar), + contentScale = ContentScale.Crop, + placeholder = painterResource(R.drawable.account_circle_96dp), + error = painterResource(R.drawable.account_circle_96dp), + modifier = modifier + ) + } } is AvatarContent.Res -> { - AsyncImage( - model = avatarContent.resId, - contentDescription = stringResource(R.string.avatar), - contentScale = ContentScale.Crop, - modifier = modifier - ) + if (isInPreview) { + Box(modifier = modifier.background(Color.LightGray)) + } else { + AsyncImage( + model = avatarContent.resId, + contentDescription = stringResource(R.string.avatar), + contentScale = ContentScale.Crop, + modifier = modifier + ) + } } AvatarContent.System -> { @@ -417,25 +440,34 @@ private fun PublicBadgeOverlay(model: ConversationModel, modifier: Modifier = Mo else -> null } ?: return - Icon( - painter = painterResource(badgeRes), - contentDescription = stringResource(R.string.nc_public_call_status), - tint = colorResource(R.color.no_emphasis_text), + Box( + contentAlignment = Alignment.Center, modifier = modifier - ) + .background(MaterialTheme.colorScheme.surface, CircleShape) + ) { + Icon( + painter = painterResource(badgeRes), + contentDescription = stringResource(R.string.nc_public_call_status), + tint = colorResource(R.color.no_emphasis_text), + modifier = Modifier + .padding(1.dp) + .fillMaxSize() + ) + } } @Composable -private fun ConversationNameRow(model: ConversationModel, chatMessage: ChatMessage?) { +private fun ConversationNameRow(model: ConversationModel, chatMessage: ChatMessage?, searchQuery: String = "") { val hasDraft = model.messageDraft?.messageText?.isNotBlank() == true val showDate = chatMessage != null || hasDraft + val primaryColor = MaterialTheme.colorScheme.primary Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { Text( - text = model.displayName, + text = buildHighlightedText(model.displayName, searchQuery, primaryColor), modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -448,7 +480,7 @@ private fun ConversationNameRow(model: ConversationModel, chatMessage: ChatMessa val dateText = remember(model.lastActivity) { if (model.lastActivity > 0L) { DateUtils.getRelativeTimeSpanString( - model.lastActivity * 1_000L, + model.lastActivity * MILLIS_PER_SECOND, System.currentTimeMillis(), 0L, DateUtils.FORMAT_ABBREV_RELATIVE @@ -471,7 +503,8 @@ private fun ConversationNameRow(model: ConversationModel, chatMessage: ChatMessa private fun ConversationLastMessageRow( model: ConversationModel, currentUser: User, - chatMessage: ChatMessage? + chatMessage: ChatMessage?, + searchQuery: String = "" ) { Row( modifier = Modifier.fillMaxWidth(), @@ -481,6 +514,7 @@ private fun ConversationLastMessageRow( model = model, currentUser = currentUser, chatMessage = chatMessage, + searchQuery = searchQuery, modifier = Modifier.weight(1f) ) Spacer(Modifier.width(4.dp)) @@ -488,8 +522,6 @@ private fun ConversationLastMessageRow( } } -// ── Unread Bubble ───────────────────────────────────────────────────────────── - @Composable private fun UnreadBubble(model: ConversationModel, currentUser: User) { if (model.unreadMessages <= 0) return @@ -565,22 +597,22 @@ private fun GreyUnreadChip(text: String) { } } -// ── Last Message Content ─────────────────────────────────────────────────────── - +@Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") @Composable private fun LastMessageContent( model: ConversationModel, currentUser: User, chatMessage: ChatMessage?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + searchQuery: String = "" ) { val isBold = model.unreadMessages > 0 val fontWeight = if (isBold) FontWeight.Bold else FontWeight.Normal + val primaryColor = MaterialTheme.colorScheme.primary - // ── Case 1: Draft ───────────────────────────────────────────────────────── + // Draft val draftText = model.messageDraft?.messageText?.takeIf { it.isNotBlank() } if (draftText != null) { - val primaryColor = MaterialTheme.colorScheme.primary val draftPrefixTemplate = stringResource(R.string.nc_draft_prefix) val fullLabel = remember(draftText, draftPrefixTemplate) { String.format(draftPrefixTemplate, draftText) @@ -590,7 +622,7 @@ private fun LastMessageContent( withStyle(SpanStyle(color = primaryColor, fontWeight = FontWeight.Bold)) { append(fullLabel.substring(0, prefixEnd)) } - append(draftText) + append(buildHighlightedText(draftText, searchQuery, primaryColor)) } Text( text = annotated, @@ -598,21 +630,26 @@ private fun LastMessageContent( maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, - fontWeight = fontWeight + fontWeight = fontWeight, + color = colorResource(R.color.textColorMaxContrast) ) return } - // ── Case 2: No message ──────────────────────────────────────────────────── + // No message if (chatMessage == null) { Text(text = "", modifier = modifier) return } - // ── Case 3: Deleted comment ─────────────────────────────────────────────── + // Deleted comment if (chatMessage.isDeletedCommentMessage) { Text( - text = chatMessage.message ?: "", + text = buildHighlightedText( + ChatUtils.getParsedMessage(chatMessage.message, chatMessage.messageParameters) ?: "", + searchQuery, + primaryColor + ), modifier = modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -624,27 +661,36 @@ private fun LastMessageContent( val msgType = chatMessage.getCalculateMessageType() - // ── Case 4: System message ──────────────────────────────────────────────── + // System message if (msgType == ChatMessage.MessageType.SYSTEM_MESSAGE || model.type == ConversationEnums.ConversationType.ROOM_SYSTEM ) { + val parsedText = ChatUtils.getParsedMessage(chatMessage.message, chatMessage.messageParameters) ?: "" Text( - text = chatMessage.message ?: "", + text = buildHighlightedText(parsedText, searchQuery, primaryColor), modifier = modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, - fontWeight = fontWeight + fontWeight = fontWeight, + color = colorResource(R.color.textColorMaxContrast) ) return } - // ── Case 5: Attachment / special message types ──────────────────────────── + // Attachment / special message types when (msgType) { ChatMessage.MessageType.VOICE_MESSAGE -> { val name = chatMessage.messageParameters?.get("file")?.get("name") ?: "" val prefix = authorPrefix(chatMessage, currentUser) - AttachmentRow(prefix, R.drawable.baseline_mic_24, name, fontWeight, modifier) + AttachmentRow( + authorPrefix = prefix, + iconRes = R.drawable.baseline_mic_24, + name = name, + fontWeight = fontWeight, + modifier = modifier, + searchQuery = searchQuery + ) return } @@ -654,28 +700,56 @@ private fun LastMessageContent( val mime = chatMessage.messageParameters?.get("file")?.get("mimetype") val icon = attachmentIconRes(mime) val prefix = authorPrefix(chatMessage, currentUser) - AttachmentRow(prefix, icon, name, fontWeight, modifier) + AttachmentRow( + authorPrefix = prefix, + iconRes = icon, + name = name, + fontWeight = fontWeight, + modifier = modifier, + searchQuery = searchQuery + ) return } ChatMessage.MessageType.SINGLE_NC_GEOLOCATION_MESSAGE -> { val name = chatMessage.messageParameters?.get("object")?.get("name") ?: "" val prefix = authorPrefix(chatMessage, currentUser) - AttachmentRow(prefix, R.drawable.baseline_location_pin_24, name, fontWeight, modifier) + AttachmentRow( + authorPrefix = prefix, + iconRes = R.drawable.baseline_location_pin_24, + name = name, + fontWeight = fontWeight, + modifier = modifier, + searchQuery = searchQuery + ) return } ChatMessage.MessageType.POLL_MESSAGE -> { val name = chatMessage.messageParameters?.get("object")?.get("name") ?: "" val prefix = authorPrefix(chatMessage, currentUser) - AttachmentRow(prefix, R.drawable.baseline_bar_chart_24, name, fontWeight, modifier) + AttachmentRow( + authorPrefix = prefix, + iconRes = R.drawable.baseline_bar_chart_24, + name = name, + fontWeight = fontWeight, + modifier = modifier, + searchQuery = searchQuery + ) return } ChatMessage.MessageType.DECK_CARD -> { val name = chatMessage.messageParameters?.get("object")?.get("name") ?: "" val prefix = authorPrefix(chatMessage, currentUser) - AttachmentRow(prefix, R.drawable.baseline_article_24, name, fontWeight, modifier) + AttachmentRow( + authorPrefix = prefix, + iconRes = R.drawable.baseline_article_24, + name = name, + fontWeight = fontWeight, + modifier = modifier, + searchQuery = searchQuery + ) return } @@ -686,8 +760,12 @@ private fun LastMessageContent( val gifOther = stringResource(R.string.nc_sent_a_gif, chatMessage.actorDisplayName ?: "") Text( text = if (chatMessage.actorId == currentUser.userId) gifSelf else gifOther, - modifier = modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium, fontWeight = fontWeight + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + fontWeight = fontWeight, + color = colorResource(R.color.textColorMaxContrast) ) return } @@ -697,8 +775,12 @@ private fun LastMessageContent( val imgOther = stringResource(R.string.nc_sent_an_image, chatMessage.actorDisplayName ?: "") Text( text = if (chatMessage.actorId == currentUser.userId) imgSelf else imgOther, - modifier = modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium, fontWeight = fontWeight + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + fontWeight = fontWeight, + color = colorResource(R.color.textColorMaxContrast) ) return } @@ -708,8 +790,12 @@ private fun LastMessageContent( val vidOther = stringResource(R.string.nc_sent_a_video, chatMessage.actorDisplayName ?: "") Text( text = if (chatMessage.actorId == currentUser.userId) vidSelf else vidOther, - modifier = modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium, fontWeight = fontWeight + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + fontWeight = fontWeight, + color = colorResource(R.color.textColorMaxContrast) ) return } @@ -719,8 +805,12 @@ private fun LastMessageContent( val audOther = stringResource(R.string.nc_sent_an_audio, chatMessage.actorDisplayName ?: "") Text( text = if (chatMessage.actorId == currentUser.userId) audSelf else audOther, - modifier = modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium, fontWeight = fontWeight + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + fontWeight = fontWeight, + color = colorResource(R.color.textColorMaxContrast) ) return } @@ -728,14 +818,14 @@ private fun LastMessageContent( else -> { /* fall through to regular text */ } } - // ── Case 6: Regular text message ───────────────────────────────────────── - val rawText = chatMessage.message ?: "" - val youPrefix = stringResource(R.string.nc_formatted_message_you, rawText) + // Regular text message + val parsedText = ChatUtils.getParsedMessage(chatMessage.message, chatMessage.messageParameters) ?: "" + val youPrefix = stringResource(R.string.nc_formatted_message_you, parsedText) val groupFormat = stringResource(R.string.nc_formatted_message) val guestLabel = stringResource(R.string.nc_guest) val displayText = when { chatMessage.actorId == currentUser.userId -> youPrefix - model.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> rawText + model.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> parsedText else -> { val actorName = chatMessage.actorDisplayName?.takeIf { it.isNotBlank() } ?: if (chatMessage.actorType == "guests" || chatMessage.actorType == "emails") { @@ -743,34 +833,39 @@ private fun LastMessageContent( } else { "" } - String.format(groupFormat, actorName, rawText) + String.format(groupFormat, actorName, parsedText) } } Text( - text = displayText, + text = buildHighlightedText(displayText, searchQuery, primaryColor), modifier = modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, - fontWeight = fontWeight + fontWeight = fontWeight, + color = colorResource(R.color.textColorMaxContrast) ) } +@Suppress("LongParameterList") @Composable private fun AttachmentRow( authorPrefix: String, @DrawableRes iconRes: Int?, name: String, fontWeight: FontWeight, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + searchQuery: String = "" ) { + val primaryColor = MaterialTheme.colorScheme.primary Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { if (authorPrefix.isNotBlank()) { Text( text = "$authorPrefix ", style = MaterialTheme.typography.bodyMedium, fontWeight = fontWeight, - maxLines = 1 + maxLines = 1, + color = colorResource(R.color.textColorMaxContrast) ) } if (iconRes != null) { @@ -783,18 +878,17 @@ private fun AttachmentRow( Spacer(Modifier.width(ICON_MSG_SPACING_DP.dp)) } Text( - text = name, + text = buildHighlightedText(name, searchQuery, primaryColor), style = MaterialTheme.typography.bodyMedium, fontWeight = fontWeight, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + color = colorResource(R.color.textColorMaxContrast) ) } } -// ── Pure Helper Functions ────────────────────────────────────────────────────── - private fun authorPrefix(chatMessage: ChatMessage, currentUser: User): String = if (chatMessage.actorId == currentUser.userId) { "You:" @@ -803,27 +897,27 @@ private fun authorPrefix(chatMessage: ChatMessage, currentUser: User): String = if (!name.isNullOrBlank()) "$name:" else "" } -private fun attachmentIconRes(mimetype: String?): Int? = when { - mimetype == null -> null - mimetype.contains("image") -> R.drawable.baseline_image_24 - mimetype.contains("video") -> R.drawable.baseline_video_24 - mimetype.contains("application") -> R.drawable.baseline_insert_drive_file_24 - mimetype.contains("audio") -> R.drawable.baseline_audiotrack_24 - mimetype.contains("text/vcard") -> R.drawable.baseline_contacts_24 - else -> null -} - -// ── Preview Helpers ──────────────────────────────────────────────────────────── +private fun attachmentIconRes(mimetype: String?): Int? = + when { + mimetype == null -> null + mimetype.contains("image") -> R.drawable.baseline_image_24 + mimetype.contains("video") -> R.drawable.baseline_video_24 + mimetype.contains("application") -> R.drawable.baseline_insert_drive_file_24 + mimetype.contains("audio") -> R.drawable.baseline_audiotrack_24 + mimetype.contains("text/vcard") -> R.drawable.baseline_contacts_24 + else -> null + } -private fun previewUser(userId: String = "user1") = User( - id = 1L, - userId = userId, - username = userId, - baseUrl = "https://cloud.example.com", - token = "token", - displayName = "Test User", - capabilities = null -) +private fun previewUser(userId: String = "user1") = + User( + id = 1L, + userId = userId, + username = userId, + baseUrl = "https://cloud.example.com", + token = "token", + displayName = "Test User", + capabilities = null + ) @Suppress("LongParameterList") private fun previewModel( @@ -884,6 +978,7 @@ private fun previewModel( lastActivity = System.currentTimeMillis() / 1000L - 3600L ) +@Suppress("LongParameterList") private fun previewMsg( actorId: String = "other", actorDisplayName: String = "Bob", @@ -911,747 +1006,793 @@ private fun PreviewWrapper(darkTheme: Boolean = isSystemInDarkTheme(), content: } } -// ── Section A – Conversation Type ───────────────────────────────────────────── +// Section A – Conversation Type @Preview(name = "A1 – 1:1 online") @Composable -private fun PreviewOneToOne() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - type = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, - status = "online", - lastMessage = previewMsg(message = "Hey, how are you?") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewOneToOne() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + type = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, + status = "online", + lastMessage = previewMsg(message = "Hey, how are you?") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "A2 – Group") @Composable -private fun PreviewGroup() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Project Team", - type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, - lastMessage = previewMsg(message = "Meeting at 3pm") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewGroup() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Project Team", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + lastMessage = previewMsg(message = "Meeting at 3pm") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "A3 – Group no avatar") @Composable -private fun PreviewGroupNoAvatar() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Team Chat", - type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, - lastMessage = previewMsg(message = "Anyone free?") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewGroupNoAvatar() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Team Chat", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + lastMessage = previewMsg(message = "Anyone free?") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "A4 – Public room") @Composable -private fun PreviewPublicRoom() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Open Room", - type = ConversationEnums.ConversationType.ROOM_PUBLIC_CALL, - lastMessage = previewMsg(message = "Welcome everyone!") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewPublicRoom() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Open Room", + type = ConversationEnums.ConversationType.ROOM_PUBLIC_CALL, + lastMessage = previewMsg(message = "Welcome everyone!") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "A5 – System room") @Composable -private fun PreviewSystemRoom() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Nextcloud", - type = ConversationEnums.ConversationType.ROOM_SYSTEM, - lastMessage = previewMsg(message = "You joined the conversation", messageType = "system") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewSystemRoom() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Nextcloud", + type = ConversationEnums.ConversationType.ROOM_SYSTEM, + lastMessage = previewMsg(message = "You joined the conversation", messageType = "system") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "A6 – Note to self") @Composable -private fun PreviewNoteToSelf() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Personal notes", - type = ConversationEnums.ConversationType.NOTE_TO_SELF, - lastMessage = previewMsg(message = "Reminder: buy groceries") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewNoteToSelf() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Personal notes", + type = ConversationEnums.ConversationType.NOTE_TO_SELF, + lastMessage = previewMsg(message = "Reminder: buy groceries") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "A7 – Former 1:1") @Composable -private fun PreviewFormerOneToOne() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Deleted User", - type = ConversationEnums.ConversationType.FORMER_ONE_TO_ONE, - lastMessage = previewMsg(message = "Last message before leaving") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewFormerOneToOne() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Deleted User", + type = ConversationEnums.ConversationType.FORMER_ONE_TO_ONE, + lastMessage = previewMsg(message = "Last message before leaving") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } -// ── Section B – ObjectType / Special Avatar ──────────────────────────────────── +// Section B – ObjectType / Special Avatar @Preview(name = "B8 – Password protected") @Composable -private fun PreviewPasswordProtected() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Protected room", - objectType = ConversationEnums.ObjectType.SHARE_PASSWORD, - type = ConversationEnums.ConversationType.ROOM_PUBLIC_CALL, - lastMessage = previewMsg(message = "Enter password to join") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewPasswordProtected() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Protected room", + objectType = ConversationEnums.ObjectType.SHARE_PASSWORD, + type = ConversationEnums.ConversationType.ROOM_PUBLIC_CALL, + lastMessage = previewMsg(message = "Enter password to join") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "B9 – File room") @Composable -private fun PreviewFileRoom() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "document.pdf", - objectType = ConversationEnums.ObjectType.FILE, - type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, - lastMessage = previewMsg(message = "What do you think about this file?") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewFileRoom() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "document.pdf", + objectType = ConversationEnums.ObjectType.FILE, + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + lastMessage = previewMsg(message = "What do you think about this file?") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "B10 – Phone temporary room") @Composable -private fun PreviewPhoneNumberRoom() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "+49 170 1234567", - objectType = ConversationEnums.ObjectType.PHONE_TEMPORARY, - type = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, - lastMessage = previewMsg(message = "Missed call") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewPhoneNumberRoom() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "+49 170 1234567", + objectType = ConversationEnums.ObjectType.PHONE_TEMPORARY, + type = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, + lastMessage = previewMsg(message = "Missed call") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } -// ── Section C – Federated ───────────────────────────────────────────────────── +// Section C – Federated @Preview(name = "C11 – Federated") @Composable -private fun PreviewFederated() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Remote Friend", - type = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, - remoteServer = "https://other.cloud.com", - lastMessage = previewMsg(message = "Hi from another server!") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewFederated() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Remote Friend", + type = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, + remoteServer = "https://other.cloud.com", + lastMessage = previewMsg(message = "Hi from another server!") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } -// ── Section D – Unread States ───────────────────────────────────────────────── +// Section D – Unread States @Preview(name = "D12 – No unread") @Composable -private fun PreviewNoUnread() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - unreadMessages = 0, - lastMessage = previewMsg(message = "See you tomorrow") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewNoUnread() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + unreadMessages = 0, + lastMessage = previewMsg(message = "See you tomorrow") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "D13 – Unread few (5)") @Composable -private fun PreviewUnreadFew() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - unreadMessages = 5, - lastMessage = previewMsg(message = "Did you see this?") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewUnreadFew() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + unreadMessages = 5, + lastMessage = previewMsg(message = "Did you see this?") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "D14 – Unread many (1500)") @Composable -private fun PreviewUnreadMany() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Busy Channel", - type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, - unreadMessages = 1500, - lastMessage = previewMsg(message = "So many messages!") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewUnreadMany() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Busy Channel", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + unreadMessages = 1500, + lastMessage = previewMsg(message = "So many messages!") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "D15 – Unread mention group (outlined)") @Composable -private fun PreviewUnreadMentionGroup() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Dev Team", - type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, - unreadMessages = 3, - unreadMention = true, - unreadMentionDirect = false, - lastMessage = previewMsg(message = "@user1 please review PR") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewUnreadMentionGroup() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Dev Team", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + unreadMessages = 3, + unreadMention = true, + unreadMentionDirect = false, + lastMessage = previewMsg(message = "@user1 please review PR") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "D16 – Unread mention direct 1:1 (filled)") @Composable -private fun PreviewUnreadMentionDirect1to1() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - type = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, - unreadMessages = 2, - unreadMention = true, - unreadMentionDirect = true, - lastMessage = previewMsg(message = "Did you see my message?") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewUnreadMentionDirect1to1() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + type = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, + unreadMessages = 2, + unreadMention = true, + unreadMentionDirect = true, + lastMessage = previewMsg(message = "Did you see my message?") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "D17 – Unread mention group direct (filled)") @Composable -private fun PreviewUnreadMentionGroupDirect() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Ops Team", - type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, - unreadMessages = 7, - unreadMention = true, - unreadMentionDirect = true, - lastMessage = previewMsg(message = "@user1 urgent!") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewUnreadMentionGroupDirect() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Ops Team", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + unreadMessages = 7, + unreadMention = true, + unreadMentionDirect = true, + lastMessage = previewMsg(message = "@user1 urgent!") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } -// ── Section E – Favorite ────────────────────────────────────────────────────── +// Section E – Favorite @Preview(name = "E18 – Favorite") @Composable -private fun PreviewFavorite() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Best Friend", - favorite = true, - lastMessage = previewMsg(message = "Let's meet up!") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewFavorite() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Best Friend", + favorite = true, + lastMessage = previewMsg(message = "Let's meet up!") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "E19 – Not favorite") @Composable -private fun PreviewNotFavorite() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Colleague", - favorite = false, - lastMessage = previewMsg(message = "See the report?") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewNotFavorite() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Colleague", + favorite = false, + lastMessage = previewMsg(message = "See the report?") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } -// ── Section F – Status (1:1 only) ───────────────────────────────────────────── +// Section F – Status (1:1 only) @Preview(name = "F20 – Status online") @Composable -private fun PreviewStatusOnline() = PreviewWrapper { - ConversationListItem( - model = previewModel(displayName = "Alice", status = "online", lastMessage = previewMsg()), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewStatusOnline() = + PreviewWrapper { + ConversationListItem( + model = previewModel(displayName = "Alice", status = "online", lastMessage = previewMsg()), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "F21 – Status away") @Composable -private fun PreviewStatusAway() = PreviewWrapper { - ConversationListItem( - model = previewModel(displayName = "Bob", status = "away", lastMessage = previewMsg()), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewStatusAway() = + PreviewWrapper { + ConversationListItem( + model = previewModel(displayName = "Bob", status = "away", lastMessage = previewMsg()), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "F22 – Status DND") @Composable -private fun PreviewStatusDnd() = PreviewWrapper { - ConversationListItem( - model = previewModel(displayName = "Carol", status = "dnd", lastMessage = previewMsg()), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewStatusDnd() = + PreviewWrapper { + ConversationListItem( + model = previewModel(displayName = "Carol", status = "dnd", lastMessage = previewMsg()), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "F23 – Status offline") @Composable -private fun PreviewStatusOffline() = PreviewWrapper { - ConversationListItem( - model = previewModel(displayName = "Dave", status = "offline", lastMessage = previewMsg()), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewStatusOffline() = + PreviewWrapper { + ConversationListItem( + model = previewModel(displayName = "Dave", status = "offline", lastMessage = previewMsg()), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "F24 – Status with emoji") @Composable -private fun PreviewStatusWithEmoji() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Eve", - status = "online", - statusIcon = "☕", - lastMessage = previewMsg(message = "Grabbing coffee") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewStatusWithEmoji() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Eve", + status = "online", + statusIcon = "☕", + lastMessage = previewMsg(message = "Grabbing coffee") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } -// ── Section G – Last Message Types ──────────────────────────────────────────── +// Section G – Last Message Types @Preview(name = "G25 – Own regular text") @Composable -private fun PreviewLastMessageOwnText() = PreviewWrapper { - ConversationListItem( - model = previewModel( - type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, - displayName = "Team", - lastMessage = previewMsg(actorId = "user1", actorDisplayName = "Me", message = "Good morning!") - ), - currentUser = previewUser("user1"), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewLastMessageOwnText() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + displayName = "?? Team", + lastMessage = previewMsg(actorId = "user1", actorDisplayName = "Me", message = "Good morning!") + ), + currentUser = previewUser("user1"), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "G26 – Other regular text") @Composable -private fun PreviewLastMessageOtherText() = PreviewWrapper { - ConversationListItem( - model = previewModel( - type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, - displayName = "Team", - lastMessage = previewMsg(actorId = "user2", actorDisplayName = "Alice", message = "Good morning!") - ), - currentUser = previewUser("user1"), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewLastMessageOtherText() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + displayName = "Team", + lastMessage = previewMsg(actorId = "user2", actorDisplayName = "Alice", message = "Good morning!") + ), + currentUser = previewUser("user1"), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "G27 – System message") @Composable -private fun PreviewLastMessageSystem() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - lastMessage = previewMsg( - message = "Alice joined the call", - messageType = "system", - systemMessageType = ChatMessage.SystemMessageType.CALL_STARTED - ) - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewLastMessageSystem() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "Alice joined the call", + messageType = "system", + systemMessageType = ChatMessage.SystemMessageType.CALL_STARTED + ) + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "G28 – Voice message") @Composable -private fun PreviewLastMessageVoice() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - lastMessage = previewMsg( - message = "voice-message.mp3", - messageType = "voice-message", - messageParameters = hashMapOf("file" to hashMapOf("name" to "voice_001.mp3")) - ) - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewLastMessageVoice() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "voice-message.mp3", + messageType = "voice-message", + messageParameters = hashMapOf("file" to hashMapOf("name" to "voice_001.mp3")) + ) + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "G29 – Image attachment") @Composable -private fun PreviewLastMessageImage() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - lastMessage = previewMsg( - message = "{file}", - messageParameters = hashMapOf( - "file" to hashMapOf("name" to "photo.jpg", "mimetype" to "image/jpeg") +private fun PreviewLastMessageImage() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "{file}", + messageParameters = hashMapOf( + "file" to hashMapOf("name" to "photo.jpg", "mimetype" to "image/jpeg") + ) ) - ) - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "G30 – Video attachment") @Composable -private fun PreviewLastMessageVideo() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - lastMessage = previewMsg( - message = "{file}", - messageParameters = hashMapOf( - "file" to hashMapOf("name" to "clip.mp4", "mimetype" to "video/mp4") +private fun PreviewLastMessageVideo() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "{file}", + messageParameters = hashMapOf( + "file" to hashMapOf("name" to "clip.mp4", "mimetype" to "video/mp4") + ) ) - ) - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "G31 – Audio attachment") @Composable -private fun PreviewLastMessageAudio() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - lastMessage = previewMsg( - message = "{file}", - messageParameters = hashMapOf( - "file" to hashMapOf("name" to "song.mp3", "mimetype" to "audio/mpeg") +private fun PreviewLastMessageAudio() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "{file}", + messageParameters = hashMapOf( + "file" to hashMapOf("name" to "song.mp3", "mimetype" to "audio/mpeg") + ) ) - ) - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "G32 – File attachment") @Composable -private fun PreviewLastMessageFile() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - lastMessage = previewMsg( - message = "{file}", - messageParameters = hashMapOf( - "file" to hashMapOf("name" to "report.pdf", "mimetype" to "application/pdf") +private fun PreviewLastMessageFile() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "{file}", + messageParameters = hashMapOf( + "file" to hashMapOf("name" to "report.pdf", "mimetype" to "application/pdf") + ) ) - ) - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "G33 – GIF message") @Composable -private fun PreviewLastMessageGif() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - lastMessage = previewMsg(message = "https://giphy.com/gif.gif", messageType = "comment") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewLastMessageGif() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg(message = "https://giphy.com/gif.gif", messageType = "comment") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "G34 – Location message") @Composable -private fun PreviewLastMessageLocation() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - lastMessage = previewMsg( - message = "geo:48.8566,2.3522", - messageParameters = hashMapOf( - "object" to hashMapOf("name" to "Eiffel Tower", "type" to "geo-location") +private fun PreviewLastMessageLocation() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "geo:48.8566,2.3522", + messageParameters = hashMapOf( + "object" to hashMapOf("name" to "Eiffel Tower", "type" to "geo-location") + ) ) - ) - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "G35 – Poll message") @Composable -private fun PreviewLastMessagePoll() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Team", - type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, - lastMessage = previewMsg( - message = "{object}", - messageParameters = hashMapOf( - "object" to hashMapOf("name" to "Best framework?", "type" to "talk-poll") +private fun PreviewLastMessagePoll() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Team", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + lastMessage = previewMsg( + message = "{object}", + messageParameters = hashMapOf( + "object" to hashMapOf("name" to "Best framework?", "type" to "talk-poll") + ) ) - ) - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "G36 – Deck card") @Composable -private fun PreviewLastMessageDeck() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - lastMessage = previewMsg( - message = "{object}", - messageParameters = hashMapOf( - "object" to hashMapOf("name" to "Sprint backlog item", "type" to "deck-card") +private fun PreviewLastMessageDeck() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "{object}", + messageParameters = hashMapOf( + "object" to hashMapOf("name" to "Sprint backlog item", "type" to "deck-card") + ) ) - ) - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "G37 – Deleted message") @Composable -private fun PreviewLastMessageDeleted() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - lastMessage = previewMsg( - message = "Message deleted", - messageType = "comment_deleted" - ) - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewLastMessageDeleted() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "Message deleted", + messageType = "comment_deleted" + ) + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "G38 – No last message") @Composable -private fun PreviewNoLastMessage() = PreviewWrapper { - ConversationListItem( - model = previewModel(displayName = "New Conversation", lastMessage = null), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewNoLastMessage() = + PreviewWrapper { + ConversationListItem( + model = previewModel(displayName = "New Conversation", lastMessage = null), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "G39 – Draft") @Composable -private fun PreviewDraft() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - messageDraft = MessageDraft(messageText = "I was going to say…") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewDraft() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + messageDraft = MessageDraft(messageText = "I was going to say…") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "G49 – Text with emoji") @Composable -private fun PreviewLastMessageTextWithEmoji() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Alice", - lastMessage = previewMsg(message = "Schönes Wochenende! 🎉😊") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewLastMessageTextWithEmoji() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg(message = "Schönes Wochenende! 🎉😊") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } -// ── Section H – Call Status ──────────────────────────────────────────────────── +// Section H – Call Status @Preview(name = "H40 – Active call") @Composable -private fun PreviewActiveCall() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Team Stand-up", - type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, - hasCall = true, - lastMessage = previewMsg(message = "Call in progress") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewActiveCall() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Team Stand-up", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + hasCall = true, + lastMessage = previewMsg(message = "Call in progress") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } -// ── Section I – Lobby / Read-only ───────────────────────────────────────────── +// Section I – Lobby / Read-only @Preview(name = "I41 – Lobby active") @Composable -private fun PreviewLobbyActive() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "VIP Room", - type = ConversationEnums.ConversationType.ROOM_PUBLIC_CALL, - lobbyState = ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY, - lastMessage = previewMsg(message = "Waiting for moderator to let you in") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewLobbyActive() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "VIP Room", + type = ConversationEnums.ConversationType.ROOM_PUBLIC_CALL, + lobbyState = ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY, + lastMessage = previewMsg(message = "Waiting for moderator to let you in") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "I42 – Read only") @Composable -private fun PreviewReadOnly() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Announcements", - type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, - readOnlyState = ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_ONLY, - lastMessage = previewMsg(message = "Important update posted") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewReadOnly() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Announcements", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + readOnlyState = ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_ONLY, + lastMessage = previewMsg(message = "Important update posted") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } -// ── Section J – Sensitive ───────────────────────────────────────────────────── +// Section J – Sensitive @Preview(name = "J43 – Sensitive (name only)") @Composable -private fun PreviewSensitive() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Confidential Project", - hasSensitive = true, - unreadMessages = 3, - lastMessage = previewMsg(message = "This text should be hidden") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewSensitive() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Confidential Project", + hasSensitive = true, + unreadMessages = 3, + lastMessage = previewMsg(message = "This text should be hidden") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } -// ── Section K – Archived ────────────────────────────────────────────────────── +// Section K – Archived @Preview(name = "K44 – Archived") @Composable -private fun PreviewArchived() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "Old Project", - hasArchived = true, - lastMessage = previewMsg(message = "Project completed ✓") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewArchived() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Old Project", + hasArchived = true, + lastMessage = previewMsg(message = "Project completed ✓") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } -// ── Section L – UI Variants ──────────────────────────────────────────────────── +// Section L – UI Variants @Preview( name = "L45 – Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES ) @Composable -private fun PreviewDarkMode() = PreviewWrapper(darkTheme = true) { - ConversationListItem( - model = previewModel( - displayName = "Alice", - unreadMessages = 4, - lastMessage = previewMsg(message = "Good night!") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewDarkMode() = + PreviewWrapper(darkTheme = true) { + ConversationListItem( + model = previewModel( + displayName = "Alice", + unreadMessages = 4, + lastMessage = previewMsg(message = "Good night!") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "L46 – Long name (truncation)") @Composable -private fun PreviewLongName() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "This Is A Very Long Conversation Name That Should Be Truncated With Ellipsis", - lastMessage = previewMsg(message = "Short message") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewLongName() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "This Is A Very Long Conversation Name That Should Be Truncated With Ellipsis", + lastMessage = previewMsg(message = "Short message") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "L47 – Short content, no date") @Composable -private fun PreviewShortContent() = PreviewWrapper { - ConversationListItem( - model = previewModel(displayName = "Hi", lastMessage = null), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} +private fun PreviewShortContent() = + PreviewWrapper { + ConversationListItem( + model = previewModel(displayName = "Hi", lastMessage = null), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } @Preview(name = "L48 – RTL (Arabic)", locale = "ar") @Composable -private fun PreviewRtl() = PreviewWrapper { - ConversationListItem( - model = previewModel( - displayName = "محادثة", - unreadMessages = 2, - lastMessage = previewMsg(message = "مرحبا كيف حالك") - ), - currentUser = previewUser(), - onClick = {}, onLongClick = {} - ) -} - - - +private fun PreviewRtl() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "محادثة", + unreadMessages = 2, + lastMessage = previewMsg(message = "مرحبا كيف حالك") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationShimmerList.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationShimmerList.kt index a8949ae85c8..429587cbc20 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationShimmerList.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationShimmerList.kt @@ -71,7 +71,7 @@ fun ConversationListSkeleton(isVisible: Boolean, itemCount: Int = SHIMMER_ITEM_C label = "shimmerAlpha" ) val shimmerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = shimmerAlpha) - + Spacer(modifier = Modifier.width(4.dp)) Column { repeat(itemCount) { ShimmerConversationItem(shimmerColor = shimmerColor) @@ -85,7 +85,7 @@ private fun ShimmerConversationItem(shimmerColor: androidx.compose.ui.graphics.C Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = 16.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Box( diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index f39ff634a0f..bc9635fc2bf 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt @@ -13,14 +13,10 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.talk.R -import com.nextcloud.talk.adapters.items.ContactItem -import com.nextcloud.talk.adapters.items.ConversationItem -import com.nextcloud.talk.adapters.items.GenericTextHeaderItem -import com.nextcloud.talk.adapters.items.LoadMoreResultsItem -import com.nextcloud.talk.adapters.items.MessageResultItem import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.contacts.ContactsRepository import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository +import com.nextcloud.talk.conversationlist.ui.ConversationListEntry import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.invitation.data.InvitationsModel import com.nextcloud.talk.invitation.data.InvitationsRepository @@ -28,19 +24,22 @@ import com.nextcloud.talk.messagesearch.MessageSearchHelper import com.nextcloud.talk.messagesearch.MessageSearchHelper.MessageSearchResults import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.conversations.Conversation +import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.models.json.converters.EnumActorTypeConverter import com.nextcloud.talk.models.json.participants.Participant import com.nextcloud.talk.openconversations.data.OpenConversationsRepository import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import com.nextcloud.talk.threadsoverview.data.ThreadsRepository -import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.ARCHIVE +import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.DEFAULT +import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.MENTION +import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.UNREAD import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -63,13 +62,13 @@ import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit import javax.inject.Inject +@Suppress("LongParameterList") class ConversationsListViewModel @Inject constructor( private val repository: OfflineConversationsRepository, private val threadsRepository: ThreadsRepository, private val currentUserProvider: CurrentUserProviderOld, private val openConversationsRepository: OpenConversationsRepository, private val contactsRepository: ContactsRepository, - private val viewThemeUtils: ViewThemeUtils, private val unifiedSearchRepository: UnifiedSearchRepository, private val invitationsRepository: InvitationsRepository, private val arbitraryStorageManager: ArbitraryStorageManager, @@ -117,8 +116,18 @@ class ConversationsListViewModel @Inject constructor( _getRoomsViewState.value = GetRoomsErrorState } + private val _isShimmerVisible = MutableStateFlow(true) + + /** + * Drives the shimmer skeleton visibility. Set to false as soon as the first room-list + * emission arrives (same subscription as [getRoomsStateFlow] so it hides in the same + * coroutine step that populates [conversationListEntriesFlow]. + */ + val isShimmerVisible: StateFlow = _isShimmerVisible.asStateFlow() + val getRoomsStateFlow = repository .roomListFlow + .onEach { _isShimmerVisible.value = false } .stateIn(viewModelScope, SharingStarted.Eagerly, listOf()) private val _federationInvitationHintVisible = MutableStateFlow(false) @@ -132,6 +141,54 @@ class ConversationsListViewModel @Inject constructor( val showBadgeViewState: LiveData get() = _showBadgeViewState + private val searchResultEntries: MutableStateFlow> = + MutableStateFlow(emptyList()) + + private val filterStateFlow = MutableStateFlow>( + mapOf(MENTION to false, UNREAD to false, ARCHIVE to false, DEFAULT to true) + ) + + private val _isSearchActiveFlow = MutableStateFlow(false) + val isSearchActiveFlow: StateFlow = _isSearchActiveFlow.asStateFlow() + + private val _currentSearchQueryFlow = MutableStateFlow("") + val currentSearchQueryFlow: StateFlow = _currentSearchQueryFlow.asStateFlow() + + private val hideRoomToken = MutableStateFlow(null) + + /** + * Single source of truth for the [ConversationList] LazyColumn. + * Auto-reacts to rooms, filter, search-active and search-result changes. + */ + val conversationListEntriesFlow: StateFlow> = combine( + getRoomsStateFlow, + filterStateFlow, + _isSearchActiveFlow, + searchResultEntries, + hideRoomToken + ) { rooms, filterState, isSearchActive, searchResults, hideToken -> + buildConversationListEntries(rooms, filterState, isSearchActive, searchResults, hideToken) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(WHILE_SUBSCRIBED_TIMEOUT_MS), emptyList()) + + /** Update filter state; triggers [conversationListEntriesFlow] re-emit. */ + fun applyFilter(newFilterState: Map) { + filterStateFlow.value = newFilterState + } + + /** Mark the SearchView as expanded (true) or collapsed (false). */ + fun setIsSearchActive(active: Boolean) { + _isSearchActiveFlow.value = active + if (!active) { + searchResultEntries.value = emptyList() + _currentSearchQueryFlow.value = "" + } + } + + /** Exclude the forward-source room token from the list. */ + fun setHideRoomToken(token: String?) { + hideRoomToken.value = token + } + fun getFederationInvitations() { _federationInvitationHintVisible.value = false _showBadgeViewState.value = ShowBadgeStartState @@ -144,82 +201,59 @@ class ConversationsListViewModel @Inject constructor( } } - private val _searchResultFlow: MutableStateFlow>> = MutableStateFlow(listOf()) - val searchResultFlow = _searchResultFlow.asStateFlow() - @Suppress("LongMethod") fun getSearchQuery(context: Context, filter: String) { - val conversationsTitle: String = context.resources!!.getString(R.string.conversations) - val conversationsHeader = GenericTextHeaderItem(conversationsTitle, viewThemeUtils) - val openConversationsTitle = context.resources!!.getString(R.string.openConversations) - val openConversationsHeader = GenericTextHeaderItem(openConversationsTitle, viewThemeUtils) - val usersTitle = context.resources!!.getString(R.string.nc_user) - val usersHeader = GenericTextHeaderItem(usersTitle, viewThemeUtils) + _currentSearchQueryFlow.value = filter + val conversationsTitle = context.resources.getString(R.string.conversations) + val openConversationsTitle = context.resources.getString(R.string.openConversations) + val usersTitle = context.resources.getString(R.string.nc_user) + val messagesTitle = context.resources.getString(R.string.messages) val actorTypeConverter = EnumActorTypeConverter() viewModelScope.launch { combine( getRoomsStateFlow.map { list -> - list.map { conversation -> - ConversationItem( - conversation, - currentUser, - context, - conversationsHeader, - viewThemeUtils - ) - }.filter { it.model.displayName.contains(filter, true) } + list.filter { it.displayName?.contains(filter, ignoreCase = true) == true } }, - openConversationsRepository.fetchOpenConversationsFlow(currentUser, filter) - .map { list -> - list.map { conversation -> - ConversationItem( - ConversationModel.mapToConversationModel(conversation, currentUser), - currentUser, - context, - openConversationsHeader, - viewThemeUtils - ) - } - }, - contactsRepository.getContactsFlow(currentUser, filter) - .map { list -> - list.map { autocompleteUser -> - val participant = Participant() - participant.actorId = autocompleteUser.id - participant.actorType = actorTypeConverter.getFromString(autocompleteUser.source) - participant.displayName = autocompleteUser.label - - ContactItem( - participant, - currentUser, - usersHeader, - viewThemeUtils - ) - } - }, + openConversationsRepository.fetchOpenConversationsFlow(currentUser, filter), + contactsRepository.getContactsFlow(currentUser, filter), getMessagesFlow(filter) - .map { (messages, hasMore) -> - messages.mapIndexed { index, entry -> - MessageResultItem( - context, - currentUser, - entry, - index == 0, - viewThemeUtils = viewThemeUtils + ) { localConvs, openConvs, contacts, (messages, hasMore) -> + val entries = mutableListOf() + + if (localConvs.isNotEmpty()) { + entries.add(ConversationListEntry.Header(conversationsTitle)) + localConvs.forEach { entries.add(ConversationListEntry.ConversationEntry(it)) } + } + if (openConvs.isNotEmpty()) { + entries.add(ConversationListEntry.Header(openConversationsTitle)) + openConvs.forEach { conv -> + entries.add( + ConversationListEntry.ConversationEntry( + ConversationModel.mapToConversationModel(conv, currentUser) ) - }.let { - if (hasMore) { - it + LoadMoreResultsItem - } else { - it - } - } + ) + } + } + if (contacts.isNotEmpty()) { + entries.add(ConversationListEntry.Header(usersTitle)) + contacts.forEach { autocompleteUser -> + val participant = Participant() + participant.actorId = autocompleteUser.id + participant.actorType = actorTypeConverter.getFromString(autocompleteUser.source) + participant.displayName = autocompleteUser.label + entries.add(ConversationListEntry.ContactEntry(participant)) } - ) { conversations, openConversations, users, messages -> - conversations + openConversations + users + messages - }.collect { searchResults -> - _searchResultFlow.emit(searchResults) + } + if (messages.isNotEmpty()) { + entries.add(ConversationListEntry.Header(messagesTitle)) + messages.forEach { msg -> entries.add(ConversationListEntry.MessageResultEntry(msg)) } + } + if (hasMore) entries.add(ConversationListEntry.LoadMore) + + entries.toList() + }.collect { results -> + searchResultEntries.emit(results) } } } @@ -232,26 +266,16 @@ class ConversationsListViewModel @Inject constructor( searchHelper.loadMore() ?.asFlow() ?.map { (messages, hasMore) -> - messages.map { entry -> - MessageResultItem( - context, - currentUser, - entry, - false, - viewThemeUtils = viewThemeUtils - ) - }.let { - if (hasMore) { - it + LoadMoreResultsItem - } else { - it + val newEntries: List = + messages.map { ConversationListEntry.MessageResultEntry(it) } + if (hasMore) newEntries + ConversationListEntry.LoadMore else newEntries + }?.collect { newEntries -> + searchResultEntries.update { current -> + val withoutOld = current.filter { entry -> + entry !is ConversationListEntry.MessageResultEntry && + entry !is ConversationListEntry.LoadMore } - } - }?.collect { messages -> - _searchResultFlow.update { - it.filter { item -> - item !is LoadMoreResultsItem && item !is MessageResultItem - } + messages + withoutOld + newEntries } } } @@ -270,8 +294,8 @@ class ConversationsListViewModel @Inject constructor( fun isLastCheckTooOld(lastCheckDate: Long): Boolean { val currentTimeMillis = System.currentTimeMillis() val differenceMillis = currentTimeMillis - lastCheckDate - val checkIntervalInMillies = TimeUnit.HOURS.toMillis(2) - return differenceMillis > checkIntervalInMillies + val checkIntervalInMillis = TimeUnit.HOURS.toMillis(2) + return differenceMillis > checkIntervalInMillis } @Suppress("Detekt.TooGenericExceptionCaught") @@ -351,12 +375,7 @@ class ConversationsListViewModel @Inject constructor( val apiVersion = ApiUtils.getConversationApiVersion( currentUser, - intArrayOf( - ApiUtils.API_V4, - ApiUtils - .API_V3, - 1 - ) + intArrayOf(ApiUtils.API_V4, ApiUtils.API_V3, 1) ) val url = ApiUtils.getUrlForOpenConversations(apiVersion, currentUser.baseUrl!!) @@ -374,6 +393,75 @@ class ConversationsListViewModel @Inject constructor( } } + private fun buildConversationListEntries( + rooms: List, + filterState: Map, + isSearchActive: Boolean, + searchResults: List, + hideToken: String? + ): List { + if (isSearchActive && searchResults.isNotEmpty()) return searchResults + + val hasFilterEnabled = filterState[MENTION] == true || + filterState[UNREAD] == true || + filterState[ARCHIVE] == true + + var filtered = rooms + .filter { it.token != hideToken } + .filter { conversation -> + !( + conversation.objectType == ConversationEnums.ObjectType.ROOM && + conversation.lobbyState == ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY + ) + } + + filtered = if (hasFilterEnabled) { + filtered.filter { filterConversationModel(it, filterState) } + } else { + filtered.filter { !isFutureEvent(it) && !it.hasArchived } + } + + val sorted = filtered.sortedWith( + compareByDescending { it.favorite } + .thenByDescending { it.lastActivity } + ) + return sorted.map { ConversationListEntry.ConversationEntry(it) } + } + + @Suppress("CyclomaticComplexMethod", "NestedBlockDepth") + private fun filterConversationModel(conversation: ConversationModel, filterState: Map): Boolean { + var result = true + for ((k, v) in filterState) { + if (v) { + when (k) { + MENTION -> result = (result && conversation.unreadMention) || + ( + result && + ( + conversation.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL || + conversation.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE + ) && + (conversation.unreadMessages > 0) + ) + UNREAD -> result = result && (conversation.unreadMessages > 0) + DEFAULT -> result = if (filterState[ARCHIVE] == true) { + result && conversation.hasArchived + } else { + result && !conversation.hasArchived + } + } + } + } + return result + } + + private fun isFutureEvent(conversation: ConversationModel): Boolean { + val eventTimeStart = conversation.objectId.substringBefore("#").toLongOrNull() ?: return false + val currentTimeStampInSeconds = System.currentTimeMillis() / LONG_1000 + return conversation.objectType == ConversationEnums.ObjectType.EVENT && + (eventTimeStart - currentTimeStampInSeconds) > SIXTEEN_HOURS_IN_SECONDS + } + inner class FederatedInvitationsObserver : Observer { override fun onSubscribe(d: Disposable) { // unused atm @@ -385,11 +473,7 @@ class ConversationsListViewModel @Inject constructor( if (invitationsModel.user.userId?.equals(currentUser.userId) == true && invitationsModel.user.baseUrl?.equals(currentUser.baseUrl) == true ) { - if (invitationsModel.invitations.isNotEmpty()) { - _federationInvitationHintVisible.value = true - } else { - _federationInvitationHintVisible.value = false - } + _federationInvitationHintVisible.value = invitationsModel.invitations.isNotEmpty() } else { if (invitationsModel.invitations.isNotEmpty()) { _showBadgeViewState.value = ShowBadgeSuccessState(true) @@ -410,5 +494,8 @@ class ConversationsListViewModel @Inject constructor( private val TAG = ConversationsListViewModel::class.simpleName const val FOLLOWED_THREADS_EXIST_LAST_CHECK = "FOLLOWED_THREADS_EXIST_LAST_CHECK" const val FOLLOWED_THREADS_EXIST = "FOLLOWED_THREADS_EXIST" + private const val SIXTEEN_HOURS_IN_SECONDS: Long = 57600 + private const val LONG_1000: Long = 1000 + private const val WHILE_SUBSCRIBED_TIMEOUT_MS: Long = 5_000 } } diff --git a/app/src/main/java/com/nextcloud/talk/profile/AvatarSection.kt b/app/src/main/java/com/nextcloud/talk/profile/AvatarSection.kt index 88ec2e54ef9..e021371189e 100644 --- a/app/src/main/java/com/nextcloud/talk/profile/AvatarSection.kt +++ b/app/src/main/java/com/nextcloud/talk/profile/AvatarSection.kt @@ -6,7 +6,6 @@ */ package com.nextcloud.talk.profile -import android.content.Context import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -37,16 +36,12 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.annotation.ExperimentalCoilApi import coil.compose.AsyncImage -import coil.compose.AsyncImagePainter import coil.imageLoader -import coil.memory.MemoryCache import coil.request.CachePolicy import coil.request.ImageRequest import com.nextcloud.talk.R +import com.nextcloud.talk.ui.copyAvatarToOtherThemeCache import com.nextcloud.talk.utils.ApiUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @OptIn(ExperimentalCoilApi::class) @Composable @@ -130,45 +125,6 @@ private fun AvatarCacheEffect(state: ProfileUiState, isDark: Boolean) { } } -/** - * Copies the freshly loaded avatar into the opposite theme's cache slots (memory + disk) - * preventing the need for a second network request. - */ -@OptIn(ExperimentalCoilApi::class) -private fun copyAvatarToOtherThemeCache( - successState: AsyncImagePainter.State.Success, - context: Context, - otherUrl: String, - url: String, - coroutineScope: CoroutineScope -) { - val imageLoader = context.imageLoader - // Copy memory-cache entry, preserving key extras (e.g. resolved image size). - val currentMemKey = successState.result.memoryCacheKey - val memValue = currentMemKey?.let { imageLoader.memoryCache?.get(it) } - if (currentMemKey != null && memValue != null) { - imageLoader.memoryCache?.set(MemoryCache.Key(otherUrl, currentMemKey.extras), memValue) - } - // Copy disk-cache bytes on a background thread. - val diskKey = successState.result.diskCacheKey ?: url - coroutineScope.launch(Dispatchers.IO) { - val diskCache = imageLoader.diskCache ?: return@launch - diskCache.openSnapshot(diskKey)?.use { snapshot -> - diskCache.openEditor(otherUrl)?.let { editor -> - try { - java.io.File(snapshot.data.toString()) - .copyTo(java.io.File(editor.data.toString()), overwrite = true) - java.io.File(snapshot.metadata.toString()) - .copyTo(java.io.File(editor.metadata.toString()), overwrite = true) - editor.commitAndOpenSnapshot()?.close() - } catch (_: Exception) { - editor.abort() - } - } - } - } -} - @Composable fun AvatarSection(state: ProfileUiState, callbacks: ProfileCallbacks, modifier: Modifier) { Column(modifier = modifier.padding(top = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) { diff --git a/app/src/main/java/com/nextcloud/talk/ui/CoilAvatarCache.kt b/app/src/main/java/com/nextcloud/talk/ui/CoilAvatarCache.kt new file mode 100644 index 00000000000..1d7fa97b5a2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/CoilAvatarCache.kt @@ -0,0 +1,65 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.ui + +import android.content.Context +import coil.annotation.ExperimentalCoilApi +import coil.compose.AsyncImagePainter +import coil.imageLoader +import coil.memory.MemoryCache +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * Copies a freshly loaded avatar into the opposite theme's cache slots (memory + disk), + * preventing a second network request when the system theme switches. + * + * Both the light URL (e.g. `.../avatar/alice/64`) and the dark URL + * (`.../avatar/alice/64/dark`) carry the same image bytes — the server just + * returns a theme-adapted version via the URL suffix. By writing the just- + * fetched bytes under the other-theme key we ensure that a theme toggle hits + * the cache rather than the network. + * + * This only applies for uploading an avatar. When fetching avatars there is no indication + * if it is server-generated (different for dark and light) or else (same for dark and light). + * So we only do this cache copy when uploading an avatar, which is always server-generated. + */ +@OptIn(ExperimentalCoilApi::class) +internal fun copyAvatarToOtherThemeCache( + successState: AsyncImagePainter.State.Success, + context: Context, + otherUrl: String, + url: String, + coroutineScope: CoroutineScope +) { + val imageLoader = context.imageLoader + // Copy the memory-cache entry, preserving key extras (e.g. resolved image size). + val currentMemKey = successState.result.memoryCacheKey + val memValue = currentMemKey?.let { imageLoader.memoryCache?.get(it) } + if (currentMemKey != null && memValue != null) { + imageLoader.memoryCache?.set(MemoryCache.Key(otherUrl, currentMemKey.extras), memValue) + } + // Copy the disk-cache bytes on a background thread. + val diskKey = successState.result.diskCacheKey ?: url + coroutineScope.launch(Dispatchers.IO) { + val diskCache = imageLoader.diskCache ?: return@launch + diskCache.openSnapshot(diskKey)?.use { snapshot -> + diskCache.openEditor(otherUrl)?.let { editor -> + try { + java.io.File(snapshot.data.toString()) + .copyTo(java.io.File(editor.data.toString()), overwrite = true) + java.io.File(snapshot.metadata.toString()) + .copyTo(java.io.File(editor.metadata.toString()), overwrite = true) + editor.commitAndOpenSnapshot()?.close() + } catch (_: Exception) { + editor.abort() + } + } + } + } +} diff --git a/app/src/main/res/drawable/ic_videocam_24px.xml b/app/src/main/res/drawable/ic_videocam_24px.xml index 80d2efe6dea..26ab23fbc3f 100644 --- a/app/src/main/res/drawable/ic_videocam_24px.xml +++ b/app/src/main/res/drawable/ic_videocam_24px.xml @@ -1,10 +1,16 @@ + - + android:viewportHeight="960"> + diff --git a/app/src/main/res/layout/activity_conversations.xml b/app/src/main/res/layout/activity_conversations.xml index 85eb672ebbd..d192fafb407 100644 --- a/app/src/main/res/layout/activity_conversations.xml +++ b/app/src/main/res/layout/activity_conversations.xml @@ -182,36 +182,32 @@ android:layout_height="wrap_content" android:layout_gravity="center" /> - + - - - + android:layout_height="wrap_content" /> - + - + - + - Date: Sun, 22 Mar 2026 20:30:08 +0100 Subject: [PATCH 11/21] style(conv-list): resize fab to properly show the drop shadow AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../talk/adapters/items/ConversationItem.kt | 531 ------------------ .../ConversationsListActivity.kt | 102 +--- .../ui/ConversationListFab.kt | 4 +- .../ui/ConversationListItem.kt | 182 +++--- .../ui/ConversationsEmptyState.kt | 68 ++- .../viewmodels/ConversationsListViewModel.kt | 37 +- .../res/layout/activity_conversations.xml | 5 +- ...rv_item_conversation_with_last_message.xml | 135 ----- ...conversation_with_last_message_shimmer.xml | 46 -- 9 files changed, 221 insertions(+), 889 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt delete mode 100644 app/src/main/res/layout/rv_item_conversation_with_last_message.xml delete mode 100644 app/src/main/res/layout/rv_item_conversation_with_last_message_shimmer.xml diff --git a/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt b/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt deleted file mode 100644 index ffaf644507f..00000000000 --- a/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.kt +++ /dev/null @@ -1,531 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2022 Marcel Hibbe - * SPDX-FileCopyrightText: 2022 Tim Krüger - * SPDX-FileCopyrightText: 2021 Andy Scherzinger - * SPDX-FileCopyrightText: 2017-2019 Mario Danic - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.adapters.items - -import android.annotation.SuppressLint -import android.content.Context -import android.content.res.ColorStateList -import android.graphics.Typeface -import android.text.SpannableStringBuilder -import android.text.Spanned -import android.text.TextUtils -import android.text.format.DateUtils -import android.text.style.ImageSpan -import android.view.View -import android.widget.RelativeLayout -import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat -import coil.dispose -import com.nextcloud.talk.R -import com.nextcloud.talk.adapters.items.ConversationItem.ConversationItemViewHolder -import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication -import com.nextcloud.talk.chat.data.model.ChatMessage -import com.nextcloud.talk.chat.data.model.ChatMessage.MessageType -import com.nextcloud.talk.data.database.mappers.asModel -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.RvItemConversationWithLastMessageBinding -import com.nextcloud.talk.extensions.loadConversationAvatar -import com.nextcloud.talk.extensions.loadNoteToSelfAvatar -import com.nextcloud.talk.extensions.loadSystemAvatar -import com.nextcloud.talk.extensions.loadUserAvatar -import com.nextcloud.talk.models.domain.ConversationModel -import com.nextcloud.talk.models.json.conversations.ConversationEnums -import com.nextcloud.talk.ui.StatusDrawable -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability -import com.nextcloud.talk.utils.DisplayUtils -import com.nextcloud.talk.utils.SpreedFeatures -import eu.davidea.flexibleadapter.FlexibleAdapter -import eu.davidea.flexibleadapter.items.AbstractFlexibleItem -import eu.davidea.flexibleadapter.items.IFilterable -import eu.davidea.flexibleadapter.items.IFlexible -import eu.davidea.flexibleadapter.items.ISectionable -import eu.davidea.viewholders.FlexibleViewHolder -import java.util.Locale -import java.util.regex.Pattern - -class ConversationItem( - val model: ConversationModel, - private val user: User, - private val context: Context, - private val viewThemeUtils: ViewThemeUtils -) : AbstractFlexibleItem(), - ISectionable, - IFilterable { - private var header: GenericTextHeaderItem? = null - private val chatMessage = model.lastMessage?.asModel() - var mHolder: ConversationItemViewHolder? = null - - constructor( - conversation: ConversationModel, - user: User, - activityContext: Context, - genericTextHeaderItem: GenericTextHeaderItem?, - viewThemeUtils: ViewThemeUtils - ) : this(conversation, user, activityContext, viewThemeUtils) { - header = genericTextHeaderItem - } - - override fun equals(other: Any?): Boolean { - if (other is ConversationItem) { - return model == other.model - } - return false - } - - override fun hashCode(): Int { - var result = model.hashCode() - result *= 31 - return result - } - - override fun getLayoutRes(): Int = R.layout.rv_item_conversation_with_last_message - - override fun getItemViewType(): Int = VIEW_TYPE - - override fun createViewHolder(view: View, adapter: FlexibleAdapter?>?): ConversationItemViewHolder = - ConversationItemViewHolder(view, adapter) - - @SuppressLint("SetTextI18n") - override fun bindViewHolder( - adapter: FlexibleAdapter?>, - holder: ConversationItemViewHolder, - position: Int, - payloads: List - ) { - mHolder = holder - val appContext = sharedApplication!!.applicationContext - holder.binding.dialogName.setTextColor( - ResourcesCompat.getColor( - context.resources, - R.color.conversation_item_header, - null - ) - ) - if (adapter.hasFilter()) { - viewThemeUtils.platform.highlightText( - holder.binding.dialogName, - model.displayName!!, - adapter.getFilter(String::class.java).toString() - ) - } else { - holder.binding.dialogName.text = model.displayName - } - if (model.unreadMessages > 0) { - showUnreadMessages(holder) - } else { - holder.binding.dialogName.setTypeface(null, Typeface.NORMAL) - holder.binding.dialogDate.setTypeface(null, Typeface.NORMAL) - holder.binding.dialogLastMessage.setTypeface(null, Typeface.NORMAL) - holder.binding.dialogUnreadBubble.visibility = View.GONE - } - if (model.favorite) { - holder.binding.favoriteConversationImageView.visibility = View.VISIBLE - } else { - holder.binding.favoriteConversationImageView.visibility = View.GONE - } - if (ConversationEnums.ConversationType.ROOM_PUBLIC_CALL == model.type) { - holder.binding.publicCallBadge.setImageResource(R.drawable.ic_avatar_link) - holder.binding.publicCallBadge.visibility = View.VISIBLE - } else if (model.remoteServer?.isNotEmpty() == true) { - holder.binding.publicCallBadge.setImageResource(R.drawable.ic_avatar_federation) - holder.binding.publicCallBadge.visibility = View.VISIBLE - } else { - holder.binding.publicCallBadge.visibility = View.GONE - } - if (ConversationEnums.ConversationType.ROOM_SYSTEM !== model.type) { - val size = DisplayUtils.convertDpToPixel(STATUS_SIZE_IN_DP, appContext) - holder.binding.userStatusImage.visibility = View.VISIBLE - holder.binding.userStatusImage.setImageDrawable( - StatusDrawable( - model.status, - model.statusIcon, - size, - context.resources.getColor(R.color.bg_default, null), - appContext - ) - ) - } else { - holder.binding.userStatusImage.visibility = View.GONE - } - - val dialogNameParams = holder.binding.dialogName.layoutParams as RelativeLayout.LayoutParams - val unreadBubbleParams = holder.binding.dialogUnreadBubble.layoutParams as RelativeLayout.LayoutParams - val relativeLayoutParams = holder.binding.relativeLayout.layoutParams as RelativeLayout.LayoutParams - - if (model.hasSensitive == true) { - dialogNameParams.addRule(RelativeLayout.CENTER_VERTICAL) - relativeLayoutParams.addRule(RelativeLayout.ALIGN_TOP, R.id.dialogAvatarFrameLayout) - dialogNameParams.marginEnd = - context.resources.getDimensionPixelSize(R.dimen.standard_double_padding) - unreadBubbleParams.topMargin = - context.resources.getDimensionPixelSize(R.dimen.double_margin_between_elements) - unreadBubbleParams.addRule(RelativeLayout.CENTER_VERTICAL) - } else { - dialogNameParams.removeRule(RelativeLayout.CENTER_VERTICAL) - relativeLayoutParams.removeRule(RelativeLayout.ALIGN_TOP) - dialogNameParams.marginEnd = 0 - unreadBubbleParams.topMargin = 0 - unreadBubbleParams.removeRule(RelativeLayout.CENTER_VERTICAL) - } - holder.binding.relativeLayout.layoutParams = relativeLayoutParams - holder.binding.dialogUnreadBubble.layoutParams = unreadBubbleParams - holder.binding.dialogName.layoutParams = dialogNameParams - - setLastMessage(holder, appContext) - showAvatar(holder) - } - - private fun showAvatar(holder: ConversationItemViewHolder) { - holder.binding.dialogAvatar.dispose() - holder.binding.dialogAvatar.visibility = View.VISIBLE - - var shouldLoadAvatar = shouldLoadAvatar(holder) - if (ConversationEnums.ConversationType.ROOM_SYSTEM == model.type) { - holder.binding.dialogAvatar.loadSystemAvatar() - shouldLoadAvatar = false - } - if (shouldLoadAvatar) { - when (model.type) { - ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> { - if (!TextUtils.isEmpty(model.name)) { - holder.binding.dialogAvatar.loadUserAvatar( - user, - model.name!!, - true, - false - ) - } else { - holder.binding.dialogAvatar.visibility = View.GONE - } - } - - ConversationEnums.ConversationType.ROOM_GROUP_CALL, - ConversationEnums.ConversationType.FORMER_ONE_TO_ONE, - ConversationEnums.ConversationType.ROOM_PUBLIC_CALL -> - holder.binding.dialogAvatar.loadConversationAvatar(user, model, false, viewThemeUtils) - - ConversationEnums.ConversationType.NOTE_TO_SELF -> - holder.binding.dialogAvatar.loadNoteToSelfAvatar() - - else -> holder.binding.dialogAvatar.visibility = View.GONE - } - } - } - - private fun shouldLoadAvatar(holder: ConversationItemViewHolder): Boolean = - when (model.objectType) { - ConversationEnums.ObjectType.SHARE_PASSWORD -> { - holder.binding.dialogAvatar.setImageDrawable( - ContextCompat.getDrawable( - context, - R.drawable.ic_circular_lock - ) - ) - false - } - - ConversationEnums.ObjectType.FILE -> { - holder.binding.dialogAvatar.loadUserAvatar( - viewThemeUtils.talk.themePlaceholderAvatar( - holder.binding.dialogAvatar, - R.drawable.ic_avatar_document - ) - ) - false - } - - else -> true - } - - private fun setLastMessage(holder: ConversationItemViewHolder, appContext: Context) { - val draftText = model.messageDraft?.messageText?.takeIf { it.isNotBlank() } - if (draftText != null) { - showDraft(holder, appContext, draftText) - return - } - - holder.binding.dialogLastMessage.setTextColor( - ContextCompat.getColor(context, R.color.textColorMaxContrast) - ) - - if (chatMessage != null) { - holder.binding.dialogDate.visibility = View.VISIBLE - holder.binding.dialogDate.text = DateUtils.getRelativeTimeSpanString( - model.lastActivity * MILLIES, - System.currentTimeMillis(), - 0, - DateUtils.FORMAT_ABBREV_RELATIVE - ) - if (!TextUtils.isEmpty(chatMessage?.systemMessage) || - ConversationEnums.ConversationType.ROOM_SYSTEM === model.type - ) { - holder.binding.dialogLastMessage.text = chatMessage.text - } else { - chatMessage?.activeUser = user - - val text = - if ( - chatMessage?.getCalculateMessageType() == MessageType.REGULAR_TEXT_MESSAGE - ) { - calculateRegularLastMessageText(appContext) - } else { - lastMessageDisplayText - } - holder.binding.dialogLastMessage.text = text - } - } else { - holder.binding.dialogDate.visibility = View.GONE - holder.binding.dialogLastMessage.text = "" - } - } - - private fun showDraft(holder: ConversationItemViewHolder, appContext: Context, draftText: String) { - holder.binding.dialogDate.visibility = View.VISIBLE - holder.binding.dialogDate.text = DateUtils.getRelativeTimeSpanString( - model.lastActivity * MILLIES, - System.currentTimeMillis(), - 0, - DateUtils.FORMAT_ABBREV_RELATIVE - ) - - val label = String.format(appContext.getString(R.string.nc_draft_prefix), draftText) - viewThemeUtils.talk.themeDraftSubline(holder.binding.dialogLastMessage, label, draftText) - } - - private fun calculateRegularLastMessageText(appContext: Context): CharSequence = - if (chatMessage?.actorId == user.userId) { - String.format( - appContext.getString(R.string.nc_formatted_message_you), - lastMessageDisplayText - ) - } else if (model.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) { - lastMessageDisplayText - } else { - val actorName = chatMessage?.actorDisplayName - val authorDisplayName = if (!actorName.isNullOrBlank()) { - actorName - } else if ("guests" == chatMessage?.actorType || "emails" == chatMessage?.actorType) { - appContext.getString(R.string.nc_guest) - } else { - "" - } - - String.format( - appContext.getString(R.string.nc_formatted_message), - authorDisplayName, - lastMessageDisplayText - ) - } - - private fun showUnreadMessages(holder: ConversationItemViewHolder) { - holder.binding.dialogName.setTypeface(holder.binding.dialogName.typeface, Typeface.BOLD) - holder.binding.dialogLastMessage.setTypeface(holder.binding.dialogLastMessage.typeface, Typeface.BOLD) - holder.binding.dialogUnreadBubble.visibility = View.VISIBLE - if (model.unreadMessages < UNREAD_MESSAGES_TRESHOLD) { - holder.binding.dialogUnreadBubble.text = String.format(Locale.getDefault(), "%d", model.unreadMessages) - } else { - holder.binding.dialogUnreadBubble.setText(R.string.tooManyUnreadMessages) - } - val lightBubbleFillColor = ColorStateList.valueOf( - ContextCompat.getColor( - context, - R.color.conversation_unread_bubble - ) - ) - val lightBubbleTextColor = ContextCompat.getColor( - context, - R.color.conversation_unread_bubble_text - ) - if (model.type === ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) { - viewThemeUtils.material.colorChipBackground(holder.binding.dialogUnreadBubble) - } else if (model.unreadMention) { - if (hasSpreedFeatureCapability(user.capabilities?.spreedCapability!!, SpreedFeatures.DIRECT_MENTION_FLAG)) { - if (model.unreadMentionDirect!!) { - viewThemeUtils.material.colorChipBackground(holder.binding.dialogUnreadBubble) - } else { - viewThemeUtils.material.colorChipOutlined( - holder.binding.dialogUnreadBubble, - UNREAD_BUBBLE_STROKE_WIDTH - ) - } - } else { - viewThemeUtils.material.colorChipBackground(holder.binding.dialogUnreadBubble) - } - } else { - holder.binding.dialogUnreadBubble.chipBackgroundColor = lightBubbleFillColor - holder.binding.dialogUnreadBubble.setTextColor(lightBubbleTextColor) - } - } - - override fun filter(constraint: String?): Boolean = - model.displayName != null && - Pattern - .compile(constraint!!, Pattern.CASE_INSENSITIVE or Pattern.LITERAL) - .matcher(model.displayName.trim()) - .find() - - override fun getHeader(): GenericTextHeaderItem? = header - - override fun setHeader(header: GenericTextHeaderItem?) { - this.header = header - } - - private val lastMessageDisplayText: CharSequence - get() { - if (chatMessage?.getCalculateMessageType() == MessageType.REGULAR_TEXT_MESSAGE || - chatMessage?.getCalculateMessageType() == MessageType.SYSTEM_MESSAGE || - chatMessage?.getCalculateMessageType() == MessageType.SINGLE_LINK_MESSAGE - ) { - return chatMessage.text - } else { - if (MessageType.SINGLE_LINK_GIPHY_MESSAGE == chatMessage?.getCalculateMessageType() || - MessageType.SINGLE_LINK_TENOR_MESSAGE == chatMessage?.getCalculateMessageType() || - MessageType.SINGLE_LINK_GIF_MESSAGE == chatMessage?.getCalculateMessageType() - ) { - return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) { - sharedApplication!!.getString(R.string.nc_sent_a_gif_you) - } else { - String.format( - sharedApplication!!.resources.getString(R.string.nc_sent_a_gif), - chatMessage?.getNullsafeActorDisplayName() - ) - } - } else if (MessageType.SINGLE_NC_GEOLOCATION_MESSAGE == chatMessage?.getCalculateMessageType()) { - var locationName = chatMessage.messageParameters?.get("object")?.get("name") ?: "" - val author = authorName(chatMessage) - val lastMessage = - setLastNameForAttachmentMessage(author, R.drawable.baseline_location_pin_24, locationName) - return lastMessage - } else if (MessageType.VOICE_MESSAGE == chatMessage?.getCalculateMessageType()) { - var voiceMessageName = chatMessage.messageParameters?.get("file")?.get("name") ?: "" - val author = authorName(chatMessage) - val lastMessage = setLastNameForAttachmentMessage( - author, - R.drawable.baseline_mic_24, - voiceMessageName - ) - return lastMessage - } else if (MessageType.SINGLE_LINK_AUDIO_MESSAGE == chatMessage?.getCalculateMessageType()) { - return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) { - sharedApplication!!.getString(R.string.nc_sent_an_audio_you) - } else { - String.format( - sharedApplication!!.resources.getString(R.string.nc_sent_an_audio), - chatMessage?.getNullsafeActorDisplayName() - ) - } - } else if (MessageType.SINGLE_LINK_VIDEO_MESSAGE == chatMessage?.getCalculateMessageType()) { - return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) { - sharedApplication!!.getString(R.string.nc_sent_a_video_you) - } else { - String.format( - sharedApplication!!.resources.getString(R.string.nc_sent_a_video), - chatMessage?.getNullsafeActorDisplayName() - ) - } - } else if (MessageType.SINGLE_LINK_IMAGE_MESSAGE == chatMessage?.getCalculateMessageType()) { - return if (chatMessage?.actorId == chatMessage?.activeUser!!.userId) { - sharedApplication!!.getString(R.string.nc_sent_an_image_you) - } else { - String.format( - sharedApplication!!.resources.getString(R.string.nc_sent_an_image), - chatMessage?.getNullsafeActorDisplayName() - ) - } - } else if (MessageType.POLL_MESSAGE == chatMessage?.getCalculateMessageType()) { - var pollMessageTitle = chatMessage.messageParameters?.get("object")?.get("name") ?: "" - val author = authorName(chatMessage) - val lastMessage = setLastNameForAttachmentMessage( - author, - R.drawable.baseline_bar_chart_24, - pollMessageTitle - ) - return lastMessage - } else if (MessageType.SINGLE_NC_ATTACHMENT_MESSAGE == chatMessage?.getCalculateMessageType()) { - var attachmentName = chatMessage.text - if (attachmentName == "{file}") { - attachmentName = chatMessage.messageParameters?.get("file")?.get("name") ?: "" - } - val author = authorName(chatMessage) - - val drawable = chatMessage.messageParameters?.get("file")?.get("mimetype")?.let { - when { - it.contains("image") -> R.drawable.baseline_image_24 - it.contains("video") -> R.drawable.baseline_video_24 - it.contains("application") -> R.drawable.baseline_insert_drive_file_24 - it.contains("audio") -> R.drawable.baseline_audiotrack_24 - it.contains("text/vcard") -> R.drawable.baseline_contacts_24 - else -> null - } - } - val lastMessage = setLastNameForAttachmentMessage(author, drawable, attachmentName!!) - return lastMessage - } else if (MessageType.DECK_CARD == chatMessage?.getCalculateMessageType()) { - var deckTitle = chatMessage.messageParameters?.get("object")?.get("name") ?: "" - val author = authorName(chatMessage) - val lastMessage = setLastNameForAttachmentMessage(author, R.drawable.baseline_article_24, deckTitle) - return lastMessage - } - } - return "" - } - - fun authorName(chatMessage: ChatMessage): String { - val name = if (chatMessage.actorId == chatMessage.activeUser!!.userId) { - sharedApplication!!.resources.getString(R.string.nc_current_user) - } else { - chatMessage.getNullsafeActorDisplayName()?.let { "$it:" } ?: "" - } - return name - } - - fun setLastNameForAttachmentMessage(actor: String, icon: Int?, attachmentName: String): SpannableStringBuilder { - val builder = SpannableStringBuilder() - builder.append(actor) - - val drawable = icon?.let { it -> ContextCompat.getDrawable(context, it) } - if (drawable != null) { - viewThemeUtils.platform.colorDrawable( - drawable, - context.resources.getColor(R.color.low_emphasis_text, null) - ) - val desiredWidth = (drawable.intrinsicWidth * IMAGE_SCALE_FACTOR).toInt() - val desiredHeight = (drawable.intrinsicHeight * IMAGE_SCALE_FACTOR).toInt() - drawable.setBounds(0, 0, desiredWidth, desiredHeight) - - val imageSpan = ImageSpan(drawable, ImageSpan.ALIGN_BOTTOM) - val startImage = builder.length - builder.append(" ") - builder.setSpan(imageSpan, startImage, startImage + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - } else { - builder.append(" ") - } - builder.append(attachmentName) - return builder - } - - class ConversationItemViewHolder(view: View?, adapter: FlexibleAdapter<*>?) : FlexibleViewHolder(view, adapter) { - var binding: RvItemConversationWithLastMessageBinding - - init { - binding = RvItemConversationWithLastMessageBinding.bind(view!!) - } - } - - companion object { - const val VIEW_TYPE = FlexibleItemViewType.CONVERSATION_ITEM - private const val MILLIES = 1000L - private const val STATUS_SIZE_IN_DP = 9f - private const val UNREAD_BUBBLE_STROKE_WIDTH = 6.0f - private const val UNREAD_MESSAGES_TRESHOLD = 1000 - private const val IMAGE_SCALE_FACTOR = 0.7f - } -} diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 9fec173ec17..6bf014c2025 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -97,11 +97,9 @@ import com.nextcloud.talk.jobs.AccountRemovalWorker import com.nextcloud.talk.jobs.ContactAddressBookWorker.Companion.run import com.nextcloud.talk.jobs.DeleteConversationWorker import com.nextcloud.talk.jobs.UploadAndShareFilesWorker -import com.nextcloud.talk.messagesearch.MessageSearchHelper import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.domain.SearchMessageEntry import com.nextcloud.talk.models.json.conversations.ConversationEnums -import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import com.nextcloud.talk.settings.SettingsActivity import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity import com.nextcloud.talk.ui.BackgroundVoiceMessageCard @@ -143,7 +141,6 @@ import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers -import io.reactivex.subjects.BehaviorSubject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach @@ -168,9 +165,6 @@ class ConversationsListActivity : BaseActivity() { @Inject lateinit var ncApi: NcApi - @Inject - lateinit var unifiedSearchRepository: UnifiedSearchRepository - @Inject lateinit var platformPermissionUtil: PlatformPermissionUtil @@ -220,16 +214,7 @@ class ConversationsListActivity : BaseActivity() { private var selectedMessageId: String? = null private var forwardMessage: Boolean = false private var conversationsListBottomDialog: ConversationsListBottomDialog? = null - private var searchHelper: MessageSearchHelper? = null private var searchViewDisposable: Disposable? = null - private var filterState = - mutableMapOf( - MENTION to false, - UNREAD to false, - ARCHIVE to false, - FilterConversationFragment.DEFAULT to true - ) - val searchBehaviorSubject = BehaviorSubject.createDefault(false) private lateinit var accountIconBadge: BadgeDrawable private val onBackPressedCallback = object : OnBackPressedCallback(true) { @@ -303,21 +288,10 @@ class ConversationsListActivity : BaseActivity() { showServerEOLDialog() return } - currentUser?.capabilities?.spreedCapability?.let { spreedCapabilities -> - if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.UNIFIED_SEARCH)) { - searchHelper = MessageSearchHelper( - unifiedSearchRepository = unifiedSearchRepository, - currentUser = currentUser!! - ) - } - } credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token) loadUserAvatar(binding.switchAccountButton) viewThemeUtils.material.colorMaterialTextButton(binding.switchAccountButton) - val isSearchCurrentlyActive = conversationsListViewModel.isSearchActiveFlow.value - searchBehaviorSubject.onNext(isSearchCurrentlyActive) - conversationsListViewModel.setIsSearchActive(isSearchCurrentlyActive) conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) fetchRooms() fetchPendingInvitations() @@ -328,9 +302,7 @@ class ConversationsListActivity : BaseActivity() { showSearchOrToolbar() conversationsListViewModel.checkIfThreadsExist() - // initialise filter state in ViewModel - getFilterStates() - conversationsListViewModel.applyFilter(filterState) + conversationsListViewModel.reloadFilterFromStorage(UserIdUtils.getIdForUser(currentUser)) } override fun onPause() { @@ -433,6 +405,15 @@ class ConversationsListActivity : BaseActivity() { }.collect() } + lifecycleScope.launch { + conversationsListViewModel.filterStateFlow.collect { filterState -> + val archiveFilterOn = filterState[ARCHIVE] == true + showNoArchivedViewState.value = archiveFilterOn + if (archiveFilterOn) showUnreadBubbleState.value = false + updateFilterConversationButtonColor() + } + } + lifecycleScope.launch { chatViewModel.backgroundPlayUIFlow.onEach { msg -> binding.composeViewForBackgroundPlay.apply { @@ -507,26 +488,24 @@ class ConversationsListActivity : BaseActivity() { } fun applyFilter() { - getFilterStates() - conversationsListViewModel.applyFilter(filterState) - updateFilterConversationButtonColor() + conversationsListViewModel.reloadFilterFromStorage(UserIdUtils.getIdForUser(currentUser)) } - private fun hasFilterEnabled(): Boolean { - for ((k, v) in filterState) { - if (k != FilterConversationFragment.DEFAULT && v) return true + private fun hasFilterEnabled(): Boolean = + conversationsListViewModel.filterStateFlow.value.any { (k, v) -> + k != FilterConversationFragment.DEFAULT && v } - return false - } fun showOnlyNearFutureEvents() { // Reset all filters so the ViewModel's default view (non-archived, non-future-events) is shown - filterState[MENTION] = false - filterState[UNREAD] = false - filterState[ARCHIVE] = false - filterState[FilterConversationFragment.DEFAULT] = true - conversationsListViewModel.applyFilter(filterState) - updateFilterConversationButtonColor() + conversationsListViewModel.applyFilter( + mapOf( + MENTION to false, + UNREAD to false, + ARCHIVE to false, + FilterConversationFragment.DEFAULT to true + ) + ) } private fun setupConversationList() { @@ -617,34 +596,19 @@ class ConversationsListActivity : BaseActivity() { } updateFilterConversationButtonColor() binding.filterConversationsButton.setOnClickListener { - val newFragment = FilterConversationFragment.newInstance(filterState) + val newFragment = FilterConversationFragment.newInstance( + conversationsListViewModel.filterStateFlow.value.toMutableMap() + ) newFragment.show(supportFragmentManager, FilterConversationFragment.TAG) } binding.threadsButton.setOnClickListener { openFollowedThreadsOverview() } viewThemeUtils.platform.colorImageView(binding.threadsButton, ColorRole.ON_SURFACE_VARIANT) } - fun getFilterStates() { - val accountId = UserIdUtils.getIdForUser(currentUser) - filterState[UNREAD] = ( - arbitraryStorageManager.getStorageSetting(accountId, UNREAD, "").blockingGet()?.value ?: "" - ) == "true" - filterState[MENTION] = ( - arbitraryStorageManager.getStorageSetting(accountId, MENTION, "").blockingGet()?.value ?: "" - ) == "true" - filterState[ARCHIVE] = ( - arbitraryStorageManager.getStorageSetting(accountId, ARCHIVE, "").blockingGet()?.value ?: "" - ) == "true" - } - fun filterConversation() { - // Delegate to ViewModel; FilterConversationFragment still calls this via cast - getFilterStates() - val archiveFilterOn = filterState[ARCHIVE] == true - showNoArchivedViewState.value = archiveFilterOn - conversationsListViewModel.applyFilter(filterState) - if (archiveFilterOn) showUnreadBubbleState.value = false - updateFilterConversationButtonColor() + // Delegate to ViewModel; FilterConversationFragment still calls this via cast. + // filterStateFlow observer in initObservers handles showNoArchivedViewState + button colour. + conversationsListViewModel.reloadFilterFromStorage(UserIdUtils.getIdForUser(currentUser)) } private fun setupActionBar() { @@ -823,7 +787,7 @@ class ConversationsListActivity : BaseActivity() { supportActionBar?.setTitle(R.string.nc_forward_to_three_dots) } else { searchItem!!.isVisible = conversationsListViewModel.conversationListEntriesFlow.value.isNotEmpty() - if (searchBehaviorSubject.value == true && searchView != null) { + if (conversationsListViewModel.isSearchActiveFlow.value && searchView != null) { showSearchView(searchView, searchItem) val savedQuery = conversationsListViewModel.currentSearchQueryFlow.value if (savedQuery.isNotEmpty()) { @@ -859,17 +823,13 @@ class ConversationsListActivity : BaseActivity() { searchItem!!.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { override fun onMenuItemActionExpand(item: MenuItem): Boolean { initSearchDisposable() - searchBehaviorSubject.onNext(true) conversationsListViewModel.setIsSearchActive(true) return true } override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - searchBehaviorSubject.onNext(false) conversationsListViewModel.setIsSearchActive(false) - if (searchHelper != null) { - searchHelper!!.cancelSearch() - } + conversationsListViewModel.cancelSearch() searchView!!.onActionViewCollapsed() binding.conversationListAppbar.stateListAnimator = AnimatorInflater.loadStateListAnimator( @@ -1146,7 +1106,7 @@ class ConversationsListActivity : BaseActivity() { @Suppress("Detekt.TooGenericExceptionCaught") private fun checkToShowUnreadBubble(lastVisibleIndex: Int) { - if (searchBehaviorSubject.value == true || conversationsListViewModel.isSearchActiveFlow.value) { + if (conversationsListViewModel.isSearchActiveFlow.value) { nextUnreadConversationScrollPosition = 0 showUnreadBubbleState.value = false return diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt index eb96487b87e..499e00d16f8 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt @@ -48,7 +48,9 @@ fun ConversationListFab(isVisible: Boolean, isEnabled: Boolean, onClick: () -> U ) { FloatingActionButton( onClick = { if (isEnabled) onClick() }, - modifier = Modifier.alpha(if (isEnabled) 1f else DISABLED_ALPHA) + modifier = Modifier + .padding(8.dp) + .alpha(if (isEnabled) 1f else DISABLED_ALPHA) ) { Icon( painter = painterResource(R.drawable.ic_pencil_grey600_24dp), diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt index 8a978963239..4ab98b87760 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt @@ -1,4 +1,4 @@ -/* +/* * Nextcloud Talk - Android Client * * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors @@ -1006,9 +1006,9 @@ private fun PreviewWrapper(darkTheme: Boolean = isSystemInDarkTheme(), content: } } -// Section A – Conversation Type +// Section A - Conversation Type -@Preview(name = "A1 – 1:1 online") +@Preview(name = "A1 - 1:1 online") @Composable private fun PreviewOneToOne() = PreviewWrapper { @@ -1024,10 +1024,10 @@ private fun PreviewOneToOne() = ) } -@Preview(name = "A2 – Group") +@Preview(name = "A2 - Group", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewGroup() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Project Team", @@ -1039,7 +1039,7 @@ private fun PreviewGroup() = ) } -@Preview(name = "A3 – Group no avatar") +@Preview(name = "A3 - Group no avatar") @Composable private fun PreviewGroupNoAvatar() = PreviewWrapper { @@ -1054,10 +1054,10 @@ private fun PreviewGroupNoAvatar() = ) } -@Preview(name = "A4 – Public room") +@Preview(name = "A4 - Public room", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewPublicRoom() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Open Room", @@ -1069,7 +1069,7 @@ private fun PreviewPublicRoom() = ) } -@Preview(name = "A5 – System room") +@Preview(name = "A5 - System room") @Composable private fun PreviewSystemRoom() = PreviewWrapper { @@ -1084,10 +1084,10 @@ private fun PreviewSystemRoom() = ) } -@Preview(name = "A6 – Note to self") +@Preview(name = "A6 - Note to self", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewNoteToSelf() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Personal notes", @@ -1099,7 +1099,7 @@ private fun PreviewNoteToSelf() = ) } -@Preview(name = "A7 – Former 1:1") +@Preview(name = "A7 - Former 1:1") @Composable private fun PreviewFormerOneToOne() = PreviewWrapper { @@ -1114,12 +1114,12 @@ private fun PreviewFormerOneToOne() = ) } -// Section B – ObjectType / Special Avatar +// Section B - ObjectType / Special Avatar -@Preview(name = "B8 – Password protected") +@Preview(name = "B8 - Password protected", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewPasswordProtected() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Protected room", @@ -1132,7 +1132,7 @@ private fun PreviewPasswordProtected() = ) } -@Preview(name = "B9 – File room") +@Preview(name = "B9 - File room") @Composable private fun PreviewFileRoom() = PreviewWrapper { @@ -1148,10 +1148,10 @@ private fun PreviewFileRoom() = ) } -@Preview(name = "B10 – Phone temporary room") +@Preview(name = "B10 - Phone temporary room", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewPhoneNumberRoom() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "+49 170 1234567", @@ -1164,9 +1164,9 @@ private fun PreviewPhoneNumberRoom() = ) } -// Section C – Federated +// Section C - Federated -@Preview(name = "C11 – Federated") +@Preview(name = "C11 - Federated") @Composable private fun PreviewFederated() = PreviewWrapper { @@ -1182,12 +1182,12 @@ private fun PreviewFederated() = ) } -// Section D – Unread States +// Section D - Unread States -@Preview(name = "D12 – No unread") +@Preview(name = "D12 - No unread", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewNoUnread() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Alice", @@ -1199,7 +1199,7 @@ private fun PreviewNoUnread() = ) } -@Preview(name = "D13 – Unread few (5)") +@Preview(name = "D13 - Unread few (5)") @Composable private fun PreviewUnreadFew() = PreviewWrapper { @@ -1214,10 +1214,10 @@ private fun PreviewUnreadFew() = ) } -@Preview(name = "D14 – Unread many (1500)") +@Preview(name = "D14 - Unread many (1500)", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewUnreadMany() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Busy Channel", @@ -1230,7 +1230,7 @@ private fun PreviewUnreadMany() = ) } -@Preview(name = "D15 – Unread mention group (outlined)") +@Preview(name = "D15 - Unread mention group (outlined)") @Composable private fun PreviewUnreadMentionGroup() = PreviewWrapper { @@ -1248,10 +1248,10 @@ private fun PreviewUnreadMentionGroup() = ) } -@Preview(name = "D16 – Unread mention direct 1:1 (filled)") +@Preview(name = "D16 - Unread mention direct 1:1 (filled)", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewUnreadMentionDirect1to1() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Alice", @@ -1266,7 +1266,7 @@ private fun PreviewUnreadMentionDirect1to1() = ) } -@Preview(name = "D17 – Unread mention group direct (filled)") +@Preview(name = "D17 - Unread mention group direct (filled)") @Composable private fun PreviewUnreadMentionGroupDirect() = PreviewWrapper { @@ -1284,12 +1284,12 @@ private fun PreviewUnreadMentionGroupDirect() = ) } -// Section E – Favorite +// Section E - Favorite -@Preview(name = "E18 – Favorite") +@Preview(name = "E18 - Favorite", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewFavorite() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Best Friend", @@ -1301,7 +1301,7 @@ private fun PreviewFavorite() = ) } -@Preview(name = "E19 – Not favorite") +@Preview(name = "E19 - Not favorite") @Composable private fun PreviewNotFavorite() = PreviewWrapper { @@ -1316,12 +1316,12 @@ private fun PreviewNotFavorite() = ) } -// Section F – Status (1:1 only) +// Section F - Status (1:1 only) -@Preview(name = "F20 – Status online") +@Preview(name = "F20 - Status online", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewStatusOnline() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel(displayName = "Alice", status = "online", lastMessage = previewMsg()), currentUser = previewUser(), @@ -1329,7 +1329,7 @@ private fun PreviewStatusOnline() = ) } -@Preview(name = "F21 – Status away") +@Preview(name = "F21 - Status away") @Composable private fun PreviewStatusAway() = PreviewWrapper { @@ -1340,10 +1340,10 @@ private fun PreviewStatusAway() = ) } -@Preview(name = "F22 – Status DND") +@Preview(name = "F22 - Status DND", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewStatusDnd() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel(displayName = "Carol", status = "dnd", lastMessage = previewMsg()), currentUser = previewUser(), @@ -1351,7 +1351,7 @@ private fun PreviewStatusDnd() = ) } -@Preview(name = "F23 – Status offline") +@Preview(name = "F23 - Status offline") @Composable private fun PreviewStatusOffline() = PreviewWrapper { @@ -1362,15 +1362,15 @@ private fun PreviewStatusOffline() = ) } -@Preview(name = "F24 – Status with emoji") +@Preview(name = "F24 - Status with emoji", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewStatusWithEmoji() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Eve", status = "online", - statusIcon = "☕", + statusIcon = "?", lastMessage = previewMsg(message = "Grabbing coffee") ), currentUser = previewUser(), @@ -1378,9 +1378,9 @@ private fun PreviewStatusWithEmoji() = ) } -// Section G – Last Message Types +// Section G - Last Message Types -@Preview(name = "G25 – Own regular text") +@Preview(name = "G25 - Own regular text") @Composable private fun PreviewLastMessageOwnText() = PreviewWrapper { @@ -1395,10 +1395,10 @@ private fun PreviewLastMessageOwnText() = ) } -@Preview(name = "G26 – Other regular text") +@Preview(name = "G26 - Other regular text", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewLastMessageOtherText() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, @@ -1410,7 +1410,7 @@ private fun PreviewLastMessageOtherText() = ) } -@Preview(name = "G27 – System message") +@Preview(name = "G27 - System message") @Composable private fun PreviewLastMessageSystem() = PreviewWrapper { @@ -1428,10 +1428,10 @@ private fun PreviewLastMessageSystem() = ) } -@Preview(name = "G28 – Voice message") +@Preview(name = "G28 - Voice message", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewLastMessageVoice() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Alice", @@ -1446,7 +1446,7 @@ private fun PreviewLastMessageVoice() = ) } -@Preview(name = "G29 – Image attachment") +@Preview(name = "G29 - Image attachment") @Composable private fun PreviewLastMessageImage() = PreviewWrapper { @@ -1465,10 +1465,10 @@ private fun PreviewLastMessageImage() = ) } -@Preview(name = "G30 – Video attachment") +@Preview(name = "G30 - Video attachment", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewLastMessageVideo() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Alice", @@ -1484,7 +1484,7 @@ private fun PreviewLastMessageVideo() = ) } -@Preview(name = "G31 – Audio attachment") +@Preview(name = "G31 - Audio attachment") @Composable private fun PreviewLastMessageAudio() = PreviewWrapper { @@ -1503,10 +1503,10 @@ private fun PreviewLastMessageAudio() = ) } -@Preview(name = "G32 – File attachment") +@Preview(name = "G32 - File attachment", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewLastMessageFile() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Alice", @@ -1522,7 +1522,7 @@ private fun PreviewLastMessageFile() = ) } -@Preview(name = "G33 – GIF message") +@Preview(name = "G33 - GIF message") @Composable private fun PreviewLastMessageGif() = PreviewWrapper { @@ -1536,10 +1536,10 @@ private fun PreviewLastMessageGif() = ) } -@Preview(name = "G34 – Location message") +@Preview(name = "G34 - Location message", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewLastMessageLocation() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Alice", @@ -1555,7 +1555,7 @@ private fun PreviewLastMessageLocation() = ) } -@Preview(name = "G35 – Poll message") +@Preview(name = "G35 - Poll message") @Composable private fun PreviewLastMessagePoll() = PreviewWrapper { @@ -1575,10 +1575,10 @@ private fun PreviewLastMessagePoll() = ) } -@Preview(name = "G36 – Deck card") +@Preview(name = "G36 - Deck card", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewLastMessageDeck() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Alice", @@ -1594,7 +1594,7 @@ private fun PreviewLastMessageDeck() = ) } -@Preview(name = "G37 – Deleted message") +@Preview(name = "G37 - Deleted message") @Composable private fun PreviewLastMessageDeleted() = PreviewWrapper { @@ -1611,10 +1611,10 @@ private fun PreviewLastMessageDeleted() = ) } -@Preview(name = "G38 – No last message") +@Preview(name = "G38 - No last message", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewNoLastMessage() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel(displayName = "New Conversation", lastMessage = null), currentUser = previewUser(), @@ -1622,37 +1622,37 @@ private fun PreviewNoLastMessage() = ) } -@Preview(name = "G39 – Draft") +@Preview(name = "G39 - Draft") @Composable private fun PreviewDraft() = PreviewWrapper { ConversationListItem( model = previewModel( displayName = "Alice", - messageDraft = MessageDraft(messageText = "I was going to say…") + messageDraft = MessageDraft(messageText = "I was going to say-") ), currentUser = previewUser(), callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) ) } -@Preview(name = "G49 – Text with emoji") +@Preview(name = "G49 - Text with emoji", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewLastMessageTextWithEmoji() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Alice", - lastMessage = previewMsg(message = "Schönes Wochenende! 🎉😊") + lastMessage = previewMsg(message = "Sch-nes Wochenende! ????") ), currentUser = previewUser(), callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) ) } -// Section H – Call Status +// Section H - Call Status -@Preview(name = "H40 – Active call") +@Preview(name = "H40 - Active call") @Composable private fun PreviewActiveCall() = PreviewWrapper { @@ -1668,12 +1668,12 @@ private fun PreviewActiveCall() = ) } -// Section I – Lobby / Read-only +// Section I - Lobby / Read-only -@Preview(name = "I41 – Lobby active") +@Preview(name = "I41 - Lobby active", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewLobbyActive() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "VIP Room", @@ -1686,7 +1686,7 @@ private fun PreviewLobbyActive() = ) } -@Preview(name = "I42 – Read only") +@Preview(name = "I42 - Read only") @Composable private fun PreviewReadOnly() = PreviewWrapper { @@ -1702,12 +1702,12 @@ private fun PreviewReadOnly() = ) } -// Section J – Sensitive +// Section J - Sensitive -@Preview(name = "J43 – Sensitive (name only)") +@Preview(name = "J43 - Sensitive (name only)", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewSensitive() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel( displayName = "Confidential Project", @@ -1720,9 +1720,9 @@ private fun PreviewSensitive() = ) } -// Section K – Archived +// Section K - Archived -@Preview(name = "K44 – Archived") +@Preview(name = "K44 - Archived") @Composable private fun PreviewArchived() = PreviewWrapper { @@ -1730,17 +1730,17 @@ private fun PreviewArchived() = model = previewModel( displayName = "Old Project", hasArchived = true, - lastMessage = previewMsg(message = "Project completed ✓") + lastMessage = previewMsg(message = "Project completed ?") ), currentUser = previewUser(), callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) ) } -// Section L – UI Variants +// Section L - UI Variants @Preview( - name = "L45 – Dark mode", + name = "L45 - Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES ) @Composable @@ -1757,7 +1757,7 @@ private fun PreviewDarkMode() = ) } -@Preview(name = "L46 – Long name (truncation)") +@Preview(name = "L46 - Long name (truncation)") @Composable private fun PreviewLongName() = PreviewWrapper { @@ -1771,10 +1771,10 @@ private fun PreviewLongName() = ) } -@Preview(name = "L47 – Short content, no date") +@Preview(name = "L47 - Short content, no date", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun PreviewShortContent() = - PreviewWrapper { + PreviewWrapper(darkTheme = true) { ConversationListItem( model = previewModel(displayName = "Hi", lastMessage = null), currentUser = previewUser(), @@ -1782,15 +1782,15 @@ private fun PreviewShortContent() = ) } -@Preview(name = "L48 – RTL (Arabic)", locale = "ar") +@Preview(name = "L48 - RTL (Arabic)", locale = "ar") @Composable private fun PreviewRtl() = PreviewWrapper { ConversationListItem( model = previewModel( - displayName = "محادثة", + displayName = "??????", unreadMessages = 2, - lastMessage = previewMsg(message = "مرحبا كيف حالك") + lastMessage = previewMsg(message = "????? ??? ????") ), currentUser = previewUser(), callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt index 3c0f8f883f3..82206954ae8 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt @@ -7,13 +7,19 @@ package com.nextcloud.talk.conversationlist.ui +import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,7 +48,7 @@ fun ConversationsEmptyStateView( onCreateNewConversation: () -> Unit ) { when { - showNoArchivedView -> NoArchivedConversationsView() + showNoArchivedView && isListEmpty -> NoArchivedConversationsView() isListEmpty -> EmptyConversationsView(showLogo = showLogo, onCreateNewConversation = onCreateNewConversation) } } @@ -124,20 +130,68 @@ fun NoArchivedConversationsView() { } } -@Preview(showBackground = true) +@Preview(name = "Empty – with logo · Light") +@Preview(name = "Empty – with logo · Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun EmptyConversationsWithLogoPreview() { - EmptyConversationsView(showLogo = true, onCreateNewConversation = {}) + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + EmptyConversationsView(showLogo = true, onCreateNewConversation = {}) + } + } } -@Preview(showBackground = true) +@Preview(name = "Empty – with logo · RTL / Arabic", locale = "ar") +@Composable +private fun EmptyConversationsWithLogoRtlPreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + Surface { + EmptyConversationsView(showLogo = true, onCreateNewConversation = {}) + } + } +} + +@Preview(name = "Empty – no logo · Light") +@Preview(name = "Empty – no logo · Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun EmptyConversationsNoLogoPreview() { - EmptyConversationsView(showLogo = false, onCreateNewConversation = {}) + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + EmptyConversationsView(showLogo = false, onCreateNewConversation = {}) + } + } } -@Preview(showBackground = true) +@Preview(name = "Empty – no logo · RTL / Arabic", locale = "ar") +@Composable +private fun EmptyConversationsNoLogoRtlPreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + Surface { + EmptyConversationsView(showLogo = false, onCreateNewConversation = {}) + } + } +} + +@Preview(name = "No archived conversations · Light") +@Preview(name = "No archived conversations · Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun NoArchivedConversationsPreview() { - NoArchivedConversationsView() + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + NoArchivedConversationsView() + } + } +} + +@Preview(name = "No archived conversations · RTL / Arabic", locale = "ar") +@Composable +private fun NoArchivedConversationsRtlPreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + Surface { + NoArchivedConversationsView() + } + } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index bc9635fc2bf..63ba7e7821c 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt @@ -62,7 +62,7 @@ import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") class ConversationsListViewModel @Inject constructor( private val repository: OfflineConversationsRepository, private val threadsRepository: ThreadsRepository, @@ -144,9 +144,10 @@ class ConversationsListViewModel @Inject constructor( private val searchResultEntries: MutableStateFlow> = MutableStateFlow(emptyList()) - private val filterStateFlow = MutableStateFlow>( + private val _filterStateFlow = MutableStateFlow>( mapOf(MENTION to false, UNREAD to false, ARCHIVE to false, DEFAULT to true) ) + val filterStateFlow: StateFlow> = _filterStateFlow.asStateFlow() private val _isSearchActiveFlow = MutableStateFlow(false) val isSearchActiveFlow: StateFlow = _isSearchActiveFlow.asStateFlow() @@ -162,7 +163,7 @@ class ConversationsListViewModel @Inject constructor( */ val conversationListEntriesFlow: StateFlow> = combine( getRoomsStateFlow, - filterStateFlow, + _filterStateFlow, _isSearchActiveFlow, searchResultEntries, hideRoomToken @@ -172,7 +173,35 @@ class ConversationsListViewModel @Inject constructor( /** Update filter state; triggers [conversationListEntriesFlow] re-emit. */ fun applyFilter(newFilterState: Map) { - filterStateFlow.value = newFilterState + _filterStateFlow.value = newFilterState + } + + /** + * Reload filter settings from [ArbitraryStorageManager] and update [filterStateFlow]. + * Runs on IO dispatcher; [conversationListEntriesFlow] will re-emit once done. + */ + fun reloadFilterFromStorage(accountId: Long) { + viewModelScope.launch(Dispatchers.IO) { + val mention = arbitraryStorageManager.getStorageSetting(accountId, MENTION, "") + .blockingGet()?.value == "true" + val unread = arbitraryStorageManager.getStorageSetting(accountId, UNREAD, "") + .blockingGet()?.value == "true" + val archive = arbitraryStorageManager.getStorageSetting(accountId, ARCHIVE, "") + .blockingGet()?.value == "true" + _filterStateFlow.value = mapOf( + MENTION to mention, + UNREAD to unread, + ARCHIVE to archive, + DEFAULT to true + ) + } + } + + /** Cancel any active message search and clear search results. */ + fun cancelSearch() { + searchHelper.cancelSearch() + searchResultEntries.value = emptyList() + _currentSearchQueryFlow.value = "" } /** Mark the SearchView as expanded (true) or collapsed (false). */ diff --git a/app/src/main/res/layout/activity_conversations.xml b/app/src/main/res/layout/activity_conversations.xml index d192fafb407..d6cd0eefe57 100644 --- a/app/src/main/res/layout/activity_conversations.xml +++ b/app/src/main/res/layout/activity_conversations.xml @@ -22,7 +22,6 @@ android:clipToPadding="false" android:windowContentOverlay="@null" app:elevation="0dp" - app:liftOnScrollTargetViewId="@id/recycler_view" app:liftOnScrollColor="@color/bg_default"> + android:layout_marginEnd="8dp" + android:layout_marginBottom="28dp" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/rv_item_conversation_with_last_message_shimmer.xml b/app/src/main/res/layout/rv_item_conversation_with_last_message_shimmer.xml deleted file mode 100644 index 1029af497b7..00000000000 --- a/app/src/main/res/layout/rv_item_conversation_with_last_message_shimmer.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - From 9ae89e8a259673e8baa59eaeeb593c49bbba7c4d Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 23 Mar 2026 08:55:31 +0100 Subject: [PATCH 12/21] feat(conv-list): Migrate top bars to Composable AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../ConversationsListActivity.kt | 659 +++++------------ .../ui/ConversationListTopBar.kt | 691 ++++++++++++++++++ .../ui/ConversationsEmptyState.kt | 64 ++ .../viewmodels/ConversationsListViewModel.kt | 73 +- app/src/main/res/drawable/ic_search_24px.xml | 16 + .../res/layout/activity_conversations.xml | 139 +--- .../menu/menu_conversation_plus_filter.xml | 29 - 7 files changed, 1013 insertions(+), 658 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListTopBar.kt create mode 100644 app/src/main/res/drawable/ic_search_24px.xml delete mode 100644 app/src/main/res/menu/menu_conversation_plus_filter.xml diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 6bf014c2025..5674b094d3a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -9,28 +9,24 @@ package com.nextcloud.talk.conversationlist import android.Manifest import android.animation.AnimatorInflater import android.annotation.SuppressLint -import android.app.SearchManager import android.content.ActivityNotFoundException import android.content.Intent import android.content.pm.PackageManager -import android.graphics.drawable.Drawable import android.os.Build import android.os.Bundle import android.os.Handler import android.provider.Settings -import android.text.InputType -import android.text.TextUtils import android.util.Log -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.inputmethod.EditorInfo import android.widget.Toast import androidx.activity.OnBackPressedCallback -import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog -import androidx.appcompat.widget.SearchView +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -39,11 +35,7 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat -import androidx.core.graphics.drawable.toDrawable import androidx.core.net.toUri -import androidx.core.view.MenuItemCompat -import androidx.core.view.isVisible -import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope @@ -52,18 +44,8 @@ import androidx.work.OneTimeWorkRequest import androidx.work.WorkInfo import androidx.work.WorkManager import autodagger.AutoInjector -import coil.imageLoader -import coil.request.ImageRequest -import coil.target.Target -import coil.transform.CircleCropTransformation -import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.badge.BadgeDrawable -import com.google.android.material.badge.BadgeUtils -import com.google.android.material.badge.ExperimentalBadgeUtils -import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar -import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.talk.R import com.nextcloud.talk.account.BrowserLoginActivity import com.nextcloud.talk.account.ServerSelectionActivity @@ -81,10 +63,15 @@ import com.nextcloud.talk.contextchat.ContextChatViewModel import com.nextcloud.talk.conversationlist.ui.ConversationList import com.nextcloud.talk.conversationlist.ui.ConversationListFab import com.nextcloud.talk.conversationlist.ui.ConversationListSkeleton +import com.nextcloud.talk.conversationlist.ui.ConversationListTopBar +import com.nextcloud.talk.conversationlist.ui.ConversationListTopBarActions +import com.nextcloud.talk.conversationlist.ui.ConversationListTopBarState import com.nextcloud.talk.conversationlist.ui.ConversationsEmptyStateView +import com.nextcloud.talk.conversationlist.ui.SearchNoResultsView import com.nextcloud.talk.conversationlist.ui.FederationInvitationHintCard import com.nextcloud.talk.conversationlist.ui.NotificationWarningCard import com.nextcloud.talk.conversationlist.ui.StatusBannerRow +import com.nextcloud.talk.conversationlist.ui.TopBarMode import com.nextcloud.talk.conversationlist.ui.UnreadMentionBubble import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel import com.nextcloud.talk.data.network.NetworkMonitor @@ -135,12 +122,8 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SCROLL_TO_NOTIFICATION_CAT import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SHARED_TEXT import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.power.PowerManagerUtils -import com.nextcloud.talk.utils.rx.SearchViewObservable.Companion.observeSearchView import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder -import io.reactivex.Observable -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach @@ -202,20 +185,17 @@ class ConversationsListActivity : BaseActivity() { private var conversationListLazyListState: androidx.compose.foundation.lazy.LazyListState? = null private var nextUnreadConversationScrollPosition = 0 - private var searchItem: MenuItem? = null - private var chooseAccountItem: MenuItem? = null - private var searchView: SearchView? = null - private var searchQuery: String? = null private var credentials: String? = null - private var showShareToScreen = false + private val showShareToScreenState = MutableStateFlow(false) + private val forwardMessageState = MutableStateFlow(false) + private val hasMultipleAccountsState = MutableStateFlow(false) + private val showShareToScreen get() = showShareToScreenState.value + private val forwardMessage get() = forwardMessageState.value private var filesToShare: ArrayList? = null private var selectedConversation: ConversationModel? = null private var textToPaste: String? = "" private var selectedMessageId: String? = null - private var forwardMessage: Boolean = false private var conversationsListBottomDialog: ConversationsListBottomDialog? = null - private var searchViewDisposable: Disposable? = null - private lateinit var accountIconBadge: BadgeDrawable private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { @@ -237,7 +217,7 @@ class ConversationsListActivity : BaseActivity() { contextChatViewModel = ViewModelProvider(this, viewModelFactory)[ContextChatViewModel::class.java] binding = ActivityConversationsBinding.inflate(layoutInflater) - setupActionBar() + setSupportActionBar(null) setContentView(binding.root) setupStatusBanner() setupEmptyStateView() @@ -247,12 +227,10 @@ class ConversationsListActivity : BaseActivity() { setupNotificationWarning() setupFederationHintCard() setupConversationList() + setupTopBar() initSystemBars() - viewThemeUtils.material.themeSearchCardView(binding.searchToolbarContainer) - viewThemeUtils.material.colorMaterialButtonContent(binding.menuButton, ColorRole.ON_SURFACE_VARIANT) - viewThemeUtils.platform.colorTextView(binding.searchText, ColorRole.ON_SURFACE_VARIANT) - forwardMessage = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false) + forwardMessageState.value = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false) onBackPressedDispatcher.addCallback(this, onBackPressedCallback) initObservers() @@ -277,7 +255,7 @@ class ConversationsListActivity : BaseActivity() { super.onResume() showNotificationWarningState.value = shouldShowNotificationWarning() - showShareToScreen = hasActivityActionSendIntent() + showShareToScreenState.value = hasActivityActionSendIntent() if (!eventBus.isRegistered(this)) { eventBus.register(this) @@ -290,8 +268,7 @@ class ConversationsListActivity : BaseActivity() { } credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token) - loadUserAvatar(binding.switchAccountButton) - viewThemeUtils.material.colorMaterialTextButton(binding.switchAccountButton) + hasMultipleAccountsState.value = userManager.users.blockingGet().size > 1 conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) fetchRooms() fetchPendingInvitations() @@ -300,7 +277,6 @@ class ConversationsListActivity : BaseActivity() { Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() } - showSearchOrToolbar() conversationsListViewModel.checkIfThreadsExist() conversationsListViewModel.reloadFilterFromStorage(UserIdUtils.getIdForUser(currentUser)) } @@ -315,30 +291,6 @@ class ConversationsListActivity : BaseActivity() { @Suppress("LongMethod", "CyclomaticComplexMethod") private fun initObservers() { - this.lifecycleScope.launch { - networkMonitor.isOnline.onEach { isOnline -> - handleUI(isOnline) - }.collect() - } - - conversationsListViewModel.showBadgeViewState.observe(this) { state -> - when (state) { - is ConversationsListViewModel.ShowBadgeStartState -> { - showAccountIconBadge(false) - } - - is ConversationsListViewModel.ShowBadgeSuccessState -> { - showAccountIconBadge(state.showBadge) - } - - is ConversationsListViewModel.ShowBadgeErrorState -> { - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - } - - else -> {} - } - } - conversationsListViewModel.getRoomsViewState.observe(this) { state -> when (state) { is ConversationsListViewModel.GetRoomsSuccessState -> { @@ -355,23 +307,6 @@ class ConversationsListActivity : BaseActivity() { } } - lifecycleScope.launch { - conversationsListViewModel.threadsExistState.collect { state -> - when (state) { - is ConversationsListViewModel.ThreadsExistUiState.Success -> { - binding.threadsButton.visibility = if (state.threadsExistence == true) { - View.VISIBLE - } else { - View.GONE - } - } - else -> { - binding.threadsButton.visibility = View.GONE - } - } - } - } - lifecycleScope.launch { conversationsListViewModel.getRoomsFlow .onEach { list -> @@ -410,7 +345,6 @@ class ConversationsListActivity : BaseActivity() { val archiveFilterOn = filterState[ARCHIVE] == true showNoArchivedViewState.value = archiveFilterOn if (archiveFilterOn) showUnreadBubbleState.value = false - updateFilterConversationButtonColor() } } @@ -518,6 +452,10 @@ class ConversationsListActivity : BaseActivity() { val isRefreshing by isRefreshingState.collectAsStateWithLifecycle() val searchQuery by conversationsListViewModel.currentSearchQueryFlow .collectAsStateWithLifecycle() + val isSearchActive by conversationsListViewModel.isSearchActiveFlow + .collectAsStateWithLifecycle() + val isSearchLoading by conversationsListViewModel.isSearchLoadingFlow + .collectAsStateWithLifecycle() val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState() // Store reference so Activity can read scroll position in onPause @@ -527,34 +465,161 @@ class ConversationsListActivity : BaseActivity() { } MaterialTheme(colorScheme = colorScheme) { - ConversationList( - entries = entries, - isRefreshing = isRefreshing, - currentUser = currentUser!!, - credentials = credentials ?: "", - searchQuery = searchQuery, - onConversationClick = { handleConversation(it) }, - onConversationLongClick = { handleConversationLongClick(it) }, - onMessageResultClick = { showContextChatForMessage(it) }, - onContactClick = { - contactsViewModel.createRoom(ROOM_TYPE_ONE_ONE, null, it.actorId!!, null) - }, - onLoadMoreClick = { conversationsListViewModel.loadMoreMessages(context) }, - onRefresh = { - isMaintenanceModeState.value = false - isRefreshingState.value = true - fetchRooms() - fetchPendingInvitations() - }, - onScrollChanged = { isFabVisibleState.value = !it }, - onScrollStopped = { checkToShowUnreadBubble(it) }, - listState = lazyListState + if (isSearchActive && entries.isEmpty() && searchQuery.isNotEmpty() && !isSearchLoading) { + Box( + modifier = Modifier.fillMaxSize().imePadding(), + contentAlignment = Alignment.Center + ) { + SearchNoResultsView() + } + } else { + ConversationList( + entries = entries, + isRefreshing = isRefreshing, + currentUser = currentUser!!, + credentials = credentials ?: "", + searchQuery = searchQuery, + onConversationClick = { handleConversation(it) }, + onConversationLongClick = { handleConversationLongClick(it) }, + onMessageResultClick = { showContextChatForMessage(it) }, + onContactClick = { + contactsViewModel.createRoom(ROOM_TYPE_ONE_ONE, null, it.actorId!!, null) + }, + onLoadMoreClick = { conversationsListViewModel.loadMoreMessages(context) }, + onRefresh = { + isMaintenanceModeState.value = false + isRefreshingState.value = true + fetchRooms() + fetchPendingInvitations() + }, + onScrollChanged = { isFabVisibleState.value = !it }, + onScrollStopped = { checkToShowUnreadBubble(it) }, + listState = lazyListState + ) + } + } + } + } + } + + @Suppress("LongMethod") + private fun setupTopBar() { + updateAppBarElevation(elevated = false) + binding.toolbarComposeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val colorScheme = remember { viewThemeUtils.getColorScheme(context) } + + val isSearchActive by conversationsListViewModel.isSearchActiveFlow + .collectAsStateWithLifecycle() + val searchQuery by conversationsListViewModel.currentSearchQueryFlow + .collectAsStateWithLifecycle() + val showAvatarBadge by conversationsListViewModel.showAvatarBadge + .collectAsStateWithLifecycle() + val filterState by conversationsListViewModel.filterStateFlow + .collectAsStateWithLifecycle() + val threadsState by conversationsListViewModel.threadsExistState + .collectAsStateWithLifecycle() + val showShareTo by showShareToScreenState.collectAsStateWithLifecycle() + val isForward by forwardMessageState.collectAsStateWithLifecycle() + val multipleAccounts by hasMultipleAccountsState.collectAsStateWithLifecycle() + + val showFilterActive = filterState.any { (k, v) -> + k != FilterConversationFragment.DEFAULT && v + } + val showThreadsButton = + threadsState is ConversationsListViewModel.ThreadsExistUiState.Success && + (threadsState as ConversationsListViewModel.ThreadsExistUiState.Success) + .threadsExistence == true + + val mode: TopBarMode = when { + showShareTo -> TopBarMode.TitleBar( + title = getString(R.string.send_to_three_dots), + showAccountChooser = multipleAccounts + ) + isForward -> TopBarMode.TitleBar( + title = getString(R.string.nc_forward_to_three_dots), + showAccountChooser = false + ) + isSearchActive -> TopBarMode.SearchActive(query = searchQuery) + else -> TopBarMode.SearchBarIdle + } + + val avatarUrl = remember(currentUser) { + ApiUtils.getUrlForAvatar( + currentUser?.baseUrl, + currentUser?.userId, + true, + darkMode = DisplayUtils.isDarkModeOn(this@ConversationsListActivity) + ) + } + + // Debounce search: 300 ms after last keystroke, then fire ViewModel search. + LaunchedEffect(searchQuery) { + if (searchQuery.isNotEmpty()) { + delay(SEARCH_DEBOUNCE_INTERVAL_MS.toLong()) + if (searchQuery.length >= SEARCH_MIN_CHARS) { + conversationsListViewModel.getSearchQuery(context, searchQuery) + } + } + } + + MaterialTheme(colorScheme = colorScheme) { + ConversationListTopBar( + state = ConversationListTopBarState( + mode = mode, + showAvatarBadge = showAvatarBadge, + avatarUrl = avatarUrl, + credentials = credentials ?: "", + showFilterActive = showFilterActive, + showThreadsButton = showThreadsButton + ), + actions = ConversationListTopBarActions( + onSearchQueryChange = { conversationsListViewModel.setSearchQuery(it) }, + onSearchActivate = { + conversationsListViewModel.setIsSearchActive(true) + updateAppBarElevation(elevated = true) + viewThemeUtils.platform.themeStatusBar(this@ConversationsListActivity) + }, + onSearchClose = { + conversationsListViewModel.setIsSearchActive(false) + updateAppBarElevation(elevated = false) + viewThemeUtils.platform.resetStatusBar(this@ConversationsListActivity) + lifecycleScope.launch { + conversationListLazyListState?.scrollToItem(0) + } + }, + onFilterClick = { + FilterConversationFragment + .newInstance( + conversationsListViewModel.filterStateFlow.value.toMutableMap() + ) + .show(supportFragmentManager, FilterConversationFragment.TAG) + }, + onThreadsClick = { openFollowedThreadsOverview() }, + onAvatarClick = { + if (resources.getBoolean(R.bool.multiaccount_support)) { + showChooseAccountDialog() + } else { + startActivity(Intent(context, SettingsActivity::class.java)) + } + }, + onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, + onAccountChooserClick = { + ChooseAccountShareToDialogFragment.newInstance() + .show(supportFragmentManager, ChooseAccountShareToDialogFragment.TAG) + } + ) ) } } } + } - setupToolbarButtons() + private fun updateAppBarElevation(elevated: Boolean) { + val animRes = if (elevated) R.animator.appbar_elevation_on else R.animator.appbar_elevation_off + binding.conversationListAppbar.stateListAnimator = + AnimatorInflater.loadStateListAnimator(binding.conversationListAppbar.context, animRes) } private fun handleConversationLongClick(model: ConversationModel) { @@ -586,70 +651,12 @@ class ConversationsListActivity : BaseActivity() { } } - private fun setupToolbarButtons() { - binding.switchAccountButton.setOnClickListener { - if (resources != null && resources!!.getBoolean(R.bool.multiaccount_support)) { - showChooseAccountDialog() - } else { - startActivity(Intent(context, SettingsActivity::class.java)) - } - } - updateFilterConversationButtonColor() - binding.filterConversationsButton.setOnClickListener { - val newFragment = FilterConversationFragment.newInstance( - conversationsListViewModel.filterStateFlow.value.toMutableMap() - ) - newFragment.show(supportFragmentManager, FilterConversationFragment.TAG) - } - binding.threadsButton.setOnClickListener { openFollowedThreadsOverview() } - viewThemeUtils.platform.colorImageView(binding.threadsButton, ColorRole.ON_SURFACE_VARIANT) - } - fun filterConversation() { // Delegate to ViewModel; FilterConversationFragment still calls this via cast. // filterStateFlow observer in initObservers handles showNoArchivedViewState + button colour. conversationsListViewModel.reloadFilterFromStorage(UserIdUtils.getIdForUser(currentUser)) } - private fun setupActionBar() { - setSupportActionBar(binding.conversationListToolbar) - binding.conversationListToolbar.setNavigationOnClickListener { - onBackPressedDispatcher.onBackPressed() - } - supportActionBar?.setDisplayHomeAsUpEnabled(true) - supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setIcon(resources!!.getColor(R.color.transparent, null).toDrawable()) - supportActionBar?.title = resources!!.getString(R.string.nc_app_product_name) - viewThemeUtils.material.themeToolbar(binding.conversationListToolbar) - } - - private fun loadUserAvatar(target: Target) { - if (currentUser != null) { - val url = ApiUtils.getUrlForAvatar( - currentUser!!.baseUrl!!, - currentUser!!.userId, - true, - darkMode = DisplayUtils.isDarkModeOn(this) - ) - - val credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token) - - context.imageLoader.enqueue( - ImageRequest.Builder(context) - .data(url) - .addHeader("Authorization", credentials!!) - .placeholder(R.drawable.ic_user) - .transformations(CircleCropTransformation()) - .crossfade(true) - .target(target) - .build() - ) - } else { - Log.e(TAG, "currentUser was null in loadUserAvatar") - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - } - } - private fun showChooseAccountDialog() { val brandedClient = getResources().getBoolean(R.bool.is_branded_client) binding.genericComposeView.apply { @@ -664,257 +671,9 @@ class ConversationsListActivity : BaseActivity() { } } - private fun loadUserAvatar(button: MaterialButton) { - val target = object : Target { - override fun onStart(placeholder: Drawable?) { - button.icon = placeholder - } - - override fun onSuccess(result: Drawable) { - button.icon = result - } - } - - loadUserAvatar(target) - } - - private fun loadUserAvatar(menuItem: MenuItem) { - val target = object : Target { - override fun onStart(placeholder: Drawable?) { - menuItem.icon = placeholder - } - - override fun onSuccess(result: Drawable) { - menuItem.icon = result - } - } - loadUserAvatar(target) - } - - private fun initSearchView() { - val searchManager = getSystemService(SEARCH_SERVICE) as SearchManager? - if (searchItem != null) { - searchView = MenuItemCompat.getActionView(searchItem) as SearchView - viewThemeUtils.talk.themeSearchView(searchView!!) - searchView!!.maxWidth = Int.MAX_VALUE - searchView!!.inputType = InputType.TYPE_TEXT_VARIATION_FILTER - var imeOptions = EditorInfo.IME_ACTION_DONE or EditorInfo.IME_FLAG_NO_FULLSCREEN - if (appPreferences.isKeyboardIncognito) { - imeOptions = imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING - } - searchView!!.imeOptions = imeOptions - searchView!!.queryHint = getString(R.string.appbar_search_in, getString(R.string.nc_app_product_name)) - if (searchManager != null) { - searchView!!.setSearchableInfo(searchManager.getSearchableInfo(componentName)) - } - initSearchDisposable() - } - } - - private fun initSearchDisposable() { - if (searchViewDisposable == null || searchViewDisposable?.isDisposed == true) { - searchViewDisposable = observeSearchView(searchView!!) - .debounce { query: String? -> - if (TextUtils.isEmpty(query)) { - return@debounce Observable.empty() - } else { - return@debounce Observable.timer( - SEARCH_DEBOUNCE_INTERVAL_MS.toLong(), - TimeUnit.MILLISECONDS - ) - } - } - .distinctUntilChanged() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { newText: String? -> onQueryTextChange(newText) } - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - super.onCreateOptionsMenu(menu) - - menuInflater.inflate(R.menu.menu_conversation_plus_filter, menu) - searchItem = menu.findItem(R.id.action_search) - chooseAccountItem = menu.findItem(R.id.action_choose_account) - loadUserAvatar(chooseAccountItem!!) - - chooseAccountItem?.setOnMenuItemClickListener { - if (resources != null && resources!!.getBoolean(R.bool.multiaccount_support)) { - val newFragment: DialogFragment = ChooseAccountShareToDialogFragment.newInstance() - newFragment.show( - supportFragmentManager, - ChooseAccountShareToDialogFragment.TAG - ) - } - true - } - initSearchView() - return true - } - - @OptIn(ExperimentalBadgeUtils::class) - fun showAccountIconBadge(showBadge: Boolean) { - if (!::accountIconBadge.isInitialized) { - accountIconBadge = BadgeDrawable.create(binding.switchAccountButton.context) - accountIconBadge.verticalOffset = BADGE_OFFSET - accountIconBadge.horizontalOffset = BADGE_OFFSET - accountIconBadge.backgroundColor = resources.getColor(R.color.badge_color, null) - } - - if (showBadge) { - BadgeUtils.attachBadgeDrawable(accountIconBadge, binding.switchAccountButton) - } else { - BadgeUtils.detachBadgeDrawable(accountIconBadge, binding.switchAccountButton) - } - } - - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - super.onPrepareOptionsMenu(menu) - - if (searchItem != null) { - searchView = MenuItemCompat.getActionView(searchItem) as SearchView - } - - val moreAccountsAvailable = userManager.users.blockingGet().size > 1 - menu.findItem(R.id.action_choose_account).isVisible = showShareToScreen && moreAccountsAvailable - - if (showShareToScreen) { - hideSearchBar() - supportActionBar?.setTitle(R.string.send_to_three_dots) - } else if (forwardMessage) { - hideSearchBar() - supportActionBar?.setTitle(R.string.nc_forward_to_three_dots) - } else { - searchItem!!.isVisible = conversationsListViewModel.conversationListEntriesFlow.value.isNotEmpty() - if (conversationsListViewModel.isSearchActiveFlow.value && searchView != null) { - showSearchView(searchView, searchItem) - val savedQuery = conversationsListViewModel.currentSearchQueryFlow.value - if (savedQuery.isNotEmpty()) { - searchView!!.setQuery(savedQuery, false) - } - } - binding.searchText.setOnClickListener { - showSearchView(searchView, searchItem) - viewThemeUtils.platform.themeStatusBar(this) - } - searchView?.findViewById(R.id.search_close_btn)?.setOnClickListener { - if (TextUtils.isEmpty(searchView!!.query.toString())) { - searchView!!.onActionViewCollapsed() - viewThemeUtils.platform.resetStatusBar(this) - } else { - searchView!!.setQuery("", false) - } - } - - searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(p0: String?): Boolean { - initSearchDisposable() - searchView!!.clearFocus() - return true - } - - override fun onQueryTextChange(p0: String?): Boolean { - this@ConversationsListActivity.onQueryTextChange(p0) - return true - } - }) - - searchItem!!.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - initSearchDisposable() - conversationsListViewModel.setIsSearchActive(true) - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - conversationsListViewModel.setIsSearchActive(false) - conversationsListViewModel.cancelSearch() - searchView!!.onActionViewCollapsed() - - binding.conversationListAppbar.stateListAnimator = AnimatorInflater.loadStateListAnimator( - binding.conversationListAppbar.context, - R.animator.appbar_elevation_off - ) - binding.conversationListToolbar.visibility = View.GONE - binding.searchToolbar.visibility = View.VISIBLE - if (resources != null) { - viewThemeUtils.platform.resetStatusBar(this@ConversationsListActivity) - } - - lifecycleScope.launch { - conversationListLazyListState?.scrollToItem(0) - } - return true - } - }) - } - return true - } - - private fun showSearchOrToolbar() { - if (TextUtils.isEmpty(searchQuery) && !conversationsListViewModel.isSearchActiveFlow.value) { - if (appBarLayoutType == AppBarLayoutType.SEARCH_BAR) { - showSearchBar() - } else { - showToolbar() - } - initSystemBars() - } - } - - private fun showSearchBar() { - val layoutParams = binding.searchToolbar.layoutParams as AppBarLayout.LayoutParams - binding.searchToolbar.visibility = View.VISIBLE - binding.searchText.text = getString(R.string.appbar_search_in, getString(R.string.nc_app_product_name)) - binding.conversationListToolbar.visibility = View.GONE - // layoutParams.setScrollFlags(AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL | AppBarLayout - // .LayoutParams.SCROLL_FLAG_SNAP | AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS); - layoutParams.scrollFlags = 0 - binding.conversationListAppbar.stateListAnimator = AnimatorInflater.loadStateListAnimator( - binding.conversationListAppbar.context, - R.animator.appbar_elevation_off - ) - binding.searchToolbar.layoutParams = layoutParams - } - - private fun showToolbar() { - val layoutParams = binding.searchToolbar.layoutParams as AppBarLayout.LayoutParams - binding.searchToolbar.visibility = View.GONE - binding.conversationListToolbar.visibility = View.VISIBLE - viewThemeUtils.material.colorToolbarOverflowIcon(binding.conversationListToolbar) - layoutParams.scrollFlags = 0 - binding.conversationListAppbar.stateListAnimator = AnimatorInflater.loadStateListAnimator( - binding.conversationListAppbar.context, - R.animator.appbar_elevation_on - ) - binding.conversationListToolbar.layoutParams = layoutParams - } - - private fun hideSearchBar() { - val layoutParams = binding.searchToolbar.layoutParams as AppBarLayout.LayoutParams - binding.searchToolbar.visibility = View.GONE - binding.conversationListToolbar.visibility = View.VISIBLE - layoutParams.scrollFlags = 0 - binding.conversationListAppbar.stateListAnimator = AnimatorInflater.loadStateListAnimator( - binding.conversationListAppbar.context, - R.animator.appbar_elevation_on - ) - } - private fun hasActivityActionSendIntent(): Boolean = Intent.ACTION_SEND == intent.action || Intent.ACTION_SEND_MULTIPLE == intent.action - private fun showSearchView(searchView: SearchView?, searchItem: MenuItem?) { - binding.conversationListAppbar.stateListAnimator = AnimatorInflater.loadStateListAnimator( - binding.conversationListAppbar.context, - R.animator.appbar_elevation_on - ) - binding.conversationListToolbar.visibility = View.VISIBLE - binding.searchToolbar.visibility = View.GONE - searchItem!!.expandActionView() - } - fun showSnackbar(text: String) { Snackbar.make(binding.root, text, Snackbar.LENGTH_LONG).show() } @@ -1065,11 +824,6 @@ class ConversationsListActivity : BaseActivity() { } } - private fun handleUI(show: Boolean) { - binding.searchText.isEnabled = show - binding.searchText.isVisible = show - } - private suspend fun fetchOpenConversations(searchTerm: String) { conversationsListViewModel.fetchOpenConversations(searchTerm) } @@ -1153,52 +907,8 @@ class ConversationsListActivity : BaseActivity() { startActivity(intent) } - private fun dispose(disposable: Disposable?) { - if (disposable != null && !disposable.isDisposed) { - disposable.dispose() - } - } - - override fun onSaveInstanceState(bundle: Bundle) { - super.onSaveInstanceState(bundle) - - if (searchView != null && !TextUtils.isEmpty(searchView!!.query)) { - bundle.putString(KEY_SEARCH_QUERY, searchView!!.query.toString()) - } - } - - override fun onRestoreInstanceState(savedInstanceState: Bundle) { - super.onRestoreInstanceState(savedInstanceState) - - if (savedInstanceState.containsKey(KEY_SEARCH_QUERY)) { - searchQuery = savedInstanceState.getString(KEY_SEARCH_QUERY, "") - } - } - public override fun onDestroy() { super.onDestroy() - dispose(null) - if (searchViewDisposable != null && !searchViewDisposable!!.isDisposed) { - searchViewDisposable!!.dispose() - } - } - - private fun onQueryTextChange(newText: String?) { - if (!TextUtils.isEmpty(searchQuery)) { - val filter = searchQuery - searchQuery = "" - performFilterAndSearch(filter) - } else { - performFilterAndSearch(newText) - } - } - - private fun performFilterAndSearch(filter: String?) { - if (!filter.isNullOrEmpty() && filter.length >= SEARCH_MIN_CHARS) { - showNoArchivedViewState.value = false - conversationsListViewModel.getSearchQuery(context, filter) - } - // Query too short: search results remain empty, ViewModel shows regular list } private fun showConversationByToken(conversationToken: String) { @@ -1234,7 +944,7 @@ class ConversationsListActivity : BaseActivity() { } else if (forwardMessage) { if (hasChatPermission && !isReadOnlyConversation(selectedConversation!!)) { openConversation(intent.getStringExtra(KEY_FORWARD_MSG_TEXT)) - forwardMessage = false + forwardMessageState.value = false } else { Snackbar.make(binding.root, R.string.send_to_forbidden, Snackbar.LENGTH_LONG).show() } @@ -1810,19 +1520,6 @@ class ConversationsListActivity : BaseActivity() { } } - fun updateFilterConversationButtonColor() { - if (hasFilterEnabled()) { - binding.filterConversationsButton.let { viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) } - } else { - binding.filterConversationsButton.let { - viewThemeUtils.platform.colorImageView( - it, - ColorRole.ON_SURFACE_VARIANT - ) - } - } - } - fun openFollowedThreadsOverview() { val threadsUrl = ApiUtils.getUrlForSubscribedThreads( version = 1, @@ -1841,7 +1538,6 @@ class ConversationsListActivity : BaseActivity() { private val TAG = ConversationsListActivity::class.java.simpleName const val UNREAD_BUBBLE_DELAY = 2500 const val BOTTOM_SHEET_DELAY: Long = 2500 - private const val KEY_SEARCH_QUERY = "ConversationsListActivity.searchQuery" private const val CHAT_ACTIVITY_LOCAL_NAME = "com.nextcloud.talk.chat.ChatActivity" const val SEARCH_DEBOUNCE_INTERVAL_MS = 300 const val SEARCH_MIN_CHARS = 1 @@ -1852,7 +1548,6 @@ class ConversationsListActivity : BaseActivity() { const val HTTP_SERVICE_UNAVAILABLE = 503 const val MAINTENANCE_MODE_HEADER_KEY = "X-Nextcloud-Maintenance-Mode" const val REQUEST_POST_NOTIFICATIONS_PERMISSION = 111 - const val BADGE_OFFSET = 35 const val DAYS_FOR_NOTIFICATION_WARNING = 5L const val NOTIFICATION_WARNING_DATE_NOT_SET = 0L const val OFFSET_HEIGHT_DIVIDER: Int = 3 diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListTopBar.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListTopBar.kt new file mode 100644 index 00000000000..3fa002bfd46 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListTopBar.kt @@ -0,0 +1,691 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +@file:Suppress("TooManyFunctions", "MatchingDeclarationName") + +package com.nextcloud.talk.conversationlist.ui + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import coil.transform.CircleCropTransformation +import com.nextcloud.talk.R + +sealed class TopBarMode { + data object SearchBarIdle : TopBarMode() + + data class SearchActive(val query: String) : TopBarMode() + + data class TitleBar(val title: String, val showAccountChooser: Boolean = false) : TopBarMode() +} + +data class ConversationListTopBarState( + val mode: TopBarMode, + val showAvatarBadge: Boolean, + val avatarUrl: String?, + val credentials: String, + val showFilterActive: Boolean, + val showThreadsButton: Boolean +) + +@Suppress("LongParameterList") +data class ConversationListTopBarActions( + val onSearchQueryChange: (String) -> Unit, + val onSearchActivate: () -> Unit, + val onSearchClose: () -> Unit, + val onFilterClick: () -> Unit, + val onThreadsClick: () -> Unit, + val onAvatarClick: () -> Unit, + val onNavigateBack: () -> Unit, + val onAccountChooserClick: () -> Unit +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConversationListTopBar( + state: ConversationListTopBarState, + actions: ConversationListTopBarActions, + modifier: Modifier = Modifier +) { + val focusRequester = remember { FocusRequester() } + + if (state.mode is TopBarMode.SearchActive) { + BackHandler { + actions.onSearchQueryChange("") + actions.onSearchClose() + } + } + + Column(modifier = modifier.fillMaxWidth()) { + when (val mode = state.mode) { + is TopBarMode.SearchBarIdle -> TopBarIdleContent( + onSearchActivate = actions.onSearchActivate, + onFilterClick = actions.onFilterClick, + onThreadsClick = actions.onThreadsClick, + onAvatarClick = actions.onAvatarClick, + showAvatarBadge = state.showAvatarBadge, + avatarUrl = state.avatarUrl, + credentials = state.credentials, + showFilterActive = state.showFilterActive, + showThreadsButton = state.showThreadsButton + ) + is TopBarMode.SearchActive -> TopBarSearchActiveContent( + query = mode.query, + onQueryChange = actions.onSearchQueryChange, + onSearchClose = actions.onSearchClose, + focusRequester = focusRequester + ) + is TopBarMode.TitleBar -> TopBarTitleContent( + title = mode.title, + showAccountChooser = mode.showAccountChooser, + avatarUrl = state.avatarUrl, + credentials = state.credentials, + onNavigateBack = actions.onNavigateBack, + onAccountChooserClick = actions.onAccountChooserClick + ) + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun TopBarIdleContent( + onSearchActivate: () -> Unit, + onFilterClick: () -> Unit, + onThreadsClick: () -> Unit, + onAvatarClick: () -> Unit, + showAvatarBadge: Boolean, + avatarUrl: String?, + credentials: String, + showFilterActive: Boolean, + showThreadsButton: Boolean +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Card( + modifier = Modifier + .weight(1f) + .height(50.dp), + shape = RoundedCornerShape(25.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.appbar_search_in, stringResource(R.string.nc_app_product_name)), + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterStart) + .padding(start = 16.dp, end = if (showThreadsButton) 80.dp else 48.dp) + .clickable { onSearchActivate() }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Row( + modifier = Modifier.align(Alignment.CenterEnd), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onFilterClick, + modifier = Modifier.size(48.dp) + ) { + Box( + Modifier.fillMaxSize(), + contentAlignment = if (showThreadsButton) Alignment.CenterEnd else Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_filter_list_24), + contentDescription = stringResource(R.string.nc_filter), + tint = if (showFilterActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + ) + } + } + if (showThreadsButton) { + IconButton( + onClick = onThreadsClick, + modifier = Modifier.size(48.dp) + ) { + Icon( + painter = painterResource(R.drawable.outline_forum_24), + contentDescription = stringResource(R.string.threads), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + Spacer(modifier = Modifier.width(8.dp)) + AvatarButton( + avatarUrl = avatarUrl, + credentials = credentials, + showBadge = showAvatarBadge, + onClick = onAvatarClick + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBarSearchActiveContent( + query: String, + onQueryChange: (String) -> Unit, + onSearchClose: () -> Unit, + focusRequester: FocusRequester +) { + Column { + TopAppBar( + navigationIcon = { + IconButton(onClick = { + onQueryChange("") + onSearchClose() + }) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.nc_cancel), + tint = MaterialTheme.colorScheme.onSurface + ) + } + }, + title = { + SearchTextField( + query = query, + onQueryChange = onQueryChange, + focusRequester = focusRequester, + modifier = Modifier.fillMaxWidth() + ) + }, + actions = { + if (query.isNotEmpty()) { + IconButton(onClick = { + onQueryChange("") + focusRequester.requestFocus() + }) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_close_search), + contentDescription = stringResource(R.string.nc_search_clear), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ), + windowInsets = WindowInsets(0) + ) + HorizontalDivider() + } + LaunchedEffect(Unit) { focusRequester.requestFocus() } +} + +@Composable +private fun SearchTextField( + query: String, + onQueryChange: (String) -> Unit, + focusRequester: FocusRequester, + modifier: Modifier = Modifier +) { + BasicTextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier.focusRequester(focusRequester), + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface + ), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Search, + keyboardType = KeyboardType.Text + ), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (query.isEmpty()) { + Text( + text = stringResource( + R.string.appbar_search_in, + stringResource(R.string.nc_app_product_name) + ), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + innerTextField() + } + } + ) +} + +@Suppress("LongParameterList") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopBarTitleContent( + title: String, + showAccountChooser: Boolean, + avatarUrl: String?, + credentials: String, + onNavigateBack: () -> Unit, + onAccountChooserClick: () -> Unit +) { + TopAppBar( + title = { Text(text = title) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.back_button) + ) + } + }, + actions = { + if (showAccountChooser) { + IconButton(onClick = onAccountChooserClick) { + AsyncImage( + model = buildAvatarImageRequest(avatarUrl, credentials), + contentDescription = stringResource(R.string.nc_settings), + modifier = Modifier + .size(32.dp) + .clip(CircleShape), + placeholder = painterResource(R.drawable.ic_user), + error = painterResource(R.drawable.ic_user) + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) +} + +@Composable +private fun AvatarButton(avatarUrl: String?, credentials: String, showBadge: Boolean, onClick: () -> Unit) { + BadgedBox( + badge = { + if (showBadge) { + Badge( + containerColor = colorResource(R.color.badge_color), + modifier = Modifier.offset(x = (-BADGE_OFFSET_DP).dp, y = BADGE_OFFSET_DP.dp) + ) + } + } + ) { + IconButton(onClick = onClick) { + AsyncImage( + model = buildAvatarImageRequest(avatarUrl, credentials), + contentDescription = stringResource(R.string.nc_settings), + modifier = Modifier + .size(32.dp) + .clip(CircleShape), + placeholder = painterResource(R.drawable.ic_user), + error = painterResource(R.drawable.ic_user) + ) + } + } +} + +@Composable +private fun buildAvatarImageRequest(url: String?, credentials: String): ImageRequest { + val context = LocalContext.current + return ImageRequest.Builder(context) + .data(url) + .addHeader("Authorization", credentials) + .crossfade(true) + .transformations(CircleCropTransformation()) + .placeholder(R.drawable.ic_user) + .error(R.drawable.ic_user) + .build() +} + +private const val BADGE_OFFSET_DP = 8 + +@Preview(name = "Idle - no filter, no threads - Light") +@Preview(name = "Idle - no filter, no threads - Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "Idle - no filter, no threads - RTL Arabic", locale = "ar") +@Composable +private fun PreviewTopBarIdle() { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + Surface(color = MaterialTheme.colorScheme.surface) { + ConversationListTopBar( + state = ConversationListTopBarState( + mode = TopBarMode.SearchBarIdle, + showAvatarBadge = false, + avatarUrl = null, + credentials = "", + showFilterActive = false, + showThreadsButton = false + ), + actions = ConversationListTopBarActions( + onSearchQueryChange = {}, + onSearchActivate = {}, + onSearchClose = {}, + onFilterClick = {}, + onThreadsClick = {}, + onAvatarClick = {}, + onNavigateBack = {}, + onAccountChooserClick = {} + ) + ) + } + } +} + +@Preview(name = "Idle - filter active - Light") +@Preview(name = "Idle - filter active - Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "Idle - filter active - RTL Arabic", locale = "ar") +@Composable +private fun PreviewTopBarIdleFilterActive() { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + Surface(color = MaterialTheme.colorScheme.surface) { + ConversationListTopBar( + state = ConversationListTopBarState( + mode = TopBarMode.SearchBarIdle, + showAvatarBadge = false, + avatarUrl = null, + credentials = "", + showFilterActive = true, + showThreadsButton = false + ), + actions = ConversationListTopBarActions( + onSearchQueryChange = {}, + onSearchActivate = {}, + onSearchClose = {}, + onFilterClick = {}, + onThreadsClick = {}, + onAvatarClick = {}, + onNavigateBack = {}, + onAccountChooserClick = {} + ) + ) + } + } +} + +@Preview(name = "Idle - with threads button - Light") +@Preview(name = "Idle - with threads button - Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "Idle - with threads button - RTL Arabic", locale = "ar") +@Composable +private fun PreviewTopBarIdleWithThreads() { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + Surface(color = MaterialTheme.colorScheme.surface) { + ConversationListTopBar( + state = ConversationListTopBarState( + mode = TopBarMode.SearchBarIdle, + showAvatarBadge = false, + avatarUrl = null, + credentials = "", + showFilterActive = false, + showThreadsButton = true + ), + actions = ConversationListTopBarActions( + onSearchQueryChange = {}, + onSearchActivate = {}, + onSearchClose = {}, + onFilterClick = {}, + onThreadsClick = {}, + onAvatarClick = {}, + onNavigateBack = {}, + onAccountChooserClick = {} + ) + ) + } + } +} + +@Preview(name = "Idle - avatar badge visible - Light") +@Preview(name = "Idle - avatar badge visible - Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "Idle - avatar badge visible - RTL Arabic", locale = "ar") +@Composable +private fun PreviewTopBarIdleWithBadge() { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + Surface(color = MaterialTheme.colorScheme.surface) { + ConversationListTopBar( + state = ConversationListTopBarState( + mode = TopBarMode.SearchBarIdle, + showAvatarBadge = true, + avatarUrl = null, + credentials = "", + showFilterActive = false, + showThreadsButton = false + ), + actions = ConversationListTopBarActions( + onSearchQueryChange = {}, + onSearchActivate = {}, + onSearchClose = {}, + onFilterClick = {}, + onThreadsClick = {}, + onAvatarClick = {}, + onNavigateBack = {}, + onAccountChooserClick = {} + ) + ) + } + } +} + +@Preview(name = "Search active - empty query - Light") +@Preview(name = "Search active - empty query - Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "Search active - empty query - RTL Arabic", locale = "ar") +@Composable +private fun PreviewTopBarSearchEmpty() { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + Surface(color = MaterialTheme.colorScheme.surface) { + ConversationListTopBar( + state = ConversationListTopBarState( + mode = TopBarMode.SearchActive(query = ""), + showAvatarBadge = false, + avatarUrl = null, + credentials = "", + showFilterActive = false, + showThreadsButton = false + ), + actions = ConversationListTopBarActions( + onSearchQueryChange = {}, + onSearchActivate = {}, + onSearchClose = {}, + onFilterClick = {}, + onThreadsClick = {}, + onAvatarClick = {}, + onNavigateBack = {}, + onAccountChooserClick = {} + ) + ) + } + } +} + +@Preview(name = "Search active - with text - Light") +@Preview(name = "Search active - with text - Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "Search active - with text - RTL Arabic", locale = "ar") +@Composable +private fun PreviewTopBarSearchWithText() { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + Surface(color = MaterialTheme.colorScheme.surface) { + ConversationListTopBar( + state = ConversationListTopBarState( + mode = TopBarMode.SearchActive(query = "Nextcloud"), + showAvatarBadge = false, + avatarUrl = null, + credentials = "", + showFilterActive = false, + showThreadsButton = false + ), + actions = ConversationListTopBarActions( + onSearchQueryChange = {}, + onSearchActivate = {}, + onSearchClose = {}, + onFilterClick = {}, + onThreadsClick = {}, + onAvatarClick = {}, + onNavigateBack = {}, + onAccountChooserClick = {} + ) + ) + } + } +} + +@Preview(name = "TitleBar - Share To - Light") +@Preview(name = "TitleBar - Share To - Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "TitleBar - Share To - RTL Arabic", locale = "ar") +@Composable +private fun PreviewTopBarShareTo() { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + Surface(color = MaterialTheme.colorScheme.surface) { + ConversationListTopBar( + state = ConversationListTopBarState( + mode = TopBarMode.TitleBar(title = "Send to\u2026"), + showAvatarBadge = false, + avatarUrl = null, + credentials = "", + showFilterActive = false, + showThreadsButton = false + ), + actions = ConversationListTopBarActions( + onSearchQueryChange = {}, + onSearchActivate = {}, + onSearchClose = {}, + onFilterClick = {}, + onThreadsClick = {}, + onAvatarClick = {}, + onNavigateBack = {}, + onAccountChooserClick = {} + ) + ) + } + } +} + +@Preview(name = "TitleBar - Share To, multi-account - Light") +@Preview(name = "TitleBar - Share To, multi-account - Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "TitleBar - Share To, multi-account - RTL Arabic", locale = "ar") +@Composable +private fun PreviewTopBarShareToMultiAccount() { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + Surface(color = MaterialTheme.colorScheme.surface) { + ConversationListTopBar( + state = ConversationListTopBarState( + mode = TopBarMode.TitleBar(title = "Send to\u2026", showAccountChooser = true), + showAvatarBadge = false, + avatarUrl = null, + credentials = "", + showFilterActive = false, + showThreadsButton = false + ), + actions = ConversationListTopBarActions( + onSearchQueryChange = {}, + onSearchActivate = {}, + onSearchClose = {}, + onFilterClick = {}, + onThreadsClick = {}, + onAvatarClick = {}, + onNavigateBack = {}, + onAccountChooserClick = {} + ) + ) + } + } +} + +@Preview(name = "TitleBar - Forward To - Light") +@Preview(name = "TitleBar - Forward To - Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "TitleBar - Forward To - RTL Arabic", locale = "ar") +@Composable +private fun PreviewTopBarForward() { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + Surface(color = MaterialTheme.colorScheme.surface) { + ConversationListTopBar( + state = ConversationListTopBarState( + mode = TopBarMode.TitleBar(title = "Forward to\u2026"), + showAvatarBadge = false, + avatarUrl = null, + credentials = "", + showFilterActive = false, + showThreadsButton = false + ), + actions = ConversationListTopBarActions( + onSearchQueryChange = {}, + onSearchActivate = {}, + onSearchClose = {}, + onFilterClick = {}, + onThreadsClick = {}, + onAvatarClick = {}, + onNavigateBack = {}, + onAccountChooserClick = {} + ) + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt index 82206954ae8..d2be4dd7529 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt @@ -130,6 +130,70 @@ fun NoArchivedConversationsView() { } } +@Composable +fun SearchNoResultsView() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.ic_search_24px), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(72.dp), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(colorResource(R.color.grey_600), BlendMode.SrcIn) + ) + + Text( + text = stringResource(R.string.nc_no_search_results_headline), + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 8.dp), + color = colorResource(R.color.conversation_item_header), + fontSize = 22.sp, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = stringResource(R.string.nc_no_search_results_text), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + color = colorResource(R.color.textColorMaxContrast), + fontSize = 16.sp, + textAlign = TextAlign.Center + ) + } +} + +@Preview(name = "No search results · Light") +@Preview(name = "No search results · Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun SearchNoResultsPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + SearchNoResultsView() + } + } +} + +@Preview(name = "No search results · RTL / Arabic", locale = "ar") +@Composable +private fun SearchNoResultsRtlPreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + Surface { + SearchNoResultsView() + } + } +} + @Preview(name = "Empty – with logo · Light") @Preview(name = "Empty – with logo · Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index 63ba7e7821c..9098511247f 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt @@ -45,6 +45,7 @@ import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -133,17 +134,24 @@ class ConversationsListViewModel @Inject constructor( private val _federationInvitationHintVisible = MutableStateFlow(false) val federationInvitationHintVisible: StateFlow = _federationInvitationHintVisible.asStateFlow() - object ShowBadgeStartState : ViewState - object ShowBadgeErrorState : ViewState - open class ShowBadgeSuccessState(val showBadge: Boolean) : ViewState - - private val _showBadgeViewState: MutableLiveData = MutableLiveData(ShowBadgeStartState) - val showBadgeViewState: LiveData - get() = _showBadgeViewState + private val _showAvatarBadge = MutableStateFlow(false) + val showAvatarBadge: StateFlow = _showAvatarBadge.asStateFlow() private val searchResultEntries: MutableStateFlow> = MutableStateFlow(emptyList()) + /** Job tracking the currently active [getSearchQuery] coroutine; canceled on cleanup. */ + private var searchJob: Job? = null + + /** + * Tracks the query that the last completed/running [getSearchQuery] call was started for. + * Used to skip re-searching the same query on configuration changes (rotation, display + * off/on) where the [LaunchedEffect] in the Compose toolbar fires again with an unchanged + * key but a brand-new composition. Reset whenever search is canceled or deactivated so + * that the next explicit search always runs. + */ + private var lastSearchedFilter: String = "" + private val _filterStateFlow = MutableStateFlow>( mapOf(MENTION to false, UNREAD to false, ARCHIVE to false, DEFAULT to true) ) @@ -155,6 +163,15 @@ class ConversationsListViewModel @Inject constructor( private val _currentSearchQueryFlow = MutableStateFlow("") val currentSearchQueryFlow: StateFlow = _currentSearchQueryFlow.asStateFlow() + /** + * True from the moment the user types a non-empty query (debounce starts) until the first + * set of search results arrives. Used by the UI to distinguish "waiting for results" from + * "search completed with no results", so that [SearchNoResultsView] is not shown + * prematurely during the search round-trip. + */ + private val _isSearchLoadingFlow = MutableStateFlow(false) + val isSearchLoadingFlow: StateFlow = _isSearchLoadingFlow.asStateFlow() + private val hideRoomToken = MutableStateFlow(null) /** @@ -199,15 +216,40 @@ class ConversationsListViewModel @Inject constructor( /** Cancel any active message search and clear search results. */ fun cancelSearch() { + lastSearchedFilter = "" + _isSearchLoadingFlow.value = false + searchJob?.cancel() + searchJob = null searchHelper.cancelSearch() searchResultEntries.value = emptyList() _currentSearchQueryFlow.value = "" } + /** + * Updates the displayed query text without triggering an immediate search. + * Debouncing is handled by the caller via [kotlinx.coroutines.delay] in a + * [androidx.compose.runtime.LaunchedEffect]. + */ + fun setSearchQuery(text: String) { + _currentSearchQueryFlow.value = text + if (text.isEmpty()) { + _isSearchLoadingFlow.value = false + searchResultEntries.value = emptyList() + } else { + _isSearchLoadingFlow.value = true + } + } + /** Mark the SearchView as expanded (true) or collapsed (false). */ fun setIsSearchActive(active: Boolean) { _isSearchActiveFlow.value = active if (!active) { + cancelSearch() + } else { + // Clear any stale results from a previous search so they don't + // flash on screen before the user has typed the first character. + lastSearchedFilter = "" + _isSearchLoadingFlow.value = false searchResultEntries.value = emptyList() _currentSearchQueryFlow.value = "" } @@ -220,7 +262,7 @@ class ConversationsListViewModel @Inject constructor( fun getFederationInvitations() { _federationInvitationHintVisible.value = false - _showBadgeViewState.value = ShowBadgeStartState + _showAvatarBadge.value = false userManager.users.blockingGet()?.forEach { invitationsRepository.fetchInvitations(it) @@ -232,6 +274,14 @@ class ConversationsListViewModel @Inject constructor( @Suppress("LongMethod") fun getSearchQuery(context: Context, filter: String) { + // Rotation / display-off guard: if the composition restarts (config change) the + // LaunchedEffect fires again with the same query. Skip the search so the existing + // results are not replaced by a partial first-emit from the cold network flows. + if (filter == lastSearchedFilter && searchResultEntries.value.isNotEmpty()) return + + _isSearchLoadingFlow.value = true + lastSearchedFilter = filter + searchJob?.cancel() _currentSearchQueryFlow.value = filter val conversationsTitle = context.resources.getString(R.string.conversations) val openConversationsTitle = context.resources.getString(R.string.openConversations) @@ -239,7 +289,7 @@ class ConversationsListViewModel @Inject constructor( val messagesTitle = context.resources.getString(R.string.messages) val actorTypeConverter = EnumActorTypeConverter() - viewModelScope.launch { + searchJob = viewModelScope.launch { combine( getRoomsStateFlow.map { list -> list.filter { it.displayName?.contains(filter, ignoreCase = true) == true } @@ -283,6 +333,7 @@ class ConversationsListViewModel @Inject constructor( entries.toList() }.collect { results -> searchResultEntries.emit(results) + _isSearchLoadingFlow.value = false } } } @@ -429,7 +480,7 @@ class ConversationsListViewModel @Inject constructor( searchResults: List, hideToken: String? ): List { - if (isSearchActive && searchResults.isNotEmpty()) return searchResults + if (isSearchActive) return searchResults val hasFilterEnabled = filterState[MENTION] == true || filterState[UNREAD] == true || @@ -505,7 +556,7 @@ class ConversationsListViewModel @Inject constructor( _federationInvitationHintVisible.value = invitationsModel.invitations.isNotEmpty() } else { if (invitationsModel.invitations.isNotEmpty()) { - _showBadgeViewState.value = ShowBadgeSuccessState(true) + _showAvatarBadge.value = true } } } diff --git a/app/src/main/res/drawable/ic_search_24px.xml b/app/src/main/res/drawable/ic_search_24px.xml new file mode 100644 index 00000000000..e86cf61e4d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_24px.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/layout/activity_conversations.xml b/app/src/main/res/layout/activity_conversations.xml index d6cd0eefe57..eaf12d08ae8 100644 --- a/app/src/main/res/layout/activity_conversations.xml +++ b/app/src/main/res/layout/activity_conversations.xml @@ -7,7 +7,6 @@ --> @@ -18,9 +17,6 @@ android:layout_height="wrap_content" android:background="@color/bg_default" android:elevation="0dp" - android:clipChildren="true" - android:clipToPadding="false" - android:windowContentOverlay="@null" app:elevation="0dp" app:liftOnScrollColor="@color/bg_default"> @@ -29,139 +25,10 @@ android:layout_width="match_parent" android:layout_height="wrap_content" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="wrap_content" /> - - - - - - - - - From 5763c1373b9a61294f378fdbb2aca701aa02cdd9 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 23 Mar 2026 23:21:57 +0100 Subject: [PATCH 13/21] feat(conv-list): Migrate conversation list screen AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../java/com/nextcloud/talk/ui/LoginIT.java | 4 +- .../ConversationsListActivity.kt | 700 ++++-------------- .../conversationlist/ui/ConversationList.kt | 11 +- .../ui/ConversationListFab.kt | 4 +- .../ui/ConversationListItem.kt | 33 +- .../ui/ConversationListTopBar.kt | 192 ++--- .../ui/ConversationsListScreen.kt | 604 +++++++++++++++ .../viewmodels/ConversationsListViewModel.kt | 4 +- .../res/animator/appbar_elevation_off.xml | 17 - .../main/res/animator/appbar_elevation_on.xml | 17 - app/src/main/res/drawable/cutout_circle.xml | 11 - app/src/main/res/drawable/ic_menu.xml | 15 - .../res/layout/activity_conversations.xml | 98 --- .../res/layout/federated_invitation_hint.xml | 39 - app/src/main/res/layout/search_layout.xml | 96 --- app/src/main/res/values-night/colors.xml | 1 - app/src/main/res/values/colors.xml | 1 - app/src/main/res/values/dimens.xml | 1 - app/src/main/res/values/strings.xml | 2 - 19 files changed, 882 insertions(+), 968 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt delete mode 100644 app/src/main/res/animator/appbar_elevation_off.xml delete mode 100644 app/src/main/res/animator/appbar_elevation_on.xml delete mode 100644 app/src/main/res/drawable/cutout_circle.xml delete mode 100644 app/src/main/res/drawable/ic_menu.xml delete mode 100644 app/src/main/res/layout/activity_conversations.xml delete mode 100644 app/src/main/res/layout/federated_invitation_hint.xml delete mode 100644 app/src/main/res/layout/search_layout.xml diff --git a/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java b/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java index 5a432cef59b..75cb101dfff 100644 --- a/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java +++ b/app/src/androidTest/java/com/nextcloud/talk/ui/LoginIT.java @@ -67,7 +67,7 @@ public void login() throws InterruptedException { try { // Delete account if exists - onView(withId(R.id.switch_account_button)).perform(click()); + onView(withContentDescription(R.string.nc_settings)).perform(click()); onView(withId(R.id.settings_remove_account)).perform(click()); onView(withText(R.string.nc_settings_remove)).perform(click()); // The remove button must be clicked two times @@ -120,7 +120,7 @@ public void login() throws InterruptedException { Thread.sleep(5 * 1000); - onView(withId(R.id.switch_account_button)).perform(click()); + onView(withContentDescription(R.string.nc_settings)).perform(click()); onView(withId(R.id.user_name)).check(matches(withText("User One"))); activityScenario.onActivity(activity -> { diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 5674b094d3a..bc9ae8b7dd8 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -7,7 +7,6 @@ package com.nextcloud.talk.conversationlist import android.Manifest -import android.animation.AnimatorInflater import android.annotation.SuppressLint import android.content.ActivityNotFoundException import android.content.Intent @@ -19,25 +18,14 @@ import android.provider.Settings import android.util.Log import android.widget.Toast import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent import androidx.appcompat.app.AlertDialog -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.imePadding -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.material3.SnackbarHostState import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.work.Data import androidx.work.OneTimeWorkRequest @@ -45,38 +33,24 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar import com.nextcloud.talk.R import com.nextcloud.talk.account.BrowserLoginActivity import com.nextcloud.talk.account.ServerSelectionActivity import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.activities.CallActivity import com.nextcloud.talk.activities.MainActivity -import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.contextchat.ContextChatViewModel -import com.nextcloud.talk.conversationlist.ui.ConversationList -import com.nextcloud.talk.conversationlist.ui.ConversationListFab -import com.nextcloud.talk.conversationlist.ui.ConversationListSkeleton -import com.nextcloud.talk.conversationlist.ui.ConversationListTopBar -import com.nextcloud.talk.conversationlist.ui.ConversationListTopBarActions -import com.nextcloud.talk.conversationlist.ui.ConversationListTopBarState -import com.nextcloud.talk.conversationlist.ui.ConversationsEmptyStateView -import com.nextcloud.talk.conversationlist.ui.SearchNoResultsView -import com.nextcloud.talk.conversationlist.ui.FederationInvitationHintCard -import com.nextcloud.talk.conversationlist.ui.NotificationWarningCard -import com.nextcloud.talk.conversationlist.ui.StatusBannerRow -import com.nextcloud.talk.conversationlist.ui.TopBarMode -import com.nextcloud.talk.conversationlist.ui.UnreadMentionBubble +import com.nextcloud.talk.conversationlist.ui.ConversationsListScreen +import com.nextcloud.talk.conversationlist.ui.ConversationsListScreenCallbacks +import com.nextcloud.talk.conversationlist.ui.ConversationsListScreenState import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel import com.nextcloud.talk.data.network.NetworkMonitor import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.databinding.ActivityConversationsBinding import com.nextcloud.talk.events.ConversationsListFetchDataEvent import com.nextcloud.talk.events.EventStatus import com.nextcloud.talk.invitation.InvitationsActivity @@ -89,9 +63,7 @@ import com.nextcloud.talk.models.domain.SearchMessageEntry import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.settings.SettingsActivity import com.nextcloud.talk.threadsoverview.ThreadsOverviewActivity -import com.nextcloud.talk.ui.BackgroundVoiceMessageCard import com.nextcloud.talk.ui.chooseaccount.ChooseAccountShareToDialogFragment -import com.nextcloud.talk.ui.dialog.ChooseAccountDialogCompose import com.nextcloud.talk.ui.dialog.ConversationsListBottomDialog import com.nextcloud.talk.ui.dialog.FilterConversationFragment import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.ARCHIVE @@ -104,7 +76,6 @@ import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.CapabilitiesUtil.isServerEOL import com.nextcloud.talk.utils.ClosedInterfaceImpl import com.nextcloud.talk.utils.ConversationUtils -import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.NotificationUtils @@ -123,7 +94,6 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SHARED_TEXT import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.power.PowerManagerUtils import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach @@ -131,7 +101,6 @@ import kotlinx.coroutines.launch import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import retrofit2.HttpException -import java.io.File import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -140,20 +109,12 @@ import javax.inject.Inject @Suppress("LargeClass", "TooManyFunctions") class ConversationsListActivity : BaseActivity() { - private lateinit var binding: ActivityConversationsBinding - @Inject lateinit var userManager: UserManager - @Inject - lateinit var ncApi: NcApi - @Inject lateinit var platformPermissionUtil: PlatformPermissionUtil - @Inject - lateinit var arbitraryStorageManager: ArbitraryStorageManager - @Inject lateinit var viewModelFactory: ViewModelProvider.Factory @@ -169,17 +130,14 @@ class ConversationsListActivity : BaseActivity() { lateinit var conversationsListViewModel: ConversationsListViewModel lateinit var contextChatViewModel: ContextChatViewModel - override val appBarLayoutType: AppBarLayoutType - get() = AppBarLayoutType.SEARCH_BAR - private var currentUser: User? = null + private val snackbarHostState = SnackbarHostState() private val isMaintenanceModeState = MutableStateFlow(false) - private val isListEmptyState = MutableStateFlow(false) - private val showNoArchivedViewState = MutableStateFlow(false) private val showUnreadBubbleState = MutableStateFlow(false) private val isFabVisibleState = MutableStateFlow(true) private val showNotificationWarningState = MutableStateFlow(false) private val isRefreshingState = MutableStateFlow(false) + private val showAccountDialogState = MutableStateFlow(false) // Lazy list state – set from inside setContent, read from onPause private var conversationListLazyListState: androidx.compose.foundation.lazy.LazyListState? = null @@ -216,26 +174,104 @@ class ConversationsListActivity : BaseActivity() { conversationsListViewModel = ViewModelProvider(this, viewModelFactory)[ConversationsListViewModel::class.java] contextChatViewModel = ViewModelProvider(this, viewModelFactory)[ContextChatViewModel::class.java] - binding = ActivityConversationsBinding.inflate(layoutInflater) setSupportActionBar(null) - setContentView(binding.root) - setupStatusBanner() - setupEmptyStateView() - setupFab() - setupUnreadBubble() - setupShimmer() - setupNotificationWarning() - setupFederationHintCard() - setupConversationList() - setupTopBar() - initSystemBars() - forwardMessageState.value = intent.getBooleanExtra(KEY_FORWARD_MSG_FLAG, false) onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + setContent { + ConversationsListScreen( + viewModel = conversationsListViewModel, + contextChatViewModel = contextChatViewModel, + chatViewModel = chatViewModel, + state = buildScreenState(), + callbacks = buildScreenCallbacks() + ) + } + initObservers() } + private fun buildScreenState() = + ConversationsListScreenState( + currentUser = currentUser, + credentials = credentials ?: "", + showLogo = BrandingUtils.isOriginalNextcloudClient(applicationContext), + viewThemeUtils = viewThemeUtils, + isShowEcosystem = appPreferences.isShowEcosystem && !resources.getBoolean(R.bool.is_branded_client), + snackbarHostState = snackbarHostState, + isMaintenanceModeFlow = isMaintenanceModeState, + isOnlineFlow = networkMonitor.isOnline, + showUnreadBubbleFlow = showUnreadBubbleState, + isFabVisibleFlow = isFabVisibleState, + showNotificationWarningFlow = showNotificationWarningState, + isRefreshingFlow = isRefreshingState, + showShareToFlow = showShareToScreenState, + forwardMessageFlow = forwardMessageState, + hasMultipleAccountsFlow = hasMultipleAccountsState, + showAccountDialogFlow = showAccountDialogState + ) + + @Suppress("LongMethod") + private fun buildScreenCallbacks() = + ConversationsListScreenCallbacks( + onLazyListStateAvailable = { listState -> conversationListLazyListState = listState }, + onScrollChanged = { isFabVisibleState.value = !it }, + onScrollStopped = { checkToShowUnreadBubble(it) }, + onConversationClick = { handleConversation(it) }, + onConversationLongClick = { handleConversationLongClick(it) }, + onMessageResultClick = { showContextChatForMessage(it) }, + onContactClick = { contactsViewModel.createRoom(ROOM_TYPE_ONE_ONE, null, it.actorId!!, null) }, + onLoadMoreClick = { conversationsListViewModel.loadMoreMessages(context) }, + onRefresh = { + isMaintenanceModeState.value = false + isRefreshingState.value = true + fetchRooms() + fetchPendingInvitations() + }, + onFabClick = { + run(context) + showNewConversationsScreen() + }, + onUnreadBubbleClick = { + lifecycleScope.launch { + conversationListLazyListState?.scrollToItem(nextUnreadConversationScrollPosition, 0) + } + showUnreadBubbleState.value = false + }, + onNotificationWarningNotNow = { + appPreferences.setNotificationWarningLastPostponedDate(System.currentTimeMillis()) + showNotificationWarningState.value = false + }, + onNotificationWarningShowSettings = { + val bundle = Bundle() + bundle.putBoolean(KEY_SCROLL_TO_NOTIFICATION_CATEGORY, true) + val settingsIntent = Intent(context, SettingsActivity::class.java) + settingsIntent.putExtras(bundle) + startActivity(settingsIntent) + }, + onFederationHintClick = { startActivity(Intent(context, InvitationsActivity::class.java)) }, + onFilterClick = { + FilterConversationFragment + .newInstance(conversationsListViewModel.filterStateFlow.value.toMutableMap()) + .show(supportFragmentManager, FilterConversationFragment.TAG) + }, + onThreadsClick = { openFollowedThreadsOverview() }, + onAvatarClick = { + if (resources.getBoolean(R.bool.multiaccount_support)) { + showChooseAccountDialog() + } else { + startActivity(Intent(context, SettingsActivity::class.java)) + } + }, + onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, + onAccountChooserClick = { + ChooseAccountShareToDialogFragment.newInstance() + .show(supportFragmentManager, ChooseAccountShareToDialogFragment.TAG) + }, + onNewConversation = { showNewConversationsScreen() }, + onAccountDialogDismiss = { showAccountDialogState.value = false } + ) + override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) @@ -274,7 +310,7 @@ class ConversationsListActivity : BaseActivity() { fetchPendingInvitations() } else { Log.e(TAG, "currentUser was null") - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + showSnackbar(getString(R.string.nc_common_error_sorry)) } conversationsListViewModel.checkIfThreadsExist() @@ -295,12 +331,11 @@ class ConversationsListActivity : BaseActivity() { when (state) { is ConversationsListViewModel.GetRoomsSuccessState -> { isRefreshingState.value = false - initOverallLayout(state.listIsNotEmpty) } is ConversationsListViewModel.GetRoomsErrorState -> { isRefreshingState.value = false - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_SHORT).show() + handleHttpExceptions(state.throwable) } else -> {} @@ -342,60 +377,9 @@ class ConversationsListActivity : BaseActivity() { lifecycleScope.launch { conversationsListViewModel.filterStateFlow.collect { filterState -> - val archiveFilterOn = filterState[ARCHIVE] == true - showNoArchivedViewState.value = archiveFilterOn - if (archiveFilterOn) showUnreadBubbleState.value = false + if (filterState[ARCHIVE] == true) showUnreadBubbleState.value = false } } - - lifecycleScope.launch { - chatViewModel.backgroundPlayUIFlow.onEach { msg -> - binding.composeViewForBackgroundPlay.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - msg?.let { - val duration = chatViewModel.mediaPlayerDuration - val position = chatViewModel.mediaPlayerPosition - val offset = position.toFloat() / duration - val imageURI = ApiUtils.getUrlForAvatar( - currentUser?.baseUrl, - msg.actorId, - true, - darkMode = DisplayUtils.isDarkModeOn(LocalContext.current) - ) - val conversationImageURI = ApiUtils.getUrlForConversationAvatar( - ApiUtils.API_V1, - currentUser?.baseUrl, - msg.token - ) - - if (duration > 0) { - BackgroundVoiceMessageCard( - msg.actorDisplayName!!, - duration - position, - offset, - imageURI, - conversationImageURI, - viewThemeUtils, - context - ) - .GetView({ isPaused -> - if (isPaused) { - chatViewModel.pauseMediaPlayer(false) - } else { - val filename = msg.selectedIndividualHashMap!!["name"] - val file = File(context.cacheDir, filename!!) - chatViewModel.startMediaPlayer(file.canonicalPath) - } - }) { - chatViewModel.stopMediaPlayer() - } - } - } - } - } - }.collect() - } } private fun handleNoteToSelfShortcut(noteToSelfAvailable: Boolean, noteToSelfToken: String) { @@ -421,15 +405,6 @@ class ConversationsListActivity : BaseActivity() { } } - fun applyFilter() { - conversationsListViewModel.reloadFilterFromStorage(UserIdUtils.getIdForUser(currentUser)) - } - - private fun hasFilterEnabled(): Boolean = - conversationsListViewModel.filterStateFlow.value.any { (k, v) -> - k != FilterConversationFragment.DEFAULT && v - } - fun showOnlyNearFutureEvents() { // Reset all filters so the ViewModel's default view (non-archived, non-future-events) is shown conversationsListViewModel.applyFilter( @@ -442,186 +417,6 @@ class ConversationsListActivity : BaseActivity() { ) } - private fun setupConversationList() { - binding.conversationListComposeView.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val colorScheme = remember { viewThemeUtils.getColorScheme(context) } - val entries by conversationsListViewModel.conversationListEntriesFlow - .collectAsStateWithLifecycle() - val isRefreshing by isRefreshingState.collectAsStateWithLifecycle() - val searchQuery by conversationsListViewModel.currentSearchQueryFlow - .collectAsStateWithLifecycle() - val isSearchActive by conversationsListViewModel.isSearchActiveFlow - .collectAsStateWithLifecycle() - val isSearchLoading by conversationsListViewModel.isSearchLoadingFlow - .collectAsStateWithLifecycle() - val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState() - - // Store reference so Activity can read scroll position in onPause - androidx.compose.runtime.DisposableEffect(lazyListState) { - conversationListLazyListState = lazyListState - onDispose { conversationListLazyListState = null } - } - - MaterialTheme(colorScheme = colorScheme) { - if (isSearchActive && entries.isEmpty() && searchQuery.isNotEmpty() && !isSearchLoading) { - Box( - modifier = Modifier.fillMaxSize().imePadding(), - contentAlignment = Alignment.Center - ) { - SearchNoResultsView() - } - } else { - ConversationList( - entries = entries, - isRefreshing = isRefreshing, - currentUser = currentUser!!, - credentials = credentials ?: "", - searchQuery = searchQuery, - onConversationClick = { handleConversation(it) }, - onConversationLongClick = { handleConversationLongClick(it) }, - onMessageResultClick = { showContextChatForMessage(it) }, - onContactClick = { - contactsViewModel.createRoom(ROOM_TYPE_ONE_ONE, null, it.actorId!!, null) - }, - onLoadMoreClick = { conversationsListViewModel.loadMoreMessages(context) }, - onRefresh = { - isMaintenanceModeState.value = false - isRefreshingState.value = true - fetchRooms() - fetchPendingInvitations() - }, - onScrollChanged = { isFabVisibleState.value = !it }, - onScrollStopped = { checkToShowUnreadBubble(it) }, - listState = lazyListState - ) - } - } - } - } - } - - @Suppress("LongMethod") - private fun setupTopBar() { - updateAppBarElevation(elevated = false) - binding.toolbarComposeView.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val colorScheme = remember { viewThemeUtils.getColorScheme(context) } - - val isSearchActive by conversationsListViewModel.isSearchActiveFlow - .collectAsStateWithLifecycle() - val searchQuery by conversationsListViewModel.currentSearchQueryFlow - .collectAsStateWithLifecycle() - val showAvatarBadge by conversationsListViewModel.showAvatarBadge - .collectAsStateWithLifecycle() - val filterState by conversationsListViewModel.filterStateFlow - .collectAsStateWithLifecycle() - val threadsState by conversationsListViewModel.threadsExistState - .collectAsStateWithLifecycle() - val showShareTo by showShareToScreenState.collectAsStateWithLifecycle() - val isForward by forwardMessageState.collectAsStateWithLifecycle() - val multipleAccounts by hasMultipleAccountsState.collectAsStateWithLifecycle() - - val showFilterActive = filterState.any { (k, v) -> - k != FilterConversationFragment.DEFAULT && v - } - val showThreadsButton = - threadsState is ConversationsListViewModel.ThreadsExistUiState.Success && - (threadsState as ConversationsListViewModel.ThreadsExistUiState.Success) - .threadsExistence == true - - val mode: TopBarMode = when { - showShareTo -> TopBarMode.TitleBar( - title = getString(R.string.send_to_three_dots), - showAccountChooser = multipleAccounts - ) - isForward -> TopBarMode.TitleBar( - title = getString(R.string.nc_forward_to_three_dots), - showAccountChooser = false - ) - isSearchActive -> TopBarMode.SearchActive(query = searchQuery) - else -> TopBarMode.SearchBarIdle - } - - val avatarUrl = remember(currentUser) { - ApiUtils.getUrlForAvatar( - currentUser?.baseUrl, - currentUser?.userId, - true, - darkMode = DisplayUtils.isDarkModeOn(this@ConversationsListActivity) - ) - } - - // Debounce search: 300 ms after last keystroke, then fire ViewModel search. - LaunchedEffect(searchQuery) { - if (searchQuery.isNotEmpty()) { - delay(SEARCH_DEBOUNCE_INTERVAL_MS.toLong()) - if (searchQuery.length >= SEARCH_MIN_CHARS) { - conversationsListViewModel.getSearchQuery(context, searchQuery) - } - } - } - - MaterialTheme(colorScheme = colorScheme) { - ConversationListTopBar( - state = ConversationListTopBarState( - mode = mode, - showAvatarBadge = showAvatarBadge, - avatarUrl = avatarUrl, - credentials = credentials ?: "", - showFilterActive = showFilterActive, - showThreadsButton = showThreadsButton - ), - actions = ConversationListTopBarActions( - onSearchQueryChange = { conversationsListViewModel.setSearchQuery(it) }, - onSearchActivate = { - conversationsListViewModel.setIsSearchActive(true) - updateAppBarElevation(elevated = true) - viewThemeUtils.platform.themeStatusBar(this@ConversationsListActivity) - }, - onSearchClose = { - conversationsListViewModel.setIsSearchActive(false) - updateAppBarElevation(elevated = false) - viewThemeUtils.platform.resetStatusBar(this@ConversationsListActivity) - lifecycleScope.launch { - conversationListLazyListState?.scrollToItem(0) - } - }, - onFilterClick = { - FilterConversationFragment - .newInstance( - conversationsListViewModel.filterStateFlow.value.toMutableMap() - ) - .show(supportFragmentManager, FilterConversationFragment.TAG) - }, - onThreadsClick = { openFollowedThreadsOverview() }, - onAvatarClick = { - if (resources.getBoolean(R.bool.multiaccount_support)) { - showChooseAccountDialog() - } else { - startActivity(Intent(context, SettingsActivity::class.java)) - } - }, - onNavigateBack = { onBackPressedDispatcher.onBackPressed() }, - onAccountChooserClick = { - ChooseAccountShareToDialogFragment.newInstance() - .show(supportFragmentManager, ChooseAccountShareToDialogFragment.TAG) - } - ) - ) - } - } - } - } - - private fun updateAppBarElevation(elevated: Boolean) { - val animRes = if (elevated) R.animator.appbar_elevation_on else R.animator.appbar_elevation_off - binding.conversationListAppbar.stateListAnimator = - AnimatorInflater.loadStateListAnimator(binding.conversationListAppbar.context, animRes) - } - private fun handleConversationLongClick(model: ConversationModel) { lifecycleScope.launch { if (!showShareToScreen && networkMonitor.isOnline.value) { @@ -636,46 +431,29 @@ class ConversationsListActivity : BaseActivity() { } private fun showContextChatForMessage(result: SearchMessageEntry) { - binding.genericComposeView.apply { - setContent { - contextChatViewModel.getContextForChatMessages( - credentials = credentials!!, - baseUrl = currentUser!!.baseUrl!!, - token = result.conversationToken, - threadId = result.threadId, - messageId = result.messageId!!, - title = result.title - ) - com.nextcloud.talk.contextchat.ContextChatView(context, contextChatViewModel) - } - } + contextChatViewModel.getContextForChatMessages( + credentials = credentials ?: "", + baseUrl = currentUser?.baseUrl ?: "", + token = result.conversationToken, + threadId = result.threadId, + messageId = result.messageId ?: "", + title = result.title + ) } fun filterConversation() { - // Delegate to ViewModel; FilterConversationFragment still calls this via cast. - // filterStateFlow observer in initObservers handles showNoArchivedViewState + button colour. conversationsListViewModel.reloadFilterFromStorage(UserIdUtils.getIdForUser(currentUser)) } private fun showChooseAccountDialog() { - val brandedClient = getResources().getBoolean(R.bool.is_branded_client) - binding.genericComposeView.apply { - val shouldDismiss = mutableStateOf(false) - setContent { - ChooseAccountDialogCompose().GetChooseAccountDialog( - shouldDismiss, - this@ConversationsListActivity, - appPreferences.isShowEcosystem && !brandedClient - ) - } - } + showAccountDialogState.value = true } private fun hasActivityActionSendIntent(): Boolean = Intent.ACTION_SEND == intent.action || Intent.ACTION_SEND_MULTIPLE == intent.action fun showSnackbar(text: String) { - Snackbar.make(binding.root, text, Snackbar.LENGTH_LONG).show() + lifecycleScope.launch { snackbarHostState.showSnackbar(text) } } fun fetchRooms() { @@ -688,8 +466,23 @@ class ConversationsListActivity : BaseActivity() { } } - private fun initOverallLayout(isConversationListNotEmpty: Boolean) { - isListEmptyState.value = !isConversationListNotEmpty + private fun handleHttpExceptions(throwable: Throwable) { + if (!networkMonitor.isOnline.value) return + + if (throwable is HttpException) { + when (throwable.code()) { + HTTP_UNAUTHORIZED -> showUnauthorizedDialog() + HTTP_CLIENT_UPGRADE_REQUIRED -> showOutdatedClientDialog() + HTTP_SERVICE_UNAVAILABLE -> showServiceUnavailableDialog(throwable) + else -> { + Log.e(TAG, "Http Exception in ConversationListActivity", throwable) + showErrorDialog() + } + } + } else { + Log.e(TAG, "Exception in ConversationListActivity", throwable) + showErrorDialog() + } } private fun showErrorDialog() { @@ -727,137 +520,6 @@ class ConversationsListActivity : BaseActivity() { ) } - private fun setupStatusBanner() { - binding.statusBannerComposeView.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val colorScheme = remember { viewThemeUtils.getColorScheme(context) } - val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val isMaintenanceMode by isMaintenanceModeState.collectAsStateWithLifecycle() - MaterialTheme(colorScheme = colorScheme) { - StatusBannerRow( - isOffline = !isOnline, - isMaintenanceMode = isMaintenanceMode - ) - } - } - } - } - - private fun setupEmptyStateView() { - val showLogo = BrandingUtils.isOriginalNextcloudClient(applicationContext) - binding.emptyStateComposeView.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val colorScheme = remember { viewThemeUtils.getColorScheme(context) } - val isListEmpty by isListEmptyState.collectAsStateWithLifecycle() - val showNoArchivedView by showNoArchivedViewState.collectAsStateWithLifecycle() - MaterialTheme(colorScheme = colorScheme) { - ConversationsEmptyStateView( - isListEmpty = isListEmpty, - showNoArchivedView = showNoArchivedView, - showLogo = showLogo, - onCreateNewConversation = { showNewConversationsScreen() } - ) - } - } - } - } - - private fun setupFab() { - binding.fabComposeView.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val colorScheme = remember { viewThemeUtils.getColorScheme(context) } - val isOnline by networkMonitor.isOnline.collectAsStateWithLifecycle() - val isFabVisible by isFabVisibleState.collectAsStateWithLifecycle() - val isSearchActive by conversationsListViewModel.isSearchActiveFlow.collectAsStateWithLifecycle() - MaterialTheme(colorScheme = colorScheme) { - ConversationListFab( - isVisible = isFabVisible && !isSearchActive, - isEnabled = isOnline, - onClick = { - run(context) - showNewConversationsScreen() - } - ) - } - } - } - } - - private fun setupUnreadBubble() { - binding.unreadBubbleComposeView.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val colorScheme = remember { viewThemeUtils.getColorScheme(context) } - val showBubble by showUnreadBubbleState.collectAsStateWithLifecycle() - val isSearchActive by conversationsListViewModel.isSearchActiveFlow.collectAsStateWithLifecycle() - MaterialTheme(colorScheme = colorScheme) { - UnreadMentionBubble( - visible = showBubble && !isSearchActive, - onClick = { - lifecycleScope.launch { - conversationListLazyListState?.scrollToItem( - nextUnreadConversationScrollPosition, - 0 - ) - } - showUnreadBubbleState.value = false - } - ) - } - } - } - } - - private fun setupShimmer() { - binding.shimmerComposeView.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val colorScheme = remember { viewThemeUtils.getColorScheme(context) } - val isShimmerVisible by conversationsListViewModel.isShimmerVisible.collectAsStateWithLifecycle() - MaterialTheme(colorScheme = colorScheme) { - ConversationListSkeleton(isVisible = isShimmerVisible) - } - } - } - } - - private suspend fun fetchOpenConversations(searchTerm: String) { - conversationsListViewModel.fetchOpenConversations(searchTerm) - } - - private suspend fun fetchUsers(query: String = "") { - contactsViewModel.getBlockingContactsFromSearchParams(query) - } - - private fun handleHttpExceptions(throwable: Throwable) { - if (!networkMonitor.isOnline.value) return - - if (throwable is HttpException) { - when (throwable.code()) { - HTTP_UNAUTHORIZED -> showUnauthorizedDialog() - HTTP_CLIENT_UPGRADE_REQUIRED -> showOutdatedClientDialog() - HTTP_SERVICE_UNAVAILABLE -> showServiceUnavailableDialog(throwable) - else -> { - Log.e(TAG, "Http Exception in ConversationListActivity", throwable) - showErrorDialog() - } - } - } else { - Log.e(TAG, "Exception in ConversationListActivity", throwable) - showErrorDialog() - } - } - - @SuppressLint("ClickableViewAccessibility") - private fun prepareViews() { - isMaintenanceModeState.value = false - // RecyclerView setup moved to setupConversationList(). - // Only legacy view wiring that hasn't migrated to Compose yet remains here. - } - @Suppress("Detekt.TooGenericExceptionCaught") private fun checkToShowUnreadBubble(lastVisibleIndex: Int) { if (conversationsListViewModel.isSearchActiveFlow.value) { @@ -911,18 +573,6 @@ class ConversationsListActivity : BaseActivity() { super.onDestroy() } - private fun showConversationByToken(conversationToken: String) { - val entries = conversationsListViewModel.conversationListEntriesFlow.value - for (entry in entries) { - if (entry is com.nextcloud.talk.conversationlist.ui.ConversationListEntry.ConversationEntry && - entry.model.token == conversationToken - ) { - handleConversation(entry.model) - return - } - } - } - @Suppress("Detekt.ComplexMethod") private fun handleConversation(conversation: ConversationModel?) { selectedConversation = conversation @@ -939,14 +589,14 @@ class ConversationsListActivity : BaseActivity() { ) { handleSharedData() } else { - Snackbar.make(binding.root, R.string.send_to_forbidden, Snackbar.LENGTH_LONG).show() + showSnackbar(getString(R.string.send_to_forbidden)) } } else if (forwardMessage) { if (hasChatPermission && !isReadOnlyConversation(selectedConversation!!)) { openConversation(intent.getStringExtra(KEY_FORWARD_MSG_TEXT)) forwardMessageState.value = false } else { - Snackbar.make(binding.root, R.string.send_to_forbidden, Snackbar.LENGTH_LONG).show() + showSnackbar(getString(R.string.send_to_forbidden)) } } else { openConversation() @@ -975,9 +625,7 @@ class ConversationsListActivity : BaseActivity() { } else if (filesToShare != null && filesToShare!!.isNotEmpty()) { showSendFilesConfirmDialog() } else { - Snackbar - .make(binding.root, context.resources.getString(R.string.nc_common_error_sorry), Snackbar.LENGTH_LONG) - .show() + showSnackbar(context.resources.getString(R.string.nc_common_error_sorry)) } } @@ -1044,19 +692,11 @@ class ConversationsListActivity : BaseActivity() { extractFilesFromClipData() } if (filesToShare!!.isEmpty() && textToPaste!!.isEmpty()) { - Snackbar.make( - binding.root, - context.resources.getString(R.string.nc_common_error_sorry), - Snackbar.LENGTH_LONG - ).show() + showSnackbar(context.resources.getString(R.string.nc_common_error_sorry)) Log.e(TAG, "failed to get data from intent") } } catch (e: Exception) { - Snackbar.make( - binding.root, - context.resources.getString(R.string.nc_common_error_sorry), - Snackbar.LENGTH_LONG - ).show() + showSnackbar(context.resources.getString(R.string.nc_common_error_sorry)) Log.e(TAG, "Something went wrong when extracting data from intent") } } @@ -1072,6 +712,7 @@ class ConversationsListActivity : BaseActivity() { textToPaste = item.text.toString() return } + else -> Log.w(TAG, "datatype not yet implemented for share-to") } } @@ -1082,11 +723,7 @@ class ConversationsListActivity : BaseActivity() { private fun upload() { if (selectedConversation == null) { - Snackbar.make( - binding.root, - context.resources.getString(R.string.nc_common_error_sorry), - Snackbar.LENGTH_LONG - ).show() + showSnackbar(context.resources.getString(R.string.nc_common_error_sorry)) Log.e(TAG, "not able to upload any files because conversation was null.") return } @@ -1100,8 +737,7 @@ class ConversationsListActivity : BaseActivity() { ) } } catch (e: IllegalArgumentException) { - Snackbar.make(binding.root, context.resources.getString(R.string.nc_upload_failed), Snackbar.LENGTH_LONG) - .show() + showSnackbar(context.resources.getString(R.string.nc_upload_failed)) Log.e(TAG, "Something went wrong when trying to upload file", e) } } @@ -1115,11 +751,7 @@ class ConversationsListActivity : BaseActivity() { Log.d(TAG, "upload starting after permissions were granted") showSendFilesConfirmDialog() } else { - Snackbar.make( - binding.root, - context.getString(R.string.read_storage_no_permission), - Snackbar.LENGTH_LONG - ).show() + showSnackbar(context.getString(R.string.read_storage_no_permission)) } } @@ -1162,51 +794,6 @@ class ConversationsListActivity : BaseActivity() { } } - private fun setupNotificationWarning() { - binding.notificationWarningComposeView.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val colorScheme = remember { viewThemeUtils.getColorScheme(context) } - val showWarning by showNotificationWarningState.collectAsStateWithLifecycle() - MaterialTheme(colorScheme = colorScheme) { - NotificationWarningCard( - visible = showWarning, - onNotNow = { - appPreferences.setNotificationWarningLastPostponedDate(System.currentTimeMillis()) - showNotificationWarningState.value = false - }, - onShowSettings = { - val bundle = Bundle() - bundle.putBoolean(KEY_SCROLL_TO_NOTIFICATION_CATEGORY, true) - val settingsIntent = Intent(context, SettingsActivity::class.java) - settingsIntent.putExtras(bundle) - startActivity(settingsIntent) - } - ) - } - } - } - } - - private fun setupFederationHintCard() { - binding.federationHintComposeView.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val colorScheme = remember { viewThemeUtils.getColorScheme(context) } - val visible by conversationsListViewModel.federationInvitationHintVisible.collectAsStateWithLifecycle() - MaterialTheme(colorScheme = colorScheme) { - FederationInvitationHintCard( - visible = visible, - onClick = { - val intent = Intent(context, InvitationsActivity::class.java) - startActivity(intent) - } - ) - } - } - } - } - private fun shouldShowNotificationWarning(): Boolean { fun shouldShowWarningIfDateTooOld(date1: Long): Boolean { val currentTimeMillis = System.currentTimeMillis() @@ -1253,11 +840,7 @@ class ConversationsListActivity : BaseActivity() { if (CallActivity.active && selectedConversation!!.token != ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken ) { - Snackbar.make( - binding.root, - context.getString(R.string.restrict_join_other_room_while_call), - Snackbar.LENGTH_LONG - ).show() + showSnackbar(context.getString(R.string.restrict_join_other_room_while_call)) return } @@ -1536,11 +1119,8 @@ class ConversationsListActivity : BaseActivity() { companion object { private val TAG = ConversationsListActivity::class.java.simpleName - const val UNREAD_BUBBLE_DELAY = 2500 const val BOTTOM_SHEET_DELAY: Long = 2500 - private const val CHAT_ACTIVITY_LOCAL_NAME = "com.nextcloud.talk.chat.ChatActivity" const val SEARCH_DEBOUNCE_INTERVAL_MS = 300 - const val SEARCH_MIN_CHARS = 1 const val HTTP_UNAUTHORIZED = 401 const val HTTP_CLIENT_UPGRADE_REQUIRED = 426 const val CLIENT_UPGRADE_MARKET_LINK = "market://details?id=" @@ -1550,11 +1130,7 @@ class ConversationsListActivity : BaseActivity() { const val REQUEST_POST_NOTIFICATIONS_PERMISSION = 111 const val DAYS_FOR_NOTIFICATION_WARNING = 5L const val NOTIFICATION_WARNING_DATE_NOT_SET = 0L - const val OFFSET_HEIGHT_DIVIDER: Int = 3 const val ROOM_TYPE_ONE_ONE = "1" - private const val SIXTEEN_HOURS_IN_SECONDS: Long = 57600 - const val LONG_1000: Long = 1000 private const val NOTE_TO_SELF_SHORTCUT_ID = "NOTE_TO_SELF_SHORTCUT_ID" - private const val CONVERSATION_ITEM_HEIGHT = 44 } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt index 4b24fdb400c..2e5db502713 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt @@ -9,6 +9,7 @@ package com.nextcloud.talk.conversationlist.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -46,6 +47,7 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest @@ -82,7 +84,9 @@ fun ConversationList( onScrollChanged: (scrolledDown: Boolean) -> Unit = {}, /** Called when the list stops scrolling; delivers the last-visible item index. */ onScrollStopped: (lastVisibleIndex: Int) -> Unit = {}, - listState: LazyListState = rememberLazyListState() + listState: LazyListState = rememberLazyListState(), + /** Extra bottom padding added as LazyColumn contentPadding so the last item is reachable above the nav bar. */ + contentBottomPadding: Dp = 0.dp ) { var prevIndex by remember { mutableIntStateOf(listState.firstVisibleItemIndex) } var prevOffset by remember { mutableIntStateOf(listState.firstVisibleItemScrollOffset) } @@ -116,7 +120,8 @@ fun ConversationList( ) { LazyColumn( state = listState, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = contentBottomPadding) ) { items( items = entries, @@ -194,7 +199,7 @@ private fun MessageResultListItem(result: SearchMessageEntry, credentials: Strin modifier = Modifier .fillMaxWidth() .clickable { onClick() } - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = 16.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically ) { AsyncImage( diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt index 499e00d16f8..34e6e1f93b0 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt @@ -49,7 +49,6 @@ fun ConversationListFab(isVisible: Boolean, isEnabled: Boolean, onClick: () -> U FloatingActionButton( onClick = { if (isEnabled) onClick() }, modifier = Modifier - .padding(8.dp) .alpha(if (isEnabled) 1f else DISABLED_ALPHA) ) { Icon( @@ -61,9 +60,10 @@ fun ConversationListFab(isVisible: Boolean, isEnabled: Boolean, onClick: () -> U } @Composable -fun UnreadMentionBubble(visible: Boolean, onClick: () -> Unit) { +fun UnreadMentionBubble(visible: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { AnimatedVisibility( visible = visible, + modifier = modifier, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt index 4ab98b87760..1c37a93062c 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt @@ -644,12 +644,25 @@ private fun LastMessageContent( // Deleted comment if (chatMessage.isDeletedCommentMessage) { + val parsedText = ChatUtils.getParsedMessage(chatMessage.message, chatMessage.messageParameters) ?: "" + val youPrefix = stringResource(R.string.nc_formatted_message_you, parsedText) + val groupFormat = stringResource(R.string.nc_formatted_message) + val guestLabel = stringResource(R.string.nc_guest) + val displayText = when { + chatMessage.actorId == currentUser.userId -> youPrefix + model.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> parsedText + else -> { + val actorName = chatMessage.actorDisplayName?.takeIf { it.isNotBlank() } + ?: if (chatMessage.actorType == "guests" || chatMessage.actorType == "emails") { + guestLabel + } else { + "" + } + if (actorName.isBlank()) parsedText else String.format(groupFormat, actorName, parsedText) + } + } Text( - text = buildHighlightedText( - ChatUtils.getParsedMessage(chatMessage.message, chatMessage.messageParameters) ?: "", - searchQuery, - primaryColor - ), + text = buildHighlightedText(displayText, searchQuery, primaryColor), modifier = modifier, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -696,7 +709,11 @@ private fun LastMessageContent( ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE -> { var name = chatMessage.message ?: "" - if (name == "{file}") name = chatMessage.messageParameters?.get("file")?.get("name") ?: "" + name = if (name == "{file}") { + chatMessage.messageParameters?.get("file")?.get("name") ?: "" + } else { + ChatUtils.getParsedMessage(name, chatMessage.messageParameters) ?: name + } val mime = chatMessage.messageParameters?.get("file")?.get("mimetype") val icon = attachmentIconRes(mime) val prefix = authorPrefix(chatMessage, currentUser) @@ -833,7 +850,7 @@ private fun LastMessageContent( } else { "" } - String.format(groupFormat, actorName, parsedText) + if (actorName.isBlank()) parsedText else String.format(groupFormat, actorName, parsedText) } } Text( @@ -1602,7 +1619,7 @@ private fun PreviewLastMessageDeleted() = model = previewModel( displayName = "Alice", lastMessage = previewMsg( - message = "Message deleted", + message = "You: Message deleted", messageType = "comment_deleted" ) ), diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListTopBar.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListTopBar.kt index 3fa002bfd46..a9387e70cc2 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListTopBar.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListTopBar.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -91,14 +92,14 @@ data class ConversationListTopBarState( @Suppress("LongParameterList") data class ConversationListTopBarActions( - val onSearchQueryChange: (String) -> Unit, - val onSearchActivate: () -> Unit, - val onSearchClose: () -> Unit, - val onFilterClick: () -> Unit, - val onThreadsClick: () -> Unit, - val onAvatarClick: () -> Unit, - val onNavigateBack: () -> Unit, - val onAccountChooserClick: () -> Unit + val onSearchQueryChange: (String) -> Unit = {}, + val onSearchActivate: () -> Unit = {}, + val onSearchClose: () -> Unit = {}, + val onFilterClick: () -> Unit = {}, + val onThreadsClick: () -> Unit = {}, + val onAvatarClick: () -> Unit = {}, + val onNavigateBack: () -> Unit = {}, + val onAccountChooserClick: () -> Unit = {} ) @OptIn(ExperimentalMaterial3Api::class) @@ -117,18 +118,11 @@ fun ConversationListTopBar( } } - Column(modifier = modifier.fillMaxWidth()) { + Column(modifier = modifier.fillMaxWidth().statusBarsPadding()) { when (val mode = state.mode) { is TopBarMode.SearchBarIdle -> TopBarIdleContent( - onSearchActivate = actions.onSearchActivate, - onFilterClick = actions.onFilterClick, - onThreadsClick = actions.onThreadsClick, - onAvatarClick = actions.onAvatarClick, - showAvatarBadge = state.showAvatarBadge, - avatarUrl = state.avatarUrl, - credentials = state.credentials, - showFilterActive = state.showFilterActive, - showThreadsButton = state.showThreadsButton + state = state, + actions = actions ) is TopBarMode.SearchActive -> TopBarSearchActiveContent( query = mode.query, @@ -148,91 +142,106 @@ fun ConversationListTopBar( } } -@Suppress("LongParameterList") @Composable -private fun TopBarIdleContent( - onSearchActivate: () -> Unit, - onFilterClick: () -> Unit, - onThreadsClick: () -> Unit, - onAvatarClick: () -> Unit, - showAvatarBadge: Boolean, - avatarUrl: String?, - credentials: String, - showFilterActive: Boolean, - showThreadsButton: Boolean -) { +private fun TopBarIdleContent(state: ConversationListTopBarState, actions: ConversationListTopBarActions) { Row( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically ) { - Card( + IdleSearchBarCard( + state = state, + actions = actions, modifier = Modifier .weight(1f) - .height(50.dp), - shape = RoundedCornerShape(25.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + .height(50.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + AvatarButton( + avatarUrl = state.avatarUrl, + credentials = state.credentials, + showBadge = state.showAvatarBadge, + onClick = actions.onAvatarClick + ) + } +} + +@Composable +private fun IdleSearchBarCard( + state: ConversationListTopBarState, + actions: ConversationListTopBarActions, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier, + shape = RoundedCornerShape(25.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.appbar_search_in, stringResource(R.string.nc_app_product_name)), + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterStart) + .padding(start = 16.dp, end = if (state.showThreadsButton) 80.dp else 48.dp) + .clickable { actions.onSearchActivate() }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + IdleSearchBarActions( + state = state, + actions = actions, + modifier = Modifier.align(Alignment.CenterEnd) + ) + } + } +} + +@Composable +private fun IdleSearchBarActions( + state: ConversationListTopBarState, + actions: ConversationListTopBarActions, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = actions.onFilterClick, + modifier = Modifier.size(48.dp) ) { - Box(modifier = Modifier.fillMaxSize()) { - Text( - text = stringResource(R.string.appbar_search_in, stringResource(R.string.nc_app_product_name)), - modifier = Modifier - .fillMaxWidth() - .align(Alignment.CenterStart) - .padding(start = 16.dp, end = if (showThreadsButton) 80.dp else 48.dp) - .clickable { onSearchActivate() }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Row( - modifier = Modifier.align(Alignment.CenterEnd), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = onFilterClick, - modifier = Modifier.size(48.dp) - ) { - Box( - Modifier.fillMaxSize(), - contentAlignment = if (showThreadsButton) Alignment.CenterEnd else Alignment.Center - ) { - Icon( - painter = painterResource(R.drawable.ic_baseline_filter_list_24), - contentDescription = stringResource(R.string.nc_filter), - tint = if (showFilterActive) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - ) - } - } - if (showThreadsButton) { - IconButton( - onClick = onThreadsClick, - modifier = Modifier.size(48.dp) - ) { - Icon( - painter = painterResource(R.drawable.outline_forum_24), - contentDescription = stringResource(R.string.threads), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Box( + Modifier.fillMaxSize(), + contentAlignment = if (state.showThreadsButton) Alignment.CenterEnd else Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_filter_list_24), + contentDescription = stringResource(R.string.nc_filter), + tint = if (state.showFilterActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant } - } + ) + } + } + if (state.showThreadsButton) { + IconButton( + onClick = actions.onThreadsClick, + modifier = Modifier.size(48.dp) + ) { + Icon( + painter = painterResource(R.drawable.outline_forum_24), + contentDescription = stringResource(R.string.threads), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } } - Spacer(modifier = Modifier.width(8.dp)) - AvatarButton( - avatarUrl = avatarUrl, - credentials = credentials, - showBadge = showAvatarBadge, - onClick = onAvatarClick - ) } } @@ -366,7 +375,8 @@ private fun TopBarTitleContent( }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surface - ) + ), + windowInsets = WindowInsets(0) ) } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt new file mode 100644 index 00000000000..6c04864c373 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt @@ -0,0 +1,604 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.conversationlist.ui + +import android.app.Activity +import android.content.res.Configuration +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.viewmodels.ChatViewModel +import com.nextcloud.talk.components.ColoredStatusBar +import com.nextcloud.talk.contextchat.ContextChatView +import com.nextcloud.talk.contextchat.ContextChatViewModel +import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.domain.SearchMessageEntry +import com.nextcloud.talk.models.json.chat.ChatMessageJson +import com.nextcloud.talk.models.json.conversations.ConversationEnums +import com.nextcloud.talk.models.json.participants.Participant +import com.nextcloud.talk.ui.BackgroundVoiceMessageCard +import com.nextcloud.talk.ui.dialog.ChooseAccountDialogCompose +import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.ARCHIVE +import com.nextcloud.talk.ui.dialog.FilterConversationFragment.Companion.DEFAULT +import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.DisplayUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import java.io.File + +private const val SEARCH_DEBOUNCE_MS = 300 +private const val SEARCH_MIN_CHARS = 1 + +@Suppress("LongParameterList") +data class ConversationsListScreenState( + val currentUser: User?, + val credentials: String, + val showLogo: Boolean, + val viewThemeUtils: ViewThemeUtils, + val isShowEcosystem: Boolean, + val snackbarHostState: SnackbarHostState, + val isMaintenanceModeFlow: StateFlow, + val isOnlineFlow: StateFlow, + val showUnreadBubbleFlow: StateFlow, + val isFabVisibleFlow: StateFlow, + val showNotificationWarningFlow: StateFlow, + val isRefreshingFlow: StateFlow, + val showShareToFlow: StateFlow, + val forwardMessageFlow: StateFlow, + val hasMultipleAccountsFlow: StateFlow, + val showAccountDialogFlow: StateFlow +) + +@Suppress("LongParameterList") +data class ConversationsListScreenCallbacks( + val onLazyListStateAvailable: (LazyListState?) -> Unit, + val onScrollChanged: (scrolledDown: Boolean) -> Unit, + val onScrollStopped: (lastVisibleIndex: Int) -> Unit, + val onConversationClick: (ConversationModel) -> Unit, + val onConversationLongClick: (ConversationModel) -> Unit, + val onMessageResultClick: (SearchMessageEntry) -> Unit, + val onContactClick: (Participant) -> Unit, + val onLoadMoreClick: () -> Unit, + val onRefresh: () -> Unit, + val onFabClick: () -> Unit, + val onUnreadBubbleClick: () -> Unit, + val onNotificationWarningNotNow: () -> Unit, + val onNotificationWarningShowSettings: () -> Unit, + val onFederationHintClick: () -> Unit, + val onFilterClick: () -> Unit, + val onThreadsClick: () -> Unit, + val onAvatarClick: () -> Unit, + val onNavigateBack: () -> Unit, + val onAccountChooserClick: () -> Unit, + val onNewConversation: () -> Unit, + val onAccountDialogDismiss: () -> Unit +) + +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +fun ConversationsListScreen( + viewModel: ConversationsListViewModel, + contextChatViewModel: ContextChatViewModel, + chatViewModel: ChatViewModel, + state: ConversationsListScreenState, + callbacks: ConversationsListScreenCallbacks +) { + val context = LocalContext.current + val activity = context as Activity + val colorScheme = remember { state.viewThemeUtils.getColorScheme(context) } + val coroutineScope = rememberCoroutineScope() + + // ViewModel state + val entries by viewModel.conversationListEntriesFlow.collectAsStateWithLifecycle() + val rooms by viewModel.getRoomsStateFlow.collectAsStateWithLifecycle() + val isShimmerVisible by viewModel.isShimmerVisible.collectAsStateWithLifecycle() + val isSearchActive by viewModel.isSearchActiveFlow.collectAsStateWithLifecycle() + val searchQuery by viewModel.currentSearchQueryFlow.collectAsStateWithLifecycle() + val isSearchLoading by viewModel.isSearchLoadingFlow.collectAsStateWithLifecycle() + val filterState by viewModel.filterStateFlow.collectAsStateWithLifecycle() + val showAvatarBadge by viewModel.showAvatarBadge.collectAsStateWithLifecycle() + val threadsState by viewModel.threadsExistState.collectAsStateWithLifecycle() + val federationHintVisible by viewModel.federationInvitationHintVisible.collectAsStateWithLifecycle() + + // Activity-level state + val isMaintenanceMode by state.isMaintenanceModeFlow.collectAsStateWithLifecycle() + val isOnline by state.isOnlineFlow.collectAsStateWithLifecycle() + val showUnreadBubble by state.showUnreadBubbleFlow.collectAsStateWithLifecycle() + val isFabVisible by state.isFabVisibleFlow.collectAsStateWithLifecycle() + val showNotificationWarning by state.showNotificationWarningFlow.collectAsStateWithLifecycle() + val isRefreshing by state.isRefreshingFlow.collectAsStateWithLifecycle() + val showShareTo by state.showShareToFlow.collectAsStateWithLifecycle() + val isForward by state.forwardMessageFlow.collectAsStateWithLifecycle() + val hasMultipleAccounts by state.hasMultipleAccountsFlow.collectAsStateWithLifecycle() + val showAccountDialog by state.showAccountDialogFlow.collectAsStateWithLifecycle() + + // Background voice message mini-player + val backgroundMsg by chatViewModel.backgroundPlayUIFlow.collectAsStateWithLifecycle() + + // Derived state + val isArchivedFilterActive = filterState[ARCHIVE] == true + val isRoomsEmpty = rooms.isEmpty() && !isShimmerVisible + val showSearchNoResults = isSearchActive && entries.isEmpty() && searchQuery.isNotEmpty() && !isSearchLoading + val showFilterActive = filterState.any { (k, v) -> k != DEFAULT && v } + val showThreadsButton = + threadsState is ConversationsListViewModel.ThreadsExistUiState.Success && + (threadsState as ConversationsListViewModel.ThreadsExistUiState.Success).threadsExistence == true + + val mode: TopBarMode = when { + showShareTo -> TopBarMode.TitleBar( + title = stringResource(R.string.send_to_three_dots), + showAccountChooser = hasMultipleAccounts + ) + + isForward -> TopBarMode.TitleBar( + title = stringResource(R.string.nc_forward_to_three_dots), + showAccountChooser = false + ) + + isSearchActive -> TopBarMode.SearchActive(query = searchQuery) + else -> TopBarMode.SearchBarIdle + } + + val avatarUrl = remember(state.currentUser) { + ApiUtils.getUrlForAvatar( + state.currentUser?.baseUrl, + state.currentUser?.userId, + true, + darkMode = DisplayUtils.isDarkModeOn(context) + ) + } + + val lazyListState = rememberLazyListState() + DisposableEffect(lazyListState) { + callbacks.onLazyListStateAvailable(lazyListState) + onDispose { callbacks.onLazyListStateAvailable(null) } + } + + LaunchedEffect(searchQuery) { + if (searchQuery.isNotEmpty()) { + delay(SEARCH_DEBOUNCE_MS.toLong()) + if (searchQuery.length >= SEARCH_MIN_CHARS) { + viewModel.getSearchQuery(context, searchQuery) + } + } + } + + MaterialTheme(colorScheme = colorScheme) { + ColoredStatusBar() + + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + topBar = { + Column(modifier = Modifier.fillMaxWidth()) { + StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) + ConversationListTopBar( + state = ConversationListTopBarState( + mode = mode, + showAvatarBadge = showAvatarBadge, + avatarUrl = avatarUrl, + credentials = state.credentials, + showFilterActive = showFilterActive, + showThreadsButton = showThreadsButton + ), + actions = ConversationListTopBarActions( + onSearchQueryChange = { viewModel.setSearchQuery(it) }, + onSearchActivate = { viewModel.setIsSearchActive(true) }, + onSearchClose = { + viewModel.setIsSearchActive(false) + coroutineScope.launch { lazyListState.scrollToItem(0) } + }, + onFilterClick = callbacks.onFilterClick, + onThreadsClick = callbacks.onThreadsClick, + onAvatarClick = callbacks.onAvatarClick, + onNavigateBack = callbacks.onNavigateBack, + onAccountChooserClick = callbacks.onAccountChooserClick + ) + ) + BackgroundVoiceMiniPlayer( + backgroundMsg = backgroundMsg, + currentUser = state.currentUser, + chatViewModel = chatViewModel, + viewThemeUtils = state.viewThemeUtils + ) + } + }, + floatingActionButton = { + ConversationListFab( + isVisible = isFabVisible && !isSearchActive, + isEnabled = isOnline, + onClick = callbacks.onFabClick + ) + }, + snackbarHost = { + SnackbarHost( + hostState = state.snackbarHostState, + modifier = Modifier.navigationBarsPadding() + ) + } + ) { paddingValues -> + val layoutDirection = LocalLayoutDirection.current + Box( + modifier = Modifier + .padding( + top = paddingValues.calculateTopPadding(), + start = paddingValues.calculateStartPadding(layoutDirection), + end = paddingValues.calculateEndPadding(layoutDirection) + ) + .fillMaxSize() + ) { + Column(modifier = Modifier.fillMaxSize()) { + NotificationWarningCard( + visible = showNotificationWarning, + onNotNow = callbacks.onNotificationWarningNotNow, + onShowSettings = callbacks.onNotificationWarningShowSettings + ) + FederationInvitationHintCard( + visible = federationHintVisible, + onClick = callbacks.onFederationHintClick + ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + when { + showSearchNoResults -> + Box( + modifier = Modifier + .fillMaxSize() + .imePadding(), + contentAlignment = Alignment.Center + ) { + SearchNoResultsView() + } + + state.currentUser != null -> + ConversationList( + entries = entries, + isRefreshing = isRefreshing, + currentUser = state.currentUser, + credentials = state.credentials, + searchQuery = searchQuery, + onConversationClick = callbacks.onConversationClick, + onConversationLongClick = callbacks.onConversationLongClick, + onMessageResultClick = callbacks.onMessageResultClick, + onContactClick = callbacks.onContactClick, + onLoadMoreClick = callbacks.onLoadMoreClick, + onRefresh = callbacks.onRefresh, + onScrollChanged = callbacks.onScrollChanged, + onScrollStopped = callbacks.onScrollStopped, + listState = lazyListState, + contentBottomPadding = paddingValues.calculateBottomPadding() + ) + } + // Empty-state overlay (centered; handles its own visibility) + if (!isShimmerVisible) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + ConversationsEmptyStateView( + isListEmpty = isRoomsEmpty, + showNoArchivedView = isArchivedFilterActive, + showLogo = state.showLogo, + onCreateNewConversation = callbacks.onNewConversation + ) + } + } + // Shimmer overlay (on top while first load is in progress) + ConversationListSkeleton(isVisible = isShimmerVisible) + } + } + + // Unread-mention bubble (bottom-center overlay) + UnreadMentionBubble( + visible = showUnreadBubble && !isSearchActive, + onClick = callbacks.onUnreadBubbleClick, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 16.dp) + ) + } + + // Context-chat dialog (Dialog composable; renders on top regardless of position) + ContextChatView(context, contextChatViewModel) + + // Account-chooser dialog + if (showAccountDialog) { + val dialog = remember { ChooseAccountDialogCompose() } + val shouldDismiss = remember { mutableStateOf(false) } + LaunchedEffect(shouldDismiss.value) { + if (shouldDismiss.value) callbacks.onAccountDialogDismiss() + } + dialog.GetChooseAccountDialog(shouldDismiss, activity, state.isShowEcosystem) + } + } + } +} + +@Composable +private fun BackgroundVoiceMiniPlayer( + backgroundMsg: com.nextcloud.talk.chat.data.model.ChatMessage?, + currentUser: User?, + chatViewModel: ChatViewModel, + viewThemeUtils: ViewThemeUtils +) { + val context = LocalContext.current + backgroundMsg ?: return + + val duration = chatViewModel.mediaPlayerDuration + val position = chatViewModel.mediaPlayerPosition + if (duration <= 0) return + + val offset = position.toFloat() / duration + val imageURI = ApiUtils.getUrlForAvatar( + currentUser?.baseUrl, + backgroundMsg.actorId, + true, + darkMode = DisplayUtils.isDarkModeOn(context) + ) + val conversationImageURI = ApiUtils.getUrlForConversationAvatar( + ApiUtils.API_V1, + currentUser?.baseUrl, + backgroundMsg.token + ) + val card = remember(backgroundMsg) { + BackgroundVoiceMessageCard( + name = backgroundMsg.actorDisplayName ?: "", + duration = duration - position, + offset = offset, + imageURI = imageURI, + conversationImageURI = conversationImageURI, + viewThemeUtils = viewThemeUtils, + context = context + ) + } + card.GetView( + onPlayPaused = { isPaused -> + if (isPaused) { + chatViewModel.pauseMediaPlayer(false) + } else { + val filename = backgroundMsg.selectedIndividualHashMap?.get("name") + val file = File(context.cacheDir, filename ?: "") + chatViewModel.startMediaPlayer(file.canonicalPath) + } + }, + onClosed = { chatViewModel.stopMediaPlayer() } + ) +} + +@Suppress("LongParameterList") +private fun previewConvModel( + displayName: String, + token: String, + type: ConversationEnums.ConversationType = ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL, + unreadMessages: Int = 0, + unreadMention: Boolean = false, + unreadMentionDirect: Boolean = false, + favorite: Boolean = false, + status: String? = null, + statusIcon: String? = null, + remoteServer: String? = null, + lastMessage: ChatMessageJson? = null +) = ConversationModel( + internalId = "1@$token", + accountId = 1L, + token = token, + name = displayName.lowercase(), + displayName = displayName, + description = "", + type = type, + participantType = Participant.ParticipantType.USER, + sessionId = "", + actorId = "user1", + actorType = "users", + objectType = ConversationEnums.ObjectType.DEFAULT, + notificationLevel = ConversationEnums.NotificationLevel.DEFAULT, + conversationReadOnlyState = ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_WRITE, + lobbyState = ConversationEnums.LobbyState.LOBBY_STATE_ALL_PARTICIPANTS, + lobbyTimer = 0L, + canLeaveConversation = true, + canDeleteConversation = false, + unreadMentionDirect = unreadMentionDirect, + notificationCalls = 0, + avatarVersion = "", + hasCustomAvatar = false, + callStartTime = 0L, + unreadMessages = unreadMessages, + unreadMention = unreadMention, + favorite = favorite, + status = status, + statusIcon = statusIcon, + remoteServer = remoteServer, + lastMessage = lastMessage, + lastActivity = System.currentTimeMillis() / 1000L - 600L +) + +private fun previewMsg( + actorId: String = "other", + actorDisplayName: String = "Bob", + message: String = "Hello there", + messageType: String = "comment", + messageParameters: HashMap>? = null +) = ChatMessageJson( + id = 1L, + actorId = actorId, + actorDisplayName = actorDisplayName, + message = message, + messageType = messageType, + messageParameters = messageParameters +) + +private fun previewCurrentUser() = + User( + id = 1L, + userId = "user1", + username = "user1", + baseUrl = "https://cloud.example.com", + token = "token", + displayName = "Test User", + capabilities = null + ) + +private fun previewConvEntries(): List = + listOf( + ConversationListEntry.ConversationEntry( + previewConvModel( + "Alice", + "tok1", + status = "online", + unreadMessages = 2, + unreadMention = true, + unreadMentionDirect = true, + lastMessage = previewMsg(message = "Did you see my message?") + ) + ), + ConversationListEntry.ConversationEntry( + previewConvModel( + "Project Team", + "tok2", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + unreadMessages = 3, + unreadMention = true, + lastMessage = previewMsg(actorDisplayName = "Carol", message = "@user1 please review the PR") + ) + ), + ConversationListEntry.ConversationEntry( + previewConvModel( + "Bob", + "tok3", + favorite = true, + status = "away", + lastMessage = previewMsg( + actorDisplayName = "Bob", + message = "{file}", + messageParameters = hashMapOf( + "file" to hashMapOf("name" to "voice.mp3", "mimetype" to "audio/mpeg") + ) + ) + ) + ), + ConversationListEntry.ConversationEntry( + previewConvModel( + "Dev Team", + "tok4", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + unreadMessages = 1500, + lastMessage = previewMsg(actorDisplayName = "Dave", message = "So many messages!") + ) + ) + ) + +@Composable +private fun ConversationsListScreenPreviewContent( + showThreadsButton: Boolean = false, + showUnreadBubble: Boolean = false +) { + Surface(color = MaterialTheme.colorScheme.background) { + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + ConversationListTopBar( + state = ConversationListTopBarState( + mode = TopBarMode.SearchBarIdle, + showAvatarBadge = false, + avatarUrl = null, + credentials = "", + showFilterActive = false, + showThreadsButton = showThreadsButton + ), + actions = ConversationListTopBarActions() + ) + }, + floatingActionButton = { + ConversationListFab(isVisible = true, isEnabled = true, onClick = {}) + } + ) { paddingValues -> + Box( + modifier = Modifier + .padding( + top = paddingValues.calculateTopPadding(), + start = paddingValues.calculateStartPadding(LocalLayoutDirection.current), + end = paddingValues.calculateEndPadding(LocalLayoutDirection.current) + ) + .fillMaxSize() + ) { + ConversationList( + entries = previewConvEntries(), + isRefreshing = false, + currentUser = previewCurrentUser(), + credentials = "", + onConversationClick = {}, + onConversationLongClick = {}, + onMessageResultClick = {}, + onContactClick = {}, + onLoadMoreClick = {}, + onRefresh = {}, + contentBottomPadding = paddingValues.calculateBottomPadding() + ) + UnreadMentionBubble( + visible = showUnreadBubble, + onClick = {}, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 16.dp) + ) + } + } + } +} + +@Preview(name = "Light") +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "RTL Arabic", locale = "ar") +@Composable +private fun ConversationsListScreenDarkPreview() { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + ConversationsListScreenPreviewContent(showThreadsButton = !isSystemInDarkTheme()) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index 9098511247f..fb168b889fd 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt @@ -103,7 +103,7 @@ class ConversationsListViewModel @Inject constructor( val openConversationsState: StateFlow = _openConversationsState object GetRoomsStartState : ViewState - object GetRoomsErrorState : ViewState + class GetRoomsErrorState(val throwable: Throwable) : ViewState open class GetRoomsSuccessState(val listIsNotEmpty: Boolean) : ViewState private val _getRoomsViewState: MutableLiveData = MutableLiveData(GetRoomsStartState) @@ -114,7 +114,7 @@ class ConversationsListViewModel @Inject constructor( .onEach { list -> _getRoomsViewState.value = GetRoomsSuccessState(list.isNotEmpty()) }.catch { - _getRoomsViewState.value = GetRoomsErrorState + _getRoomsViewState.value = GetRoomsErrorState(it) } private val _isShimmerVisible = MutableStateFlow(true) diff --git a/app/src/main/res/animator/appbar_elevation_off.xml b/app/src/main/res/animator/appbar_elevation_off.xml deleted file mode 100644 index cafe2f275b9..00000000000 --- a/app/src/main/res/animator/appbar_elevation_off.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/animator/appbar_elevation_on.xml b/app/src/main/res/animator/appbar_elevation_on.xml deleted file mode 100644 index 75ec616f61a..00000000000 --- a/app/src/main/res/animator/appbar_elevation_on.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/cutout_circle.xml b/app/src/main/res/drawable/cutout_circle.xml deleted file mode 100644 index c48a09ed1cd..00000000000 --- a/app/src/main/res/drawable/cutout_circle.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_menu.xml b/app/src/main/res/drawable/ic_menu.xml deleted file mode 100644 index c1ffd54ba84..00000000000 --- a/app/src/main/res/drawable/ic_menu.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/layout/activity_conversations.xml b/app/src/main/res/layout/activity_conversations.xml deleted file mode 100644 index eaf12d08ae8..00000000000 --- a/app/src/main/res/layout/activity_conversations.xml +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/federated_invitation_hint.xml b/app/src/main/res/layout/federated_invitation_hint.xml deleted file mode 100644 index 972ab5c03fd..00000000000 --- a/app/src/main/res/layout/federated_invitation_hint.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/layout/search_layout.xml b/app/src/main/res/layout/search_layout.xml deleted file mode 100644 index fa16d5733ef..00000000000 --- a/app/src/main/res/layout/search_layout.xml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 0b22a8e87f1..2b7ac3ca35c 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -53,7 +53,6 @@ #4B4B4B - #282828 #818181 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index c4825f318a7..ede263b0b08 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -74,7 +74,6 @@ #D7D7D7 - #B4B4B4 #606060 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 5d8439deb4d..2a2dee4bc42 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -23,7 +23,6 @@ 8dp 16dp 40dp - 30dp 96dp 52dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 601120002d6..576e363ec35 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -168,7 +168,6 @@ How to translate with transifex: No proxy About - Privacy Get source code License GNU General Public License, Version 3 @@ -475,7 +474,6 @@ How to translate with transifex: Failed to send message: Add attachment Recent - You: See %d similar message See %d similar messages From f79ed58e6f6a70de3e1edfc3e40a462048ffbf78 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 25 Mar 2026 13:12:42 +0100 Subject: [PATCH 14/21] style(unread): center button text and lower padding between icon and label Signed-off-by: Andy Scherzinger --- .../talk/conversationlist/ui/ConversationListFab.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt index 34e6e1f93b0..cc915b46fa5 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.nextcloud.talk.R @@ -80,10 +81,11 @@ fun UnreadMentionBubble(visible: Boolean, onClick: () -> Unit, modifier: Modifie contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary ) - Spacer(modifier = Modifier.width(dimensionResource(R.dimen.standard_padding))) + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.standard_half_padding))) Text( text = stringResource(R.string.nc_new_mention), - color = MaterialTheme.colorScheme.onPrimary + color = MaterialTheme.colorScheme.onPrimary, + textAlign = TextAlign.Center ) } } From 46b48131932092fe748a708ceb049ad63ccbf533 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 25 Mar 2026 13:29:59 +0100 Subject: [PATCH 15/21] style(conv-list): Move statusBarsPadding() from top bar to list screen to ensure all content is always below the status bar Signed-off-by: Andy Scherzinger --- .../talk/conversationlist/ui/ConversationListTopBar.kt | 3 +-- .../talk/conversationlist/ui/ConversationsListScreen.kt | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListTopBar.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListTopBar.kt index a9387e70cc2..54eebc9ec63 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListTopBar.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListTopBar.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -118,7 +117,7 @@ fun ConversationListTopBar( } } - Column(modifier = modifier.fillMaxWidth().statusBarsPadding()) { + Column(modifier = modifier.fillMaxWidth()) { when (val mode = state.mode) { is TopBarMode.SearchBarIdle -> TopBarIdleContent( state = state, diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt index 6c04864c373..b7cc321d995 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme @@ -212,7 +213,7 @@ fun ConversationsListScreen( Scaffold( contentWindowInsets = WindowInsets.safeDrawing, topBar = { - Column(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().statusBarsPadding()) { StatusBannerRow(isOffline = !isOnline, isMaintenanceMode = isMaintenanceMode) ConversationListTopBar( state = ConversationListTopBarState( From f64e8bec18e6e81ec6e40ee33de494e62cd7f167 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 25 Mar 2026 13:52:39 +0100 Subject: [PATCH 16/21] style(conv-list): Ensure shimmer is hidden before conversation items are shown AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../ui/ConversationShimmerList.kt | 4 +-- .../ui/ConversationsListScreen.kt | 29 +++++++++++++++++-- .../viewmodels/ConversationsListViewModel.kt | 3 +- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationShimmerList.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationShimmerList.kt index 429587cbc20..b259f5a2b19 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationShimmerList.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationShimmerList.kt @@ -8,13 +8,13 @@ package com.nextcloud.talk.conversationlist.ui import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -58,7 +58,7 @@ fun ConversationListSkeleton(isVisible: Boolean, itemCount: Int = SHIMMER_ITEM_C AnimatedVisibility( visible = isVisible, enter = fadeIn(), - exit = fadeOut() + exit = ExitTransition.None ) { val infiniteTransition = rememberInfiniteTransition(label = "shimmer") val shimmerAlpha by infiniteTransition.animateFloat( diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt index b7cc321d995..feff0b0e8be 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -161,7 +162,29 @@ fun ConversationsListScreen( // Derived state val isArchivedFilterActive = filterState[ARCHIVE] == true - val isRoomsEmpty = rooms.isEmpty() && !isShimmerVisible + val hasConversationEntries = entries.any { it is ConversationListEntry.ConversationEntry } + + // Tracks whether the initial shimmer→list transition has completed at least once. + // Once true it stays true for the lifetime of this composition, which prevents a filter + // that returns no results from re-showing the shimmer after the list was already loaded. + val initialLoadComplete = remember { mutableStateOf(false) } + SideEffect { + if (!initialLoadComplete.value && !isShimmerVisible && hasConversationEntries) { + initialLoadComplete.value = true + } + } + + // Keep the shimmer visible until actual conversation entries are ready to render. + // Without this guard there is a brief window — between isShimmerVisible becoming false + // and the LazyColumn emitting its first items — where the screen would be blank. + // After the initial load has completed we simply mirror the ViewModel flag so that + // filter/search changes can never accidentally re-show the shimmer. + val effectiveShimmerVisible = when { + initialLoadComplete.value -> isShimmerVisible + else -> isShimmerVisible || (rooms.isNotEmpty() && !hasConversationEntries) + } + + val isRoomsEmpty = rooms.isEmpty() && !effectiveShimmerVisible val showSearchNoResults = isSearchActive && entries.isEmpty() && searchQuery.isNotEmpty() && !isSearchLoading val showFilterActive = filterState.any { (k, v) -> k != DEFAULT && v } val showThreadsButton = @@ -316,7 +339,7 @@ fun ConversationsListScreen( ) } // Empty-state overlay (centered; handles its own visibility) - if (!isShimmerVisible) { + if (!effectiveShimmerVisible) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -330,7 +353,7 @@ fun ConversationsListScreen( } } // Shimmer overlay (on top while first load is in progress) - ConversationListSkeleton(isVisible = isShimmerVisible) + ConversationListSkeleton(isVisible = effectiveShimmerVisible) } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index fb168b889fd..a2d288cdeb5 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt @@ -186,7 +186,7 @@ class ConversationsListViewModel @Inject constructor( hideRoomToken ) { rooms, filterState, isSearchActive, searchResults, hideToken -> buildConversationListEntries(rooms, filterState, isSearchActive, searchResults, hideToken) - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(WHILE_SUBSCRIBED_TIMEOUT_MS), emptyList()) + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) /** Update filter state; triggers [conversationListEntriesFlow] re-emit. */ fun applyFilter(newFilterState: Map) { @@ -576,6 +576,5 @@ class ConversationsListViewModel @Inject constructor( const val FOLLOWED_THREADS_EXIST = "FOLLOWED_THREADS_EXIST" private const val SIXTEEN_HOURS_IN_SECONDS: Long = 57600 private const val LONG_1000: Long = 1000 - private const val WHILE_SUBSCRIBED_TIMEOUT_MS: Long = 5_000 } } From 459aa6494661ab179dba86df4550a69f70b11a8d Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 25 Mar 2026 14:29:40 +0100 Subject: [PATCH 17/21] fix(conv-list): drop list position on pull-to-refresh and account switching AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../conversationlist/ConversationsListActivity.kt | 6 ++++++ .../talk/utils/preferences/AppPreferences.java | 4 ++++ .../talk/utils/preferences/AppPreferencesImpl.kt | 13 +++++++++++++ 3 files changed, 23 insertions(+) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index bc9ae8b7dd8..f429db0c6b5 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -225,6 +225,7 @@ class ConversationsListActivity : BaseActivity() { onRefresh = { isMaintenanceModeState.value = false isRefreshingState.value = true + appPreferences.setConversationListPositionAndOffset(0, 0) fetchRooms() fetchPendingInvitations() }, @@ -304,6 +305,10 @@ class ConversationsListActivity : BaseActivity() { } credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token) + if (currentUser!!.id != appPreferences.getConversationListLastUserId()) { + appPreferences.setConversationListPositionAndOffset(0, 0) + } + hasMultipleAccountsState.value = userManager.users.blockingGet().size > 1 conversationsListViewModel.setHideRoomToken(intent.getStringExtra(KEY_FORWARD_HIDE_SOURCE_ROOM)) fetchRooms() @@ -323,6 +328,7 @@ class ConversationsListActivity : BaseActivity() { val firstOffset = state.layoutInfo.visibleItemsInfo.firstOrNull()?.offset ?: 0 appPreferences.setConversationListPositionAndOffset(state.firstVisibleItemIndex, firstOffset) } + appPreferences.setConversationListLastUserId(currentUser?.id ?: -1L) } @Suppress("LongMethod", "CyclomaticComplexMethod") diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java index 8dea169ad58..71184fabba4 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java @@ -187,5 +187,9 @@ public interface AppPreferences { Pair getConversationListPositionAndOffset(); + void setConversationListLastUserId(long userId); + + long getConversationListLastUserId(); + void clear(); } diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt index 16de5234d5a..48bdd67412d 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt @@ -422,6 +422,18 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { return Pair(position, offset) } + override fun setConversationListLastUserId(userId: Long) = + runBlocking { + async { + writeLong(CONVERSATION_LIST_LAST_USER_ID, userId) + } + } + + override fun getConversationListLastUserId(): Long = + runBlocking { + async { readLong(CONVERSATION_LIST_LAST_USER_ID, defaultValue = 0L).first() } + }.getCompleted() + override fun setPhoneBookIntegrationLastRun(currentTimeMillis: Long) = runBlocking { async { @@ -637,6 +649,7 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { const val SHOW_REGULAR_NOTIFICATION_WARNING = "show_regular_notification_warning" const val LAST_NOTIFICATION_WARNING = "last_notification_warning" const val CONVERSATION_LIST_POSITION_OFFSET = "CONVERSATION_LIST_POSITION_OFFSET" + const val CONVERSATION_LIST_LAST_USER_ID = "CONVERSATION_LIST_LAST_USER_ID" private fun String.convertStringToArray(): Array { var varString = this val floatList = mutableListOf() From 86c7552b6b9783d1cf550d18a405b9b2f3b679fd Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 25 Mar 2026 19:54:08 +0100 Subject: [PATCH 18/21] fix(conv-list): ensure shimmer is properly hidden quickly enough AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../ui/ConversationsListScreen.kt | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt index feff0b0e8be..95cfd9fa872 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -162,27 +161,8 @@ fun ConversationsListScreen( // Derived state val isArchivedFilterActive = filterState[ARCHIVE] == true - val hasConversationEntries = entries.any { it is ConversationListEntry.ConversationEntry } - - // Tracks whether the initial shimmer→list transition has completed at least once. - // Once true it stays true for the lifetime of this composition, which prevents a filter - // that returns no results from re-showing the shimmer after the list was already loaded. - val initialLoadComplete = remember { mutableStateOf(false) } - SideEffect { - if (!initialLoadComplete.value && !isShimmerVisible && hasConversationEntries) { - initialLoadComplete.value = true - } - } - // Keep the shimmer visible until actual conversation entries are ready to render. - // Without this guard there is a brief window — between isShimmerVisible becoming false - // and the LazyColumn emitting its first items — where the screen would be blank. - // After the initial load has completed we simply mirror the ViewModel flag so that - // filter/search changes can never accidentally re-show the shimmer. - val effectiveShimmerVisible = when { - initialLoadComplete.value -> isShimmerVisible - else -> isShimmerVisible || (rooms.isNotEmpty() && !hasConversationEntries) - } + val effectiveShimmerVisible = isShimmerVisible val isRoomsEmpty = rooms.isEmpty() && !effectiveShimmerVisible val showSearchNoResults = isSearchActive && entries.isEmpty() && searchQuery.isNotEmpty() && !isSearchLoading From 37f73e12c3d09de8215d52b8a45203fd65878662 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 25 Mar 2026 20:01:15 +0100 Subject: [PATCH 19/21] fix(conv-list): center position on scrolling to unread, mentioned conversation AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../talk/conversationlist/ConversationsListActivity.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index f429db0c6b5..9b5ffbda3e0 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -235,7 +235,15 @@ class ConversationsListActivity : BaseActivity() { }, onUnreadBubbleClick = { lifecycleScope.launch { - conversationListLazyListState?.scrollToItem(nextUnreadConversationScrollPosition, 0) + val listState = conversationListLazyListState ?: return@launch + val viewportHeight = listState.layoutInfo.viewportEndOffset + val avgItemHeight = listState.layoutInfo.visibleItemsInfo + .map { it.size } + .average() + .takeIf { it.isFinite() } + ?.toInt() ?: 0 + val scrollOffset = -(viewportHeight / 2) + (avgItemHeight / 2) + listState.scrollToItem(nextUnreadConversationScrollPosition, scrollOffset) } showUnreadBubbleState.value = false }, From ed1b95dccf364d8c3f8c9ed8b3df1dbab8434e42 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 25 Mar 2026 20:10:35 +0100 Subject: [PATCH 20/21] fix(unread): Ensure unread button is also initially on-updates shown if needed AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../talk/conversationlist/ui/ConversationList.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt index 2e5db502713..8b7fc5e3776 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.first import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -113,6 +114,19 @@ fun ConversationList( } } + // Unread-bubble: also trigger the check after entries are first loaded (or updated) + LaunchedEffect(entries) { + if (entries.isEmpty()) { + onScrollStopped(0) + return@LaunchedEffect + } + // Wait until the LazyColumn has measured visible items so the last-visible index is accurate. + snapshotFlow { listState.layoutInfo.visibleItemsInfo } + .first { it.isNotEmpty() } + val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + onScrollStopped(lastVisible) + } + PullToRefreshBox( isRefreshing = isRefreshing, onRefresh = onRefresh, From 5147916356cfa17cebea9d1e30676f1e2fafedae Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 25 Mar 2026 20:17:13 +0100 Subject: [PATCH 21/21] fix(unread): Don't restore scroll position when interaction with a converation (like makr as (un)read) AI-assistant: Copilot 1.0.10 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../conversationlist/ConversationsListActivity.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index 9b5ffbda3e0..052ec7d50ab 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -142,6 +142,9 @@ class ConversationsListActivity : BaseActivity() { // Lazy list state – set from inside setContent, read from onPause private var conversationListLazyListState: androidx.compose.foundation.lazy.LazyListState? = null + // Ensures saved scroll position is restored only once per resume cycle, not on every room-list refresh. + private var scrollPositionRestored = false + private var nextUnreadConversationScrollPosition = 0 private var credentials: String? = null private val showShareToScreenState = MutableStateFlow(false) @@ -298,6 +301,7 @@ class ConversationsListActivity : BaseActivity() { override fun onResume() { super.onResume() + scrollPositionRestored = false showNotificationWarningState.value = shouldShowNotificationWarning() showShareToScreenState.value = hasActivityActionSendIntent() @@ -364,9 +368,12 @@ class ConversationsListActivity : BaseActivity() { val isNoteToSelfAvailable = noteToSelf != null handleNoteToSelfShortcut(isNoteToSelfAvailable, noteToSelf?.token ?: "") - val pair = appPreferences.conversationListPositionAndOffset - lifecycleScope.launch { - conversationListLazyListState?.scrollToItem(pair.first, pair.second) + if (!scrollPositionRestored) { + scrollPositionRestored = true + val pair = appPreferences.conversationListPositionAndOffset + lifecycleScope.launch { + conversationListLazyListState?.scrollToItem(pair.first, pair.second) + } } }.collect() }