From 77a18111e19d0b6d98c32232337cb2e6bac6cc99 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 17 Apr 2026 14:31:24 -0300 Subject: [PATCH] Convert search mediator to compose / viewmodel pattern. --- .../ContactSelectionListFragment.java | 360 +++++++++--------- .../securesms/contacts/paged/ContactSearch.kt | 262 +++++++++++++ .../contacts/paged/ContactSearchAdapter.kt | 31 ++ .../contacts/paged/ContactSearchCallbacks.kt | 49 +++ .../paged/ContactSearchConfiguration.kt | 2 +- .../contacts/paged/ContactSearchMediator.kt | 289 -------------- .../contacts/paged/ContactSearchView.kt | 129 +++++++ .../contacts/paged/ContactSearchViewModel.kt | 128 +++++-- .../forward/MultiselectForwardFragment.kt | 100 +++-- .../ConversationListFragment.java | 90 ++--- .../v2/stories/ChooseGroupStoryBottomSheet.kt | 74 ++-- .../ViewAllSignalConnectionsFragment.kt | 30 +- .../contact_selection_list_fragment.xml | 8 +- .../layout/multiselect_forward_fragment.xml | 8 +- .../stories_choose_group_bottom_sheet.xml | 8 +- .../view_all_signal_connections_fragment.xml | 7 +- 16 files changed, 937 insertions(+), 638 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearch.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchCallbacks.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchView.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index 967d7779ce..a9783aa12e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -45,6 +45,9 @@ 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.signal.core.util.concurrent.LifecycleDisposable; @@ -60,10 +63,12 @@ import org.thoughtcrime.securesms.contacts.SelectedContact; import org.thoughtcrime.securesms.contacts.SelectedContacts; import org.thoughtcrime.securesms.contacts.paged.ChatType; import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter; +import org.thoughtcrime.securesms.contacts.paged.ContactSearchCallbacks; 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.contacts.paged.ContactSearchMediator; +import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository; +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.selection.ContactSelectionArguments; @@ -74,6 +79,7 @@ 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; @@ -86,6 +92,7 @@ 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; @@ -118,7 +125,7 @@ public final class ContactSelectionListFragment extends LoggingFragment { private OnContactSelectedListener onContactSelectedListener; private SwipeRefreshLayout swipeRefresh; private String cursorFilter; - private RecyclerView recyclerView; + private ContactSearchView contactSearchView; private RecyclerViewFastScroller fastScroller; private RecyclerView chipRecycler; private OnSelectionLimitReachedListener onSelectionLimitReachedListener; @@ -127,8 +134,10 @@ public final class ContactSelectionListFragment extends LoggingFragment { private LifecycleDisposable lifecycleDisposable; private HeaderActionProvider headerActionProvider; private TextView headerActionView; - private ContactSearchMediator contactSearchMediator; + private ContactSearchViewModel contactSearchViewModel; + @Nullable private RecyclerView innerRecyclerView; + @Nullable private LinearLayoutManager innerLayoutManager; @Nullable private NewConversationCallback newConversationCallback; @Nullable private FindByCallback findByCallback; @Nullable private NewCallCallback newCallCallback; @@ -239,7 +248,7 @@ public final class ContactSelectionListFragment extends LoggingFragment { handleContactPermissionGranted(); } else { requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); - contactSearchMediator.refresh(); + contactSearchViewModel.refresh(); } } @@ -247,29 +256,14 @@ public final class ContactSelectionListFragment extends LoggingFragment { public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false); - emptyText = view.findViewById(android.R.id.empty); - recyclerView = view.findViewById(R.id.recycler_view); - swipeRefresh = view.findViewById(R.id.swipe_refresh); + 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); - final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext()); - - recyclerView.setLayoutManager(layoutManager); - recyclerView.setItemAnimator(new DefaultItemAnimator() { - @Override - public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) { - return true; - } - - @Override - public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) { - recyclerView.setAlpha(1f); - } - }); - contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class); contactChipAdapter = new MappingAdapter(); lifecycleDisposable = new LifecycleDisposable(); @@ -284,12 +278,6 @@ public final class ContactSelectionListFragment extends LoggingFragment { fragmentArgs = ContactSelectionArguments.fromBundle(safeArguments(), requireActivity().getIntent()); - if (fragmentArgs.getRecyclerPadBottom() != -1) { - ViewUtil.setPaddingBottom(recyclerView, fragmentArgs.getRecyclerPadBottom()); - } - - recyclerView.setClipToPadding(fragmentArgs.getRecyclerChildClipping()); - swipeRefresh.setNestedScrollingEnabled(fragmentArgs.isRefreshable()); swipeRefresh.setEnabled(fragmentArgs.isRefreshable()); @@ -303,6 +291,26 @@ public final class ContactSelectionListFragment extends LoggingFragment { currentSelection = getCurrentSelection(); + Set fixedContacts = currentSelection.stream() + .map(r -> new ContactSearchKey.RecipientSearchKey(r, false)) + .collect(Collectors.toSet()); + + contactSearchViewModel = new ViewModelProvider( + this, + new ContactSearchViewModel.Factory( + selectionLimit, + isMulti, + new ContactSearchRepository(), + false, + new ContactSelectionListAdapter.ArbitraryRepository(), + new SearchRepository(requireContext().getString(R.string.note_to_self)), + new ContactSearchPagedDataSourceRepository(requireContext()), + fixedContacts + ) + ).get(ContactSearchViewModel.class); + + List scrollListeners = new ArrayList<>(); + final HeaderAction headerAction; if (headerActionProvider != null) { headerAction = headerActionProvider.getHeaderAction(); @@ -311,24 +319,20 @@ public final class ContactSelectionListFragment extends LoggingFragment { headerActionView.setText(headerAction.getLabel()); headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0); headerActionView.setOnClickListener(v -> headerAction.getAction().run()); - recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + scrollListeners.add(new RecyclerView.OnScrollListener() { private final Rect bounds = new Rect(); @Override - public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { - } - - @Override - public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { - if (hideLetterHeaders()) { + public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) { + if (hideLetterHeaders() || innerLayoutManager == null) { return; } - int firstPosition = layoutManager.findFirstVisibleItemPosition(); + int firstPosition = innerLayoutManager.findFirstVisibleItemPosition(); if (firstPosition == 0) { - View firstChild = recyclerView.getChildAt(0); - recyclerView.getDecoratedBoundsWithMargins(firstChild, bounds); + View firstChild = rv.getChildAt(0); + rv.getDecoratedBoundsWithMargins(firstChild, bounds); headerActionView.setTranslationY(bounds.top); } } @@ -337,13 +341,104 @@ public final class ContactSelectionListFragment extends LoggingFragment { headerActionView.setEnabled(false); } - contactSearchMediator = new ContactSearchMediator( - this, - currentSelection.stream() - .map(r -> new ContactSearchKey.RecipientSearchKey(r, false)) - .collect(Collectors.toSet()), - selectionLimit, - isMulti, + 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(), new ContactSearchAdapter.DisplayOptions( isMulti, ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS, @@ -351,94 +446,31 @@ public final class ContactSelectionListFragment extends LoggingFragment { false ), this::mapStateToConfiguration, - new ContactSearchMediator.SimpleCallbacks() { + new ContactSearchCallbacks.Simple() { @Override public void onAdapterListCommitted(int size) { onLoadFinished(size); } }, - false, - (context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) -> new ContactSelectionListAdapter( - context, - fixedContacts, - displayOptions, - new ContactSelectionListAdapter.OnContactSelectionClick() { - @Override - public void onDismissFindContactsBannerClicked() { - SignalStore.uiHints().markDismissedContactsPermissionBanner(); - contactSearchMediator.refresh(); - } + 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; + } - @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() - - ), - new ContactSelectionListAdapter.ArbitraryRepository() + @Override + public void onAnimationFinished(@NonNull RecyclerView.ViewHolder viewHolder) { + contactSearchView.setAlpha(1f); + } + }); + } ); return view; @@ -461,30 +493,30 @@ public final class ContactSelectionListFragment extends LoggingFragment { } public @NonNull List getSelectedContacts() { - if (contactSearchMediator == null) { + if (contactSearchViewModel == null) { return Collections.emptyList(); } - return contactSearchMediator.getSelectedContacts() - .stream() - .map(ContactSearchKey::requireSelectedContact) - .collect(Collectors.toList()); + return contactSearchViewModel.getSelectedContacts() + .stream() + .map(ContactSearchKey::requireSelectedContact) + .collect(Collectors.toList()); } public int getSelectedContactsCount() { - if (contactSearchMediator == null) { + if (contactSearchViewModel == null) { return 0; } - return contactSearchMediator.getSelectedContacts().size(); + return contactSearchViewModel.getSelectedContacts().size(); } public int getTotalMemberCount() { - if (contactSearchMediator == null) { + if (contactSearchViewModel == null) { return 0; } - return getSelectedContactsCount() + contactSearchMediator.getFixedContactsSize(); + return getSelectedContactsCount() + contactSearchViewModel.getFixedContactsSize(); } private Set getCurrentSelection() { @@ -500,36 +532,23 @@ public final class ContactSelectionListFragment extends LoggingFragment { .request(Manifest.permission.WRITE_CONTACTS, Manifest.permission.READ_CONTACTS) .ifNecessary() .onAllGranted(() -> { - recyclerView.setAlpha(0.5f); + contactSearchView.setAlpha(0.5f); if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) { handleContactPermissionGranted(); } else { - contactSearchMediator.refresh(); + contactSearchViewModel.refresh(); if (onRefreshListener != null) { swipeRefresh.setRefreshing(true); onRefreshListener.onRefresh(); } } }) - .onAnyDenied(() -> contactSearchMediator.refresh()) + .onAnyDenied(() -> contactSearchViewModel.refresh()) .withPermanentDenialDialog(getString(R.string.ContactSelectionListFragment_signal_requires_the_contacts_permission_in_order_to_display_your_contacts), null, R.string.ContactSelectionListFragment_allow_access_contacts, R.string.ContactSelectionListFragment_to_find_people, getParentFragmentManager()) .execute(); } private void initializeCursor() { - recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders)); - recyclerView.setAdapter(contactSearchMediator.getAdapter()); - recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { - @Override - public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { - if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { - if (scrollCallback != null) { - scrollCallback.onBeginScroll(); - } - } - } - }); - if (onContactSelectedListener != null) { onContactSelectedListener.onSelectionChanged(); } @@ -547,7 +566,7 @@ public final class ContactSelectionListFragment extends LoggingFragment { this.resetPositionOnCommit = true; this.cursorFilter = filter; - contactSearchMediator.onFilterChanged(filter); + contactSearchViewModel.setQuery(filter); } public void resetQueryFilter() { @@ -558,7 +577,7 @@ public final class ContactSelectionListFragment extends LoggingFragment { public void onDataRefreshed() { this.resetPositionOnCommit = true; swipeRefresh.setRefreshing(false); - contactSearchMediator.refresh(); + contactSearchViewModel.refresh(); } public boolean hasQueryFilter() { @@ -574,26 +593,25 @@ public final class ContactSelectionListFragment extends LoggingFragment { } public void reset() { - contactSearchMediator.clearSelection(); - contactSearchMediator.refresh(); + contactSearchViewModel.clearSelection(); + contactSearchViewModel.refresh(); fastScroller.setVisibility(View.GONE); headerActionView.setVisibility(View.GONE); } private void onLoadFinished(int count) { - if (resetPositionOnCommit) { + if (resetPositionOnCommit && innerRecyclerView != null) { resetPositionOnCommit = false; - recyclerView.scrollToPosition(0); + innerRecyclerView.scrollToPosition(0); } swipeRefresh.setVisibility(View.VISIBLE); emptyText.setText(R.string.contact_selection_group_activity__no_contacts); boolean useFastScroller = count > 20; - recyclerView.setVerticalScrollBarEnabled(!useFastScroller); - if (useFastScroller) { + if (useFastScroller && innerRecyclerView != null) { fastScroller.setVisibility(View.VISIBLE); - fastScroller.setRecyclerView(recyclerView); + fastScroller.setRecyclerView(innerRecyclerView); } else { fastScroller.setRecyclerView(null); fastScroller.setVisibility(View.GONE); @@ -660,8 +678,8 @@ public final class ContactSelectionListFragment extends LoggingFragment { } Set toMarkSelected = contacts.stream() - .filter(r -> !contactSearchMediator.getSelectedContacts() - .contains(new ContactSearchKey.RecipientSearchKey(r, false))) + .filter(r -> !contactSearchViewModel.getSelectedContacts() + .contains(new ContactSearchKey.RecipientSearchKey(r, false))) .map(SelectedContact::forRecipientId) .collect(Collectors.toSet()); @@ -688,7 +706,7 @@ public final class ContactSelectionListFragment extends LoggingFragment { return; } - if (selectedContact.hasChatType() && !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) { + if (selectedContact.hasChatType() && !contactSearchViewModel.getSelectedContacts().contains(selectedContact.toContactSearchKey())) { if (onContactSelectedListener != null) { onContactSelectedListener.onBeforeContactSelected(true, Optional.empty(), null, Optional.of(selectedContact.getChatType()), allowed -> { if (allowed) { @@ -705,7 +723,7 @@ public final class ContactSelectionListFragment extends LoggingFragment { return; } - if (!isMulti || !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) { + if (!isMulti || !contactSearchViewModel.getSelectedContacts().contains(selectedContact.toContactSearchKey())) { if (selectionHardLimitReached()) { if (onSelectionLimitReachedListener != null) { onSelectionLimitReachedListener.onHardLimitReached(selectionLimit.getHardLimit()); @@ -772,8 +790,8 @@ public final class ContactSelectionListFragment extends LoggingFragment { } public boolean onItemLongClick(View anchorView, ContactSearchKey item) { - if (onItemLongClickListener != null) { - return onItemLongClickListener.onLongClick(anchorView, item, recyclerView); + if (onItemLongClickListener != null && innerRecyclerView != null) { + return onItemLongClickListener.onLongClick(anchorView, item, innerRecyclerView); } else { return false; } @@ -793,7 +811,7 @@ public final class ContactSelectionListFragment extends LoggingFragment { } public void markContactSelected(@NonNull SelectedContact selectedContact) { - contactSearchMediator.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey())); + contactSearchViewModel.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey())); if (isMulti) { addChipForSelectedContact(selectedContact); } @@ -803,7 +821,7 @@ public final class ContactSelectionListFragment extends LoggingFragment { } private void markContactUnselected(@NonNull SelectedContact selectedContact) { - contactSearchMediator.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey())); + contactSearchViewModel.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey())); contactChipViewModel.remove(selectedContact); if (onContactSelectedListener != null) { @@ -865,8 +883,8 @@ public final class ContactSelectionListFragment extends LoggingFragment { AutoTransition transition = new AutoTransition(); transition.setDuration(CHIP_GROUP_REVEAL_DURATION_MS); - transition.excludeChildren(recyclerView, true); - transition.excludeTarget(recyclerView, true); + transition.excludeChildren(contactSearchView, true); + transition.excludeTarget(contactSearchView, true); TransitionManager.beginDelayedTransition(constraintLayout, transition); ConstraintSet constraintSet = new ConstraintSet(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearch.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearch.kt new file mode 100644 index 0000000000..c4d5fcba56 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearch.kt @@ -0,0 +1,262 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.contacts.paged + +import android.content.Context +import android.view.View +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +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 org.signal.core.ui.compose.DayNightPreviews +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.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.signal.core.ui.R as CoreUiR + +/** + * A composable that displays a paged, selectable contact list driven by a [ContactSearchViewModel]. + * + * Intended for use in two ways: + * 1. Directly inside a Compose layout — the caller creates and holds a [ContactSearchViewModel] + * via `viewModel()` or a parent composable and passes it in. + * 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. + */ +@Composable +fun ContactSearch( + viewModel: ContactSearchViewModel, + mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration, + modifier: Modifier = Modifier, + displayOptions: ContactSearchAdapter.DisplayOptions = ContactSearchAdapter.DisplayOptions(), + callbacks: ContactSearchCallbacks = remember { ContactSearchCallbacks.Simple() }, + storyFragmentManager: FragmentManager? = null, + onListCommitted: (Int) -> Unit = {}, + itemDecorations: List = emptyList(), + contentBottomPadding: Dp = 0.dp, + adapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory, + scrollListeners: List = emptyList(), + onRecyclerViewReady: RecyclerViewReadyCallback? = null +) { + val mappingModels by viewModel.mappingModels.collectAsStateWithLifecycle() + val controller by viewModel.controller.collectAsStateWithLifecycle() + val configState by viewModel.configurationState.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) + } + } + + 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() + ) +} + +private class DefaultClickCallbacks( + private val viewModel: ContactSearchViewModel, + private val callbacks: State, + private val fragmentManager: State +) : ContactSearchAdapter.ClickCallbacks { + + companion object { + private val TAG = Log.tag(DefaultClickCallbacks::class.java) + } + + 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) } + } else { + toggle(view, story, isSelected) + } + } + + override fun onKnownRecipientClicked(view: View, knownRecipient: ContactSearchData.KnownRecipient, isSelected: Boolean) { + Log.d(TAG, "onKnownRecipientClicked()") + toggle(view, knownRecipient, isSelected) + } + + override fun onExpandClicked(expand: ContactSearchData.Expand) { + Log.d(TAG, "onExpandClicked()") + viewModel.expandSection(expand.sectionKey) + } + + override fun onChatTypeClicked(view: View, chatTypeRow: ContactSearchData.ChatTypeRow, isSelected: Boolean) { + Log.d(TAG, "onChatTypeClicked()") + if (isSelected) { + viewModel.setKeysNotSelected(setOf(chatTypeRow.contactSearchKey)) + } else { + viewModel.setKeysSelected(callbacks.value.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) + viewModel.setKeysNotSelected(setOf(data.contactSearchKey)) + } else { + Log.d(TAG, "toggle(ON) ${data.contactSearchKey}") + viewModel.setKeysSelected(callbacks.value.onBeforeContactsSelected(view, setOf(data.contactSearchKey))) + } + } +} + +private class DefaultStoryContextMenuCallbacks( + private val viewModel: ContactSearchViewModel, + private val fragmentManager: State, + private val context: State +) : ContactSearchAdapter.StoryContextMenuCallbacks { + + override fun onOpenStorySettings(story: ContactSearchData.Story) { + val fm = fragmentManager.value ?: return + if (story.recipient.isMyStory) { + MyStorySettingsFragment.createAsDialog().show(fm, null) + } else { + PrivateStorySettingsFragment.createAsDialog(story.recipient.requireDistributionListId()).show(fm, null) + } + } + + override fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean) { + fragmentManager.value ?: return + MaterialAlertDialogBuilder(context.value) + .setTitle(R.string.ContactSearchMediator__remove_group_story) + .setMessage(R.string.ContactSearchMediator__this_will_remove) + .setPositiveButton(R.string.ContactSearchMediator__remove) { _, _ -> viewModel.removeGroupStory(story) } + .setNegativeButton(android.R.string.cancel) { _, _ -> } + .show() + } + + override fun onDeletePrivateStory(story: ContactSearchData.Story, isSelected: Boolean) { + fragmentManager.value ?: return + val ctx = context.value + MaterialAlertDialogBuilder(ctx) + .setTitle(R.string.ContactSearchMediator__delete_story) + .setMessage(ctx.getString(R.string.ContactSearchMediator__delete_the_custom, story.recipient.getDisplayName(ctx))) + .setPositiveButton(SpanUtil.color(ContextCompat.getColor(ctx, CoreUiR.color.signal_colorError), ctx.getString(R.string.ContactSearchMediator__delete))) { _, _ -> viewModel.deletePrivateStory(story) } + .setNegativeButton(android.R.string.cancel) { _, _ -> } + .show() + } +} + +@DayNightPreviews +@Composable +private fun ContactSearchPreview() { + Previews.Preview { + Box(modifier = Modifier.fillMaxSize()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt index 78d6c6fb7c..992d1d8c83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt @@ -825,6 +825,37 @@ open class ContactSearchAdapter( class LongClickCallbacksAdapter : LongClickCallbacks { override fun onKnownRecipientLongClick(view: View, data: ContactSearchData.KnownRecipient): Boolean = false } + + /** + * Creates a [PagingMappingAdapter] backed by [ContactSearchAdapter] (or a subclass). + * Pass a custom implementation to inject alternative adapters for testing or specialised UIs. + */ + fun interface AdapterFactory { + fun create( + context: Context, + fixedContacts: Set, + displayOptions: DisplayOptions, + callbacks: ClickCallbacks, + longClickCallbacks: LongClickCallbacks, + storyContextMenuCallbacks: StoryContextMenuCallbacks, + callButtonClickCallbacks: CallButtonClickCallbacks + ): PagingMappingAdapter + } + + /** Standard implementation that creates a plain [ContactSearchAdapter]. */ + object DefaultAdapterFactory : AdapterFactory { + override fun create( + context: Context, + fixedContacts: Set, + displayOptions: DisplayOptions, + callbacks: ClickCallbacks, + longClickCallbacks: LongClickCallbacks, + storyContextMenuCallbacks: StoryContextMenuCallbacks, + callButtonClickCallbacks: CallButtonClickCallbacks + ): PagingMappingAdapter { + return ContactSearchAdapter(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) + } + } } private data class RecipientDisplayName(val recipient: Recipient, val displayName: String) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchCallbacks.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchCallbacks.kt new file mode 100644 index 0000000000..b24055bc71 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchCallbacks.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.contacts.paged + +import android.view.View +import org.signal.core.util.logging.Log + +/** + * Hooks for observing and intercepting contact selection changes driven by a + * [ContactSearchViewModel]. Pass an implementation to [ContactSearchView.bind] or + * [ContactSearch] to intercept selection events (e.g. apply selection limits or show + * confirmation dialogs) and to react to list commits. + */ +interface ContactSearchCallbacks { + + /** + * Called before [contactSearchKeys] are added to the selection. Return the keys that should + * actually be selected — return an empty set to cancel the entire selection, or a filtered + * subset to allow only some keys through. + */ + fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set): Set + + /** Called after [contactSearchKey] has been removed from the selection. */ + fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey) + + /** Called after each [androidx.recyclerview.widget.RecyclerView.Adapter.submitList] completes, with the committed list [size]. */ + fun onAdapterListCommitted(size: Int) + + /** No-op implementation — override only the methods you need. */ + open class Simple : ContactSearchCallbacks { + override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set): Set { + Log.d(TAG, "onBeforeContactsSelected() Selecting: ${contactSearchKeys.map { it.toString() }}") + return contactSearchKeys + } + + override fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey) { + Log.i(TAG, "onContactDeselected() Deselected: $contactSearchKey") + } + + override fun onAdapterListCommitted(size: Int) = Unit + + companion object { + private val TAG = Log.tag(Simple::class.java) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt index bc33036cbd..dc5d2a3ef3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt @@ -107,7 +107,7 @@ class ContactSearchConfiguration private constructor( /** * A set of arbitrary rows, in the order given in the builder. Usage requires - * an implementation of [ArbitraryRepository] to be passed into [ContactSearchMediator] + * an implementation of [ArbitraryRepository] to be passed into [ContactSearchViewModel.Factory] * * Key: [ContactSearchKey.Arbitrary] * Data: [ContactSearchData.Arbitrary] diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt deleted file mode 100644 index 4f50f8bacd..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt +++ /dev/null @@ -1,289 +0,0 @@ -package org.thoughtcrime.securesms.contacts.paged - -import android.content.Context -import android.view.View -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import androidx.lifecycle.LiveData -import androidx.lifecycle.ViewModelProvider -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import org.signal.core.util.logging.Log -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest -import org.thoughtcrime.securesms.groups.SelectionLimits -import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.search.SearchFilter -import org.thoughtcrime.securesms.search.SearchRepository -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.Debouncer -import org.thoughtcrime.securesms.util.SpanUtil -import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter -import org.thoughtcrime.securesms.util.livedata.LiveDataUtil -import java.util.concurrent.TimeUnit -import org.signal.core.ui.R as CoreUiR - -/** - * This mediator serves as the delegate for interacting with the ContactSearch* framework. - * - * @param fragment The fragment displaying the content search results. - * @param fixedContacts Contacts which are "pre-selected" (for example, already a member of a group we're adding to) - * @param selectionLimits [SelectionLimits] describing how large the result set can be. - * @param displayCheckBox Whether or not to display checkboxes on items. - * @param displaySecondaryInformation Whether or not to display phone numbers on known contacts. - * @param mapStateToConfiguration Maps a [ContactSearchState] to a [ContactSearchConfiguration] - * @param callbacks Hooks to help process, filter, and react to selection - * @param performSafetyNumberChecks Whether to perform safety number checks for selected users - * @param adapterFactory A factory for creating an instance of [PagingMappingAdapter] to display items - * @param arbitraryRepository A repository for managing [ContactSearchKey.Arbitrary] data - */ -class ContactSearchMediator( - private val fragment: Fragment, - private val fixedContacts: Set = setOf(), - selectionLimits: SelectionLimits, - private val isMultiSelect: Boolean = true, - displayOptions: ContactSearchAdapter.DisplayOptions, - mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration, - private val callbacks: Callbacks = SimpleCallbacks(), - performSafetyNumberChecks: Boolean = true, - adapterFactory: AdapterFactory = DefaultAdapterFactory, - arbitraryRepository: ArbitraryRepository? = null -) { - - companion object { - private val TAG = Log.tag(ContactSearchMediator::class.java) - } - - private val queryDebouncer = Debouncer(300, TimeUnit.MILLISECONDS) - - private val viewModel: ContactSearchViewModel = ViewModelProvider( - fragment, - ContactSearchViewModel.Factory( - selectionLimits = selectionLimits, - isMultiSelect = isMultiSelect, - repository = ContactSearchRepository(), - performSafetyNumberChecks = performSafetyNumberChecks, - arbitraryRepository = arbitraryRepository, - searchRepository = SearchRepository(fragment.requireContext().getString(R.string.note_to_self)), - contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(fragment.requireContext()) - ) - )[ContactSearchViewModel::class.java] - - val adapter = adapterFactory.create( - context = fragment.requireContext(), - fixedContacts = fixedContacts, - displayOptions = displayOptions, - callbacks = object : ContactSearchAdapter.ClickCallbacks { - override fun onStoryClicked(view: View, story: ContactSearchData.Story, isSelected: Boolean) { - Log.d(TAG, "onStoryClicked() Recipient: ${story.recipient.id}") - toggleStorySelection(view, story, isSelected) - } - - override fun onKnownRecipientClicked(view: View, knownRecipient: ContactSearchData.KnownRecipient, isSelected: Boolean) { - Log.d(TAG, "onKnownRecipientClicked() Recipient: ${knownRecipient.recipient.id}") - toggleSelection(view, knownRecipient, isSelected) - } - - override fun onExpandClicked(expand: ContactSearchData.Expand) { - Log.d(TAG, "onExpandClicked()") - viewModel.expandSection(expand.sectionKey) - } - - override fun onChatTypeClicked(view: View, chatTypeRow: ContactSearchData.ChatTypeRow, isSelected: Boolean) { - Log.d(TAG, "onChatTypeClicked() chatType $chatTypeRow") - toggleChatTypeSelection(view, chatTypeRow, isSelected) - } - }, - longClickCallbacks = ContactSearchAdapter.LongClickCallbacksAdapter(), - storyContextMenuCallbacks = StoryContextMenuCallbacks(), - callButtonClickCallbacks = ContactSearchAdapter.EmptyCallButtonClickCallbacks - ) - - init { - val dataAndSelection: LiveData, Set>> = LiveDataUtil.combineLatest( - viewModel.data, - viewModel.selectionState, - ::Pair - ) - - dataAndSelection.observe(fragment.viewLifecycleOwner) { (data, selection) -> - adapter.submitList(ContactSearchAdapter.toMappingModelList(data, selection, arbitraryRepository), { - callbacks.onAdapterListCommitted(data.size) - }) - } - - viewModel.controller.observe(fragment.viewLifecycleOwner) { controller -> - adapter.setPagingController(controller) - } - - viewModel.configurationState.observe(fragment.viewLifecycleOwner) { - viewModel.setConfiguration(mapStateToConfiguration(it)) - } - } - - fun onFilterChanged(filter: String?) { - queryDebouncer.publish { - viewModel.setQuery(filter) - } - } - - fun getFilter(): String? = viewModel.getQuery() - - fun onConversationFilterRequestChanged(conversationFilterRequest: ConversationFilterRequest) { - viewModel.setConversationFilterRequest(conversationFilterRequest) - } - - fun onSearchFilterChanged(searchFilter: SearchFilter) { - viewModel.setSearchFilter(searchFilter) - } - - fun setKeysSelected(keys: Set) { - Log.d(TAG, "setKeysSelected() Keys: ${keys.map { it.toString() }}") - viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(null, keys)) - } - - fun setKeysNotSelected(keys: Set) { - keys.forEach { - callbacks.onContactDeselected(null, it) - } - viewModel.setKeysNotSelected(keys) - } - - fun clearSelection() { - viewModel.clearSelection() - } - - fun getSelectedContacts(): Set { - return viewModel.getSelectedContacts() - } - - fun getFixedContactsSize(): Int { - return fixedContacts.size - } - - fun getSelectionState(): LiveData> { - return viewModel.selectionState - } - - fun getErrorEvents(): Observable { - return viewModel.errorEventsStream.observeOn(AndroidSchedulers.mainThread()) - } - - fun addToVisibleGroupStories(groupStories: Set) { - viewModel.addToVisibleGroupStories(groupStories) - } - - fun refresh() { - viewModel.refresh() - } - - private fun toggleStorySelection(view: View, contactSearchData: ContactSearchData.Story, isSelected: Boolean) { - if (contactSearchData.recipient.isMyStory && !SignalStore.story.userHasBeenNotifiedAboutStories) { - ChooseInitialMyStoryMembershipBottomSheetDialogFragment.show(fragment.childFragmentManager) - } else { - toggleSelection(view, contactSearchData, isSelected) - } - } - - private fun toggleSelection(view: View, contactSearchData: ContactSearchData, isSelected: Boolean) { - return if (isSelected) { - Log.d(TAG, "toggleSelection(OFF) ${contactSearchData.contactSearchKey}") - callbacks.onContactDeselected(view, contactSearchData.contactSearchKey) - viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey)) - } else { - Log.d(TAG, "toggleSelection(ON) ${contactSearchData.contactSearchKey}") - viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(view, setOf(contactSearchData.contactSearchKey))) - } - } - - private fun toggleChatTypeSelection(view: View, contactSearchData: ContactSearchData, isSelected: Boolean) { - return if (isSelected) { - Log.d(TAG, "toggleSelection(OFF) ${contactSearchData.contactSearchKey}") - viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey)) - } else { - Log.d(TAG, "toggleSelection(ON) ${contactSearchData.contactSearchKey}") - viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(view, setOf(contactSearchData.contactSearchKey))) - } - } - - private inner class StoryContextMenuCallbacks : ContactSearchAdapter.StoryContextMenuCallbacks { - override fun onOpenStorySettings(story: ContactSearchData.Story) { - if (story.recipient.isMyStory) { - MyStorySettingsFragment.createAsDialog() - .show(fragment.childFragmentManager, null) - } else { - PrivateStorySettingsFragment.createAsDialog(story.recipient.requireDistributionListId()) - .show(fragment.childFragmentManager, null) - } - } - - override fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean) { - MaterialAlertDialogBuilder(fragment.requireContext()) - .setTitle(R.string.ContactSearchMediator__remove_group_story) - .setMessage(R.string.ContactSearchMediator__this_will_remove) - .setPositiveButton(R.string.ContactSearchMediator__remove) { _, _ -> viewModel.removeGroupStory(story) } - .setNegativeButton(android.R.string.cancel) { _, _ -> } - .show() - } - - override fun onDeletePrivateStory(story: ContactSearchData.Story, isSelected: Boolean) { - MaterialAlertDialogBuilder(fragment.requireContext()) - .setTitle(R.string.ContactSearchMediator__delete_story) - .setMessage(fragment.getString(R.string.ContactSearchMediator__delete_the_custom, story.recipient.getDisplayName(fragment.requireContext()))) - .setPositiveButton(SpanUtil.color(ContextCompat.getColor(fragment.requireContext(), CoreUiR.color.signal_colorError), fragment.getString(R.string.ContactSearchMediator__delete))) { _, _ -> viewModel.deletePrivateStory(story) } - .setNegativeButton(android.R.string.cancel) { _, _ -> } - .show() - } - } - - interface Callbacks { - fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set): Set - fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey) - fun onAdapterListCommitted(size: Int) - } - - open class SimpleCallbacks : Callbacks { - override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set): Set { - Log.d(TAG, "onBeforeContactsSelected() Selecting: ${contactSearchKeys.map { it.toString() }}") - return contactSearchKeys - } - - override fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey) { - Log.i(TAG, "onContactDeselected() Deselected: $contactSearchKey}") - } - override fun onAdapterListCommitted(size: Int) = Unit - } - - /** - * Wraps the construction of a PagingMappingAdapter so that it can - * be swapped for another implementation, allow listeners to be wrapped, etc. - */ - fun interface AdapterFactory { - fun create( - context: Context, - fixedContacts: Set, - displayOptions: ContactSearchAdapter.DisplayOptions, - callbacks: ContactSearchAdapter.ClickCallbacks, - longClickCallbacks: ContactSearchAdapter.LongClickCallbacks, - storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks, - callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks - ): PagingMappingAdapter - } - - private object DefaultAdapterFactory : AdapterFactory { - override fun create( - context: Context, - fixedContacts: Set, - displayOptions: ContactSearchAdapter.DisplayOptions, - callbacks: ContactSearchAdapter.ClickCallbacks, - longClickCallbacks: ContactSearchAdapter.LongClickCallbacks, - storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks, - callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks - ): PagingMappingAdapter { - return ContactSearchAdapter(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchView.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchView.kt new file mode 100644 index 0000000000..72d44b6e04 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchView.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.contacts.paged + +import android.content.Context +import android.util.AttributeSet +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.AbstractComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.RecyclerView + +/** + * A Compose-compatible wrapper view for the ContactSearch framework. + * + * Usage: + * 1. Create a [ContactSearchViewModel] in the host fragment (via `viewModels { ... }` or + * `ViewModelProvider`). + * 2. Declare `` in your fragment's XML layout. + * 3. Call [bind] from `onViewCreated`, passing the ViewModel and the Fragment. + * 4. Call ViewModel methods directly for all operations, including query updates. + */ +class ContactSearchView : AbstractComposeView { + constructor(context: Context) : super(context) + 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) + } + + init { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + } + + 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 currentCallbacks: ContactSearchCallbacks = ContactSearchCallbacks.Simple() + private var currentItemDecorations: List = emptyList() + private var currentContentBottomPadding: Dp = 0.dp + private var currentAdapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory + private var currentScrollListeners: List = emptyList() + private var currentOnRecyclerViewReady: RecyclerViewReadyCallback? = null + + /** + * Configures and activates the contact search. Must be called exactly once from the host + * fragment's `onViewCreated`. The [viewModel] must be created and held by the caller so it + * can be accessed directly for selection queries and mutations. + * + * Pre-selected/fixed contacts (e.g. existing group members) are owned by the ViewModel and + * passed via [ContactSearchViewModel.Factory]. + * + * @param viewModel The externally-created ViewModel. Fixed contacts are a + * constructor parameter of [ContactSearchViewModel.Factory]. + * @param fragmentManager Used for showing story-related dialogs. Pass + * [childFragmentManager] from a Fragment or + * [supportFragmentManager] from an Activity. + * @param displayOptions Controls checkbox and secondary-info visibility. + * @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. + */ + fun bind( + viewModel: ContactSearchViewModel, + fragmentManager: FragmentManager, + displayOptions: ContactSearchAdapter.DisplayOptions, + mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration, + callbacks: ContactSearchCallbacks = ContactSearchCallbacks.Simple(), + itemDecorations: List = emptyList(), + contentBottomPaddingDp: Float = 0f, + adapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory, + scrollListeners: List = emptyList(), + onRecyclerViewReady: RecyclerViewReadyCallback? = null + ) { + 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 + this.viewModel = viewModel // triggers recomposition + } + + @Composable + override fun Content() { + val vm = viewModel ?: return + 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 = currentOnRecyclerViewReady + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt index ee6c7a59bb..5b5ebea5dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt @@ -1,45 +1,66 @@ package org.thoughtcrime.securesms.contacts.paged +import androidx.compose.runtime.Stable import androidx.lifecycle.AbstractSavedStateViewModelFactory -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.asLiveData -import androidx.lifecycle.map -import androidx.lifecycle.switchMap +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewModelScope 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.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import org.signal.paging.LivePagedData +import kotlinx.coroutines.launch import org.signal.paging.PagedData import org.signal.paging.PagingConfig import org.signal.paging.PagingController +import org.signal.paging.StateFlowPagedData import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode import org.thoughtcrime.securesms.groups.SelectionLimits import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.search.SearchFilter import org.thoughtcrime.securesms.search.SearchRepository -import org.thoughtcrime.securesms.util.livedata.Store +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList +import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter import org.whispersystems.signalservice.api.util.Preconditions /** - * Simple, reusable view model that manages a ContactSearchPagedDataSource as well as filter and expansion state. + * Manages paged contact search data, query/filter state, and contact selection. Drives + * [ContactSearch] / [ContactSearchView] and can also be used standalone via + * [bindAdapterToLifecycle] when only the data pipeline is needed (no Compose surface). + * + * Create via [Factory] and scope to the host Fragment or Activity. All state is exposed as + * [kotlinx.coroutines.flow.StateFlow] so it can be collected from Compose or coroutine scopes. + * + * @param fixedContacts Pre-selected contacts that cannot be deselected (e.g. existing group + * members). Owned here rather than by the UI layer. */ +@Stable class ContactSearchViewModel( private val savedStateHandle: SavedStateHandle, private val selectionLimits: SelectionLimits, private val isMultiSelect: Boolean, private val contactSearchRepository: ContactSearchRepository, private val performSafetyNumberChecks: Boolean, - private val arbitraryRepository: ArbitraryRepository?, + val arbitraryRepository: ArbitraryRepository?, private val searchRepository: SearchRepository, - private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository + private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository, + val fixedContacts: Set = emptySet() ) : ViewModel() { companion object { @@ -56,16 +77,41 @@ class ContactSearchViewModel( .setStartIndex(0) .build() - private val pagedData = MutableLiveData>() - private val configurationStore = Store(ContactSearchState(query = savedStateHandle[QUERY])) + private val pagedData = MutableStateFlow?>(null) + private val internalConfigurationState = MutableStateFlow(ContactSearchState(query = savedStateHandle[QUERY])) private val internalSelectedContacts = MutableStateFlow>(emptySet()) private val errorEvents = PublishSubject.create() + private val rawQuery = MutableStateFlow(savedStateHandle[QUERY]) - val controller: LiveData> = pagedData.map { it.controller } - val data: LiveData> = pagedData.switchMap { it.data } - val configurationState: LiveData = configurationStore.stateLiveData - private val selectedContacts: StateFlow> = internalSelectedContacts - val selectionState: LiveData> = selectedContacts.asLiveData() + init { + viewModelScope.launch { + rawQuery.drop(1).debounce(300).collect { query -> + savedStateHandle[QUERY] = query + internalConfigurationState.update { it.copy(query = query) } + } + } + } + + /** The paging controller for the current data source. Null until [setConfiguration] is called. */ + val controller: StateFlow?> = pagedData + .map { it?.controller } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + /** Raw paged contact data. Prefer [mappingModels] for binding to an adapter. */ + val data: StateFlow> = pagedData + .flatMapLatest { it?.data ?: flowOf(emptyList()) } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + /** The current query/filter/expansion state. Changes here trigger a new [setConfiguration] call via the Compose layer or [bindAdapterToLifecycle]. */ + val configurationState: StateFlow = internalConfigurationState + + /** Currently selected contact keys, excluding [fixedContacts]. */ + val selectionState: StateFlow> = internalSelectedContacts + + /** Adapter-ready models combining [data] with [selectionState]. Suitable for direct submission to a [ContactSearchAdapter]. */ + val mappingModels: StateFlow = combine(data, selectionState) { contactData, selection -> + ContactSearchAdapter.toMappingModelList(contactData, selection, arbitraryRepository) + }.stateIn(viewModelScope, SharingStarted.Eagerly, MappingModelList()) val errorEventsStream: Observable = errorEvents @@ -80,26 +126,25 @@ class ContactSearchViewModel( searchRepository = searchRepository, contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository ) - pagedData.value = PagedData.createForLiveData(pagedDataSource, pagingConfig) + pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig) } - fun getQuery(): String? = savedStateHandle[QUERY] + fun getQuery(): String? = rawQuery.value fun setQuery(query: String?) { - savedStateHandle[QUERY] = query - configurationStore.update { it.copy(query = query) } + rawQuery.value = query } fun setConversationFilterRequest(conversationFilterRequest: ConversationFilterRequest) { - configurationStore.update { it.copy(conversationFilterRequest = conversationFilterRequest) } + internalConfigurationState.update { it.copy(conversationFilterRequest = conversationFilterRequest) } } fun setSearchFilter(searchFilter: SearchFilter) { - configurationStore.update { it.copy(searchFilter = searchFilter) } + internalConfigurationState.update { it.copy(searchFilter = searchFilter) } } fun expandSection(sectionKey: ContactSearchConfiguration.SectionKey) { - configurationStore.update { it.copy(expandedSections = it.expandedSections + sectionKey) } + internalConfigurationState.update { it.copy(expandedSections = it.expandedSections + sectionKey) } } fun setKeysSelected(contactSearchKeys: Set) { @@ -135,7 +180,7 @@ class ContactSearchViewModel( } fun getSelectedContacts(): Set { - return selectedContacts.value + return internalSelectedContacts.value } fun clearSelection() { @@ -144,7 +189,7 @@ class ContactSearchViewModel( fun addToVisibleGroupStories(groupStories: Set) { disposables += contactSearchRepository.markDisplayAsStory(groupStories.map { it.recipientId }).subscribe { - configurationStore.update { state -> + internalConfigurationState.update { state -> state.copy( groupStories = state.groupStories + groupStories.map { val recipient = Recipient.resolved(it.recipientId) @@ -159,7 +204,7 @@ class ContactSearchViewModel( Preconditions.checkArgument(story.recipient.isGroup) setKeysNotSelected(setOf(story.contactSearchKey)) disposables += contactSearchRepository.unmarkDisplayAsStory(story.recipient.requireGroupId()).subscribe { - configurationStore.update { state -> + internalConfigurationState.update { state -> state.copy( groupStories = state.groupStories.filter { it.recipient.id == story.recipient.id }.toSet() ) @@ -176,6 +221,8 @@ class ContactSearchViewModel( } } + fun getFixedContactsSize(): Int = fixedContacts.size + fun refresh() { controller.value?.onDataInvalidated() } @@ -187,7 +234,8 @@ class ContactSearchViewModel( private val performSafetyNumberChecks: Boolean, private val arbitraryRepository: ArbitraryRepository?, private val searchRepository: SearchRepository, - private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository + private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository, + private val fixedContacts: Set = emptySet() ) : AbstractSavedStateViewModelFactory() { override fun create(key: String, modelClass: Class, handle: SavedStateHandle): T { return modelClass.cast( @@ -199,9 +247,31 @@ class ContactSearchViewModel( performSafetyNumberChecks = performSafetyNumberChecks, arbitraryRepository = arbitraryRepository, searchRepository = searchRepository, - contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository + contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository, + fixedContacts = fixedContacts ) ) as T } } } + +/** + * Wires the three core flows of [ContactSearchViewModel] to a [PagingMappingAdapter], scoped to + * the given [LifecycleOwner]. Designed for Java callers that create the adapter directly (without + * [ContactSearchView]) and only need the data pipeline, not a full Compose surface. + * + * Call once from `onViewCreated` after constructing the ViewModel and adapter. + */ +fun ContactSearchViewModel.bindAdapterToLifecycle( + lifecycleOwner: LifecycleOwner, + adapter: PagingMappingAdapter, + mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration +) { + lifecycleOwner.lifecycleScope.launch { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { mappingModels.collect { adapter.submitList(it) } } + launch { controller.collect { it?.let { c -> adapter.setPagingController(c) } } } + launch { configurationState.collect { setConfiguration(mapStateToConfiguration(it)) } } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt index 696f1105f5..97deb26d10 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt @@ -25,8 +25,13 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.launch import org.signal.core.ui.BottomSheetUtil import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.getParcelableArrayListCompat @@ -38,11 +43,15 @@ import org.thoughtcrime.securesms.components.ContactFilterView import org.thoughtcrime.securesms.components.TooltipPopup import org.thoughtcrime.securesms.components.WrapperDialogFragment import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter +import org.thoughtcrime.securesms.contacts.paged.ContactSearchCallbacks import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration import org.thoughtcrime.securesms.contacts.paged.ContactSearchError import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey -import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator +import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository +import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository 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.database.RecipientTable import org.thoughtcrime.securesms.database.model.IdentityRecord import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -50,6 +59,7 @@ import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomShe import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet +import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel import org.thoughtcrime.securesms.stories.GroupStoryEducationSheet @@ -89,12 +99,22 @@ class MultiselectForwardFragment : ChooseInitialMyStoryMembershipBottomSheetDialogFragment.Callback { private val viewModel: MultiselectForwardViewModel by viewModels(factoryProducer = this::createViewModelFactory) + private val contactSearchViewModel: ContactSearchViewModel by viewModels { + ContactSearchViewModel.Factory( + selectionLimits = RemoteConfig.shareSelectionLimit, + isMultiSelect = !args.selectSingleRecipient, + repository = ContactSearchRepository(), + performSafetyNumberChecks = true, + arbitraryRepository = null, + searchRepository = SearchRepository(requireContext().getString(R.string.note_to_self)), + contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(requireContext()) + ) + } private val disposables = LifecycleDisposable() private lateinit var contactFilterView: ContactFilterView private lateinit var addMessage: EditText - private lateinit var contactSearchMediator: ContactSearchMediator - private lateinit var contactSearchRecycler: RecyclerView + private lateinit var contactSearch: ContactSearchView private lateinit var callback: Callback private var dismissibleDialog: SimpleProgressDialog.DismissibleDialog? = null @@ -121,27 +141,25 @@ class MultiselectForwardFragment : view.minimumHeight = resources.displayMetrics.heightPixels - contactSearchRecycler = view.findViewById(R.id.contact_selection_list) - contactSearchMediator = ContactSearchMediator( - fragment = this, - fixedContacts = emptySet(), - selectionLimits = RemoteConfig.shareSelectionLimit, - isMultiSelect = !args.selectSingleRecipient, + contactSearch = view.findViewById(R.id.contact_selection_list) + contactSearch.bind( + viewModel = contactSearchViewModel, + fragmentManager = childFragmentManager, displayOptions = ContactSearchAdapter.DisplayOptions( displayCheckBox = !args.selectSingleRecipient, displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER, displayStoryRing = true ), mapStateToConfiguration = this::getConfiguration, - callbacks = object : ContactSearchMediator.SimpleCallbacks() { + callbacks = object : ContactSearchCallbacks.Simple() { override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set): Set { val filtered: Set = filterContacts(view, contactSearchKeys) Log.d(TAG, "onBeforeContactsSelected() Attempting to select: ${contactSearchKeys.map { it.toString() }}, Filtered selection: ${filtered.map { it.toString() } }") return filtered } - } + }, + contentBottomPaddingDp = 44f ) - contactSearchRecycler.adapter = contactSearchMediator.adapter callback = findListener()!! disposables.bindTo(viewLifecycleOwner.lifecycle) @@ -156,7 +174,7 @@ class MultiselectForwardFragment : } contactFilterView.setOnFilterChangedListener { - contactSearchMediator.onFilterChanged(it) + contactSearchViewModel.setQuery(it) } val container = callback.getContainer() @@ -207,27 +225,31 @@ class MultiselectForwardFragment : container.addView(bottomBarAndSpacer) - contactSearchMediator.getSelectionState().observe(viewLifecycleOwner) { contactSelection -> - if (contactSelection.isNotEmpty() && args.selectSingleRecipient) { - onSend(sendButton) - return@observe - } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + contactSearchViewModel.selectionState.collect { contactSelection -> + if (contactSelection.isNotEmpty() && args.selectSingleRecipient) { + onSend(sendButton) + return@collect + } - shareSelectionAdapter.submitList(contactSelection.mapIndexed { index, key -> ShareSelectionMappingModel(key.requireShareContact(), index == 0) }) + shareSelectionAdapter.submitList(contactSelection.mapIndexed { index, key -> ShareSelectionMappingModel(key.requireShareContact(), index == 0) }) - addMessage.visible = !args.forceDisableAddMessage && contactSelection.any { key -> !key.requireRecipientSearchKey().isStory } && args.multiShareArgs.isNotEmpty() + addMessage.visible = !args.forceDisableAddMessage && contactSelection.any { key -> !key.requireRecipientSearchKey().isStory } && args.multiShareArgs.isNotEmpty() - if (contactSelection.isNotEmpty() && !bottomBar.isVisible) { - bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_from_bottom) - bottomBar.visible = true - } else if (contactSelection.isEmpty() && bottomBar.isVisible) { - bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_to_bottom) - bottomBar.visible = false + if (contactSelection.isNotEmpty() && !bottomBar.isVisible) { + bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_from_bottom) + bottomBar.visible = true + } else if (contactSelection.isEmpty() && bottomBar.isVisible) { + bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_to_bottom) + bottomBar.visible = false + } + } } } - disposables += contactSearchMediator - .getErrorEvents() + disposables += contactSearchViewModel.errorEventsStream + .observeOn(AndroidSchedulers.mainThread()) .subscribe { val toastMessage: Int? = when (it) { ContactSearchError.CONTACT_NOT_SELECTABLE -> R.string.MultiselectForwardFragment__only_admins_can_send_messages_to_this_group @@ -264,15 +286,15 @@ class MultiselectForwardFragment : setFragmentResultListener(CreateStoryWithViewersFragment.REQUEST_KEY) { _, bundle -> val recipientId: RecipientId = bundle.getParcelableCompat(CreateStoryWithViewersFragment.STORY_RECIPIENT, RecipientId::class.java)!! - contactSearchMediator.setKeysSelected(setOf(ContactSearchKey.RecipientSearchKey(recipientId, true))) + contactSearchViewModel.setKeysSelected(setOf(ContactSearchKey.RecipientSearchKey(recipientId, true))) contactFilterView.clear() } setFragmentResultListener(ChooseGroupStoryBottomSheet.GROUP_STORY) { _, bundle -> val groups: Set = bundle.getParcelableArrayListCompat(ChooseGroupStoryBottomSheet.RESULT_SET, RecipientId::class.java)?.toSet() ?: emptySet() val keys: Set = groups.map { ContactSearchKey.RecipientSearchKey(it, true) }.toSet() - contactSearchMediator.addToVisibleGroupStories(keys) - contactSearchMediator.setKeysSelected(keys) + contactSearchViewModel.addToVisibleGroupStories(keys) + contactSearchViewModel.setKeysSelected(keys) contactFilterView.clear() } } @@ -286,7 +308,7 @@ class MultiselectForwardFragment : val expiringMessages = args.multiShareArgs.filter { it.expiresAt > 0L } val firstToExpire = expiringMessages.minByOrNull { it.expiresAt } val earliestExpiration = firstToExpire?.expiresAt ?: -1L - if (viewModel.state.value?.stage is MultiselectForwardState.Stage.SelectionConfirmed && contactSearchMediator.getSelectedContacts().isNotEmpty()) { + if (viewModel.state.value?.stage is MultiselectForwardState.Stage.SelectionConfirmed && contactSearchViewModel.getSelectedContacts().isNotEmpty()) { onCanceled() } if (earliestExpiration > 0) { @@ -320,7 +342,7 @@ class MultiselectForwardFragment : .setMessage(R.string.MultiselectForwardFragment__forwarded_messages_are_now) .setPositiveButton(resources.getQuantityString(R.plurals.MultiselectForwardFragment_send_d_messages, messageCount, messageCount)) { d, _ -> d.dismiss() - viewModel.confirmFirstSend(addMessage.text.toString(), contactSearchMediator.getSelectedContacts()) + viewModel.confirmFirstSend(addMessage.text.toString(), contactSearchViewModel.getSelectedContacts()) } .setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() @@ -331,7 +353,7 @@ class MultiselectForwardFragment : private fun onSend(sendButton: View) { sendButton.isEnabled = false - viewModel.send(addMessage.text.toString(), contactSearchMediator.getSelectedContacts()) + viewModel.send(addMessage.text.toString(), contactSearchViewModel.getSelectedContacts()) } private fun displaySafetyNumberConfirmation(identityRecords: List, selectedContacts: List) { @@ -341,7 +363,7 @@ class MultiselectForwardFragment : } private fun dismissWithSuccess(@PluralsRes toastTextResId: Int) { - Log.d(TAG, "dismissWithSuccess() Selected: ${contactSearchMediator.getSelectedContacts().map { it.toString() }}") + Log.d(TAG, "dismissWithSuccess() Selected: ${contactSearchViewModel.getSelectedContacts().map { it.toString() }}") requireListener().setResult( Bundle().apply { @@ -353,7 +375,7 @@ class MultiselectForwardFragment : } private fun dismissAndShowToast(@PluralsRes toastTextResId: Int) { - Log.d(TAG, "dismissAndShowToast() Selected: ${contactSearchMediator.getSelectedContacts().map { it.toString() }}") + Log.d(TAG, "dismissAndShowToast() Selected: ${contactSearchViewModel.getSelectedContacts().map { it.toString() }}") val argCount = getMessageCount() @@ -519,12 +541,12 @@ class MultiselectForwardFragment : } override fun onWrapperDialogFragmentDismissed() { - contactSearchMediator.refresh() + contactSearchViewModel.refresh() } override fun onMyStoryConfigured(recipientId: RecipientId) { - contactSearchMediator.setKeysSelected(setOf(ContactSearchKey.RecipientSearchKey(recipientId, true))) - contactSearchMediator.refresh() + contactSearchViewModel.setKeysSelected(setOf(ContactSearchKey.RecipientSearchKey(recipientId, true))) + contactSearchViewModel.refresh() } interface Callback { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 51fefd429f..fd1f30a6e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -118,8 +118,12 @@ 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.contacts.paged.ContactSearchMediator; +import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository; +import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository; import org.thoughtcrime.securesms.contacts.paged.ContactSearchState; +import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel; +import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModelKt; +import org.thoughtcrime.securesms.search.SearchRepository; import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments; import org.thoughtcrime.securesms.conversation.ConversationUpdateTick; import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest; @@ -231,7 +235,7 @@ public class ConversationListFragment extends MainFragment implements Conversati protected ConversationListArchiveItemDecoration archiveDecoration; protected ConversationListItemAnimator itemAnimator; private Stopwatch startupStopwatch; - private ContactSearchMediator contactSearchMediator; + private ContactSearchViewModel contactSearchViewModel; private MainToolbarViewModel mainToolbarViewModel; private ChatListBackHandler chatListBackHandler; @@ -318,44 +322,34 @@ public class ConversationListFragment extends MainFragment implements Conversati pullView = view.findViewById(R.id.pull_view); pullViewAppBarLayout = view.findViewById(R.id.recycler_coordinator_app_bar); - contactSearchMediator = new ContactSearchMediator(this, - Collections.emptySet(), - SelectionLimits.NO_LIMITS, - false, - new ContactSearchAdapter.DisplayOptions( - false, - ContactSearchAdapter.DisplaySecondaryInformation.NEVER, - false, - false - ), - this::mapSearchStateToConfiguration, - new ContactSearchMediator.SimpleCallbacks(), - false, - (context, - fixedContacts, - displayOptions, - callbacks, - longClickCallbacks, - storyContextMenuCallbacks, - callButtonClickCallbacks - ) -> { - //noinspection CodeBlock2Expr - return new ConversationListSearchAdapter( - context, - fixedContacts, - displayOptions, - new ContactSearchClickCallbacks(callbacks), - longClickCallbacks, - storyContextMenuCallbacks, - callButtonClickCallbacks, - getViewLifecycleOwner(), - Glide.with(this) - ); - }, - new ConversationListSearchAdapter.ChatFilterRepository() + contactSearchViewModel = new ViewModelProvider(this, new ContactSearchViewModel.Factory( + SelectionLimits.NO_LIMITS, + false, + new ContactSearchRepository(), + false, + new ConversationListSearchAdapter.ChatFilterRepository(), + new SearchRepository(requireContext().getString(R.string.note_to_self)), + new ContactSearchPagedDataSourceRepository(requireContext()), + Collections.emptySet() + )).get(ContactSearchViewModel.class); + + searchAdapter = new ConversationListSearchAdapter( + requireContext(), + Collections.emptySet(), + new ContactSearchAdapter.DisplayOptions(false, ContactSearchAdapter.DisplaySecondaryInformation.NEVER, false, false), + new ContactSearchClickCallbacks(), + new ContactSearchAdapter.LongClickCallbacksAdapter(), + new ContactSearchAdapter.StoryContextMenuCallbacks() { + @Override public void onOpenStorySettings(@NonNull ContactSearchData.Story story) {} + @Override public void onRemoveGroupStory(@NonNull ContactSearchData.Story story, boolean isSelected) {} + @Override public void onDeletePrivateStory(@NonNull ContactSearchData.Story story, boolean isSelected) {} + }, + ContactSearchAdapter.EmptyCallButtonClickCallbacks.INSTANCE, + getViewLifecycleOwner(), + Glide.with(this) ); - searchAdapter = contactSearchMediator.getAdapter(); + ContactSearchViewModelKt.bindAdapterToLifecycle(contactSearchViewModel, getViewLifecycleOwner(), searchAdapter, this::mapSearchStateToConfiguration); initializeSearchFilterListener(); @@ -436,7 +430,7 @@ public class ConversationListFragment extends MainFragment implements Conversati maybeScheduleRefreshProfileJob(); ConversationListFragmentExtensionsKt.listenToEventBusWhileResumed(this, mainNavigationViewModel.getDetailLocation()); - String query = contactSearchMediator.getFilter(); + String query = contactSearchViewModel.getQuery(); if (query != null) { onSearchQueryUpdated(query); } @@ -723,7 +717,7 @@ public class ConversationListFragment extends MainFragment implements Conversati lifecycleDisposable.add( viewModel.getFilterRequestState().subscribe(request -> { updateSearchToolbarHint(request); - contactSearchMediator.onConversationFilterRequestChanged(request); + contactSearchViewModel.setConversationFilterRequest(request); }) ); @@ -773,13 +767,13 @@ public class ConversationListFragment extends MainFragment implements Conversati authorIdStr != null ? RecipientId.from(Long.parseLong(authorIdStr)) : null ); mainToolbarViewModel.setHasActiveSearchFilter(!activeSearchFilter.isEmpty()); - contactSearchMediator.onSearchFilterChanged(activeSearchFilter); + contactSearchViewModel.setSearchFilter(activeSearchFilter); break; case SearchFilterBottomSheet.ACTION_CLEAR: activeSearchFilter = SearchFilter.EMPTY; mainToolbarViewModel.setHasActiveSearchFilter(false); - contactSearchMediator.onSearchFilterChanged(activeSearchFilter); + contactSearchViewModel.setSearchFilter(activeSearchFilter); break; case SearchFilterBottomSheet.ACTION_SELECT_AUTHOR: @@ -1747,7 +1741,7 @@ public class ConversationListFragment extends MainFragment implements Conversati activeSearchFilter = SearchFilter.EMPTY; mainToolbarViewModel.setHasActiveSearchFilter(false); - contactSearchMediator.onSearchFilterChanged(activeSearchFilter); + contactSearchViewModel.setSearchFilter(activeSearchFilter); chatListBackHandler.setEnabled(false); } @@ -1755,7 +1749,7 @@ public class ConversationListFragment extends MainFragment implements Conversati private void onSearchQueryUpdated(@NonNull String query) { String trimmed = query.trim(); - contactSearchMediator.onFilterChanged(trimmed); + contactSearchViewModel.setQuery(trimmed); if (!trimmed.isEmpty()) { if (activeAdapter != searchAdapter && list != null) { @@ -1970,12 +1964,6 @@ public class ConversationListFragment extends MainFragment implements Conversati private class ContactSearchClickCallbacks implements ConversationListSearchAdapter.ConversationListSearchClickCallbacks { - private final ContactSearchAdapter.ClickCallbacks delegate; - - private ContactSearchClickCallbacks(@NonNull ContactSearchAdapter.ClickCallbacks delegate) { - this.delegate = delegate; - } - @Override public void onThreadClicked(@NonNull View view, @NonNull ContactSearchData.Thread thread, boolean isSelected) { onConversationClicked(thread.getThreadRecord()); @@ -2013,7 +2001,7 @@ public class ConversationListFragment extends MainFragment implements Conversati @Override public void onExpandClicked(@NonNull ContactSearchData.Expand expand) { - delegate.onExpandClicked(expand); + contactSearchViewModel.expandSection(expand.getSectionKey()); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt index 9a4f0c482a..9474a1322b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt @@ -11,7 +11,12 @@ import android.widget.EditText import android.widget.FrameLayout import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.launch import org.signal.core.ui.FixedRoundedCornerBottomSheetDialogFragment import org.signal.core.util.DimensionUnit import org.signal.core.util.getParcelableArrayListCompat @@ -19,9 +24,13 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey -import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator +import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository +import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder +import org.thoughtcrime.securesms.contacts.paged.ContactSearchView +import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.sharing.ShareContact import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel @@ -35,11 +44,23 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment( } private lateinit var divider: View - private lateinit var mediator: ContactSearchMediator + private lateinit var contactSearch: ContactSearchView private lateinit var innerContainer: View private var animatorSet: AnimatorSet? = null + private val contactSearchViewModel: ContactSearchViewModel by viewModels { + ContactSearchViewModel.Factory( + selectionLimits = RemoteConfig.shareSelectionLimit, + isMultiSelect = true, + repository = ContactSearchRepository(), + performSafetyNumberChecks = false, + arbitraryRepository = null, + searchRepository = SearchRepository(requireContext().getString(R.string.note_to_self)), + contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(requireContext()) + ) + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.cloneInContext(ContextThemeWrapper(inflater.context, themeResId)).inflate(R.layout.stories_choose_group_bottom_sheet, container, false) } @@ -62,11 +83,10 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment( onDone() } - val contactRecycler: RecyclerView = view.findViewById(R.id.contact_recycler) - mediator = ContactSearchMediator( - fragment = this, - selectionLimits = RemoteConfig.shareSelectionLimit, - isMultiSelect = true, + contactSearch = view.findViewById(R.id.contact_recycler) + contactSearch.bind( + viewModel = contactSearchViewModel, + fragmentManager = childFragmentManager, displayOptions = ContactSearchAdapter.DisplayOptions( displayCheckBox = true, displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER @@ -84,33 +104,35 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment( ) } }, - performSafetyNumberChecks = false + contentBottomPaddingDp = 44f ) - contactRecycler.adapter = mediator.adapter + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + contactSearchViewModel.selectionState.collect { state -> + adapter.submitList( + state.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java) + .map { it.recipientId } + .mapIndexed { index, recipientId -> + ShareSelectionMappingModel( + ShareContact(recipientId), + index == 0 + ) + } + ) - mediator.getSelectionState().observe(viewLifecycleOwner) { state -> - adapter.submitList( - state.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java) - .map { it.recipientId } - .mapIndexed { index, recipientId -> - ShareSelectionMappingModel( - ShareContact(recipientId), - index == 0 - ) + if (state.isEmpty()) { + animateOutBottomBar() + } else { + animateInBottomBar() } - ) - - if (state.isEmpty()) { - animateOutBottomBar() - } else { - animateInBottomBar() + } } } val searchField: EditText = view.findViewById(R.id.search_field) searchField.doAfterTextChanged { - mediator.onFilterChanged(it?.toString()) + contactSearchViewModel.setQuery(it?.toString()) } } @@ -150,7 +172,7 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment( putParcelableArrayList( RESULT_SET, ArrayList( - mediator.getSelectedContacts() + contactSearchViewModel.getSelectedContacts() .filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java) .map { it.recipientId } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt index 70a2b530d7..db9cbe5b55 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt @@ -4,40 +4,52 @@ import android.os.Bundle import android.view.View import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager +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.ContactSearchMediator +import org.thoughtcrime.securesms.contacts.paged.ContactSearchPagedDataSourceRepository +import org.thoughtcrime.securesms.contacts.paged.ContactSearchRepository +import org.thoughtcrime.securesms.contacts.paged.ContactSearchViewModel import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.databinding.ViewAllSignalConnectionsFragmentBinding import org.thoughtcrime.securesms.groups.SelectionLimits +import org.thoughtcrime.securesms.search.SearchRepository class ViewAllSignalConnectionsFragment : Fragment(R.layout.view_all_signal_connections_fragment) { private val binding by ViewBinderDelegate(ViewAllSignalConnectionsFragmentBinding::bind) + private val contactSearchViewModel: ContactSearchViewModel by viewModels { + ContactSearchViewModel.Factory( + selectionLimits = SelectionLimits(0, 0), + isMultiSelect = false, + repository = ContactSearchRepository(), + performSafetyNumberChecks = false, + arbitraryRepository = null, + searchRepository = SearchRepository(requireContext().getString(R.string.note_to_self)), + contactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(requireContext()) + ) + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - binding.recycler.addItemDecoration(LetterHeaderDecoration(requireContext()) { false }) binding.toolbar.setNavigationOnClickListener { requireActivity().onBackPressedDispatcher.onBackPressed() } - val mediator = ContactSearchMediator( - fragment = this, - selectionLimits = SelectionLimits(0, 0), - isMultiSelect = false, + binding.recycler.bind( + viewModel = contactSearchViewModel, + fragmentManager = childFragmentManager, displayOptions = ContactSearchAdapter.DisplayOptions( displayCheckBox = false, displaySecondaryInformation = ContactSearchAdapter.DisplaySecondaryInformation.NEVER ), mapStateToConfiguration = { getConfiguration() }, - performSafetyNumberChecks = false + itemDecorations = listOf(LetterHeaderDecoration(requireContext()) { false }) ) - - binding.recycler.adapter = mediator.adapter } private fun getConfiguration(): ContactSearchConfiguration { diff --git a/app/src/main/res/layout/contact_selection_list_fragment.xml b/app/src/main/res/layout/contact_selection_list_fragment.xml index 86529c36fc..84b33ded4f 100644 --- a/app/src/main/res/layout/contact_selection_list_fragment.xml +++ b/app/src/main/res/layout/contact_selection_list_fragment.xml @@ -17,14 +17,10 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/chipRecycler"> - + android:layout_height="match_parent" /> - + android:layout_weight="1" /> \ No newline at end of file diff --git a/app/src/main/res/layout/stories_choose_group_bottom_sheet.xml b/app/src/main/res/layout/stories_choose_group_bottom_sheet.xml index cedb998b93..69ee9060fe 100644 --- a/app/src/main/res/layout/stories_choose_group_bottom_sheet.xml +++ b/app/src/main/res/layout/stories_choose_group_bottom_sheet.xml @@ -40,13 +40,9 @@ android:textColor="@color/signal_colorOnSurfaceVariant" app:backgroundTint="@color/signal_colorSurface5" /> - + android:layout_weight="1" /> \ No newline at end of file diff --git a/app/src/main/res/layout/view_all_signal_connections_fragment.xml b/app/src/main/res/layout/view_all_signal_connections_fragment.xml index 3e84dea1bf..5bd1aefb5f 100644 --- a/app/src/main/res/layout/view_all_signal_connections_fragment.xml +++ b/app/src/main/res/layout/view_all_signal_connections_fragment.xml @@ -18,13 +18,10 @@ app:title="@string/MyStorySettingsFragment__all_signal_connections" app:titleTextAppearance="@style/Signal.Text.TitleLarge" /> - + android:layout_weight="1" /> \ No newline at end of file