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