Convert search mediator to compose / viewmodel pattern.

This commit is contained in:
Alex Hart
2026-04-17 14:31:24 -03:00
committed by jeffrey-signal
parent 2a699a23dd
commit 77a18111e1
16 changed files with 937 additions and 638 deletions

View File

@@ -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<ContactSearchKey> 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<RecyclerView.OnScrollListener> 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<SelectedContact> 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<RecipientId> 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<SelectedContact> 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();

View File

@@ -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<RecyclerView.ItemDecoration> = emptyList(),
contentBottomPadding: Dp = 0.dp,
adapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory,
scrollListeners: List<RecyclerView.OnScrollListener> = 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<ContactSearchCallbacks>,
private val fragmentManager: State<FragmentManager?>
) : 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<FragmentManager?>,
private val context: State<Context>
) : 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())
}
}

View File

@@ -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<ContactSearchKey>,
displayOptions: DisplayOptions,
callbacks: ClickCallbacks,
longClickCallbacks: LongClickCallbacks,
storyContextMenuCallbacks: StoryContextMenuCallbacks,
callButtonClickCallbacks: CallButtonClickCallbacks
): PagingMappingAdapter<ContactSearchKey>
}
/** Standard implementation that creates a plain [ContactSearchAdapter]. */
object DefaultAdapterFactory : AdapterFactory {
override fun create(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayOptions: DisplayOptions,
callbacks: ClickCallbacks,
longClickCallbacks: LongClickCallbacks,
storyContextMenuCallbacks: StoryContextMenuCallbacks,
callButtonClickCallbacks: CallButtonClickCallbacks
): PagingMappingAdapter<ContactSearchKey> {
return ContactSearchAdapter(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks)
}
}
}
private data class RecipientDisplayName(val recipient: Recipient, val displayName: String)

View File

@@ -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<ContactSearchKey>): Set<ContactSearchKey>
/** 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<ContactSearchKey>): Set<ContactSearchKey> {
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)
}
}
}

View File

@@ -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]

View File

@@ -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<ContactSearchKey> = 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<Pair<List<ContactSearchData>, Set<ContactSearchKey>>> = 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<ContactSearchKey>) {
Log.d(TAG, "setKeysSelected() Keys: ${keys.map { it.toString() }}")
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(null, keys))
}
fun setKeysNotSelected(keys: Set<ContactSearchKey>) {
keys.forEach {
callbacks.onContactDeselected(null, it)
}
viewModel.setKeysNotSelected(keys)
}
fun clearSelection() {
viewModel.clearSelection()
}
fun getSelectedContacts(): Set<ContactSearchKey> {
return viewModel.getSelectedContacts()
}
fun getFixedContactsSize(): Int {
return fixedContacts.size
}
fun getSelectionState(): LiveData<Set<ContactSearchKey>> {
return viewModel.selectionState
}
fun getErrorEvents(): Observable<ContactSearchError> {
return viewModel.errorEventsStream.observeOn(AndroidSchedulers.mainThread())
}
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.RecipientSearchKey>) {
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<ContactSearchKey>): Set<ContactSearchKey>
fun onContactDeselected(view: View?, contactSearchKey: ContactSearchKey)
fun onAdapterListCommitted(size: Int)
}
open class SimpleCallbacks : Callbacks {
override fun onBeforeContactsSelected(view: View?, contactSearchKeys: Set<ContactSearchKey>): Set<ContactSearchKey> {
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<ContactSearchKey> so that it can
* be swapped for another implementation, allow listeners to be wrapped, etc.
*/
fun interface AdapterFactory {
fun create(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayOptions: ContactSearchAdapter.DisplayOptions,
callbacks: ContactSearchAdapter.ClickCallbacks,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks,
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
): PagingMappingAdapter<ContactSearchKey>
}
private object DefaultAdapterFactory : AdapterFactory {
override fun create(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayOptions: ContactSearchAdapter.DisplayOptions,
callbacks: ContactSearchAdapter.ClickCallbacks,
longClickCallbacks: ContactSearchAdapter.LongClickCallbacks,
storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks,
callButtonClickCallbacks: ContactSearchAdapter.CallButtonClickCallbacks
): PagingMappingAdapter<ContactSearchKey> {
return ContactSearchAdapter(context, fixedContacts, displayOptions, callbacks, longClickCallbacks, storyContextMenuCallbacks, callButtonClickCallbacks)
}
}
}

View File

@@ -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 `<ContactSearchView>` 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<RecyclerView.ItemDecoration> = emptyList()
private var currentContentBottomPadding: Dp = 0.dp
private var currentAdapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory
private var currentScrollListeners: List<RecyclerView.OnScrollListener> = emptyList()
private var 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<RecyclerView.ItemDecoration> = emptyList(),
contentBottomPaddingDp: Float = 0f,
adapterFactory: ContactSearchAdapter.AdapterFactory = ContactSearchAdapter.DefaultAdapterFactory,
scrollListeners: List<RecyclerView.OnScrollListener> = 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
)
}
}

View File

@@ -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<ContactSearchKey> = emptySet()
) : ViewModel() {
companion object {
@@ -56,16 +77,41 @@ class ContactSearchViewModel(
.setStartIndex(0)
.build()
private val pagedData = MutableLiveData<LivePagedData<ContactSearchKey, ContactSearchData>>()
private val configurationStore = Store(ContactSearchState(query = savedStateHandle[QUERY]))
private val pagedData = MutableStateFlow<StateFlowPagedData<ContactSearchKey, ContactSearchData>?>(null)
private val internalConfigurationState = MutableStateFlow(ContactSearchState(query = savedStateHandle[QUERY]))
private val internalSelectedContacts = MutableStateFlow<Set<ContactSearchKey>>(emptySet())
private val errorEvents = PublishSubject.create<ContactSearchError>()
private val rawQuery = MutableStateFlow<String?>(savedStateHandle[QUERY])
val controller: LiveData<PagingController<ContactSearchKey>> = pagedData.map { it.controller }
val data: LiveData<List<ContactSearchData>> = pagedData.switchMap { it.data }
val configurationState: LiveData<ContactSearchState> = configurationStore.stateLiveData
private val selectedContacts: StateFlow<Set<ContactSearchKey>> = internalSelectedContacts
val selectionState: LiveData<Set<ContactSearchKey>> = 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<PagingController<ContactSearchKey>?> = pagedData
.map { it?.controller }
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
/** Raw paged contact data. Prefer [mappingModels] for binding to an adapter. */
val data: StateFlow<List<ContactSearchData>> = 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<ContactSearchState> = internalConfigurationState
/** Currently selected contact keys, excluding [fixedContacts]. */
val selectionState: StateFlow<Set<ContactSearchKey>> = internalSelectedContacts
/** Adapter-ready models combining [data] with [selectionState]. Suitable for direct submission to a [ContactSearchAdapter]. */
val mappingModels: StateFlow<MappingModelList> = combine(data, selectionState) { contactData, selection ->
ContactSearchAdapter.toMappingModelList(contactData, selection, arbitraryRepository)
}.stateIn(viewModelScope, SharingStarted.Eagerly, MappingModelList())
val errorEventsStream: Observable<ContactSearchError> = 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<ContactSearchKey>) {
@@ -135,7 +180,7 @@ class ContactSearchViewModel(
}
fun getSelectedContacts(): Set<ContactSearchKey> {
return selectedContacts.value
return internalSelectedContacts.value
}
fun clearSelection() {
@@ -144,7 +189,7 @@ class ContactSearchViewModel(
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.RecipientSearchKey>) {
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<ContactSearchKey> = emptySet()
) : AbstractSavedStateViewModelFactory() {
override fun <T : ViewModel> create(key: String, modelClass: Class<T>, 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<ContactSearchKey>,
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)) } }
}
}
}

View File

@@ -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<ContactSearchKey>): Set<ContactSearchKey> {
val filtered: Set<ContactSearchKey> = 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<RecipientId> = bundle.getParcelableArrayListCompat(ChooseGroupStoryBottomSheet.RESULT_SET, RecipientId::class.java)?.toSet() ?: emptySet()
val keys: Set<ContactSearchKey.RecipientSearchKey> = 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<IdentityRecord>, selectedContacts: List<ContactSearchKey>) {
@@ -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<Callback>().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 {

View File

@@ -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

View File

@@ -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 }
)

View File

@@ -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 {