mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 17:29:32 +01:00
Add chat filter support behind a flag.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
package org.thoughtcrime.securesms.conversationlist
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior
|
||||
import androidx.core.view.ViewCompat
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
|
||||
class ConversationFilterBehavior(context: Context, attributeSet: AttributeSet) : AppBarLayout.Behavior(context, attributeSet) {
|
||||
|
||||
override fun onStartNestedScroll(parent: CoordinatorLayout, child: AppBarLayout, directTargetChild: View, target: View, nestedScrollAxes: Int, type: Int): Boolean {
|
||||
if (type == ViewCompat.TYPE_NON_TOUCH || !FeatureFlags.chatFilters()) {
|
||||
return false
|
||||
} else {
|
||||
return super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes, type)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopNestedScroll(coordinatorLayout: CoordinatorLayout, child: AppBarLayout, target: View, type: Int) {
|
||||
super.onStopNestedScroll(coordinatorLayout, child, target, type)
|
||||
child.setExpanded(false, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.thoughtcrime.securesms.conversationlist
|
||||
|
||||
/**
|
||||
* Small state machine that describes moving and triggering actions
|
||||
* based off pulling down the conversation filter.
|
||||
*/
|
||||
enum class ConversationFilterLatch {
|
||||
SET,
|
||||
RESET;
|
||||
}
|
||||
@@ -30,10 +30,13 @@ import java.util.Set;
|
||||
|
||||
class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.ViewHolder> {
|
||||
|
||||
private static final int TYPE_THREAD = 1;
|
||||
private static final int TYPE_ACTION = 2;
|
||||
private static final int TYPE_PLACEHOLDER = 3;
|
||||
private static final int TYPE_HEADER = 4;
|
||||
private static final int TYPE_THREAD = 1;
|
||||
private static final int TYPE_ACTION = 2;
|
||||
private static final int TYPE_PLACEHOLDER = 3;
|
||||
private static final int TYPE_HEADER = 4;
|
||||
private static final int TYPE_EMPTY = 5;
|
||||
private static final int TYPE_CLEAR_FILTER_FOOTER = 6;
|
||||
private static final int TYPE_CLEAR_FILTER_EMPTY = 7;
|
||||
|
||||
private enum Payload {
|
||||
TYPING_INDICATOR,
|
||||
@@ -43,6 +46,7 @@ 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 Set<Long> typingSet = new HashSet<>();
|
||||
|
||||
@@ -50,13 +54,15 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
|
||||
|
||||
protected ConversationListAdapter(@NonNull LifecycleOwner lifecycleOwner,
|
||||
@NonNull GlideRequests glideRequests,
|
||||
@NonNull OnConversationClickListener onConversationClickListener)
|
||||
@NonNull OnConversationClickListener onConversationClickListener,
|
||||
@NonNull OnClearFilterClickListener onClearFilterClicked)
|
||||
{
|
||||
super(new ConversationDiffCallback());
|
||||
|
||||
this.lifecycleOwner = lifecycleOwner;
|
||||
this.glideRequests = glideRequests;
|
||||
this.onConversationClickListener = onConversationClickListener;
|
||||
this.onClearFilterClicked = onClearFilterClicked;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -101,6 +107,15 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
|
||||
} else if (viewType == TYPE_HEADER) {
|
||||
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.dsl_section_header, parent, false);
|
||||
return new HeaderViewHolder(v);
|
||||
} else if (viewType == TYPE_EMPTY) {
|
||||
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_empty_state, parent, false);
|
||||
return new HeaderViewHolder(v);
|
||||
} else if (viewType == TYPE_CLEAR_FILTER_FOOTER) {
|
||||
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_item_clear_filter, parent, false);
|
||||
return new ClearFilterViewHolder(v, onClearFilterClicked);
|
||||
} else if (viewType == TYPE_CLEAR_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 {
|
||||
throw new IllegalStateException("Unknown type! " + viewType);
|
||||
}
|
||||
@@ -197,8 +212,14 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
|
||||
return TYPE_HEADER;
|
||||
case ARCHIVED_FOOTER:
|
||||
return TYPE_ACTION;
|
||||
case CONVERSATION_FILTER_FOOTER:
|
||||
return TYPE_CLEAR_FILTER_FOOTER;
|
||||
case CONVERSATION_FILTER_EMPTY:
|
||||
return TYPE_CLEAR_FILTER_EMPTY;
|
||||
case THREAD:
|
||||
return TYPE_THREAD;
|
||||
case EMPTY:
|
||||
return TYPE_EMPTY;
|
||||
default:
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
@@ -247,9 +268,22 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.thoughtcrime.securesms.conversationlist;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.database.MergeCursor;
|
||||
@@ -9,9 +8,11 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import org.signal.core.util.Stopwatch;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.paging.PagedDataSource;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationReader;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
@@ -22,9 +23,9 @@ import org.thoughtcrime.securesms.database.model.UpdateDescription;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.signal.core.util.Stopwatch;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
@@ -35,15 +36,17 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
||||
|
||||
private static final String TAG = Log.tag(ConversationListDataSource.class);
|
||||
|
||||
protected final ThreadDatabase threadDatabase;
|
||||
protected final ThreadDatabase threadDatabase;
|
||||
protected final ConversationFilter conversationFilter;
|
||||
|
||||
protected ConversationListDataSource(@NonNull Context context) {
|
||||
this.threadDatabase = SignalDatabase.threads();
|
||||
protected ConversationListDataSource(@NonNull ConversationFilter conversationFilter) {
|
||||
this.threadDatabase = SignalDatabase.threads();
|
||||
this.conversationFilter = conversationFilter;
|
||||
}
|
||||
|
||||
public static ConversationListDataSource create(@NonNull Context context, boolean isArchived) {
|
||||
if (!isArchived) return new UnarchivedConversationListDataSource(context);
|
||||
else return new ArchivedConversationListDataSource(context);
|
||||
public static ConversationListDataSource create(@NonNull ConversationFilter conversationFilter, boolean isArchived) {
|
||||
if (!isArchived) return new UnarchivedConversationListDataSource(conversationFilter);
|
||||
else return new ArchivedConversationListDataSource(conversationFilter);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -51,13 +54,17 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
||||
long startTime = System.currentTimeMillis();
|
||||
int count = getTotalCount();
|
||||
|
||||
Log.d(TAG, "[size(), " + getClass().getSimpleName() + "] " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
return count;
|
||||
if (conversationFilter != ConversationFilter.OFF) {
|
||||
count += 1;
|
||||
}
|
||||
|
||||
Log.d(TAG, "[size(), " + getClass().getSimpleName() + ", " + conversationFilter + "] " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
return Math.max(1, count);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull List<Conversation> load(int start, int length, @NonNull CancellationSignal cancellationSignal) {
|
||||
Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), " + getClass().getSimpleName());
|
||||
Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), " + getClass().getSimpleName() + ", " + conversationFilter);
|
||||
|
||||
List<Conversation> conversations = new ArrayList<>(length);
|
||||
List<Recipient> recipients = new LinkedList<>();
|
||||
@@ -89,7 +96,15 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
||||
|
||||
stopwatch.stop(TAG);
|
||||
|
||||
return conversations;
|
||||
if (conversations.isEmpty() && start == 0 && length == 1) {
|
||||
if (conversationFilter == ConversationFilter.OFF) {
|
||||
return Collections.singletonList(new Conversation(ConversationReader.buildThreadRecordForType(Conversation.Type.EMPTY, 0)));
|
||||
} else {
|
||||
return Collections.singletonList(new Conversation(ConversationReader.buildThreadRecordForType(Conversation.Type.CONVERSATION_FILTER_EMPTY, 0)));
|
||||
}
|
||||
} else {
|
||||
return conversations;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -107,18 +122,31 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
||||
|
||||
private static class ArchivedConversationListDataSource extends ConversationListDataSource {
|
||||
|
||||
ArchivedConversationListDataSource(@NonNull Context context) {
|
||||
super(context);
|
||||
private int totalCount;
|
||||
|
||||
ArchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter) {
|
||||
super(conversationFilter);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getTotalCount() {
|
||||
return threadDatabase.getArchivedConversationListCount();
|
||||
totalCount = threadDatabase.getArchivedConversationListCount(conversationFilter);
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Cursor getCursor(long offset, long limit) {
|
||||
return threadDatabase.getArchivedConversationList(offset, limit);
|
||||
List<Cursor> cursors = new ArrayList<>(2);
|
||||
Cursor cursor = threadDatabase.getArchivedConversationList(conversationFilter, offset, limit);
|
||||
|
||||
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);
|
||||
cursors.add(conversationFilterFooter);
|
||||
}
|
||||
|
||||
return new MergeCursor(cursors.toArray(new Cursor[]{}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,16 +158,16 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
||||
private int archivedCount;
|
||||
private int unpinnedCount;
|
||||
|
||||
UnarchivedConversationListDataSource(@NonNull Context context) {
|
||||
super(context);
|
||||
UnarchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter) {
|
||||
super(conversationFilter);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getTotalCount() {
|
||||
int unarchivedCount = threadDatabase.getUnarchivedConversationListCount();
|
||||
int unarchivedCount = threadDatabase.getUnarchivedConversationListCount(conversationFilter);
|
||||
|
||||
pinnedCount = threadDatabase.getPinnedConversationListCount();
|
||||
archivedCount = threadDatabase.getArchivedConversationListCount();
|
||||
pinnedCount = threadDatabase.getPinnedConversationListCount(conversationFilter);
|
||||
archivedCount = threadDatabase.getArchivedConversationListCount(conversationFilter);
|
||||
unpinnedCount = unarchivedCount - pinnedCount;
|
||||
totalCount = unarchivedCount;
|
||||
|
||||
@@ -170,7 +198,7 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
||||
limit--;
|
||||
}
|
||||
|
||||
Cursor pinnedCursor = threadDatabase.getUnarchivedConversationList(true, offset, limit);
|
||||
Cursor pinnedCursor = threadDatabase.getUnarchivedConversationList(conversationFilter, true, offset, limit);
|
||||
cursors.add(pinnedCursor);
|
||||
limit -= pinnedCursor.getCount();
|
||||
|
||||
@@ -182,15 +210,23 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
||||
}
|
||||
|
||||
long unpinnedOffset = Math.max(0, offset - pinnedCount - getHeaderOffset());
|
||||
Cursor unpinnedCursor = threadDatabase.getUnarchivedConversationList(false, unpinnedOffset, limit);
|
||||
Cursor unpinnedCursor = threadDatabase.getUnarchivedConversationList(conversationFilter, false, unpinnedOffset, limit);
|
||||
cursors.add(unpinnedCursor);
|
||||
|
||||
if (offset + originalLimit >= totalCount && hasArchivedFooter()) {
|
||||
boolean shouldInsertConversationFilterFooter = offset + originalLimit >= totalCount && hasConversationFilterFooter();
|
||||
boolean shouldInsertArchivedFooter = offset + originalLimit >= totalCount - (shouldInsertConversationFilterFooter ? 1 : 0) && hasArchivedFooter();
|
||||
if (shouldInsertArchivedFooter) {
|
||||
MatrixCursor archivedFooterCursor = new MatrixCursor(ConversationReader.ARCHIVED_COLUMNS);
|
||||
archivedFooterCursor.addRow(ConversationReader.createArchivedFooterRow(archivedCount));
|
||||
cursors.add(archivedFooterCursor);
|
||||
}
|
||||
|
||||
if (shouldInsertConversationFilterFooter) {
|
||||
MatrixCursor conversationFilterFooter = new MatrixCursor(ConversationReader.HEADER_COLUMN);
|
||||
conversationFilterFooter.addRow(ConversationReader.CONVERSATION_FILTER_FOOTER);
|
||||
cursors.add(conversationFilterFooter);
|
||||
}
|
||||
|
||||
return new MergeCursor(cursors.toArray(new Cursor[]{}));
|
||||
}
|
||||
|
||||
@@ -213,5 +249,9 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
||||
boolean hasArchivedFooter() {
|
||||
return archivedCount != 0;
|
||||
}
|
||||
|
||||
boolean hasConversationFilterFooter() {
|
||||
return totalCount > 1 && conversationFilter != ConversationFilter.OFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.thoughtcrime.securesms.conversationlist
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.AttributeSet
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.databinding.ConversationListFilterPullViewBinding
|
||||
|
||||
/**
|
||||
* Encapsulates the push / pull latch for enabling and disabling
|
||||
* filters into a convenient view.
|
||||
*/
|
||||
class ConversationListFilterPullView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
|
||||
private val colorPull = ContextCompat.getColor(context, R.color.signal_colorSurface1)
|
||||
private val colorRelease = ContextCompat.getColor(context, R.color.signal_colorSecondaryContainer)
|
||||
private var state: State = State.PULL
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.conversation_list_filter_pull_view, this)
|
||||
setBackgroundColor(colorPull)
|
||||
}
|
||||
|
||||
private val binding = ConversationListFilterPullViewBinding.bind(this)
|
||||
|
||||
fun setToPull() {
|
||||
if (state == State.PULL) {
|
||||
return
|
||||
}
|
||||
|
||||
state = State.PULL
|
||||
setBackgroundColor(colorPull)
|
||||
binding.arrow.setImageResource(R.drawable.ic_arrow_down)
|
||||
binding.text.setText(R.string.ConversationListFilterPullView__pull_down_to_filter)
|
||||
}
|
||||
|
||||
fun setToRelease() {
|
||||
if (state == State.RELEASE) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Settings.System.getInt(context.contentResolver, Settings.System.HAPTIC_FEEDBACK_ENABLED, 0) != 0) {
|
||||
performHapticFeedback(if (Build.VERSION.SDK_INT >= 30) HapticFeedbackConstants.CONFIRM else HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
}
|
||||
|
||||
state = State.RELEASE
|
||||
setBackgroundColor(colorRelease)
|
||||
binding.arrow.setImageResource(R.drawable.ic_arrow_up_16)
|
||||
binding.text.setText(R.string.ConversationListFilterPullView__release_to_filter)
|
||||
}
|
||||
|
||||
enum class State {
|
||||
RELEASE,
|
||||
PULL
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
import android.view.HapticFeedbackConstants;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
@@ -40,7 +42,6 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
@@ -68,6 +69,7 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
import com.airbnb.lottie.SimpleColorFilter;
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.material.animation.ArgbEvaluatorCompat;
|
||||
import com.google.android.material.appbar.AppBarLayout;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
|
||||
@@ -136,8 +138,6 @@ import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile;
|
||||
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity;
|
||||
import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsFragmentArgs;
|
||||
import org.thoughtcrime.securesms.payments.preferences.details.PaymentDetailsParcelable;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
@@ -191,7 +191,7 @@ import static android.app.Activity.RESULT_OK;
|
||||
public class ConversationListFragment extends MainFragment implements ActionMode.Callback,
|
||||
ConversationListAdapter.OnConversationClickListener,
|
||||
ConversationListSearchAdapter.EventListener,
|
||||
MegaphoneActionController
|
||||
MegaphoneActionController, ConversationListAdapter.OnClearFilterClickListener
|
||||
{
|
||||
public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562;
|
||||
public static final short SMS_ROLE_REQUEST_CODE = 32563;
|
||||
@@ -207,8 +207,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
private RecyclerView list;
|
||||
private Stub<ReminderView> reminderView;
|
||||
private Stub<UnreadPaymentsView> paymentNotificationView;
|
||||
private Stub<ViewGroup> emptyState;
|
||||
private TextView searchEmptyState;
|
||||
private PulsingFloatingActionButton fab;
|
||||
private PulsingFloatingActionButton cameraFab;
|
||||
private ConversationListViewModel viewModel;
|
||||
@@ -263,10 +261,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
coordinator = view.findViewById(R.id.coordinator);
|
||||
list = view.findViewById(R.id.list);
|
||||
searchEmptyState = view.findViewById(R.id.search_no_results);
|
||||
bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar);
|
||||
reminderView = new Stub<>(view.findViewById(R.id.reminder));
|
||||
emptyState = new Stub<>(view.findViewById(R.id.empty_state));
|
||||
megaphoneContainer = new Stub<>(view.findViewById(R.id.megaphone_container));
|
||||
paymentNotificationView = new Stub<>(view.findViewById(R.id.payments_notification));
|
||||
voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player));
|
||||
@@ -276,6 +272,19 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
fab.setVisibility(View.VISIBLE);
|
||||
cameraFab.setVisibility(View.VISIBLE);
|
||||
|
||||
ConversationListFilterPullView pullView = view.findViewById(R.id.pull_view);
|
||||
|
||||
AppBarLayout appBarLayout = view.findViewById(R.id.recycler_coordinator_app_bar);
|
||||
appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> {
|
||||
if (verticalOffset == 0) {
|
||||
viewModel.setConversationFilterLatch(ConversationFilterLatch.SET);
|
||||
pullView.setToRelease();
|
||||
} else if (verticalOffset == -layout.getHeight()) {
|
||||
viewModel.setConversationFilterLatch(ConversationFilterLatch.RESET);
|
||||
pullView.setToPull();
|
||||
}
|
||||
});
|
||||
|
||||
fab.show();
|
||||
cameraFab.show();
|
||||
|
||||
@@ -345,10 +354,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
public void onDestroyView() {
|
||||
coordinator = null;
|
||||
list = null;
|
||||
searchEmptyState = null;
|
||||
bottomActionBar = null;
|
||||
reminderView = null;
|
||||
emptyState = null;
|
||||
megaphoneContainer = null;
|
||||
paymentNotificationView = null;
|
||||
voiceNotePlayerViewStub = null;
|
||||
@@ -486,6 +493,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
handleInsights(); return true;
|
||||
case R.id.menu_notification_profile:
|
||||
handleNotificationProfile(); return true;
|
||||
case R.id.menu_filter_unread_chats:
|
||||
handleFilterUnreadChats(); return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -691,7 +700,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
|
||||
|
||||
private void initializeListAdapters() {
|
||||
defaultAdapter = new ConversationListAdapter(getViewLifecycleOwner(), GlideApp.with(this), this);
|
||||
defaultAdapter = new ConversationListAdapter(getViewLifecycleOwner(), GlideApp.with(this), this, this);
|
||||
searchAdapter = new ConversationListSearchAdapter(getViewLifecycleOwner(), GlideApp.with(this), this, Locale.getDefault());
|
||||
searchAdapterDecoration = new StickyHeaderDecoration(searchAdapter, false, false, 0);
|
||||
|
||||
@@ -727,7 +736,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
}
|
||||
|
||||
if (adapter instanceof ConversationListAdapter) {
|
||||
((ConversationListAdapter) adapter).setPagingController(viewModel.getPagingController());
|
||||
viewModel.getPagingController()
|
||||
.observe(getViewLifecycleOwner(),
|
||||
controller -> ((ConversationListAdapter) adapter).setPagingController(controller));
|
||||
}
|
||||
|
||||
list.setAdapter(adapter);
|
||||
@@ -826,13 +837,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
private void onSearchResultChanged(@Nullable SearchResult result) {
|
||||
result = result != null ? result : SearchResult.EMPTY;
|
||||
searchAdapter.updateResults(result);
|
||||
|
||||
if (result.isEmpty() && activeAdapter == searchAdapter) {
|
||||
searchEmptyState.setText(getString(R.string.SearchFragment_no_results, result.getQuery()));
|
||||
searchEmptyState.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
searchEmptyState.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void onMegaphoneChanged(@Nullable Megaphone megaphone) {
|
||||
@@ -966,6 +970,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
NotificationProfileSelectionFragment.show(getParentFragmentManager());
|
||||
}
|
||||
|
||||
private void handleFilterUnreadChats() {
|
||||
viewModel.toggleUnreadChatsFilter();
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void handleArchive(@NonNull Collection<Long> ids, boolean showProgress) {
|
||||
Set<Long> selectedConversations = new HashSet<>(ids);
|
||||
@@ -1173,21 +1181,14 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
void updateEmptyState(boolean isConversationEmpty) {
|
||||
if (isConversationEmpty) {
|
||||
Log.i(TAG, "Received an empty data set.");
|
||||
list.setVisibility(View.INVISIBLE);
|
||||
emptyState.get().setVisibility(View.VISIBLE);
|
||||
fab.startPulse(3 * 1000);
|
||||
cameraFab.startPulse(3 * 1000);
|
||||
|
||||
SignalStore.onboarding().setShowNewGroup(true);
|
||||
SignalStore.onboarding().setShowInviteFriends(true);
|
||||
} else {
|
||||
list.setVisibility(View.VISIBLE);
|
||||
fab.stopPulse();
|
||||
cameraFab.stopPulse();
|
||||
|
||||
if (emptyState.resolved()) {
|
||||
emptyState.get().setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1456,6 +1457,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
}.executeOnExecutor(SignalExecutors.BOUNDED, threadId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClearFilterClick() {
|
||||
viewModel.toggleUnreadChatsFilter();
|
||||
}
|
||||
|
||||
private class PaymentNotificationListener implements UnreadPaymentsView.Listener {
|
||||
|
||||
private final UnreadPayments unreadPayments;
|
||||
|
||||
@@ -22,9 +22,12 @@ import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
|
||||
class ConversationListSearchAdapter extends RecyclerView.Adapter<ConversationListSearchAdapter.SearchResultViewHolder>
|
||||
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 TYPE_CONVERSATIONS = 1;
|
||||
private static final int TYPE_CONTACTS = 2;
|
||||
private static final int TYPE_MESSAGES = 3;
|
||||
@@ -49,47 +52,69 @@ class ConversationListSearchAdapter extends RecyclerView.Adapter<Conversation
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull SearchResultViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
return new SearchResultViewHolder(LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.conversation_list_item_view, parent, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull SearchResultViewHolder holder, int position) {
|
||||
ThreadRecord conversationResult = getConversationResult(position);
|
||||
|
||||
if (conversationResult != null) {
|
||||
holder.bind(lifecycleOwner, conversationResult, glideRequests, eventListener, locale, searchResult.getQuery());
|
||||
return;
|
||||
}
|
||||
|
||||
Recipient contactResult = getContactResult(position);
|
||||
|
||||
if (contactResult != null) {
|
||||
holder.bind(lifecycleOwner, contactResult, glideRequests, eventListener, locale, searchResult.getQuery());
|
||||
return;
|
||||
}
|
||||
|
||||
MessageResult messageResult = getMessageResult(position);
|
||||
|
||||
if (messageResult != null) {
|
||||
holder.bind(lifecycleOwner, messageResult, glideRequests, eventListener, locale, searchResult.getQuery());
|
||||
public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
if (viewType == VIEW_TYPE_EMPTY) {
|
||||
return new EmptyViewHolder(LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.conversation_list_empty_search_state, parent, false));
|
||||
} else {
|
||||
return new SearchResultViewHolder(LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.conversation_list_item_view, parent, false));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewRecycled(@NonNull SearchResultViewHolder holder) {
|
||||
holder.recycle();
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
|
||||
if (holder instanceof SearchResultViewHolder) {
|
||||
SearchResultViewHolder viewHolder = (SearchResultViewHolder) holder;
|
||||
ThreadRecord conversationResult = getConversationResult(position);
|
||||
|
||||
if (conversationResult != null) {
|
||||
viewHolder.bind(lifecycleOwner, conversationResult, glideRequests, eventListener, locale, searchResult.getQuery());
|
||||
return;
|
||||
}
|
||||
|
||||
Recipient contactResult = getContactResult(position);
|
||||
|
||||
if (contactResult != null) {
|
||||
viewHolder.bind(lifecycleOwner, contactResult, glideRequests, eventListener, locale, searchResult.getQuery());
|
||||
return;
|
||||
}
|
||||
|
||||
MessageResult messageResult = getMessageResult(position);
|
||||
|
||||
if (messageResult != null) {
|
||||
viewHolder.bind(lifecycleOwner, messageResult, glideRequests, eventListener, locale, searchResult.getQuery());
|
||||
}
|
||||
} else if (holder instanceof EmptyViewHolder) {
|
||||
EmptyViewHolder viewHolder = (EmptyViewHolder) holder;
|
||||
viewHolder.bind(searchResult.getQuery());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
if (searchResult.isEmpty()) {
|
||||
return VIEW_TYPE_EMPTY;
|
||||
} else {
|
||||
return VIEW_TYPE_NON_EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
|
||||
if (holder instanceof SearchResultViewHolder) {
|
||||
((SearchResultViewHolder) holder).recycle();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return searchResult.size();
|
||||
return searchResult.isEmpty() ? 1 : searchResult.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getHeaderId(int position) {
|
||||
if (position < 0) {
|
||||
if (position < 0 || searchResult.isEmpty()) {
|
||||
return StickyHeaderDecoration.StickyHeaderAdapter.NO_HEADER_ID;
|
||||
} else if (getConversationResult(position) != null) {
|
||||
return TYPE_CONVERSATIONS;
|
||||
@@ -154,6 +179,20 @@ class ConversationListSearchAdapter extends RecyclerView.Adapter<Conversation
|
||||
void onMessageClicked(@NonNull MessageResult message);
|
||||
}
|
||||
|
||||
static class EmptyViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
private final TextView textView;
|
||||
|
||||
public EmptyViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
textView = itemView.findViewById(R.id.search_no_results);
|
||||
}
|
||||
|
||||
public void bind(@NonNull String query) {
|
||||
textView.setText(textView.getContext().getString(R.string.SearchFragment_no_results, query));
|
||||
}
|
||||
}
|
||||
|
||||
static class SearchResultViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
private final ConversationListItem root;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package org.thoughtcrime.securesms.conversationlist;
|
||||
|
||||
import android.app.Application;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.LiveDataReactiveStreams;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
@@ -17,6 +17,7 @@ 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.model.Conversation;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.UnreadPayments;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData;
|
||||
@@ -40,6 +41,7 @@ import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -50,6 +52,7 @@ import io.reactivex.rxjava3.core.Observable;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
import kotlin.Pair;
|
||||
|
||||
class ConversationListViewModel extends ViewModel {
|
||||
|
||||
@@ -57,30 +60,32 @@ class ConversationListViewModel extends ViewModel {
|
||||
|
||||
private static boolean coldStart = true;
|
||||
|
||||
private final MutableLiveData<Megaphone> megaphone;
|
||||
private final MutableLiveData<SearchResult> searchResult;
|
||||
private final MutableLiveData<ConversationSet> selectedConversations;
|
||||
private final Set<Conversation> internalSelection;
|
||||
private final ConversationListDataSource conversationListDataSource;
|
||||
private final LivePagedData<Long, Conversation> pagedData;
|
||||
private final LiveData<Boolean> hasNoConversations;
|
||||
private final SearchRepository searchRepository;
|
||||
private final MegaphoneRepository megaphoneRepository;
|
||||
private final Debouncer messageSearchDebouncer;
|
||||
private final Debouncer contactSearchDebouncer;
|
||||
private final ThrottledDebouncer updateDebouncer;
|
||||
private final DatabaseObserver.Observer observer;
|
||||
private final Invalidator invalidator;
|
||||
private final CompositeDisposable disposables;
|
||||
private final UnreadPaymentsLiveData unreadPaymentsLiveData;
|
||||
private final UnreadPaymentsRepository unreadPaymentsRepository;
|
||||
private final NotificationProfilesRepository notificationProfilesRepository;
|
||||
private final MutableLiveData<Megaphone> megaphone;
|
||||
private final MutableLiveData<SearchResult> searchResult;
|
||||
private final MutableLiveData<ConversationSet> selectedConversations;
|
||||
private final MutableLiveData<ConversationFilter> conversationFilter;
|
||||
private final LiveData<ConversationListDataSource> conversationListDataSource;
|
||||
private final Set<Conversation> internalSelection;
|
||||
private final LiveData<LivePagedData<Long, Conversation>> pagedData;
|
||||
private final LiveData<Boolean> hasNoConversations;
|
||||
private final SearchRepository searchRepository;
|
||||
private final MegaphoneRepository megaphoneRepository;
|
||||
private final Debouncer messageSearchDebouncer;
|
||||
private final Debouncer contactSearchDebouncer;
|
||||
private final ThrottledDebouncer updateDebouncer;
|
||||
private final DatabaseObserver.Observer observer;
|
||||
private final Invalidator invalidator;
|
||||
private final CompositeDisposable disposables;
|
||||
private final UnreadPaymentsLiveData unreadPaymentsLiveData;
|
||||
private final UnreadPaymentsRepository unreadPaymentsRepository;
|
||||
private final NotificationProfilesRepository notificationProfilesRepository;
|
||||
|
||||
private String activeQuery;
|
||||
private SearchResult activeSearchResult;
|
||||
private int pinnedCount;
|
||||
private String activeQuery;
|
||||
private SearchResult activeSearchResult;
|
||||
private int pinnedCount;
|
||||
private ConversationFilterLatch conversationFilterLatch;
|
||||
|
||||
private ConversationListViewModel(@NonNull Application application, @NonNull SearchRepository searchRepository, boolean isArchived) {
|
||||
private ConversationListViewModel(@NonNull SearchRepository searchRepository, boolean isArchived) {
|
||||
this.megaphone = new MutableLiveData<>();
|
||||
this.searchResult = new MutableLiveData<>();
|
||||
this.internalSelection = new HashSet<>();
|
||||
@@ -95,29 +100,37 @@ class ConversationListViewModel extends ViewModel {
|
||||
this.activeSearchResult = SearchResult.EMPTY;
|
||||
this.invalidator = new Invalidator();
|
||||
this.disposables = new CompositeDisposable();
|
||||
this.conversationListDataSource = ConversationListDataSource.create(application, isArchived);
|
||||
this.pagedData = PagedData.createForLiveData(conversationListDataSource,
|
||||
new PagingConfig.Builder()
|
||||
.setPageSize(15)
|
||||
.setBufferPages(2)
|
||||
.build());
|
||||
this.conversationFilter = new MutableLiveData<>(ConversationFilter.OFF);
|
||||
this.conversationFilterLatch = ConversationFilterLatch.RESET;
|
||||
this.conversationListDataSource = Transformations.map(conversationFilter, filter -> ConversationListDataSource.create(filter, isArchived));
|
||||
this.pagedData = Transformations.map(conversationListDataSource, source -> PagedData.createForLiveData(source,
|
||||
new PagingConfig.Builder()
|
||||
.setPageSize(15)
|
||||
.setBufferPages(2)
|
||||
.build()));
|
||||
this.unreadPaymentsLiveData = new UnreadPaymentsLiveData();
|
||||
this.observer = () -> {
|
||||
updateDebouncer.publish(() -> {
|
||||
if (!TextUtils.isEmpty(activeQuery)) {
|
||||
onSearchQueryUpdated(activeQuery);
|
||||
}
|
||||
pagedData.getController().onDataInvalidated();
|
||||
|
||||
LivePagedData<Long, Conversation> data = pagedData.getValue();
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.getController().onDataInvalidated();
|
||||
});
|
||||
};
|
||||
|
||||
this.hasNoConversations = LiveDataUtil.mapAsync(pagedData.getData(), conversations -> {
|
||||
pinnedCount = SignalDatabase.threads().getPinnedConversationListCount();
|
||||
this.hasNoConversations = LiveDataUtil.mapAsync(LiveDataUtil.combineLatest(conversationFilter, getConversationList(), Pair::new), filterAndData -> {
|
||||
pinnedCount = SignalDatabase.threads().getPinnedConversationListCount(ConversationFilter.OFF);
|
||||
|
||||
if (conversations.size() > 0) {
|
||||
if (filterAndData.getSecond().size() > 0) {
|
||||
return false;
|
||||
} else {
|
||||
return SignalDatabase.threads().getArchivedConversationListCount() == 0;
|
||||
return SignalDatabase.threads().getArchivedConversationListCount(filterAndData.getFirst()) == 0;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -137,11 +150,11 @@ class ConversationListViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
@NonNull LiveData<List<Conversation>> getConversationList() {
|
||||
return pagedData.getData();
|
||||
return Transformations.switchMap(pagedData, LivePagedData::getData);
|
||||
}
|
||||
|
||||
@NonNull PagingController getPagingController() {
|
||||
return pagedData.getController();
|
||||
@NonNull LiveData<PagingController<Long>> getPagingController() {
|
||||
return Transformations.map(pagedData, LivePagedData::getController);
|
||||
}
|
||||
|
||||
@NonNull LiveData<List<NotificationProfile>> getNotificationProfiles() {
|
||||
@@ -199,6 +212,25 @@ class ConversationListViewModel extends ViewModel {
|
||||
setSelection(newSelection);
|
||||
}
|
||||
|
||||
void setConversationFilterLatch(@NonNull ConversationFilterLatch latch) {
|
||||
ConversationFilterLatch previous = conversationFilterLatch;
|
||||
conversationFilterLatch = latch;
|
||||
if (previous != latch && latch == ConversationFilterLatch.RESET) {
|
||||
toggleUnreadChatsFilter();
|
||||
}
|
||||
}
|
||||
|
||||
public void toggleUnreadChatsFilter() {
|
||||
ConversationFilter filter = Objects.requireNonNull(conversationFilter.getValue());
|
||||
if (filter == ConversationFilter.UNREAD) {
|
||||
Log.d(TAG, "Setting filter to OFF");
|
||||
conversationFilter.setValue(ConversationFilter.OFF);
|
||||
} else {
|
||||
Log.d(TAG, "Setting filter to UNREAD");
|
||||
conversationFilter.setValue(ConversationFilter.UNREAD);
|
||||
}
|
||||
}
|
||||
|
||||
private void setSelection(@NonNull Collection<Conversation> newSelection) {
|
||||
internalSelection.clear();
|
||||
internalSelection.addAll(newSelection);
|
||||
@@ -206,8 +238,13 @@ class ConversationListViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
void onSelectAllClick() {
|
||||
ConversationListDataSource dataSource = conversationListDataSource.getValue();
|
||||
if (dataSource == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
disposables.add(
|
||||
Single.fromCallable(() -> conversationListDataSource.load(0, conversationListDataSource.size(), disposables::isDisposed))
|
||||
Single.fromCallable(() -> dataSource.load(0, dataSource.size(), disposables::isDisposed))
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(this::setSelection)
|
||||
@@ -301,7 +338,7 @@ class ConversationListViewModel extends ViewModel {
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new ConversationListViewModel(ApplicationDependencies.getApplication(), new SearchRepository(noteToSelfTitle), isArchived));
|
||||
return modelClass.cast(new ConversationListViewModel(new SearchRepository(noteToSelfTitle), isArchived));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ public class Conversation {
|
||||
THREAD,
|
||||
PINNED_HEADER,
|
||||
UNPINNED_HEADER,
|
||||
ARCHIVED_FOOTER
|
||||
ARCHIVED_FOOTER,
|
||||
CONVERSATION_FILTER_FOOTER,
|
||||
CONVERSATION_FILTER_EMPTY,
|
||||
EMPTY
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.thoughtcrime.securesms.conversationlist.model
|
||||
|
||||
/**
|
||||
* Describes what conversations should display in the
|
||||
* conversation list.
|
||||
*/
|
||||
enum class ConversationFilter {
|
||||
/**
|
||||
* No filtering is applied to the conversation list
|
||||
*/
|
||||
OFF,
|
||||
|
||||
/**
|
||||
* Only unread chats will be displayed in the conversation list
|
||||
*/
|
||||
UNREAD,
|
||||
|
||||
/**
|
||||
* Only muted chats will be displayed in the conversation list
|
||||
*/
|
||||
MUTED,
|
||||
|
||||
/**
|
||||
* Only group chats will be displayed in the conversation list
|
||||
*/
|
||||
GROUPS
|
||||
}
|
||||
@@ -12,10 +12,11 @@ import org.signal.core.util.CursorUtil;
|
||||
|
||||
public class ConversationReader extends ThreadDatabase.StaticReader {
|
||||
|
||||
public static final String[] HEADER_COLUMN = {"header"};
|
||||
public static final String[] ARCHIVED_COLUMNS = {"header", "count"};
|
||||
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[] HEADER_COLUMN = { "header" };
|
||||
public static final String[] ARCHIVED_COLUMNS = { "header", "count" };
|
||||
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() };
|
||||
|
||||
private final Cursor cursor;
|
||||
|
||||
@@ -43,11 +44,16 @@ public class ConversationReader extends ThreadDatabase.StaticReader {
|
||||
if (type == Conversation.Type.ARCHIVED_FOOTER) {
|
||||
count = CursorUtil.requireInt(cursor, ARCHIVED_COLUMNS[1]);
|
||||
}
|
||||
|
||||
return buildThreadRecordForType(type, count);
|
||||
}
|
||||
|
||||
public static ThreadRecord buildThreadRecordForType(@NonNull Conversation.Type type, int count) {
|
||||
return new ThreadRecord.Builder(-(100 + type.ordinal()))
|
||||
.setBody(type.toString())
|
||||
.setDate(100)
|
||||
.setRecipient(Recipient.UNKNOWN)
|
||||
.setUnreadCount(count)
|
||||
.build();
|
||||
.setBody(type.toString())
|
||||
.setDate(100)
|
||||
.setRecipient(Recipient.UNKNOWN)
|
||||
.setUnreadCount(count)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.drafts
|
||||
@@ -132,7 +133,8 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
|
||||
val CREATE_INDEXS = arrayOf(
|
||||
"CREATE INDEX IF NOT EXISTS thread_recipient_id_index ON $TABLE_NAME ($RECIPIENT_ID);",
|
||||
"CREATE INDEX IF NOT EXISTS archived_count_index ON $TABLE_NAME ($ARCHIVED, $MEANINGFUL_MESSAGES);",
|
||||
"CREATE INDEX IF NOT EXISTS thread_pinned_index ON $TABLE_NAME ($PINNED);"
|
||||
"CREATE INDEX IF NOT EXISTS thread_pinned_index ON $TABLE_NAME ($PINNED);",
|
||||
"CREATE INDEX IF NOT EXISTS thread_read ON $TABLE_NAME ($READ);"
|
||||
)
|
||||
|
||||
private val THREAD_PROJECTION = arrayOf(
|
||||
@@ -754,7 +756,7 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
|
||||
}
|
||||
|
||||
fun getArchivedRecipients(): Set<RecipientId> {
|
||||
return getArchivedConversationList().readToList { cursor ->
|
||||
return getArchivedConversationList(ConversationFilter.OFF).readToList { cursor ->
|
||||
RecipientId.from(cursor.requireLong(RECIPIENT_ID))
|
||||
}.toSet()
|
||||
}
|
||||
@@ -775,16 +777,18 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
|
||||
return positions
|
||||
}
|
||||
|
||||
fun getArchivedConversationList(offset: Long = 0, limit: Long = 0): Cursor {
|
||||
val query = createQuery("$ARCHIVED = ? AND $MEANINGFUL_MESSAGES != 0", offset, limit, preferPinned = false)
|
||||
fun getArchivedConversationList(conversationFilter: ConversationFilter, offset: Long = 0, limit: Long = 0): Cursor {
|
||||
val filterQuery = conversationFilter.toQuery()
|
||||
val query = createQuery("$ARCHIVED = ? AND $MEANINGFUL_MESSAGES != 0 $filterQuery", offset, limit, preferPinned = false)
|
||||
return readableDatabase.rawQuery(query, arrayOf("1"))
|
||||
}
|
||||
|
||||
fun getUnarchivedConversationList(pinned: Boolean, offset: Long, limit: Long): Cursor {
|
||||
fun getUnarchivedConversationList(conversationFilter: ConversationFilter, pinned: Boolean, offset: Long, limit: Long): Cursor {
|
||||
val filterQuery = conversationFilter.toQuery()
|
||||
val where = if (pinned) {
|
||||
"$ARCHIVED = 0 AND $PINNED != 0"
|
||||
"$ARCHIVED = 0 AND $PINNED != 0 $filterQuery"
|
||||
} else {
|
||||
"$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0"
|
||||
"$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0 $filterQuery"
|
||||
}
|
||||
|
||||
val query = if (pinned) {
|
||||
@@ -796,11 +800,12 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
|
||||
return readableDatabase.rawQuery(query, null)
|
||||
}
|
||||
|
||||
fun getArchivedConversationListCount(): Int {
|
||||
fun getArchivedConversationListCount(conversationFilter: ConversationFilter): Int {
|
||||
val filterQuery = conversationFilter.toQuery()
|
||||
return readableDatabase
|
||||
.select("COUNT(*)")
|
||||
.from(TABLE_NAME)
|
||||
.where("$ARCHIVED = 1 AND $MEANINGFUL_MESSAGES != 0")
|
||||
.where("$ARCHIVED = 1 AND $MEANINGFUL_MESSAGES != 0 $filterQuery")
|
||||
.run()
|
||||
.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
@@ -811,11 +816,12 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
|
||||
}
|
||||
}
|
||||
|
||||
fun getPinnedConversationListCount(): Int {
|
||||
fun getPinnedConversationListCount(conversationFilter: ConversationFilter): Int {
|
||||
val filterQuery = conversationFilter.toQuery()
|
||||
return readableDatabase
|
||||
.select("COUNT(*)")
|
||||
.from(TABLE_NAME)
|
||||
.where("$ARCHIVED = 0 AND $PINNED != 0")
|
||||
.where("$ARCHIVED = 0 AND $PINNED != 0 $filterQuery")
|
||||
.run()
|
||||
.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
@@ -826,11 +832,12 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
|
||||
}
|
||||
}
|
||||
|
||||
fun getUnarchivedConversationListCount(): Int {
|
||||
fun getUnarchivedConversationListCount(conversationFilter: ConversationFilter): Int {
|
||||
val filterQuery = conversationFilter.toQuery()
|
||||
return readableDatabase
|
||||
.select("COUNT(*)")
|
||||
.from(TABLE_NAME)
|
||||
.where("$ARCHIVED = 0 AND ($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0)")
|
||||
.where("$ARCHIVED = 0 AND ($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0) $filterQuery")
|
||||
.run()
|
||||
.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
@@ -888,7 +895,7 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
|
||||
.run()
|
||||
}
|
||||
|
||||
var pinnedCount = getPinnedConversationListCount()
|
||||
var pinnedCount = getPinnedConversationListCount(ConversationFilter.OFF)
|
||||
|
||||
for (threadId in threadIds) {
|
||||
pinnedCount++
|
||||
@@ -1587,6 +1594,15 @@ class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Databas
|
||||
return this.trimIndent().split("\n").joinToString(separator = " ")
|
||||
}
|
||||
|
||||
private fun ConversationFilter.toQuery(): String {
|
||||
return when (this) {
|
||||
ConversationFilter.OFF -> ""
|
||||
ConversationFilter.UNREAD -> " AND $READ != ${ReadStatus.READ.serialize()}"
|
||||
ConversationFilter.MUTED -> error("This filter selection isn't supported yet.")
|
||||
ConversationFilter.GROUPS -> error("This filter selection isn't supported yet.")
|
||||
}
|
||||
}
|
||||
|
||||
object DistributionTypes {
|
||||
const val DEFAULT = 2
|
||||
const val BROADCAST = 1
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V160_SmsMmsExported
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V161_StorySendMessageIdIndex
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V162_ThreadUnreadSelfMentionCountFixup
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V163_RemoteMegaphoneSnoozeSupportMigration
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V164_ThreadDatabaseReadIndexMigration
|
||||
|
||||
/**
|
||||
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
|
||||
@@ -27,7 +28,7 @@ object SignalDatabaseMigrations {
|
||||
|
||||
val TAG: String = Log.tag(SignalDatabaseMigrations.javaClass)
|
||||
|
||||
const val DATABASE_VERSION = 163
|
||||
const val DATABASE_VERSION = 164
|
||||
|
||||
@JvmStatic
|
||||
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
@@ -90,6 +91,10 @@ object SignalDatabaseMigrations {
|
||||
if (oldVersion < 163) {
|
||||
V163_RemoteMegaphoneSnoozeSupportMigration.migrate(context, db, oldVersion, newVersion)
|
||||
}
|
||||
|
||||
if (oldVersion < 164) {
|
||||
V164_ThreadDatabaseReadIndexMigration.migrate(context, db, oldVersion, newVersion)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
|
||||
object V164_ThreadDatabaseReadIndexMigration : SignalDatabaseMigration {
|
||||
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS thread_read ON thread (read);")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import org.signal.core.util.Hex
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase
|
||||
import org.thoughtcrime.securesms.database.RemoteMegaphoneDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
@@ -150,7 +151,7 @@ class RetrieveRemoteAnnouncementsJob private constructor(private val force: Bool
|
||||
}
|
||||
|
||||
if (!values.hasMetConversationRequirement) {
|
||||
if ((SignalDatabase.threads.getArchivedConversationListCount() + SignalDatabase.threads.getUnarchivedConversationListCount()) < 6) {
|
||||
if ((SignalDatabase.threads.getArchivedConversationListCount(ConversationFilter.OFF) + SignalDatabase.threads.getUnarchivedConversationListCount(ConversationFilter.OFF)) < 6) {
|
||||
Log.i(TAG, "User does not have enough conversations to show release channel")
|
||||
values.nextScheduledCheck = System.currentTimeMillis() + RETRIEVE_FREQUENCY
|
||||
return
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.MainActivity;
|
||||
import org.thoughtcrime.securesms.NewConversationActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
@@ -76,8 +77,8 @@ public class UserNotificationMigrationJob extends MigrationJob {
|
||||
|
||||
ThreadDatabase threadDatabase = SignalDatabase.threads();
|
||||
|
||||
int threadCount = threadDatabase.getUnarchivedConversationListCount() +
|
||||
threadDatabase.getArchivedConversationListCount();
|
||||
int threadCount = threadDatabase.getUnarchivedConversationListCount(ConversationFilter.OFF) +
|
||||
threadDatabase.getArchivedConversationListCount(ConversationFilter.OFF);
|
||||
|
||||
if (threadCount >= 3) {
|
||||
Log.w(TAG, "Already have 3 or more threads. Skipping.");
|
||||
|
||||
@@ -108,6 +108,7 @@ public final class FeatureFlags {
|
||||
public static final String PAYPAL_DISABLED_REGIONS = "global.donations.paypalDisabledRegions";
|
||||
private static final String CDS_HARD_LIMIT = "android.cds.hardLimit";
|
||||
private static final String PAYMENTS_IN_CHAT_MESSAGES = "android.payments.inChatMessages";
|
||||
private static final String CHAT_FILTERS = "android.chat.filters";
|
||||
|
||||
/**
|
||||
* We will only store remote values for flags in this set. If you want a flag to be controllable
|
||||
@@ -168,7 +169,8 @@ public final class FeatureFlags {
|
||||
PAYPAL_DISABLED_REGIONS,
|
||||
KEEP_MUTED_CHATS_ARCHIVED,
|
||||
CDS_HARD_LIMIT,
|
||||
PAYMENTS_IN_CHAT_MESSAGES
|
||||
PAYMENTS_IN_CHAT_MESSAGES,
|
||||
CHAT_FILTERS
|
||||
);
|
||||
|
||||
@VisibleForTesting
|
||||
@@ -608,6 +610,13 @@ public final class FeatureFlags {
|
||||
return getInteger(CDS_HARD_LIMIT, 50_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables chat filters. Note that this UI is incomplete.
|
||||
*/
|
||||
public static boolean chatFilters() {
|
||||
return getBoolean(CHAT_FILTERS, false);
|
||||
}
|
||||
|
||||
/** Only for rendering debug info. */
|
||||
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
|
||||
return new TreeMap<>(REMOTE_VALUES);
|
||||
|
||||
Reference in New Issue
Block a user