From cbe72307a0c89e86f69d68fb0cbbbd789a51039b Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 15 Oct 2025 14:45:05 -0300 Subject: [PATCH] Add proper selection state support to Chats and Calls tabs. --- .../securesms/calls/log/CallLogAdapter.kt | 29 +++++++++++++++++-- .../securesms/calls/log/CallLogFragment.kt | 7 +++-- .../ConversationListFragment.java | 13 ++------- .../main/MainNavigationDetailLocation.kt | 9 +++--- .../securesms/main/MainNavigationViewModel.kt | 24 ++++++++++++++- .../main/res/layout/call_log_adapter_item.xml | 2 +- 6 files changed, 62 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt index 2e83f19dbf..ca08601be3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogAdapter.kt @@ -92,6 +92,7 @@ class CallLogAdapter( fun submitCallRows( rows: List, selectionState: CallLogSelectionState, + activeCallLogRowId: CallLogRow.Id?, localCallRecipientId: RecipientId, onCommit: () -> Unit ): Int { @@ -99,8 +100,19 @@ class CallLogAdapter( .filterNotNull() .map { when (it) { - is CallLogRow.Call -> CallModel(it, selectionState, itemCount, it.peer.id == localCallRecipientId) - is CallLogRow.CallLink -> CallLinkModel(it, selectionState, itemCount, it.recipient.id == localCallRecipientId) + is CallLogRow.Call -> CallModel( + call = it, + selectionState = selectionState, + itemCount = itemCount, + isLocalDeviceInCall = it.peer.id == localCallRecipientId + ) + is CallLogRow.CallLink -> CallLinkModel( + callLink = it, + selectionState = selectionState, + activeCallLogRowId = activeCallLogRowId, + itemCount = itemCount, + isLocalDeviceInCall = it.recipient.id == localCallRecipientId + ) is CallLogRow.ClearFilter -> ClearFilterModel() is CallLogRow.ClearFilterEmpty -> ClearFilterEmptyModel() is CallLogRow.CreateCallLink -> CreateCallLinkModel() @@ -148,6 +160,7 @@ class CallLogAdapter( private class CallLinkModel( val callLink: CallLogRow.CallLink, val selectionState: CallLogSelectionState, + val activeCallLogRowId: CallLogRow.Id?, val itemCount: Int, val isLocalDeviceInCall: Boolean ) : MappingModel { @@ -159,12 +172,13 @@ class CallLogAdapter( override fun areContentsTheSame(newItem: CallLinkModel): Boolean { return callLink == newItem.callLink && isSelectionStateTheSame(newItem) && + isActiveIdStateTheSame(newItem) && isItemCountTheSame(newItem) && isLocalDeviceInCall == newItem.isLocalDeviceInCall } override fun getChangePayload(newItem: CallLinkModel): Any? { - return if (callLink == newItem.callLink && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem))) { + return if (callLink == newItem.callLink && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem) || !isActiveIdStateTheSame(newItem))) { PAYLOAD_SELECTION_STATE } else { null @@ -176,6 +190,13 @@ class CallLogAdapter( selectionState.isNotEmpty(itemCount) == newItem.selectionState.isNotEmpty(newItem.itemCount) } + private fun isActiveIdStateTheSame(newItem: CallLinkModel): Boolean { + val isOldItemActive = activeCallLogRowId == callLink.id + val isNewItemActive = newItem.activeCallLogRowId == newItem.callLink.id + + return (isOldItemActive && isNewItemActive) || (!isOldItemActive && !isNewItemActive) + } + private fun isItemCountTheSame(newItem: CallLinkModel): Boolean { return itemCount == newItem.itemCount } @@ -220,6 +241,8 @@ class CallLogAdapter( binding.callSelected.isChecked = model.selectionState.contains(model.callLink.id) binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount) + itemView.isActivated = model.activeCallLogRowId == model.callLink.id + if (payload.isNotEmpty()) { return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt index 9b1749ad7f..7a45ef2fa0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt @@ -18,6 +18,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.appbar.AppBarLayout import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.BackpressureStrategy import io.reactivex.rxjava3.kotlin.Flowables import io.reactivex.rxjava3.kotlin.subscribeBy import kotlinx.coroutines.launch @@ -25,6 +26,7 @@ import org.signal.core.util.DimensionUnit import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.concurrent.addTo import org.signal.core.util.logging.Log +import org.signal.core.util.orNull import org.thoughtcrime.securesms.MainNavigator import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.calls.links.create.CreateCallLinkBottomSheetDialogFragment @@ -122,12 +124,13 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal ) disposables += scrollToPositionDelegate - disposables += Flowables.combineLatest(viewModel.data, viewModel.selected) + disposables += Flowables.combineLatest(viewModel.data, viewModel.selected, mainNavigationViewModel.observableActiveCallId.toFlowable(BackpressureStrategy.LATEST)) .observeOn(AndroidSchedulers.mainThread()) - .subscribe { (data, selected) -> + .subscribe { (data, selected, activeRowId) -> val filteredCount = callLogAdapter.submitCallRows( data, selected, + activeCallLogRowId = activeRowId.orNull().takeIf { resources.getWindowSizeClass().isSplitPane() }, viewModel.callLogPeekHelper.localDeviceCallRecipientId, scrollToPositionDelegate::notifyListCommitted ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 115f9d8f76..6d13669561 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -114,7 +114,6 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchData; import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey; import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator; import org.thoughtcrime.securesms.contacts.paged.ContactSearchState; -import org.thoughtcrime.securesms.conversation.ConversationArgs; import org.thoughtcrime.securesms.conversation.ConversationUpdateTick; import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest; import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource; @@ -131,7 +130,6 @@ import org.thoughtcrime.securesms.groups.SelectionLimits; import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob; import org.thoughtcrime.securesms.keyvalue.AccountValues; import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.main.MainNavigationDetailLocation; import org.thoughtcrime.securesms.main.MainNavigationListLocation; import org.thoughtcrime.securesms.main.MainNavigationViewModel; import org.thoughtcrime.securesms.main.MainToolbarMode; @@ -413,16 +411,9 @@ public class ConversationListFragment extends MainFragment implements Conversati })); if (WindowSizeClass.Companion.getWindowSizeClass(getResources()).isSplitPane()) { - lifecycleDisposable.add(mainNavigationViewModel.getDetailLocationObservable() + lifecycleDisposable.add(mainNavigationViewModel.getObservableActiveChatThreadId() .subscribeOn(AndroidSchedulers.mainThread()) - .subscribe(location -> { - if (location instanceof MainNavigationDetailLocation.Chats.Conversation) { - ConversationArgs args = ((MainNavigationDetailLocation.Chats.Conversation) location).getConversationArgs(); - long threadId = args.threadId; - - defaultAdapter.setActiveThreadId(threadId); - } - })); + .subscribe(defaultAdapter::setActiveThreadId)); } else { defaultAdapter.setActiveThreadId(0); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt index 1656f5e0e5..ac7b8e8f63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt @@ -12,6 +12,7 @@ import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import kotlinx.serialization.json.Json +import org.thoughtcrime.securesms.calls.log.CallLogRow import org.thoughtcrime.securesms.conversation.ConversationArgs import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId @@ -70,11 +71,11 @@ sealed class MainNavigationDetailLocation : Parcelable { @Parcelize sealed class Calls : MainNavigationDetailLocation() { + abstract val controllerKey: CallLogRow.Id + @Parcelize sealed class CallLinks : Calls() { - abstract val controllerKey: CallLinkRoomId - @Serializable data class CallLinkDetails(val callLinkRoomId: CallLinkRoomId) : CallLinks() { @Transient @@ -83,14 +84,14 @@ sealed class MainNavigationDetailLocation : Parcelable { @Transient @IgnoredOnParcel - override val controllerKey: CallLinkRoomId = callLinkRoomId + override val controllerKey: CallLogRow.Id = CallLogRow.Id.CallLink(callLinkRoomId) } @Serializable data class EditCallLinkName(val callLinkRoomId: CallLinkRoomId) : CallLinks() { @Transient @IgnoredOnParcel - override val controllerKey: CallLinkRoomId = callLinkRoomId + override val controllerKey: CallLogRow.Id = CallLogRow.Id.CallLink(callLinkRoomId) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt index 7b9939c321..c13e197101 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt @@ -19,10 +19,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.rx3.asObservable +import org.thoughtcrime.securesms.calls.log.CallLogRow import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -32,6 +34,7 @@ import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.window.AppScaffoldNavigator import org.thoughtcrime.securesms.window.WindowSizeClass +import java.util.Optional @OptIn(ExperimentalMaterial3AdaptiveApi::class) class MainNavigationViewModel( @@ -48,7 +51,12 @@ class MainNavigationViewModel( */ private val internalDetailLocation = MutableSharedFlow() val detailLocation: SharedFlow = internalDetailLocation - val detailLocationObservable: Observable = internalDetailLocation.asObservable() + + private val internalActiveChatThreadId = MutableStateFlow(-1L) + val observableActiveChatThreadId: Observable = internalActiveChatThreadId.asObservable() + + private val internalActiveCallId = MutableStateFlow(null) + val observableActiveCallId: Observable> = internalActiveCallId.map { Optional.ofNullable(it) }.asObservable() private val internalMegaphone = MutableStateFlow(Megaphone.NONE) val megaphone: StateFlow = internalMegaphone @@ -99,6 +107,20 @@ class MainNavigationViewModel( performStoreUpdate(MainNavigationRepository.getHasFailedOutgoingStories()) { hasFailedStories, state -> state.copy(storyFailure = hasFailedStories) } + + viewModelScope.launch { + internalDetailLocation.collect { location -> + when (location) { + is MainNavigationDetailLocation.Chats.Conversation -> { + internalActiveChatThreadId.update { location.conversationArgs.threadId } + } + is MainNavigationDetailLocation.Calls -> { + internalActiveCallId.update { location.controllerKey } + } + else -> Unit + } + } + } } /** diff --git a/app/src/main/res/layout/call_log_adapter_item.xml b/app/src/main/res/layout/call_log_adapter_item.xml index c6de6ffb0d..a23390c259 100644 --- a/app/src/main/res/layout/call_log_adapter_item.xml +++ b/app/src/main/res/layout/call_log_adapter_item.xml @@ -7,7 +7,7 @@ android:layout_marginHorizontal="12dp" android:layout_marginVertical="2dp" android:animateLayoutChanges="true" - android:background="@drawable/selectable_list_item_background" + android:background="@drawable/conversation_list_item_background" android:minHeight="68dp">