Implement activated state for conversation list items.

This commit is contained in:
Alex Hart
2025-04-23 14:26:55 -03:00
committed by Cody Henthorne
parent dac3a332d7
commit 64239962fc
10 changed files with 128 additions and 55 deletions

View File

@@ -17,9 +17,11 @@ public interface BindableConversationListItem extends Unbindable {
@NonNull ThreadRecord thread,
@NonNull RequestManager requestManager, @NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull ConversationSet selectedConversations);
@NonNull ConversationSet selectedConversations,
long activeThreadId);
void setSelectedConversations(@NonNull ConversationSet conversations);
void setActiveThreadId(long activeThreadId);
void updateTypingIndicator(@NonNull Set<Long> typingThreads);
void updateTimestamp();
}

View File

@@ -211,7 +211,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
setContent {
val listHostState = rememberFragmentState()
val detailLocation by mainNavigationViewModel.detailLocation.collectAsStateWithLifecycle(MainNavigationDetailLocation.Empty)
val detailLocation by mainNavigationViewModel.detailLocationRequests.collectAsStateWithLifecycle(MainNavigationDetailLocation.Empty)
val snackbar by mainNavigationViewModel.snackbar.collectAsStateWithLifecycle()
val mainToolbarState by toolbarViewModel.state.collectAsStateWithLifecycle()
val megaphone by mainNavigationViewModel.megaphone.collectAsStateWithLifecycle()

View File

@@ -44,7 +44,8 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
private enum Payload {
TYPING_INDICATOR,
SELECTION,
TIMESTAMP
TIMESTAMP,
ACTIVE
}
private final LifecycleOwner lifecycleOwner;
@@ -55,6 +56,7 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
private final Set<Long> typingSet = new HashSet<>();
private ConversationSet selectedConversations = new ConversationSet();
private long activeThreadId = 0;
private PagingController pagingController;
protected ConversationListAdapter(@NonNull LifecycleOwner lifecycleOwner,
@@ -148,6 +150,7 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
case TYPING_INDICATOR -> vh.getConversationListItem().updateTypingIndicator(typingSet);
case SELECTION -> vh.getConversationListItem().setSelectedConversations(selectedConversations);
case TIMESTAMP -> vh.getConversationListItem().updateTimestamp();
case ACTIVE -> vh.getConversationListItem().setActiveThreadId(activeThreadId);
}
}
}
@@ -165,7 +168,8 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
requestManager,
Locale.getDefault(),
typingSet,
selectedConversations);
selectedConversations,
activeThreadId);
} else if (holder.getItemViewType() == TYPE_HEADER) {
HeaderViewHolder casted = (HeaderViewHolder) holder;
Conversation conversation = Objects.requireNonNull(getItem(position));
@@ -224,6 +228,11 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
notifyItemRangeChanged(0, getItemCount(), Payload.SELECTION);
}
void setActiveThreadId(long activeThreadId) {
this.activeThreadId = activeThreadId;
notifyItemRangeChanged(0, getItemCount(), Payload.ACTIVE);
}
@Override
public int getItemViewType(int position) {
Conversation conversation = getItem(position);

View File

@@ -18,6 +18,7 @@ package org.thoughtcrime.securesms.conversationlist;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.content.res.Configuration;
import android.content.res.Resources;
@@ -117,6 +118,7 @@ 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.ConversationIntents;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView;
@@ -132,6 +134,7 @@ 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;
@@ -160,6 +163,7 @@ import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import org.thoughtcrime.securesms.util.views.Stub;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import org.thoughtcrime.securesms.window.WindowSizeClass;
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
import java.lang.ref.WeakReference;
@@ -403,6 +407,22 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
}));
if (WindowSizeClass.Companion.getWindowSizeClass(getResources()).isSplitPane()) {
lifecycleDisposable.add(mainNavigationViewModel.getDetailLocationObservable()
.subscribeOn(AndroidSchedulers.mainThread())
.subscribe(location -> {
if (location instanceof MainNavigationDetailLocation.Conversation) {
Intent intent = ((MainNavigationDetailLocation.Conversation) location).getIntent();
ConversationIntents.Args args = ConversationIntents.Args.from(Objects.requireNonNull(intent.getExtras()));
long threadId = args.getThreadId();
defaultAdapter.setActiveThreadId(threadId);
}
}));
} else {
defaultAdapter.setActiveThreadId(0);
}
requireCallback().bindScrollHelper(list, getViewLifecycleOwner(), chatFolderList, color -> {
for (int i = 0; i < chatFolderList.getChildCount(); i++) {
View child = chatFolderList.getChildAt(i);

View File

@@ -65,6 +65,7 @@ import org.thoughtcrime.securesms.components.emoji.EmojiStrings;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.database.MessageTypes;
import org.thoughtcrime.securesms.database.ThreadTable;
@@ -213,9 +214,10 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
@NonNull RequestManager glideRequests,
@NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull ConversationSet selectedConversations)
@NonNull ConversationSet selectedConversations,
long activeThreadId)
{
bindThread(lifecycleOwner, thread, glideRequests, locale, typingThreads, selectedConversations, null, false, true);
bindThread(lifecycleOwner, thread, glideRequests, locale, typingThreads, selectedConversations, null, false, true, activeThreadId);
}
public void bindThread(@NonNull LifecycleOwner lifecycleOwner,
@@ -226,7 +228,8 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
@NonNull ConversationSet selectedConversations,
@Nullable String highlightSubstring,
boolean appendSystemContactIcon,
boolean showPinned)
boolean showPinned,
long activeThreadId)
{
this.threadId = thread.getThreadId();
this.requestManager = requestManager;
@@ -282,6 +285,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
this.archivedView.setVisibility(View.GONE);
}
setActiveThreadId(activeThreadId);
setStatusIcons(thread);
setSelectedConversations(selectedConversations);
setBadgeFromRecipient(recipient.get());
@@ -326,6 +330,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
alertView.setNone();
setSelectedConversations(new ConversationSet());
setActiveThreadId(0);
setBadgeFromRecipient(recipient.get());
contactPhotoImage.setAvatar(requestManager, recipient.get(), !batchMode, false);
}
@@ -363,6 +368,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
alertView.setNone();
setSelectedConversations(new ConversationSet());
setActiveThreadId(0);
setBadgeFromRecipient(recipient.get());
contactPhotoImage.setAvatar(requestManager, recipient.get(), !batchMode);
}
@@ -383,6 +389,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
if (this.recipient != null) {
observeRecipient(null, null);
setSelectedConversations(new ConversationSet());
setActiveThreadId(0);
contactPhotoImage.setAvatar(requestManager, null, !batchMode);
}
@@ -391,6 +398,11 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
updateDateView = null;
}
@Override
public void setActiveThreadId(long activeThreadId) {
setActivated(activeThreadId > 0 && this.threadId == activeThreadId);
}
@Override
public void setSelectedConversations(@NonNull ConversationSet conversations) {
this.batchMode = !conversations.isEmpty();

View File

@@ -46,7 +46,8 @@ public class ConversationListItemAction extends FrameLayout implements BindableC
@NonNull RequestManager requestManager,
@NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull ConversationSet selectedConversations)
@NonNull ConversationSet selectedConversations,
long activeThreadId)
{
this.description.setText(getContext().getString(R.string.ConversationListItemAction_archived_conversations_d, thread.getUnreadCount()));
}
@@ -56,6 +57,11 @@ public class ConversationListItemAction extends FrameLayout implements BindableC
}
@Override
public void setActiveThreadId(long activeThreadId) {
}
@Override
public void setSelectedConversations(@NonNull ConversationSet conversations) {

View File

@@ -122,7 +122,8 @@ class ConversationListSearchAdapter(
ConversationSet(),
model.thread.query,
true,
false
false,
0
)
}
}

View File

@@ -30,8 +30,18 @@ import org.thoughtcrime.securesms.stories.Stories
class MainNavigationViewModel(initialListLocation: MainNavigationListLocation = MainNavigationListLocation.CHATS) : ViewModel() {
private val megaphoneRepository = AppDependencies.megaphoneRepository
private val detailLocationFlow = MutableSharedFlow<MainNavigationDetailLocation>()
val detailLocation: SharedFlow<MainNavigationDetailLocation> = detailLocationFlow
/**
* A shared flow of detail location requests that the MainActivity will service.
*/
private val detailLocationRequestFlow = MutableSharedFlow<MainNavigationDetailLocation>()
val detailLocationRequests: SharedFlow<MainNavigationDetailLocation> = detailLocationRequestFlow
/**
* The latest detail location that has been requested, for consumption by other components.
*/
private val detailLocationFlow = MutableStateFlow<MainNavigationDetailLocation>(MainNavigationDetailLocation.Empty)
val detailLocation: StateFlow<MainNavigationDetailLocation> = detailLocationFlow
val detailLocationObservable: Observable<MainNavigationDetailLocation> = detailLocationFlow.asObservable()
private val internalMegaphone = MutableStateFlow(Megaphone.NONE)
val megaphone: StateFlow<Megaphone> = internalMegaphone
@@ -73,6 +83,7 @@ class MainNavigationViewModel(initialListLocation: MainNavigationListLocation =
fun goTo(location: MainNavigationDetailLocation) {
viewModelScope.launch {
detailLocationRequestFlow.emit(location)
detailLocationFlow.emit(location)
}
}

View File

@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector
xmlns:android="http://schemas.android.com/apk/res/android"
android:enterFadeDuration="100"
android:exitFadeDuration="100">
<item android:state_selected="true">
<inset
android:insetLeft="12dp"
android:insetRight="12dp"
android:insetTop="2dp"
android:insetBottom="2dp">
<shape android:shape="rectangle">
<solid android:color="@color/conversation_list_selected_color" />
<corners android:radius="18dp" />
</shape>
</inset>
</item>
<item>
<ripple android:color="@color/conversation_list_selected_color">
<item android:id="@android:id/mask">
<inset
android:insetLeft="12dp"
android:insetRight="12dp"
android:insetTop="2dp"
android:insetBottom="2dp">
<shape android:shape="rectangle">
<solid android:color="@color/transparent_black_60" />
<corners android:radius="18dp" />
</shape>
</inset>
</item>
<item android:drawable="@drawable/conversation_list_item_background_default" />
</ripple>
</item>
</selector>

View File

@@ -1,7 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/signal_colorSurface2" android:state_selected="true" />
<item android:drawable="@color/signal_colorSurface2" android:state_pressed="true" />
<item android:drawable="@color/signal_colorSurface3" android:state_focused="true" />
<item android:drawable="@drawable/conversation_list_item_background_default" />
<!--
~ Copyright 2025 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<selector
xmlns:android="http://schemas.android.com/apk/res/android"
android:enterFadeDuration="100"
android:exitFadeDuration="100">
<item android:state_activated="true">
<inset
android:insetLeft="12dp"
android:insetRight="12dp"
android:insetTop="0dp"
android:insetBottom="0dp">
<shape android:shape="rectangle">
<solid android:color="@color/signal_colorSecondaryContainer" />
<corners android:radius="18dp" />
</shape>
</inset>
</item>
<item android:state_selected="true">
<inset
android:insetLeft="12dp"
android:insetRight="12dp"
android:insetTop="2dp"
android:insetBottom="2dp">
<shape android:shape="rectangle">
<solid android:color="@color/conversation_list_selected_color" />
<corners android:radius="18dp" />
</shape>
</inset>
</item>
<item>
<ripple android:color="@color/conversation_list_selected_color">
<item android:id="@android:id/mask">
<inset
android:insetLeft="12dp"
android:insetRight="12dp"
android:insetTop="2dp"
android:insetBottom="2dp">
<shape android:shape="rectangle">
<solid android:color="@color/transparent_black_60" />
<corners android:radius="18dp" />
</shape>
</inset>
</item>
<item android:drawable="@drawable/conversation_list_item_background_default" />
</ripple>
</item>
</selector>