Add ability to pin up to 4 conversations.

master
Alex Hart 2020-08-13 09:31:55 -03:00 committed by Greyson Parrelli
parent 9892c4392e
commit d63e5165eb
18 changed files with 528 additions and 71 deletions

View File

@ -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<Conversation> pinnedConversations) {
pinnedAdapter.submitList(pinnedConversations);
}
public void submitUnpinnedList(@NonNull PagedList<Conversation> unpinnedConversations) {
unpinnedAdapter.submitList(unpinnedConversations);
}
public void setTypingThreads(@NonNull Set<Long> threads) {
pinnedAdapter.setTypingThreads(threads);
unpinnedAdapter.setTypingThreads(threads);
}
public @NonNull Set<Long> getBatchSelectionIds() {
HashSet<Long> 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<Conversation> getBatchSelection() {
Set<Conversation> 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();
}
}
}
}
}

View File

@ -29,7 +29,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerView.ViewHolder> {
class ConversationListAdapter extends PagedListAdapter<Conversation, ConversationListAdapter.BaseViewHolder> {
private static final int TYPE_THREAD = 1;
private static final int TYPE_ACTION = 2;
@ -55,13 +55,13 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
}
@Override
public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
public @NonNull BaseViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
if (viewType == TYPE_ACTION) {
ConversationViewHolder holder = new ConversationViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.conversation_list_item_action, parent, false));
.inflate(R.layout.conversation_list_item_action, parent, false), viewType);
holder.itemView.setOnClickListener(v -> {
int position = holder.getAdapterPosition();
int position = holder.getLocalAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
onConversationClickListener.onShowArchiveClick();
@ -71,10 +71,10 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
return holder;
} else if (viewType == TYPE_THREAD) {
ConversationViewHolder holder = new ConversationViewHolder(CachedInflater.from(parent.getContext())
.inflate(R.layout.conversation_list_item_view, parent, false));
.inflate(R.layout.conversation_list_item_view, parent, false), viewType);
holder.itemView.setOnClickListener(v -> {
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<Conversation, RecyclerVie
});
holder.itemView.setOnLongClickListener(v -> {
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<Conversation, RecyclerVie
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position, @NonNull List<Object> payloads) {
public void onBindViewHolder(@NonNull BaseViewHolder holder, int position, @NonNull List<Object> payloads) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position);
} else {
@ -120,8 +120,9 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
if (holder.getItemViewType() == TYPE_ACTION) {
public void onBindViewHolder(@NonNull BaseViewHolder holder, int position) {
holder.setLocalAdapterPosition(position);
if (holder.getLocalViewType() == TYPE_ACTION) {
ConversationViewHolder casted = (ConversationViewHolder) holder;
casted.getConversationListItem().bind(new ThreadRecord.Builder(100)
@ -135,7 +136,7 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
typingSet,
getBatchSelectionIds(),
batchMode);
} else if (holder.getItemViewType() == TYPE_THREAD) {
} else if (holder.getLocalViewType() == TYPE_THREAD) {
ConversationViewHolder casted = (ConversationViewHolder) holder;
Conversation conversation = Objects.requireNonNull(getItem(position));
@ -149,7 +150,7 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
}
@Override
public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
public void onViewRecycled(@NonNull BaseViewHolder holder) {
if (holder instanceof ConversationViewHolder) {
((ConversationViewHolder) holder).getConversationListItem().unbind();
}
@ -234,12 +235,35 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
notifyItemRangeChanged(0, getItemCount(), Payload.SELECTION);
}
static final class ConversationViewHolder extends RecyclerView.ViewHolder {
static class BaseViewHolder extends RecyclerView.ViewHolder {
private final int viewType;
private int adapterPosition = RecyclerView.NO_POSITION;
public BaseViewHolder(@NonNull View itemView, int viewType) {
super(itemView);
this.viewType = viewType;
}
public int getLocalViewType() {
return viewType;
}
public int getLocalAdapterPosition() {
return getAdapterPosition() == RecyclerView.NO_POSITION ? RecyclerView.NO_POSITION : adapterPosition;
}
public void setLocalAdapterPosition(int adapterPosition) {
this.adapterPosition = adapterPosition;
}
}
static final class ConversationViewHolder extends BaseViewHolder {
private final BindableConversationListItem conversationListItem;
ConversationViewHolder(@NonNull View itemView) {
super(itemView);
ConversationViewHolder(@NonNull View itemView, int viewType) {
super(itemView, viewType);
conversationListItem = (BindableConversationListItem) itemView;
}
@ -262,9 +286,9 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
}
}
private static class PlaceholderViewHolder extends RecyclerView.ViewHolder {
private static class PlaceholderViewHolder extends BaseViewHolder {
PlaceholderViewHolder(@NonNull View itemView) {
super(itemView);
super(itemView, TYPE_PLACEHOLDER);
}
}

View File

@ -17,7 +17,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.paging.Invalidator;
import org.thoughtcrime.securesms.util.paging.SizeFixResult;
@ -58,9 +57,10 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
context.getContentResolver().registerContentObserver(DatabaseContentProviders.ConversationList.CONTENT_URI, true, contentObserver);
}
private static ConversationListDataSource create(@NonNull Context context, @NonNull Invalidator invalidator, boolean isArchived) {
if (!isArchived) return new UnarchivedConversationListDataSource(context, invalidator);
else return new ArchivedConversationListDataSource(context, invalidator);
private static ConversationListDataSource create(@NonNull Context context, @NonNull Invalidator invalidator, boolean isPinned, boolean isArchived) {
if (isPinned) return new PinnedConversationListDataSource(context, invalidator);
else if (!isArchived) return new UnarchivedConversationListDataSource(context, invalidator);
else return new ArchivedConversationListDataSource(context, invalidator);
}
@Override
@ -142,12 +142,29 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
@Override
protected int getTotalCount() {
return threadDatabase.getUnarchivedConversationListCount();
return threadDatabase.getUnpinnedConversationListCount();
}
@Override
protected Cursor getCursor(long offset, long limit) {
return threadDatabase.getConversationList(offset, limit);
return threadDatabase.getUnpinnedConversationList(offset, limit);
}
}
private static class PinnedConversationListDataSource extends ConversationListDataSource {
protected PinnedConversationListDataSource(@NonNull Context context, @NonNull Invalidator invalidator) {
super(context, invalidator);
}
@Override
protected int getTotalCount() {
return threadDatabase.getPinnedConversationListCount();
}
@Override
protected Cursor getCursor(long offset, long limit) {
return threadDatabase.getPinnedConversationList(offset, limit);
}
}
@ -155,17 +172,19 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
private final Context context;
private final Invalidator invalidator;
private final boolean isPinned;
private final boolean isArchived;
public Factory(@NonNull Context context, @NonNull Invalidator invalidator, boolean isArchived) {
public Factory(@NonNull Context context, @NonNull Invalidator invalidator, boolean isPinned, boolean isArchived) {
this.context = context;
this.invalidator = invalidator;
this.isPinned = isPinned;
this.isArchived = isArchived;
}
@Override
public @NonNull DataSource<Integer, Conversation> create() {
return ConversationListDataSource.create(context, invalidator, isArchived);
return ConversationListDataSource.create(context, invalidator, isPinned, isArchived);
}
}
}

View File

@ -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<Long> 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<Long> 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<Conversation> 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() {

View File

@ -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> megaphone;
private final MutableLiveData<SearchResult> searchResult;
private final LiveData<ConversationList> 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> megaphone;
private final MutableLiveData<SearchResult> searchResult;
private final LiveData<PagedList<Conversation>> pinnedList;
private final LiveData<ConversationList> 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<Integer, Conversation> factory = new ConversationListDataSource.Factory(application, invalidator, isArchived);
DataSource.Factory<Integer, Conversation> 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<Integer, Conversation> 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<Boolean> hasNoConversations() {
return LiveDataUtil.combineLatest(getPinnedConversations(),
getConversationList(),
(pinned, unpinned) -> pinned.isEmpty() && unpinned.isEmpty());
}
@NonNull LiveData<PagedList<Conversation>> getPinnedConversations() {
return pinnedList;
}
@NonNull LiveData<SearchResult> getSearchResult() {

View File

@ -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<String> 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<Long> 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<Long> 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();
}

View File

@ -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();

View File

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

View File

@ -46,10 +46,10 @@ public class RecyclerViewConcatenateAdapter extends RecyclerView.Adapter<Recycle
/** Observes a single sub adapter and maps the positions on the events to global positions. */
private static class AdapterDataObserver extends RecyclerView.AdapterDataObserver {
private final RecyclerViewConcatenateAdapter mergeAdapter;
private final RecyclerView.Adapter<RecyclerView.ViewHolder> adapter;
private final RecyclerViewConcatenateAdapter mergeAdapter;
private final RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter;
AdapterDataObserver(RecyclerViewConcatenateAdapter mergeAdapter, RecyclerView.Adapter<RecyclerView.ViewHolder> adapter) {
AdapterDataObserver(RecyclerViewConcatenateAdapter mergeAdapter, RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter) {
this.mergeAdapter = mergeAdapter;
this.adapter = adapter;
}
@ -83,7 +83,7 @@ public class RecyclerViewConcatenateAdapter extends RecyclerView.Adapter<Recycle
private static class ChildAdapter {
final RecyclerView.Adapter<RecyclerView.ViewHolder> adapter;
final RecyclerView.Adapter<? extends RecyclerView.ViewHolder> 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<Recycle
/** Map of local ids to global ids. */
private final LongSparseArray<Long> localItemIdMap = new LongSparseArray<>();
ChildAdapter(@NonNull RecyclerView.Adapter<RecyclerView.ViewHolder> adapter, @NonNull AdapterDataObserver adapterDataObserver) {
ChildAdapter(@NonNull RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter, @NonNull AdapterDataObserver adapterDataObserver) {
this.adapter = adapter;
this.adapterDataObserver = adapterDataObserver;
@ -153,7 +153,7 @@ public class RecyclerViewConcatenateAdapter extends RecyclerView.Adapter<Recycle
localPosition = position;
}
RecyclerView.Adapter<RecyclerView.ViewHolder> getAdapter() {
RecyclerView.Adapter<? extends RecyclerView.ViewHolder> getAdapter() {
return childAdapter.adapter;
}
}
@ -161,7 +161,7 @@ public class RecyclerViewConcatenateAdapter extends RecyclerView.Adapter<Recycle
/**
* @param adapter Append an adapter to the list of adapters.
*/
public void addAdapter(@NonNull RecyclerView.Adapter<RecyclerView.ViewHolder> adapter) {
public void addAdapter(@NonNull RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter) {
addAdapter(adapters.size(), adapter);
}
@ -169,7 +169,7 @@ public class RecyclerViewConcatenateAdapter extends RecyclerView.Adapter<Recycle
* @param index The index at which to add an adapter to the list of adapters.
* @param adapter The adapter to add.
*/
public void addAdapter(int index, @NonNull RecyclerView.Adapter<RecyclerView.ViewHolder> adapter) {
public void addAdapter(int index, @NonNull RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter) {
AdapterDataObserver adapterDataObserver = new AdapterDataObserver(this, adapter);
adapters.add(index, new ChildAdapter(adapter, adapterDataObserver));
notifyDataSetChanged();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="40dp"
android:gravity="start|center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:textAppearance="@style/TextAppearance.Signal.Body2"
android:fontFamily="sans-serif-medium"
android:textColor="?title_text_color_secondary"
tools:text="Chats" />

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_pin_selected"
android:icon="?menu_pin_icon"
android:title="@string/conversation_list_batch__menu_unpin_selected"
app:showAsAction="always" />
<item
android:id="@+id/menu_unpin_selected"
android:icon="?menu_unpin_icon"
android:title="@string/conversation_list_batch__menu_unpin_selected"
app:showAsAction="always" />
</menu>

View File

@ -218,6 +218,8 @@
<attr name="menu_multi_select_icon" format="reference" />
<attr name="menu_archive_icon" format="reference" />
<attr name="menu_edit_icon" format="reference" />
<attr name="menu_pin_icon" format="reference" />
<attr name="menu_unpin_icon" format="reference" />
<attr name="message_icon" format="reference" />
<attr name="notifications_icon" format="reference" />

View File

@ -2076,6 +2076,8 @@
<!-- conversation_list_batch -->
<string name="conversation_list_batch__menu_delete_selected">Delete selected</string>
<string name="conversation_list_batch__menu_pin_selected">Pin selected</string>
<string name="conversation_list_batch__menu_unpin_selected">Unpin selected</string>
<string name="conversation_list_batch__menu_select_all">Select all</string>
<string name="conversation_list_batch_archive__menu_archive_selected">Archive selected</string>
<string name="conversation_list_batch_unarchive__menu_unarchive_selected">Unarchive selected</string>
@ -2086,6 +2088,9 @@
<!-- conversation_list -->
<string name="conversation_list_settings_shortcut">Settings shortcut</string>
<string name="conversation_list_search_description">Search</string>
<string name="conversation_list__pinned">Pinned</string>
<string name="conversation_list__chats">Chats</string>
<string name="conversation_list__you_can_only_pin_up_to_d_chats">You can only pin up to %1$d chats</string>
<!-- conversation_list_item_view -->
<string name="conversation_list_item_view__contact_photo_image">Contact Photo Image</string>

View File

@ -383,6 +383,8 @@
<item name="menu_multi_select_icon">@drawable/ic_select_24</item>
<item name="menu_archive_icon">@drawable/ic_archive_white_24dp</item>
<item name="menu_edit_icon">@drawable/ic_compose_outline_tinted_24</item>
<item name="menu_pin_icon">@drawable/ic_pin_outline_24</item>
<item name="menu_unpin_icon">@drawable/ic_unpin_outline_24</item>
<item name="message_icon">@drawable/ic_message_outline_tinted_bitmap_24</item>
<item name="notifications_icon">@drawable/ic_bell_outline_24</item>
@ -696,6 +698,8 @@
<item name="menu_multi_select_icon">@drawable/ic_select_24</item>
<item name="menu_archive_icon">@drawable/ic_archive_white_24dp</item>
<item name="menu_edit_icon">@drawable/ic_compose_solid_tinted_24</item>
<item name="menu_pin_icon">@drawable/ic_pin_solid_24</item>
<item name="menu_unpin_icon">@drawable/ic_unpin_solid_24</item>
<item name="message_icon">@drawable/ic_message_solid_tinted_bitmap_24</item>
<item name="notifications_icon">@drawable/ic_bell_solid_24</item>