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/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 a79c7b78631..052ec7d50ab 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -1,99 +1,56 @@ /* * 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 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.MotionEvent -import android.view.View -import android.view.animation.AnimationUtils -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.OnBackPressedCallback -import androidx.annotation.OptIn +import androidx.activity.compose.setContent import androidx.appcompat.app.AlertDialog -import androidx.appcompat.widget.SearchView -import androidx.compose.runtime.mutableStateOf -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.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.lifecycleScope -import androidx.recyclerview.widget.RecyclerView import androidx.work.Data 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 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 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.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 @@ -101,15 +58,11 @@ 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.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 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.ConversationsListBottomDialog import com.nextcloud.talk.ui.dialog.FilterConversationFragment @@ -117,9 +70,7 @@ 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 @@ -142,52 +93,28 @@ 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 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 -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 -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 -import java.io.File import java.util.concurrent.TimeUnit import javax.inject.Inject @SuppressLint("StringFormatInvalid") @AutoInjector(NextcloudTalkApplication::class) -class ConversationsListActivity : - BaseActivity(), - FlexibleAdapter.OnItemClickListener, - FlexibleAdapter.OnItemLongClickListener { - - private lateinit var binding: ActivityConversationsBinding +@Suppress("LargeClass", "TooManyFunctions") +class ConversationsListActivity : BaseActivity() { @Inject lateinit var userManager: UserManager - @Inject - lateinit var ncApi: NcApi - - @Inject - lateinit var unifiedSearchRepository: UnifiedSearchRepository - @Inject lateinit var platformPermissionUtil: PlatformPermissionUtil - @Inject - lateinit var arbitraryStorageManager: ArbitraryStorageManager - @Inject lateinit var viewModelFactory: ViewModelProvider.Factory @@ -203,47 +130,33 @@ class ConversationsListActivity : lateinit var conversationsListViewModel: ConversationsListViewModel lateinit var contextChatViewModel: ContextChatViewModel - override val appBarLayoutType: AppBarLayoutType - get() = AppBarLayoutType.SEARCH_BAR - private var currentUser: User? = null - 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 var searchItem: MenuItem? = null - private var chooseAccountItem: MenuItem? = null - private var searchView: SearchView? = null - private var searchQuery: String? = null + private val snackbarHostState = SnackbarHostState() + private val isMaintenanceModeState = 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 + + // 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 var adapterWasNull = true - private var isRefreshing = false - 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 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 - 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) { override fun handleOnBackPressed() { @@ -264,20 +177,113 @@ class ConversationsListActivity : conversationsListViewModel = ViewModelProvider(this, viewModelFactory)[ConversationsListViewModel::class.java] contextChatViewModel = ViewModelProvider(this, viewModelFactory)[ContextChatViewModel::class.java] - binding = ActivityConversationsBinding.inflate(layoutInflater) - setupActionBar() - setContentView(binding.root) - 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) + setSupportActionBar(null) + 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 + appPreferences.setConversationListPositionAndOffset(0, 0) + fetchRooms() + fetchPendingInvitations() + }, + onFabClick = { + run(context) + showNewConversationsScreen() + }, + onUnreadBubbleClick = { + lifecycleScope.launch { + 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 + }, + 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) @@ -295,18 +301,10 @@ class ConversationsListActivity : override fun onResume() { super.onResume() - if (adapter == null) { - adapter = FlexibleAdapter(conversationItems, this, true) - addEmptyItemForEdgeToEdgeIfNecessary() - } else { - binding.loadingContent.visibility = View.GONE - } - adapter?.addListener(this) - prepareViews() + scrollPositionRestored = false - showNotificationWarning() - - showShareToScreen = hasActivityActionSendIntent() + showNotificationWarningState.value = shouldShowNotificationWarning() + showShareToScreenState.value = hasActivityActionSendIntent() if (!eventBus.isRegistered(this)) { eventBus.register(this) @@ -317,157 +315,66 @@ class ConversationsListActivity : 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) - 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) + 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() 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)) } - showSearchOrToolbar() conversationsListViewModel.checkIfThreadsExist() + conversationsListViewModel.reloadFilterFromStorage(UserIdUtils.getIdForUser(currentUser)) } 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) } + appPreferences.setConversationListLastUserId(currentUser?.id ?: -1L) } - @Suppress("LongMethod") + @Suppress("LongMethod", "CyclomaticComplexMethod") private fun initObservers() { - this.lifecycleScope.launch { - networkMonitor.isOnline.onEach { isOnline -> - showNetworkErrorDialog(!isOnline) - handleUI(isOnline) - }.collect() - } - - lifecycleScope.launch { - conversationsListViewModel.searchResultFlow.collect { searchResults -> - if (adapter?.hasFilter() == true) { - adapter?.updateDataSet(searchResults) - } - } - } - - 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 -> { - 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 -> { - if (adapterWasNull) { - adapterWasNull = false - binding.loadingContent.visibility = View.GONE - } - initOverallLayout(state.listIsNotEmpty) - binding.swipeRefreshLayoutView.isRefreshing = false + isRefreshingState.value = false } is ConversationsListViewModel.GetRoomsErrorState -> { - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_SHORT).show() + isRefreshingState.value = false + handleHttpExceptions(state.throwable) } else -> {} } } - 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 -> - 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) + if (!scrollPositionRestored) { + scrollPositionRestored = true + val pair = appPreferences.conversationListPositionAndOffset + lifecycleScope.launch { + conversationListLazyListState?.scrollToItem(pair.first, pair.second) + } + } }.collect() } @@ -490,53 +397,9 @@ 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 { - 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() + conversationsListViewModel.filterStateFlow.collect { filterState -> + if (filterState[ARCHIVE] == true) showUnreadBubbleState.value = false + } } } @@ -563,623 +426,65 @@ 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) - } - - getFilterStates() - val noFiltersActive = !( - filterState[MENTION] == true || - filterState[UNREAD] == true || - filterState[ARCHIVE] == true - ) - - sortConversations(conversationItems) - sortConversations(conversationItemsWithHeader) - sortConversations(nearFutureEventConversationItems) - - if (noFiltersActive && searchBehaviorSubject.value == false) { - adapter?.updateDataSet(nearFutureEventConversationItems, false) - } else { - applyFilter() - } - - Handler().postDelayed({ checkToShowUnreadBubble() }, UNREAD_BUBBLE_DELAY.toLong()) - } - - fun applyFilter() { - if (!hasFilterEnabled()) { - filterableConversationItems = conversationItems - } - filterConversation() - adapter?.updateDataSet(filterableConversationItems, false) - } - - private fun hasFilterEnabled(): Boolean { - for ((k, v) in filterState) { - if (k != FilterConversationFragment.DEFAULT && v) return true - } - - return false - } - - 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 - } - fun showOnlyNearFutureEvents() { - sortConversations(nearFutureEventConversationItems) - adapter?.updateDataSet(nearFutureEventConversationItems, false) - adapter?.smoothScrollToPosition(0) - } - - private fun addToNearFutureEventConversationItems(conversation: ConversationModel) { - val conversationItem = ConversationItem(conversation, currentUser!!, this, null, viewThemeUtils) - nearFutureEventConversationItems.add(conversationItem) - } - - 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() { - 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 - if (archiveFilterOn && newItems.isEmpty()) { - binding.noArchivedConversationLayout.visibility = View.VISIBLE - } else { - binding.noArchivedConversationLayout.visibility = View.GONE - } - - adapter?.updateDataSet(newItems, true) - setFilterableItems(newItems) - if (archiveFilterOn) { - // Never a notification from archived conversations - binding.newMentionPopupBubble.visibility = View.GONE - } - - layoutManager?.scrollToPositionWithOffset(0, 0) - 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 { - 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 { - val shouldDismiss = mutableStateOf(false) - setContent { - ChooseAccountDialogCompose().GetChooseAccountDialog( - shouldDismiss, - this@ConversationsListActivity, - appPreferences.isShowEcosystem && !brandedClient - ) - } - } - } - - 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) - - 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 = conversationItems.size > 0 - if (adapter?.hasFilter() == true) { - showSearchView(searchView, searchItem) - searchView!!.setQuery(adapter?.getFilter(String::class.java), 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 { - resetSearchResults() - 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() - adapter?.setHeadersShown(true) - adapter!!.showAllHeaders() - searchableConversationItems.addAll(conversationItemsWithHeader) - if (!hasFilterEnabled()) filterableConversationItems = searchableConversationItems - binding.swipeRefreshLayoutView.isEnabled = false - searchBehaviorSubject.onNext(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() - if (searchHelper != null) { - // cancel any pending searches - searchHelper!!.cancelSearch() - } - binding.swipeRefreshLayoutView.isRefreshing = false - binding.swipeRefreshLayoutView.isEnabled = true - 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) - } - - val layoutManager = binding.recyclerView.layoutManager as SmoothScrollLinearLayoutManager? - layoutManager?.scrollToPositionWithOffset(0, 0) - return true - } - }) - } - return true - } - - private fun showSearchOrToolbar() { - if (TextUtils.isEmpty(searchQuery)) { - 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() - } - - fun fetchRooms() { - conversationsListViewModel.getRooms(currentUser!!) - } - - 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() - } - } - - private fun initOverallLayout(isConversationListNotEmpty: Boolean) { - 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 - } - } - } - - 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() { - binding.floatingActionButton.let { - val dialogBuilder = MaterialAlertDialogBuilder(it.context) - .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) - - 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) - } - } - - 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) + // Reset all filters so the ViewModel's default view (non-archived, non-future-events) is shown + conversationsListViewModel.applyFilter( + mapOf( + MENTION to false, + UNREAD to false, + ARCHIVE to false, + FilterConversationFragment.DEFAULT to true ) + ) + } + + private fun handleConversationLongClick(model: ConversationModel) { + lifecycleScope.launch { + if (!showShareToScreen && networkMonitor.isOnline.value) { + conversationsListBottomDialog = ConversationsListBottomDialog( + this@ConversationsListActivity, + currentUser!!, + model + ) + conversationsListBottomDialog!!.show() + } } } - private fun showNetworkErrorDialog(show: Boolean) { - binding.chatListConnectionLost.visibility = if (show) View.VISIBLE else View.GONE + private fun showContextChatForMessage(result: SearchMessageEntry) { + contextChatViewModel.getContextForChatMessages( + credentials = credentials ?: "", + baseUrl = currentUser?.baseUrl ?: "", + token = result.conversationToken, + threadId = result.threadId, + messageId = result.messageId ?: "", + title = result.title + ) } - private fun showMaintenanceModeWarning(show: Boolean) { - binding.chatListMaintenanceWarning.visibility = if (show) View.VISIBLE else View.GONE + fun filterConversation() { + conversationsListViewModel.reloadFilterFromStorage(UserIdUtils.getIdForUser(currentUser)) } - private fun handleUI(show: Boolean) { - binding.floatingActionButton.isEnabled = show - binding.searchText.isEnabled = show - binding.searchText.isVisible = show + private fun showChooseAccountDialog() { + showAccountDialogState.value = true } - 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 fun hasActivityActionSendIntent(): Boolean = + Intent.ACTION_SEND == intent.action || Intent.ACTION_SEND_MULTIPLE == intent.action + + fun showSnackbar(text: String) { + lifecycleScope.launch { snackbarHostState.showSnackbar(text) } } - private suspend fun fetchOpenConversations(searchTerm: String) { - searchableConversationItems.clear() - searchableConversationItems.addAll(conversationItemsWithHeader) - conversationsListViewModel.fetchOpenConversations(searchTerm) + fun fetchRooms() { + conversationsListViewModel.getRooms(currentUser!!) } - private suspend fun fetchUsers(query: String = "") { - contactsViewModel.getBlockingContactsFromSearchParams(query) + private fun fetchPendingInvitations() { + if (hasSpreedFeatureCapability(currentUser?.capabilities?.spreedCapability, SpreedFeatures.FEDERATION_V1)) { + conversationsListViewModel.getFederationInvitations() + } } private fun handleHttpExceptions(throwable: Throwable) { @@ -1201,302 +506,92 @@ class ConversationsListActivity : } } - @SuppressLint("ClickableViewAccessibility") - private fun prepareViews() { - hideLogoForBrandedClients() - - showMaintenanceModeWarning(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() - } - } - } - }) - 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 { - showMaintenanceModeWarning(false) - fetchRooms() - fetchPendingInvitations() - } - binding.swipeRefreshLayoutView.let { viewThemeUtils.androidx.themeSwipeRefreshLayout(it) } - binding.emptyLayout.setOnClickListener { showNewConversationsScreen() } - binding.floatingActionButton.setOnClickListener { - run(context) - showNewConversationsScreen() - } - binding.floatingActionButton.let { viewThemeUtils.material.themeFAB(it) } + private fun showErrorDialog() { + 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) - binding.switchAccountButton.setOnClickListener { - if (resources != null && resources!!.getBoolean(R.bool.multiaccount_support)) { + if (resources!!.getBoolean(R.bool.multiaccount_support) && userManager.users.blockingGet().size > 1) { + dialogBuilder.setPositiveButton(R.string.nc_switch_account) { _, _ -> 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) + 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) + } } - 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) } + 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 hideLogoForBrandedClients() { - if (!BrandingUtils.isOriginalNextcloudClient(applicationContext)) { - binding.emptyListIcon.visibility = View.GONE + @Suppress("Detekt.TooGenericExceptionCaught") + private fun checkToShowUnreadBubble(lastVisibleIndex: Int) { + if (conversationsListViewModel.isSearchActiveFlow.value) { + nextUnreadConversationScrollPosition = 0 + showUnreadBubbleState.value = false + return + } + try { + val entries = conversationsListViewModel.conversationListEntriesFlow.value + val firstUnreadPosition = findFirstOffscreenUnreadPosition(entries, lastVisibleIndex) + if (firstUnreadPosition != null) { + nextUnreadConversationScrollPosition = firstUnreadPosition + showUnreadBubbleState.value = true + } else { + nextUnreadConversationScrollPosition = 0 + showUnreadBubbleState.value = false + } + } catch (e: Exception) { + Log.d(TAG, "Exception in checkToShowUnreadBubble", e) } } - @SuppressLint("CheckResult") - @Suppress("Detekt.TooGenericExceptionCaught") - private fun checkToShowUnreadBubble() { - searchBehaviorSubject.subscribe { value -> - if (value) { - nextUnreadConversationScrollPosition = 0 - binding.newMentionPopupBubble.visibility = View.GONE - } 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 - if (!binding.newMentionPopupBubble.isShown) { - binding.newMentionPopupBubble.visibility = View.VISIBLE - val popupAnimation = AnimationUtils.loadAnimation(this, R.anim.popup_animation) - binding.newMentionPopupBubble.startAnimation(popupAnimation) - } - return@subscribe - } - } - nextUnreadConversationScrollPosition = 0 - binding.newMentionPopupBubble.visibility = View.GONE - } 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( + 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) } - 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 - } - } - - 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 if (adapter?.hasNewFilter(newText) == true) { - performFilterAndSearch(newText) - } - } - - private fun performFilterAndSearch(filter: String?) { - if (filter!!.length >= SEARCH_MIN_CHARS) { - binding.noArchivedConversationLayout.visibility = View.GONE - adapter?.setFilter(filter) - conversationsListViewModel.getSearchQuery(context, filter) - } else { - resetSearchResults() - } - } - - private fun resetSearchResults() { - adapter?.updateDataSet(conversationItems) - adapter?.setFilter("") - adapter?.filterItems() - val archiveFilterOn = filterState[ARCHIVE] == true - if (archiveFilterOn && adapter!!.isEmpty) { - binding.noArchivedConversationLayout.visibility = View.VISIBLE - } else { - binding.noArchivedConversationLayout.visibility = View.GONE - } - } - - 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 - } - - 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) - } - } } @Suppress("Detekt.ComplexMethod") @@ -1515,14 +610,14 @@ class ConversationsListActivity : ) { 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)) - forwardMessage = false + 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() @@ -1551,9 +646,7 @@ class ConversationsListActivity : } 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)) } } @@ -1575,27 +668,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) } @@ -1605,82 +696,55 @@ 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.") - } 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() - } - } - } - } - @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()) - } - } - 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") + 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 { + extractFilesFromClipData() + } + if (filesToShare!!.isEmpty() && textToPaste!!.isEmpty()) { + showSnackbar(context.resources.getString(R.string.nc_common_error_sorry)) + Log.e(TAG, "failed to get data from intent") + } + } catch (e: Exception) { + showSnackbar(context.resources.getString(R.string.nc_common_error_sorry)) + Log.e(TAG, "Something went wrong when extracting data from intent") + } + } + + 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 } - } 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()) } } 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 } @@ -1694,8 +758,7 @@ class ConversationsListActivity : ) } } 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) } } @@ -1709,11 +772,7 @@ class ConversationsListActivity : 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)) } } @@ -1756,28 +815,6 @@ 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) - } - } else { - binding.conversationListNotificationWarning.conversationListNotificationWarningLayout.visibility = View.GONE - } - } - private fun shouldShowNotificationWarning(): Boolean { fun shouldShowWarningIfDateTooOld(date1: Long): Boolean { val currentTimeMillis = System.currentTimeMillis() @@ -1824,11 +861,7 @@ class ConversationsListActivity : 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 } @@ -1851,7 +884,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() } @@ -1871,61 +904,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") @@ -1973,94 +1002,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") { - showMaintenanceModeWarning(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) { @@ -2099,62 +1124,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) } - } else { - binding.filterConversationsButton.let { - viewThemeUtils.platform.colorImageView( - it, - ColorRole.ON_SURFACE_VARIANT - ) - } - } - } - fun openFollowedThreadsOverview() { val threadsUrl = ApiUtils.getUrlForSubscribedThreads( version = 1, @@ -2171,12 +1140,8 @@ class ConversationsListActivity : 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 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 const val HTTP_UNAUTHORIZED = 401 const val HTTP_CLIENT_UPGRADE_REQUIRED = 426 const val CLIENT_UPGRADE_MARKET_LINK = "market://details?id=" @@ -2184,14 +1149,9 @@ class ConversationsListActivity : 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 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 new file mode 100644 index 00000000000..8b7fc5e3776 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationList.kt @@ -0,0 +1,335 @@ +/* + * 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.PaddingValues +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 kotlinx.coroutines.flow.first +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 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(), + /** 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) } + 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) + } + } + } + + // 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, + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = contentBottomPadding) + ) { + 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 = 16.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 new file mode 100644 index 00000000000..cc915b46fa5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListFab.kt @@ -0,0 +1,110 @@ +/* + * 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.padding +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.text.style.TextAlign +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) { + 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() }, + 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) + ) + } + } +} + +@Composable +fun UnreadMentionBubble(visible: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + AnimatedVisibility( + visible = visible, + modifier = modifier, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + Button( + onClick = onClick, + modifier = Modifier.padding(horizontal = UNREAD_MENTIONS_HORIZONTAL_SPACING.dp), + 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_half_padding))) + Text( + text = stringResource(R.string.nc_new_mention), + color = MaterialTheme.colorScheme.onPrimary, + textAlign = TextAlign.Center + ) + } + } +} + +@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/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..1c37a93062c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListItem.kt @@ -0,0 +1,1815 @@ +/* + * 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 +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.fillMaxSize +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.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 +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 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 +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 + +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 +private const val MILLIS_PER_SECOND = 1_000L + +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() +} + +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) + + 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, + avatarVersion + ) + ) + } +} + +/** 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, + callbacks: ConversationListItemCallbacks, + modifier: Modifier = Modifier, + searchQuery: String = "" +) { + val chatMessage = remember(model.lastMessage, currentUser) { + model.lastMessage?.asModel()?.also { it.activeUser = currentUser } + } + + Row( + modifier = modifier + .fillMaxWidth() + .combinedClickable(onClick = callbacks.onClick, onLongClick = callbacks.onLongClick) + .padding( + horizontal = 16.dp, + vertical = 16.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, searchQuery = searchQuery) + } + } +} + +@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?, + searchQuery: String = "" +) { + Column(modifier = Modifier.weight(1f)) { + ConversationNameRow(model = model, chatMessage = chatMessage, searchQuery = searchQuery) + Spacer(Modifier.height(4.dp)) + ConversationLastMessageRow( + model = model, + currentUser = currentUser, + chatMessage = chatMessage, + searchQuery = searchQuery + ) + } +} + +@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) + ) + } +} + +@Suppress("LongMethod") +@Composable +private fun ConversationAvatarImage(model: ConversationModel, currentUser: User, modifier: Modifier = Modifier) { + val isInPreview = LocalInspectionMode.current + 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 -> { + 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 -> { + 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 -> { + 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 + + 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?, 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 = buildHighlightedText(model.displayName, searchQuery, primaryColor), + 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 * MILLIS_PER_SECOND, + 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?, + searchQuery: String = "" +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + LastMessageContent( + model = model, + currentUser = currentUser, + chatMessage = chatMessage, + searchQuery = searchQuery, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(4.dp)) + UnreadBubble(model = model, currentUser = currentUser) + } +} + +@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 + ) + } +} + +@Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount") +@Composable +private fun LastMessageContent( + model: ConversationModel, + currentUser: User, + chatMessage: ChatMessage?, + modifier: Modifier = Modifier, + searchQuery: String = "" +) { + val isBold = model.unreadMessages > 0 + val fontWeight = if (isBold) FontWeight.Bold else FontWeight.Normal + val primaryColor = MaterialTheme.colorScheme.primary + + // Draft + val draftText = model.messageDraft?.messageText?.takeIf { it.isNotBlank() } + if (draftText != null) { + 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(buildHighlightedText(draftText, searchQuery, primaryColor)) + } + Text( + text = annotated, + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + fontWeight = fontWeight, + color = colorResource(R.color.textColorMaxContrast) + ) + return + } + + // No message + if (chatMessage == null) { + Text(text = "", modifier = modifier) + return + } + + // 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(displayText, searchQuery, primaryColor), + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + return + } + + val msgType = chatMessage.getCalculateMessageType() + + // System message + if (msgType == ChatMessage.MessageType.SYSTEM_MESSAGE || + model.type == ConversationEnums.ConversationType.ROOM_SYSTEM + ) { + val parsedText = ChatUtils.getParsedMessage(chatMessage.message, chatMessage.messageParameters) ?: "" + Text( + text = buildHighlightedText(parsedText, searchQuery, primaryColor), + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + fontWeight = fontWeight, + color = colorResource(R.color.textColorMaxContrast) + ) + return + } + + // Attachment / special message types + when (msgType) { + ChatMessage.MessageType.VOICE_MESSAGE -> { + val name = chatMessage.messageParameters?.get("file")?.get("name") ?: "" + val prefix = authorPrefix(chatMessage, currentUser) + AttachmentRow( + authorPrefix = prefix, + iconRes = R.drawable.baseline_mic_24, + name = name, + fontWeight = fontWeight, + modifier = modifier, + searchQuery = searchQuery + ) + return + } + + ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE -> { + var name = chatMessage.message ?: "" + 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) + 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( + 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( + 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( + authorPrefix = prefix, + iconRes = R.drawable.baseline_article_24, + name = name, + fontWeight = fontWeight, + modifier = modifier, + searchQuery = searchQuery + ) + 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, + color = colorResource(R.color.textColorMaxContrast) + ) + 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, + color = colorResource(R.color.textColorMaxContrast) + ) + 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, + color = colorResource(R.color.textColorMaxContrast) + ) + 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, + color = colorResource(R.color.textColorMaxContrast) + ) + return + } + + else -> { /* fall through to regular text */ } + } + + // 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 -> 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(displayText, searchQuery, primaryColor), + modifier = modifier, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + 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, + 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, + color = colorResource(R.color.textColorMaxContrast) + ) + } + 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 = buildHighlightedText(name, searchQuery, primaryColor), + style = MaterialTheme.typography.bodyMedium, + fontWeight = fontWeight, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + color = colorResource(R.color.textColorMaxContrast) + ) + } +} + +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 + } + +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 +) + +@Suppress("LongParameterList") +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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "A2 - Group", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewGroup() = + PreviewWrapper(darkTheme = true) { + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "A4 - Public room", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewPublicRoom() = + PreviewWrapper(darkTheme = true) { + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "A6 - Note to self", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewNoteToSelf() = + PreviewWrapper(darkTheme = true) { + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +// Section B - ObjectType / Special Avatar + +@Preview(name = "B8 - Password protected", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewPasswordProtected() = + PreviewWrapper(darkTheme = true) { + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "B10 - Phone temporary room", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewPhoneNumberRoom() = + PreviewWrapper(darkTheme = true) { + 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 + +@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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +// Section D - Unread States + +@Preview(name = "D12 - No unread", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewNoUnread() = + PreviewWrapper(darkTheme = true) { + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "D14 - Unread many (1500)", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewUnreadMany() = + PreviewWrapper(darkTheme = true) { + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "D16 - Unread mention direct 1:1 (filled)", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewUnreadMentionDirect1to1() = + PreviewWrapper(darkTheme = true) { + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +// Section E - Favorite + +@Preview(name = "E18 - Favorite", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewFavorite() = + PreviewWrapper(darkTheme = true) { + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +// Section F - Status (1:1 only) + +@Preview(name = "F20 - Status online", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewStatusOnline() = + PreviewWrapper(darkTheme = true) { + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "F22 - Status DND", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewStatusDnd() = + PreviewWrapper(darkTheme = true) { + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "F24 - Status with emoji", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewStatusWithEmoji() = + PreviewWrapper(darkTheme = true) { + ConversationListItem( + model = previewModel( + displayName = "Eve", + status = "online", + statusIcon = "?", + lastMessage = previewMsg(message = "Grabbing coffee") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(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"), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "G26 - Other regular text", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewLastMessageOtherText() = + PreviewWrapper(darkTheme = true) { + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "G28 - Voice message", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewLastMessageVoice() = + PreviewWrapper(darkTheme = true) { + 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") + ) + ) + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "G30 - Video attachment", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewLastMessageVideo() = + PreviewWrapper(darkTheme = true) { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "{file}", + messageParameters = hashMapOf( + "file" to hashMapOf("name" to "clip.mp4", "mimetype" to "video/mp4") + ) + ) + ), + 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") + ) + ) + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "G32 - File attachment", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewLastMessageFile() = + PreviewWrapper(darkTheme = true) { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "{file}", + messageParameters = hashMapOf( + "file" to hashMapOf("name" to "report.pdf", "mimetype" to "application/pdf") + ) + ) + ), + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "G34 - Location message", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewLastMessageLocation() = + PreviewWrapper(darkTheme = true) { + 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(), + 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") + ) + ) + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "G36 - Deck card", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewLastMessageDeck() = + PreviewWrapper(darkTheme = true) { + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "G37 - Deleted message") +@Composable +private fun PreviewLastMessageDeleted() = + PreviewWrapper { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg( + message = "You: Message deleted", + messageType = "comment_deleted" + ) + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "G38 - No last message", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewNoLastMessage() = + PreviewWrapper(darkTheme = true) { + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "G49 - Text with emoji", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewLastMessageTextWithEmoji() = + PreviewWrapper(darkTheme = true) { + ConversationListItem( + model = previewModel( + displayName = "Alice", + lastMessage = previewMsg(message = "Sch-nes Wochenende! ????") + ), + currentUser = previewUser(), + callbacks = ConversationListItemCallbacks(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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +// Section I - Lobby / Read-only + +@Preview(name = "I41 - Lobby active", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewLobbyActive() = + PreviewWrapper(darkTheme = true) { + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +// Section J - Sensitive + +@Preview(name = "J43 - Sensitive (name only)", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewSensitive() = + PreviewWrapper(darkTheme = true) { + 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 + +@Preview(name = "K44 - Archived") +@Composable +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 + +@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(), + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } + +@Preview(name = "L47 - Short content, no date", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewShortContent() = + PreviewWrapper(darkTheme = true) { + 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(), + callbacks = ConversationListItemCallbacks(onClick = {}, onLongClick = {}) + ) + } 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..54eebc9ec63 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationListTopBar.kt @@ -0,0 +1,700 @@ +/* + * 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( + state = state, + actions = actions + ) + 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 + ) + } + } +} + +@Composable +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 + ) { + IdleSearchBarCard( + state = state, + actions = actions, + modifier = Modifier + .weight(1f) + .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.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 + ) + } + } + } +} + +@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 + ), + windowInsets = WindowInsets(0) + ) +} + +@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/ConversationShimmerList.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationShimmerList.kt new file mode 100644 index 00000000000..b259f5a2b19 --- /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.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.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 = ExitTransition.None + ) { + 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) + Spacer(modifier = Modifier.width(4.dp)) + 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 = 16.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 new file mode 100644 index 00000000000..d2be4dd7529 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsEmptyState.kt @@ -0,0 +1,261 @@ +/* + * 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.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 +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 && isListEmpty -> 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 + ) + } +} + +@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 +private fun EmptyConversationsWithLogoPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + EmptyConversationsView(showLogo = true, onCreateNewConversation = {}) + } + } +} + +@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() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + EmptyConversationsView(showLogo = false, onCreateNewConversation = {}) + } + } +} + +@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() { + 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/ui/ConversationsListScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt new file mode 100644 index 00000000000..95cfd9fa872 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ui/ConversationsListScreen.kt @@ -0,0 +1,608 @@ +/* + * 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.layout.statusBarsPadding +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 effectiveShimmerVisible = isShimmerVisible + + val isRoomsEmpty = rooms.isEmpty() && !effectiveShimmerVisible + 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().statusBarsPadding()) { + 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 (!effectiveShimmerVisible) { + 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 = effectiveShimmerVisible) + } + } + + // 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/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/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/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/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/viewmodels/ConversationsListViewModel.kt index 316efb57afe..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 @@ -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,24 +24,28 @@ 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 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 @@ -63,13 +63,13 @@ import kotlinx.coroutines.withContext import java.util.concurrent.TimeUnit import javax.inject.Inject +@Suppress("LongParameterList", "TooManyFunctions") 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, @@ -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,34 +114,155 @@ class ConversationsListViewModel @Inject constructor( .onEach { list -> _getRoomsViewState.value = GetRoomsSuccessState(list.isNotEmpty()) }.catch { - _getRoomsViewState.value = GetRoomsErrorState + _getRoomsViewState.value = GetRoomsErrorState(it) } + 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()) - object GetFederationInvitationsStartState : ViewState - object GetFederationInvitationsErrorState : ViewState + private val _federationInvitationHintVisible = MutableStateFlow(false) + val federationInvitationHintVisible: StateFlow = _federationInvitationHintVisible.asStateFlow() + + 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) + ) + val filterStateFlow: StateFlow> = _filterStateFlow.asStateFlow() + + private val _isSearchActiveFlow = MutableStateFlow(false) + val isSearchActiveFlow: StateFlow = _isSearchActiveFlow.asStateFlow() + + 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) + + /** + * 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.Eagerly, emptyList()) + + /** Update filter state; triggers [conversationListEntriesFlow] re-emit. */ + fun applyFilter(newFilterState: Map) { + _filterStateFlow.value = newFilterState + } - open class GetFederationInvitationsSuccessState(val showInvitationsHint: Boolean) : ViewState + /** + * 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 + ) + } + } - private val _getFederationInvitationsViewState: MutableLiveData = - MutableLiveData(GetFederationInvitationsStartState) - val getFederationInvitationsViewState: LiveData - get() = _getFederationInvitationsViewState + /** 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 = "" + } - object ShowBadgeStartState : ViewState - object ShowBadgeErrorState : ViewState - open class ShowBadgeSuccessState(val showBadge: Boolean) : ViewState + /** + * 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 + } + } - private val _showBadgeViewState: MutableLiveData = MutableLiveData(ShowBadgeStartState) - val showBadgeViewState: LiveData - get() = _showBadgeViewState + /** 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 = "" + } + } + + /** Exclude the forward-source room token from the list. */ + fun setHideRoomToken(token: String?) { + hideRoomToken.value = token + } fun getFederationInvitations() { - _getFederationInvitationsViewState.value = GetFederationInvitationsStartState - _showBadgeViewState.value = ShowBadgeStartState + _federationInvitationHintVisible.value = false + _showAvatarBadge.value = false userManager.users.blockingGet()?.forEach { invitationsRepository.fetchInvitations(it) @@ -151,82 +272,68 @@ 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) + // 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) + val usersTitle = context.resources.getString(R.string.nc_user) + val messagesTitle = context.resources.getString(R.string.messages) val actorTypeConverter = EnumActorTypeConverter() - viewModelScope.launch { + searchJob = 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) + _isSearchLoadingFlow.value = false } } } @@ -239,26 +346,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 } } } @@ -277,8 +374,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") @@ -358,12 +455,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!!) @@ -381,6 +473,75 @@ class ConversationsListViewModel @Inject constructor( } } + private fun buildConversationListEntries( + rooms: List, + filterState: Map, + isSearchActive: Boolean, + searchResults: List, + hideToken: String? + ): List { + if (isSearchActive) 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 @@ -392,20 +553,15 @@ class ConversationsListViewModel @Inject constructor( if (invitationsModel.user.userId?.equals(currentUser.userId) == true && invitationsModel.user.baseUrl?.equals(currentUser.baseUrl) == true ) { - if (invitationsModel.invitations.isNotEmpty()) { - _getFederationInvitationsViewState.value = GetFederationInvitationsSuccessState(true) - } else { - _getFederationInvitationsViewState.value = GetFederationInvitationsSuccessState(false) - } + _federationInvitationHintVisible.value = invitationsModel.invitations.isNotEmpty() } else { if (invitationsModel.invitations.isNotEmpty()) { - _showBadgeViewState.value = ShowBadgeSuccessState(true) + _showAvatarBadge.value = true } } } override fun onError(e: Throwable) { - _getFederationInvitationsViewState.value = GetFederationInvitationsErrorState Log.e(TAG, "Failed to fetch pending invitations", e) } @@ -418,5 +574,7 @@ 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 } } 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/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() 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/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/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/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/drawable/ic_videocam_24px.xml b/app/src/main/res/drawable/ic_videocam_24px.xml new file mode 100644 index 00000000000..26ab23fbc3f --- /dev/null +++ b/app/src/main/res/drawable/ic_videocam_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 deleted file mode 100644 index bc99de77bae..00000000000 --- a/app/src/main/res/layout/activity_conversations.xml +++ /dev/null @@ -1,352 +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/notifications_warning.xml b/app/src/main/res/layout/notifications_warning.xml deleted file mode 100644 index 4155ba3518c..00000000000 --- a/app/src/main/res/layout/notifications_warning.xml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/rv_item_conversation_with_last_message.xml b/app/src/main/res/layout/rv_item_conversation_with_last_message.xml deleted file mode 100644 index ad83cb91c5f..00000000000 --- a/app/src/main/res/layout/rv_item_conversation_with_last_message.xml +++ /dev/null @@ -1,135 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 @@ - - - - - - - - - - - - - - - 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/menu/menu_conversation_plus_filter.xml b/app/src/main/res/menu/menu_conversation_plus_filter.xml deleted file mode 100644 index 4bc6c11816a..00000000000 --- a/app/src/main/res/menu/menu_conversation_plus_filter.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - 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 diff --git a/scripts/analysis/lint-results.txt b/scripts/analysis/lint-results.txt index 7a5728da392..586c6ae18a3 100644 --- a/scripts/analysis/lint-results.txt +++ b/scripts/analysis/lint-results.txt @@ -1,2 +1,2 @@ DO NOT TOUCH; GENERATED BY DRONE - Lint Report: 93 warnings + Lint Report: 95 warnings