Add proper selection state support to Chats and Calls tabs.

This commit is contained in:
Alex Hart
2025-10-15 14:45:05 -03:00
committed by Cody Henthorne
parent e57b47ec82
commit cbe72307a0
6 changed files with 62 additions and 22 deletions

View File

@@ -92,6 +92,7 @@ class CallLogAdapter(
fun submitCallRows( fun submitCallRows(
rows: List<CallLogRow?>, rows: List<CallLogRow?>,
selectionState: CallLogSelectionState, selectionState: CallLogSelectionState,
activeCallLogRowId: CallLogRow.Id?,
localCallRecipientId: RecipientId, localCallRecipientId: RecipientId,
onCommit: () -> Unit onCommit: () -> Unit
): Int { ): Int {
@@ -99,8 +100,19 @@ class CallLogAdapter(
.filterNotNull() .filterNotNull()
.map { .map {
when (it) { when (it) {
is CallLogRow.Call -> CallModel(it, selectionState, itemCount, it.peer.id == localCallRecipientId) is CallLogRow.Call -> CallModel(
is CallLogRow.CallLink -> CallLinkModel(it, selectionState, itemCount, it.recipient.id == localCallRecipientId) 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.ClearFilter -> ClearFilterModel()
is CallLogRow.ClearFilterEmpty -> ClearFilterEmptyModel() is CallLogRow.ClearFilterEmpty -> ClearFilterEmptyModel()
is CallLogRow.CreateCallLink -> CreateCallLinkModel() is CallLogRow.CreateCallLink -> CreateCallLinkModel()
@@ -148,6 +160,7 @@ class CallLogAdapter(
private class CallLinkModel( private class CallLinkModel(
val callLink: CallLogRow.CallLink, val callLink: CallLogRow.CallLink,
val selectionState: CallLogSelectionState, val selectionState: CallLogSelectionState,
val activeCallLogRowId: CallLogRow.Id?,
val itemCount: Int, val itemCount: Int,
val isLocalDeviceInCall: Boolean val isLocalDeviceInCall: Boolean
) : MappingModel<CallLinkModel> { ) : MappingModel<CallLinkModel> {
@@ -159,12 +172,13 @@ class CallLogAdapter(
override fun areContentsTheSame(newItem: CallLinkModel): Boolean { override fun areContentsTheSame(newItem: CallLinkModel): Boolean {
return callLink == newItem.callLink && return callLink == newItem.callLink &&
isSelectionStateTheSame(newItem) && isSelectionStateTheSame(newItem) &&
isActiveIdStateTheSame(newItem) &&
isItemCountTheSame(newItem) && isItemCountTheSame(newItem) &&
isLocalDeviceInCall == newItem.isLocalDeviceInCall isLocalDeviceInCall == newItem.isLocalDeviceInCall
} }
override fun getChangePayload(newItem: CallLinkModel): Any? { 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 PAYLOAD_SELECTION_STATE
} else { } else {
null null
@@ -176,6 +190,13 @@ class CallLogAdapter(
selectionState.isNotEmpty(itemCount) == newItem.selectionState.isNotEmpty(newItem.itemCount) 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 { private fun isItemCountTheSame(newItem: CallLinkModel): Boolean {
return itemCount == newItem.itemCount return itemCount == newItem.itemCount
} }
@@ -220,6 +241,8 @@ class CallLogAdapter(
binding.callSelected.isChecked = model.selectionState.contains(model.callLink.id) binding.callSelected.isChecked = model.selectionState.contains(model.callLink.id)
binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount) binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount)
itemView.isActivated = model.activeCallLogRowId == model.callLink.id
if (payload.isNotEmpty()) { if (payload.isNotEmpty()) {
return return
} }

View File

@@ -18,6 +18,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.kotlin.Flowables import io.reactivex.rxjava3.kotlin.Flowables
import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.coroutines.launch 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.LifecycleDisposable
import org.signal.core.util.concurrent.addTo import org.signal.core.util.concurrent.addTo
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.MainNavigator import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.create.CreateCallLinkBottomSheetDialogFragment 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 += scrollToPositionDelegate
disposables += Flowables.combineLatest(viewModel.data, viewModel.selected) disposables += Flowables.combineLatest(viewModel.data, viewModel.selected, mainNavigationViewModel.observableActiveCallId.toFlowable(BackpressureStrategy.LATEST))
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { (data, selected) -> .subscribe { (data, selected, activeRowId) ->
val filteredCount = callLogAdapter.submitCallRows( val filteredCount = callLogAdapter.submitCallRows(
data, data,
selected, selected,
activeCallLogRowId = activeRowId.orNull().takeIf { resources.getWindowSizeClass().isSplitPane() },
viewModel.callLogPeekHelper.localDeviceCallRecipientId, viewModel.callLogPeekHelper.localDeviceCallRecipientId,
scrollToPositionDelegate::notifyListCommitted scrollToPositionDelegate::notifyListCommitted
) )

View File

@@ -114,7 +114,6 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey; import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator; import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState; import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.conversation.ConversationArgs;
import org.thoughtcrime.securesms.conversation.ConversationUpdateTick; import org.thoughtcrime.securesms.conversation.ConversationUpdateTick;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest; import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource; 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.jobs.RefreshOwnProfileJob;
import org.thoughtcrime.securesms.keyvalue.AccountValues; import org.thoughtcrime.securesms.keyvalue.AccountValues;
import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation;
import org.thoughtcrime.securesms.main.MainNavigationListLocation; import org.thoughtcrime.securesms.main.MainNavigationListLocation;
import org.thoughtcrime.securesms.main.MainNavigationViewModel; import org.thoughtcrime.securesms.main.MainNavigationViewModel;
import org.thoughtcrime.securesms.main.MainToolbarMode; import org.thoughtcrime.securesms.main.MainToolbarMode;
@@ -413,16 +411,9 @@ public class ConversationListFragment extends MainFragment implements Conversati
})); }));
if (WindowSizeClass.Companion.getWindowSizeClass(getResources()).isSplitPane()) { if (WindowSizeClass.Companion.getWindowSizeClass(getResources()).isSplitPane()) {
lifecycleDisposable.add(mainNavigationViewModel.getDetailLocationObservable() lifecycleDisposable.add(mainNavigationViewModel.getObservableActiveChatThreadId()
.subscribeOn(AndroidSchedulers.mainThread()) .subscribeOn(AndroidSchedulers.mainThread())
.subscribe(location -> { .subscribe(defaultAdapter::setActiveThreadId));
if (location instanceof MainNavigationDetailLocation.Chats.Conversation) {
ConversationArgs args = ((MainNavigationDetailLocation.Chats.Conversation) location).getConversationArgs();
long threadId = args.threadId;
defaultAdapter.setActiveThreadId(threadId);
}
}));
} else { } else {
defaultAdapter.setActiveThreadId(0); defaultAdapter.setActiveThreadId(0);
} }

View File

@@ -12,6 +12,7 @@ import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.thoughtcrime.securesms.calls.log.CallLogRow
import org.thoughtcrime.securesms.conversation.ConversationArgs import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
@@ -70,11 +71,11 @@ sealed class MainNavigationDetailLocation : Parcelable {
@Parcelize @Parcelize
sealed class Calls : MainNavigationDetailLocation() { sealed class Calls : MainNavigationDetailLocation() {
abstract val controllerKey: CallLogRow.Id
@Parcelize @Parcelize
sealed class CallLinks : Calls() { sealed class CallLinks : Calls() {
abstract val controllerKey: CallLinkRoomId
@Serializable @Serializable
data class CallLinkDetails(val callLinkRoomId: CallLinkRoomId) : CallLinks() { data class CallLinkDetails(val callLinkRoomId: CallLinkRoomId) : CallLinks() {
@Transient @Transient
@@ -83,14 +84,14 @@ sealed class MainNavigationDetailLocation : Parcelable {
@Transient @Transient
@IgnoredOnParcel @IgnoredOnParcel
override val controllerKey: CallLinkRoomId = callLinkRoomId override val controllerKey: CallLogRow.Id = CallLogRow.Id.CallLink(callLinkRoomId)
} }
@Serializable @Serializable
data class EditCallLinkName(val callLinkRoomId: CallLinkRoomId) : CallLinks() { data class EditCallLinkName(val callLinkRoomId: CallLinkRoomId) : CallLinks() {
@Transient @Transient
@IgnoredOnParcel @IgnoredOnParcel
override val controllerKey: CallLinkRoomId = callLinkRoomId override val controllerKey: CallLogRow.Id = CallLogRow.Id.CallLink(callLinkRoomId)
} }
} }
} }

View File

@@ -19,10 +19,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.reactive.asFlow
import kotlinx.coroutines.rx3.asObservable 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.components.settings.app.notifications.profiles.NotificationProfilesRepository
import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore 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.stories.Stories
import org.thoughtcrime.securesms.window.AppScaffoldNavigator import org.thoughtcrime.securesms.window.AppScaffoldNavigator
import org.thoughtcrime.securesms.window.WindowSizeClass import org.thoughtcrime.securesms.window.WindowSizeClass
import java.util.Optional
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
class MainNavigationViewModel( class MainNavigationViewModel(
@@ -48,7 +51,12 @@ class MainNavigationViewModel(
*/ */
private val internalDetailLocation = MutableSharedFlow<MainNavigationDetailLocation>() private val internalDetailLocation = MutableSharedFlow<MainNavigationDetailLocation>()
val detailLocation: SharedFlow<MainNavigationDetailLocation> = internalDetailLocation val detailLocation: SharedFlow<MainNavigationDetailLocation> = internalDetailLocation
val detailLocationObservable: Observable<MainNavigationDetailLocation> = internalDetailLocation.asObservable()
private val internalActiveChatThreadId = MutableStateFlow(-1L)
val observableActiveChatThreadId: Observable<Long> = internalActiveChatThreadId.asObservable()
private val internalActiveCallId = MutableStateFlow<CallLogRow.Id?>(null)
val observableActiveCallId: Observable<Optional<CallLogRow.Id>> = internalActiveCallId.map { Optional.ofNullable(it) }.asObservable()
private val internalMegaphone = MutableStateFlow(Megaphone.NONE) private val internalMegaphone = MutableStateFlow(Megaphone.NONE)
val megaphone: StateFlow<Megaphone> = internalMegaphone val megaphone: StateFlow<Megaphone> = internalMegaphone
@@ -99,6 +107,20 @@ class MainNavigationViewModel(
performStoreUpdate(MainNavigationRepository.getHasFailedOutgoingStories()) { hasFailedStories, state -> performStoreUpdate(MainNavigationRepository.getHasFailedOutgoingStories()) { hasFailedStories, state ->
state.copy(storyFailure = hasFailedStories) 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
}
}
}
} }
/** /**

View File

@@ -7,7 +7,7 @@
android:layout_marginHorizontal="12dp" android:layout_marginHorizontal="12dp"
android:layout_marginVertical="2dp" android:layout_marginVertical="2dp"
android:animateLayoutChanges="true" android:animateLayoutChanges="true"
android:background="@drawable/selectable_list_item_background" android:background="@drawable/conversation_list_item_background"
android:minHeight="68dp"> android:minHeight="68dp">
<androidx.appcompat.widget.AppCompatCheckBox <androidx.appcompat.widget.AppCompatCheckBox