diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/CompositeConversationListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/CompositeConversationListAdapter.java new file mode 100644 index 0000000000..58c84b5aa0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/CompositeConversationListAdapter.java @@ -0,0 +1,150 @@ +package org.thoughtcrime.securesms.conversationlist; + +import android.view.LayoutInflater; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.paging.PagedList; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversationlist.model.Conversation; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter; +import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapter; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +class CompositeConversationListAdapter extends RecyclerViewConcatenateAdapter { + + private final FixedViewsAdapter pinnedHeaderAdapter; + private final ConversationListAdapter pinnedAdapter; + private final FixedViewsAdapter unpinnedHeaderAdapter; + private final ConversationListAdapter unpinnedAdapter; + + CompositeConversationListAdapter(@NonNull RecyclerView rv, + @NonNull GlideRequests glideRequests, + @NonNull ConversationListAdapter.OnConversationClickListener onConversationClickListener) + { + + TextView pinned = (TextView) LayoutInflater.from(rv.getContext()).inflate(R.layout.conversation_list_item_header, rv, false); + TextView unpinned = (TextView) LayoutInflater.from(rv.getContext()).inflate(R.layout.conversation_list_item_header, rv, false); + + pinned.setText(rv.getContext().getString(R.string.conversation_list__pinned)); + unpinned.setText(rv.getContext().getString(R.string.conversation_list__chats)); + + this.pinnedHeaderAdapter = new FixedViewsAdapter(pinned); + this.pinnedAdapter = new ConversationListAdapter(glideRequests, onConversationClickListener); + this.unpinnedHeaderAdapter = new FixedViewsAdapter(unpinned); + this.unpinnedAdapter = new ConversationListAdapter(glideRequests, onConversationClickListener); + + pinnedHeaderAdapter.hide(); + unpinnedHeaderAdapter.hide(); + + unpinnedAdapter.registerAdapterDataObserver(new UnpinnedAdapterDataObserver()); + pinnedAdapter.registerAdapterDataObserver(new PinnedAdapterDataObserver()); + + addAdapter(pinnedHeaderAdapter); + addAdapter(pinnedAdapter); + addAdapter(unpinnedHeaderAdapter); + addAdapter(unpinnedAdapter); + } + + public void submitPinnedList(@NonNull PagedList pinnedConversations) { + pinnedAdapter.submitList(pinnedConversations); + } + + public void submitUnpinnedList(@NonNull PagedList unpinnedConversations) { + unpinnedAdapter.submitList(unpinnedConversations); + } + + public void setTypingThreads(@NonNull Set threads) { + pinnedAdapter.setTypingThreads(threads); + unpinnedAdapter.setTypingThreads(threads); + } + + public @NonNull Set getBatchSelectionIds() { + HashSet hashSet = new HashSet(); + + hashSet.addAll(pinnedAdapter.getBatchSelectionIds()); + hashSet.addAll(unpinnedAdapter.getBatchSelectionIds()); + + return hashSet; + } + + public void selectAllThreads() { + pinnedAdapter.selectAllThreads(); + unpinnedAdapter.selectAllThreads(); + } + + public void updateArchived(int archivedCount) { + unpinnedAdapter.updateArchived(archivedCount); + } + + public void toggleConversationInBatchSet(@NonNull Conversation conversation) { + if (conversation.getThreadRecord().isPinned()) { + pinnedAdapter.toggleConversationInBatchSet(conversation); + } else { + unpinnedAdapter.toggleConversationInBatchSet(conversation); + } + } + + public void initializeBatchMode(boolean toggle) { + pinnedAdapter.initializeBatchMode(toggle); + unpinnedAdapter.initializeBatchMode(toggle); + } + + public long getPinnedItemCount() { + return pinnedAdapter.getItemCount(); + } + + public @NonNull Collection getBatchSelection() { + Set conversations = new HashSet<>(); + + conversations.addAll(pinnedAdapter.getBatchSelection()); + conversations.addAll(unpinnedAdapter.getBatchSelection()); + + return conversations; + } + + private class UnpinnedAdapterDataObserver extends RecyclerView.AdapterDataObserver { + @Override + public void onItemRangeRemoved(int positionStart, int itemCount) { + if (unpinnedAdapter.getItemCount() == 0) { + unpinnedHeaderAdapter.hide(); + } + } + + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + if (itemCount > 0 && pinnedAdapter.getItemCount() > 0) { + unpinnedHeaderAdapter.show(); + } + } + } + + private class PinnedAdapterDataObserver extends RecyclerView.AdapterDataObserver { + @Override + public void onItemRangeRemoved(int positionStart, int itemCount) { + if (pinnedAdapter.getItemCount() == 0) { + pinnedHeaderAdapter.hide(); + unpinnedHeaderAdapter.hide(); + } + } + + @Override + public void onItemRangeInserted(int positionStart, int itemCount) { + if (itemCount > 0) { + pinnedHeaderAdapter.show(); + + if (unpinnedAdapter.getItemCount() > 0) { + unpinnedHeaderAdapter.show(); + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java index de884c0535..eb710a4e6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListAdapter.java @@ -29,7 +29,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; -class ConversationListAdapter extends PagedListAdapter { +class ConversationListAdapter extends PagedListAdapter { private static final int TYPE_THREAD = 1; private static final int TYPE_ACTION = 2; @@ -55,13 +55,13 @@ class ConversationListAdapter extends PagedListAdapter { - int position = holder.getAdapterPosition(); + int position = holder.getLocalAdapterPosition(); if (position != RecyclerView.NO_POSITION) { onConversationClickListener.onShowArchiveClick(); @@ -71,10 +71,10 @@ class ConversationListAdapter extends PagedListAdapter { - int position = holder.getAdapterPosition(); + int position = holder.getLocalAdapterPosition(); if (position != RecyclerView.NO_POSITION) { onConversationClickListener.onConversationClick(getItem(position)); @@ -82,7 +82,7 @@ class ConversationListAdapter extends PagedListAdapter { - int position = holder.getAdapterPosition(); + int position = holder.getLocalAdapterPosition(); if (position != RecyclerView.NO_POSITION) { return onConversationClickListener.onConversationLongClick(getItem(position)); @@ -101,7 +101,7 @@ class ConversationListAdapter extends PagedListAdapter payloads) { + public void onBindViewHolder(@NonNull BaseViewHolder holder, int position, @NonNull List payloads) { if (payloads.isEmpty()) { onBindViewHolder(holder, position); } else { @@ -120,8 +120,9 @@ class ConversationListAdapter extends PagedListAdapter create() { - return ConversationListDataSource.create(context, invalidator, isArchived); + return ConversationListDataSource.create(context, invalidator, isPinned, isArchived); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 3bc63d78ce..afd89fa417 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -58,6 +58,7 @@ import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.ProcessLifecycleOwner; import androidx.lifecycle.ViewModelProviders; +import androidx.paging.PagedList; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -100,7 +101,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.ReminderUpdateEvent; import org.thoughtcrime.securesms.insights.InsightsLauncher; import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob; -import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mediasend.MediaSendActivity; @@ -147,6 +147,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode private static final String TAG = Log.tag(ConversationListFragment.class); + private static final int MAXIMUM_PINNED_CONVERSATIONS = 4; + private static final int[] EMPTY_IMAGES = new int[] { R.drawable.empty_inbox_1, R.drawable.empty_inbox_2, R.drawable.empty_inbox_3, @@ -166,7 +168,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode private View toolbarShadow; private ConversationListViewModel viewModel; private RecyclerView.Adapter activeAdapter; - private ConversationListAdapter defaultAdapter; + private CompositeConversationListAdapter defaultAdapter; private ConversationListSearchAdapter searchAdapter; private StickyHeaderDecoration searchAdapterDecoration; private ViewGroup megaphoneContainer; @@ -280,7 +282,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode EventBus.getDefault().unregister(this); } - @Override public void onPrepareOptionsMenu(Menu menu) { MenuInflater inflater = requireActivity().getMenuInflater(); @@ -457,7 +458,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode } private void initializeListAdapters() { - defaultAdapter = new ConversationListAdapter(GlideApp.with(this), this); + defaultAdapter = new CompositeConversationListAdapter(list, GlideApp.with(this), this); searchAdapter = new ConversationListSearchAdapter(GlideApp.with(this), this, Locale.getDefault()); searchAdapterDecoration = new StickyHeaderDecoration(searchAdapter, false, false); @@ -502,7 +503,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode viewModel.getSearchResult().observe(getViewLifecycleOwner(), this::onSearchResultChanged); viewModel.getMegaphone().observe(getViewLifecycleOwner(), this::onMegaphoneChanged); - viewModel.getConversationList().observe(getViewLifecycleOwner(), this::onSubmitList); + viewModel.getConversationList().observe(getViewLifecycleOwner(), this::onSubmitUnpinnedList); + viewModel.getPinnedConversations().observe(getViewLifecycleOwner(), this::onSubmitPinnedList); + viewModel.hasNoConversations().observe(getViewLifecycleOwner(), this::updateEmptyState); ProcessLifecycleOwner.get().getLifecycle().addObserver(new DefaultLifecycleObserver() { @Override @@ -740,6 +743,43 @@ public class ConversationListFragment extends MainFragment implements ActionMode alert.show(); } + private void handlePinAllSelected() { + final Set toPin = new HashSet<>(Stream.of(defaultAdapter.getBatchSelection()) + .filterNot(conversation -> conversation.getThreadRecord().isPinned()) + .map(conversation -> conversation.getThreadRecord().getThreadId()) + .toList()); + + if (toPin.size() + defaultAdapter.getPinnedItemCount() > MAXIMUM_PINNED_CONVERSATIONS) { + Snackbar.make(fab, + getString(R.string.conversation_list__you_can_only_pin_up_to_d_chats, MAXIMUM_PINNED_CONVERSATIONS), + Snackbar.LENGTH_LONG) + .setTextColor(Color.WHITE) + .show(); + actionMode.finish(); + return; + } + + SimpleTask.run(SignalExecutors.BOUNDED, () -> { + ThreadDatabase db = DatabaseFactory.getThreadDatabase(ApplicationDependencies.getApplication()); + + db.pinConversations(toPin); + + return null; + }, unused -> actionMode.finish()); + } + + private void handleUnpinAllSelected() { + final Set toPin = new HashSet<>(defaultAdapter.getBatchSelectionIds()); + + SimpleTask.run(SignalExecutors.BOUNDED, () -> { + ThreadDatabase db = DatabaseFactory.getThreadDatabase(ApplicationDependencies.getApplication()); + + db.unpinConversations(toPin); + + return null; + }, unused -> actionMode.finish()); + } + private void handleSelectAllThreads() { defaultAdapter.selectAllThreads(); actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelectionIds().size())); @@ -749,8 +789,19 @@ public class ConversationListFragment extends MainFragment implements ActionMode getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1); } - private void onSubmitList(@NonNull ConversationListViewModel.ConversationList conversationList) { - if (conversationList.isEmpty()) { + private void onSubmitPinnedList(@NonNull PagedList pinnedConversations) { + defaultAdapter.submitPinnedList(pinnedConversations); + } + + private void onSubmitUnpinnedList(@NonNull ConversationListViewModel.ConversationList conversationList) { + defaultAdapter.submitUnpinnedList(conversationList.getConversations()); + defaultAdapter.updateArchived(conversationList.getArchivedCount()); + + onPostSubmitList(); + } + + private void updateEmptyState(boolean isConversationEmpty) { + if (isConversationEmpty) { Log.i(TAG, "Received an empty data set."); list.setVisibility(View.INVISIBLE); emptyState.setVisibility(View.VISIBLE); @@ -763,11 +814,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode fab.stopPulse(); cameraFab.stopPulse(); } - - defaultAdapter.submitList(conversationList.getConversations()); - defaultAdapter.updateArchived(conversationList.getArchivedCount()); - - onPostSubmitList(); } protected void onPostSubmitList() { @@ -791,11 +837,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode @Override public boolean onConversationLongClick(Conversation conversation) { - actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(ConversationListFragment.this); - defaultAdapter.initializeBatchMode(true); defaultAdapter.toggleConversationInBatchSet(conversation); + actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(ConversationListFragment.this); + return true; } @@ -803,6 +849,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode public boolean onCreateActionMode(ActionMode mode, Menu menu) { MenuInflater inflater = getActivity().getMenuInflater(); + inflater.inflate(R.menu.conversation_list_batch_pin, menu); inflater.inflate(getActionModeMenuRes(), menu); inflater.inflate(R.menu.conversation_list_batch, menu); @@ -831,6 +878,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode switch (item.getItemId()) { case R.id.menu_select_all: handleSelectAllThreads(); return true; case R.id.menu_delete_selected: handleDeleteAllSelected(); return true; + case R.id.menu_pin_selected: handlePinAllSelected(); return true; + case R.id.menu_unpin_selected: handleUnpinAllSelected(); return true; case R.id.menu_archive_selected: handleArchiveAllSelected(); return true; case R.id.menu_mark_as_read: handleMarkSelectedAsRead(); return true; case R.id.menu_mark_as_unread: handleMarkSelectedAsUnread(); return true; @@ -875,7 +924,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode } private void setCorrectMenuVisibility(@NonNull Menu menu) { - boolean hasUnread = Stream.of(defaultAdapter.getBatchSelection()).anyMatch(conversation -> !conversation.getThreadRecord().isRead()); + boolean hasUnread = Stream.of(defaultAdapter.getBatchSelection()).anyMatch(conversation -> !conversation.getThreadRecord().isRead()); + boolean hasUnpinned = Stream.of(defaultAdapter.getBatchSelection()).anyMatch(conversation -> !conversation.getThreadRecord().isPinned()); + boolean canPin = defaultAdapter.getPinnedItemCount() < MAXIMUM_PINNED_CONVERSATIONS; if (hasUnread) { menu.findItem(R.id.menu_mark_as_unread).setVisible(false); @@ -884,6 +935,17 @@ public class ConversationListFragment extends MainFragment implements ActionMode menu.findItem(R.id.menu_mark_as_unread).setVisible(true); menu.findItem(R.id.menu_mark_as_read).setVisible(false); } + + if (!isArchived() && hasUnpinned && canPin) { + menu.findItem(R.id.menu_pin_selected).setVisible(true); + menu.findItem(R.id.menu_unpin_selected).setVisible(false); + } else if (!isArchived() && !hasUnpinned) { + menu.findItem(R.id.menu_pin_selected).setVisible(false); + menu.findItem(R.id.menu_unpin_selected).setVisible(true); + } else { + menu.findItem(R.id.menu_pin_selected).setVisible(false); + menu.findItem(R.id.menu_unpin_selected).setVisible(false); + } } protected @IdRes int getToolbarRes() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java index 700158c5e7..83d97a17fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.java @@ -35,15 +35,16 @@ class ConversationListViewModel extends ViewModel { private static final String TAG = Log.tag(ConversationListViewModel.class); - private final Application application; - private final MutableLiveData megaphone; - private final MutableLiveData searchResult; - private final LiveData conversationList; - private final SearchRepository searchRepository; - private final MegaphoneRepository megaphoneRepository; - private final Debouncer debouncer; - private final ContentObserver observer; - private final Invalidator invalidator; + private final Application application; + private final MutableLiveData megaphone; + private final MutableLiveData searchResult; + private final LiveData> pinnedList; + private final LiveData conversationList; + private final SearchRepository searchRepository; + private final MegaphoneRepository megaphoneRepository; + private final Debouncer debouncer; + private final ContentObserver observer; + private final Invalidator invalidator; private String lastQuery; @@ -64,7 +65,7 @@ class ConversationListViewModel extends ViewModel { } }; - DataSource.Factory factory = new ConversationListDataSource.Factory(application, invalidator, isArchived); + DataSource.Factory factory = new ConversationListDataSource.Factory(application, invalidator, false, isArchived); PagedList.Config config = new PagedList.Config.Builder() .setPageSize(15) .setInitialLoadSizeHint(30) @@ -96,6 +97,26 @@ class ConversationListViewModel extends ViewModel { return updated; }); + + if (!isArchived) { + DataSource.Factory pinnedFactory = new ConversationListDataSource.Factory(application, invalidator, true, false); + + this.pinnedList = new LivePagedListBuilder<>(pinnedFactory, config).setFetchExecutor(ConversationListDataSource.EXECUTOR) + .setInitialLoadKey(0) + .build(); + } else { + this.pinnedList = new MutableLiveData<>(); + } + } + + public LiveData hasNoConversations() { + return LiveDataUtil.combineLatest(getPinnedConversations(), + getConversationList(), + (pinned, unpinned) -> pinned.isEmpty() && unpinned.isEmpty()); + } + + @NonNull LiveData> getPinnedConversations() { + return pinnedList; } @NonNull LiveData getSearchResult() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index c3b7156c27..7e7ef0e3d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -31,6 +31,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import net.sqlcipher.database.SQLiteDatabase; +import org.jsoup.helper.StringUtil; import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedMember; import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; @@ -96,6 +97,7 @@ public class ThreadDatabase extends Database { public static final String LAST_SEEN = "last_seen"; public static final String HAS_SENT = "has_sent"; private static final String LAST_SCROLLED = "last_scrolled"; + private static final String PINNED = "pinned"; public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + DATE + " INTEGER DEFAULT 0, " + @@ -118,16 +120,18 @@ public class ThreadDatabase extends Database { HAS_SENT + " INTEGER DEFAULT 0, " + READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + UNREAD_COUNT + " INTEGER DEFAULT 0, " + - LAST_SCROLLED + " INTEGER DEFAULT 0);"; + LAST_SCROLLED + " INTEGER DEFAULT 0, " + + PINNED + " INTEGER DEFAULT 0);"; public static final String[] CREATE_INDEXS = { "CREATE INDEX IF NOT EXISTS thread_recipient_ids_index ON " + TABLE_NAME + " (" + RECIPIENT_ID + ");", "CREATE INDEX IF NOT EXISTS archived_count_index ON " + TABLE_NAME + " (" + ARCHIVED + ", " + MESSAGE_COUNT + ");", + "CREATE INDEX IF NOT EXISTS thread_pinned_index ON " + TABLE_NAME + " (" + PINNED + ");", }; private static final String[] THREAD_PROJECTION = { ID, DATE, MESSAGE_COUNT, RECIPIENT_ID, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, TYPE, ERROR, SNIPPET_TYPE, - SNIPPET_URI, SNIPPET_CONTENT_TYPE, SNIPPET_EXTRAS, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, READ_RECEIPT_COUNT, LAST_SCROLLED + SNIPPET_URI, SNIPPET_CONTENT_TYPE, SNIPPET_EXTRAS, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, READ_RECEIPT_COUNT, LAST_SCROLLED, PINNED }; private static final List TYPED_THREAD_PROJECTION = Stream.of(THREAD_PROJECTION) @@ -579,8 +583,12 @@ public class ThreadDatabase extends Database { return positions; } - public Cursor getConversationList(long offset, long limit) { - return getConversationList("0", offset, limit); + public Cursor getPinnedConversationList(long offset, long limit) { + return getUnarchivedConversationList("1", offset, limit); + } + + public Cursor getUnpinnedConversationList(long offset, long limit) { + return getUnarchivedConversationList("0", offset, limit); } public Cursor getArchivedConversationList(long offset, long limit) { @@ -591,6 +599,16 @@ public class ThreadDatabase extends Database { return getConversationList(archived, 0, 0); } + private Cursor getUnarchivedConversationList(@NonNull String pinned, long offset, long limit) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String query = createQuery(ARCHIVED + " = 0 AND " + MESSAGE_COUNT + " != 0 AND " + PINNED + " = ?", offset, limit); + Cursor cursor = db.rawQuery(query, new String[]{pinned}); + + setNotifyConversationListListeners(cursor); + + return cursor; + } + private Cursor getConversationList(@NonNull String archived, long offset, long limit) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); String query = createQuery(ARCHIVED + " = ? AND " + MESSAGE_COUNT + " != 0", offset, limit); @@ -601,19 +619,19 @@ public class ThreadDatabase extends Database { return cursor; } - public int getUnarchivedConversationListCount() { - return getConversationListCount(false); + public int getPinnedConversationListCount() { + return getUnarchivedConversationListCount(true); + } + + public int getUnpinnedConversationListCount() { + return getUnarchivedConversationListCount(false); } public int getArchivedConversationListCount() { - return getConversationListCount(true); - } - - private int getConversationListCount(boolean archived) { SQLiteDatabase db = databaseHelper.getReadableDatabase(); String[] columns = new String[] { "COUNT(*)" }; String query = ARCHIVED + " = ? AND " + MESSAGE_COUNT + " != 0"; - String[] args = new String[] { archived ? "1" : "0" }; + String[] args = new String[] {"1"}; try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, null)) { if (cursor != null && cursor.moveToFirst()) { @@ -624,6 +642,46 @@ public class ThreadDatabase extends Database { return 0; } + private int getUnarchivedConversationListCount(boolean pinned) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String[] columns = new String[] { "COUNT(*)" }; + String query = ARCHIVED + " = 0 AND " + MESSAGE_COUNT + " != 0 AND " + PINNED + " = ?"; + String[] args = new String[] { pinned ? "1" : "0" }; + + try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + + return 0; + } + + public void pinConversations(@NonNull Set threadIds) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + ContentValues contentValues = new ContentValues(1); + String placeholders = StringUtil.join(Stream.of(threadIds).map(unused -> "?").toList(), ","); + String selection = ID + " IN (" + placeholders + ")"; + + contentValues.put(PINNED, 1); + + db.update(TABLE_NAME, contentValues, selection, SqlUtil.buildArgs(Stream.of(threadIds).toArray())); + + notifyConversationListListeners(); + } + + public void unpinConversations(@NonNull Set threadIds) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + ContentValues contentValues = new ContentValues(1); + String placeholders = StringUtil.join(Stream.of(threadIds).map(unused -> "?").toList(), ","); + String selection = ID + " IN (" + placeholders + ")"; + + contentValues.put(PINNED, 0); + + db.update(TABLE_NAME, contentValues, selection, SqlUtil.buildArgs(Stream.of(threadIds).toArray())); + notifyConversationListListeners(); + } + public void archiveConversation(long threadId) { SQLiteDatabase db = databaseHelper.getWritableDatabase(); ContentValues contentValues = new ContentValues(1); @@ -1122,6 +1180,7 @@ public class ThreadDatabase extends Database { .setCount(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MESSAGE_COUNT))) .setUnreadCount(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT))) .setForcedUnread(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.READ)) == ReadStatus.FORCED_UNREAD.serialize()) + .setPinned(CursorUtil.requireBoolean(cursor, ThreadDatabase.PINNED)) .setExtra(extra) .build(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 069947d15e..c4d27dbe7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -141,8 +141,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int BORDERLESS = 66; private static final int REMAPPED_RECORDS = 67; private static final int MENTIONS = 68; + private static final int PINNED_CONVERSATIONS = 69; - private static final int DATABASE_VERSION = 68; + private static final int DATABASE_VERSION = 69; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -991,6 +992,11 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL("ALTER TABLE recipient ADD COLUMN mention_setting INTEGER DEFAULT 0"); } + if (oldVersion < PINNED_CONVERSATIONS) { + db.execSQL("ALTER TABLE thread ADD COLUMN pinned INTEGER DEFAULT 0"); + db.execSQL("CREATE INDEX IF NOT EXISTS thread_pinned_index ON thread (pinned)"); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java index 8b38a1d3e1..ee7028824c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -55,6 +55,7 @@ public final class ThreadRecord { private final boolean archived; private final long expiresIn; private final long lastSeen; + private final boolean isPinned; private ThreadRecord(@NonNull Builder builder) { this.threadId = builder.threadId; @@ -75,6 +76,7 @@ public final class ThreadRecord { this.archived = builder.archived; this.expiresIn = builder.expiresIn; this.lastSeen = builder.lastSeen; + this.isPinned = builder.isPinned; } public long getThreadId() { @@ -187,6 +189,10 @@ public final class ThreadRecord { else return true; } + public boolean isPinned() { + return isPinned; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -205,6 +211,7 @@ public final class ThreadRecord { archived == that.archived && expiresIn == that.expiresIn && lastSeen == that.lastSeen && + isPinned == that.isPinned && body.equals(that.body) && recipient.equals(that.recipient) && Objects.equals(snippetUri, that.snippetUri) && @@ -231,7 +238,8 @@ public final class ThreadRecord { distributionType, archived, expiresIn, - lastSeen); + lastSeen, + isPinned); } public static class Builder { @@ -253,6 +261,7 @@ public final class ThreadRecord { private boolean archived; private long expiresIn; private long lastSeen; + private boolean isPinned; public Builder(long threadId) { this.threadId = threadId; @@ -348,6 +357,11 @@ public final class ThreadRecord { return this; } + public Builder setPinned(boolean isPinned) { + this.isPinned = isPinned; + return this; + } + public ThreadRecord build() { if (distributionType == ThreadDatabase.DistributionTypes.CONVERSATION) { Preconditions.checkArgument(threadId > 0); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewConcatenateAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewConcatenateAdapter.java index c61bec40e2..8ce996789c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewConcatenateAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/adapter/RecyclerViewConcatenateAdapter.java @@ -46,10 +46,10 @@ public class RecyclerViewConcatenateAdapter extends RecyclerView.Adapter adapter; + private final RecyclerViewConcatenateAdapter mergeAdapter; + private final RecyclerView.Adapter adapter; - AdapterDataObserver(RecyclerViewConcatenateAdapter mergeAdapter, RecyclerView.Adapter adapter) { + AdapterDataObserver(RecyclerViewConcatenateAdapter mergeAdapter, RecyclerView.Adapter adapter) { this.mergeAdapter = mergeAdapter; this.adapter = adapter; } @@ -83,7 +83,7 @@ public class RecyclerViewConcatenateAdapter extends RecyclerView.Adapter adapter; + final RecyclerView.Adapter adapter; /** Map of global view types to local view types */ private final SparseIntArray globalViewTypesMap = new SparseIntArray(); @@ -96,7 +96,7 @@ public class RecyclerViewConcatenateAdapter extends RecyclerView.Adapter localItemIdMap = new LongSparseArray<>(); - ChildAdapter(@NonNull RecyclerView.Adapter adapter, @NonNull AdapterDataObserver adapterDataObserver) { + ChildAdapter(@NonNull RecyclerView.Adapter adapter, @NonNull AdapterDataObserver adapterDataObserver) { this.adapter = adapter; this.adapterDataObserver = adapterDataObserver; @@ -153,7 +153,7 @@ public class RecyclerViewConcatenateAdapter extends RecyclerView.Adapter getAdapter() { + RecyclerView.Adapter getAdapter() { return childAdapter.adapter; } } @@ -161,7 +161,7 @@ public class RecyclerViewConcatenateAdapter extends RecyclerView.Adapter adapter) { + public void addAdapter(@NonNull RecyclerView.Adapter adapter) { addAdapter(adapters.size(), adapter); } @@ -169,7 +169,7 @@ public class RecyclerViewConcatenateAdapter extends RecyclerView.Adapter adapter) { + public void addAdapter(int index, @NonNull RecyclerView.Adapter adapter) { AdapterDataObserver adapterDataObserver = new AdapterDataObserver(this, adapter); adapters.add(index, new ChildAdapter(adapter, adapterDataObserver)); notifyDataSetChanged(); diff --git a/app/src/main/res/drawable/ic_pin_outline_24.xml b/app/src/main/res/drawable/ic_pin_outline_24.xml new file mode 100644 index 0000000000..170b0b3e51 --- /dev/null +++ b/app/src/main/res/drawable/ic_pin_outline_24.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_pin_solid_24.xml b/app/src/main/res/drawable/ic_pin_solid_24.xml new file mode 100644 index 0000000000..4d7ad35753 --- /dev/null +++ b/app/src/main/res/drawable/ic_pin_solid_24.xml @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_unpin_outline_24.xml b/app/src/main/res/drawable/ic_unpin_outline_24.xml new file mode 100644 index 0000000000..ea7309066e --- /dev/null +++ b/app/src/main/res/drawable/ic_unpin_outline_24.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_unpin_solid_24.xml b/app/src/main/res/drawable/ic_unpin_solid_24.xml new file mode 100644 index 0000000000..8703936b6c --- /dev/null +++ b/app/src/main/res/drawable/ic_unpin_solid_24.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/layout/conversation_list_item_header.xml b/app/src/main/res/layout/conversation_list_item_header.xml new file mode 100644 index 0000000000..6b4647aecc --- /dev/null +++ b/app/src/main/res/layout/conversation_list_item_header.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/src/main/res/menu/conversation_list_batch_pin.xml b/app/src/main/res/menu/conversation_list_batch_pin.xml new file mode 100644 index 0000000000..f939b676f7 --- /dev/null +++ b/app/src/main/res/menu/conversation_list_batch_pin.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index b6161feb6a..60e1c8425b 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -218,6 +218,8 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f82851a63..da55a4fd0f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2076,6 +2076,8 @@ Delete selected + Pin selected + Unpin selected Select all Archive selected Unarchive selected @@ -2086,6 +2088,9 @@ Settings shortcut Search + Pinned + Chats + You can only pin up to %1$d chats Contact Photo Image diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 6ce46e3518..f6e1526728 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -383,6 +383,8 @@ @drawable/ic_select_24 @drawable/ic_archive_white_24dp @drawable/ic_compose_outline_tinted_24 + @drawable/ic_pin_outline_24 + @drawable/ic_unpin_outline_24 @drawable/ic_message_outline_tinted_bitmap_24 @drawable/ic_bell_outline_24 @@ -696,6 +698,8 @@ @drawable/ic_select_24 @drawable/ic_archive_white_24dp @drawable/ic_compose_solid_tinted_24 + @drawable/ic_pin_solid_24 + @drawable/ic_unpin_solid_24 @drawable/ic_message_solid_tinted_bitmap_24 @drawable/ic_bell_solid_24