Migrate ContactSearch RV to MappingLazyColumn.

This commit is contained in:
Alex Hart
2026-05-20 16:45:52 -03:00
committed by jeffrey-signal
parent 1d74b00b91
commit 698fc38aed
22 changed files with 2259 additions and 1518 deletions
@@ -34,7 +34,6 @@ import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ServiceUtil;
import java.io.IOException;
import java.lang.ref.WeakReference;
@@ -49,8 +48,7 @@ import java.util.function.Consumer;
*/
public abstract class ContactSelectionActivity extends PassphraseRequiredActivity
implements SwipeRefreshLayout.OnRefreshListener,
ContactSelectionListFragment.OnContactSelectedListener,
ContactSelectionListFragment.ScrollCallback
ContactSelectionListFragment.OnContactSelectedListener
{
private static final String TAG = Log.tag(ContactSelectionActivity.class);
@@ -136,17 +134,6 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
@Override
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {}
@Override
public void onBeginScroll() {
hideKeyboard();
}
private void hideKeyboard() {
ServiceUtil.getInputMethodManager(this)
.hideSoftInputFromWindow(toolbar.getWindowToken(), 0);
toolbar.clearFocus();
}
private static class RefreshDirectoryTask extends AsyncTask<Context, Void, Void> {
private final WeakReference<ContactSelectionActivity> activity;
@@ -1,16 +1,19 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.View
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.ContactSelectionListModels.FindByPhoneNumberModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindByUsernameModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindContactsBannerModel
import org.thoughtcrime.securesms.ContactSelectionListModels.FindContactsModel
import org.thoughtcrime.securesms.ContactSelectionListModels.InviteToSignalModel
import org.thoughtcrime.securesms.ContactSelectionListModels.MoreHeaderModel
import org.thoughtcrime.securesms.ContactSelectionListModels.NewGroupModel
import org.thoughtcrime.securesms.ContactSelectionListModels.RefreshContactsModel
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
class ContactSelectionListAdapter(
context: Context,
@@ -23,152 +26,19 @@ class ContactSelectionListAdapter(
) : ContactSearchAdapter(context, fixedContacts, displayOptions, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) {
init {
registerFactory(NewGroupModel::class.java, LayoutFactory({ NewGroupViewHolder(it, onClickCallbacks::onNewGroupClicked) }, R.layout.contact_selection_new_group_item))
registerFactory(InviteToSignalModel::class.java, LayoutFactory({ InviteToSignalViewHolder(it, onClickCallbacks::onInviteToSignalClicked) }, R.layout.contact_selection_invite_action_item))
registerFactory(FindContactsModel::class.java, LayoutFactory({ FindContactsViewHolder(it, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_item))
registerFactory(FindContactsBannerModel::class.java, LayoutFactory({ FindContactsBannerViewHolder(it, onClickCallbacks::onDismissFindContactsBannerClicked, onClickCallbacks::onFindContactsClicked) }, R.layout.contact_selection_find_contacts_banner_item))
registerFactory(RefreshContactsModel::class.java, LayoutFactory({ RefreshContactsViewHolder(it, onClickCallbacks::onRefreshContactsClicked) }, R.layout.contact_selection_refresh_action_item))
registerFactory(MoreHeaderModel::class.java, LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header))
registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state))
registerFactory(FindByUsernameModel::class.java, LayoutFactory({ FindByUsernameViewHolder(it, onClickCallbacks::onFindByUsernameClicked) }, R.layout.contact_selection_find_by_username_item))
registerFactory(FindByPhoneNumberModel::class.java, LayoutFactory({ FindByPhoneNumberViewHolder(it, onClickCallbacks::onFindByPhoneNumberClicked) }, R.layout.contact_selection_find_by_phone_number_item))
}
class NewGroupModel : MappingModel<NewGroupModel> {
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
}
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
}
class RefreshContactsModel : MappingModel<RefreshContactsModel> {
override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
}
class FindContactsModel : MappingModel<FindContactsModel> {
override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true
}
class FindContactsBannerModel : MappingModel<FindContactsBannerModel> {
override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true
}
class FindByUsernameModel : MappingModel<FindByUsernameModel> {
override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true
override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true
}
class FindByPhoneNumberModel : MappingModel<FindByPhoneNumberModel> {
override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
}
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true
}
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: InviteToSignalModel) = Unit
}
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: NewGroupModel) = Unit
}
private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<RefreshContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: RefreshContactsModel) = Unit
}
private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsModel) = Unit
}
private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder<FindContactsBannerModel>(itemView) {
init {
itemView.findViewById<MaterialButton>(R.id.no_thanks_button).setOnClickListener { onDismissListener() }
itemView.findViewById<MaterialButton>(R.id.allow_contacts_button).setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsBannerModel) = Unit
}
private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder<MoreHeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: MoreHeaderModel) {
headerTextView.setText(R.string.contact_selection_activity__more)
}
}
private class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
private val emptyText: TextView = itemView.findViewById(R.id.search_no_results)
override fun bind(model: EmptyModel) {
emptyText.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByPhoneNumberModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByPhoneNumberModel) = Unit
}
private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByUsernameModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByUsernameModel) = Unit
ContactSelectionListModels.registerNewGroup(this, onClickCallbacks::onNewGroupClicked)
ContactSelectionListModels.registerInviteToSignal(this, onClickCallbacks::onInviteToSignalClicked)
ContactSelectionListModels.registerFindContacts(this, onClickCallbacks::onFindContactsClicked)
ContactSelectionListModels.registerFindContactsBanner(this, onClickCallbacks::onDismissFindContactsBannerClicked, onClickCallbacks::onFindContactsClicked)
ContactSelectionListModels.registerRefreshContacts(this, onClickCallbacks::onRefreshContactsClicked)
ContactSelectionListModels.registerMoreHeader(this)
ContactSelectionListModels.registerEmpty(this)
ContactSelectionListModels.registerFindByUsername(this, onClickCallbacks::onFindByUsernameClicked)
ContactSelectionListModels.registerFindByPhoneNumber(this, onClickCallbacks::onFindByPhoneNumberClicked)
}
class ArbitraryRepository : org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository {
enum class ArbitraryRow(val code: String) {
NEW_GROUP("new-group"),
INVITE_TO_SIGNAL("invite-to-signal"),
MORE_HEADING("more-heading"),
REFRESH_CONTACTS("refresh-contacts"),
FIND_CONTACTS("find-contacts"),
FIND_CONTACTS_BANNER("find-contacts-banner"),
FIND_BY_USERNAME("find-by-username"),
FIND_BY_PHONE_NUMBER("find-by-phone-number");
companion object {
fun fromCode(code: String) = entries.first { it.code == code }
}
}
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int {
return section.types.size
}
@@ -179,15 +49,15 @@ class ContactSelectionListAdapter(
}
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
return when (ArbitraryRow.fromCode(arbitrary.type)) {
ArbitraryRow.NEW_GROUP -> NewGroupModel()
ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
ArbitraryRow.FIND_CONTACTS -> FindContactsModel()
ArbitraryRow.FIND_CONTACTS_BANNER -> FindContactsBannerModel()
ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel()
ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel()
return when (ContactSelectionListModels.ArbitraryRow.fromCode(arbitrary.type)) {
ContactSelectionListModels.ArbitraryRow.NEW_GROUP -> NewGroupModel()
ContactSelectionListModels.ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
ContactSelectionListModels.ArbitraryRow.MORE_HEADING -> MoreHeaderModel()
ContactSelectionListModels.ArbitraryRow.REFRESH_CONTACTS -> RefreshContactsModel()
ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS -> FindContactsModel()
ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS_BANNER -> FindContactsBannerModel()
ContactSelectionListModels.ArbitraryRow.FIND_BY_PHONE_NUMBER -> FindByPhoneNumberModel()
ContactSelectionListModels.ArbitraryRow.FIND_BY_USERNAME -> FindByUsernameModel()
}
}
}
@@ -18,10 +18,8 @@ package org.thoughtcrime.securesms;
import android.Manifest;
import org.signal.core.ui.logging.LoggingFragment;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Rect;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.TextUtils;
@@ -38,27 +36,24 @@ import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.transition.AutoTransition;
import androidx.transition.TransitionManager;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.jetbrains.annotations.NotNull;
import org.signal.core.ui.logging.LoggingFragment;
import org.signal.core.ui.permissions.Permissions;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.HeaderAction;
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.SelectedContacts;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
@@ -71,18 +66,19 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRep
import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.signal.core.ui.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.search.SearchRepository;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
@@ -92,14 +88,13 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.stream.Collectors;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.disposables.Disposable;
import kotlin.Unit;
@@ -126,22 +121,17 @@ public final class ContactSelectionListFragment extends LoggingFragment {
private SwipeRefreshLayout swipeRefresh;
private String cursorFilter;
private ContactSearchView contactSearchView;
private RecyclerViewFastScroller fastScroller;
private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private MappingAdapter contactChipAdapter;
private ContactChipViewModel contactChipViewModel;
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
private ContactSearchViewModel contactSearchViewModel;
@Nullable private RecyclerView innerRecyclerView;
@Nullable private LinearLayoutManager innerLayoutManager;
@Nullable private NewConversationCallback newConversationCallback;
@Nullable private FindByCallback findByCallback;
@Nullable private NewCallCallback newCallCallback;
@Nullable private ScrollCallback scrollCallback;
@Nullable private OnItemLongClickListener onItemLongClickListener;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
@@ -168,14 +158,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
setNewCallCallback((NewCallCallback) context);
}
if (getParentFragment() instanceof ScrollCallback) {
setScrollCallback((ScrollCallback) getParentFragment());
}
if (context instanceof ScrollCallback) {
setScrollCallback((ScrollCallback) context);
}
if (getParentFragment() instanceof OnContactSelectedListener) {
setOnContactSelectedListener((OnContactSelectedListener) getParentFragment());
}
@@ -221,10 +203,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
this.newCallCallback = callback;
}
public void setScrollCallback(@Nullable ScrollCallback callback) {
this.scrollCallback = callback;
}
public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) {
this.onContactSelectedListener = listener;
}
@@ -259,10 +237,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
emptyText = view.findViewById(android.R.id.empty);
contactSearchView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
fastScroller = view.findViewById(R.id.fast_scroller);
chipRecycler = view.findViewById(R.id.chipRecycler);
constraintLayout = view.findViewById(R.id.container);
headerActionView = view.findViewById(R.id.header_action);
contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class);
contactChipAdapter = new MappingAdapter();
@@ -309,133 +285,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
)
).get(ContactSearchViewModel.class);
List<RecyclerView.OnScrollListener> scrollListeners = new ArrayList<>();
final HeaderAction headerAction;
if (headerActionProvider != null) {
headerAction = headerActionProvider.getHeaderAction();
headerActionView.setEnabled(true);
headerActionView.setText(headerAction.getLabel());
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0);
headerActionView.setOnClickListener(v -> headerAction.getAction().run());
scrollListeners.add(new RecyclerView.OnScrollListener() {
private final Rect bounds = new Rect();
@Override
public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
if (hideLetterHeaders() || innerLayoutManager == null) {
return;
}
int firstPosition = innerLayoutManager.findFirstVisibleItemPosition();
if (firstPosition == 0) {
View firstChild = rv.getChildAt(0);
rv.getDecoratedBoundsWithMargins(firstChild, bounds);
headerActionView.setTranslationY(bounds.top);
}
}
});
} else {
headerActionView.setEnabled(false);
}
scrollListeners.add(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView rv, int newState) {
if (newState == RecyclerView.SCROLL_STATE_DRAGGING && scrollCallback != null) {
scrollCallback.onBeginScroll();
}
}
});
float contentBottomPaddingDp = fragmentArgs.getRecyclerPadBottom() != -1
? fragmentArgs.getRecyclerPadBottom() / getResources().getDisplayMetrics().density
: 0f;
ContactSearchAdapter.AdapterFactory adapterFactory =
(context, fc, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) ->
new ContactSelectionListAdapter(
context,
fc,
displayOptions,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchViewModel.refresh();
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
callbacks.onExpandClicked(expand);
}
@Override
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
@Override
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks,
new CallButtonClickCallbacks()
);
contactSearchView.bind(
contactSearchViewModel,
getChildFragmentManager(),
@@ -452,25 +301,83 @@ public final class ContactSelectionListFragment extends LoggingFragment {
onLoadFinished(size);
}
},
Collections.singletonList(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders)),
contentBottomPaddingDp,
adapterFactory,
scrollListeners,
rv -> {
innerRecyclerView = rv;
innerLayoutManager = (LinearLayoutManager) rv.getLayoutManager();
rv.setItemAnimator(new DefaultItemAnimator() {
@Override
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
return true;
}
ContactSelectionListModels.composeEntries(
new ContactSelectionListModels.Callback() {
@Override
public void onNewGroupClicked() {
newConversationCallback.onNewGroup(false);
}
@Override
public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) {
contactSearchView.setAlpha(1f);
@Override
public void onInviteToSignalClicked() {
if (newConversationCallback != null) {
newConversationCallback.onInvite();
}
if (newCallCallback != null) {
newCallCallback.onInvite();
}
}
@Override
public void onFindContactsClicked() {
requestContactPermissions();
}
@Override
public void onDismissFindContactsBannerClicked() {
SignalStore.uiHints().markDismissedContactsPermissionBanner();
contactSearchViewModel.refresh();
}
@Override
public void onRefreshContactsClicked() {
if (onRefreshListener != null && !isRefreshing()) {
setRefreshing(true);
onRefreshListener.onRefresh();
}
}
@Override
public void onFindByUsernameClicked() {
findByCallback.onFindByUsername();
}
@Override
public void onFindByPhoneNumberClicked() {
findByCallback.onFindByPhoneNumber();
}
}
});
}
),
new ContactSearchAdapter.ClickCallbacks() {
@Override
public void onStoryClicked(@NotNull View view, ContactSearchData.@NotNull Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NotNull View view, ContactSearchData.@NotNull KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(ContactSearchData.@NotNull Expand expand) {
contactSearchViewModel.expandSection(expand.getSectionKey());
}
@Override
public void onChatTypeClicked(@NotNull View view, ContactSearchData.@NotNull ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
@Override
public void onUnknownRecipientClicked(@NotNull View view, ContactSearchData.@NotNull UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
null,
new CallButtonClickCallbacks()
);
return view;
@@ -595,32 +502,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public void reset() {
contactSearchViewModel.clearSelection();
contactSearchViewModel.refresh();
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
contactSearchViewModel.setFastScrollEnabled(false);
}
private void onLoadFinished(int count) {
if (resetPositionOnCommit && innerRecyclerView != null) {
if (resetPositionOnCommit) {
resetPositionOnCommit = false;
innerRecyclerView.scrollToPosition(0);
contactSearchViewModel.requestScrollPosition(0);
}
swipeRefresh.setVisibility(View.VISIBLE);
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
boolean useFastScroller = count > 20;
if (useFastScroller && innerRecyclerView != null) {
fastScroller.setVisibility(View.VISIBLE);
fastScroller.setRecyclerView(innerRecyclerView);
if (useFastScroller) {
contactSearchViewModel.setFastScrollEnabled(true);
} else {
fastScroller.setRecyclerView(null);
fastScroller.setVisibility(View.GONE);
}
if (headerActionView.isEnabled() && !hasQueryFilter()) {
headerActionView.setVisibility(View.VISIBLE);
} else {
headerActionView.setVisibility(View.GONE);
contactSearchViewModel.setFastScrollEnabled(false);
}
}
@@ -790,8 +688,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
if (onItemLongClickListener != null && innerRecyclerView != null) {
return onItemLongClickListener.onLongClick(anchorView, item, innerRecyclerView);
if (onItemLongClickListener != null) {
return onItemLongClickListener.onLongClick(anchorView, item, isDisplayingContextMenu -> contactSearchViewModel.setDisplayingContextMenu(isDisplayingContextMenu));
} else {
return false;
}
@@ -933,19 +831,19 @@ public final class ContactSelectionListFragment extends LoggingFragment {
!SignalStore.uiHints().getDismissedContactsPermissionBanner() &&
!hasQuery)
{
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
}
if (fragmentArgs.getEnableCreateNewGroup() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.NEW_GROUP.getCode());
}
if (fragmentArgs.getEnableFindByUsername() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_USERNAME.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_BY_USERNAME.getCode());
}
if (fragmentArgs.getEnableFindByPhoneNumber() && !hasQuery) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
}
if (includeChatTypes && !hasQuery) {
@@ -967,10 +865,12 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
boolean hideHeader = newCallCallback != null || (newConversationCallback != null && !hasQuery);
HeaderAction sectionHeaderAction = (headerActionProvider != null && !hasQuery) ? headerActionProvider.getHeaderAction() : null;
builder.addSection(new ContactSearchConfiguration.Section.Individuals(
includeSelf ? new RecipientTable.IncludeSelfMode.IncludeWithRemap(getString(R.string.note_to_self)) : RecipientTable.IncludeSelfMode.Exclude.INSTANCE,
transportType,
!hideHeader,
sectionHeaderAction,
null,
!hideLetterHeaders(),
newConversationCallback != null ? ContactSearchSortOrder.RECENCY : ContactSearchSortOrder.NATURAL
@@ -1017,13 +917,13 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
private void addMoreSection(@NonNull ContactSearchConfiguration.Builder builder) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.MORE_HEADING.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.MORE_HEADING.getCode());
if (hasContactsPermissions(requireContext())) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.REFRESH_CONTACTS.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.REFRESH_CONTACTS.getCode());
} else if (SignalStore.uiHints().getDismissedContactsPermissionBanner()) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.FIND_CONTACTS.getCode());
}
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
builder.arbitrary(ContactSelectionListModels.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
}
private static @Nullable ContactSearchConfiguration.TransportType resolveTransportType(boolean includePushContacts, boolean includeSmsContacts) {
@@ -1113,15 +1013,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
void onInvite();
}
public interface ScrollCallback {
void onBeginScroll();
}
public interface HeaderActionProvider {
@NonNull HeaderAction getHeaderAction();
}
public interface OnItemLongClickListener {
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView);
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, Consumer<Boolean> setIsDisplayingContextMenu);
}
}
@@ -0,0 +1,299 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels.EmptyModel
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProviderBuilder
/**
* Holds the [MappingModel]s and [MappingViewHolder]s used by [ContactSelectionListAdapter] on top of
* the base set in [org.thoughtcrime.securesms.contacts.paged.ContactSearchModels], along with helpers
* for registering them on a [MappingAdapter] (RecyclerView) or building a [MappingEntryProvider]
* (Compose).
*/
object ContactSelectionListModels {
fun registerNewGroup(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
NewGroupModel::class.java,
LayoutFactory({ NewGroupViewHolder(it, onClick) }, R.layout.contact_selection_new_group_item)
)
}
fun registerInviteToSignal(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
InviteToSignalModel::class.java,
LayoutFactory({ InviteToSignalViewHolder(it, onClick) }, R.layout.contact_selection_invite_action_item)
)
}
fun registerFindContacts(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindContactsModel::class.java,
LayoutFactory({ FindContactsViewHolder(it, onClick) }, R.layout.contact_selection_find_contacts_item)
)
}
fun registerFindContactsBanner(mappingAdapter: MappingAdapter, onDismiss: () -> Unit, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindContactsBannerModel::class.java,
LayoutFactory({ FindContactsBannerViewHolder(it, onDismiss, onClick) }, R.layout.contact_selection_find_contacts_banner_item)
)
}
fun registerRefreshContacts(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
RefreshContactsModel::class.java,
LayoutFactory({ RefreshContactsViewHolder(it, onClick) }, R.layout.contact_selection_refresh_action_item)
)
}
fun registerMoreHeader(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
MoreHeaderModel::class.java,
LayoutFactory({ MoreHeaderViewHolder(it) }, R.layout.contact_search_section_header)
)
}
fun registerEmpty(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
EmptyModel::class.java,
LayoutFactory({ EmptyViewHolder(it) }, R.layout.contact_selection_empty_state)
)
}
fun registerFindByUsername(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindByUsernameModel::class.java,
LayoutFactory({ FindByUsernameViewHolder(it, onClick) }, R.layout.contact_selection_find_by_username_item)
)
}
fun registerFindByPhoneNumber(mappingAdapter: MappingAdapter, onClick: () -> Unit) {
mappingAdapter.registerFactory(
FindByPhoneNumberModel::class.java,
LayoutFactory({ FindByPhoneNumberViewHolder(it, onClick) }, R.layout.contact_selection_find_by_phone_number_item)
)
}
/**
* Returns a [MappingEntryProvider] containing the same set of view holders registered by the
* adapter-side `register*` methods, suitable for use with a Compose `MappingLazyColumn`.
*/
@JvmStatic
fun composeEntries(
callback: Callback
): MappingEntryProvider<Any> {
return MappingEntryProviderBuilder<Any>().apply {
viewHolder<NewGroupModel> { context ->
LayoutFactory(
{ view -> NewGroupViewHolder(view, callback::onNewGroupClicked) },
R.layout.contact_selection_new_group_item
).createViewHolder(FrameLayout(context))
}
viewHolder<InviteToSignalModel> { context ->
LayoutFactory(
{ view -> InviteToSignalViewHolder(view, callback::onInviteToSignalClicked) },
R.layout.contact_selection_invite_action_item
).createViewHolder(FrameLayout(context))
}
viewHolder<FindContactsModel> { context ->
LayoutFactory(
{ view -> FindContactsViewHolder(view, callback::onFindContactsClicked) },
R.layout.contact_selection_find_contacts_item
).createViewHolder(FrameLayout(context))
}
viewHolder<FindContactsBannerModel> { context ->
LayoutFactory(
{ view -> FindContactsBannerViewHolder(view, callback::onDismissFindContactsBannerClicked, callback::onFindContactsClicked) },
R.layout.contact_selection_find_contacts_banner_item
).createViewHolder(FrameLayout(context))
}
viewHolder<RefreshContactsModel> { context ->
LayoutFactory(
{ view -> RefreshContactsViewHolder(view, callback::onRefreshContactsClicked) },
R.layout.contact_selection_refresh_action_item
).createViewHolder(FrameLayout(context))
}
viewHolder<MoreHeaderModel> { context ->
LayoutFactory(
{ view -> MoreHeaderViewHolder(view) },
R.layout.contact_search_section_header
).createViewHolder(FrameLayout(context))
}
viewHolder<EmptyModel> { context ->
LayoutFactory(
{ view -> EmptyViewHolder(view) },
R.layout.contact_selection_empty_state
).createViewHolder(FrameLayout(context))
}
viewHolder<FindByUsernameModel> { context ->
LayoutFactory(
{ view -> FindByUsernameViewHolder(view, callback::onFindByUsernameClicked) },
R.layout.contact_selection_find_by_username_item
).createViewHolder(FrameLayout(context))
}
viewHolder<FindByPhoneNumberModel> { context ->
LayoutFactory(
{ view -> FindByPhoneNumberViewHolder(view, callback::onFindByPhoneNumberClicked) },
R.layout.contact_selection_find_by_phone_number_item
).createViewHolder(FrameLayout(context))
}
}.build()
}
interface Callback {
fun onNewGroupClicked()
fun onInviteToSignalClicked()
fun onFindContactsClicked()
fun onDismissFindContactsBannerClicked()
fun onRefreshContactsClicked()
fun onFindByUsernameClicked()
fun onFindByPhoneNumberClicked()
}
enum class ArbitraryRow(val code: String) {
NEW_GROUP("new-group"),
INVITE_TO_SIGNAL("invite-to-signal"),
MORE_HEADING("more-heading"),
REFRESH_CONTACTS("refresh-contacts"),
FIND_CONTACTS("find-contacts"),
FIND_CONTACTS_BANNER("find-contacts-banner"),
FIND_BY_USERNAME("find-by-username"),
FIND_BY_PHONE_NUMBER("find-by-phone-number");
companion object {
fun fromCode(code: String) = entries.first { it.code == code }
}
}
class NewGroupModel : MappingModel<NewGroupModel> {
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
}
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
}
class RefreshContactsModel : MappingModel<RefreshContactsModel> {
override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true
override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true
}
class FindContactsModel : MappingModel<FindContactsModel> {
override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true
}
class FindContactsBannerModel : MappingModel<FindContactsBannerModel> {
override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true
override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true
}
class FindByUsernameModel : MappingModel<FindByUsernameModel> {
override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true
override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true
}
class FindByPhoneNumberModel : MappingModel<FindByPhoneNumberModel> {
override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true
}
class MoreHeaderModel : MappingModel<MoreHeaderModel> {
override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true
override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true
}
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: InviteToSignalModel) = Unit
}
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: NewGroupModel) = Unit
}
private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<RefreshContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: RefreshContactsModel) = Unit
}
private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindContactsModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsModel) = Unit
}
private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder<FindContactsBannerModel>(itemView) {
init {
itemView.findViewById<MaterialButton>(R.id.no_thanks_button).setOnClickListener { onDismissListener() }
itemView.findViewById<MaterialButton>(R.id.allow_contacts_button).setOnClickListener { onClickListener() }
}
override fun bind(model: FindContactsBannerModel) = Unit
}
private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder<MoreHeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: MoreHeaderModel) {
headerTextView.setText(R.string.contact_selection_activity__more)
}
}
private class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
private val emptyText: TextView = itemView.findViewById(R.id.search_no_results)
override fun bind(model: EmptyModel) {
emptyText.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class FindByPhoneNumberViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByPhoneNumberModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByPhoneNumberModel) = Unit
}
private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<FindByUsernameModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: FindByUsernameModel) = Unit
}
}
@@ -9,36 +9,39 @@ import android.content.Context
import android.view.View
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.collections.immutable.persistentHashMapOf
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.FastScrollerState
import org.signal.core.ui.compose.LazyColumnFastScroller
import org.signal.core.ui.compose.LocalFragmentManager
import org.signal.core.ui.compose.Previews
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchView.RecyclerViewReadyCallback
import org.thoughtcrime.securesms.components.emoji.Emojifier
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.stories.settings.custom.PrivateStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.my.MyStorySettingsFragment
import org.thoughtcrime.securesms.stories.settings.privacy.ChooseInitialMyStoryMembershipBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingLazyColumn
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingLazyListController
import org.thoughtcrime.securesms.util.adapter.mapping.compose.rememberMappingEntryProvider
import org.signal.core.ui.R as CoreUiR
/**
@@ -50,26 +53,16 @@ import org.signal.core.ui.R as CoreUiR
* 2. Via [ContactSearchView] in XML/View-based layouts — [ContactSearchView] creates the ViewModel
* and delegates its `Content()` to this function.
*
* The [PagingMappingAdapter] is created internally via `remember` and re-created if
* [displayOptions] or [adapterFactory] change.
*
* @param viewModel Drives the list — managed by the caller.
* @param mapStateToConfiguration Maps the current [ContactSearchState] to the active
* [ContactSearchConfiguration], re-evaluated whenever state changes.
* @param modifier Modifier applied to the composable root.
* @param displayOptions Controls checkbox and secondary-info visibility.
* @param callbacks Hooks for filtering and reacting to selection changes.
* @param storyFragmentManager [FragmentManager] used to show story-related dialogs.
* Pass `null` to disable story context menus and dialogs.
* @param onListCommitted Called after each list commit with the committed item count.
* @param itemDecorations [RecyclerView.ItemDecoration]s added to the internal list.
* @param contentBottomPadding Extra bottom padding so last items scroll above overlaid UI.
* Automatically disables `clipToPadding` when non-zero.
* @param adapterFactory Factory for the adapter — swap for custom adapters (e.g.
* [ContactSelectionListAdapter]).
* @param scrollListeners [RecyclerView.OnScrollListener]s attached to the inner list.
* @param onRecyclerViewReady Called once with the inner [RecyclerView] after first composition.
* Useful for attaching fast-scrollers or custom item animators.
* @param additionalEntries Extra [MappingEntryProvider] entries layered on top of the base
* set from [ContactSearchModels.composeEntries]. The base set is
* always applied; on key collisions the base entry wins.
*/
@Composable
fun ContactSearch(
@@ -77,99 +70,151 @@ fun ContactSearch(
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
modifier: Modifier = Modifier,
displayOptions: ContactSearchAdapter.DisplayOptions = ContactSearchAdapter.DisplayOptions(),
callbacks: ContactSearchCallbacks = remember { ContactSearchCallbacks.Simple() },
storyFragmentManager: FragmentManager? = null,
onListCommitted: (Int) -> Unit = {},
itemDecorations: List<RecyclerView.ItemDecoration> = emptyList(),
contentBottomPadding: Dp = 0.dp,
adapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory,
scrollListeners: List<RecyclerView.OnScrollListener> = emptyList(),
onRecyclerViewReady: RecyclerViewReadyCallback? = null
additionalEntries: MappingEntryProvider<Any> = persistentHashMapOf(),
lazyListState: LazyListState = rememberLazyListState(),
callbacks: ContactSearchCallbacks = remember { ContactSearchCallbacks.Simple() },
clickCallbacks: ContactSearchAdapter.ClickCallbacks = rememberDefaultContactSearchItemClickCallbacks(viewModel, callbacks),
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks = rememberDefaultContactSearchItemLongClickCallbacks(),
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks = rememberDefaultContactSearchItemStoryContextMenuCallbacks(viewModel),
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks = rememberDefaultContactSearchItemCallButtonClickCallbacks()
) {
val mappingModels by viewModel.mappingModels.collectAsStateWithLifecycle()
val controller by viewModel.controller.collectAsStateWithLifecycle()
val configState by viewModel.configurationState.collectAsStateWithLifecycle()
val totalCount by viewModel.totalCount.collectAsStateWithLifecycle()
val fastScrollerEnabled by viewModel.fastScrollerEnabled.collectAsStateWithLifecycle()
val isDisplayingContextMenu by viewModel.isDisplayingContextMenu.collectAsStateWithLifecycle()
val currentMapStateToConfiguration by rememberUpdatedState(mapStateToConfiguration)
val currentOnListCommitted by rememberUpdatedState(onListCommitted)
// Held as State references (not delegated) so click-callback lambdas captured inside
// remember() always read the latest value without recreating the adapter.
val currentCallbacks = rememberUpdatedState(callbacks)
val currentStoryFragmentManager = rememberUpdatedState(storyFragmentManager)
val context = LocalContext.current
val contextState = rememberUpdatedState(context)
val adapter = remember(viewModel.fixedContacts, displayOptions, adapterFactory) {
adapterFactory.create(
context = context,
fixedContacts = viewModel.fixedContacts,
displayOptions = displayOptions,
callbacks = DefaultClickCallbacks(viewModel, currentCallbacks, currentStoryFragmentManager),
longClickCallbacks = ContactSearchAdapter.LongClickCallbacksAdapter(),
storyContextMenuCallbacks = DefaultStoryContextMenuCallbacks(viewModel, currentStoryFragmentManager, contextState),
callButtonClickCallbacks = ContactSearchAdapter.EmptyCallButtonClickCallbacks
)
}
LaunchedEffect(mappingModels) {
adapter.submitList(mappingModels) {
currentOnListCommitted(mappingModels.size)
}
}
LaunchedEffect(controller) {
controller?.let { adapter.setPagingController(it) }
}
LaunchedEffect(configState) {
viewModel.setConfiguration(currentMapStateToConfiguration(configState))
}
val recyclerView = remember(context) {
RecyclerView(context).apply {
layoutManager = LinearLayoutManager(context)
LaunchedEffect(lazyListState) {
viewModel.scrollRequests.collect {
lazyListState.requestScrollToItem(0)
}
}
DisposableEffect(recyclerView, itemDecorations) {
itemDecorations.forEach { recyclerView.addItemDecoration(it) }
onDispose {
itemDecorations.forEach { recyclerView.removeItemDecoration(it) }
}
}
DisposableEffect(recyclerView, scrollListeners) {
scrollListeners.forEach { recyclerView.addOnScrollListener(it) }
onDispose {
scrollListeners.forEach { recyclerView.removeOnScrollListener(it) }
}
}
val bottomPaddingPx = with(LocalDensity.current) { contentBottomPadding.roundToPx() }
LaunchedEffect(recyclerView) {
onRecyclerViewReady?.onRecyclerViewReady(recyclerView)
}
AndroidView(
factory = { recyclerView },
update = { rv ->
if (rv.adapter !== adapter) {
rv.adapter = adapter
}
rv.setPadding(0, 0, 0, bottomPaddingPx)
rv.clipToPadding = bottomPaddingPx == 0
rv.clipChildren = bottomPaddingPx == 0
},
modifier = modifier.fillMaxSize()
val baseProvider = rememberContactSearchMappingEntryProvider(
fixedContacts = viewModel.fixedContacts,
displayOptions = displayOptions,
callbacks = clickCallbacks,
longClickCallbacks = longClickCallbacks,
storyContextMenuCallbacks = storyContextMenuCallbacks,
callButtonClickCallbacks = callButtonClickCallbacks
)
val provider = remember(baseProvider, additionalEntries) {
additionalEntries.putAll(baseProvider)
}
val mappingCtrl = remember {
MappingLazyListController(
entryProvider = provider
)
}
LaunchedEffect(controller) {
controller?.run {
mappingCtrl.pagingController = this
}
}
LaunchedEffect(mappingModels) {
mappingCtrl.items = mappingModels
currentOnListCommitted(mappingModels.size)
}
val fastScrollerState = remember(mappingModels, totalCount) {
FastScrollerState(items = mappingModels, totalCount = totalCount)
}
LazyColumnFastScroller(
enabled = fastScrollerEnabled,
userScrollEnabled = !isDisplayingContextMenu,
fastScrollerState = fastScrollerState,
lazyListState = lazyListState,
modifier = modifier.fillMaxSize(),
letterContent = {
Emojifier(text = it.toString()) { annotatedText, inlineContent ->
Text(
text = annotatedText,
inlineContent = inlineContent,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
) {
MappingLazyColumn(
userScrollEnabled = !isDisplayingContextMenu,
controller = mappingCtrl,
lazyListState = it,
modifier = Modifier.fillMaxSize()
)
}
}
@Composable
private fun rememberContactSearchMappingEntryProvider(
fixedContacts: Set<ContactSearchKey>,
displayOptions: ContactSearchAdapter.DisplayOptions,
callbacks: ContactSearchAdapter.ClickCallbacks,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks?,
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
): MappingEntryProvider<Any> {
return rememberMappingEntryProvider {
// Subclass-registered models (Message, Thread, Empty, GroupWithMembers) and
// ArbitraryRepository-backed models are handled separately.
provider<Any>(
ContactSearchModels.composeEntries(
fixedContacts = fixedContacts,
displayOptions = displayOptions,
callbacks = callbacks,
longClickCallbacks = longClickCallbacks,
storyContextMenuCallbacks = storyContextMenuCallbacks,
callButtonClickCallbacks = callButtonClickCallbacks
)
)
}
}
@Composable
fun rememberDefaultContactSearchItemClickCallbacks(viewModel: ContactSearchViewModel, callbacks: ContactSearchCallbacks): ContactSearchAdapter.ClickCallbacks {
val fragmentManager = LocalFragmentManager.current
return remember(callbacks) {
DefaultClickCallbacks(viewModel, callbacks, fragmentManager)
}
}
@Composable
fun rememberDefaultContactSearchItemLongClickCallbacks(): ContactSearchAdapter.LongClickCallbacks {
return remember { ContactSearchAdapter.LongClickCallbacksAdapter() }
}
@Composable
fun rememberDefaultContactSearchItemStoryContextMenuCallbacks(viewModel: ContactSearchViewModel): ContactSearchAdapter.StoryContextMenuCallbacks {
val context = LocalContext.current
val fragmentManager = LocalFragmentManager.current
return remember { DefaultStoryContextMenuCallbacks(viewModel, fragmentManager, context) }
}
@Composable
fun rememberDefaultContactSearchItemCallButtonClickCallbacks(): ContactSearchAdapter.CallButtonClickCallbacks {
return remember { ContactSearchAdapter.EmptyCallButtonClickCallbacks }
}
private class DefaultClickCallbacks(
private val viewModel: ContactSearchViewModel,
private val callbacks: State<ContactSearchCallbacks>,
private val fragmentManager: State<FragmentManager?>
private val callbacks: ContactSearchCallbacks,
private val fragmentManager: FragmentManager?
) : ContactSearchAdapter.ClickCallbacks {
companion object {
@@ -179,7 +224,7 @@ private class DefaultClickCallbacks(
override fun onStoryClicked(view: View, story: ContactSearchData.Story, isSelected: Boolean) {
Log.d(TAG, "onStoryClicked()")
if (story.recipient.isMyStory && !SignalStore.story.userHasBeenNotifiedAboutStories) {
fragmentManager.value?.let { ChooseInitialMyStoryMembershipBottomSheetDialogFragment.show(it) }
fragmentManager?.let { ChooseInitialMyStoryMembershipBottomSheetDialogFragment.show(it) }
} else {
toggle(view, story, isSelected)
}
@@ -200,30 +245,30 @@ private class DefaultClickCallbacks(
if (isSelected) {
viewModel.setKeysNotSelected(setOf(chatTypeRow.contactSearchKey))
} else {
viewModel.setKeysSelected(callbacks.value.onBeforeContactsSelected(view, setOf(chatTypeRow.contactSearchKey)))
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(view, setOf(chatTypeRow.contactSearchKey)))
}
}
private fun toggle(view: View, data: ContactSearchData, isSelected: Boolean) {
if (isSelected) {
Log.d(TAG, "toggle(OFF) ${data.contactSearchKey}")
callbacks.value.onContactDeselected(view, data.contactSearchKey)
callbacks.onContactDeselected(view, data.contactSearchKey)
viewModel.setKeysNotSelected(setOf(data.contactSearchKey))
} else {
Log.d(TAG, "toggle(ON) ${data.contactSearchKey}")
viewModel.setKeysSelected(callbacks.value.onBeforeContactsSelected(view, setOf(data.contactSearchKey)))
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(view, setOf(data.contactSearchKey)))
}
}
}
private class DefaultStoryContextMenuCallbacks(
private val viewModel: ContactSearchViewModel,
private val fragmentManager: State<FragmentManager?>,
private val context: State<Context>
private val fragmentManager: FragmentManager?,
private val context: Context
) : ContactSearchAdapter.StoryContextMenuCallbacks {
override fun onOpenStorySettings(story: ContactSearchData.Story) {
val fm = fragmentManager.value ?: return
val fm = fragmentManager ?: return
if (story.recipient.isMyStory) {
MyStorySettingsFragment.createAsDialog().show(fm, null)
} else {
@@ -232,8 +277,8 @@ private class DefaultStoryContextMenuCallbacks(
}
override fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean) {
fragmentManager.value ?: return
MaterialAlertDialogBuilder(context.value)
fragmentManager ?: return
MaterialAlertDialogBuilder(context)
.setTitle(R.string.ContactSearchMediator__remove_group_story)
.setMessage(R.string.ContactSearchMediator__this_will_remove)
.setPositiveButton(R.string.ContactSearchMediator__remove) { _, _ -> viewModel.removeGroupStory(story) }
@@ -242,8 +287,8 @@ private class DefaultStoryContextMenuCallbacks(
}
override fun onDeletePrivateStory(story: ContactSearchData.Story, isSelected: Boolean) {
fragmentManager.value ?: return
val ctx = context.value
fragmentManager ?: return
val ctx = context
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.ContactSearchMediator__delete_story)
.setMessage(ctx.getString(R.string.ContactSearchMediator__delete_the_custom, story.recipient.getDisplayName(ctx)))
@@ -1,47 +1,15 @@
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.text.SpannableStringBuilder
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.requireDrawable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar
import org.thoughtcrime.securesms.avatar.view.AvatarView
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.FromTextView
import org.signal.core.ui.compose.FastScrollCharacterProvider
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller.FastScrollAdapter
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.visible
import org.signal.core.ui.R as CoreUiR
/**
* Default contact search adapter, using the models defined in `ContactSearchItems`
* Default contact search adapter. View holders, mapping models, and the helpers that register them
* live in [ContactSearchModels].
*/
@Suppress("LeakingThis")
open class ContactSearchAdapter(
@@ -55,12 +23,12 @@ open class ContactSearchAdapter(
) : PagingMappingAdapter<ContactSearchKey>(), FastScrollAdapter {
init {
registerStoryItems(this, displayOptions.displayCheckBox, onClickCallbacks::onStoryClicked, storyContextMenuCallbacks, displayOptions.displayStoryRing)
registerKnownRecipientItems(this, fixedContacts, displayOptions, onClickCallbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick, callButtonClickCallbacks)
registerHeaders(this)
registerExpands(this, onClickCallbacks::onExpandClicked)
registerChatTypeItems(this, onClickCallbacks::onChatTypeClicked)
registerFactory(UnknownRecipientModel::class.java, LayoutFactory({ UnknownRecipientViewHolder(it, onClickCallbacks::onUnknownRecipientClicked, displayOptions.displayCheckBox) }, R.layout.contact_search_unknown_item))
ContactSearchModels.registerStoryItems(this, displayOptions.displayCheckBox, onClickCallbacks::onStoryClicked, storyContextMenuCallbacks, displayOptions.displayStoryRing)
ContactSearchModels.registerKnownRecipientItems(this, fixedContacts, displayOptions, onClickCallbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick, callButtonClickCallbacks)
ContactSearchModels.registerHeaders(this)
ContactSearchModels.registerExpands(this, onClickCallbacks::onExpandClicked)
ContactSearchModels.registerChatTypeItems(this, onClickCallbacks::onChatTypeClicked)
ContactSearchModels.registerUnknownRecipientItems(this, onClickCallbacks::onUnknownRecipientClicked, displayOptions.displayCheckBox)
}
override fun getBubbleText(position: Int): CharSequence {
@@ -72,701 +40,6 @@ open class ContactSearchAdapter(
}
}
interface FastScrollCharacterProvider {
fun getFastScrollCharacter(context: Context): CharSequence
}
companion object {
fun registerStoryItems(
mappingAdapter: MappingAdapter,
displayCheckBox: Boolean = false,
storyListener: OnClickedCallback<ContactSearchData.Story>,
storyContextMenuCallbacks: StoryContextMenuCallbacks? = null,
showStoryRing: Boolean = false
) {
mappingAdapter.registerFactory(
StoryModel::class.java,
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener, storyContextMenuCallbacks, showStoryRing) }, R.layout.contact_search_story_item)
)
}
fun registerKnownRecipientItems(
mappingAdapter: MappingAdapter,
fixedContacts: Set<ContactSearchKey>,
displayOptions: DisplayOptions,
recipientListener: OnClickedCallback<ContactSearchData.KnownRecipient>,
recipientLongClickCallback: OnLongClickedCallback<ContactSearchData.KnownRecipient>,
recipientCallButtonClickCallbacks: CallButtonClickCallbacks
) {
mappingAdapter.registerFactory(
RecipientModel::class.java,
LayoutFactory({
KnownRecipientViewHolder(it, fixedContacts, displayOptions, recipientListener, recipientLongClickCallback, recipientCallButtonClickCallbacks)
}, R.layout.contact_search_item)
)
}
fun registerHeaders(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
HeaderModel::class.java,
LayoutFactory({ HeaderViewHolder(it) }, R.layout.contact_search_section_header)
)
}
fun registerExpands(mappingAdapter: MappingAdapter, expandListener: (ContactSearchData.Expand) -> Unit) {
mappingAdapter.registerFactory(
ExpandModel::class.java,
LayoutFactory({ ExpandViewHolder(it, expandListener) }, R.layout.contacts_expand_item)
)
}
fun registerChatTypeItems(mappingAdapter: MappingAdapter, chatTypeRowListener: OnClickedCallback<ContactSearchData.ChatTypeRow>) {
mappingAdapter.registerFactory(
ChatTypeModel::class.java,
LayoutFactory({ ChatTypeViewHolder(it, chatTypeRowListener) }, R.layout.contact_search_chat_type_item)
)
}
fun toMappingModelList(contactSearchData: List<ContactSearchData?>, selection: Set<ContactSearchKey>, arbitraryRepository: ArbitraryRepository?): MappingModelList {
return MappingModelList(
contactSearchData.filterNotNull().map {
when (it) {
is ContactSearchData.Story -> StoryModel(it, selection.contains(it.contactSearchKey), SignalStore.story.userHasBeenNotifiedAboutStories)
is ContactSearchData.KnownRecipient -> RecipientModel(it, selection.contains(it.contactSearchKey), it.shortSummary)
is ContactSearchData.Expand -> ExpandModel(it)
is ContactSearchData.Header -> HeaderModel(it)
is ContactSearchData.TestRow -> error("This row exists for testing only.")
is ContactSearchData.Arbitrary -> arbitraryRepository?.getMappingModel(it) ?: error("This row must be handled manually")
is ContactSearchData.Message -> MessageModel(it)
is ContactSearchData.Thread -> ThreadModel(it)
is ContactSearchData.Empty -> EmptyModel(it)
is ContactSearchData.GroupWithMembers -> GroupWithMembersModel(it)
is ContactSearchData.UnknownRecipient -> UnknownRecipientModel(it)
is ContactSearchData.ChatTypeRow -> ChatTypeModel(it, selection.contains(it.contactSearchKey))
}
}
)
}
}
/**
* Story Model
*/
class StoryModel(val story: ContactSearchData.Story, val isSelected: Boolean, val hasBeenNotified: Boolean) : MappingModel<StoryModel> {
override fun areItemsTheSame(newItem: StoryModel): Boolean {
return newItem.story == story
}
override fun areContentsTheSame(newItem: StoryModel): Boolean {
return story.recipient.hasSameContent(newItem.story.recipient) &&
isSelected == newItem.isSelected &&
hasBeenNotified == newItem.hasBeenNotified
}
override fun getChangePayload(newItem: StoryModel): Any? {
return if (story.recipient.hasSameContent(newItem.story.recipient) &&
hasBeenNotified == newItem.hasBeenNotified &&
newItem.isSelected != isSelected
) {
0
} else {
null
}
}
}
private class StoryViewHolder(
itemView: View,
val displayCheckBox: Boolean,
val onClick: OnClickedCallback<ContactSearchData.Story>,
private val storyContextMenuCallbacks: StoryContextMenuCallbacks?,
private val showStoryRing: Boolean = false
) : MappingViewHolder<StoryModel>(itemView) {
val avatar: AvatarView = itemView.findViewById(R.id.contact_photo_image)
val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
val name: FromTextView = itemView.findViewById(R.id.name)
val number: TextView = itemView.findViewById(R.id.number)
val groupStoryIndicator: AppCompatImageView = itemView.findViewById(R.id.group_story_indicator)
var storyViewState: Observable<StoryViewState>? = null
var storyDisposable: Disposable? = null
override fun bind(model: StoryModel) {
itemView.setOnClickListener { onClick.onClicked(avatar, getData(model), isSelected(model)) }
bindLongPress(model)
bindCheckbox(model)
if (payload.isNotEmpty()) {
return
}
storyViewState = if (showStoryRing) StoryViewState.getForRecipientId(getRecipient(model).id) else null
avatar.setStoryRingFromState(StoryViewState.NONE)
groupStoryIndicator.isActivated = false
name.setText(getRecipient(model))
badge.setBadgeFromRecipient(getRecipient(model))
bindAvatar(model)
bindNumberField(model)
}
fun isSelected(model: StoryModel): Boolean = model.isSelected
fun getData(model: StoryModel): ContactSearchData.Story = model.story
fun getRecipient(model: StoryModel): Recipient = model.story.recipient
fun bindNumberField(model: StoryModel) {
number.visible = true
val count = if (model.story.recipient.isGroup) {
model.story.recipient.participantIds.size
} else {
model.story.count
}
if (model.story.recipient.isMyStory && !model.hasBeenNotified) {
number.setText(R.string.ContactSearchItems__tap_to_choose_your_viewers)
number.setSingleLine(false)
} else {
number.setSingleLine(true)
number.text = when {
model.story.recipient.isGroup -> context.resources.getQuantityString(R.plurals.ContactSearchItems__group_story_d_viewers, count, count)
model.story.recipient.isMyStory -> {
if (model.story.privacyMode == DistributionListPrivacyMode.ALL_EXCEPT) {
context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_excluded, count, presentPrivacyMode(DistributionListPrivacyMode.ALL), count)
} else {
context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_viewers, count, presentPrivacyMode(model.story.privacyMode), count)
}
}
else -> context.resources.getQuantityString(R.plurals.ContactSearchItems__custom_story_d_viewers, count, count)
}
}
}
fun bindCheckbox(model: StoryModel) {
checkbox.visible = displayCheckBox
checkbox.isChecked = isSelected(model)
}
fun bindAvatar(model: StoryModel) {
if (model.story.recipient.isMyStory) {
avatar.setFallbackAvatarProvider(MyStoryFallbackAvatarProvider)
avatar.displayProfileAvatar(Recipient.self())
} else {
avatar.setFallbackAvatarProvider(null)
avatar.displayChatAvatar(getRecipient(model))
}
groupStoryIndicator.visible = showStoryRing && model.story.recipient.isGroup
}
fun bindLongPress(model: StoryModel) {
if (storyContextMenuCallbacks == null) {
return
}
itemView.setOnLongClickListener {
val actions: List<ActionItem> = when {
model.story.recipient.isMyStory -> getMyStoryContextMenuActions(model, storyContextMenuCallbacks)
model.story.recipient.isGroup -> getGroupStoryContextMenuActions(model, storyContextMenuCallbacks)
model.story.recipient.isDistributionList -> getPrivateStoryContextMenuActions(model, storyContextMenuCallbacks)
else -> error("Unsupported story target. Not a group or distribution list.")
}
SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
.offsetX(context.resources.getDimensionPixelSize(R.dimen.dsl_settings_gutter))
.show(actions)
true
}
}
private fun getMyStoryContextMenuActions(model: StoryModel, callbacks: StoryContextMenuCallbacks): List<ActionItem> {
return listOf(
ActionItem(CoreUiR.drawable.symbol_settings_android_24, context.getString(R.string.ContactSearchItems__story_settings)) {
callbacks.onOpenStorySettings(model.story)
}
)
}
private fun getGroupStoryContextMenuActions(model: StoryModel, callbacks: StoryContextMenuCallbacks): List<ActionItem> {
return listOf(
ActionItem(R.drawable.symbol_minus_circle_24, context.getString(R.string.ContactSearchItems__remove_story)) {
callbacks.onRemoveGroupStory(model.story, model.isSelected)
}
)
}
private fun getPrivateStoryContextMenuActions(model: StoryModel, callbacks: StoryContextMenuCallbacks): List<ActionItem> {
return listOf(
ActionItem(CoreUiR.drawable.symbol_settings_android_24, context.getString(R.string.ContactSearchItems__story_settings)) {
callbacks.onOpenStorySettings(model.story)
},
ActionItem(CoreUiR.drawable.symbol_trash_24, context.getString(R.string.ContactSearchItems__delete_story), CoreUiR.color.signal_colorError) {
callbacks.onDeletePrivateStory(model.story, model.isSelected)
}
)
}
private fun presentPrivacyMode(privacyMode: DistributionListPrivacyMode): String {
return when (privacyMode) {
DistributionListPrivacyMode.ONLY_WITH -> context.getString(R.string.ContactSearchItems__only_share_with)
DistributionListPrivacyMode.ALL_EXCEPT -> context.getString(R.string.ChooseInitialMyStoryMembershipFragment__all_except)
DistributionListPrivacyMode.ALL -> context.getString(R.string.ChooseInitialMyStoryMembershipFragment__all_signal_connections)
}
}
private object MyStoryFallbackAvatarProvider : AvatarImageView.FallbackAvatarProvider {
override fun getFallbackAvatar(recipient: Recipient): FallbackAvatar {
if (recipient.isSelf) {
return FallbackAvatar.Resource.Person(recipient.avatarColor)
}
return super.getFallbackAvatar(recipient)
}
}
override fun onAttachedToWindow() {
storyDisposable = storyViewState?.observeOn(AndroidSchedulers.mainThread())?.subscribe {
avatar.setStoryRingFromState(it)
when (it) {
StoryViewState.UNVIEWED -> groupStoryIndicator.isActivated = true
else -> groupStoryIndicator.isActivated = false
}
}
}
override fun onDetachedFromWindow() {
storyDisposable?.dispose()
}
}
/**
* Recipient model
*/
class RecipientModel(
val knownRecipient: ContactSearchData.KnownRecipient,
val isSelected: Boolean,
val shortSummary: Boolean
) : MappingModel<RecipientModel>, FastScrollCharacterProvider {
override fun getFastScrollCharacter(context: Context): CharSequence {
val name = if (knownRecipient.recipient.isSelf) {
context.getString(R.string.note_to_self)
} else {
knownRecipient.recipient.getDisplayName(context)
}
val letter: CharSequence = BreakIteratorCompat.getInstance()
.apply { setText(name) }
.asSequence()
.map { charSequence -> charSequence.trim { it <= ' ' } }
.filter { it.isNotEmpty() }
.mapNotNull {
when {
EmojiUtil.isEmoji(it.toString()) -> it
Character.isLetterOrDigit(it[0]) -> it[0].uppercaseChar().toString()
else -> null
}
}
.firstOrNull() ?: "#"
return letter
}
override fun areItemsTheSame(newItem: RecipientModel): Boolean {
return newItem.knownRecipient == knownRecipient
}
override fun areContentsTheSame(newItem: RecipientModel): Boolean {
return knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && isSelected == newItem.isSelected
}
override fun getChangePayload(newItem: RecipientModel): Any? {
return if (knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && newItem.isSelected != isSelected) {
0
} else {
null
}
}
}
class UnknownRecipientModel(val data: ContactSearchData.UnknownRecipient) : MappingModel<UnknownRecipientModel> {
override fun areItemsTheSame(newItem: UnknownRecipientModel): Boolean = true
override fun areContentsTheSame(newItem: UnknownRecipientModel): Boolean = data == newItem.data
}
private class UnknownRecipientViewHolder(
itemView: View,
private val onClick: OnClickedCallback<ContactSearchData.UnknownRecipient>,
private val displayCheckBox: Boolean
) : MappingViewHolder<UnknownRecipientModel>(itemView) {
private val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
private val name: FromTextView = itemView.findViewById(R.id.name)
private val number: TextView = itemView.findViewById(R.id.number)
private val headerGroup: View = itemView.findViewById(R.id.contact_header)
private val headerText: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: UnknownRecipientModel) {
checkbox.visible = displayCheckBox
checkbox.isSelected = false
val nameText = when (model.data.mode) {
ContactSearchConfiguration.NewRowMode.NEW_CALL -> R.string.contact_selection_list__new_call
ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION -> -1
ContactSearchConfiguration.NewRowMode.BLOCK -> R.string.contact_selection_list__unknown_contact_block
ContactSearchConfiguration.NewRowMode.ADD_TO_GROUP -> R.string.contact_selection_list__unknown_contact_add_to_group
}
if (nameText > 0) {
name.setText(nameText)
number.text = model.data.query
number.visible = true
} else {
name.text = model.data.query
number.visible = false
}
if (model.data.mode == ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION) {
headerGroup.visible = true
headerText.setText(
if (model.data.sectionKey == ContactSearchConfiguration.SectionKey.PHONE_NUMBER) {
R.string.FindByActivity__find_by_phone_number
} else {
R.string.FindByActivity__find_by_username
}
)
} else {
headerGroup.visible = false
}
itemView.setOnClickListener {
onClick.onClicked(itemView, model.data, false)
}
}
}
private class KnownRecipientViewHolder(
itemView: View,
private val fixedContacts: Set<ContactSearchKey>,
displayOptions: DisplayOptions,
onClick: OnClickedCallback<ContactSearchData.KnownRecipient>,
private val onLongClick: OnLongClickedCallback<ContactSearchData.KnownRecipient>,
callButtonClickCallbacks: CallButtonClickCallbacks
) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayOptions, onClick, callButtonClickCallbacks), LetterHeaderDecoration.LetterHeaderItem {
private var headerLetter: String? = null
override fun isSelected(model: RecipientModel): Boolean = model.isSelected
override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient
override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
override fun bindNumberField(model: RecipientModel) {
val recipient = getRecipient(model)
if (model.knownRecipient.sectionKey == ContactSearchConfiguration.SectionKey.GROUP_MEMBERS) {
number.text = model.knownRecipient.groupsInCommon.toDisplayText(context, displayGroupsLimit = 2)
number.visible = true
} else if (model.shortSummary && recipient.isGroup) {
val count = recipient.participantIds.size
number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count)
number.visible = true
} else if (displayOptions.displaySecondaryInformation == DisplaySecondaryInformation.ALWAYS && recipient.combinedAboutAndEmoji != null) {
number.text = recipient.combinedAboutAndEmoji
number.visible = true
} else if (displayOptions.displaySecondaryInformation == DisplaySecondaryInformation.ALWAYS && recipient.hasE164) {
number.visible = false
} else {
super.bindNumberField(model)
}
headerLetter = model.knownRecipient.headerLetter
}
override fun bindCheckbox(model: RecipientModel) {
super.bindCheckbox(model)
if (fixedContacts.contains(model.knownRecipient.contactSearchKey)) {
checkbox.isChecked = true
}
checkbox.isEnabled = !fixedContacts.contains(model.knownRecipient.contactSearchKey)
}
override fun isEnabled(model: RecipientModel): Boolean {
return !fixedContacts.contains(model.knownRecipient.contactSearchKey)
}
override fun getHeaderLetter(): String? {
return headerLetter
}
override fun bindLongPress(model: RecipientModel) {
itemView.setOnLongClickListener { onLongClick.onLongClicked(itemView, model.knownRecipient) }
}
}
/**
* Base Recipient View Holder
*/
abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>(
itemView: View,
val displayOptions: DisplayOptions,
val onClick: OnClickedCallback<D>,
val onCallButtonClickCallbacks: CallButtonClickCallbacks
) : MappingViewHolder<T>(itemView) {
protected val avatar: AvatarImageView = itemView.findViewById(R.id.contact_photo_image)
protected val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
protected val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
protected val name: FromTextView = itemView.findViewById(R.id.name)
protected val number: TextView = itemView.findViewById(R.id.number)
protected val label: TextView = itemView.findViewById(R.id.label)
private val startAudio: View = itemView.findViewById(R.id.start_audio)
private val startVideo: View = itemView.findViewById(R.id.start_video)
override fun bind(model: T) {
if (isEnabled(model)) {
itemView.setOnClickListener { onClick.onClicked(avatar, getData(model), isSelected(model)) }
bindLongPress(model)
} else {
itemView.setOnClickListener(null)
}
bindCheckbox(model)
if (payload.isNotEmpty()) {
return
}
val recipient = getRecipient(model)
val suffix: CharSequence? = if (recipient.isSystemContact && !recipient.showVerified) {
SpannableStringBuilder().apply {
val drawable = context.requireDrawable(R.drawable.symbol_person_circle_24).apply {
setTint(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface))
}
SpanUtil.appendCenteredImageSpan(this, drawable, 16, 16)
}
} else {
null
}
name.setText(recipient, suffix)
badge.setBadgeFromRecipient(getRecipient(model))
bindAvatar(model)
bindNumberField(model)
bindLabelField(model)
bindCallButtons(model)
}
protected open fun bindCheckbox(model: T) {
checkbox.visible = displayOptions.displayCheckBox
checkbox.isChecked = isSelected(model)
}
protected open fun isEnabled(model: T): Boolean = true
protected open fun bindAvatar(model: T) {
avatar.setAvatar(getRecipient(model))
}
protected open fun bindNumberField(model: T) {
number.visible = getRecipient(model).isGroup
if (getRecipient(model).isGroup) {
number.text = getRecipient(model).participantIds
.take(10)
.map { id ->
val recipient = Recipient.resolved(id)
RecipientDisplayName(
recipient = recipient,
displayName = if (recipient.isSelf) {
context.getString(R.string.ConversationTitleView_you)
} else {
recipient.getShortDisplayName(context)
}
)
}
.sortedWith(compareBy({ it.recipient.isUnregistered }, { it.recipient.isSelf }, { it.displayName }))
.joinToString(", ") { it.displayName }
}
}
protected open fun bindLabelField(model: T) {
label.visible = false
}
protected open fun bindLongPress(model: T) = Unit
private fun bindCallButtons(model: T) {
val recipient = getRecipient(model)
if (displayOptions.displayCallButtons && (recipient.isPushGroup || recipient.isRegistered)) {
startVideo.visible = true
startAudio.visible = !recipient.isPushGroup
startVideo.setOnClickListener {
onCallButtonClickCallbacks.onVideoCallButtonClicked(recipient)
}
startAudio.setOnClickListener {
onCallButtonClickCallbacks.onAudioCallButtonClicked(recipient)
}
} else {
startVideo.visible = false
startAudio.visible = false
}
}
abstract fun isSelected(model: T): Boolean
abstract fun getData(model: T): D
abstract fun getRecipient(model: T): Recipient
}
/**
* Mapping Model for section headers
*/
class HeaderModel(val header: ContactSearchData.Header) : MappingModel<HeaderModel> {
override fun areItemsTheSame(newItem: HeaderModel): Boolean {
return header.sectionKey == newItem.header.sectionKey
}
override fun areContentsTheSame(newItem: HeaderModel): Boolean {
return areItemsTheSame(newItem) &&
header.action?.icon == newItem.header.action?.icon &&
header.action?.label == newItem.header.action?.label
}
}
/**
* Mapping Model for messages
*/
class MessageModel(val message: ContactSearchData.Message) : MappingModel<MessageModel> {
override fun areItemsTheSame(newItem: MessageModel): Boolean = message.contactSearchKey == newItem.message.contactSearchKey
override fun areContentsTheSame(newItem: MessageModel): Boolean {
return message == newItem.message
}
}
/**
* Mapping Model for threads
*/
class ThreadModel(val thread: ContactSearchData.Thread) : MappingModel<ThreadModel> {
override fun areItemsTheSame(newItem: ThreadModel): Boolean = thread.contactSearchKey == newItem.thread.contactSearchKey
override fun areContentsTheSame(newItem: ThreadModel): Boolean {
return thread == newItem.thread
}
}
class EmptyModel(val empty: ContactSearchData.Empty) : MappingModel<EmptyModel> {
override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
override fun areContentsTheSame(newItem: EmptyModel): Boolean = newItem.empty == empty
}
/**
* Mapping Model for [ContactSearchData.GroupWithMembers]
*/
class GroupWithMembersModel(val groupWithMembers: ContactSearchData.GroupWithMembers) : MappingModel<GroupWithMembersModel> {
override fun areContentsTheSame(newItem: GroupWithMembersModel): Boolean = newItem.groupWithMembers == groupWithMembers
override fun areItemsTheSame(newItem: GroupWithMembersModel): Boolean = newItem.groupWithMembers.contactSearchKey == groupWithMembers.contactSearchKey
}
/**
* View Holder for section headers
*/
private class HeaderViewHolder(itemView: View) : MappingViewHolder<HeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
private val headerActionView: MaterialButton = itemView.findViewById(R.id.section_header_action)
override fun bind(model: HeaderModel) {
headerTextView.setText(
when (model.header.sectionKey) {
ContactSearchConfiguration.SectionKey.STORIES -> R.string.ContactsCursorLoader_my_stories
ContactSearchConfiguration.SectionKey.RECENTS -> R.string.ContactsCursorLoader_recent_chats
ContactSearchConfiguration.SectionKey.INDIVIDUALS -> R.string.ContactsCursorLoader_contacts
ContactSearchConfiguration.SectionKey.GROUPS -> R.string.ContactsCursorLoader_groups
ContactSearchConfiguration.SectionKey.GROUP_MEMBERS -> R.string.ContactsCursorLoader_group_members
ContactSearchConfiguration.SectionKey.CHATS -> R.string.ContactsCursorLoader__chats
ContactSearchConfiguration.SectionKey.MESSAGES -> R.string.ContactsCursorLoader__messages
ContactSearchConfiguration.SectionKey.GROUPS_WITH_MEMBERS -> R.string.ContactsCursorLoader_group_members
ContactSearchConfiguration.SectionKey.CONTACTS_WITHOUT_THREADS -> R.string.ContactsCursorLoader_contacts
ContactSearchConfiguration.SectionKey.CHAT_TYPES -> R.string.ContactsCursorLoader__chat_types
else -> error("This section does not support HEADER")
}
)
if (model.header.action != null) {
headerActionView.visible = true
headerActionView.setIconResource(model.header.action.icon)
headerActionView.setText(model.header.action.label)
headerActionView.setOnClickListener { model.header.action.action.run() }
} else {
headerActionView.visible = false
}
}
}
/**
* Mapping Model for expandable content rows.
*/
class ExpandModel(val expand: ContactSearchData.Expand) : MappingModel<ExpandModel> {
override fun areItemsTheSame(newItem: ExpandModel): Boolean {
return expand.contactSearchKey == newItem.expand.contactSearchKey
}
override fun areContentsTheSame(newItem: ExpandModel): Boolean {
return areItemsTheSame(newItem)
}
}
/**
* View Holder for expandable content rows.
*/
private class ExpandViewHolder(itemView: View, private val expandListener: (ContactSearchData.Expand) -> Unit) : MappingViewHolder<ExpandModel>(itemView) {
override fun bind(model: ExpandModel) {
itemView.setOnClickListener { expandListener.invoke(model.expand) }
}
}
/**
* Mapping Model for chat types.
*/
class ChatTypeModel(val data: ContactSearchData.ChatTypeRow, val isSelected: Boolean) : MappingModel<ChatTypeModel> {
override fun areItemsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data
override fun areContentsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data && isSelected == newItem.isSelected
}
/**
* View Holder for chat types
*/
private class ChatTypeViewHolder(
itemView: View,
val onClick: OnClickedCallback<ContactSearchData.ChatTypeRow>
) : MappingViewHolder<ChatTypeModel>(itemView) {
val image: ImageView = itemView.findViewById(R.id.image)
val name: TextView = itemView.findViewById(R.id.name)
val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
override fun bind(model: ChatTypeModel) {
itemView.setOnClickListener { onClick.onClicked(itemView, model.data, model.isSelected) }
image.setImageResource(model.data.imageResId)
if (model.data.chatType == ChatType.INDIVIDUAL) {
name.text = context.getString(R.string.ChatFoldersFragment__one_on_one_chats)
}
if (model.data.chatType == ChatType.GROUPS) {
name.text = context.getString(R.string.ChatFoldersFragment__groups)
}
checkbox.isChecked = model.isSelected
}
}
interface StoryContextMenuCallbacks {
fun onOpenStorySettings(story: ContactSearchData.Story)
fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean)
@@ -857,5 +130,3 @@ open class ContactSearchAdapter(
}
}
}
private data class RecipientDisplayName(val recipient: Recipient, val displayName: String)
@@ -36,7 +36,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.RecipientSearchKey]
* Data: [ContactSearchData.Story]
* Model: [ContactSearchAdapter.StoryModel]
* Model: [ContactSearchModels.StoryModel]
*/
data class Stories(
val groupStories: Set<ContactSearchData.Story> = emptySet(),
@@ -50,7 +50,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.RecipientSearchKey]
* Data: [ContactSearchData.KnownRecipient]
* Model: [ContactSearchAdapter.RecipientModel]
* Model: [ContactSearchModels.RecipientModel]
*/
data class Recents(
val limit: Int = 25,
@@ -76,12 +76,13 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.RecipientSearchKey]
* Data: [ContactSearchData.KnownRecipient]
* Model: [ContactSearchAdapter.RecipientModel]
* Model: [ContactSearchModels.RecipientModel]
*/
data class Individuals(
val includeSelfMode: RecipientTable.IncludeSelfMode,
val transportType: TransportType,
override val includeHeader: Boolean,
override val headerAction: HeaderAction? = null,
override val expandConfig: ExpandConfig? = null,
val includeLetterHeaders: Boolean = false,
val pushSearchResultsSortOrder: ContactSearchSortOrder = ContactSearchSortOrder.NATURAL
@@ -92,7 +93,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.RecipientSearchKey]
* Data: [ContactSearchData.KnownRecipient]
* Model: [ContactSearchAdapter.RecipientModel]
* Model: [ContactSearchModels.RecipientModel]
*/
data class Groups(
val includeMms: Boolean = false,
@@ -126,7 +127,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.RecipientSearchKey]
* Data: [ContactSearchData.KnownRecipient]
* Model: [ContactSearchAdapter.RecipientModel]
* Model: [ContactSearchModels.RecipientModel]
*/
data class GroupMembers(
override val includeHeader: Boolean = true,
@@ -139,7 +140,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.GroupWithMembers]
* Data: [ContactSearchData.GroupWithMembers]
* Model: [ContactSearchAdapter.GroupWithMembersModel]
* Model: [ContactSearchModels.GroupWithMembersModel]
*/
data class GroupsWithMembers(
override val includeHeader: Boolean = true,
@@ -152,7 +153,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.Thread]
* Data: [ContactSearchData.Thread]
* Model: [ContactSearchAdapter.ThreadModel]
* Model: [ContactSearchModels.ThreadModel]
*/
data class Chats(
val isUnreadOnly: Boolean = false,
@@ -166,7 +167,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.Message]
* Data: [ContactSearchData.Message]
* Model: [ContactSearchAdapter.MessageModel]
* Model: [ContactSearchModels.MessageModel]
*/
data class Messages(
override val includeHeader: Boolean = true,
@@ -180,7 +181,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.RecipientSearchKey]
* Data: [ContactSearchData.KnownRecipient]
* Model: [ContactSearchAdapter.RecipientModel]
* Model: [ContactSearchModels.RecipientModel]
*/
data class ContactsWithoutThreads(
override val includeHeader: Boolean = true,
@@ -202,7 +203,7 @@ class ContactSearchConfiguration private constructor(
*
* Key: [ContactSearchKey.ChatTypeSearchKey]
* Data: [ContactSearchData.ChatTypeRow]
* Model: [ContactSearchAdapter.ChatTypeModel]
* Model: [ContactSearchModels.ChatTypeModel]
*/
data class ChatTypes(
override val includeHeader: Boolean = true,
@@ -0,0 +1,867 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.text.SpannableStringBuilder
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
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.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import org.signal.core.ui.compose.FastScrollCharacterProvider
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.requireDrawable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar
import org.thoughtcrime.securesms.avatar.view.AvatarView
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.FromTextView
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProviderBuilder
import org.thoughtcrime.securesms.util.visible
import org.signal.core.ui.R as CoreUiR
/**
* Holds the [MappingModel]s and [MappingViewHolder]s used by [ContactSearchAdapter], along with
* helpers for registering them on a [MappingAdapter] (RecyclerView) or building a
* [MappingEntryProvider] (Compose).
*/
object ContactSearchModels {
fun registerStoryItems(
mappingAdapter: MappingAdapter,
displayCheckBox: Boolean = false,
storyListener: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Story>,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks? = null,
showStoryRing: Boolean = false
) {
mappingAdapter.registerFactory(
StoryModel::class.java,
LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener, storyContextMenuCallbacks, showStoryRing) }, R.layout.contact_search_story_item)
)
}
fun registerKnownRecipientItems(
mappingAdapter: MappingAdapter,
fixedContacts: Set<ContactSearchKey>,
displayOptions: ContactSearchAdapter.DisplayOptions,
recipientListener: ContactSearchAdapter.OnClickedCallback<ContactSearchData.KnownRecipient>,
recipientLongClickCallback: ContactSearchAdapter.OnLongClickedCallback<ContactSearchData.KnownRecipient>,
recipientCallButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
) {
mappingAdapter.registerFactory(
RecipientModel::class.java,
LayoutFactory({
KnownRecipientViewHolder(it, fixedContacts, displayOptions, recipientListener, recipientLongClickCallback, recipientCallButtonClickCallbacks)
}, R.layout.contact_search_item)
)
}
fun registerUnknownRecipientItems(
mappingAdapter: MappingAdapter,
onClick: ContactSearchAdapter.OnClickedCallback<ContactSearchData.UnknownRecipient>,
displayCheckBox: Boolean
) {
mappingAdapter.registerFactory(
UnknownRecipientModel::class.java,
LayoutFactory({ UnknownRecipientViewHolder(it, onClick, displayCheckBox) }, R.layout.contact_search_unknown_item)
)
}
fun registerHeaders(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
HeaderModel::class.java,
LayoutFactory({ HeaderViewHolder(it) }, R.layout.contact_search_section_header)
)
}
fun registerExpands(mappingAdapter: MappingAdapter, expandListener: (ContactSearchData.Expand) -> Unit) {
mappingAdapter.registerFactory(
ExpandModel::class.java,
LayoutFactory({ ExpandViewHolder(it, expandListener) }, R.layout.contacts_expand_item)
)
}
fun registerChatTypeItems(mappingAdapter: MappingAdapter, chatTypeRowListener: ContactSearchAdapter.OnClickedCallback<ContactSearchData.ChatTypeRow>) {
mappingAdapter.registerFactory(
ChatTypeModel::class.java,
LayoutFactory({ ChatTypeViewHolder(it, chatTypeRowListener) }, R.layout.contact_search_chat_type_item)
)
}
/**
* Returns a [MappingEntryProvider] containing the same set of view holders registered by the
* adapter-side `register*` methods, suitable for use with a Compose `MappingLazyColumn`.
* Compose-side callers can merge this with their own provider via
* [MappingEntryProviderBuilder.provider].
*/
fun composeEntries(
fixedContacts: Set<ContactSearchKey>,
displayOptions: ContactSearchAdapter.DisplayOptions,
callbacks: ContactSearchAdapter.ClickCallbacks,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks? = null,
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
): MappingEntryProvider<Any> {
return MappingEntryProviderBuilder<Any>().apply {
viewHolder<StoryModel> { ctx ->
LayoutFactory(
{ view -> StoryViewHolder(view, displayOptions.displayCheckBox, callbacks::onStoryClicked, storyContextMenuCallbacks, displayOptions.displayStoryRing) },
R.layout.contact_search_story_item
).createViewHolder(FrameLayout(ctx))
}
entry<RecipientModel>(
key = { model -> model.knownRecipient.recipient.id }
) { model ->
Column(modifier = Modifier.fillMaxWidth()) {
val letter = model.knownRecipient.headerLetter
if (letter != null) {
Text(
text = letter,
color = colorResource(R.color.signal_text_primary),
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
modifier = Modifier
.fillMaxWidth()
.padding(
start = dimensionResource(R.dimen.dsl_settings_gutter),
end = dimensionResource(R.dimen.dsl_settings_gutter),
top = 16.dp,
bottom = 12.dp
)
)
}
var viewHolder: MappingViewHolder<RecipientModel>? by remember { mutableStateOf(null) }
AndroidView(
factory = { ctx ->
val holder = LayoutFactory(
{ view -> KnownRecipientViewHolder(view, fixedContacts, displayOptions, callbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick, callButtonClickCallbacks) },
R.layout.contact_search_item
).createViewHolder(FrameLayout(ctx))
viewHolder = holder
holder.itemView
},
update = {
viewHolder?.bind(model)
}
)
}
}
viewHolder<UnknownRecipientModel> { ctx ->
LayoutFactory(
{ view -> UnknownRecipientViewHolder(view, callbacks::onUnknownRecipientClicked, displayOptions.displayCheckBox) },
R.layout.contact_search_unknown_item
).createViewHolder(FrameLayout(ctx))
}
viewHolder<HeaderModel>(
key = { model -> "HEADER${model.header.sectionKey}" }
) { ctx ->
LayoutFactory(
{ view -> HeaderViewHolder(view) },
R.layout.contact_search_section_header
).createViewHolder(FrameLayout(ctx))
}
viewHolder<ExpandModel>(
key = { model -> "EXPAND${model.expand.sectionKey}" }
) { ctx ->
LayoutFactory(
{ view -> ExpandViewHolder(view, callbacks::onExpandClicked) },
R.layout.contacts_expand_item
).createViewHolder(FrameLayout(ctx))
}
viewHolder<ChatTypeModel>(
key = { model -> "ChatType${model.data.chatType}" }
) { ctx ->
LayoutFactory(
{ view -> ChatTypeViewHolder(view, callbacks::onChatTypeClicked) },
R.layout.contact_search_chat_type_item
).createViewHolder(FrameLayout(ctx))
}
}.build()
}
fun toMappingModelList(contactSearchData: List<ContactSearchData?>, selection: Set<ContactSearchKey>, arbitraryRepository: ArbitraryRepository?): MappingModelList {
return MappingModelList(
contactSearchData.filterNotNull().map {
when (it) {
is ContactSearchData.Story -> StoryModel(it, selection.contains(it.contactSearchKey), SignalStore.story.userHasBeenNotifiedAboutStories)
is ContactSearchData.KnownRecipient -> RecipientModel(it, selection.contains(it.contactSearchKey), it.shortSummary)
is ContactSearchData.Expand -> ExpandModel(it)
is ContactSearchData.Header -> HeaderModel(it)
is ContactSearchData.TestRow -> error("This row exists for testing only.")
is ContactSearchData.Arbitrary -> arbitraryRepository?.getMappingModel(it) ?: error("This row must be handled manually")
is ContactSearchData.Message -> MessageModel(it)
is ContactSearchData.Thread -> ThreadModel(it)
is ContactSearchData.Empty -> EmptyModel(it)
is ContactSearchData.GroupWithMembers -> GroupWithMembersModel(it)
is ContactSearchData.UnknownRecipient -> UnknownRecipientModel(it)
is ContactSearchData.ChatTypeRow -> ChatTypeModel(it, selection.contains(it.contactSearchKey))
}
}
)
}
/**
* Story Model
*/
class StoryModel(val story: ContactSearchData.Story, val isSelected: Boolean, val hasBeenNotified: Boolean) : MappingModel<StoryModel> {
override fun areItemsTheSame(newItem: StoryModel): Boolean {
return newItem.story == story
}
override fun areContentsTheSame(newItem: StoryModel): Boolean {
return story.recipient.hasSameContent(newItem.story.recipient) &&
isSelected == newItem.isSelected &&
hasBeenNotified == newItem.hasBeenNotified
}
override fun getChangePayload(newItem: StoryModel): Any? {
return if (story.recipient.hasSameContent(newItem.story.recipient) &&
hasBeenNotified == newItem.hasBeenNotified &&
newItem.isSelected != isSelected
) {
0
} else {
null
}
}
}
private class StoryViewHolder(
itemView: View,
val displayCheckBox: Boolean,
val onClick: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Story>,
private val storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks?,
private val showStoryRing: Boolean = false
) : MappingViewHolder<StoryModel>(itemView) {
val avatar: AvatarView = itemView.findViewById(R.id.contact_photo_image)
val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
val name: FromTextView = itemView.findViewById(R.id.name)
val number: TextView = itemView.findViewById(R.id.number)
val groupStoryIndicator: AppCompatImageView = itemView.findViewById(R.id.group_story_indicator)
var storyViewState: Observable<StoryViewState>? = null
var storyDisposable: Disposable? = null
override fun bind(model: StoryModel) {
itemView.setOnClickListener { onClick.onClicked(avatar, getData(model), isSelected(model)) }
bindLongPress(model)
bindCheckbox(model)
if (payload.isNotEmpty()) {
return
}
storyViewState = if (showStoryRing) StoryViewState.getForRecipientId(getRecipient(model).id) else null
avatar.setStoryRingFromState(StoryViewState.NONE)
groupStoryIndicator.isActivated = false
name.setText(getRecipient(model))
badge.setBadgeFromRecipient(getRecipient(model))
bindAvatar(model)
bindNumberField(model)
}
fun isSelected(model: StoryModel): Boolean = model.isSelected
fun getData(model: StoryModel): ContactSearchData.Story = model.story
fun getRecipient(model: StoryModel): Recipient = model.story.recipient
fun bindNumberField(model: StoryModel) {
number.visible = true
val count = if (model.story.recipient.isGroup) {
model.story.recipient.participantIds.size
} else {
model.story.count
}
if (model.story.recipient.isMyStory && !model.hasBeenNotified) {
number.setText(R.string.ContactSearchItems__tap_to_choose_your_viewers)
number.setSingleLine(false)
} else {
number.setSingleLine(true)
number.text = when {
model.story.recipient.isGroup -> context.resources.getQuantityString(R.plurals.ContactSearchItems__group_story_d_viewers, count, count)
model.story.recipient.isMyStory -> {
if (model.story.privacyMode == DistributionListPrivacyMode.ALL_EXCEPT) {
context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_excluded, count, presentPrivacyMode(DistributionListPrivacyMode.ALL), count)
} else {
context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_viewers, count, presentPrivacyMode(model.story.privacyMode), count)
}
}
else -> context.resources.getQuantityString(R.plurals.ContactSearchItems__custom_story_d_viewers, count, count)
}
}
}
fun bindCheckbox(model: StoryModel) {
checkbox.visible = displayCheckBox
checkbox.isChecked = isSelected(model)
}
fun bindAvatar(model: StoryModel) {
if (model.story.recipient.isMyStory) {
avatar.setFallbackAvatarProvider(MyStoryFallbackAvatarProvider)
avatar.displayProfileAvatar(Recipient.self())
} else {
avatar.setFallbackAvatarProvider(null)
avatar.displayChatAvatar(getRecipient(model))
}
groupStoryIndicator.visible = showStoryRing && model.story.recipient.isGroup
}
fun bindLongPress(model: StoryModel) {
if (storyContextMenuCallbacks == null) {
return
}
itemView.setOnLongClickListener {
val actions: List<ActionItem> = when {
model.story.recipient.isMyStory -> getMyStoryContextMenuActions(model, storyContextMenuCallbacks)
model.story.recipient.isGroup -> getGroupStoryContextMenuActions(model, storyContextMenuCallbacks)
model.story.recipient.isDistributionList -> getPrivateStoryContextMenuActions(model, storyContextMenuCallbacks)
else -> error("Unsupported story target. Not a group or distribution list.")
}
SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
.offsetX(context.resources.getDimensionPixelSize(R.dimen.dsl_settings_gutter))
.show(actions)
true
}
}
private fun getMyStoryContextMenuActions(model: StoryModel, callbacks: ContactSearchAdapter.StoryContextMenuCallbacks): List<ActionItem> {
return listOf(
ActionItem(CoreUiR.drawable.symbol_settings_android_24, context.getString(R.string.ContactSearchItems__story_settings)) {
callbacks.onOpenStorySettings(model.story)
}
)
}
private fun getGroupStoryContextMenuActions(model: StoryModel, callbacks: ContactSearchAdapter.StoryContextMenuCallbacks): List<ActionItem> {
return listOf(
ActionItem(R.drawable.symbol_minus_circle_24, context.getString(R.string.ContactSearchItems__remove_story)) {
callbacks.onRemoveGroupStory(model.story, model.isSelected)
}
)
}
private fun getPrivateStoryContextMenuActions(model: StoryModel, callbacks: ContactSearchAdapter.StoryContextMenuCallbacks): List<ActionItem> {
return listOf(
ActionItem(CoreUiR.drawable.symbol_settings_android_24, context.getString(R.string.ContactSearchItems__story_settings)) {
callbacks.onOpenStorySettings(model.story)
},
ActionItem(CoreUiR.drawable.symbol_trash_24, context.getString(R.string.ContactSearchItems__delete_story), CoreUiR.color.signal_colorError) {
callbacks.onDeletePrivateStory(model.story, model.isSelected)
}
)
}
private fun presentPrivacyMode(privacyMode: DistributionListPrivacyMode): String {
return when (privacyMode) {
DistributionListPrivacyMode.ONLY_WITH -> context.getString(R.string.ContactSearchItems__only_share_with)
DistributionListPrivacyMode.ALL_EXCEPT -> context.getString(R.string.ChooseInitialMyStoryMembershipFragment__all_except)
DistributionListPrivacyMode.ALL -> context.getString(R.string.ChooseInitialMyStoryMembershipFragment__all_signal_connections)
}
}
private object MyStoryFallbackAvatarProvider : AvatarImageView.FallbackAvatarProvider {
override fun getFallbackAvatar(recipient: Recipient): FallbackAvatar {
if (recipient.isSelf) {
return FallbackAvatar.Resource.Person(recipient.avatarColor)
}
return super.getFallbackAvatar(recipient)
}
}
override fun onAttachedToWindow() {
storyDisposable = storyViewState?.observeOn(AndroidSchedulers.mainThread())?.subscribe {
avatar.setStoryRingFromState(it)
when (it) {
StoryViewState.UNVIEWED -> groupStoryIndicator.isActivated = true
else -> groupStoryIndicator.isActivated = false
}
}
}
override fun onDetachedFromWindow() {
storyDisposable?.dispose()
}
}
/**
* Recipient model
*/
class RecipientModel(
val knownRecipient: ContactSearchData.KnownRecipient,
val isSelected: Boolean,
val shortSummary: Boolean
) : MappingModel<RecipientModel>, FastScrollCharacterProvider {
override fun getFastScrollCharacter(context: Context): CharSequence {
val name = if (knownRecipient.recipient.isSelf) {
context.getString(R.string.note_to_self)
} else {
knownRecipient.recipient.getDisplayName(context)
}
val letter: CharSequence = BreakIteratorCompat.getInstance()
.apply { setText(name) }
.asSequence()
.map { charSequence -> charSequence.trim { it <= ' ' } }
.filter { it.isNotEmpty() }
.mapNotNull {
when {
EmojiUtil.isEmoji(it.toString()) -> it
Character.isLetterOrDigit(it[0]) -> it[0].uppercaseChar().toString()
else -> null
}
}
.firstOrNull() ?: "#"
return letter
}
override fun areItemsTheSame(newItem: RecipientModel): Boolean {
return newItem.knownRecipient == knownRecipient
}
override fun areContentsTheSame(newItem: RecipientModel): Boolean {
return knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && isSelected == newItem.isSelected
}
override fun getChangePayload(newItem: RecipientModel): Any? {
return if (knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && newItem.isSelected != isSelected) {
0
} else {
null
}
}
}
class UnknownRecipientModel(val data: ContactSearchData.UnknownRecipient) : MappingModel<UnknownRecipientModel> {
override fun areItemsTheSame(newItem: UnknownRecipientModel): Boolean = true
override fun areContentsTheSame(newItem: UnknownRecipientModel): Boolean = data == newItem.data
}
private class UnknownRecipientViewHolder(
itemView: View,
private val onClick: ContactSearchAdapter.OnClickedCallback<ContactSearchData.UnknownRecipient>,
private val displayCheckBox: Boolean
) : MappingViewHolder<UnknownRecipientModel>(itemView) {
private val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
private val name: FromTextView = itemView.findViewById(R.id.name)
private val number: TextView = itemView.findViewById(R.id.number)
private val headerGroup: View = itemView.findViewById(R.id.contact_header)
private val headerText: TextView = itemView.findViewById(R.id.section_header)
override fun bind(model: UnknownRecipientModel) {
checkbox.visible = displayCheckBox
checkbox.isSelected = false
val nameText = when (model.data.mode) {
ContactSearchConfiguration.NewRowMode.NEW_CALL -> R.string.contact_selection_list__new_call
ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION -> -1
ContactSearchConfiguration.NewRowMode.BLOCK -> R.string.contact_selection_list__unknown_contact_block
ContactSearchConfiguration.NewRowMode.ADD_TO_GROUP -> R.string.contact_selection_list__unknown_contact_add_to_group
}
if (nameText > 0) {
name.setText(nameText)
number.text = model.data.query
number.visible = true
} else {
name.text = model.data.query
number.visible = false
}
if (model.data.mode == ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION) {
headerGroup.visible = true
headerText.setText(
if (model.data.sectionKey == ContactSearchConfiguration.SectionKey.PHONE_NUMBER) {
R.string.FindByActivity__find_by_phone_number
} else {
R.string.FindByActivity__find_by_username
}
)
} else {
headerGroup.visible = false
}
itemView.setOnClickListener {
onClick.onClicked(itemView, model.data, false)
}
}
}
private class KnownRecipientViewHolder(
itemView: View,
private val fixedContacts: Set<ContactSearchKey>,
displayOptions: ContactSearchAdapter.DisplayOptions,
onClick: ContactSearchAdapter.OnClickedCallback<ContactSearchData.KnownRecipient>,
private val onLongClick: ContactSearchAdapter.OnLongClickedCallback<ContactSearchData.KnownRecipient>,
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, displayOptions, onClick, callButtonClickCallbacks), LetterHeaderDecoration.LetterHeaderItem {
private var headerLetter: String? = null
override fun isSelected(model: RecipientModel): Boolean = model.isSelected
override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient
override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient
override fun bindNumberField(model: RecipientModel) {
val recipient = getRecipient(model)
if (model.knownRecipient.sectionKey == ContactSearchConfiguration.SectionKey.GROUP_MEMBERS) {
number.text = model.knownRecipient.groupsInCommon.toDisplayText(context, displayGroupsLimit = 2)
number.visible = true
} else if (model.shortSummary && recipient.isGroup) {
val count = recipient.participantIds.size
number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count)
number.visible = true
} else if (displayOptions.displaySecondaryInformation == ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS && recipient.combinedAboutAndEmoji != null) {
number.text = recipient.combinedAboutAndEmoji
number.visible = true
} else if (displayOptions.displaySecondaryInformation == ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS && recipient.hasE164) {
number.visible = false
} else {
super.bindNumberField(model)
}
headerLetter = model.knownRecipient.headerLetter
}
override fun bindCheckbox(model: RecipientModel) {
super.bindCheckbox(model)
if (fixedContacts.contains(model.knownRecipient.contactSearchKey)) {
checkbox.isChecked = true
}
checkbox.isEnabled = !fixedContacts.contains(model.knownRecipient.contactSearchKey)
}
override fun isEnabled(model: RecipientModel): Boolean {
return !fixedContacts.contains(model.knownRecipient.contactSearchKey)
}
override fun getHeaderLetter(): String? {
return headerLetter
}
override fun bindLongPress(model: RecipientModel) {
itemView.setOnLongClickListener { onLongClick.onLongClicked(itemView, model.knownRecipient) }
}
}
/**
* Base Recipient View Holder
*/
abstract class BaseRecipientViewHolder<T : MappingModel<T>, D : ContactSearchData>(
itemView: View,
val displayOptions: ContactSearchAdapter.DisplayOptions,
val onClick: ContactSearchAdapter.OnClickedCallback<D>,
val onCallButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
) : MappingViewHolder<T>(itemView) {
protected val avatar: AvatarImageView = itemView.findViewById(R.id.contact_photo_image)
protected val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge)
protected val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
protected val name: FromTextView = itemView.findViewById(R.id.name)
protected val number: TextView = itemView.findViewById(R.id.number)
protected val label: TextView = itemView.findViewById(R.id.label)
private val startAudio: View = itemView.findViewById(R.id.start_audio)
private val startVideo: View = itemView.findViewById(R.id.start_video)
override fun bind(model: T) {
if (isEnabled(model)) {
itemView.setOnClickListener { onClick.onClicked(avatar, getData(model), isSelected(model)) }
bindLongPress(model)
} else {
itemView.setOnClickListener(null)
}
bindCheckbox(model)
if (payload.isNotEmpty()) {
return
}
val recipient = getRecipient(model)
val suffix: CharSequence? = if (recipient.isSystemContact && !recipient.showVerified) {
SpannableStringBuilder().apply {
val drawable = context.requireDrawable(R.drawable.symbol_person_circle_24).apply {
setTint(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface))
}
SpanUtil.appendCenteredImageSpan(this, drawable, 16, 16)
}
} else {
null
}
name.setText(recipient, suffix)
badge.setBadgeFromRecipient(getRecipient(model))
bindAvatar(model)
bindNumberField(model)
bindLabelField(model)
bindCallButtons(model)
}
protected open fun bindCheckbox(model: T) {
checkbox.visible = displayOptions.displayCheckBox
checkbox.isChecked = isSelected(model)
}
protected open fun isEnabled(model: T): Boolean = true
protected open fun bindAvatar(model: T) {
avatar.setAvatar(getRecipient(model))
}
protected open fun bindNumberField(model: T) {
number.visible = getRecipient(model).isGroup
if (getRecipient(model).isGroup) {
number.text = getRecipient(model).participantIds
.take(10)
.map { id ->
val recipient = Recipient.resolved(id)
RecipientDisplayName(
recipient = recipient,
displayName = if (recipient.isSelf) {
context.getString(R.string.ConversationTitleView_you)
} else {
recipient.getShortDisplayName(context)
}
)
}
.sortedWith(compareBy({ it.recipient.isUnregistered }, { it.recipient.isSelf }, { it.displayName }))
.joinToString(", ") { it.displayName }
}
}
protected open fun bindLabelField(model: T) {
label.visible = false
}
protected open fun bindLongPress(model: T) = Unit
private fun bindCallButtons(model: T) {
val recipient = getRecipient(model)
if (displayOptions.displayCallButtons && (recipient.isPushGroup || recipient.isRegistered)) {
startVideo.visible = true
startAudio.visible = !recipient.isPushGroup
startVideo.setOnClickListener {
onCallButtonClickCallbacks.onVideoCallButtonClicked(recipient)
}
startAudio.setOnClickListener {
onCallButtonClickCallbacks.onAudioCallButtonClicked(recipient)
}
} else {
startVideo.visible = false
startAudio.visible = false
}
}
abstract fun isSelected(model: T): Boolean
abstract fun getData(model: T): D
abstract fun getRecipient(model: T): Recipient
}
/**
* Mapping Model for section headers
*/
class HeaderModel(val header: ContactSearchData.Header) : MappingModel<HeaderModel> {
override fun areItemsTheSame(newItem: HeaderModel): Boolean {
return header.sectionKey == newItem.header.sectionKey
}
override fun areContentsTheSame(newItem: HeaderModel): Boolean {
return areItemsTheSame(newItem) &&
header.action?.icon == newItem.header.action?.icon &&
header.action?.label == newItem.header.action?.label
}
}
/**
* Mapping Model for messages
*/
class MessageModel(val message: ContactSearchData.Message) : MappingModel<MessageModel> {
override fun areItemsTheSame(newItem: MessageModel): Boolean = message.contactSearchKey == newItem.message.contactSearchKey
override fun areContentsTheSame(newItem: MessageModel): Boolean {
return message == newItem.message
}
}
/**
* Mapping Model for threads
*/
class ThreadModel(val thread: ContactSearchData.Thread) : MappingModel<ThreadModel> {
override fun areItemsTheSame(newItem: ThreadModel): Boolean = thread.contactSearchKey == newItem.thread.contactSearchKey
override fun areContentsTheSame(newItem: ThreadModel): Boolean {
return thread == newItem.thread
}
}
class EmptyModel(val empty: ContactSearchData.Empty) : MappingModel<EmptyModel> {
override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
override fun areContentsTheSame(newItem: EmptyModel): Boolean = newItem.empty == empty
}
/**
* Mapping Model for [ContactSearchData.GroupWithMembers]
*/
class GroupWithMembersModel(val groupWithMembers: ContactSearchData.GroupWithMembers) : MappingModel<GroupWithMembersModel> {
override fun areContentsTheSame(newItem: GroupWithMembersModel): Boolean = newItem.groupWithMembers == groupWithMembers
override fun areItemsTheSame(newItem: GroupWithMembersModel): Boolean = newItem.groupWithMembers.contactSearchKey == groupWithMembers.contactSearchKey
}
/**
* View Holder for section headers
*/
private class HeaderViewHolder(itemView: View) : MappingViewHolder<HeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
private val headerActionView: MaterialButton = itemView.findViewById(R.id.section_header_action)
override fun bind(model: HeaderModel) {
headerTextView.setText(
when (model.header.sectionKey) {
ContactSearchConfiguration.SectionKey.STORIES -> R.string.ContactsCursorLoader_my_stories
ContactSearchConfiguration.SectionKey.RECENTS -> R.string.ContactsCursorLoader_recent_chats
ContactSearchConfiguration.SectionKey.INDIVIDUALS -> R.string.ContactsCursorLoader_contacts
ContactSearchConfiguration.SectionKey.GROUPS -> R.string.ContactsCursorLoader_groups
ContactSearchConfiguration.SectionKey.GROUP_MEMBERS -> R.string.ContactsCursorLoader_group_members
ContactSearchConfiguration.SectionKey.CHATS -> R.string.ContactsCursorLoader__chats
ContactSearchConfiguration.SectionKey.MESSAGES -> R.string.ContactsCursorLoader__messages
ContactSearchConfiguration.SectionKey.GROUPS_WITH_MEMBERS -> R.string.ContactsCursorLoader_group_members
ContactSearchConfiguration.SectionKey.CONTACTS_WITHOUT_THREADS -> R.string.ContactsCursorLoader_contacts
ContactSearchConfiguration.SectionKey.CHAT_TYPES -> R.string.ContactsCursorLoader__chat_types
else -> error("This section does not support HEADER")
}
)
if (model.header.action != null) {
headerActionView.visible = true
headerActionView.setIconResource(model.header.action.icon)
headerActionView.setText(model.header.action.label)
headerActionView.setOnClickListener { model.header.action.action.run() }
} else {
headerActionView.visible = false
}
}
}
/**
* Mapping Model for expandable content rows.
*/
class ExpandModel(val expand: ContactSearchData.Expand) : MappingModel<ExpandModel> {
override fun areItemsTheSame(newItem: ExpandModel): Boolean {
return expand.contactSearchKey == newItem.expand.contactSearchKey
}
override fun areContentsTheSame(newItem: ExpandModel): Boolean {
return areItemsTheSame(newItem)
}
}
/**
* View Holder for expandable content rows.
*/
private class ExpandViewHolder(itemView: View, private val expandListener: (ContactSearchData.Expand) -> Unit) : MappingViewHolder<ExpandModel>(itemView) {
override fun bind(model: ExpandModel) {
itemView.setOnClickListener { expandListener.invoke(model.expand) }
}
}
/**
* Mapping Model for chat types.
*/
class ChatTypeModel(val data: ContactSearchData.ChatTypeRow, val isSelected: Boolean) : MappingModel<ChatTypeModel> {
override fun areItemsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data
override fun areContentsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data && isSelected == newItem.isSelected
}
/**
* View Holder for chat types
*/
private class ChatTypeViewHolder(
itemView: View,
val onClick: ContactSearchAdapter.OnClickedCallback<ContactSearchData.ChatTypeRow>
) : MappingViewHolder<ChatTypeModel>(itemView) {
val image: ImageView = itemView.findViewById(R.id.image)
val name: TextView = itemView.findViewById(R.id.name)
val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
override fun bind(model: ChatTypeModel) {
itemView.setOnClickListener { onClick.onClicked(itemView, model.data, model.isSelected) }
image.setImageResource(model.data.imageResId)
if (model.data.chatType == ChatType.INDIVIDUAL) {
name.text = context.getString(R.string.ChatFoldersFragment__one_on_one_chats)
}
if (model.data.chatType == ChatType.GROUPS) {
name.text = context.getString(R.string.ChatFoldersFragment__groups)
}
checkbox.isChecked = model.isSelected
}
}
private data class RecipientDisplayName(val recipient: Recipient, val displayName: String)
}
@@ -7,16 +7,28 @@ package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.util.AttributeSet
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.collections.immutable.persistentHashMapOf
import kotlinx.coroutines.flow.filter
import org.signal.core.ui.compose.LocalFragmentManager
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
/**
* A Compose-compatible wrapper view for the ContactSearch framework.
@@ -33,25 +45,20 @@ class ContactSearchView : AbstractComposeView {
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
/**
* Called once with the inner [RecyclerView] after first composition.
* Java callers may implement this as a lambda: `rv -> fastScroller.setRecyclerView(rv)`.
*/
fun interface RecyclerViewReadyCallback {
fun onRecyclerViewReady(recyclerView: RecyclerView)
}
private var viewModel: ContactSearchViewModel? by mutableStateOf(null)
private var currentFragmentManager: FragmentManager? = null
private var currentDisplayOptions: ContactSearchAdapter.DisplayOptions? = null
private var currentMapStateToConfiguration: ((ContactSearchState) -> ContactSearchConfiguration)? = null
private var currentAdditionalEntries: MappingEntryProvider<Any> = persistentHashMapOf()
private var lazyListState: LazyListState? = null
private var currentCallbacks: ContactSearchCallbacks = ContactSearchCallbacks.Simple()
private var currentItemDecorations: List<RecyclerView.ItemDecoration> = emptyList()
private var currentContentBottomPadding: Dp = 0.dp
private var currentAdapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory
private var currentScrollListeners: List<RecyclerView.OnScrollListener> = emptyList()
private var recyclerView: RecyclerView? = null
private var currentOnRecyclerViewReady: RecyclerViewReadyCallback? = null
private var currentClickCallbacks: ContactSearchAdapter.ClickCallbacks? = null
private var currentLongClickCallbacks: ContactSearchAdapter.LongClickCallbacks? = null
private var currentStoryContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks? = null
private var currentCallButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks? = null
init {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
@@ -74,13 +81,9 @@ class ContactSearchView : AbstractComposeView {
* @param mapStateToConfiguration Maps the current [ContactSearchState] to the active
* [ContactSearchConfiguration], re-evaluated on every state change.
* @param callbacks Hooks for filtering and reacting to selection changes.
* @param itemDecorations [RecyclerView.ItemDecoration]s added to the internal list.
* @param contentBottomPaddingDp Extra bottom padding (in dp) so last items scroll above overlaid
* UI. Java callers pass a plain `float`.
* @param adapterFactory Factory for the adapter — swap for custom adapters.
* @param scrollListeners [RecyclerView.OnScrollListener]s attached to the inner list.
* @param onRecyclerViewReady Called once with the inner [RecyclerView] after first composition.
* Useful for attaching fast-scrollers or custom item animators.
* @param additionalEntries Extra [MappingEntryProvider] entries layered on top of the base
* set from [ContactSearchModels.composeEntries]. The base set is
* always applied; on key collisions the base entry wins.
*/
fun bind(
viewModel: ContactSearchViewModel,
@@ -88,27 +91,40 @@ class ContactSearchView : AbstractComposeView {
displayOptions: ContactSearchAdapter.DisplayOptions,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration,
callbacks: ContactSearchCallbacks = ContactSearchCallbacks.Simple(),
itemDecorations: List<RecyclerView.ItemDecoration> = emptyList(),
contentBottomPaddingDp: Float = 0f,
adapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory,
scrollListeners: List<RecyclerView.OnScrollListener> = emptyList(),
onRecyclerViewReady: RecyclerViewReadyCallback? = null
additionalEntries: MappingEntryProvider<Any> = persistentHashMapOf(),
clickCallbacks: ContactSearchAdapter.ClickCallbacks? = null,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks? = null,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks? = null,
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks? = null
) {
check(this.viewModel == null) { "ContactSearchView.bind() may only be called once" }
currentFragmentManager = fragmentManager
currentDisplayOptions = displayOptions
currentMapStateToConfiguration = mapStateToConfiguration
currentCallbacks = callbacks
currentItemDecorations = itemDecorations
currentContentBottomPadding = contentBottomPaddingDp.dp
currentAdapterFactory = adapterFactory
currentScrollListeners = scrollListeners
currentOnRecyclerViewReady = onRecyclerViewReady
currentAdditionalEntries = additionalEntries
if (clickCallbacks != null) {
currentClickCallbacks = clickCallbacks
}
if (longClickCallbacks != null) {
currentLongClickCallbacks = longClickCallbacks
}
if (storyContextMenuCallbacks != null) {
currentStoryContextMenuCallbacks = storyContextMenuCallbacks
}
if (callButtonClickCallbacks != null) {
currentCallButtonClickCallbacks = callButtonClickCallbacks
}
this.viewModel = viewModel // triggers recomposition
}
override fun canScrollVertically(direction: Int): Boolean {
return recyclerView?.canScrollVertically(direction) ?: super.canScrollVertically(direction)
return lazyListState?.canScrollVertically(direction) ?: super.canScrollVertically(direction)
}
@Composable
@@ -117,21 +133,40 @@ class ContactSearchView : AbstractComposeView {
val displayOptions = currentDisplayOptions ?: return
val mapStateToConfiguration = currentMapStateToConfiguration ?: return
ContactSearch(
viewModel = vm,
mapStateToConfiguration = mapStateToConfiguration,
displayOptions = displayOptions,
callbacks = currentCallbacks,
storyFragmentManager = currentFragmentManager,
onListCommitted = { currentCallbacks.onAdapterListCommitted(it) },
itemDecorations = currentItemDecorations,
contentBottomPadding = currentContentBottomPadding,
adapterFactory = currentAdapterFactory,
scrollListeners = currentScrollListeners,
onRecyclerViewReady = RecyclerViewReadyCallback { recyclerView ->
this@ContactSearchView.recyclerView = recyclerView
currentOnRecyclerViewReady?.onRecyclerViewReady(recyclerView)
}
)
lazyListState = rememberLazyListState()
val view = LocalView.current
val context = LocalContext.current
LaunchedEffect(lazyListState) {
snapshotFlow { lazyListState!!.isScrollInProgress }
.filter { it }
.collect {
ViewUtil.hideKeyboard(context, view)
}
}
CompositionLocalProvider(LocalFragmentManager provides currentFragmentManager) {
ContactSearch(
viewModel = vm,
mapStateToConfiguration = mapStateToConfiguration,
displayOptions = displayOptions,
lazyListState = lazyListState ?: rememberLazyListState(),
callbacks = currentCallbacks,
onListCommitted = { currentCallbacks.onAdapterListCommitted(it) },
additionalEntries = currentAdditionalEntries,
clickCallbacks = currentClickCallbacks ?: rememberDefaultContactSearchItemClickCallbacks(vm, currentCallbacks),
longClickCallbacks = currentLongClickCallbacks ?: rememberDefaultContactSearchItemLongClickCallbacks(),
storyContextMenuCallbacks = currentStoryContextMenuCallbacks ?: rememberDefaultContactSearchItemStoryContextMenuCallbacks(vm),
callButtonClickCallbacks = currentCallButtonClickCallbacks ?: rememberDefaultContactSearchItemCallButtonClickCallbacks(),
modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection())
)
}
}
}
private fun LazyListState.canScrollVertically(direction: Int): Boolean {
return when {
direction < 0 -> canScrollBackward
else -> canScrollForward
}
}
@@ -13,7 +13,9 @@ import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.subjects.PublishSubject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@@ -82,6 +84,13 @@ class ContactSearchViewModel(
private val internalSelectedContacts = MutableStateFlow<Set<ContactSearchKey>>(emptySet())
private val errorEvents = PublishSubject.create<ContactSearchError>()
private val rawQuery = MutableStateFlow<String?>(savedStateHandle[QUERY])
private val internalFastScrollerEnabled = MutableStateFlow(false)
private val internalDisplayingContextMenu = MutableStateFlow(false)
private val internalScrollRequests = MutableSharedFlow<ScrollRequest>(extraBufferCapacity = 1)
val fastScrollerEnabled: StateFlow<Boolean> = internalFastScrollerEnabled
val isDisplayingContextMenu: StateFlow<Boolean> = internalDisplayingContextMenu
val scrollRequests: SharedFlow<ScrollRequest> = internalScrollRequests
init {
viewModelScope.launch {
@@ -110,15 +119,30 @@ class ContactSearchViewModel(
/** Adapter-ready models combining [data] with [selectionState]. Suitable for direct submission to a [ContactSearchAdapter]. */
val mappingModels: StateFlow<MappingModelList> = combine(data, selectionState) { contactData, selection ->
ContactSearchAdapter.toMappingModelList(contactData, selection, arbitraryRepository)
ContactSearchModels.toMappingModelList(contactData, selection, arbitraryRepository)
}.stateIn(viewModelScope, SharingStarted.Eagerly, MappingModelList())
val errorEventsStream: Observable<ContactSearchError> = errorEvents
val internalTotalCount = MutableStateFlow(0)
val totalCount: StateFlow<Int> = internalTotalCount
override fun onCleared() {
disposables.clear()
}
fun setFastScrollEnabled(enabled: Boolean) {
internalFastScrollerEnabled.update { enabled }
}
fun setDisplayingContextMenu(isDisplayingContextMenu: Boolean) {
internalDisplayingContextMenu.update { isDisplayingContextMenu }
}
fun requestScrollPosition(position: Int) {
internalScrollRequests.tryEmit(ScrollRequest(position))
}
fun setConfiguration(contactSearchConfiguration: ContactSearchConfiguration) {
val pagedDataSource = ContactSearchPagedDataSource(
contactSearchConfiguration,
@@ -126,6 +150,7 @@ class ContactSearchViewModel(
searchRepository = searchRepository,
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository
)
internalTotalCount.value = pagedDataSource.size()
pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig)
}
@@ -227,6 +252,8 @@ class ContactSearchViewModel(
controller.value?.onDataInvalidated()
}
data class ScrollRequest(val position: Int)
class Factory(
private val selectionLimits: SelectionLimits,
private val isMultiSelect: Boolean = true,
@@ -157,8 +157,7 @@ class MultiselectForwardFragment :
Log.d(TAG, "onBeforeContactsSelected() Attempting to select: ${contactSearchKeys.map { it.toString() }}, Filtered selection: ${filtered.map { it.toString() } }")
return filtered
}
},
contentBottomPaddingDp = 44f
}
)
callback = findListener()!!
@@ -642,8 +642,8 @@ public class ConversationListFragment extends MainFragment implements Conversati
} else {
builder.arbitrary(
conversationFilterRequest.getSource() == ConversationFilterSource.DRAG
? ConversationListSearchAdapter.ChatFilterOptions.WITHOUT_TIP.getCode()
: ConversationListSearchAdapter.ChatFilterOptions.WITH_TIP.getCode()
? ConversationListSearchModels.ChatFilterOptions.WITHOUT_TIP.getCode()
: ConversationListSearchModels.ChatFilterOptions.WITH_TIP.getCode()
);
}
@@ -2,22 +2,18 @@ package org.thoughtcrime.securesms.conversationlist
import android.content.Context
import android.view.View
import android.widget.TextView
import androidx.core.os.bundleOf
import androidx.lifecycle.LifecycleOwner
import com.bumptech.glide.RequestManager
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.conversationlist.ConversationListSearchModels.ChatFilterEmptyMappingModel
import org.thoughtcrime.securesms.conversationlist.ConversationListSearchModels.ChatFilterMappingModel
import org.thoughtcrime.securesms.conversationlist.ConversationListSearchModels.ChatFilterOptions
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
/**
* Adapter for ConversationList search. Adds factories to render ThreadModel and MessageModel using ConversationListItem,
@@ -35,177 +31,16 @@ class ConversationListSearchAdapter(
requestManager: RequestManager
) : ContactSearchAdapter(context, fixedContacts, displayOptions, onClickedCallbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks), TimestampPayloadSupport {
companion object {
private const val PAYLOAD_TIMESTAMP = 0
}
init {
registerFactory(
ThreadModel::class.java,
LayoutFactory({ ThreadViewHolder(onClickedCallbacks::onThreadClicked, onClickedCallbacks::onThreadLongClicked, lifecycleOwner, requestManager, it) }, R.layout.conversation_list_item_view)
)
registerFactory(
MessageModel::class.java,
LayoutFactory({ MessageViewHolder(onClickedCallbacks::onMessageClicked, lifecycleOwner, requestManager, it) }, R.layout.conversation_list_item_view)
)
registerFactory(
ChatFilterMappingModel::class.java,
LayoutFactory({ ChatFilterViewHolder(it, onClickedCallbacks::onClearFilterClicked) }, R.layout.conversation_list_item_clear_filter)
)
registerFactory(
ChatFilterEmptyMappingModel::class.java,
LayoutFactory({ ChatFilterViewHolder(it, onClickedCallbacks::onClearFilterClicked) }, R.layout.conversation_list_item_clear_filter_empty)
)
registerFactory(
EmptyModel::class.java,
LayoutFactory({ EmptyViewHolder(it) }, R.layout.conversation_list_empty_search_state)
)
registerFactory(
GroupWithMembersModel::class.java,
LayoutFactory({ GroupWithMembersViewHolder(onClickedCallbacks::onGroupWithMembersClicked, lifecycleOwner, requestManager, it) }, R.layout.conversation_list_item_view)
)
ConversationListSearchModels.registerThreads(this, onClickedCallbacks::onThreadClicked, onClickedCallbacks::onThreadLongClicked, lifecycleOwner, requestManager)
ConversationListSearchModels.registerMessages(this, onClickedCallbacks::onMessageClicked, lifecycleOwner, requestManager)
ConversationListSearchModels.registerGroupsWithMembers(this, onClickedCallbacks::onGroupWithMembersClicked, lifecycleOwner, requestManager)
ConversationListSearchModels.registerEmpty(this)
ConversationListSearchModels.registerChatFilters(this, onClickedCallbacks::onClearFilterClicked)
}
override fun notifyTimestampPayloadUpdate() {
notifyItemRangeChanged(0, itemCount, PAYLOAD_TIMESTAMP)
}
private abstract class ConversationListItemViewHolder<M : MappingModel<M>>(
itemView: View
) : MappingViewHolder<M>(itemView) {
private val conversationListItem: ConversationListItem = itemView as ConversationListItem
override fun bind(model: M) {
if (payload.contains(PAYLOAD_TIMESTAMP)) {
conversationListItem.updateTimestamp()
return
}
fullBind(model)
}
abstract fun fullBind(model: M)
}
private class EmptyViewHolder(
itemView: View
) : MappingViewHolder<EmptyModel>(itemView) {
private val noResults = itemView.findViewById<TextView>(R.id.search_no_results)
override fun bind(model: EmptyModel) {
if (payload.isNotEmpty()) {
return
}
noResults.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class ThreadViewHolder(
private val threadListener: OnClickedCallback<ContactSearchData.Thread>,
private val threadLongClickListener: (View, ContactSearchData.Thread) -> Boolean,
private val lifecycleOwner: LifecycleOwner,
private val requestManager: RequestManager,
itemView: View
) : ConversationListItemViewHolder<ThreadModel>(itemView) {
override fun fullBind(model: ThreadModel) {
itemView.setOnClickListener {
threadListener.onClicked(itemView, model.thread, false)
}
itemView.setOnLongClickListener {
threadLongClickListener(itemView, model.thread)
}
(itemView as ConversationListItem).bindThread(
lifecycleOwner,
model.thread.threadRecord,
requestManager,
Locale.getDefault(),
emptySet(),
ConversationSet(),
model.thread.query,
true,
false,
0
)
}
}
private class MessageViewHolder(
private val messageListener: OnClickedCallback<ContactSearchData.Message>,
private val lifecycleOwner: LifecycleOwner,
private val requestManager: RequestManager,
itemView: View
) : ConversationListItemViewHolder<MessageModel>(itemView) {
override fun fullBind(model: MessageModel) {
itemView.setOnClickListener {
messageListener.onClicked(itemView, model.message, false)
}
(itemView as ConversationListItem).bindMessage(
lifecycleOwner,
model.message.messageResult,
requestManager,
Locale.getDefault(),
model.message.query
)
}
}
private class GroupWithMembersViewHolder(
private val groupWithMembersListener: OnClickedCallback<ContactSearchData.GroupWithMembers>,
private val lifecycleOwner: LifecycleOwner,
private val requestManager: RequestManager,
itemView: View
) : ConversationListItemViewHolder<GroupWithMembersModel>(itemView) {
override fun fullBind(model: GroupWithMembersModel) {
itemView.setOnClickListener {
groupWithMembersListener.onClicked(itemView, model.groupWithMembers, false)
}
(itemView as ConversationListItem).bindGroupWithMembers(
lifecycleOwner,
model.groupWithMembers,
requestManager,
Locale.getDefault()
)
}
}
private open class BaseChatFilterMappingModel<T : BaseChatFilterMappingModel<T>>(val options: ChatFilterOptions) : MappingModel<T> {
override fun areItemsTheSame(newItem: T): Boolean = true
override fun areContentsTheSame(newItem: T): Boolean = options == newItem.options
}
private class ChatFilterMappingModel(options: ChatFilterOptions) : BaseChatFilterMappingModel<ChatFilterMappingModel>(options)
private class ChatFilterEmptyMappingModel(options: ChatFilterOptions) : BaseChatFilterMappingModel<ChatFilterEmptyMappingModel>(options)
private class ChatFilterViewHolder<T : BaseChatFilterMappingModel<T>>(itemView: View, listener: () -> Unit) : MappingViewHolder<T>(itemView) {
private val tip = itemView.findViewById<View>(R.id.clear_filter_tip)
init {
itemView.findViewById<View>(R.id.clear_filter).setOnClickListener { listener() }
}
override fun bind(model: T) {
tip.visible = model.options == ChatFilterOptions.WITH_TIP
}
}
enum class ChatFilterOptions(val code: String) {
WITH_TIP("with-tip"),
WITHOUT_TIP("without-tip");
companion object {
fun fromCode(code: String): ChatFilterOptions {
return entries.firstOrNull { it.code == code } ?: WITHOUT_TIP
}
}
notifyItemRangeChanged(0, itemCount, ConversationListSearchModels.PAYLOAD_TIMESTAMP)
}
class ChatFilterRepository : ArbitraryRepository {
@@ -0,0 +1,284 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversationlist
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import androidx.lifecycle.LifecycleOwner
import com.bumptech.glide.RequestManager
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels.EmptyModel
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels.GroupWithMembersModel
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels.MessageModel
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels.ThreadModel
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProvider
import org.thoughtcrime.securesms.util.adapter.mapping.compose.MappingEntryProviderBuilder
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
/**
* Holds the [MappingModel]s and [MappingViewHolder]s used by [ConversationListSearchAdapter] on top of the
* base set in [org.thoughtcrime.securesms.contacts.paged.ContactSearchModels], along with helpers for
* registering them on a [MappingAdapter] (RecyclerView) or building a [MappingEntryProvider] (Compose).
*/
object ConversationListSearchModels {
const val PAYLOAD_TIMESTAMP = 0
fun registerThreads(
mappingAdapter: MappingAdapter,
onClicked: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Thread>,
onLongClicked: (View, ContactSearchData.Thread) -> Boolean,
lifecycleOwner: LifecycleOwner,
requestManager: RequestManager
) {
mappingAdapter.registerFactory(
ThreadModel::class.java,
LayoutFactory({ ThreadViewHolder(onClicked, onLongClicked, lifecycleOwner, requestManager, it) }, R.layout.conversation_list_item_view)
)
}
fun registerMessages(
mappingAdapter: MappingAdapter,
onClicked: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Message>,
lifecycleOwner: LifecycleOwner,
requestManager: RequestManager
) {
mappingAdapter.registerFactory(
MessageModel::class.java,
LayoutFactory({ MessageViewHolder(onClicked, lifecycleOwner, requestManager, it) }, R.layout.conversation_list_item_view)
)
}
fun registerGroupsWithMembers(
mappingAdapter: MappingAdapter,
onClicked: ContactSearchAdapter.OnClickedCallback<ContactSearchData.GroupWithMembers>,
lifecycleOwner: LifecycleOwner,
requestManager: RequestManager
) {
mappingAdapter.registerFactory(
GroupWithMembersModel::class.java,
LayoutFactory({ GroupWithMembersViewHolder(onClicked, lifecycleOwner, requestManager, it) }, R.layout.conversation_list_item_view)
)
}
fun registerEmpty(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(
EmptyModel::class.java,
LayoutFactory({ EmptyViewHolder(it) }, R.layout.conversation_list_empty_search_state)
)
}
fun registerChatFilters(mappingAdapter: MappingAdapter, onClearFilterClicked: () -> Unit) {
mappingAdapter.registerFactory(
ChatFilterMappingModel::class.java,
LayoutFactory({ ChatFilterViewHolder(it, onClearFilterClicked) }, R.layout.conversation_list_item_clear_filter)
)
mappingAdapter.registerFactory(
ChatFilterEmptyMappingModel::class.java,
LayoutFactory({ ChatFilterViewHolder(it, onClearFilterClicked) }, R.layout.conversation_list_item_clear_filter_empty)
)
}
/**
* Returns a [MappingEntryProvider] containing the same set of view holders registered by the
* adapter-side `register*` methods, suitable for use with a Compose `MappingLazyColumn`.
*/
fun composeEntries(
onThreadClicked: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Thread>,
onThreadLongClicked: (View, ContactSearchData.Thread) -> Boolean,
onMessageClicked: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Message>,
onGroupWithMembersClicked: ContactSearchAdapter.OnClickedCallback<ContactSearchData.GroupWithMembers>,
onClearFilterClicked: () -> Unit,
lifecycleOwner: LifecycleOwner,
requestManager: RequestManager
): MappingEntryProvider<Any> {
return MappingEntryProviderBuilder<Any>().apply {
viewHolder<ThreadModel> { ctx ->
LayoutFactory(
{ view -> ThreadViewHolder(onThreadClicked, onThreadLongClicked, lifecycleOwner, requestManager, view) },
R.layout.conversation_list_item_view
).createViewHolder(FrameLayout(ctx))
}
viewHolder<MessageModel> { ctx ->
LayoutFactory(
{ view -> MessageViewHolder(onMessageClicked, lifecycleOwner, requestManager, view) },
R.layout.conversation_list_item_view
).createViewHolder(FrameLayout(ctx))
}
viewHolder<GroupWithMembersModel> { ctx ->
LayoutFactory(
{ view -> GroupWithMembersViewHolder(onGroupWithMembersClicked, lifecycleOwner, requestManager, view) },
R.layout.conversation_list_item_view
).createViewHolder(FrameLayout(ctx))
}
viewHolder<EmptyModel> { ctx ->
LayoutFactory(
{ view -> EmptyViewHolder(view) },
R.layout.conversation_list_empty_search_state
).createViewHolder(FrameLayout(ctx))
}
viewHolder<ChatFilterMappingModel> { ctx ->
LayoutFactory(
{ view -> ChatFilterViewHolder<ChatFilterMappingModel>(view, onClearFilterClicked) },
R.layout.conversation_list_item_clear_filter
).createViewHolder(FrameLayout(ctx))
}
viewHolder<ChatFilterEmptyMappingModel> { ctx ->
LayoutFactory(
{ view -> ChatFilterViewHolder<ChatFilterEmptyMappingModel>(view, onClearFilterClicked) },
R.layout.conversation_list_item_clear_filter_empty
).createViewHolder(FrameLayout(ctx))
}
}.build()
}
enum class ChatFilterOptions(val code: String) {
WITH_TIP("with-tip"),
WITHOUT_TIP("without-tip");
companion object {
fun fromCode(code: String): ChatFilterOptions {
return entries.firstOrNull { it.code == code } ?: WITHOUT_TIP
}
}
}
open class BaseChatFilterMappingModel<T : BaseChatFilterMappingModel<T>>(val options: ChatFilterOptions) : MappingModel<T> {
override fun areItemsTheSame(newItem: T): Boolean = true
override fun areContentsTheSame(newItem: T): Boolean = options == newItem.options
}
class ChatFilterMappingModel(options: ChatFilterOptions) : BaseChatFilterMappingModel<ChatFilterMappingModel>(options)
class ChatFilterEmptyMappingModel(options: ChatFilterOptions) : BaseChatFilterMappingModel<ChatFilterEmptyMappingModel>(options)
private abstract class ConversationListItemViewHolder<M : MappingModel<M>>(
itemView: View
) : MappingViewHolder<M>(itemView) {
private val conversationListItem: ConversationListItem = itemView as ConversationListItem
override fun bind(model: M) {
if (payload.contains(PAYLOAD_TIMESTAMP)) {
conversationListItem.updateTimestamp()
return
}
fullBind(model)
}
abstract fun fullBind(model: M)
}
private class EmptyViewHolder(
itemView: View
) : MappingViewHolder<EmptyModel>(itemView) {
private val noResults = itemView.findViewById<TextView>(R.id.search_no_results)
override fun bind(model: EmptyModel) {
if (payload.isNotEmpty()) {
return
}
noResults.text = context.getString(R.string.SearchFragment_no_results, model.empty.query ?: "")
}
}
private class ThreadViewHolder(
private val threadListener: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Thread>,
private val threadLongClickListener: (View, ContactSearchData.Thread) -> Boolean,
private val lifecycleOwner: LifecycleOwner,
private val requestManager: RequestManager,
itemView: View
) : ConversationListItemViewHolder<ThreadModel>(itemView) {
override fun fullBind(model: ThreadModel) {
itemView.setOnClickListener {
threadListener.onClicked(itemView, model.thread, false)
}
itemView.setOnLongClickListener {
threadLongClickListener(itemView, model.thread)
}
(itemView as ConversationListItem).bindThread(
lifecycleOwner,
model.thread.threadRecord,
requestManager,
Locale.getDefault(),
emptySet(),
ConversationSet(),
model.thread.query,
true,
false,
0
)
}
}
private class MessageViewHolder(
private val messageListener: ContactSearchAdapter.OnClickedCallback<ContactSearchData.Message>,
private val lifecycleOwner: LifecycleOwner,
private val requestManager: RequestManager,
itemView: View
) : ConversationListItemViewHolder<MessageModel>(itemView) {
override fun fullBind(model: MessageModel) {
itemView.setOnClickListener {
messageListener.onClicked(itemView, model.message, false)
}
(itemView as ConversationListItem).bindMessage(
lifecycleOwner,
model.message.messageResult,
requestManager,
Locale.getDefault(),
model.message.query
)
}
}
private class GroupWithMembersViewHolder(
private val groupWithMembersListener: ContactSearchAdapter.OnClickedCallback<ContactSearchData.GroupWithMembers>,
private val lifecycleOwner: LifecycleOwner,
private val requestManager: RequestManager,
itemView: View
) : ConversationListItemViewHolder<GroupWithMembersModel>(itemView) {
override fun fullBind(model: GroupWithMembersModel) {
itemView.setOnClickListener {
groupWithMembersListener.onClicked(itemView, model.groupWithMembers, false)
}
(itemView as ConversationListItem).bindGroupWithMembers(
lifecycleOwner,
model.groupWithMembers,
requestManager,
Locale.getDefault()
)
}
}
private class ChatFilterViewHolder<T : BaseChatFilterMappingModel<T>>(itemView: View, listener: () -> Unit) : MappingViewHolder<T>(itemView) {
private val tip = itemView.findViewById<View>(R.id.clear_filter_tip)
init {
itemView.findViewById<View>(R.id.clear_filter).setOnClickListener { listener() }
}
override fun bind(model: T) {
tip.visible = model.options == ChatFilterOptions.WITH_TIP
}
}
}
@@ -103,8 +103,7 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(
)
)
}
},
contentBottomPaddingDp = 44f
}
)
viewLifecycleOwner.lifecycleScope.launch {
@@ -32,7 +32,7 @@ import java.util.Optional;
import java.util.function.Consumer;
public class PaymentRecipientSelectionFragment extends LoggingFragment implements ContactSelectionListFragment.OnContactSelectedListener, ContactSelectionListFragment.ScrollCallback {
public class PaymentRecipientSelectionFragment extends LoggingFragment implements ContactSelectionListFragment.OnContactSelectedListener {
private Toolbar toolbar;
private ContactFilterView contactFilterView;
@@ -90,11 +90,6 @@ public class PaymentRecipientSelectionFragment extends LoggingFragment implement
public void onSelectionChanged() {
}
@Override
public void onBeginScroll() {
hideKeyboard();
}
private void hideKeyboard() {
ViewUtil.hideKeyboard(requireContext(), toolbar);
toolbar.clearFocus();
@@ -32,7 +32,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.fragment.compose.rememberFragmentState
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -295,22 +294,21 @@ private fun ContactSelectionListFragment.setUpCallbacks(
}
})
fragment.setOnItemLongClickListener { anchorView, contactSearchKey, recyclerView ->
fragment.setOnItemLongClickListener { anchorView, contactSearchKey, setIsDisplayingContextMenu ->
if (callbacks.contextMenu != null) {
coroutineScope.launch { showItemContextMenu(anchorView, contactSearchKey, recyclerView, callbacks.contextMenu) }
coroutineScope.launch { showItemContextMenu(anchorView, contactSearchKey, setIsDisplayingContextMenu, callbacks.contextMenu) }
true
}
return@setOnItemLongClickListener false
}
fragment.setOnRefreshListener { callbacks.refresh?.onRefresh() }
fragment.setScrollCallback { clearFocus() }
}
private suspend fun showItemContextMenu(
anchorView: View,
contactSearchKey: ContactSearchKey,
recyclerView: RecyclerView,
setIsDisplayingContextMenu: Consumer<Boolean>,
callbacks: RecipientPickerCallbacks.ContextMenu
) {
val context = anchorView.context
@@ -373,10 +371,10 @@ private suspend fun showItemContextMenu(
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
.offsetX(DimensionUnit.DP.toPixels(12f).toInt())
.offsetY(DimensionUnit.DP.toPixels(12f).toInt())
.onDismiss { recyclerView.suppressLayout(false) }
.onDismiss { setIsDisplayingContextMenu.accept(false) }
.show(actions)
recyclerView.suppressLayout(true)
setIsDisplayingContextMenu.accept(true)
}
@DayNightPreviews
@@ -8,7 +8,6 @@ import androidx.fragment.app.viewModels
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.WrapperDialogFragment
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository
@@ -47,8 +46,7 @@ class ViewAllSignalConnectionsFragment : Fragment(R.layout.view_all_signal_conne
displayCheckBox = false,
displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER
),
mapStateToConfiguration = { getConfiguration() },
itemDecorations = listOf(LetterHeaderDecoration(requireContext()) { false })
mapStateToConfiguration = { getConfiguration() }
)
}
@@ -16,8 +16,8 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchModels
import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet
@@ -68,7 +68,7 @@ class StoriesPrivacySettingsFragment :
}
@Suppress("UNCHECKED_CAST")
ContactSearchAdapter.registerStoryItems(
ContactSearchModels.registerStoryItems(
mappingAdapter = middle as PagingMappingAdapter<ContactSearchKey>,
storyListener = { _, story, _ ->
when {
@@ -142,7 +142,7 @@ class StoriesPrivacySettingsFragment :
private fun getMiddleConfiguration(state: StoriesPrivacySettingsState): DSLConfiguration {
return if (state.areStoriesEnabled) {
configure {
ContactSearchAdapter.toMappingModelList(
ContactSearchModels.toMappingModelList(
state.storyContactItems,
emptySet(),
null
@@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -50,13 +51,15 @@ typealias MappingEntryProvider<T> = PersistentMap<Class<out T>, MappingEntry<out
@Composable
fun <T : Any> MappingLazyColumn(
controller: MappingLazyListController<T>,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState(),
userScrollEnabled: Boolean = true
) {
val lazyListState = rememberLazyListState()
val items = controller.items
LazyColumn(
state = lazyListState,
userScrollEnabled = userScrollEnabled,
modifier = modifier
) {
insertProvidedItems(items, controller.entryProvider, controller.placeholder)
@@ -68,9 +71,9 @@ fun <T : Any> MappingLazyColumn(
@Composable
fun <T : Any> MappingLazyRow(
controller: MappingLazyListController<T>,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
lazyListState: LazyListState = rememberLazyListState()
) {
val lazyListState = rememberLazyListState()
val items = controller.items
LazyRow(
@@ -97,7 +100,7 @@ private fun <T : Any> LazyListScope.insertProvidedItems(
} else {
@Suppress("UNCHECKED_CAST")
val entry = provider[model.javaClass] as MappingEntry<T>
entry.key?.invoke(model) ?: index
entry.key?.invoke(model) ?: model.javaClass
}
}
) { _, model ->
@@ -178,6 +181,10 @@ class MappingEntryProviderBuilder<T : Any> {
map[R::class.java] = MappingEntry(key = key) { model -> content(model) }
}
inline fun <reified R : T> provider(entryProvider: MappingEntryProvider<R>) {
map.putAll(entryProvider)
}
inline fun <reified R : T> viewHolder(noinline key: ((R) -> Any)? = null, crossinline createViewHolder: (Context) -> MappingViewHolder<R>) {
entry<R>(key = key, content = { model ->
var viewHolder: MappingViewHolder<R>? by remember { mutableStateOf(null) }
@@ -206,12 +213,13 @@ class MappingLazyListController<T : Any>(
val placeholder: @Composable () -> Unit = { Spacer(Modifier.height(100.dp)) }
) {
private var proxyController = ProxyPagingController<T>()
private val proxyController = ProxyPagingController<Any>()
var pagingController: PagingController<T>
var pagingController: PagingController<*>
get() = proxyController
set(value) {
proxyController.set(value)
@Suppress("UNCHECKED_CAST")
proxyController.set(value as PagingController<Any>)
}
var items: List<T?> by mutableStateOf(emptyList())
@@ -33,36 +33,6 @@
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<org.thoughtcrime.securesms.components.RecyclerViewFastScroller
android:id="@+id/fast_scroller"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_gravity="end"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/chipRecycler"
tools:visibility="visible" />
<com.google.android.material.button.MaterialButton
android:id="@+id/header_action"
style="@style/Widget.Signal.Button.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginEnd="16dp"
android:ellipsize="end"
android:fontFamily="sans-serif-medium"
android:maxLines="1"
android:textColor="@color/signal_text_primary"
android:visibility="gone"
app:backgroundTint="@color/signal_colorSurface1"
app:iconTint="@color/signal_colorOnSurfaceVariant"
app:layout_constraintEnd_toEndOf="@id/swipe_refresh"
app:layout_constraintTop_toTopOf="@id/swipe_refresh"
tools:text="@string/ContactsCursorLoader_new_story"
tools:visibility="visible" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chipRecycler"
android:layout_width="match_parent"
@@ -0,0 +1,357 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.DraggableState
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
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.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.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
private val THUMB_PADDING = 16.dp
private val THUMB_HEIGHT = 48.dp
private val THUMB_WIDTH = 12.dp
private val TEARDROP_SIZE = 72.dp
private val TEARDROP_PADDING = 16.dp
/**
* Wraps a LazyColumn with a fast-scroller that runs along the 'end' of the content.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LazyColumnFastScroller(
fastScrollerState: FastScrollerState,
lazyListState: LazyListState = rememberLazyListState(),
modifier: Modifier = Modifier,
letterContent: @Composable (CharSequence) -> Unit = {
Text(text = it.toString(), style = MaterialTheme.typography.headlineLarge)
},
enabled: Boolean = true,
userScrollEnabled: Boolean = true,
content: @Composable BoxScope.(LazyListState) -> Unit
) {
val context = LocalContext.current
BoxWithConstraints(
modifier = modifier
) {
val maxHeight = maxHeight
val progress by rememberScrollProgress(lazyListState, totalCount = fastScrollerState.totalCount)
var isDragInProgress by remember { mutableStateOf(false) }
val dragState = rememberDragState(
maxHeight = maxHeight,
thumbHeight = THUMB_HEIGHT,
totalCount = fastScrollerState.totalCount,
isDragInProgress = isDragInProgress,
lazyListState = lazyListState
)
val character = remember(progress, fastScrollerState.totalCount) {
val targetIndex = (fastScrollerState.totalCount * progress).roundToInt()
val clampedIndex = targetIndex.coerceIn(0, (fastScrollerState.totalCount - 1).coerceAtLeast(0))
fastScrollerState.getFastScrollCharacter(context, clampedIndex)
}
content(lazyListState)
if (!enabled) {
return@BoxWithConstraints
}
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.width(TEARDROP_SIZE + TEARDROP_PADDING * 2)
.fillMaxHeight()
.padding(vertical = THUMB_PADDING)
) {
AnimatedVisibility(
visible = isDragInProgress,
enter = fadeIn(),
exit = fadeOut()
) {
LetterTeardrop(
letter = character,
hardCorner = if (progress > 0.5f) HardCorner.BOTTOM else HardCorner.TOP,
content = letterContent,
modifier = Modifier.offset {
val maxTravelPx = (maxHeight - (THUMB_PADDING * 2) - TEARDROP_SIZE).toPx()
val yOffset = (maxTravelPx * progress).roundToInt()
IntOffset(0, yOffset)
}
)
}
val view = LocalView.current
Box(
modifier = Modifier
.offset {
val maxTravelPx = (maxHeight - (THUMB_PADDING * 2) - THUMB_HEIGHT).toPx()
val yOffset = (maxTravelPx * progress).roundToInt()
IntOffset(0, yOffset)
}
.width(48.dp)
.height(THUMB_HEIGHT)
.pointerInput(dragState, userScrollEnabled) {
if (!userScrollEnabled) return@pointerInput
awaitPointerEventScope {
while (true) {
val down = awaitFirstDown(requireUnconsumed = false)
view.parent?.requestDisallowInterceptTouchEvent(true)
isDragInProgress = true
val dragPointerId = down.id
while (true) {
val event = awaitPointerEvent()
val dragEvent = event.changes.firstOrNull { it.id == dragPointerId }
if (dragEvent == null || dragEvent.isConsumed || !dragEvent.pressed) {
break
}
view.parent?.requestDisallowInterceptTouchEvent(true)
val delta = dragEvent.positionChange().y
dragState.dispatchRawDelta(delta)
dragEvent.consume()
}
isDragInProgress = false
view.parent?.requestDisallowInterceptTouchEvent(false)
}
}
}
.align(Alignment.TopEnd)
.padding(start = 24.dp, end = THUMB_PADDING)
.background(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(50)
)
)
}
}
}
@Composable
private fun LetterTeardrop(
letter: CharSequence,
hardCorner: HardCorner,
modifier: Modifier = Modifier,
content: @Composable (CharSequence) -> Unit = {
Text(text = letter.toString(), style = MaterialTheme.typography.headlineLarge)
}
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.size(72.dp)
.background(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(
topStartPercent = 50,
topEndPercent = if (hardCorner == HardCorner.TOP) 0 else 50,
bottomStartPercent = 50,
bottomEndPercent = if (hardCorner == HardCorner.BOTTOM) 0 else 50
)
)
) {
content(letter)
}
}
@Immutable
data class FastScrollerState(
val totalCount: Int,
private val items: List<Any>
) {
fun getFastScrollCharacter(context: Context, index: Int): CharSequence {
val model = items.getOrNull(index)
return if (model is FastScrollCharacterProvider) {
model.getFastScrollCharacter(context)
} else {
" "
}
}
}
interface FastScrollCharacterProvider {
fun getFastScrollCharacter(context: Context): CharSequence
}
private enum class HardCorner {
TOP, BOTTOM
}
@DayNightPreviews
@Composable
private fun FastScrollerPreview() {
val charPool = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".toList()
val items = remember {
(1 until 100).map {
FastScrollTestContainer(charPool.shuffled().take(5).joinToString(""))
}.sortedBy { it.word }
}
val fastScrollerState = remember {
FastScrollerState(100, items)
}
Previews.Preview {
LazyColumnFastScroller(
fastScrollerState = fastScrollerState,
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = it
) {
items(items) { item ->
Rows.TextRow(text = item.word)
}
}
}
}
}
private data class FastScrollTestContainer(
val word: String
) : FastScrollCharacterProvider {
override fun getFastScrollCharacter(context: Context): CharSequence {
return word.first().toString()
}
}
@DayNightPreviews
@Composable
private fun LetterTeardropTopPreview() {
Previews.Preview {
LetterTeardrop("A", hardCorner = HardCorner.TOP, modifier = Modifier.padding(16.dp))
}
}
@DayNightPreviews
@Composable
private fun LetterTeardropBottomPreview() {
Previews.Preview {
LetterTeardrop("A", hardCorner = HardCorner.BOTTOM, modifier = Modifier.padding(16.dp))
}
}
@Composable
private fun rememberScrollProgress(listState: LazyListState, totalCount: Int): State<Float> {
return remember(listState, totalCount) {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (totalCount <= 0 || visibleItemsInfo.isEmpty()) {
0f
} else {
val firstVisibleIndex = listState.firstVisibleItemIndex
val firstItem = visibleItemsInfo.first()
val firstItemOffsetFraction = if (firstItem.size > 0) {
listState.firstVisibleItemScrollOffset.toFloat() / firstItem.size
} else {
0f
}
val currentSmoothIndex = firstVisibleIndex + firstItemOffsetFraction
val maxPossibleTopIndex = (totalCount - visibleItemsInfo.size).coerceAtLeast(1)
(currentSmoothIndex / maxPossibleTopIndex).coerceIn(0f, 1f)
}
}
}
}
@Composable
private fun rememberDragState(
maxHeight: Dp,
thumbHeight: Dp,
totalCount: Int,
isDragInProgress: Boolean,
lazyListState: LazyListState
): DraggableState {
val density = LocalDensity.current
val coroutineScope = rememberCoroutineScope()
var accumulatedProgress by remember { mutableFloatStateOf(0f) }
LaunchedEffect(lazyListState.isScrollInProgress, isDragInProgress) {
if (!lazyListState.isScrollInProgress && !isDragInProgress && totalCount > 0) {
val visibleCount = lazyListState.layoutInfo.visibleItemsInfo.size
val maxIndex = (totalCount - visibleCount).coerceAtLeast(1)
accumulatedProgress = lazyListState.firstVisibleItemIndex.toFloat() / maxIndex
}
}
return rememberDraggableState { deltaPixels ->
if (totalCount <= 0) return@rememberDraggableState
val maxThumbTravelPx = with(density) { (maxHeight - thumbHeight).toPx() }
if (maxThumbTravelPx <= 0f) return@rememberDraggableState
val deltaProgress = deltaPixels / maxThumbTravelPx
accumulatedProgress = (accumulatedProgress + deltaProgress).coerceIn(0f, 1f)
val visibleCount = lazyListState.layoutInfo.visibleItemsInfo.size
val maxTargetIndex = (totalCount - visibleCount).coerceAtLeast(0)
val targetIndex = (accumulatedProgress * maxTargetIndex).toInt()
coroutineScope.launch {
lazyListState.scrollToItem(targetIndex)
}
}
}