diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java index 825b354213..ae205121a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java @@ -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, String number, @NonNull Optional chatType) {} - @Override - public void onBeginScroll() { - hideKeyboard(); - } - - private void hideKeyboard() { - ServiceUtil.getInputMethodManager(this) - .hideSoftInputFromWindow(toolbar.getWindowToken(), 0); - toolbar.clearFocus(); - } - private static class RefreshDirectoryTask extends AsyncTask { private final WeakReference activity; diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListAdapter.kt index f2dd6eec76..b7d020bccc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListAdapter.kt @@ -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 { - override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true - override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true - } - - class InviteToSignalModel : MappingModel { - override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true - override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true - } - - class RefreshContactsModel : MappingModel { - override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true - override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true - } - - class FindContactsModel : MappingModel { - override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true - override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true - } - - class FindContactsBannerModel : MappingModel { - override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true - override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true - } - - class FindByUsernameModel : MappingModel { - override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true - override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true - } - - class FindByPhoneNumberModel : MappingModel { - override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true - override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true - } - - class MoreHeaderModel : MappingModel { - override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true - - override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true - } - - private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder(itemView) { - init { - itemView.setOnClickListener { onClickListener() } - } - - override fun bind(model: InviteToSignalModel) = Unit - } - - private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder(itemView) { - init { - itemView.setOnClickListener { onClickListener() } - } - - override fun bind(model: NewGroupModel) = Unit - } - - private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder(itemView) { - init { - itemView.setOnClickListener { onClickListener() } - } - - override fun bind(model: RefreshContactsModel) = Unit - } - - private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder(itemView) { - init { - itemView.setOnClickListener { onClickListener() } - } - - override fun bind(model: FindContactsModel) = Unit - } - - private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder(itemView) { - init { - itemView.findViewById(R.id.no_thanks_button).setOnClickListener { onDismissListener() } - itemView.findViewById(R.id.allow_contacts_button).setOnClickListener { onClickListener() } - } - - override fun bind(model: FindContactsBannerModel) = Unit - } - - private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder(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(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(itemView) { - - init { - itemView.setOnClickListener { onClickListener() } - } - - override fun bind(model: FindByPhoneNumberModel) = Unit - } - - private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder(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() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index a9783aa12e..855454bcf8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -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 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 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 setIsDisplayingContextMenu); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListModels.kt b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListModels.kt new file mode 100644 index 0000000000..c6ac3255a2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListModels.kt @@ -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 { + return MappingEntryProviderBuilder().apply { + viewHolder { context -> + LayoutFactory( + { view -> NewGroupViewHolder(view, callback::onNewGroupClicked) }, + R.layout.contact_selection_new_group_item + ).createViewHolder(FrameLayout(context)) + } + viewHolder { context -> + LayoutFactory( + { view -> InviteToSignalViewHolder(view, callback::onInviteToSignalClicked) }, + R.layout.contact_selection_invite_action_item + ).createViewHolder(FrameLayout(context)) + } + viewHolder { context -> + LayoutFactory( + { view -> FindContactsViewHolder(view, callback::onFindContactsClicked) }, + R.layout.contact_selection_find_contacts_item + ).createViewHolder(FrameLayout(context)) + } + viewHolder { context -> + LayoutFactory( + { view -> FindContactsBannerViewHolder(view, callback::onDismissFindContactsBannerClicked, callback::onFindContactsClicked) }, + R.layout.contact_selection_find_contacts_banner_item + ).createViewHolder(FrameLayout(context)) + } + viewHolder { context -> + LayoutFactory( + { view -> RefreshContactsViewHolder(view, callback::onRefreshContactsClicked) }, + R.layout.contact_selection_refresh_action_item + ).createViewHolder(FrameLayout(context)) + } + viewHolder { context -> + LayoutFactory( + { view -> MoreHeaderViewHolder(view) }, + R.layout.contact_search_section_header + ).createViewHolder(FrameLayout(context)) + } + viewHolder { context -> + LayoutFactory( + { view -> EmptyViewHolder(view) }, + R.layout.contact_selection_empty_state + ).createViewHolder(FrameLayout(context)) + } + viewHolder { context -> + LayoutFactory( + { view -> FindByUsernameViewHolder(view, callback::onFindByUsernameClicked) }, + R.layout.contact_selection_find_by_username_item + ).createViewHolder(FrameLayout(context)) + } + viewHolder { 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 { + override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true + override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true + } + + class InviteToSignalModel : MappingModel { + override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true + override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true + } + + class RefreshContactsModel : MappingModel { + override fun areItemsTheSame(newItem: RefreshContactsModel): Boolean = true + override fun areContentsTheSame(newItem: RefreshContactsModel): Boolean = true + } + + class FindContactsModel : MappingModel { + override fun areItemsTheSame(newItem: FindContactsModel): Boolean = true + override fun areContentsTheSame(newItem: FindContactsModel): Boolean = true + } + + class FindContactsBannerModel : MappingModel { + override fun areItemsTheSame(newItem: FindContactsBannerModel): Boolean = true + override fun areContentsTheSame(newItem: FindContactsBannerModel): Boolean = true + } + + class FindByUsernameModel : MappingModel { + override fun areItemsTheSame(newItem: FindByUsernameModel): Boolean = true + override fun areContentsTheSame(newItem: FindByUsernameModel): Boolean = true + } + + class FindByPhoneNumberModel : MappingModel { + override fun areItemsTheSame(newItem: FindByPhoneNumberModel): Boolean = true + override fun areContentsTheSame(newItem: FindByPhoneNumberModel): Boolean = true + } + + class MoreHeaderModel : MappingModel { + override fun areItemsTheSame(newItem: MoreHeaderModel): Boolean = true + + override fun areContentsTheSame(newItem: MoreHeaderModel): Boolean = true + } + + private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder(itemView) { + init { + itemView.setOnClickListener { onClickListener() } + } + + override fun bind(model: InviteToSignalModel) = Unit + } + + private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder(itemView) { + init { + itemView.setOnClickListener { onClickListener() } + } + + override fun bind(model: NewGroupModel) = Unit + } + + private class RefreshContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder(itemView) { + init { + itemView.setOnClickListener { onClickListener() } + } + + override fun bind(model: RefreshContactsModel) = Unit + } + + private class FindContactsViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder(itemView) { + init { + itemView.setOnClickListener { onClickListener() } + } + + override fun bind(model: FindContactsModel) = Unit + } + + private class FindContactsBannerViewHolder(itemView: View, onDismissListener: () -> Unit, onClickListener: () -> Unit) : MappingViewHolder(itemView) { + init { + itemView.findViewById(R.id.no_thanks_button).setOnClickListener { onDismissListener() } + itemView.findViewById(R.id.allow_contacts_button).setOnClickListener { onClickListener() } + } + + override fun bind(model: FindContactsBannerModel) = Unit + } + + private class MoreHeaderViewHolder(itemView: View) : MappingViewHolder(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(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(itemView) { + + init { + itemView.setOnClickListener { onClickListener() } + } + + override fun bind(model: FindByPhoneNumberModel) = Unit + } + + private class FindByUsernameViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder(itemView) { + + init { + itemView.setOnClickListener { onClickListener() } + } + + override fun bind(model: FindByUsernameModel) = Unit + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearch.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearch.kt index c4d5fcba56..936877c159 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearch.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearch.kt @@ -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 = emptyList(), - contentBottomPadding: Dp = 0.dp, - adapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory, - scrollListeners: List = emptyList(), - onRecyclerViewReady: RecyclerViewReadyCallback? = null + additionalEntries: MappingEntryProvider = 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, + displayOptions: ContactSearchAdapter.DisplayOptions, + callbacks: ContactSearchAdapter.ClickCallbacks, + longClickCallbacks: ContactSearchAdapter.LongClickCallbacks, + storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks?, + callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks +): MappingEntryProvider { + return rememberMappingEntryProvider { + // Subclass-registered models (Message, Thread, Empty, GroupWithMembers) and + // ArbitraryRepository-backed models are handled separately. + provider( + 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, - private val fragmentManager: State + 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, - private val context: State + 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))) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt index a4c8efc265..c2ee072717 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt @@ -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(), 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, - 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, - displayOptions: DisplayOptions, - recipientListener: OnClickedCallback, - recipientLongClickCallback: OnLongClickedCallback, - 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) { - mappingAdapter.registerFactory( - ChatTypeModel::class.java, - LayoutFactory({ ChatTypeViewHolder(it, chatTypeRowListener) }, R.layout.contact_search_chat_type_item) - ) - } - - fun toMappingModelList(contactSearchData: List, selection: Set, 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 { - - 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, - private val storyContextMenuCallbacks: StoryContextMenuCallbacks?, - private val showStoryRing: Boolean = false - ) : MappingViewHolder(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? = 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 = 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 { - 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 { - 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 { - 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, 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 { - 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, - private val displayCheckBox: Boolean - ) : MappingViewHolder(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, - displayOptions: DisplayOptions, - onClick: OnClickedCallback, - private val onLongClick: OnLongClickedCallback, - callButtonClickCallbacks: CallButtonClickCallbacks - ) : BaseRecipientViewHolder(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, D : ContactSearchData>( - itemView: View, - val displayOptions: DisplayOptions, - val onClick: OnClickedCallback, - val onCallButtonClickCallbacks: CallButtonClickCallbacks - ) : MappingViewHolder(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 { - 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 { - 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 { - 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 { - 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 { - 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(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 { - 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(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 { - 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 - ) : MappingViewHolder(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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt index dc5d2a3ef3..cfc294e8ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt @@ -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 = 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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchModels.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchModels.kt new file mode 100644 index 0000000000..94485a1123 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchModels.kt @@ -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, + 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, + displayOptions: ContactSearchAdapter.DisplayOptions, + recipientListener: ContactSearchAdapter.OnClickedCallback, + recipientLongClickCallback: ContactSearchAdapter.OnLongClickedCallback, + 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, + 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) { + 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, + displayOptions: ContactSearchAdapter.DisplayOptions, + callbacks: ContactSearchAdapter.ClickCallbacks, + longClickCallbacks: ContactSearchAdapter.LongClickCallbacks, + storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks? = null, + callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks + ): MappingEntryProvider { + return MappingEntryProviderBuilder().apply { + viewHolder { ctx -> + LayoutFactory( + { view -> StoryViewHolder(view, displayOptions.displayCheckBox, callbacks::onStoryClicked, storyContextMenuCallbacks, displayOptions.displayStoryRing) }, + R.layout.contact_search_story_item + ).createViewHolder(FrameLayout(ctx)) + } + entry( + 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? 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 { ctx -> + LayoutFactory( + { view -> UnknownRecipientViewHolder(view, callbacks::onUnknownRecipientClicked, displayOptions.displayCheckBox) }, + R.layout.contact_search_unknown_item + ).createViewHolder(FrameLayout(ctx)) + } + viewHolder( + key = { model -> "HEADER${model.header.sectionKey}" } + ) { ctx -> + LayoutFactory( + { view -> HeaderViewHolder(view) }, + R.layout.contact_search_section_header + ).createViewHolder(FrameLayout(ctx)) + } + viewHolder( + key = { model -> "EXPAND${model.expand.sectionKey}" } + ) { ctx -> + LayoutFactory( + { view -> ExpandViewHolder(view, callbacks::onExpandClicked) }, + R.layout.contacts_expand_item + ).createViewHolder(FrameLayout(ctx)) + } + viewHolder( + 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, selection: Set, 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 { + + 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, + private val storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks?, + private val showStoryRing: Boolean = false + ) : MappingViewHolder(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? = 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 = 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 { + 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 { + 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 { + 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, 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 { + 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, + private val displayCheckBox: Boolean + ) : MappingViewHolder(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, + displayOptions: ContactSearchAdapter.DisplayOptions, + onClick: ContactSearchAdapter.OnClickedCallback, + private val onLongClick: ContactSearchAdapter.OnLongClickedCallback, + callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks + ) : BaseRecipientViewHolder(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, D : ContactSearchData>( + itemView: View, + val displayOptions: ContactSearchAdapter.DisplayOptions, + val onClick: ContactSearchAdapter.OnClickedCallback, + val onCallButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks + ) : MappingViewHolder(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 { + 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 { + 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 { + 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 { + 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 { + 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(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 { + 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(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 { + 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 + ) : MappingViewHolder(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) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchView.kt index 27d5830d98..3bac888f31 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchView.kt @@ -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 = persistentHashMapOf() + private var lazyListState: LazyListState? = null + private var currentCallbacks: ContactSearchCallbacks = ContactSearchCallbacks.Simple() - private var currentItemDecorations: List = emptyList() - private var currentContentBottomPadding: Dp = 0.dp - private var currentAdapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory - private var currentScrollListeners: List = 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 = emptyList(), - contentBottomPaddingDp: Float = 0f, - adapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory, - scrollListeners: List = emptyList(), - onRecyclerViewReady: RecyclerViewReadyCallback? = null + additionalEntries: MappingEntryProvider = 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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt index c37ea14925..7722d84a44 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt @@ -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>(emptySet()) private val errorEvents = PublishSubject.create() private val rawQuery = MutableStateFlow(savedStateHandle[QUERY]) + private val internalFastScrollerEnabled = MutableStateFlow(false) + private val internalDisplayingContextMenu = MutableStateFlow(false) + private val internalScrollRequests = MutableSharedFlow(extraBufferCapacity = 1) + + val fastScrollerEnabled: StateFlow = internalFastScrollerEnabled + val isDisplayingContextMenu: StateFlow = internalDisplayingContextMenu + val scrollRequests: SharedFlow = 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 = combine(data, selectionState) { contactData, selection -> - ContactSearchAdapter.toMappingModelList(contactData, selection, arbitraryRepository) + ContactSearchModels.toMappingModelList(contactData, selection, arbitraryRepository) }.stateIn(viewModelScope, SharingStarted.Eagerly, MappingModelList()) val errorEventsStream: Observable = errorEvents + val internalTotalCount = MutableStateFlow(0) + val totalCount: StateFlow = 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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt index 97deb26d10..9ff29bd4cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt @@ -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()!! diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 74710bf035..7adde86f4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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() ); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.kt index ef360d16fb..4130358ca3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchAdapter.kt @@ -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>( - itemView: View - ) : MappingViewHolder(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(itemView) { - - private val noResults = itemView.findViewById(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, - private val threadLongClickListener: (View, ContactSearchData.Thread) -> Boolean, - private val lifecycleOwner: LifecycleOwner, - private val requestManager: RequestManager, - itemView: View - ) : ConversationListItemViewHolder(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, - private val lifecycleOwner: LifecycleOwner, - private val requestManager: RequestManager, - itemView: View - ) : ConversationListItemViewHolder(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, - private val lifecycleOwner: LifecycleOwner, - private val requestManager: RequestManager, - itemView: View - ) : ConversationListItemViewHolder(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>(val options: ChatFilterOptions) : MappingModel { - override fun areItemsTheSame(newItem: T): Boolean = true - - override fun areContentsTheSame(newItem: T): Boolean = options == newItem.options - } - - private class ChatFilterMappingModel(options: ChatFilterOptions) : BaseChatFilterMappingModel(options) - - private class ChatFilterEmptyMappingModel(options: ChatFilterOptions) : BaseChatFilterMappingModel(options) - - private class ChatFilterViewHolder>(itemView: View, listener: () -> Unit) : MappingViewHolder(itemView) { - - private val tip = itemView.findViewById(R.id.clear_filter_tip) - - init { - itemView.findViewById(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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchModels.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchModels.kt new file mode 100644 index 0000000000..30ff571934 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListSearchModels.kt @@ -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, + 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, + 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, + 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, + onThreadLongClicked: (View, ContactSearchData.Thread) -> Boolean, + onMessageClicked: ContactSearchAdapter.OnClickedCallback, + onGroupWithMembersClicked: ContactSearchAdapter.OnClickedCallback, + onClearFilterClicked: () -> Unit, + lifecycleOwner: LifecycleOwner, + requestManager: RequestManager + ): MappingEntryProvider { + return MappingEntryProviderBuilder().apply { + viewHolder { ctx -> + LayoutFactory( + { view -> ThreadViewHolder(onThreadClicked, onThreadLongClicked, lifecycleOwner, requestManager, view) }, + R.layout.conversation_list_item_view + ).createViewHolder(FrameLayout(ctx)) + } + viewHolder { ctx -> + LayoutFactory( + { view -> MessageViewHolder(onMessageClicked, lifecycleOwner, requestManager, view) }, + R.layout.conversation_list_item_view + ).createViewHolder(FrameLayout(ctx)) + } + viewHolder { ctx -> + LayoutFactory( + { view -> GroupWithMembersViewHolder(onGroupWithMembersClicked, lifecycleOwner, requestManager, view) }, + R.layout.conversation_list_item_view + ).createViewHolder(FrameLayout(ctx)) + } + viewHolder { ctx -> + LayoutFactory( + { view -> EmptyViewHolder(view) }, + R.layout.conversation_list_empty_search_state + ).createViewHolder(FrameLayout(ctx)) + } + viewHolder { ctx -> + LayoutFactory( + { view -> ChatFilterViewHolder(view, onClearFilterClicked) }, + R.layout.conversation_list_item_clear_filter + ).createViewHolder(FrameLayout(ctx)) + } + viewHolder { ctx -> + LayoutFactory( + { view -> ChatFilterViewHolder(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>(val options: ChatFilterOptions) : MappingModel { + override fun areItemsTheSame(newItem: T): Boolean = true + + override fun areContentsTheSame(newItem: T): Boolean = options == newItem.options + } + + class ChatFilterMappingModel(options: ChatFilterOptions) : BaseChatFilterMappingModel(options) + + class ChatFilterEmptyMappingModel(options: ChatFilterOptions) : BaseChatFilterMappingModel(options) + + private abstract class ConversationListItemViewHolder>( + itemView: View + ) : MappingViewHolder(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(itemView) { + + private val noResults = itemView.findViewById(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, + private val threadLongClickListener: (View, ContactSearchData.Thread) -> Boolean, + private val lifecycleOwner: LifecycleOwner, + private val requestManager: RequestManager, + itemView: View + ) : ConversationListItemViewHolder(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, + private val lifecycleOwner: LifecycleOwner, + private val requestManager: RequestManager, + itemView: View + ) : ConversationListItemViewHolder(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, + private val lifecycleOwner: LifecycleOwner, + private val requestManager: RequestManager, + itemView: View + ) : ConversationListItemViewHolder(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>(itemView: View, listener: () -> Unit) : MappingViewHolder(itemView) { + + private val tip = itemView.findViewById(R.id.clear_filter_tip) + + init { + itemView.findViewById(R.id.clear_filter).setOnClickListener { listener() } + } + + override fun bind(model: T) { + tip.visible = model.options == ChatFilterOptions.WITH_TIP + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt index 9474a1322b..874abe2e23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt @@ -103,8 +103,7 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment( ) ) } - }, - contentBottomPaddingDp = 44f + } ) viewLifecycleOwner.lifecycleScope.launch { diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java index add01ff48a..39ef048c83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPicker.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPicker.kt index 7f082b3cc9..d2063a6db8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPicker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPicker.kt @@ -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, 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt index db9cbe5b55..ecc17ffbf7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt @@ -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() } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt index 713683668d..37f2d2e894 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt @@ -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, 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/compose/MappingLazyList.kt b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/compose/MappingLazyList.kt index 291b74852b..4846f0d689 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/compose/MappingLazyList.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/mapping/compose/MappingLazyList.kt @@ -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 = PersistentMap, MappingEntry MappingLazyColumn( controller: MappingLazyListController, - 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 MappingLazyColumn( @Composable fun MappingLazyRow( controller: MappingLazyListController, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + lazyListState: LazyListState = rememberLazyListState() ) { - val lazyListState = rememberLazyListState() val items = controller.items LazyRow( @@ -97,7 +100,7 @@ private fun LazyListScope.insertProvidedItems( } else { @Suppress("UNCHECKED_CAST") val entry = provider[model.javaClass] as MappingEntry - entry.key?.invoke(model) ?: index + entry.key?.invoke(model) ?: model.javaClass } } ) { _, model -> @@ -178,6 +181,10 @@ class MappingEntryProviderBuilder { map[R::class.java] = MappingEntry(key = key) { model -> content(model) } } + inline fun provider(entryProvider: MappingEntryProvider) { + map.putAll(entryProvider) + } + inline fun viewHolder(noinline key: ((R) -> Any)? = null, crossinline createViewHolder: (Context) -> MappingViewHolder) { entry(key = key, content = { model -> var viewHolder: MappingViewHolder? by remember { mutableStateOf(null) } @@ -206,12 +213,13 @@ class MappingLazyListController( val placeholder: @Composable () -> Unit = { Spacer(Modifier.height(100.dp)) } ) { - private var proxyController = ProxyPagingController() + private val proxyController = ProxyPagingController() - var pagingController: PagingController + var pagingController: PagingController<*> get() = proxyController set(value) { - proxyController.set(value) + @Suppress("UNCHECKED_CAST") + proxyController.set(value as PagingController) } var items: List by mutableStateOf(emptyList()) diff --git a/app/src/main/res/layout/contact_selection_list_fragment.xml b/app/src/main/res/layout/contact_selection_list_fragment.xml index 84b33ded4f..03bff8762a 100644 --- a/app/src/main/res/layout/contact_selection_list_fragment.xml +++ b/app/src/main/res/layout/contact_selection_list_fragment.xml @@ -33,36 +33,6 @@ - - - - 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 +) { + 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 { + 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) + } + } +}