Add "You can pull to filter" tip.

This commit is contained in:
Alex Hart
2023-01-04 09:57:14 -04:00
committed by Greyson Parrelli
parent 43fe789807
commit 296a113c65
20 changed files with 297 additions and 117 deletions

View File

@@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.payments.DataExportUtil
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.util.Optional
@@ -576,6 +577,17 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
viewModel.resetPnpInitializedState()
}
)
if (FeatureFlags.chatFilters()) {
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Chat Filters"))
clickPref(
title = DSLSettingsText.from("Reset pull to refresh tip count"),
onClick = {
SignalStore.uiHints().resetNeverDisplayPullToRefreshCount()
}
)
}
}
}

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.conversationlist;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.ConversationReader;
class ClearFilterViewHolder extends RecyclerView.ViewHolder {
private final View tip;
ClearFilterViewHolder(@NonNull View itemView, OnClearFilterClickListener listener) {
super(itemView);
tip = itemView.findViewById(R.id.clear_filter_tip);
itemView.findViewById(R.id.clear_filter).setOnClickListener(v -> {
listener.onClearFilterClick();
});
}
void bind(@NonNull Conversation conversation) {
if (conversation.getThreadRecord().getType() == ConversationReader.TYPE_SHOW_TIP) {
tip.setVisibility(View.VISIBLE);
} else {
tip.setVisibility(View.GONE);
}
}
interface OnClearFilterClickListener {
void onClearFilterClick();
}
}

View File

@@ -17,6 +17,7 @@ import org.signal.paging.PagingController;
import org.thoughtcrime.securesms.BindableConversationListItem;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.ConversationReader;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.CachedInflater;
@@ -45,9 +46,9 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
private final LifecycleOwner lifecycleOwner;
private final GlideRequests glideRequests;
private final OnConversationClickListener onConversationClickListener;
private final OnClearFilterClickListener onClearFilterClicked;
private ConversationSet selectedConversations = new ConversationSet();
private final OnConversationClickListener onConversationClickListener;
private final ClearFilterViewHolder.OnClearFilterClickListener onClearFilterClicked;
private ConversationSet selectedConversations = new ConversationSet();
private final Set<Long> typingSet = new HashSet<>();
private PagingController pagingController;
@@ -55,7 +56,7 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
protected ConversationListAdapter(@NonNull LifecycleOwner lifecycleOwner,
@NonNull GlideRequests glideRequests,
@NonNull OnConversationClickListener onConversationClickListener,
@NonNull OnClearFilterClickListener onClearFilterClicked)
@NonNull ClearFilterViewHolder.OnClearFilterClickListener onClearFilterClicked)
{
super(new ConversationDiffCallback());
@@ -165,6 +166,11 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
default:
throw new IllegalArgumentException();
}
} else if (holder.getItemViewType() == TYPE_CLEAR_FILTER_FOOTER || holder.getItemViewType() == TYPE_CLEAR_FILTER_EMPTY) {
ClearFilterViewHolder casted = (ClearFilterViewHolder) holder;
Conversation conversation = Objects.requireNonNull(getItem(position));
casted.bind(conversation);
}
}
@@ -268,22 +274,9 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
}
}
static class ClearFilterViewHolder extends RecyclerView.ViewHolder {
ClearFilterViewHolder(@NonNull View itemView, OnClearFilterClickListener listener) {
super(itemView);
itemView.findViewById(R.id.clear_filter).setOnClickListener(v -> {
listener.onClearFilterClick();
});
}
}
interface OnConversationClickListener {
void onConversationClick(@NonNull Conversation conversation);
boolean onConversationLongClick(@NonNull Conversation conversation, @NonNull View view);
void onShowArchiveClick();
}
interface OnClearFilterClickListener {
void onClearFilterClick();
}
}

View File

@@ -38,15 +38,17 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
protected final ThreadTable threadTable;
protected final ConversationFilter conversationFilter;
protected final boolean showConversationFooterTip;
protected ConversationListDataSource(@NonNull ConversationFilter conversationFilter) {
this.threadTable = SignalDatabase.threads();
this.conversationFilter = conversationFilter;
protected ConversationListDataSource(@NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
this.threadTable = SignalDatabase.threads();
this.conversationFilter = conversationFilter;
this.showConversationFooterTip = showConversationFooterTip;
}
public static ConversationListDataSource create(@NonNull ConversationFilter conversationFilter, boolean isArchived) {
if (!isArchived) return new UnarchivedConversationListDataSource(conversationFilter);
else return new ArchivedConversationListDataSource(conversationFilter);
public static ConversationListDataSource create(@NonNull ConversationFilter conversationFilter, boolean isArchived, boolean showConversationFooterTip) {
if (!isArchived) return new UnarchivedConversationListDataSource(conversationFilter, showConversationFooterTip);
else return new ArchivedConversationListDataSource(conversationFilter, showConversationFooterTip);
}
@Override
@@ -98,9 +100,11 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
if (conversations.isEmpty() && start == 0 && length == 1) {
if (conversationFilter == ConversationFilter.OFF) {
return Collections.singletonList(new Conversation(ConversationReader.buildThreadRecordForType(Conversation.Type.EMPTY, 0)));
return Collections.singletonList(new Conversation(ConversationReader.buildThreadRecordForType(Conversation.Type.EMPTY, 0, false)));
} else {
return Collections.singletonList(new Conversation(ConversationReader.buildThreadRecordForType(Conversation.Type.CONVERSATION_FILTER_EMPTY, 0)));
return Collections.singletonList(new Conversation(ConversationReader.buildThreadRecordForType(Conversation.Type.CONVERSATION_FILTER_EMPTY,
0,
showConversationFooterTip)));
}
} else {
return conversations;
@@ -124,8 +128,8 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
private int totalCount;
ArchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter) {
super(conversationFilter);
ArchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
super(conversationFilter, showConversationFooterTip);
}
@Override
@@ -141,8 +145,8 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
cursors.add(cursor);
if (offset + limit >= totalCount && totalCount > 0 && conversationFilter != ConversationFilter.OFF) {
MatrixCursor conversationFilterFooter = new MatrixCursor(ConversationReader.HEADER_COLUMN);
conversationFilterFooter.addRow(ConversationReader.CONVERSATION_FILTER_FOOTER);
MatrixCursor conversationFilterFooter = new MatrixCursor(ConversationReader.FILTER_FOOTER_COLUMNS);
conversationFilterFooter.addRow(ConversationReader.createConversationFilterFooterRow(showConversationFooterTip));
cursors.add(conversationFilterFooter);
}
@@ -158,8 +162,8 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
private int archivedCount;
private int unpinnedCount;
UnarchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter) {
super(conversationFilter);
UnarchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
super(conversationFilter, showConversationFooterTip);
}
@Override
@@ -222,8 +226,8 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
}
if (shouldInsertConversationFilterFooter) {
MatrixCursor conversationFilterFooter = new MatrixCursor(ConversationReader.HEADER_COLUMN);
conversationFilterFooter.addRow(ConversationReader.CONVERSATION_FILTER_FOOTER);
MatrixCursor conversationFilterFooter = new MatrixCursor(ConversationReader.FILTER_FOOTER_COLUMNS);
conversationFilterFooter.addRow(ConversationReader.createConversationFilterFooterRow(showConversationFooterTip));
cursors.add(conversationFilterFooter);
}
@@ -251,7 +255,7 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
}
boolean hasConversationFilterFooter() {
return totalCount > 1 && conversationFilter != ConversationFilter.OFF;
return totalCount >= 1 && conversationFilter != ConversationFilter.OFF;
}
}
}

View File

@@ -114,6 +114,7 @@ import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView;
import org.thoughtcrime.securesms.contacts.sync.CdsPermanentErrorBottomSheet;
import org.thoughtcrime.securesms.contacts.sync.CdsTemporaryErrorBottomSheet;
import org.thoughtcrime.securesms.conversation.ConversationFragment;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView;
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
@@ -195,7 +196,7 @@ import static android.app.Activity.RESULT_OK;
public class ConversationListFragment extends MainFragment implements ActionMode.Callback,
ConversationListAdapter.OnConversationClickListener,
ConversationListSearchAdapter.EventListener,
MegaphoneActionController, ConversationListAdapter.OnClearFilterClickListener
MegaphoneActionController, ClearFilterViewHolder.OnClearFilterClickListener
{
public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562;
public static final short SMS_ROLE_REQUEST_CODE = 32563;
@@ -283,16 +284,19 @@ public class ConversationListFragment extends MainFragment implements ActionMode
CollapsingToolbarLayout collapsingToolbarLayout = view.findViewById(R.id.collapsing_toolbar);
int openHeight = (int) DimensionUnit.DP.toPixels(FilterLerp.FILTER_OPEN_HEIGHT);
pullView.setOnFilterStateChanged(state -> {
pullView.setOnFilterStateChanged((state, source) -> {
switch (state) {
case CLOSING:
viewModel.setFiltered(false);
viewModel.setFiltered(false, source);
break;
case OPENING:
viewModel.setFiltered(true);
viewModel.setFiltered(true, source);
break;
case OPEN_APEX:
ViewUtil.setMinimumHeight(collapsingToolbarLayout, openHeight);
if (source == ConversationFilterSource.DRAG) {
SignalStore.uiHints().incrementNeverDisplayPullToFilterTip();
}
break;
case CLOSE_APEX:
ViewUtil.setMinimumHeight(collapsingToolbarLayout, 0);
@@ -747,7 +751,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private void initializeListAdapters() {
defaultAdapter = new ConversationListAdapter(getViewLifecycleOwner(), GlideApp.with(this), this, this);
searchAdapter = new ConversationListSearchAdapter(getViewLifecycleOwner(), GlideApp.with(this), this, Locale.getDefault());
searchAdapter = new ConversationListSearchAdapter(getViewLifecycleOwner(), GlideApp.with(this), this, Locale.getDefault(), this);
searchAdapterDecoration = new StickyHeaderDecoration(searchAdapter, false, false, 0);
setAdapter(defaultAdapter);

View File

@@ -10,7 +10,9 @@ import androidx.annotation.Nullable;
import androidx.lifecycle.LifecycleOwner;
import androidx.recyclerview.widget.RecyclerView;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.mms.GlideRequests;
@@ -25,30 +27,35 @@ import java.util.Locale;
class ConversationListSearchAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
implements StickyHeaderDecoration.StickyHeaderAdapter<ConversationListSearchAdapter.HeaderViewHolder>
{
private static final int VIEW_TYPE_EMPTY = 0;
private static final int VIEW_TYPE_NON_EMPTY = 1;
private static final int VIEW_TYPE_EMPTY = 0;
private static final int VIEW_TYPE_NON_EMPTY = 1;
private static final int VIEW_TYPE_CHAT_FILTER = 2;
private static final int VIEW_TYPE_CHAT_FILTER_EMPTY = 3;
private static final int TYPE_CONVERSATIONS = 1;
private static final int TYPE_CONTACTS = 2;
private static final int TYPE_MESSAGES = 3;
private final LifecycleOwner lifecycleOwner;
private final GlideRequests glideRequests;
private final EventListener eventListener;
private final Locale locale;
private final LifecycleOwner lifecycleOwner;
private final GlideRequests glideRequests;
private final EventListener eventListener;
private final Locale locale;
private final ClearFilterViewHolder.OnClearFilterClickListener onClearFilterClicked;
@NonNull
private SearchResult searchResult = SearchResult.EMPTY;
ConversationListSearchAdapter(@NonNull LifecycleOwner lifecycleOwner,
@NonNull GlideRequests glideRequests,
@NonNull EventListener eventListener,
@NonNull Locale locale)
ConversationListSearchAdapter(@NonNull LifecycleOwner lifecycleOwner,
@NonNull GlideRequests glideRequests,
@NonNull EventListener eventListener,
@NonNull Locale locale,
@NonNull ClearFilterViewHolder.OnClearFilterClickListener onClearFilterClicked)
{
this.lifecycleOwner = lifecycleOwner;
this.glideRequests = glideRequests;
this.eventListener = eventListener;
this.locale = locale;
this.lifecycleOwner = lifecycleOwner;
this.glideRequests = glideRequests;
this.eventListener = eventListener;
this.locale = locale;
this.onClearFilterClicked = onClearFilterClicked;
}
@Override
@@ -56,6 +63,12 @@ class ConversationListSearchAdapter extends RecyclerView.Adapter<RecyclerView
if (viewType == VIEW_TYPE_EMPTY) {
return new EmptyViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.conversation_list_empty_search_state, parent, false));
} else if (viewType == VIEW_TYPE_CHAT_FILTER) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_item_clear_filter, parent, false);
return new ClearFilterViewHolder(v, onClearFilterClicked);
} else if (viewType == VIEW_TYPE_CHAT_FILTER_EMPTY) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_item_clear_filter_empty, parent, false);
return new ClearFilterViewHolder(v, onClearFilterClicked);
} else {
return new SearchResultViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.conversation_list_item_view, parent, false));
@@ -93,8 +106,12 @@ class ConversationListSearchAdapter extends RecyclerView.Adapter<RecyclerView
@Override
public int getItemViewType(int position) {
if (searchResult.isEmpty()) {
if (searchResult.isEmpty() && searchResult.getConversationFilter() == ConversationFilter.OFF) {
return VIEW_TYPE_EMPTY;
} else if (searchResult.isEmpty()) {
return VIEW_TYPE_CHAT_FILTER_EMPTY;
} else if (position == getChatFilterIndex()) {
return VIEW_TYPE_CHAT_FILTER;
} else {
return VIEW_TYPE_NON_EMPTY;
}
@@ -109,14 +126,20 @@ class ConversationListSearchAdapter extends RecyclerView.Adapter<RecyclerView
@Override
public int getItemCount() {
return searchResult.isEmpty() ? 1 : searchResult.size();
if (searchResult.isEmpty()) {
return 1;
} if (searchResult.getConversationFilter() != ConversationFilter.OFF) {
return searchResult.size() + 1;
} else {
return searchResult.size();
}
}
@Override
public long getHeaderId(int position) {
if (position < 0 || searchResult.isEmpty()) {
if (position < 0 || searchResult.isEmpty() || position == getChatFilterIndex()) {
return StickyHeaderDecoration.StickyHeaderAdapter.NO_HEADER_ID;
} else if (getConversationResult(position) != null) {
} else if (getConversationResult(position) != null) {
return TYPE_CONVERSATIONS;
} else if (getContactResult(position) != null) {
return TYPE_CONTACTS;
@@ -173,6 +196,18 @@ class ConversationListSearchAdapter extends RecyclerView.Adapter<RecyclerView
return getFirstContactIndex() + searchResult.getContacts().size();
}
private int getChatFilterIndex() {
if (searchResult.getConversationFilter() == ConversationFilter.OFF) {
return -1;
}
if (searchResult.isEmpty()) {
return 0;
}
return searchResult.size();
}
public interface EventListener {
void onConversationClicked(@NonNull ThreadRecord threadRecord);
void onContactClicked(@NonNull Recipient contact);

View File

@@ -16,6 +16,8 @@ import org.signal.paging.PagedData;
import org.signal.paging.PagingConfig;
import org.signal.paging.PagingController;
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
@@ -24,6 +26,7 @@ import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData;
import org.thoughtcrime.securesms.database.DatabaseObserver;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.megaphone.Megaphone;
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
import org.thoughtcrime.securesms.megaphone.Megaphones;
@@ -63,7 +66,7 @@ class ConversationListViewModel extends ViewModel {
private final MutableLiveData<Megaphone> megaphone;
private final MutableLiveData<SearchResult> searchResult;
private final MutableLiveData<ConversationSet> selectedConversations;
private final MutableLiveData<ConversationFilter> conversationFilter;
private final MutableLiveData<ConversationFilterRequest> conversationFilterRequest;
private final LiveData<ConversationListDataSource> conversationListDataSource;
private final Set<Conversation> internalSelection;
private final LiveData<LivePagedData<Long, Conversation>> pagedData;
@@ -99,9 +102,11 @@ class ConversationListViewModel extends ViewModel {
this.activeSearchResult = SearchResult.EMPTY;
this.invalidator = new Invalidator();
this.disposables = new CompositeDisposable();
this.conversationFilter = new MutableLiveData<>(ConversationFilter.OFF);
this.conversationListDataSource = Transformations.map(Transformations.distinctUntilChanged(conversationFilter),
filter -> ConversationListDataSource.create(filter, isArchived));
this.conversationFilterRequest = new MutableLiveData<>(new ConversationFilterRequest(ConversationFilter.OFF, ConversationFilterSource.DRAG));
this.conversationListDataSource = Transformations.map(Transformations.distinctUntilChanged(conversationFilterRequest),
request -> ConversationListDataSource.create(request.getFilter(),
isArchived,
SignalStore.uiHints().canDisplayPullToFilterTip() && request.getSource() == ConversationFilterSource.OVERFLOW));
this.pagedData = Transformations.map(conversationListDataSource, source -> PagedData.createForLiveData(source,
new PagingConfig.Builder()
.setPageSize(15)
@@ -123,13 +128,13 @@ class ConversationListViewModel extends ViewModel {
});
};
this.hasNoConversations = LiveDataUtil.mapAsync(LiveDataUtil.combineLatest(conversationFilter, getConversationList(), Pair::new), filterAndData -> {
this.hasNoConversations = LiveDataUtil.mapAsync(LiveDataUtil.combineLatest(conversationFilterRequest, getConversationList(), Pair::new), filterAndData -> {
pinnedCount = SignalDatabase.threads().getPinnedConversationListCount(ConversationFilter.OFF);
if (filterAndData.getSecond().size() > 0) {
return false;
} else {
return SignalDatabase.threads().getArchivedConversationListCount(filterAndData.getFirst()) == 0;
return SignalDatabase.threads().getArchivedConversationListCount(filterAndData.getFirst().getFilter()) == 0;
}
});
@@ -211,14 +216,14 @@ class ConversationListViewModel extends ViewModel {
setSelection(newSelection);
}
void setFiltered(boolean isFiltered) {
void setFiltered(boolean isFiltered, @NonNull ConversationFilterSource conversationFilterSource) {
if (isFiltered) {
conversationFilter.setValue(ConversationFilter.UNREAD);
conversationFilterRequest.setValue(new ConversationFilterRequest(ConversationFilter.UNREAD, conversationFilterSource));
if (activeQuery != null) {
onSearchQueryUpdated(activeQuery);
}
} else {
conversationFilter.setValue(ConversationFilter.OFF);
conversationFilterRequest.setValue(new ConversationFilterRequest(ConversationFilter.OFF, conversationFilterSource));
if (activeQuery != null) {
onSearchQueryUpdated(activeQuery);
}
@@ -266,14 +271,14 @@ class ConversationListViewModel extends ViewModel {
void onSearchQueryUpdated(String query) {
activeQuery = query;
ConversationFilter filter = conversationFilter.getValue();
ConversationFilter filter = Objects.requireNonNull(conversationFilterRequest.getValue()).getFilter();
if (filter != ConversationFilter.OFF) {
contactSearchDebouncer.publish(() -> submitConversationSearch(query));
contactSearchDebouncer.publish(() -> submitConversationSearch(query, filter));
return;
}
contactSearchDebouncer.publish(() -> {
submitConversationSearch(query);
submitConversationSearch(query, filter);
searchRepository.queryContacts(query, result -> {
if (!result.getQuery().equals(activeQuery)) {
@@ -305,8 +310,8 @@ class ConversationListViewModel extends ViewModel {
});
}
private void submitConversationSearch(@NonNull String query) {
searchRepository.queryThreads(query, result -> {
private void submitConversationSearch(@NonNull String query, @NonNull ConversationFilter conversationFilter) {
searchRepository.queryThreads(query, conversationFilter == ConversationFilter.UNREAD, result -> {
if (!result.getQuery().equals(activeQuery)) {
return;
}
@@ -315,7 +320,7 @@ class ConversationListViewModel extends ViewModel {
activeSearchResult = SearchResult.EMPTY;
}
activeSearchResult = activeSearchResult.merge(result);
activeSearchResult = activeSearchResult.merge(result).merge(conversationFilter);
searchResult.postValue(activeSearchResult);
});
}

View File

@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.conversationlist.chatfilter
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
data class ConversationFilterRequest(
val filter: ConversationFilter,
val source: ConversationFilterSource
)

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.conversationlist.chatfilter
/**
* Describes where the chat filter was applied from.
*/
enum class ConversationFilterSource {
/**
* User pulled and released the pull view.
*/
DRAG,
/**
* User utilized the menu item in the overflow.
*/
OVERFLOW
}

View File

@@ -65,14 +65,14 @@ class ConversationListFilterPullView @JvmOverloads constructor(
binding.filterCircle.progress = progress
if (state == FilterPullState.CLOSED && progress <= 0) {
setState(FilterPullState.CLOSED)
setState(FilterPullState.CLOSED, ConversationFilterSource.DRAG)
} else if (state == FilterPullState.CLOSED && progress >= 1f) {
setState(FilterPullState.OPEN_APEX)
setState(FilterPullState.OPEN_APEX, ConversationFilterSource.DRAG)
vibrate()
resetHelpText()
resetPillColor()
} else if (state == FilterPullState.OPEN && progress >= 1f) {
setState(FilterPullState.CLOSE_APEX)
setState(FilterPullState.CLOSE_APEX, ConversationFilterSource.DRAG)
vibrate()
animatePillColor()
}
@@ -111,19 +111,19 @@ class ConversationListFilterPullView @JvmOverloads constructor(
fun onUserDragFinished() {
if (state == FilterPullState.OPEN_APEX) {
open()
open(ConversationFilterSource.DRAG)
} else if (state == FilterPullState.CLOSE_APEX) {
close()
close(ConversationFilterSource.DRAG)
}
}
fun toggle() {
if (state == FilterPullState.OPEN) {
setState(FilterPullState.CLOSE_APEX)
close()
setState(FilterPullState.CLOSE_APEX, ConversationFilterSource.OVERFLOW)
close(ConversationFilterSource.OVERFLOW)
} else if (state == FilterPullState.CLOSED) {
setState(FilterPullState.OPEN_APEX)
open()
setState(FilterPullState.OPEN_APEX, ConversationFilterSource.OVERFLOW)
open(ConversationFilterSource.OVERFLOW)
}
}
@@ -131,14 +131,14 @@ class ConversationListFilterPullView @JvmOverloads constructor(
return state == FilterPullState.OPEN
}
private fun open() {
setState(FilterPullState.OPENING)
animatePillIn()
private fun open(source: ConversationFilterSource) {
setState(FilterPullState.OPENING, source)
animatePillIn(source)
}
private fun close() {
setState(FilterPullState.CLOSING)
animatePillOut()
private fun close(source: ConversationFilterSource) {
setState(FilterPullState.CLOSING, source)
animatePillOut(source)
}
private fun resetHelpText() {
@@ -152,7 +152,7 @@ class ConversationListFilterPullView @JvmOverloads constructor(
})
}
private fun animatePillIn() {
private fun animatePillIn(source: ConversationFilterSource) {
binding.filterText.visibility = VISIBLE
binding.filterText.alpha = 0f
binding.filterText.isEnabled = true
@@ -162,20 +162,20 @@ class ConversationListFilterPullView @JvmOverloads constructor(
startDelay = 300
duration = 300
doOnEnd {
setState(FilterPullState.OPEN)
setState(FilterPullState.OPEN, source)
}
start()
}
}
private fun animatePillOut() {
private fun animatePillOut(source: ConversationFilterSource) {
pillAnimator?.cancel()
pillAnimator = ObjectAnimator.ofFloat(binding.filterText, ALPHA, 0f).apply {
duration = 150
doOnEnd {
binding.filterText.visibility = GONE
binding.filterText.isEnabled = false
postDelayed({ setState(FilterPullState.CLOSED) }, 150)
postDelayed({ setState(FilterPullState.CLOSED, source) }, 150)
}
start()
}
@@ -198,10 +198,10 @@ class ConversationListFilterPullView @JvmOverloads constructor(
binding.filterText.chipBackgroundColor = ColorStateList.valueOf(pillDefaultBackgroundTint)
}
private fun setState(state: FilterPullState) {
private fun setState(state: FilterPullState, source: ConversationFilterSource) {
this.state = state
binding.filterCircle.state = state
onFilterStateChanged?.newState(state)
onFilterStateChanged?.newState(state, source)
}
private fun vibrate() {
@@ -211,7 +211,7 @@ class ConversationListFilterPullView @JvmOverloads constructor(
}
interface OnFilterStateChanged {
fun newState(state: FilterPullState)
fun newState(state: FilterPullState, source: ConversationFilterSource)
}
interface OnCloseClicked {

View File

@@ -14,9 +14,12 @@ public class ConversationReader extends ThreadTable.StaticReader {
public static final String[] HEADER_COLUMN = { "header" };
public static final String[] ARCHIVED_COLUMNS = { "header", "count" };
public static final String[] FILTER_FOOTER_COLUMNS = { "header", "show_tip" };
public static final String[] PINNED_HEADER = { Conversation.Type.PINNED_HEADER.toString() };
public static final String[] UNPINNED_HEADER = { Conversation.Type.UNPINNED_HEADER.toString() };
public static final String[] CONVERSATION_FILTER_FOOTER = { Conversation.Type.CONVERSATION_FILTER_FOOTER.toString() };
public static final long TYPE_NONE = 0x0;
public static final long TYPE_SHOW_TIP = 0x1;
private final Cursor cursor;
@@ -29,6 +32,10 @@ public class ConversationReader extends ThreadTable.StaticReader {
return new String[]{Conversation.Type.ARCHIVED_FOOTER.toString(), String.valueOf(archivedCount)};
}
public static String[] createConversationFilterFooterRow(boolean showTip) {
return new String[]{Conversation.Type.CONVERSATION_FILTER_FOOTER.toString(), String.valueOf(showTip ? 1 : 0)};
}
@Override
public ThreadRecord getCurrent() {
if (cursor.getColumnIndex(HEADER_COLUMN[0]) == -1) {
@@ -45,15 +52,21 @@ public class ConversationReader extends ThreadTable.StaticReader {
count = CursorUtil.requireInt(cursor, ARCHIVED_COLUMNS[1]);
}
return buildThreadRecordForType(type, count);
boolean showTip = false;
if (type == Conversation.Type.CONVERSATION_FILTER_FOOTER) {
showTip = CursorUtil.requireBoolean(cursor, FILTER_FOOTER_COLUMNS[1]);
}
return buildThreadRecordForType(type, count, showTip);
}
public static ThreadRecord buildThreadRecordForType(@NonNull Conversation.Type type, int count) {
public static ThreadRecord buildThreadRecordForType(@NonNull Conversation.Type type, int count, boolean showTip) {
return new ThreadRecord.Builder(-(100 + type.ordinal()))
.setBody(type.toString())
.setDate(100)
.setRecipient(Recipient.UNKNOWN)
.setUnreadCount(count)
.setType(showTip ? TYPE_SHOW_TIP : TYPE_NONE)
.build();
}
}

View File

@@ -615,7 +615,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
}
}
fun getFilteredConversationList(filter: List<RecipientId>): Cursor? {
fun getFilteredConversationList(filter: List<RecipientId>, unreadOnly: Boolean): Cursor? {
if (filter.isEmpty()) {
return null
}
@@ -625,7 +625,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
val cursors: MutableList<Cursor> = LinkedList()
for (recipientIds in splitRecipientIds) {
var selection = "$TABLE_NAME.$RECIPIENT_ID = ?"
var selection = "($TABLE_NAME.$RECIPIENT_ID = ?"
val selectionArgs = arrayOfNulls<String>(recipientIds.size)
for (i in 0 until recipientIds.size - 1) {
@@ -638,6 +638,12 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
i++
}
selection += if (unreadOnly) {
") AND $TABLE_NAME.$READ != ${ReadStatus.READ.serialize()}"
} else {
")"
}
val query = createQuery(selection, "$DATE DESC", 0, 0)
cursors.add(db.rawQuery(query, selectionArgs))
}

View File

@@ -7,9 +7,12 @@ import java.util.List;
public class UiHints extends SignalStoreValues {
private static final int NEVER_DISPLAY_PULL_TO_FILTER_TIP_THRESHOLD = 3;
private static final String HAS_SEEN_GROUP_SETTINGS_MENU_TOAST = "uihints.has_seen_group_settings_menu_toast";
private static final String HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE = "uihints.has_confirmed_delete_for_everyone_once";
private static final String HAS_SET_OR_SKIPPED_USERNAME_CREATION = "uihints.has_set_or_skipped_username_creation";
private static final String NEVER_DISPLAY_PULL_TO_FILTER_TIP = "uihints.never_display_pull_to_filter_tip";
UiHints(@NonNull KeyValueStore store) {
super(store);
@@ -22,7 +25,7 @@ public class UiHints extends SignalStoreValues {
@Override
@NonNull List<String> getKeysToIncludeInBackup() {
return Collections.emptyList();
return Collections.singletonList(NEVER_DISPLAY_PULL_TO_FILTER_TIP);
}
public void markHasSeenGroupSettingsMenuToast() {
@@ -48,4 +51,21 @@ public class UiHints extends SignalStoreValues {
public void markHasSetOrSkippedUsernameCreation() {
putBoolean(HAS_SET_OR_SKIPPED_USERNAME_CREATION, true);
}
public void resetNeverDisplayPullToRefreshCount() {
putInteger(NEVER_DISPLAY_PULL_TO_FILTER_TIP, 0);
}
public boolean canDisplayPullToFilterTip() {
return getNeverDisplayPullToFilterTip() < NEVER_DISPLAY_PULL_TO_FILTER_TIP_THRESHOLD;
}
public void incrementNeverDisplayPullToFilterTip() {
int inc = Math.min(NEVER_DISPLAY_PULL_TO_FILTER_TIP_THRESHOLD, getNeverDisplayPullToFilterTip() + 1);
putInteger(NEVER_DISPLAY_PULL_TO_FILTER_TIP, inc);
}
private int getNeverDisplayPullToFilterTip() {
return getInteger(NEVER_DISPLAY_PULL_TO_FILTER_TIP, 0);
}
}

View File

@@ -80,10 +80,10 @@ public class SearchRepository {
this.serialExecutor = new SerialExecutor(SignalExecutors.BOUNDED);
}
public void queryThreads(@NonNull String query, @NonNull Consumer<ThreadSearchResult> callback) {
public void queryThreads(@NonNull String query, boolean unreadOnly, @NonNull Consumer<ThreadSearchResult> callback) {
searchExecutor.execute(2, () -> {
long start = System.currentTimeMillis();
List<ThreadRecord> result = queryConversations(query);
List<ThreadRecord> result = queryConversations(query, unreadOnly);
Log.d(TAG, "[threads] Search took " + (System.currentTimeMillis() - start) + " ms");
@@ -155,7 +155,7 @@ public class SearchRepository {
}
}
private @NonNull List<ThreadRecord> queryConversations(@NonNull String query) {
private @NonNull List<ThreadRecord> queryConversations(@NonNull String query, boolean unreadOnly) {
if (Util.isEmpty(query)) {
return Collections.emptyList();
}
@@ -192,15 +192,15 @@ public class SearchRepository {
List<ThreadRecord> output = new ArrayList<>(contactIds.size() + groupsByTitleIds.size() + groupsByMemberIds.size());
output.addAll(getMatchingThreads(contactIds));
output.addAll(getMatchingThreads(groupsByTitleIds));
output.addAll(getMatchingThreads(groupsByMemberIds));
output.addAll(getMatchingThreads(contactIds, unreadOnly));
output.addAll(getMatchingThreads(groupsByTitleIds, unreadOnly));
output.addAll(getMatchingThreads(groupsByMemberIds, unreadOnly));
return output;
}
private List<ThreadRecord> getMatchingThreads(@NonNull Collection<RecipientId> recipientIds) {
try (Cursor cursor = threadTable.getFilteredConversationList(new ArrayList<>(recipientIds))) {
private List<ThreadRecord> getMatchingThreads(@NonNull Collection<RecipientId> recipientIds, boolean unreadOnly) {
try (Cursor cursor = threadTable.getFilteredConversationList(new ArrayList<>(recipientIds), unreadOnly)) {
return readToList(cursor, new ThreadModelBuilder(threadTable));
}
}

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.search
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.recipients.Recipient
@@ -11,7 +12,8 @@ data class SearchResult(
val query: String,
val contacts: List<Recipient>,
val conversations: List<ThreadRecord>,
val messages: List<MessageResult>
val messages: List<MessageResult>,
val conversationFilter: ConversationFilter
) {
fun size(): Int {
return contacts.size + conversations.size + messages.size
@@ -32,8 +34,12 @@ data class SearchResult(
return this.copy(messages = result.results, query = result.query)
}
fun merge(conversationFilter: ConversationFilter): SearchResult {
return this.copy(conversationFilter = conversationFilter)
}
companion object {
@JvmField
val EMPTY = SearchResult("", emptyList(), emptyList(), emptyList())
val EMPTY = SearchResult("", emptyList(), emptyList(), emptyList(), ConversationFilter.OFF)
}
}

View File

@@ -114,8 +114,7 @@ public final class FeatureFlags {
@VisibleForTesting
static final Set<String> REMOTE_CAPABLE = SetUtil.newHashSet(
PAYMENTS_KILL_SWITCH,
GROUPS_V2_RECOMMENDED_LIMIT,
GROUPS_V2_HARD_LIMIT,
GROUPS_V2_RECOMMENDED_LIMIT, GROUPS_V2_HARD_LIMIT,
INTERNAL_USER,
VERIFY_V2,
CLIENT_EXPIRATION,

View File

@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="88dp">
android:gravity="center"
android:minHeight="88dp"
android:orientation="vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/clear_filter"
@@ -13,4 +15,14 @@
android:text="@string/ConversationListFragment__clear_filter"
android:textColor="@color/signal_colorOnSurfaceVariant" />
</FrameLayout>
<TextView
android:id="@+id/clear_filter_tip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/ChatFilter__tip_pull_down"
android:textAppearance="@style/Signal.Text.BodySmall"
android:textColor="@color/signal_colorOnSurfaceVariant"
android:visibility="gone" />
</LinearLayout>

View File

@@ -21,4 +21,14 @@
android:layout_gravity="center"
android:text="@string/ConversationListFragment__clear_filter"
android:textColor="@color/signal_colorOnSurfaceVariant" />
<TextView
android:id="@+id/clear_filter_tip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/ChatFilter__tip_pull_down"
android:textAppearance="@style/Signal.Text.BodySmall"
android:textColor="@color/signal_colorOnSurfaceVariant"
android:visibility="gone" />
</LinearLayout>

View File

@@ -5469,5 +5469,7 @@
<string name="ChatFilter__filtered_by_unread">Filtered by unread</string>
<!-- Displayed underneath the filter circle at the top of the chat list when the user pulls at a very low velocity -->
<string name="ChatFilter__pull_to_filter">Pull to filter</string>
<!-- Displayed in the "clear filter" item in the chat feed if the user opened the filter from the overflow menu -->
<string name="ChatFilter__tip_pull_down">Tip: Pull down on the chat list to filter</string>
</resources>

View File

@@ -54,7 +54,7 @@ public class UnarchivedConversationListDataSourceTest {
when(SignalDatabase.threads()).thenReturn(threadTable);
when(ApplicationDependencies.getDatabaseObserver()).thenReturn(mock(DatabaseObserver.class));
testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(ConversationFilter.OFF);
testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(ConversationFilter.OFF, false);
}
@Test
@@ -266,7 +266,7 @@ public class UnarchivedConversationListDataSourceTest {
@Test
public void givenHasNoArchivedAndIsFiltered_whenIGetCursor_thenIExpectConversationFilterFooter() {
// GIVEN
ConversationListDataSource.UnarchivedConversationListDataSource testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(ConversationFilter.UNREAD);
ConversationListDataSource.UnarchivedConversationListDataSource testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(ConversationFilter.UNREAD, false);
setupThreadDatabaseCursors(0, 3);
when(threadTable.getPinnedConversationListCount(ConversationFilter.UNREAD)).thenReturn(0);
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.UNREAD)).thenReturn(3);