Release chat folders to internal users.
This commit is contained in:
parent
e5c122d972
commit
c4fc32988c
64 changed files with 3166 additions and 251 deletions
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ChatFolderTablesTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private lateinit var alice: RecipientId
|
||||
private lateinit var bob: RecipientId
|
||||
private lateinit var charlie: RecipientId
|
||||
|
||||
private lateinit var folder1: ChatFolderRecord
|
||||
private lateinit var folder2: ChatFolderRecord
|
||||
private lateinit var folder3: ChatFolderRecord
|
||||
|
||||
private var aliceThread: Long = 0
|
||||
private var bobThread: Long = 0
|
||||
private var charlieThread: Long = 0
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
alice = harness.others[1]
|
||||
bob = harness.others[2]
|
||||
charlie = harness.others[3]
|
||||
|
||||
aliceThread = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(alice))
|
||||
bobThread = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(bob))
|
||||
charlieThread = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(charlie))
|
||||
|
||||
folder1 = ChatFolderRecord(
|
||||
id = 2,
|
||||
name = "folder1",
|
||||
position = 1,
|
||||
includedChats = listOf(aliceThread, bobThread),
|
||||
excludedChats = listOf(charlieThread),
|
||||
showUnread = true,
|
||||
showMutedChats = true,
|
||||
showIndividualChats = true,
|
||||
folderType = ChatFolderRecord.FolderType.CUSTOM
|
||||
)
|
||||
|
||||
folder2 = ChatFolderRecord(
|
||||
name = "folder2",
|
||||
includedChats = listOf(bobThread),
|
||||
showUnread = true,
|
||||
showMutedChats = true,
|
||||
showIndividualChats = true,
|
||||
folderType = ChatFolderRecord.FolderType.INDIVIDUAL
|
||||
)
|
||||
|
||||
folder3 = ChatFolderRecord(
|
||||
name = "folder3",
|
||||
includedChats = listOf(bobThread),
|
||||
excludedChats = listOf(aliceThread, charlieThread),
|
||||
showUnread = true,
|
||||
showMutedChats = true,
|
||||
showGroupChats = true,
|
||||
isMuted = true,
|
||||
folderType = ChatFolderRecord.FolderType.GROUP
|
||||
)
|
||||
|
||||
SignalDatabase.chatFolders.writableDatabase.deleteAll(ChatFolderTables.ChatFolderTable.TABLE_NAME)
|
||||
SignalDatabase.chatFolders.writableDatabase.deleteAll(ChatFolderTables.ChatFolderMembershipTable.TABLE_NAME)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenChatFolder_whenIGetFolder_thenIExpectFolderWithChats() {
|
||||
SignalDatabase.chatFolders.createFolder(folder1)
|
||||
val actualFolders = SignalDatabase.chatFolders.getChatFolders()
|
||||
|
||||
assertEquals(listOf(folder1), actualFolders)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenChatFolder_whenIUpdateFolder_thenIExpectUpdatedFolderWithChats() {
|
||||
SignalDatabase.chatFolders.createFolder(folder2)
|
||||
val folder = SignalDatabase.chatFolders.getChatFolders().first()
|
||||
val updatedFolder = folder.copy(
|
||||
name = "updatedFolder2",
|
||||
position = 1,
|
||||
isMuted = true,
|
||||
includedChats = listOf(aliceThread, charlieThread),
|
||||
excludedChats = listOf(bobThread)
|
||||
)
|
||||
SignalDatabase.chatFolders.updateFolder(updatedFolder)
|
||||
|
||||
val actualFolder = SignalDatabase.chatFolders.getChatFolders().first()
|
||||
|
||||
assertEquals(updatedFolder, actualFolder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenADeletedChatFolder_whenIGetFolders_thenIExpectAListWithoutThatFolder() {
|
||||
SignalDatabase.chatFolders.createFolder(folder1)
|
||||
SignalDatabase.chatFolders.createFolder(folder2)
|
||||
val folders = SignalDatabase.chatFolders.getChatFolders()
|
||||
SignalDatabase.chatFolders.deleteChatFolder(folders.last())
|
||||
|
||||
val actualFolders = SignalDatabase.chatFolders.getChatFolders()
|
||||
|
||||
assertEquals(listOf(folder1), actualFolders)
|
||||
}
|
||||
}
|
|
@ -11,6 +11,7 @@ import org.junit.Assert.assertNotNull
|
|||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
|
@ -25,6 +26,7 @@ class ThreadTableTest_active {
|
|||
val databaseRule = SignalDatabaseRule()
|
||||
|
||||
private lateinit var recipient: Recipient
|
||||
private val allChats: ChatFolderRecord = ChatFolderRecord(folderType = ChatFolderRecord.FolderType.ALL)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
|
@ -41,7 +43,8 @@ class ThreadTableTest_active {
|
|||
ConversationFilter.OFF,
|
||||
false,
|
||||
0,
|
||||
10
|
||||
10,
|
||||
allChats
|
||||
).use { threads ->
|
||||
assertEquals(1, threads.count)
|
||||
|
||||
|
@ -63,7 +66,8 @@ class ThreadTableTest_active {
|
|||
ConversationFilter.OFF,
|
||||
false,
|
||||
0,
|
||||
10
|
||||
10,
|
||||
allChats
|
||||
).use { threads ->
|
||||
assertEquals(0, threads.count)
|
||||
}
|
||||
|
@ -83,7 +87,8 @@ class ThreadTableTest_active {
|
|||
ConversationFilter.OFF,
|
||||
false,
|
||||
0,
|
||||
10
|
||||
10,
|
||||
allChats
|
||||
).use { threads ->
|
||||
assertEquals(0, threads.count)
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import org.junit.Before
|
|||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
|
@ -20,6 +21,7 @@ class ThreadTableTest_pinned {
|
|||
val databaseRule = SignalDatabaseRule()
|
||||
|
||||
private lateinit var recipient: Recipient
|
||||
private val allChats: ChatFolderRecord = ChatFolderRecord(folderType = ChatFolderRecord.FolderType.ALL)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
|
@ -52,7 +54,7 @@ class ThreadTableTest_pinned {
|
|||
SignalDatabase.messages.deleteMessage(messageId)
|
||||
|
||||
// THEN
|
||||
val unarchivedCount = SignalDatabase.threads.getUnarchivedConversationListCount(ConversationFilter.OFF)
|
||||
val unarchivedCount = SignalDatabase.threads.getUnarchivedConversationListCount(ConversationFilter.OFF, allChats)
|
||||
assertEquals(1, unarchivedCount)
|
||||
}
|
||||
|
||||
|
@ -67,7 +69,7 @@ class ThreadTableTest_pinned {
|
|||
SignalDatabase.messages.deleteMessage(messageId)
|
||||
|
||||
// THEN
|
||||
SignalDatabase.threads.getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 1).use {
|
||||
SignalDatabase.threads.getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 1, allChats).use {
|
||||
it.moveToFirst()
|
||||
assertEquals(threadId, CursorUtil.requireLong(it, ThreadTable.ID))
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ import org.signal.core.util.DimensionUnit;
|
|||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
|
@ -127,12 +128,12 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
callback.accept(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {}
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {}
|
||||
|
||||
@Override
|
||||
public void onBeginScroll() {
|
||||
|
|
|
@ -29,7 +29,6 @@ import android.view.LayoutInflater;
|
|||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
|
@ -47,7 +46,6 @@ import androidx.transition.AutoTransition;
|
|||
import androidx.transition.TransitionManager;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.pnikosis.materialishprogress.ProgressWheel;
|
||||
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.RxExtensions;
|
||||
|
@ -61,6 +59,7 @@ import org.thoughtcrime.securesms.contacts.HeaderAction;
|
|||
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContacts;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
|
||||
|
@ -111,16 +110,17 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
|||
|
||||
public static final int NO_LIMIT = Integer.MAX_VALUE;
|
||||
|
||||
public static final String DISPLAY_MODE = "display_mode";
|
||||
public static final String REFRESHABLE = "refreshable";
|
||||
public static final String RECENTS = "recents";
|
||||
public static final String SELECTION_LIMITS = "selection_limits";
|
||||
public static final String CURRENT_SELECTION = "current_selection";
|
||||
public static final String HIDE_COUNT = "hide_count";
|
||||
public static final String CAN_SELECT_SELF = "can_select_self";
|
||||
public static final String DISPLAY_CHIPS = "display_chips";
|
||||
public static final String RV_PADDING_BOTTOM = "recycler_view_padding_bottom";
|
||||
public static final String RV_CLIP = "recycler_view_clipping";
|
||||
public static final String DISPLAY_MODE = "display_mode";
|
||||
public static final String REFRESHABLE = "refreshable";
|
||||
public static final String RECENTS = "recents";
|
||||
public static final String SELECTION_LIMITS = "selection_limits";
|
||||
public static final String CURRENT_SELECTION = "current_selection";
|
||||
public static final String HIDE_COUNT = "hide_count";
|
||||
public static final String CAN_SELECT_SELF = "can_select_self";
|
||||
public static final String DISPLAY_CHIPS = "display_chips";
|
||||
public static final String RV_PADDING_BOTTOM = "recycler_view_padding_bottom";
|
||||
public static final String RV_CLIP = "recycler_view_clipping";
|
||||
public static final String INCLUDE_CHAT_TYPES = "include_chat_types";
|
||||
|
||||
private ConstraintLayout constraintLayout;
|
||||
private TextView emptyText;
|
||||
|
@ -421,6 +421,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
|||
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
|
||||
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
|
||||
}
|
||||
},
|
||||
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
|
||||
storyContextMenuCallbacks,
|
||||
|
@ -679,6 +684,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
|||
return;
|
||||
}
|
||||
|
||||
if (selectedContact.hasChatType() && !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onBeforeContactSelected(true, Optional.empty(), null, Optional.of(selectedContact.getChatType()), allowed -> {
|
||||
if (allowed) {
|
||||
markContactSelected(selectedContact);
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else if (selectedContact.hasChatType()) {
|
||||
markContactUnselected(selectedContact);
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactDeselected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber(), Optional.of(selectedContact.getChatType()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMulti || !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
|
||||
if (selectionHardLimitReached()) {
|
||||
if (onSelectionLimitReachedListener != null) {
|
||||
|
@ -709,7 +731,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
|||
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), username);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onBeforeContactSelected(true, Optional.of(recipient.getId()), null, allowed -> {
|
||||
onContactSelectedListener.onBeforeContactSelected(true, Optional.of(recipient.getId()), null, Optional.empty(), allowed -> {
|
||||
if (allowed) {
|
||||
markContactSelected(selected);
|
||||
}
|
||||
|
@ -731,6 +753,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
|||
isUnknown,
|
||||
Optional.ofNullable(selectedContact.getRecipientId()),
|
||||
selectedContact.getNumber(),
|
||||
Optional.empty(),
|
||||
allowed -> {
|
||||
if (allowed) {
|
||||
markContactSelected(selectedContact);
|
||||
|
@ -744,7 +767,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
|||
markContactUnselected(selectedContact);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactDeselected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber());
|
||||
onContactSelectedListener.onContactDeselected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber(), Optional.empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -770,7 +793,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
|||
return getChipCount() + currentSelection.size() > selectionLimit.getRecommendedLimit();
|
||||
}
|
||||
|
||||
private void markContactSelected(@NonNull SelectedContact selectedContact) {
|
||||
public void markContactSelected(@NonNull SelectedContact selectedContact) {
|
||||
contactSearchMediator.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
|
||||
if (isMulti) {
|
||||
addChipForSelectedContact(selectedContact);
|
||||
|
@ -789,7 +812,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
|||
}
|
||||
}
|
||||
|
||||
private void handleSelectedContactsChanged(@NonNull List<SelectedContacts.Model> selectedContacts) {
|
||||
private void handleSelectedContactsChanged(@NonNull List<SelectedContacts.Model<?>> selectedContacts) {
|
||||
contactChipAdapter.submitList(new MappingModelList(selectedContacts), this::smoothScrollChipsToEnd);
|
||||
|
||||
if (selectedContacts.isEmpty()) {
|
||||
|
@ -808,15 +831,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
|||
}
|
||||
|
||||
private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) {
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
|
||||
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
|
||||
resolved -> contactChipViewModel.add(selectedContact));
|
||||
if (selectedContact.hasChatType()) {
|
||||
contactChipViewModel.add(selectedContact);
|
||||
} else {
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
|
||||
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
|
||||
resolved -> contactChipViewModel.add(selectedContact));
|
||||
}
|
||||
}
|
||||
|
||||
private Unit onChipCloseIconClicked(SelectedContacts.Model model) {
|
||||
private Unit onChipCloseIconClicked(SelectedContacts.Model<?> model) {
|
||||
markContactUnselected(model.getSelectedContact());
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactDeselected(Optional.of(model.getRecipient().getId()), model.getRecipient().getE164().orElse(null));
|
||||
if (model instanceof SelectedContacts.ChatTypeModel) {
|
||||
onContactSelectedListener.onContactDeselected(Optional.empty(), null, Optional.of(model.getSelectedContact().getChatType()));
|
||||
} else {
|
||||
onContactSelectedListener.onContactDeselected(Optional.of(((SelectedContacts.RecipientModel) model).getRecipient().getId()), ((SelectedContacts.RecipientModel) model).getRecipient().getE164().orElse(null), Optional.empty());
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.INSTANCE;
|
||||
|
@ -870,6 +901,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
|||
boolean includeGroupsAfterContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
|
||||
boolean blocked = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_BLOCK);
|
||||
boolean includeGroupMembers = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUP_MEMBERS);
|
||||
boolean includeChatTypes = safeArguments().getBoolean(INCLUDE_CHAT_TYPES);
|
||||
boolean hasQuery = !TextUtils.isEmpty(contactSearchState.getQuery());
|
||||
|
||||
ContactSearchConfiguration.TransportType transportType = resolveTransportType(includePushContacts, includeSmsContacts);
|
||||
|
@ -895,6 +927,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
|||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
|
||||
}
|
||||
|
||||
if (includeChatTypes && !hasQuery) {
|
||||
builder.addSection(new ContactSearchConfiguration.Section.ChatTypes(true, null));
|
||||
}
|
||||
|
||||
if (transportType != null) {
|
||||
if (!hasQuery && includeRecents) {
|
||||
builder.addSection(new ContactSearchConfiguration.Section.Recents(
|
||||
|
@ -1027,9 +1063,9 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
|||
/**
|
||||
* Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it.
|
||||
*/
|
||||
void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback);
|
||||
void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback);
|
||||
|
||||
void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number);
|
||||
void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Optional<ChatType> chatType);
|
||||
|
||||
void onSelectionChanged();
|
||||
}
|
||||
|
|
|
@ -27,15 +27,14 @@ import org.thoughtcrime.securesms.components.ContactFilterView;
|
|||
import org.thoughtcrime.securesms.components.ContactFilterView.OnFilterChangedListener;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarInviteTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
|
||||
|
@ -131,13 +130,13 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
|
||||
callback.accept(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {
|
||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
|
||||
}
|
||||
|
||||
|
|
|
@ -44,8 +44,8 @@ import org.thoughtcrime.securesms.components.menu.ActionItem;
|
|||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
|
||||
import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository;
|
||||
import org.thoughtcrime.securesms.contacts.management.ContactsManagementViewModel;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
|
@ -57,11 +57,9 @@ import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode;
|
|||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
@ -121,7 +119,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
if (recipientId.isPresent()) {
|
||||
launch(Recipient.resolved(recipientId.get()));
|
||||
} else {
|
||||
|
|
|
@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.PassphraseRequiredActivity;
|
|||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
|
@ -97,7 +98,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
final String displayName = recipientId.map(id -> Recipient.resolved(id).getDisplayName(this)).orElse(number);
|
||||
|
||||
AlertDialog confirmationDialog = new MaterialAlertDialogBuilder(this)
|
||||
|
@ -126,7 +127,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.InviteActivity
|
|||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
@ -38,7 +39,7 @@ class NewCallActivity : ContactSelectionActivity(), ContactSelectionListFragment
|
|||
|
||||
override fun onSelectionChanged() = Unit
|
||||
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId?>, number: String?, callback: Consumer<Boolean?>) {
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId?>, number: String?, chatType: Optional<ChatType>, callback: Consumer<Boolean?>) {
|
||||
if (recipientId.isPresent) {
|
||||
launch(Recipient.resolved(recipientId.get()))
|
||||
} else {
|
||||
|
|
|
@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.settings.app.chats
|
|||
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
|
@ -61,6 +60,19 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch
|
|||
|
||||
dividerPref()
|
||||
|
||||
if (RemoteConfig.internalUser) {
|
||||
sectionHeaderPref(R.string.ChatsSettingsFragment__chat_folders)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ChatsSettingsFragment__add_chat_folder),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_chatFoldersFragment)
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
}
|
||||
|
||||
sectionHeaderPref(R.string.ChatsSettingsFragment__keyboard)
|
||||
|
||||
switchPref(
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.chats.folders
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
/**
|
||||
* Represents an entry in the [org.thoughtcrime.securesms.database.ChatFolderTables].
|
||||
*/
|
||||
data class ChatFolderRecord(
|
||||
val id: Long = -1,
|
||||
val name: String = "",
|
||||
val position: Int = -1,
|
||||
val includedChats: List<Long> = emptyList(),
|
||||
val excludedChats: List<Long> = emptyList(),
|
||||
val includedRecipients: Set<Recipient> = emptySet(),
|
||||
val excludedRecipients: Set<Recipient> = emptySet(),
|
||||
val showUnread: Boolean = false,
|
||||
val showMutedChats: Boolean = false,
|
||||
val showIndividualChats: Boolean = false,
|
||||
val showGroupChats: Boolean = false,
|
||||
val isMuted: Boolean = false,
|
||||
val folderType: FolderType = FolderType.CUSTOM,
|
||||
val unreadCount: Int = 0 // TODO [michelle]: unread count
|
||||
) {
|
||||
enum class FolderType(val value: Int) {
|
||||
/** Folder containing all chats */
|
||||
ALL(0),
|
||||
|
||||
/** Folder containing all 1:1 chats */
|
||||
INDIVIDUAL(1),
|
||||
|
||||
/** Folder containing group chats */
|
||||
GROUP(2),
|
||||
|
||||
/** Folder containing unread chats. */
|
||||
UNREAD(3),
|
||||
|
||||
/** Folder containing custom chosen chats */
|
||||
CUSTOM(4);
|
||||
|
||||
companion object {
|
||||
fun deserialize(value: Int): FolderType {
|
||||
return entries.firstOrNull { it.value == value } ?: CUSTOM
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,330 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.chats.folders
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.copied.androidx.compose.DraggableItem
|
||||
import org.signal.core.ui.copied.androidx.compose.dragContainer
|
||||
import org.signal.core.ui.copied.androidx.compose.rememberDragDropState
|
||||
import org.signal.core.util.toInt
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Fragment that displays current and suggested chat folders
|
||||
*/
|
||||
class ChatFoldersFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: ChatFoldersViewModel by activityViewModels()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val navController: NavController by remember { mutableStateOf(findNavController()) }
|
||||
viewModel.loadCurrentFolders(requireContext())
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.ChatsSettingsFragment__chat_folders),
|
||||
onNavigationClick = { navController.popBackStack() },
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) { contentPadding: PaddingValues ->
|
||||
FoldersScreen(
|
||||
state = state,
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
onFolderClicked = {
|
||||
viewModel.setCurrentFolder(it)
|
||||
navController.safeNavigate(R.id.action_chatFoldersFragment_to_createFoldersFragment)
|
||||
},
|
||||
onAdd = { folder ->
|
||||
Toast.makeText(requireContext(), getString(R.string.ChatFoldersFragment__folder_added, folder.name), Toast.LENGTH_SHORT).show()
|
||||
viewModel.createFolder(requireContext(), folder)
|
||||
},
|
||||
onPositionUpdated = { fromIndex, toIndex -> viewModel.updatePosition(fromIndex, toIndex) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FoldersScreen(
|
||||
state: ChatFoldersSettingsState,
|
||||
modifier: Modifier = Modifier,
|
||||
onFolderClicked: (ChatFolderRecord) -> Unit = {},
|
||||
onAdd: (ChatFolderRecord) -> Unit = {},
|
||||
onPositionUpdated: (Int, Int) -> Unit = { _, _ -> }
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val dragDropState =
|
||||
rememberDragDropState(listState) { fromIndex, toIndex ->
|
||||
onPositionUpdated(fromIndex, toIndex)
|
||||
}
|
||||
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
|
||||
Column(modifier = Modifier.padding(start = 24.dp)) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.ChatFoldersFragment__organize_your_chats),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, end = 12.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.ChatFoldersFragment__folders),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
|
||||
)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_plus_compact_16,
|
||||
title = stringResource(R.string.ChatFoldersFragment__create_a_folder),
|
||||
onClick = { onFolderClicked(ChatFolderRecord()) }
|
||||
)
|
||||
}
|
||||
|
||||
val columnHeight = dimensionResource(id = R.dimen.chat_folder_row_height).value * state.folders.size
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.height(columnHeight.dp)
|
||||
.dragContainer(dragDropState),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.folders) { index, folder ->
|
||||
DraggableItem(dragDropState, index) { isDragging ->
|
||||
val elevation = if (isDragging) 1.dp else 0.dp
|
||||
val isAllChats = folder.folderType == ChatFolderRecord.FolderType.ALL
|
||||
FolderRow(
|
||||
icon = R.drawable.ic_chat_folder_24,
|
||||
title = if (isAllChats) stringResource(R.string.ChatFoldersFragment__all_chats) else folder.name,
|
||||
subtitle = getFolderDescription(folder),
|
||||
onClick = if (!isAllChats) {
|
||||
{ onFolderClicked(folder) }
|
||||
} else null,
|
||||
elevation = elevation,
|
||||
showDragHandle = true,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.suggestedFolders.isNotEmpty()) {
|
||||
Dividers.Default()
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.ChatFoldersFragment__suggested_folders),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp, start = 24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
state.suggestedFolders.forEach { chatFolder ->
|
||||
when (chatFolder.folderType) {
|
||||
ChatFolderRecord.FolderType.UNREAD -> {
|
||||
val title: String = stringResource(R.string.ChatFoldersFragment__unreads)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_chat_badge_24,
|
||||
title = title,
|
||||
subtitle = stringResource(R.string.ChatFoldersFragment__unread_messages),
|
||||
onAdd = { onAdd(chatFolder) },
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
ChatFolderRecord.FolderType.INDIVIDUAL -> {
|
||||
val title: String = stringResource(R.string.ChatFoldersFragment__one_on_one_chats)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_person_light_24,
|
||||
title = title,
|
||||
subtitle = stringResource(R.string.ChatFoldersFragment__only_direct_messages),
|
||||
onAdd = { onAdd(chatFolder) },
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
ChatFolderRecord.FolderType.GROUP -> {
|
||||
val title: String = stringResource(R.string.ChatFoldersFragment__groups)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_group_light_20,
|
||||
title = title,
|
||||
subtitle = stringResource(R.string.ChatFoldersFragment__only_group_messages),
|
||||
onAdd = { onAdd(chatFolder) },
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
ChatFolderRecord.FolderType.ALL -> {
|
||||
throw IllegalStateException("All chats should not be suggested")
|
||||
}
|
||||
ChatFolderRecord.FolderType.CUSTOM -> {
|
||||
throw IllegalStateException("Custom folders should not be suggested")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getFolderDescription(folder: ChatFolderRecord): String {
|
||||
val chatTypeCount = folder.showIndividualChats.toInt() + folder.showGroupChats.toInt()
|
||||
val chatTypes = pluralStringResource(id = R.plurals.ChatFoldersFragment__d_chat_types, count = chatTypeCount, chatTypeCount)
|
||||
val includedChats = pluralStringResource(id = R.plurals.ChatFoldersFragment__d_chats, count = folder.includedChats.size, folder.includedChats.size)
|
||||
val excludedChats = pluralStringResource(id = R.plurals.ChatFoldersFragment__d_chats_excluded, count = folder.excludedChats.size, folder.excludedChats.size)
|
||||
|
||||
return remember(chatTypeCount, folder.includedChats.size, folder.excludedChats.size) {
|
||||
val description = mutableListOf<String>()
|
||||
if (chatTypeCount != 0) {
|
||||
description.add(chatTypes)
|
||||
}
|
||||
if (folder.includedChats.isNotEmpty()) {
|
||||
description.add(includedChats)
|
||||
}
|
||||
if (folder.excludedChats.isNotEmpty()) {
|
||||
description.add(excludedChats)
|
||||
}
|
||||
description.joinToString(separator = ", ")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FolderRow(
|
||||
modifier: Modifier = Modifier,
|
||||
icon: Int,
|
||||
title: String,
|
||||
subtitle: String = "",
|
||||
onClick: (() -> Unit)? = null,
|
||||
onAdd: (() -> Unit)? = null,
|
||||
elevation: Dp = 0.dp,
|
||||
showDragHandle: Boolean = false
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = if (onClick != null) {
|
||||
modifier
|
||||
.padding(end = 12.dp)
|
||||
.clickable(onClick = onClick)
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = dimensionResource(id = R.dimen.chat_folder_row_height))
|
||||
.shadow(elevation = elevation)
|
||||
} else {
|
||||
modifier
|
||||
.padding(end = 12.dp)
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = dimensionResource(id = R.dimen.chat_folder_row_height))
|
||||
.shadow(elevation = elevation)
|
||||
}
|
||||
) {
|
||||
Image(
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
|
||||
imageVector = ImageVector.vectorResource(id = icon),
|
||||
contentDescription = null,
|
||||
modifier = modifier
|
||||
.size(40.dp)
|
||||
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape)
|
||||
.padding(8.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
Text(text = title)
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (onAdd != null) {
|
||||
Buttons.Small(onClick = onAdd, modifier = modifier.padding(end = 12.dp)) {
|
||||
Text(stringResource(id = R.string.ChatFoldersFragment__add))
|
||||
}
|
||||
} else if (showDragHandle) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_drag_handle),
|
||||
contentDescription = null,
|
||||
modifier = modifier.padding(end = 12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun ChatFolderPreview() {
|
||||
val previewFolders = listOf(
|
||||
ChatFolderRecord(
|
||||
id = 1,
|
||||
name = "Work",
|
||||
position = 1,
|
||||
showUnread = true,
|
||||
showIndividualChats = true,
|
||||
showGroupChats = true,
|
||||
showMutedChats = true,
|
||||
isMuted = false,
|
||||
folderType = ChatFolderRecord.FolderType.CUSTOM
|
||||
),
|
||||
ChatFolderRecord(
|
||||
id = 2,
|
||||
name = "Fun People",
|
||||
position = 2,
|
||||
showUnread = true,
|
||||
showIndividualChats = true,
|
||||
showGroupChats = false,
|
||||
showMutedChats = false,
|
||||
isMuted = false,
|
||||
folderType = ChatFolderRecord.FolderType.CUSTOM
|
||||
)
|
||||
)
|
||||
|
||||
Previews.Preview {
|
||||
FoldersScreen(
|
||||
ChatFoldersSettingsState(
|
||||
folders = previewFolders
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.chats.folders
|
||||
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
|
||||
/**
|
||||
* Repository for chat folders that handles creation, deletion, listing, etc.,
|
||||
*/
|
||||
object ChatFoldersRepository {
|
||||
|
||||
fun getCurrentFolders(includeUnreadCount: Boolean = false): List<ChatFolderRecord> {
|
||||
return SignalDatabase.chatFolders.getChatFolders(includeUnreadCount)
|
||||
}
|
||||
|
||||
fun createFolder(folder: ChatFolderRecord) {
|
||||
val includedChats = folder.includedRecipients.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }
|
||||
val excludedChats = folder.excludedRecipients.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }
|
||||
val updatedFolder = folder.copy(
|
||||
includedChats = includedChats,
|
||||
excludedChats = excludedChats
|
||||
)
|
||||
|
||||
SignalDatabase.chatFolders.createFolder(updatedFolder)
|
||||
}
|
||||
|
||||
fun updateFolder(folder: ChatFolderRecord) {
|
||||
val includedChats = folder.includedRecipients.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }
|
||||
val excludedChats = folder.excludedRecipients.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }
|
||||
val updatedFolder = folder.copy(
|
||||
includedChats = includedChats,
|
||||
excludedChats = excludedChats
|
||||
)
|
||||
|
||||
SignalDatabase.chatFolders.updateFolder(updatedFolder)
|
||||
}
|
||||
|
||||
fun deleteFolder(folder: ChatFolderRecord) {
|
||||
SignalDatabase.chatFolders.deleteChatFolder(folder)
|
||||
}
|
||||
|
||||
fun updatePositions(folders: List<ChatFolderRecord>) {
|
||||
SignalDatabase.chatFolders.updatePositions(folders)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.chats.folders
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Information about chat folders. Used in [ChatFoldersViewModel].
|
||||
*/
|
||||
data class ChatFoldersSettingsState(
|
||||
val folders: List<ChatFolderRecord> = emptyList(),
|
||||
val suggestedFolders: List<ChatFolderRecord> = emptyList(),
|
||||
val originalFolder: ChatFolderRecord = ChatFolderRecord(),
|
||||
val currentFolder: ChatFolderRecord = ChatFolderRecord(),
|
||||
val showDeleteDialog: Boolean = false,
|
||||
val showConfirmationDialog: Boolean = false,
|
||||
val pendingIncludedRecipients: Set<RecipientId> = emptySet(),
|
||||
val pendingExcludedRecipients: Set<RecipientId> = emptySet(),
|
||||
val pendingChatTypes: Set<ChatType> = emptySet()
|
||||
)
|
|
@ -0,0 +1,313 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.chats.folders
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Maintains the state of the [ChatFoldersFragment] and [CreateFoldersFragment]
|
||||
*/
|
||||
class ChatFoldersViewModel : ViewModel() {
|
||||
|
||||
private val internalState = MutableStateFlow(ChatFoldersSettingsState())
|
||||
val state = internalState.asStateFlow()
|
||||
|
||||
fun loadCurrentFolders(context: Context) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val folders = ChatFoldersRepository.getCurrentFolders(includeUnreadCount = false)
|
||||
val suggestedFolders = getSuggestedFolders(context, folders)
|
||||
|
||||
internalState.update {
|
||||
it.copy(folders = folders, suggestedFolders = suggestedFolders)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSuggestedFolders(context: Context, currentFolders: List<ChatFolderRecord>): List<ChatFolderRecord> {
|
||||
var showIndividualSuggestion = true
|
||||
var showGroupSuggestion = true
|
||||
var showUnreadSuggestion = true
|
||||
|
||||
currentFolders
|
||||
.filter { folder -> folder.includedChats.isEmpty() && folder.excludedChats.isEmpty() }
|
||||
.forEach { folder ->
|
||||
if (folder.showIndividualChats && !folder.showGroupChats) {
|
||||
showIndividualSuggestion = false
|
||||
} else if (folder.showGroupChats && !folder.showIndividualChats) {
|
||||
showGroupSuggestion = false
|
||||
} else if (folder.showUnread && folder.showIndividualChats && folder.showGroupChats) {
|
||||
showUnreadSuggestion = false
|
||||
}
|
||||
}
|
||||
|
||||
val suggestions: MutableList<ChatFolderRecord> = mutableListOf()
|
||||
if (showIndividualSuggestion) {
|
||||
suggestions.add(
|
||||
ChatFolderRecord(
|
||||
name = context.getString(R.string.ChatFoldersFragment__one_on_one_chats),
|
||||
showIndividualChats = true,
|
||||
folderType = ChatFolderRecord.FolderType.INDIVIDUAL,
|
||||
showMutedChats = true
|
||||
)
|
||||
)
|
||||
}
|
||||
if (showGroupSuggestion) {
|
||||
suggestions.add(
|
||||
ChatFolderRecord(
|
||||
name = context.getString(R.string.ChatFoldersFragment__groups),
|
||||
showGroupChats = true,
|
||||
folderType = ChatFolderRecord.FolderType.GROUP,
|
||||
showMutedChats = true
|
||||
)
|
||||
)
|
||||
}
|
||||
if (showUnreadSuggestion) {
|
||||
suggestions.add(
|
||||
ChatFolderRecord(
|
||||
name = context.getString(R.string.ChatFoldersFragment__unreads),
|
||||
showUnread = true,
|
||||
showIndividualChats = true,
|
||||
showGroupChats = true,
|
||||
showMutedChats = true,
|
||||
folderType = ChatFolderRecord.FolderType.UNREAD
|
||||
)
|
||||
)
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
fun setCurrentFolder(folder: ChatFolderRecord) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val includedRecipients = folder.includedChats.mapNotNull { threadId ->
|
||||
SignalDatabase.threads.getRecipientForThreadId(threadId)
|
||||
}
|
||||
val excludedRecipients = folder.excludedChats.mapNotNull { threadId ->
|
||||
SignalDatabase.threads.getRecipientForThreadId(threadId)
|
||||
}
|
||||
|
||||
val updatedFolder = folder.copy(
|
||||
includedRecipients = includedRecipients.toSet(),
|
||||
excludedRecipients = excludedRecipients.toSet()
|
||||
)
|
||||
|
||||
internalState.update {
|
||||
it.copy(originalFolder = updatedFolder, currentFolder = updatedFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateName(name: String) {
|
||||
val updatedFolder = internalState.value.currentFolder.copy(
|
||||
name = name.substring(0, minOf(name.length, 32))
|
||||
)
|
||||
|
||||
internalState.update {
|
||||
it.copy(currentFolder = updatedFolder)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleShowUnread(showUnread: Boolean) {
|
||||
val updatedFolder = internalState.value.currentFolder.copy(
|
||||
showUnread = showUnread
|
||||
)
|
||||
|
||||
internalState.update {
|
||||
it.copy(currentFolder = updatedFolder)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleShowMutedChats(showMuted: Boolean) {
|
||||
val updatedFolder = internalState.value.currentFolder.copy(
|
||||
showMutedChats = showMuted
|
||||
)
|
||||
|
||||
internalState.update {
|
||||
it.copy(currentFolder = updatedFolder)
|
||||
}
|
||||
}
|
||||
|
||||
fun showDeleteDialog(show: Boolean) {
|
||||
internalState.update {
|
||||
it.copy(showDeleteDialog = show)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteFolder() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ChatFoldersRepository.deleteFolder(internalState.value.originalFolder)
|
||||
|
||||
internalState.update {
|
||||
it.copy(showDeleteDialog = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showConfirmationDialog(show: Boolean) {
|
||||
internalState.update {
|
||||
it.copy(showConfirmationDialog = show)
|
||||
}
|
||||
}
|
||||
|
||||
fun createFolder(context: Context, folder: ChatFolderRecord? = null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val currentFolder = folder ?: internalState.value.currentFolder
|
||||
ChatFoldersRepository.createFolder(currentFolder)
|
||||
loadCurrentFolders(context)
|
||||
|
||||
internalState.update {
|
||||
it.copy(showConfirmationDialog = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePosition(fromIndex: Int, toIndex: Int) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val folders = state.value.folders.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
|
||||
val updatedFolders = folders.mapIndexed { index, chatFolderRecord ->
|
||||
chatFolderRecord.copy(position = index)
|
||||
}
|
||||
ChatFoldersRepository.updatePositions(updatedFolders)
|
||||
|
||||
internalState.update {
|
||||
it.copy(folders = updatedFolders)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFolder(context: Context) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ChatFoldersRepository.updateFolder(internalState.value.currentFolder)
|
||||
loadCurrentFolders(context)
|
||||
|
||||
internalState.update {
|
||||
it.copy(showConfirmationDialog = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setPendingChats() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val currentFolder = internalState.value.currentFolder
|
||||
val includedChats = currentFolder.includedRecipients.map { recipient -> recipient.id }.toMutableSet()
|
||||
val excludedChats = currentFolder.excludedRecipients.map { recipient -> recipient.id }.toMutableSet()
|
||||
|
||||
val chatTypes: MutableSet<ChatType> = mutableSetOf()
|
||||
if (currentFolder.showIndividualChats) {
|
||||
chatTypes.add(ChatType.INDIVIDUAL)
|
||||
}
|
||||
if (currentFolder.showGroupChats) {
|
||||
chatTypes.add(ChatType.GROUPS)
|
||||
}
|
||||
|
||||
internalState.update {
|
||||
it.copy(
|
||||
pendingIncludedRecipients = includedChats,
|
||||
pendingExcludedRecipients = excludedChats,
|
||||
pendingChatTypes = chatTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addIncludedChat(recipientId: RecipientId) {
|
||||
val includedChats = internalState.value.pendingIncludedRecipients.plus(recipientId)
|
||||
internalState.update {
|
||||
it.copy(pendingIncludedRecipients = includedChats)
|
||||
}
|
||||
}
|
||||
|
||||
fun addExcludedChat(recipientId: RecipientId) {
|
||||
val excludedChats = internalState.value.pendingExcludedRecipients.plus(recipientId)
|
||||
internalState.update {
|
||||
it.copy(pendingExcludedRecipients = excludedChats)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeIncludedChat(recipientId: RecipientId) {
|
||||
val includedChats = internalState.value.pendingIncludedRecipients.minus(recipientId)
|
||||
internalState.update {
|
||||
it.copy(pendingIncludedRecipients = includedChats)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeExcludedChat(recipientId: RecipientId) {
|
||||
val excludedChats = internalState.value.pendingExcludedRecipients.minus(recipientId)
|
||||
internalState.update {
|
||||
it.copy(pendingExcludedRecipients = excludedChats)
|
||||
}
|
||||
}
|
||||
|
||||
fun addChatType(chatType: ChatType) {
|
||||
val updatedChatTypes = internalState.value.pendingChatTypes.plus(chatType)
|
||||
internalState.update {
|
||||
it.copy(
|
||||
pendingChatTypes = updatedChatTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeChatType(chatType: ChatType) {
|
||||
val updatedChatTypes = internalState.value.pendingChatTypes.minus(chatType)
|
||||
internalState.update {
|
||||
it.copy(
|
||||
pendingChatTypes = updatedChatTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun savePendingChats() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val updatedFolder = internalState.value.currentFolder
|
||||
val includedChatIds = internalState.value.pendingIncludedRecipients
|
||||
val excludedChatIds = internalState.value.pendingExcludedRecipients
|
||||
val showIndividualChats = internalState.value.pendingChatTypes.contains(ChatType.INDIVIDUAL)
|
||||
val showGroupChats = internalState.value.pendingChatTypes.contains(ChatType.GROUPS)
|
||||
|
||||
val includedRecipients = includedChatIds.map(Recipient::resolved).toSet()
|
||||
val excludedRecipients = excludedChatIds.map(Recipient::resolved).toSet()
|
||||
|
||||
internalState.update {
|
||||
it.copy(
|
||||
currentFolder = updatedFolder.copy(
|
||||
includedRecipients = includedRecipients,
|
||||
excludedRecipients = excludedRecipients,
|
||||
showIndividualChats = showIndividualChats,
|
||||
showGroupChats = showGroupChats
|
||||
),
|
||||
pendingIncludedRecipients = emptySet(),
|
||||
pendingExcludedRecipients = emptySet()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun enableButton(): Boolean {
|
||||
return internalState.value.pendingIncludedRecipients.isNotEmpty() ||
|
||||
internalState.value.pendingChatTypes.isNotEmpty() ||
|
||||
internalState.value.pendingExcludedRecipients.isNotEmpty()
|
||||
}
|
||||
|
||||
fun hasChanges(): Boolean {
|
||||
val currentFolder = state.value.currentFolder
|
||||
val originalFolder = state.value.originalFolder
|
||||
|
||||
return if (currentFolder.id == -1L) {
|
||||
currentFolder.name.isNotEmpty() &&
|
||||
(currentFolder.includedRecipients.isNotEmpty() || currentFolder.showIndividualChats || currentFolder.showGroupChats)
|
||||
} else {
|
||||
originalFolder != currentFolder ||
|
||||
originalFolder.includedRecipients != currentFolder.includedRecipients ||
|
||||
originalFolder.excludedRecipients != currentFolder.excludedRecipients
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,158 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.chats.folders
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import java.util.Optional
|
||||
import java.util.function.Consumer
|
||||
|
||||
class ChooseChatsFragment : LoggingFragment(), ContactSelectionListFragment.OnContactSelectedListener {
|
||||
|
||||
private val viewModel: ChatFoldersViewModel by activityViewModels()
|
||||
|
||||
private var includeChatsMode: Boolean = true
|
||||
|
||||
private lateinit var contactFilterView: ContactFilterView
|
||||
private lateinit var doneButton: MaterialButton
|
||||
private lateinit var selectionFragment: ContactSelectionListFragment
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
includeChatsMode = arguments?.getBoolean(KEY_INCLUDE_CHATS) ?: true
|
||||
val currentSelection: Set<RecipientId> = if (includeChatsMode) {
|
||||
viewModel.state.value.pendingExcludedRecipients
|
||||
} else {
|
||||
viewModel.state.value.pendingIncludedRecipients
|
||||
}
|
||||
|
||||
childFragmentManager.addFragmentOnAttachListener { _, fragment ->
|
||||
fragment.arguments = Bundle().apply {
|
||||
putInt(ContactSelectionListFragment.DISPLAY_MODE, getDefaultDisplayMode())
|
||||
putBoolean(ContactSelectionListFragment.REFRESHABLE, false)
|
||||
putBoolean(ContactSelectionListFragment.RECENTS, true)
|
||||
putParcelable(ContactSelectionListFragment.SELECTION_LIMITS, SelectionLimits.NO_LIMITS)
|
||||
putParcelableArrayList(ContactSelectionListFragment.CURRENT_SELECTION, ArrayList<RecipientId>(currentSelection))
|
||||
putBoolean(ContactSelectionListFragment.INCLUDE_CHAT_TYPES, includeChatsMode)
|
||||
putBoolean(ContactSelectionListFragment.HIDE_COUNT, true)
|
||||
putBoolean(ContactSelectionListFragment.DISPLAY_CHIPS, true)
|
||||
putBoolean(ContactSelectionListFragment.CAN_SELECT_SELF, true)
|
||||
putBoolean(ContactSelectionListFragment.RV_CLIP, false)
|
||||
putInt(ContactSelectionListFragment.RV_PADDING_BOTTOM, ViewUtil.dpToPx(60))
|
||||
}
|
||||
}
|
||||
|
||||
return inflater.inflate(R.layout.choose_chats_fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
|
||||
if (includeChatsMode) {
|
||||
toolbar.setTitle(R.string.CreateFoldersFragment__included_chats)
|
||||
} else {
|
||||
toolbar.setTitle(R.string.CreateFoldersFragment__exceptions)
|
||||
}
|
||||
toolbar.setNavigationOnClickListener { findNavController().popBackStack() }
|
||||
|
||||
selectionFragment = childFragmentManager.findFragmentById(R.id.contact_selection_list) as ContactSelectionListFragment
|
||||
contactFilterView = view.findViewById(R.id.contact_filter_edit_text)
|
||||
contactFilterView.setOnFilterChangedListener {
|
||||
if (it.isNullOrEmpty()) {
|
||||
selectionFragment.resetQueryFilter()
|
||||
} else {
|
||||
selectionFragment.setQueryFilter(it)
|
||||
}
|
||||
}
|
||||
|
||||
doneButton = view.findViewById(R.id.done_button)
|
||||
doneButton.setOnClickListener {
|
||||
viewModel.savePendingChats()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
updateEnabledButton()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
if (includeChatsMode && viewModel.state.value.pendingChatTypes.contains(ChatType.INDIVIDUAL)) {
|
||||
selectionFragment.markContactSelected(SelectedContact.forChatType(ChatType.INDIVIDUAL))
|
||||
}
|
||||
if (includeChatsMode && viewModel.state.value.pendingChatTypes.contains(ChatType.GROUPS)) {
|
||||
selectionFragment.markContactSelected(SelectedContact.forChatType(ChatType.GROUPS))
|
||||
}
|
||||
|
||||
val activeSelection: Set<RecipientId> = if (includeChatsMode) {
|
||||
viewModel.state.value.pendingIncludedRecipients
|
||||
} else {
|
||||
viewModel.state.value.pendingExcludedRecipients
|
||||
}
|
||||
|
||||
selectionFragment.markSelected(activeSelection)
|
||||
}
|
||||
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>, callback: Consumer<Boolean>) {
|
||||
if (recipientId.isPresent) {
|
||||
if (includeChatsMode) {
|
||||
viewModel.addIncludedChat(recipientId.get())
|
||||
} else {
|
||||
viewModel.addExcludedChat(recipientId.get())
|
||||
}
|
||||
callback.accept(true)
|
||||
} else if (chatType.isPresent) {
|
||||
viewModel.addChatType(chatType.get())
|
||||
callback.accept(true)
|
||||
} else {
|
||||
callback.accept(false)
|
||||
}
|
||||
updateEnabledButton()
|
||||
}
|
||||
|
||||
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>) {
|
||||
if (recipientId.isPresent) {
|
||||
if (includeChatsMode) {
|
||||
viewModel.removeIncludedChat(recipientId.get())
|
||||
} else {
|
||||
viewModel.removeExcludedChat(recipientId.get())
|
||||
}
|
||||
} else if (chatType.isPresent) {
|
||||
viewModel.removeChatType(chatType.get())
|
||||
}
|
||||
updateEnabledButton()
|
||||
}
|
||||
|
||||
override fun onSelectionChanged() = Unit
|
||||
|
||||
private fun getDefaultDisplayMode(): Int {
|
||||
return ContactSelectionDisplayMode.FLAG_PUSH or
|
||||
ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS or
|
||||
ContactSelectionDisplayMode.FLAG_HIDE_NEW or
|
||||
ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS or
|
||||
ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1 or
|
||||
ContactSelectionDisplayMode.FLAG_SELF
|
||||
}
|
||||
|
||||
private fun updateEnabledButton() {
|
||||
doneButton.isEnabled = viewModel.enableButton()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ChooseChatsFragment::class.java)
|
||||
private val KEY_INCLUDE_CHATS = "include_chats"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,449 @@
|
|||
package org.thoughtcrime.securesms.components.settings.app.chats.folders
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.AvatarImage
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Fragment that allows user to create, edit, or delete an individual folder
|
||||
*/
|
||||
class CreateFoldersFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: ChatFoldersViewModel by activityViewModels()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (viewModel.hasChanges()) {
|
||||
viewModel.showConfirmationDialog(true)
|
||||
} else {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val navController: NavController by remember { mutableStateOf(findNavController()) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val isNewFolder = state.originalFolder.id == -1L
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = if (isNewFolder) stringResource(id = R.string.CreateFoldersFragment__create_a_folder) else stringResource(id = R.string.CreateFoldersFragment__edit_folder),
|
||||
onNavigationClick = {
|
||||
if (viewModel.hasChanges()) {
|
||||
viewModel.showConfirmationDialog(true)
|
||||
} else {
|
||||
navController.popBackStack()
|
||||
}
|
||||
},
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) { contentPadding: PaddingValues ->
|
||||
CreateFolderScreen(
|
||||
state = state,
|
||||
focusRequester = focusRequester,
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
isNewFolder = isNewFolder,
|
||||
hasChanges = viewModel.hasChanges(),
|
||||
onAddChat = {
|
||||
viewModel.setPendingChats()
|
||||
navController.safeNavigate(CreateFoldersFragmentDirections.actionCreateFoldersFragmentToChooseChatsFragment(true))
|
||||
},
|
||||
onRemoveChat = {
|
||||
viewModel.setPendingChats()
|
||||
navController.safeNavigate(CreateFoldersFragmentDirections.actionCreateFoldersFragmentToChooseChatsFragment(false))
|
||||
},
|
||||
onNameChange = { viewModel.updateName(it) },
|
||||
onToggleShowUnread = { viewModel.toggleShowUnread(it) },
|
||||
onToggleShowMuted = { viewModel.toggleShowMutedChats(it) },
|
||||
onDeleteClicked = { viewModel.showDeleteDialog(true) },
|
||||
onDeleteConfirmed = {
|
||||
viewModel.deleteFolder()
|
||||
navController.popBackStack()
|
||||
},
|
||||
onDeleteDismissed = {
|
||||
viewModel.showDeleteDialog(false)
|
||||
},
|
||||
onCreateConfirmed = { shouldExit ->
|
||||
if (isNewFolder) {
|
||||
viewModel.createFolder(requireContext())
|
||||
} else {
|
||||
viewModel.updateFolder(requireContext())
|
||||
}
|
||||
if (shouldExit) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
},
|
||||
onCreateDismissed = { shouldExit ->
|
||||
viewModel.showConfirmationDialog(false)
|
||||
if (shouldExit) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateFolderScreen(
|
||||
state: ChatFoldersSettingsState,
|
||||
focusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
isNewFolder: Boolean = true,
|
||||
hasChanges: Boolean = false,
|
||||
onAddChat: () -> Unit = {},
|
||||
onRemoveChat: () -> Unit = {},
|
||||
onNameChange: (String) -> Unit = {},
|
||||
onToggleShowUnread: (Boolean) -> Unit = {},
|
||||
onToggleShowMuted: (Boolean) -> Unit = {},
|
||||
onDeleteClicked: () -> Unit = {},
|
||||
onDeleteConfirmed: () -> Unit = {},
|
||||
onDeleteDismissed: () -> Unit = {},
|
||||
onCreateConfirmed: (Boolean) -> Unit = {},
|
||||
onCreateDismissed: (Boolean) -> Unit = {}
|
||||
) {
|
||||
if (state.showDeleteDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = "",
|
||||
body = stringResource(id = R.string.CreateFoldersFragment__delete_this_chat_folder),
|
||||
confirm = stringResource(id = R.string.delete),
|
||||
onConfirm = onDeleteConfirmed,
|
||||
dismiss = stringResource(id = android.R.string.cancel),
|
||||
onDismiss = onDeleteDismissed
|
||||
)
|
||||
} else if (state.showConfirmationDialog && isNewFolder) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(id = R.string.CreateFoldersFragment__create_folder_title),
|
||||
body = stringResource(id = R.string.CreateFoldersFragment__do_you_want_to_create, state.currentFolder.name),
|
||||
confirm = stringResource(id = R.string.CreateFoldersFragment__create_folder),
|
||||
onConfirm = { onCreateConfirmed(false) },
|
||||
dismiss = stringResource(id = R.string.CreateFoldersFragment__discard),
|
||||
onDismiss = { onCreateDismissed(true) },
|
||||
onDismissRequest = { onCreateDismissed(false) }
|
||||
)
|
||||
} else if (state.showConfirmationDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(id = R.string.CreateFoldersFragment__save_changes_title),
|
||||
body = stringResource(id = R.string.CreateFoldersFragment__do_you_want_to_save),
|
||||
confirm = stringResource(id = R.string.CreateFoldersFragment__save_changes),
|
||||
onConfirm = { onCreateConfirmed(false) },
|
||||
dismiss = stringResource(id = R.string.CreateFoldersFragment__discard),
|
||||
onDismiss = { onCreateDismissed(true) },
|
||||
onDismissRequest = { onCreateDismissed(false) }
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn {
|
||||
item {
|
||||
TextField(
|
||||
value = state.currentFolder.name,
|
||||
label = { Text(text = stringResource(id = R.string.CreateFoldersFragment__folder_name)) },
|
||||
onValueChange = onNameChange,
|
||||
singleLine = true,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.padding(top = 16.dp, bottom = 12.dp, start = 20.dp, end = 28.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__included_chats),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp, start = 24.dp)
|
||||
)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_plus_compact_16,
|
||||
title = stringResource(R.string.CreateFoldersFragment__add_chats),
|
||||
onClick = onAddChat,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
|
||||
if (state.currentFolder.showIndividualChats) {
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_person_light_24,
|
||||
title = stringResource(R.string.ChatFoldersFragment__one_on_one_chats),
|
||||
onClick = onAddChat,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.currentFolder.showGroupChats) {
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_group_light_20,
|
||||
title = stringResource(R.string.ChatFoldersFragment__groups),
|
||||
onClick = onAddChat,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(state.currentFolder.includedRecipients.toList()) { recipient ->
|
||||
ChatRow(
|
||||
recipient = recipient,
|
||||
onClick = onAddChat
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__choose_chats_you_want),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, start = 24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__exceptions),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(top = 24.dp, bottom = 12.dp, end = 12.dp, start = 24.dp)
|
||||
)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_plus_compact_16,
|
||||
title = stringResource(R.string.CreateFoldersFragment__exclude_chats),
|
||||
onClick = onRemoveChat,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items(state.currentFolder.excludedRecipients.toList()) { recipient ->
|
||||
ChatRow(
|
||||
recipient = recipient,
|
||||
onClick = onRemoveChat
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__choose_chats_you_do_not_want),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, start = 24.dp, end = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
ShowUnreadSection(state, onToggleShowUnread)
|
||||
ShowMutedSection(state, onToggleShowMuted)
|
||||
|
||||
if (!isNewFolder) {
|
||||
Dividers.Default()
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__delete_folder),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier
|
||||
.clickable { onDeleteClicked() }
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, top = 16.dp, bottom = 32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
item { Spacer(modifier = Modifier.height(60.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges && isNewFolder) {
|
||||
Buttons.MediumTonal(
|
||||
onClick = { onCreateConfirmed(true) },
|
||||
modifier = modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.CreateFoldersFragment__create))
|
||||
}
|
||||
} else if (!isNewFolder) {
|
||||
Buttons.MediumTonal(
|
||||
enabled = hasChanges,
|
||||
onClick = { onCreateConfirmed(true) },
|
||||
modifier = modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.CreateFoldersFragment__save))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShowUnreadSection(state: ChatFoldersSettingsState, onToggleShowUnread: (Boolean) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.defaultMinSize(minHeight = 92.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__only_show_unread_chats),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__when_enabled_only_chats),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.currentFolder.showUnread,
|
||||
onCheckedChange = onToggleShowUnread
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShowMutedSection(state: ChatFoldersSettingsState, onToggleShowMuted: (Boolean) -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.defaultMinSize(minHeight = 56.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__include_muted_chats),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.currentFolder.showMutedChats,
|
||||
onCheckedChange = onToggleShowMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun CreateFolderPreview() {
|
||||
val previewFolder = ChatFolderRecord(id = 1, name = "WIP")
|
||||
|
||||
Previews.Preview {
|
||||
CreateFolderScreen(
|
||||
state = ChatFoldersSettingsState(currentFolder = previewFolder),
|
||||
focusRequester = FocusRequester(),
|
||||
isNewFolder = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun EditFolderPreview() {
|
||||
val previewFolder = ChatFolderRecord(id = 1, name = "Work")
|
||||
|
||||
Previews.Preview {
|
||||
CreateFolderScreen(
|
||||
state = ChatFoldersSettingsState(originalFolder = previewFolder),
|
||||
focusRequester = FocusRequester(),
|
||||
isNewFolder = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatRow(
|
||||
recipient: Recipient,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.clickable(onClick = onClick)
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 64.dp)
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 24.dp, end = 16.dp)
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = Color.Red,
|
||||
shape = CircleShape
|
||||
)
|
||||
)
|
||||
} else {
|
||||
AvatarImage(
|
||||
recipient = recipient,
|
||||
modifier = Modifier
|
||||
.padding(start = 24.dp, end = 16.dp)
|
||||
.size(40.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(text = recipient.getShortDisplayName(LocalContext.current))
|
||||
}
|
||||
}
|
|
@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.LoggingFragment
|
|||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
@ -106,7 +107,7 @@ class SelectRecipientsFragment : LoggingFragment(), ContactSelectionListFragment
|
|||
ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1
|
||||
}
|
||||
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>, callback: Consumer<Boolean>) {
|
||||
if (recipientId.isPresent) {
|
||||
viewModel.select(recipientId.get())
|
||||
callback.accept(true)
|
||||
|
@ -116,7 +117,7 @@ class SelectRecipientsFragment : LoggingFragment(), ContactSelectionListFragment
|
|||
}
|
||||
}
|
||||
|
||||
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?) {
|
||||
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>) {
|
||||
if (recipientId.isPresent) {
|
||||
viewModel.deselect(recipientId.get())
|
||||
updateAddToProfile()
|
||||
|
|
|
@ -20,9 +20,9 @@ import org.thoughtcrime.securesms.util.rx.RxStore
|
|||
*/
|
||||
class ContactChipViewModel : ViewModel() {
|
||||
|
||||
private val store = RxStore(emptyList<SelectedContacts.Model>())
|
||||
private val store = RxStore(emptyList<SelectedContacts.Model<*>>())
|
||||
|
||||
val state: Flowable<List<SelectedContacts.Model>> = store.stateFlowable
|
||||
val state: Flowable<List<SelectedContacts.Model<*>>> = store.stateFlowable
|
||||
.distinctUntilChanged()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
|
@ -39,20 +39,27 @@ class ContactChipViewModel : ViewModel() {
|
|||
}
|
||||
|
||||
fun add(selectedContact: SelectedContact) {
|
||||
disposables += getOrCreateRecipientId(selectedContact).map { Recipient.resolved(it) }.observeOn(Schedulers.io()).subscribe { recipient ->
|
||||
store.update { it + SelectedContacts.Model(selectedContact, recipient) }
|
||||
disposableMap[recipient.id]?.dispose()
|
||||
disposableMap[recipient.id] = store.update(recipient.live().observable().toFlowable(BackpressureStrategy.LATEST)) { changedRecipient, state ->
|
||||
val index = state.indexOfFirst { it.selectedContact.matches(selectedContact) }
|
||||
when {
|
||||
index == 0 -> {
|
||||
listOf(SelectedContacts.Model(selectedContact, changedRecipient)) + state.drop(index + 1)
|
||||
}
|
||||
index > 0 -> {
|
||||
state.take(index) + SelectedContacts.Model(selectedContact, changedRecipient) + state.drop(index + 1)
|
||||
}
|
||||
else -> {
|
||||
state
|
||||
if (selectedContact.hasChatType()) {
|
||||
store.update { it + SelectedContacts.ChatTypeModel(selectedContact) }
|
||||
} else {
|
||||
disposables += getOrCreateRecipientId(selectedContact).map { Recipient.resolved(it) }.observeOn(Schedulers.io()).subscribe { recipient ->
|
||||
store.update { it + SelectedContacts.RecipientModel(selectedContact, recipient) }
|
||||
disposableMap[recipient.id]?.dispose()
|
||||
disposableMap[recipient.id] = store.update(recipient.live().observable().toFlowable(BackpressureStrategy.LATEST)) { changedRecipient, state ->
|
||||
val index = state.indexOfFirst { it.selectedContact.matches(selectedContact) }
|
||||
|
||||
when {
|
||||
index == 0 -> {
|
||||
listOf(SelectedContacts.RecipientModel(selectedContact, changedRecipient)) + state.drop(index + 1)
|
||||
}
|
||||
|
||||
index > 0 -> {
|
||||
state.take(index) + SelectedContacts.RecipientModel(selectedContact, changedRecipient) + state.drop(index + 1)
|
||||
}
|
||||
|
||||
else -> {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import android.content.Context;
|
|||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
@ -19,23 +20,29 @@ public final class SelectedContact {
|
|||
private final RecipientId recipientId;
|
||||
private final String number;
|
||||
private final String username;
|
||||
private final ChatType chatType;
|
||||
|
||||
public static @NonNull SelectedContact forPhone(@Nullable RecipientId recipientId, @NonNull String number) {
|
||||
return new SelectedContact(recipientId, number, null);
|
||||
return new SelectedContact(recipientId, number, null, null);
|
||||
}
|
||||
|
||||
public static @NonNull SelectedContact forUsername(@Nullable RecipientId recipientId, @NonNull String username) {
|
||||
return new SelectedContact(recipientId, null, username);
|
||||
return new SelectedContact(recipientId, null, username, null);
|
||||
}
|
||||
|
||||
public static @NonNull SelectedContact forChatType(@NonNull ChatType chatType) {
|
||||
return new SelectedContact(null, null, null, chatType);
|
||||
}
|
||||
|
||||
public static @NonNull SelectedContact forRecipientId(@NonNull RecipientId recipientId) {
|
||||
return new SelectedContact(recipientId, null, null);
|
||||
return new SelectedContact(recipientId, null, null, null);
|
||||
}
|
||||
|
||||
private SelectedContact(@Nullable RecipientId recipientId, @Nullable String number, @Nullable String username) {
|
||||
private SelectedContact(@Nullable RecipientId recipientId, @Nullable String number, @Nullable String username, @Nullable ChatType chatType) {
|
||||
this.recipientId = recipientId;
|
||||
this.number = number;
|
||||
this.username = username;
|
||||
this.chatType = chatType;
|
||||
}
|
||||
|
||||
public @NonNull RecipientId getOrCreateRecipientId(@NonNull Context context) {
|
||||
|
@ -60,6 +67,14 @@ public final class SelectedContact {
|
|||
return username != null;
|
||||
}
|
||||
|
||||
public boolean hasChatType() {
|
||||
return chatType != null;
|
||||
}
|
||||
|
||||
public ChatType getChatType() {
|
||||
return chatType;
|
||||
}
|
||||
|
||||
public @NonNull ContactSearchKey toContactSearchKey() {
|
||||
if (recipientId != null) {
|
||||
return new ContactSearchKey.RecipientSearchKey(recipientId, false);
|
||||
|
@ -67,6 +82,8 @@ public final class SelectedContact {
|
|||
return new ContactSearchKey.UnknownRecipientKey(ContactSearchConfiguration.SectionKey.PHONE_NUMBER, number);
|
||||
} else if (username != null) {
|
||||
return new ContactSearchKey.UnknownRecipientKey(ContactSearchConfiguration.SectionKey.USERNAME, username);
|
||||
} else if (chatType != null) {
|
||||
return new ContactSearchKey.ChatTypeSearchKey(chatType);
|
||||
} else {
|
||||
throw new IllegalStateException("Nothing to map!");
|
||||
}
|
||||
|
@ -86,6 +103,7 @@ public final class SelectedContact {
|
|||
}
|
||||
|
||||
return number != null && number .equals(other.number) ||
|
||||
username != null && username.equals(other.username);
|
||||
username != null && username.equals(other.username) ||
|
||||
chatType != null && chatType.equals(other.chatType);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
package org.thoughtcrime.securesms.contacts
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.view.View
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.bumptech.glide.Glide
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
|
@ -11,25 +15,28 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
|||
|
||||
object SelectedContacts {
|
||||
@JvmStatic
|
||||
fun register(adapter: MappingAdapter, onCloseIconClicked: (Model) -> Unit) {
|
||||
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onCloseIconClicked) }, R.layout.contact_selection_list_chip))
|
||||
fun register(adapter: MappingAdapter, onCloseIconClicked: (Model<*>) -> Unit) {
|
||||
adapter.registerFactory(RecipientModel::class.java, LayoutFactory({ RecipientViewHolder(it, onCloseIconClicked) }, R.layout.contact_selection_list_chip))
|
||||
adapter.registerFactory(ChatTypeModel::class.java, LayoutFactory({ ChatTypeViewHolder(it, onCloseIconClicked) }, R.layout.contact_selection_list_chip))
|
||||
}
|
||||
|
||||
class Model(val selectedContact: SelectedContact, val recipient: Recipient) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
sealed class Model<T : Any>(val selectedContact: SelectedContact) : MappingModel<T>
|
||||
|
||||
class RecipientModel(selectedContact: SelectedContact, val recipient: Recipient) : Model<RecipientModel>(selectedContact = selectedContact) {
|
||||
override fun areItemsTheSame(newItem: RecipientModel): Boolean {
|
||||
return newItem.selectedContact.matches(selectedContact) && recipient == newItem.recipient
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
override fun areContentsTheSame(newItem: RecipientModel): Boolean {
|
||||
return areItemsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View, private val onCloseIconClicked: (Model) -> Unit) : MappingViewHolder<Model>(itemView) {
|
||||
private class RecipientViewHolder(itemView: View, private val onCloseIconClicked: (RecipientModel) -> Unit) : MappingViewHolder<RecipientModel>(itemView) {
|
||||
|
||||
private val chip: ContactChip = itemView.findViewById(R.id.contact_chip)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
override fun bind(model: RecipientModel) {
|
||||
chip.text = model.recipient.getShortDisplayName(context)
|
||||
chip.setContact(model.selectedContact)
|
||||
chip.isCloseIconVisible = true
|
||||
|
@ -39,4 +46,36 @@ object SelectedContacts {
|
|||
chip.setAvatar(Glide.with(itemView), model.recipient, null)
|
||||
}
|
||||
}
|
||||
|
||||
class ChatTypeModel(selectedContact: SelectedContact) : Model<ChatTypeModel>(selectedContact = selectedContact) {
|
||||
override fun areItemsTheSame(newItem: ChatTypeModel): Boolean {
|
||||
return newItem.selectedContact.matches(selectedContact) && newItem.selectedContact.chatType == selectedContact.chatType
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: ChatTypeModel): Boolean {
|
||||
return areItemsTheSame(newItem)
|
||||
}
|
||||
}
|
||||
|
||||
private class ChatTypeViewHolder(itemView: View, private val onCloseIconClicked: (ChatTypeModel) -> Unit) : MappingViewHolder<ChatTypeModel>(itemView) {
|
||||
|
||||
private val chip: ContactChip = itemView.findViewById(R.id.contact_chip)
|
||||
|
||||
override fun bind(model: ChatTypeModel) {
|
||||
if (model.selectedContact.chatType == ChatType.INDIVIDUAL) {
|
||||
chip.text = context.getString(R.string.ChatFoldersFragment__one_on_one_chats)
|
||||
chip.chipIcon = AppCompatResources.getDrawable(context, R.drawable.symbol_person_light_24)
|
||||
chip.chipIconTint = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.signal_colorOnSurface))
|
||||
} else {
|
||||
chip.text = context.getString(R.string.ChatFoldersFragment__groups)
|
||||
chip.chipIcon = AppCompatResources.getDrawable(context, R.drawable.symbol_group_light_20)
|
||||
chip.chipIconTint = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.signal_colorOnSurface))
|
||||
}
|
||||
chip.setContact(model.selectedContact)
|
||||
chip.isCloseIconVisible = true
|
||||
chip.setOnCloseIconClickListener {
|
||||
onCloseIconClicked(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
/**
|
||||
* Enum class that represents the different chat types a chat folder can have
|
||||
*/
|
||||
enum class ChatType {
|
||||
INDIVIDUAL,
|
||||
GROUPS
|
||||
}
|
|
@ -5,6 +5,7 @@ import android.text.SpannableStringBuilder
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CheckBox
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.content.ContextCompat
|
||||
|
@ -57,6 +58,7 @@ open class ContactSearchAdapter(
|
|||
registerKnownRecipientItems(this, fixedContacts, displayOptions, onClickCallbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick, callButtonClickCallbacks)
|
||||
registerHeaders(this)
|
||||
registerExpands(this, onClickCallbacks::onExpandClicked)
|
||||
registerChatTypeItems(this, onClickCallbacks::onChatTypeClicked)
|
||||
registerFactory(UnknownRecipientModel::class.java, LayoutFactory({ UnknownRecipientViewHolder(it, onClickCallbacks::onUnknownRecipientClicked, displayOptions.displayCheckBox) }, R.layout.contact_search_unknown_item))
|
||||
}
|
||||
|
||||
|
@ -117,6 +119,13 @@ open class ContactSearchAdapter(
|
|||
)
|
||||
}
|
||||
|
||||
fun registerChatTypeItems(mappingAdapter: MappingAdapter, chatTypeRowListener: OnClickedCallback<ContactSearchData.ChatTypeRow>) {
|
||||
mappingAdapter.registerFactory(
|
||||
ChatTypeModel::class.java,
|
||||
LayoutFactory({ ChatTypeViewHolder(it, chatTypeRowListener) }, R.layout.contact_search_chat_type_item)
|
||||
)
|
||||
}
|
||||
|
||||
fun toMappingModelList(contactSearchData: List<ContactSearchData?>, selection: Set<ContactSearchKey>, arbitraryRepository: ArbitraryRepository?): MappingModelList {
|
||||
return MappingModelList(
|
||||
contactSearchData.filterNotNull().map {
|
||||
|
@ -132,6 +141,7 @@ open class ContactSearchAdapter(
|
|||
is ContactSearchData.Empty -> EmptyModel(it)
|
||||
is ContactSearchData.GroupWithMembers -> GroupWithMembersModel(it)
|
||||
is ContactSearchData.UnknownRecipient -> UnknownRecipientModel(it)
|
||||
is ContactSearchData.ChatTypeRow -> ChatTypeModel(it, selection.contains(it.contactSearchKey))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -675,6 +685,7 @@ open class ContactSearchAdapter(
|
|||
ContactSearchConfiguration.SectionKey.MESSAGES -> R.string.ContactsCursorLoader__messages
|
||||
ContactSearchConfiguration.SectionKey.GROUPS_WITH_MEMBERS -> R.string.ContactsCursorLoader_group_members
|
||||
ContactSearchConfiguration.SectionKey.CONTACTS_WITHOUT_THREADS -> R.string.ContactsCursorLoader_contacts
|
||||
ContactSearchConfiguration.SectionKey.CHAT_TYPES -> R.string.ContactsCursorLoader__chat_types
|
||||
else -> error("This section does not support HEADER")
|
||||
}
|
||||
)
|
||||
|
@ -712,6 +723,42 @@ open class ContactSearchAdapter(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping Model for chat types.
|
||||
*/
|
||||
class ChatTypeModel(val data: ContactSearchData.ChatTypeRow, val isSelected: Boolean) : MappingModel<ChatTypeModel> {
|
||||
override fun areItemsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data
|
||||
override fun areContentsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data && isSelected == newItem.isSelected
|
||||
}
|
||||
|
||||
/**
|
||||
* View Holder for chat types
|
||||
*/
|
||||
private class ChatTypeViewHolder(
|
||||
itemView: View,
|
||||
val onClick: OnClickedCallback<ContactSearchData.ChatTypeRow>
|
||||
) : MappingViewHolder<ChatTypeModel>(itemView) {
|
||||
|
||||
val image: ImageView = itemView.findViewById(R.id.image)
|
||||
val name: TextView = itemView.findViewById(R.id.name)
|
||||
val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
|
||||
|
||||
override fun bind(model: ChatTypeModel) {
|
||||
itemView.setOnClickListener { onClick.onClicked(itemView, model.data, model.isSelected) }
|
||||
|
||||
image.setImageResource(model.data.imageResId)
|
||||
|
||||
if (model.data.chatType == ChatType.INDIVIDUAL) {
|
||||
name.text = context.getString(R.string.ChatFoldersFragment__one_on_one_chats)
|
||||
}
|
||||
if (model.data.chatType == ChatType.GROUPS) {
|
||||
name.text = context.getString(R.string.ChatFoldersFragment__groups)
|
||||
}
|
||||
|
||||
checkbox.isChecked = model.isSelected
|
||||
}
|
||||
}
|
||||
|
||||
private class IsSelfComparator : Comparator<Recipient> {
|
||||
override fun compare(lhs: Recipient?, rhs: Recipient?): Int {
|
||||
val isLeftSelf = lhs?.isSelf == true
|
||||
|
@ -764,6 +811,7 @@ open class ContactSearchAdapter(
|
|||
fun onUnknownRecipientClicked(view: View, unknownRecipient: ContactSearchData.UnknownRecipient, isSelected: Boolean) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
fun onChatTypeClicked(view: View, chatTypeRow: ContactSearchData.ChatTypeRow, isSelected: Boolean)
|
||||
}
|
||||
|
||||
interface CallButtonClickCallbacks {
|
||||
|
|
|
@ -193,6 +193,18 @@ class ContactSearchConfiguration private constructor(
|
|||
override val includeHeader: Boolean = false
|
||||
override val expandConfig: ExpandConfig? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat types that are displayed when creating a chat folder.
|
||||
*
|
||||
* Key: [ContactSearchKey.ChatType]
|
||||
* Data: [ContactSearchData.ChatTypeRow]
|
||||
* Model: [ContactSearchAdapter.ChatTypeModel]
|
||||
*/
|
||||
data class ChatTypes(
|
||||
override val includeHeader: Boolean = true,
|
||||
override val expandConfig: ExpandConfig? = null
|
||||
) : Section(SectionKey.CHAT_TYPES)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -234,6 +246,11 @@ class ContactSearchConfiguration private constructor(
|
|||
*/
|
||||
CONTACTS_WITHOUT_THREADS,
|
||||
|
||||
/**
|
||||
* Chat types (ie unreads, 1:1, groups) that are used to customize folders
|
||||
*/
|
||||
CHAT_TYPES,
|
||||
|
||||
/**
|
||||
* Arbitrary row (think new group button, username row, etc)
|
||||
*/
|
||||
|
|
|
@ -69,6 +69,14 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
|
|||
val action: HeaderAction?
|
||||
) : ContactSearchData(ContactSearchKey.Header(sectionKey))
|
||||
|
||||
/**
|
||||
* A row containing a chat type (filters that can be applied to a chat folders)
|
||||
*/
|
||||
class ChatTypeRow(
|
||||
val imageResId: Int,
|
||||
val chatType: ChatType
|
||||
) : ContactSearchData(ContactSearchKey.ChatTypeSearchKey(chatType))
|
||||
|
||||
/**
|
||||
* A row which the user can click to view all entries for a given section.
|
||||
*/
|
||||
|
|
|
@ -76,5 +76,14 @@ sealed class ContactSearchKey {
|
|||
*/
|
||||
data class Message(val messageId: Long) : ContactSearchKey()
|
||||
|
||||
/**
|
||||
* Search key for a ChatType
|
||||
*/
|
||||
data class ChatTypeSearchKey(val chatType: ChatType) : ContactSearchKey() {
|
||||
override fun requireSelectedContact(): SelectedContact {
|
||||
return SelectedContact.forChatType(chatType)
|
||||
}
|
||||
}
|
||||
|
||||
object Empty : ContactSearchKey()
|
||||
}
|
||||
|
|
|
@ -87,6 +87,11 @@ class ContactSearchMediator(
|
|||
Log.d(TAG, "onExpandClicked()")
|
||||
viewModel.expandSection(expand.sectionKey)
|
||||
}
|
||||
|
||||
override fun onChatTypeClicked(view: View, chatTypeRow: ContactSearchData.ChatTypeRow, isSelected: Boolean) {
|
||||
Log.d(TAG, "onChatTypeClicked() chatType $chatTypeRow")
|
||||
toggleChatTypeSelection(view, chatTypeRow, isSelected)
|
||||
}
|
||||
},
|
||||
longClickCallbacks = ContactSearchAdapter.LongClickCallbacksAdapter(),
|
||||
storyContextMenuCallbacks = StoryContextMenuCallbacks(),
|
||||
|
@ -188,6 +193,16 @@ class ContactSearchMediator(
|
|||
}
|
||||
}
|
||||
|
||||
private fun toggleChatTypeSelection(view: View, contactSearchData: ContactSearchData, isSelected: Boolean) {
|
||||
return if (isSelected) {
|
||||
Log.d(TAG, "toggleSelection(OFF) ${contactSearchData.contactSearchKey}")
|
||||
viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey))
|
||||
} else {
|
||||
Log.d(TAG, "toggleSelection(ON) ${contactSearchData.contactSearchKey}")
|
||||
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(view, setOf(contactSearchData.contactSearchKey)))
|
||||
}
|
||||
}
|
||||
|
||||
private inner class StoryContextMenuCallbacks : ContactSearchAdapter.StoryContextMenuCallbacks {
|
||||
override fun onOpenStorySettings(story: ContactSearchData.Story) {
|
||||
if (story.recipient.isMyStory) {
|
||||
|
|
|
@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.contacts.paged
|
|||
import android.database.Cursor
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.paging.PagedDataSource
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.ContactRepository
|
||||
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchCollection
|
||||
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator
|
||||
|
@ -142,6 +143,7 @@ class ContactSearchPagedDataSource(
|
|||
is ContactSearchConfiguration.Section.PhoneNumber -> if (isPossiblyPhoneNumber(query)) 1 else 0
|
||||
is ContactSearchConfiguration.Section.Username -> if (isPossiblyUsername(query)) 1 else 0
|
||||
is ContactSearchConfiguration.Section.Empty -> 1
|
||||
is ContactSearchConfiguration.Section.ChatTypes -> getChatTypesData(section).size
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -181,6 +183,7 @@ class ContactSearchPagedDataSource(
|
|||
is ContactSearchConfiguration.Section.PhoneNumber -> getPossiblePhoneNumber(section, query)
|
||||
is ContactSearchConfiguration.Section.Username -> getPossibleUsername(section, query)
|
||||
is ContactSearchConfiguration.Section.Empty -> listOf(ContactSearchData.Empty(query))
|
||||
is ContactSearchConfiguration.Section.ChatTypes -> getChatTypesData(section)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -348,6 +351,22 @@ class ContactSearchPagedDataSource(
|
|||
}
|
||||
}
|
||||
|
||||
// TODO [michelle]: Replace hardcoding chat types after building db
|
||||
private fun getChatTypesData(section: ContactSearchConfiguration.Section.ChatTypes): List<ContactSearchData> {
|
||||
val data = mutableListOf<ContactSearchData>()
|
||||
|
||||
if (section.includeHeader) {
|
||||
data.add(ContactSearchData.Header(section.sectionKey, section.headerAction))
|
||||
}
|
||||
data.addAll(
|
||||
listOf(
|
||||
ContactSearchData.ChatTypeRow(R.drawable.symbol_person_light_24, ChatType.INDIVIDUAL),
|
||||
ContactSearchData.ChatTypeRow(R.drawable.symbol_group_light_20, ChatType.GROUPS)
|
||||
)
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
private fun getContactsWithoutThreadsContactData(section: ContactSearchConfiguration.Section.ContactsWithoutThreads, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
|
||||
return getContactsWithoutThreadsIterator(query).use { records ->
|
||||
readContactData(
|
||||
|
|
|
@ -21,6 +21,7 @@ class ContactSearchRepository {
|
|||
val isSelectable = when (it) {
|
||||
is ContactSearchKey.RecipientSearchKey -> canSelectRecipient(it.recipientId)
|
||||
is ContactSearchKey.UnknownRecipientKey -> it.sectionKey == ContactSearchConfiguration.SectionKey.PHONE_NUMBER
|
||||
is ContactSearchKey.ChatTypeSearchKey -> true
|
||||
else -> false
|
||||
}
|
||||
ContactSearchSelectionResult(it, isSelectable)
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
package org.thoughtcrime.securesms.conversationlist
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* RecyclerView adapter for the chat folders displayed on conversation list
|
||||
*/
|
||||
class ChatFolderAdapter(val callbacks: Callbacks) : MappingAdapter() {
|
||||
|
||||
init {
|
||||
registerFactory(ChatFolderMappingModel::class.java, LayoutFactory({ v -> ViewHolder(v, callbacks) }, R.layout.chat_folder_item))
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View, private val callbacks: Callbacks) : MappingViewHolder<ChatFolderMappingModel>(itemView) {
|
||||
|
||||
private val name: TextView = findViewById(R.id.name)
|
||||
private val unreadCount: TextView = findViewById(R.id.unread_count)
|
||||
|
||||
override fun bind(model: ChatFolderMappingModel) {
|
||||
itemView.isSelected = model.isSelected
|
||||
|
||||
val folder = model.chatFolder
|
||||
name.text = getName(itemView.context, folder)
|
||||
unreadCount.visible = folder.unreadCount > 0
|
||||
unreadCount.text = folder.unreadCount.toString()
|
||||
itemView.setOnClickListener {
|
||||
callbacks.onChatFolderClicked(model.chatFolder)
|
||||
}
|
||||
if (model.isSelected) {
|
||||
itemView.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(itemView.context, R.color.signal_colorSurfaceVariant))
|
||||
} else {
|
||||
itemView.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(itemView.context, R.color.transparent))
|
||||
}
|
||||
}
|
||||
|
||||
private fun getName(context: Context, folder: ChatFolderRecord): String {
|
||||
return if (folder.folderType == ChatFolderRecord.FolderType.ALL) {
|
||||
context.getString(R.string.ChatFoldersFragment__all_chats)
|
||||
} else {
|
||||
folder.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
fun onChatFolderClicked(chatFolder: ChatFolderRecord)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package org.thoughtcrime.securesms.conversationlist
|
||||
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
|
||||
data class ChatFolderMappingModel(
|
||||
val chatFolder: ChatFolderRecord,
|
||||
val isSelected: Boolean
|
||||
) : MappingModel<ChatFolderMappingModel> {
|
||||
override fun areItemsTheSame(newItem: ChatFolderMappingModel): Boolean {
|
||||
return chatFolder == newItem.chatFolder
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: ChatFolderMappingModel): Boolean {
|
||||
return areItemsTheSame(newItem) && isSelected == newItem.isSelected
|
||||
}
|
||||
}
|
|
@ -47,6 +47,7 @@ public class ConversationListArchiveFragment extends ConversationListFragment im
|
|||
{
|
||||
private View coordinator;
|
||||
private RecyclerView list;
|
||||
private RecyclerView foldersList;
|
||||
private Stub<View> emptyState;
|
||||
private PulsingFloatingActionButton fab;
|
||||
private PulsingFloatingActionButton cameraFab;
|
||||
|
@ -73,12 +74,14 @@ public class ConversationListArchiveFragment extends ConversationListFragment im
|
|||
emptyState = new Stub<>(view.findViewById(R.id.empty_state));
|
||||
fab = view.findViewById(R.id.fab);
|
||||
cameraFab = view.findViewById(R.id.camera_fab);
|
||||
foldersList = view.findViewById(R.id.chat_folder_list);
|
||||
|
||||
toolbar.get().setNavigationOnClickListener(v -> NavHostFragment.findNavController(this).popBackStack());
|
||||
toolbar.get().setTitle(R.string.AndroidManifest_archived_conversations);
|
||||
|
||||
fab.hide();
|
||||
cameraFab.hide();
|
||||
foldersList.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -11,6 +11,7 @@ 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.components.settings.app.chats.folders.ChatFolderRecord;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationReader;
|
||||
|
@ -40,16 +41,18 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
|||
protected final ThreadTable threadTable;
|
||||
protected final ConversationFilter conversationFilter;
|
||||
protected final boolean showConversationFooterTip;
|
||||
protected final ChatFolderRecord chatFolder;
|
||||
|
||||
protected ConversationListDataSource(@NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
|
||||
protected ConversationListDataSource(ChatFolderRecord chatFolder, @NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
|
||||
this.chatFolder = chatFolder;
|
||||
this.threadTable = SignalDatabase.threads();
|
||||
this.conversationFilter = conversationFilter;
|
||||
this.showConversationFooterTip = showConversationFooterTip;
|
||||
}
|
||||
|
||||
public static ConversationListDataSource create(@NonNull ConversationFilter conversationFilter, boolean isArchived, boolean showConversationFooterTip) {
|
||||
if (!isArchived) return new UnarchivedConversationListDataSource(conversationFilter, showConversationFooterTip);
|
||||
else return new ArchivedConversationListDataSource(conversationFilter, showConversationFooterTip);
|
||||
public static ConversationListDataSource create(ChatFolderRecord chatFolder, @NonNull ConversationFilter conversationFilter, boolean isArchived, boolean showConversationFooterTip) {
|
||||
if (!isArchived) return new UnarchivedConversationListDataSource(chatFolder, conversationFilter, showConversationFooterTip);
|
||||
else return new ArchivedConversationListDataSource(chatFolder, conversationFilter, showConversationFooterTip);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -136,8 +139,8 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
|||
|
||||
private int totalCount;
|
||||
|
||||
ArchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
|
||||
super(conversationFilter, showConversationFooterTip);
|
||||
ArchivedConversationListDataSource(@NonNull ChatFolderRecord chatFolder, @NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
|
||||
super(chatFolder, conversationFilter, showConversationFooterTip);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -168,33 +171,23 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
|||
private int totalCount;
|
||||
private int pinnedCount;
|
||||
private int archivedCount;
|
||||
private int unpinnedCount;
|
||||
|
||||
UnarchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
|
||||
super(conversationFilter, showConversationFooterTip);
|
||||
UnarchivedConversationListDataSource(@NonNull ChatFolderRecord chatFolder, @NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
|
||||
super(chatFolder, conversationFilter, showConversationFooterTip);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getTotalCount() {
|
||||
int unarchivedCount = threadTable.getUnarchivedConversationListCount(conversationFilter);
|
||||
int unarchivedCount = threadTable.getUnarchivedConversationListCount(conversationFilter, chatFolder);
|
||||
|
||||
pinnedCount = threadTable.getPinnedConversationListCount(conversationFilter);
|
||||
pinnedCount = threadTable.getPinnedConversationListCount(conversationFilter, chatFolder);
|
||||
archivedCount = threadTable.getArchivedConversationListCount(conversationFilter);
|
||||
unpinnedCount = unarchivedCount - pinnedCount;
|
||||
totalCount = unarchivedCount;
|
||||
|
||||
if (archivedCount != 0) {
|
||||
if (chatFolder.getFolderType() == ChatFolderRecord.FolderType.ALL && archivedCount != 0) {
|
||||
totalCount++;
|
||||
}
|
||||
|
||||
if (pinnedCount != 0) {
|
||||
if (unpinnedCount != 0) {
|
||||
totalCount += 2;
|
||||
} else {
|
||||
totalCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
|
@ -203,26 +196,12 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
|||
List<Cursor> cursors = new ArrayList<>(5);
|
||||
long originalLimit = limit;
|
||||
|
||||
if (offset == 0 && hasPinnedHeader()) {
|
||||
MatrixCursor pinnedHeaderCursor = new MatrixCursor(ConversationReader.HEADER_COLUMN);
|
||||
pinnedHeaderCursor.addRow(ConversationReader.PINNED_HEADER);
|
||||
cursors.add(pinnedHeaderCursor);
|
||||
limit--;
|
||||
}
|
||||
|
||||
Cursor pinnedCursor = threadTable.getUnarchivedConversationList(conversationFilter, true, offset, limit);
|
||||
Cursor pinnedCursor = threadTable.getUnarchivedConversationList(conversationFilter, true, offset, limit, chatFolder);
|
||||
cursors.add(pinnedCursor);
|
||||
limit -= pinnedCursor.getCount();
|
||||
|
||||
if (offset == 0 && hasUnpinnedHeader()) {
|
||||
MatrixCursor unpinnedHeaderCursor = new MatrixCursor(ConversationReader.HEADER_COLUMN);
|
||||
unpinnedHeaderCursor.addRow(ConversationReader.UNPINNED_HEADER);
|
||||
cursors.add(unpinnedHeaderCursor);
|
||||
limit--;
|
||||
}
|
||||
|
||||
long unpinnedOffset = Math.max(0, offset - pinnedCount - getHeaderOffset());
|
||||
Cursor unpinnedCursor = threadTable.getUnarchivedConversationList(conversationFilter, false, unpinnedOffset, limit);
|
||||
long unpinnedOffset = Math.max(0, offset - pinnedCount);
|
||||
Cursor unpinnedCursor = threadTable.getUnarchivedConversationList(conversationFilter, false, unpinnedOffset, limit, chatFolder);
|
||||
cursors.add(unpinnedCursor);
|
||||
|
||||
boolean shouldInsertConversationFilterFooter = offset + originalLimit >= totalCount && hasConversationFilterFooter();
|
||||
|
@ -242,24 +221,9 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
|||
return new MergeCursor(cursors.toArray(new Cursor[]{}));
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
int getHeaderOffset() {
|
||||
return (hasPinnedHeader() ? 1 : 0) + (hasUnpinnedHeader() ? 1 : 0);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
boolean hasPinnedHeader() {
|
||||
return pinnedCount != 0;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
boolean hasUnpinnedHeader() {
|
||||
return hasPinnedHeader() && unpinnedCount != 0;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
boolean hasArchivedFooter() {
|
||||
return archivedCount != 0;
|
||||
return archivedCount != 0 && chatFolder.getFolderType() == ChatFolderRecord.FolderType.ALL;
|
||||
}
|
||||
|
||||
boolean hasConversationFilterFooter() {
|
||||
|
|
|
@ -114,6 +114,7 @@ import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar;
|
|||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
|
||||
import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord;
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation;
|
||||
|
@ -167,6 +168,7 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
|||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.CachedInflater;
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalProxyUtil;
|
||||
|
@ -200,7 +202,8 @@ import static android.app.Activity.RESULT_OK;
|
|||
public class ConversationListFragment extends MainFragment implements ActionMode.Callback,
|
||||
ConversationListAdapter.OnConversationClickListener,
|
||||
MegaphoneActionController,
|
||||
ClearFilterViewHolder.OnClearFilterClickListener
|
||||
ClearFilterViewHolder.OnClearFilterClickListener,
|
||||
ChatFolderAdapter.Callbacks
|
||||
{
|
||||
public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562;
|
||||
public static final short SMS_ROLE_REQUEST_CODE = 32563;
|
||||
|
@ -216,6 +219,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
|||
|
||||
private ActionMode actionMode;
|
||||
private View coordinator;
|
||||
private RecyclerView chatFolderList;
|
||||
private RecyclerView list;
|
||||
private Stub<ComposeView> bannerView;
|
||||
private PulsingFloatingActionButton fab;
|
||||
|
@ -236,6 +240,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
|||
private SignalBottomActionBar bottomActionBar;
|
||||
private SignalContextMenu activeContextMenu;
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
private ChatFolderAdapter chatFolderAdapter;
|
||||
|
||||
protected ConversationListArchiveItemDecoration archiveDecoration;
|
||||
protected ConversationListItemAnimator itemAnimator;
|
||||
|
@ -281,6 +286,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
|||
lifecycleDisposable.bindTo(getViewLifecycleOwner());
|
||||
|
||||
coordinator = view.findViewById(R.id.coordinator);
|
||||
chatFolderList = view.findViewById(R.id.chat_folder_list);
|
||||
list = view.findViewById(R.id.list);
|
||||
bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar);
|
||||
bannerView = new Stub<>(view.findViewById(R.id.banner_compose_view));
|
||||
|
@ -293,6 +299,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
|||
|
||||
fab.setVisibility(View.VISIBLE);
|
||||
cameraFab.setVisibility(View.VISIBLE);
|
||||
chatFolderList.setVisibility(RemoteConfig.internalUser() ? View.VISIBLE : View.GONE);
|
||||
|
||||
contactSearchMediator = new ContactSearchMediator(this,
|
||||
Collections.emptySet(),
|
||||
|
@ -381,6 +388,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
|||
archiveDecoration = new ConversationListArchiveItemDecoration(new ColorDrawable(getResources().getColor(R.color.conversation_list_archive_background_end)));
|
||||
itemAnimator = new ConversationListItemAnimator();
|
||||
|
||||
chatFolderAdapter = new ChatFolderAdapter(this);
|
||||
|
||||
chatFolderList.setLayoutManager(new LinearLayoutManager(requireActivity(), LinearLayoutManager.HORIZONTAL, false));
|
||||
chatFolderList.setAdapter(chatFolderAdapter);
|
||||
chatFolderList.setItemAnimator(null);
|
||||
|
||||
list.setLayoutManager(new LinearLayoutManager(requireActivity()));
|
||||
list.setItemAnimator(itemAnimator);
|
||||
list.addItemDecoration(archiveDecoration);
|
||||
|
@ -972,6 +985,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
|||
lifecycleDisposable.add(viewModel.getHasNoConversations().subscribe(this::updateEmptyState));
|
||||
lifecycleDisposable.add(viewModel.getNotificationProfiles().subscribe(profiles -> requireCallback().updateNotificationProfileStatus(profiles)));
|
||||
lifecycleDisposable.add(viewModel.getWebSocketState().subscribe(pipeState -> requireCallback().updateProxyStatus(pipeState)));
|
||||
lifecycleDisposable.add(viewModel.getChatFolderState().subscribe(this::onChatFoldersChanged));
|
||||
|
||||
appForegroundObserver = new AppForegroundObserver.Listener() {
|
||||
@Override
|
||||
|
@ -1031,6 +1045,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
|||
});
|
||||
}
|
||||
|
||||
private void onChatFoldersChanged(List<ChatFolderMappingModel> folders) {
|
||||
chatFolderAdapter.submitList(new ArrayList<>(folders));
|
||||
}
|
||||
|
||||
private void onMegaphoneChanged(@NonNull Megaphone megaphone) {
|
||||
if (megaphone == Megaphone.NONE || isArchived() || getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
if (megaphoneContainer.resolved()) {
|
||||
|
@ -1643,6 +1661,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
|||
pullViewAppBarLayout.setExpanded(false, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChatFolderClicked(@NonNull ChatFolderRecord chatFolder) {
|
||||
viewModel.select(chatFolder);
|
||||
}
|
||||
|
||||
private class ArchiveListenerCallback extends ItemTouchHelper.SimpleCallback {
|
||||
|
||||
private static final long SWIPE_ANIMATION_DURATION = 175;
|
||||
|
@ -1887,6 +1910,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
|||
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
|
||||
public interface Callback extends Material3OnScrollHelperBinder, SearchBinder {
|
||||
|
|
|
@ -134,6 +134,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
|
|||
private View uncheckedView;
|
||||
private View checkedView;
|
||||
private View unreadMentions;
|
||||
private View pinnedView;
|
||||
private int thumbSize;
|
||||
private GlideLiveDataTarget thumbTarget;
|
||||
|
||||
|
@ -170,6 +171,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
|
|||
this.uncheckedView = findViewById(R.id.conversation_list_item_unchecked);
|
||||
this.checkedView = findViewById(R.id.conversation_list_item_checked);
|
||||
this.unreadMentions = findViewById(R.id.conversation_list_item_unread_mentions_indicator);
|
||||
this.pinnedView = findViewById(R.id.conversation_list_item_pinned);
|
||||
this.thumbSize = (int) DimensionUnit.SP.toPixels(16f);
|
||||
this.thumbTarget = new GlideLiveDataTarget(thumbSize, thumbSize);
|
||||
this.searchStyleFactory = () -> new CharacterStyle[] { new ForegroundColorSpan(ContextCompat.getColor(getContext(), R.color.signal_colorOnSurface)), SpanUtil.getBoldSpan() };
|
||||
|
@ -279,6 +281,12 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
|
|||
this.archivedView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (thread.isPinned()) {
|
||||
this.pinnedView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
this.pinnedView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
setStatusIcons(thread);
|
||||
setSelectedConversations(selectedConversations);
|
||||
setBadgeFromRecipient(recipient.get());
|
||||
|
|
|
@ -2,16 +2,22 @@ package org.thoughtcrime.securesms.conversationlist
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.Flowables
|
||||
import io.reactivex.rxjava3.kotlin.addTo
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.paging.PagedData
|
||||
import org.signal.paging.PagingConfig
|
||||
import org.signal.paging.ProxyPagingController
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFoldersRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource
|
||||
|
@ -53,10 +59,15 @@ class ConversationListViewModel(
|
|||
val megaphoneState: Flowable<Megaphone> = store.mapDistinctForUi { it.megaphone }
|
||||
val selectedState: Flowable<ConversationSet> = store.mapDistinctForUi { it.selectedConversations }
|
||||
val filterRequestState: Flowable<ConversationFilterRequest> = store.mapDistinctForUi { it.filterRequest }
|
||||
val chatFolderState: Flowable<List<ChatFolderMappingModel>> = store.mapDistinctForUi { it.chatFolders }
|
||||
val hasNoConversations: Flowable<Boolean>
|
||||
|
||||
val controller = ProxyPagingController<Long>()
|
||||
|
||||
val folders: List<ChatFolderMappingModel>
|
||||
get() = store.state.chatFolders
|
||||
val currentFolder: ChatFolderRecord
|
||||
get() = store.state.currentFolder
|
||||
val conversationFilterRequest: ConversationFilterRequest
|
||||
get() = store.state.filterRequest
|
||||
val megaphone: Megaphone
|
||||
|
@ -74,13 +85,14 @@ class ConversationListViewModel(
|
|||
conversationListDataSource = store
|
||||
.stateFlowable
|
||||
.subscribeOn(Schedulers.io())
|
||||
.map { it.filterRequest }
|
||||
.map { it.filterRequest to it.currentFolder }
|
||||
.distinctUntilChanged()
|
||||
.map {
|
||||
.map { (filterRequest, folder) ->
|
||||
ConversationListDataSource.create(
|
||||
it.filter,
|
||||
folder,
|
||||
filterRequest.filter,
|
||||
isArchived,
|
||||
SignalStore.uiHints.canDisplayPullToFilterTip() && it.source === ConversationFilterSource.OVERFLOW
|
||||
SignalStore.uiHints.canDisplayPullToFilterTip() && filterRequest.source === ConversationFilterSource.OVERFLOW
|
||||
)
|
||||
}
|
||||
.replay(1)
|
||||
|
@ -100,6 +112,17 @@ class ConversationListViewModel(
|
|||
.subscribe { controller.onDataInvalidated() }
|
||||
.addTo(disposables)
|
||||
|
||||
Flowables.combineLatest(
|
||||
RxDatabaseObserver
|
||||
.conversationList
|
||||
.debounce(250, TimeUnit.MILLISECONDS),
|
||||
RxDatabaseObserver
|
||||
.chatFolders
|
||||
.throttleLatest(500, TimeUnit.MILLISECONDS)
|
||||
)
|
||||
.subscribe { loadCurrentFolders() }
|
||||
.addTo(disposables)
|
||||
|
||||
val pinnedCount = RxDatabaseObserver
|
||||
.conversationList
|
||||
.map { SignalDatabase.threads.getPinnedConversationListCount(ConversationFilter.OFF) }
|
||||
|
@ -191,6 +214,28 @@ class ConversationListViewModel(
|
|||
megaphoneRepository.markVisible(visible.event)
|
||||
}
|
||||
|
||||
private fun loadCurrentFolders() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val folders = ChatFoldersRepository.getCurrentFolders(includeUnreadCount = true)
|
||||
|
||||
val selectedFolderId = if (currentFolder.id == -1L) {
|
||||
folders.firstOrNull()?.id
|
||||
} else {
|
||||
currentFolder.id
|
||||
}
|
||||
val chatFolders = folders.map { folder ->
|
||||
ChatFolderMappingModel(folder, selectedFolderId == folder.id)
|
||||
}
|
||||
|
||||
store.update {
|
||||
it.copy(
|
||||
currentFolder = folders.find { folder -> folder.id == selectedFolderId } ?: ChatFolderRecord(),
|
||||
chatFolders = chatFolders
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getNotificationProfiles(): Flowable<List<NotificationProfile>> {
|
||||
return notificationProfilesRepository.getProfiles()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
|
@ -203,7 +248,20 @@ class ConversationListViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
fun select(chatFolder: ChatFolderRecord) {
|
||||
store.update {
|
||||
it.copy(
|
||||
currentFolder = chatFolder,
|
||||
chatFolders = folders.map { model ->
|
||||
model.copy(isSelected = chatFolder.id == model.chatFolder.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class ConversationListState(
|
||||
val chatFolders: List<ChatFolderMappingModel> = emptyList(),
|
||||
val currentFolder: ChatFolderRecord = ChatFolderRecord(),
|
||||
val conversations: List<Conversation> = emptyList(),
|
||||
val megaphone: Megaphone = Megaphone.NONE,
|
||||
val selectedConversations: ConversationSet = ConversationSet(),
|
||||
|
|
|
@ -0,0 +1,321 @@
|
|||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.delete
|
||||
import org.signal.core.util.groupBy
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.readToSingleInt
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
|
||||
/**
|
||||
* Stores chat folders and the chats that belong in each chat folder
|
||||
*/
|
||||
class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : DatabaseTable(context, databaseHelper), ThreadIdDatabaseReference {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val CREATE_TABLE: Array<String> = arrayOf(ChatFolderTable.CREATE_TABLE, ChatFolderMembershipTable.CREATE_TABLE)
|
||||
|
||||
@JvmField
|
||||
val CREATE_INDEXES: Array<String> = ChatFolderTable.CREATE_INDEX + ChatFolderMembershipTable.CREATE_INDEXES
|
||||
|
||||
fun insertInitialChatFoldersAtCreationTime(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
|
||||
db.insert(ChatFolderTable.TABLE_NAME, null, getAllChatsFolderContentValues())
|
||||
}
|
||||
|
||||
private fun getAllChatsFolderContentValues(): ContentValues {
|
||||
return contentValuesOf(
|
||||
ChatFolderTable.POSITION to 0,
|
||||
ChatFolderTable.FOLDER_TYPE to ChatFolderRecord.FolderType.ALL.value,
|
||||
ChatFolderTable.SHOW_INDIVIDUAL to 1,
|
||||
ChatFolderTable.SHOW_GROUPS to 1,
|
||||
ChatFolderTable.SHOW_MUTED to 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the components of a chat folder and any chat types it contains
|
||||
*/
|
||||
object ChatFolderTable {
|
||||
const val TABLE_NAME = "chat_folder"
|
||||
|
||||
const val ID = "_id"
|
||||
const val NAME = "name"
|
||||
const val POSITION = "position"
|
||||
const val SHOW_UNREAD = "show_unread"
|
||||
const val SHOW_MUTED = "show_muted"
|
||||
const val SHOW_INDIVIDUAL = "show_individual"
|
||||
const val SHOW_GROUPS = "show_groups"
|
||||
const val IS_MUTED = "is_muted"
|
||||
const val FOLDER_TYPE = "folder_type"
|
||||
|
||||
val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$NAME TEXT DEFAULT NULL,
|
||||
$POSITION INTEGER DEFAULT 0,
|
||||
$SHOW_UNREAD INTEGER DEFAULT 0,
|
||||
$SHOW_MUTED INTEGER DEFAULT 0,
|
||||
$SHOW_INDIVIDUAL INTEGER DEFAULT 0,
|
||||
$SHOW_GROUPS INTEGER DEFAULT 0,
|
||||
$IS_MUTED INTEGER DEFAULT 0,
|
||||
$FOLDER_TYPE INTEGER DEFAULT ${ChatFolderRecord.FolderType.CUSTOM.value}
|
||||
)
|
||||
"""
|
||||
|
||||
val CREATE_INDEX = arrayOf(
|
||||
"CREATE INDEX chat_folder_position_index ON $TABLE_NAME ($POSITION)"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a thread that is associated with this chat folder. They are
|
||||
* either included in the chat folder or explicitly excluded.
|
||||
*/
|
||||
object ChatFolderMembershipTable {
|
||||
const val TABLE_NAME = "chat_folder_membership"
|
||||
|
||||
const val ID = "_id"
|
||||
const val CHAT_FOLDER_ID = "chat_folder_id"
|
||||
const val THREAD_ID = "thread_id"
|
||||
const val MEMBERSHIP_TYPE = "membership_type"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$CHAT_FOLDER_ID INTEGER NOT NULL REFERENCES ${ChatFolderTable.TABLE_NAME} (${ChatFolderTable.ID}) ON DELETE CASCADE,
|
||||
$THREAD_ID INTEGER NOT NULL REFERENCES ${ThreadTable.TABLE_NAME} (${ThreadTable.ID}) ON DELETE CASCADE,
|
||||
$MEMBERSHIP_TYPE INTEGER DEFAULT 1
|
||||
)
|
||||
"""
|
||||
|
||||
val CREATE_INDEXES = arrayOf(
|
||||
"CREATE INDEX chat_folder_membership_chat_folder_id_index ON $TABLE_NAME ($CHAT_FOLDER_ID)",
|
||||
"CREATE INDEX chat_folder_membership_thread_id_index ON $TABLE_NAME ($THREAD_ID)",
|
||||
"CREATE INDEX chat_folder_membership_membership_type_index ON $TABLE_NAME ($MEMBERSHIP_TYPE)"
|
||||
)
|
||||
}
|
||||
|
||||
override fun remapThread(fromId: Long, toId: Long) {
|
||||
writableDatabase
|
||||
.update(ChatFolderMembershipTable.TABLE_NAME)
|
||||
.values(ChatFolderMembershipTable.THREAD_ID to toId)
|
||||
.where("${ChatFolderMembershipTable.THREAD_ID} = ?", fromId)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the chat folder ids to its corresponding chat folder
|
||||
*/
|
||||
fun getChatFolders(includeUnreads: Boolean = false): List<ChatFolderRecord> {
|
||||
val includedChats: Map<Long, List<Long>> = getIncludedChats()
|
||||
val excludedChats: Map<Long, List<Long>> = getExcludedChats()
|
||||
|
||||
val folders = readableDatabase
|
||||
.select()
|
||||
.from(ChatFolderTable.TABLE_NAME)
|
||||
.orderBy(ChatFolderTable.POSITION)
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
val id = cursor.requireLong(ChatFolderTable.ID)
|
||||
ChatFolderRecord(
|
||||
id = id,
|
||||
name = cursor.requireString(ChatFolderTable.NAME) ?: "",
|
||||
position = cursor.requireInt(ChatFolderTable.POSITION),
|
||||
showUnread = cursor.requireBoolean(ChatFolderTable.SHOW_UNREAD),
|
||||
showMutedChats = cursor.requireBoolean(ChatFolderTable.SHOW_MUTED),
|
||||
showIndividualChats = cursor.requireBoolean(ChatFolderTable.SHOW_INDIVIDUAL),
|
||||
showGroupChats = cursor.requireBoolean(ChatFolderTable.SHOW_GROUPS),
|
||||
isMuted = cursor.requireBoolean(ChatFolderTable.IS_MUTED),
|
||||
folderType = ChatFolderRecord.FolderType.deserialize(cursor.requireInt(ChatFolderTable.FOLDER_TYPE)),
|
||||
includedChats = includedChats[id] ?: emptyList(),
|
||||
excludedChats = excludedChats[id] ?: emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
if (includeUnreads) {
|
||||
return folders.map { folder ->
|
||||
folder.copy(
|
||||
unreadCount = SignalDatabase.threads.getUnreadCountByChatFolder(folder)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return folders
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps chat folder ids to all of its corresponding included chats
|
||||
*/
|
||||
private fun getIncludedChats(): Map<Long, List<Long>> {
|
||||
return readableDatabase
|
||||
.select()
|
||||
.from(ChatFolderMembershipTable.TABLE_NAME)
|
||||
.where("${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.INCLUDED.value}")
|
||||
.run()
|
||||
.groupBy { cursor ->
|
||||
cursor.requireLong(ChatFolderMembershipTable.CHAT_FOLDER_ID) to cursor.requireLong(ChatFolderMembershipTable.THREAD_ID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the chat folder ids to all of its corresponding excluded chats
|
||||
*/
|
||||
private fun getExcludedChats(): Map<Long, List<Long>> {
|
||||
return readableDatabase
|
||||
.select()
|
||||
.from(ChatFolderMembershipTable.TABLE_NAME)
|
||||
.where("${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.EXCLUDED.value}")
|
||||
.run()
|
||||
.groupBy { cursor ->
|
||||
cursor.requireLong(ChatFolderMembershipTable.CHAT_FOLDER_ID) to cursor.requireLong(ChatFolderMembershipTable.THREAD_ID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a chat folder and its corresponding included/excluded chats
|
||||
*/
|
||||
fun createFolder(chatFolder: ChatFolderRecord) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
val position: Int = db
|
||||
.select("MAX(${ChatFolderTable.POSITION})")
|
||||
.from(ChatFolderTable.TABLE_NAME)
|
||||
.run()
|
||||
.readToSingleInt(0) + 1
|
||||
|
||||
val id = db.insertInto(ChatFolderTable.TABLE_NAME)
|
||||
.values(
|
||||
contentValuesOf(
|
||||
ChatFolderTable.NAME to chatFolder.name,
|
||||
ChatFolderTable.SHOW_UNREAD to chatFolder.showUnread,
|
||||
ChatFolderTable.SHOW_MUTED to chatFolder.showMutedChats,
|
||||
ChatFolderTable.SHOW_INDIVIDUAL to chatFolder.showIndividualChats,
|
||||
ChatFolderTable.SHOW_GROUPS to chatFolder.showGroupChats,
|
||||
ChatFolderTable.IS_MUTED to chatFolder.isMuted,
|
||||
ChatFolderTable.POSITION to position
|
||||
)
|
||||
)
|
||||
.run(SQLiteDatabase.CONFLICT_IGNORE)
|
||||
|
||||
val includedChatsQueries = SqlUtil.buildBulkInsert(
|
||||
ChatFolderMembershipTable.TABLE_NAME,
|
||||
arrayOf(ChatFolderMembershipTable.CHAT_FOLDER_ID, ChatFolderMembershipTable.THREAD_ID, ChatFolderMembershipTable.MEMBERSHIP_TYPE),
|
||||
chatFolder.includedChats.toContentValues(chatFolderId = id, membershipType = MembershipType.INCLUDED)
|
||||
)
|
||||
|
||||
val excludedChatsQueries = SqlUtil.buildBulkInsert(
|
||||
ChatFolderMembershipTable.TABLE_NAME,
|
||||
arrayOf(ChatFolderMembershipTable.CHAT_FOLDER_ID, ChatFolderMembershipTable.THREAD_ID, ChatFolderMembershipTable.MEMBERSHIP_TYPE),
|
||||
chatFolder.excludedChats.toContentValues(chatFolderId = id, membershipType = MembershipType.EXCLUDED)
|
||||
)
|
||||
|
||||
includedChatsQueries.forEach {
|
||||
db.execSQL(it.where, it.whereArgs)
|
||||
}
|
||||
|
||||
excludedChatsQueries.forEach {
|
||||
db.execSQL(it.where, it.whereArgs)
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.notifyChatFolderObservers()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the details for an existing folder like name, chat types, etc.
|
||||
*/
|
||||
fun updateFolder(chatFolder: ChatFolderRecord) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
db.update(ChatFolderTable.TABLE_NAME)
|
||||
.values(
|
||||
ChatFolderTable.NAME to chatFolder.name,
|
||||
ChatFolderTable.SHOW_UNREAD to chatFolder.showUnread,
|
||||
ChatFolderTable.SHOW_MUTED to chatFolder.showMutedChats,
|
||||
ChatFolderTable.SHOW_INDIVIDUAL to chatFolder.showIndividualChats,
|
||||
ChatFolderTable.SHOW_GROUPS to chatFolder.showGroupChats,
|
||||
ChatFolderTable.IS_MUTED to chatFolder.isMuted
|
||||
)
|
||||
.where("${ChatFolderTable.ID} = ?", chatFolder.id)
|
||||
.run(SQLiteDatabase.CONFLICT_IGNORE)
|
||||
|
||||
db
|
||||
.delete(ChatFolderMembershipTable.TABLE_NAME)
|
||||
.where("${ChatFolderMembershipTable.CHAT_FOLDER_ID} = ?", chatFolder.id)
|
||||
.run()
|
||||
|
||||
val includedChats = SqlUtil.buildBulkInsert(
|
||||
ChatFolderMembershipTable.TABLE_NAME,
|
||||
arrayOf(ChatFolderMembershipTable.CHAT_FOLDER_ID, ChatFolderMembershipTable.THREAD_ID, ChatFolderMembershipTable.MEMBERSHIP_TYPE),
|
||||
chatFolder.includedChats.toContentValues(chatFolderId = chatFolder.id, membershipType = MembershipType.INCLUDED)
|
||||
)
|
||||
|
||||
val excludedChats = SqlUtil.buildBulkInsert(
|
||||
ChatFolderMembershipTable.TABLE_NAME,
|
||||
arrayOf(ChatFolderMembershipTable.CHAT_FOLDER_ID, ChatFolderMembershipTable.THREAD_ID, ChatFolderMembershipTable.MEMBERSHIP_TYPE),
|
||||
chatFolder.excludedChats.toContentValues(chatFolderId = chatFolder.id, membershipType = MembershipType.EXCLUDED)
|
||||
)
|
||||
|
||||
(includedChats + excludedChats).forEach {
|
||||
db.execSQL(it.where, it.whereArgs)
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.notifyChatFolderObservers()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a chat folder
|
||||
*/
|
||||
fun deleteChatFolder(chatFolder: ChatFolderRecord) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
db.delete(ChatFolderTable.TABLE_NAME, "${ChatFolderTable.ID} = ?", SqlUtil.buildArgs(chatFolder.id))
|
||||
AppDependencies.databaseObserver.notifyChatFolderObservers()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the position of the chat folders
|
||||
*/
|
||||
fun updatePositions(folders: List<ChatFolderRecord>) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
folders.forEach { folder ->
|
||||
db.update(ChatFolderTable.TABLE_NAME)
|
||||
.values(ChatFolderTable.POSITION to folder.position)
|
||||
.where("${ChatFolderTable.ID} = ?", folder.id)
|
||||
.run(SQLiteDatabase.CONFLICT_IGNORE)
|
||||
}
|
||||
AppDependencies.databaseObserver.notifyChatFolderObservers()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Collection<Long>.toContentValues(chatFolderId: Long, membershipType: MembershipType): List<ContentValues> {
|
||||
return map {
|
||||
contentValuesOf(
|
||||
ChatFolderMembershipTable.CHAT_FOLDER_ID to chatFolderId,
|
||||
ChatFolderMembershipTable.THREAD_ID to it,
|
||||
ChatFolderMembershipTable.MEMBERSHIP_TYPE to membershipType.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum class MembershipType(val value: Int) {
|
||||
/** Chat that should be included in the chat folder */
|
||||
INCLUDED(0),
|
||||
|
||||
/** Chat that should be excluded from the chat folder */
|
||||
EXCLUDED(1)
|
||||
}
|
||||
}
|
|
@ -47,6 +47,7 @@ public class DatabaseObserver {
|
|||
private static final String KEY_CALL_UPDATES = "CallUpdates";
|
||||
private static final String KEY_CALL_LINK_UPDATES = "CallLinkUpdates";
|
||||
private static final String KEY_IN_APP_PAYMENTS = "InAppPayments";
|
||||
private static final String KEY_CHAT_FOLDER = "ChatFolder";
|
||||
|
||||
private final Executor executor;
|
||||
|
||||
|
@ -69,6 +70,7 @@ public class DatabaseObserver {
|
|||
private final Set<Observer> callUpdateObservers;
|
||||
private final Map<CallLinkRoomId, Set<Observer>> callLinkObservers;
|
||||
private final Set<InAppPaymentObserver> inAppPaymentObservers;
|
||||
private final Set<Observer> chatFolderObservers;
|
||||
|
||||
public DatabaseObserver() {
|
||||
this.executor = new SerialExecutor(SignalExecutors.BOUNDED);
|
||||
|
@ -91,6 +93,7 @@ public class DatabaseObserver {
|
|||
this.callUpdateObservers = new HashSet<>();
|
||||
this.callLinkObservers = new HashMap<>();
|
||||
this.inAppPaymentObservers = new HashSet<>();
|
||||
this.chatFolderObservers = new HashSet<>();
|
||||
}
|
||||
|
||||
public void registerConversationListObserver(@NonNull Observer listener) {
|
||||
|
@ -206,6 +209,10 @@ public class DatabaseObserver {
|
|||
executor.execute(() -> inAppPaymentObservers.add(observer));
|
||||
}
|
||||
|
||||
public void registerChatFolderObserver(@NonNull Observer observer) {
|
||||
executor.execute(() -> chatFolderObservers.add(observer));
|
||||
}
|
||||
|
||||
public void unregisterObserver(@NonNull Observer listener) {
|
||||
executor.execute(() -> {
|
||||
conversationListObservers.remove(listener);
|
||||
|
@ -223,6 +230,7 @@ public class DatabaseObserver {
|
|||
unregisterMapped(conversationDeleteObservers, listener);
|
||||
callUpdateObservers.remove(listener);
|
||||
unregisterMapped(callLinkObservers, listener);
|
||||
chatFolderObservers.remove(listener);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -387,6 +395,10 @@ public class DatabaseObserver {
|
|||
});
|
||||
}
|
||||
|
||||
public void notifyChatFolderObservers() {
|
||||
runPostSuccessfulTransaction(KEY_CHAT_FOLDER, () -> notifySet(chatFolderObservers));
|
||||
}
|
||||
|
||||
private void runPostSuccessfulTransaction(@NonNull String dedupeKey, @NonNull Runnable runnable) {
|
||||
SignalDatabase.runPostSuccessfulTransaction(dedupeKey, () -> {
|
||||
executor.execute(runnable);
|
||||
|
|
|
@ -1483,6 +1483,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
|||
for (id in ids) {
|
||||
AppDependencies.databaseObserver.notifyRecipientChanged(id)
|
||||
}
|
||||
AppDependencies.databaseObserver.notifyConversationListListeners()
|
||||
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ object RxDatabaseObserver {
|
|||
|
||||
val conversationList: Flowable<Unit> by lazy { conversationListFlowable() }
|
||||
val notificationProfiles: Flowable<Unit> by lazy { notificationProfilesFlowable() }
|
||||
val chatFolders: Flowable<Unit> by lazy { chatFoldersFlowable() }
|
||||
|
||||
private fun conversationListFlowable(): Flowable<Unit> {
|
||||
return databaseFlowable { listener ->
|
||||
|
@ -36,6 +37,12 @@ object RxDatabaseObserver {
|
|||
) { _, _ -> Unit }
|
||||
}
|
||||
|
||||
private fun chatFoldersFlowable(): Flowable<Unit> {
|
||||
return databaseFlowable { listener ->
|
||||
AppDependencies.databaseObserver.registerChatFolderObserver(listener)
|
||||
}
|
||||
}
|
||||
|
||||
private fun databaseFlowable(registerObserver: (RxObserver) -> Unit): Flowable<Unit> {
|
||||
val flowable = Flowable.create(
|
||||
{
|
||||
|
|
|
@ -76,6 +76,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
|||
val nameCollisionTables: NameCollisionTables = NameCollisionTables(context, this)
|
||||
val inAppPaymentTable: InAppPaymentTable = InAppPaymentTable(context, this)
|
||||
val inAppPaymentSubscriberTable: InAppPaymentSubscriberTable = InAppPaymentSubscriberTable(context, this)
|
||||
val chatFoldersTable: ChatFolderTables = ChatFolderTables(context, this)
|
||||
|
||||
override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
|
||||
db.setForeignKeyConstraintsEnabled(true)
|
||||
|
@ -120,6 +121,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
|||
executeStatements(db, MessageSendLogTables.CREATE_TABLE)
|
||||
executeStatements(db, NotificationProfileDatabase.CREATE_TABLE)
|
||||
executeStatements(db, DistributionListTables.CREATE_TABLE)
|
||||
executeStatements(db, ChatFolderTables.CREATE_TABLE)
|
||||
|
||||
executeStatements(db, RecipientTable.CREATE_INDEXS)
|
||||
executeStatements(db, MessageTable.CREATE_INDEXS)
|
||||
|
@ -141,6 +143,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
|||
executeStatements(db, CallTable.CREATE_INDEXES)
|
||||
executeStatements(db, ReactionTable.CREATE_INDEXES)
|
||||
executeStatements(db, KyberPreKeyTable.CREATE_INDEXES)
|
||||
executeStatements(db, ChatFolderTables.CREATE_INDEXES)
|
||||
|
||||
executeStatements(db, SearchTable.CREATE_TRIGGERS)
|
||||
executeStatements(db, MessageSendLogTables.CREATE_TRIGGERS)
|
||||
|
@ -148,6 +151,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
|||
NameCollisionTables.createIndexes(db)
|
||||
|
||||
DistributionListTables.insertInitialDistributionListAtCreationTime(db)
|
||||
ChatFolderTables.insertInitialChatFoldersAtCreationTime(db)
|
||||
|
||||
if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) {
|
||||
val legacyHelper = ClassicOpenHelper(context)
|
||||
|
@ -558,5 +562,10 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
|||
@get:JvmName("inAppPaymentSubscribers")
|
||||
val inAppPaymentSubscribers: InAppPaymentSubscriberTable
|
||||
get() = instance!!.inAppPaymentSubscriberTable
|
||||
|
||||
@get:JvmStatic
|
||||
@get:JvmName("chatFolders")
|
||||
val chatFolders: ChatFolderTables
|
||||
get() = instance!!.chatFoldersTable
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import org.signal.core.util.exists
|
|||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.or
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.readToSingleInt
|
||||
import org.signal.core.util.readToSingleLong
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
|
@ -30,6 +31,7 @@ import org.signal.core.util.updateAll
|
|||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
|
||||
import org.thoughtcrime.securesms.database.MessageTable.MarkedMessageInfo
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments
|
||||
|
@ -629,6 +631,39 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
|||
return allCount + forcedUnreadCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of unread messages across all threads within a chat folder
|
||||
* Threads that are forced-unread count as 1.
|
||||
*/
|
||||
fun getUnreadCountByChatFolder(folder: ChatFolderRecord): Int {
|
||||
val chatFolderQuery = folder.toQuery()
|
||||
|
||||
val allCountQuery =
|
||||
"""
|
||||
SELECT SUM($UNREAD_COUNT)
|
||||
FROM $TABLE_NAME
|
||||
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
|
||||
WHERE
|
||||
$ARCHIVED = 0
|
||||
$chatFolderQuery
|
||||
"""
|
||||
val allCount = readableDatabase.rawQuery(allCountQuery, null).readToSingleInt(0)
|
||||
|
||||
val forcedUnreadCountQuery =
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM $TABLE_NAME
|
||||
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
|
||||
WHERE
|
||||
$ARCHIVED = 0 AND
|
||||
$READ = ${ThreadTable.ReadStatus.FORCED_UNREAD.serialize()}
|
||||
$chatFolderQuery
|
||||
"""
|
||||
val forcedUnreadCount = readableDatabase.rawQuery(forcedUnreadCountQuery, null).readToSingleInt(0)
|
||||
|
||||
return allCount + forcedUnreadCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of unread messages in a given thread.
|
||||
*/
|
||||
|
@ -915,12 +950,13 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
|||
return readableDatabase.rawQuery(query, arrayOf("1"))
|
||||
}
|
||||
|
||||
fun getUnarchivedConversationList(conversationFilter: ConversationFilter, pinned: Boolean, offset: Long, limit: Long): Cursor {
|
||||
fun getUnarchivedConversationList(conversationFilter: ConversationFilter, pinned: Boolean, offset: Long, limit: Long, chatFolder: ChatFolderRecord): Cursor {
|
||||
val folderQuery = chatFolder.toQuery()
|
||||
val filterQuery = conversationFilter.toQuery()
|
||||
val where = if (pinned) {
|
||||
"$ARCHIVED = 0 AND $PINNED != 0 $filterQuery"
|
||||
"$ARCHIVED = 0 AND $PINNED != 0 $filterQuery $folderQuery"
|
||||
} else {
|
||||
"$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0 $filterQuery"
|
||||
"$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0 $filterQuery $folderQuery"
|
||||
}
|
||||
|
||||
val query = if (pinned) {
|
||||
|
@ -948,36 +984,61 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
|||
}
|
||||
}
|
||||
|
||||
fun getPinnedConversationListCount(conversationFilter: ConversationFilter): Int {
|
||||
fun getPinnedConversationListCount(conversationFilter: ConversationFilter, chatFolder: ChatFolderRecord? = null): Int {
|
||||
val filterQuery = conversationFilter.toQuery()
|
||||
return readableDatabase
|
||||
.select("COUNT(*)")
|
||||
.from(TABLE_NAME)
|
||||
.where("$ACTIVE = 1 AND $ARCHIVED = 0 AND $PINNED != 0 $filterQuery")
|
||||
.run()
|
||||
.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getInt(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
return if (chatFolder == null || chatFolder.folderType == ChatFolderRecord.FolderType.ALL) {
|
||||
readableDatabase
|
||||
.select("COUNT(*)")
|
||||
.from(TABLE_NAME)
|
||||
.where("$ACTIVE = 1 AND $ARCHIVED = 0 AND $PINNED != 0 $filterQuery")
|
||||
.run()
|
||||
.readToSingleInt(0)
|
||||
} else {
|
||||
val folderQuery = chatFolder.toQuery()
|
||||
val query =
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM $TABLE_NAME
|
||||
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
|
||||
WHERE
|
||||
$ACTIVE = 1 AND
|
||||
$ARCHIVED = 0 AND
|
||||
$PINNED != 0
|
||||
$filterQuery
|
||||
$folderQuery
|
||||
"""
|
||||
readableDatabase.rawQuery(query, null).readToSingleInt(0)
|
||||
}
|
||||
}
|
||||
|
||||
fun getUnarchivedConversationListCount(conversationFilter: ConversationFilter): Int {
|
||||
fun getUnarchivedConversationListCount(conversationFilter: ConversationFilter, chatFolder: ChatFolderRecord? = null): Int {
|
||||
val filterQuery = conversationFilter.toQuery()
|
||||
return readableDatabase
|
||||
.select("COUNT(*)")
|
||||
.from(TABLE_NAME)
|
||||
.where("$ACTIVE = 1 AND $ARCHIVED = 0 AND ($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0) $filterQuery")
|
||||
.run()
|
||||
.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getInt(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
return if (chatFolder == null || chatFolder.folderType == ChatFolderRecord.FolderType.ALL) {
|
||||
readableDatabase
|
||||
.select("COUNT(*)")
|
||||
.from(TABLE_NAME)
|
||||
.where("$ACTIVE = 1 AND $ARCHIVED = 0 AND ($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0) $filterQuery")
|
||||
.run()
|
||||
.readToSingleInt(0)
|
||||
} else {
|
||||
val folderQuery = chatFolder.toQuery()
|
||||
|
||||
val query =
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM $TABLE_NAME
|
||||
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
|
||||
WHERE
|
||||
$ACTIVE = 1 AND
|
||||
$ARCHIVED = 0 AND
|
||||
($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0)
|
||||
$filterQuery
|
||||
$folderQuery
|
||||
"""
|
||||
readableDatabase.rawQuery(query, null).readToSingleInt(0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1979,6 +2040,42 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
|||
return Reader(cursor)
|
||||
}
|
||||
|
||||
private fun ChatFolderRecord.toQuery(): String {
|
||||
if (this.id == -1L || this.folderType == ChatFolderRecord.FolderType.ALL) {
|
||||
return ""
|
||||
}
|
||||
|
||||
val includedChatsQuery: MutableList<String> = mutableListOf()
|
||||
includedChatsQuery.add("${TABLE_NAME}.$ID IN (${this.includedChats.joinToString(",")})")
|
||||
|
||||
if (this.showIndividualChats) {
|
||||
includedChatsQuery.add("${RecipientTable.TABLE_NAME}.${RecipientTable.TYPE} = ${RecipientTable.RecipientType.INDIVIDUAL.id}")
|
||||
}
|
||||
|
||||
if (this.showGroupChats) {
|
||||
includedChatsQuery.add("${RecipientTable.TABLE_NAME}.${RecipientTable.TYPE} = ${RecipientTable.RecipientType.GV2.id}")
|
||||
}
|
||||
|
||||
val includedQuery = includedChatsQuery.joinToString(" OR ") { "($it)" }
|
||||
|
||||
val fullQuery: MutableList<String> = mutableListOf()
|
||||
fullQuery.add(includedQuery)
|
||||
|
||||
if (this.excludedChats.isNotEmpty()) {
|
||||
fullQuery.add("${TABLE_NAME}.$ID NOT IN (${this.excludedChats.joinToString(",")})")
|
||||
}
|
||||
|
||||
if (this.showUnread) {
|
||||
fullQuery.add("$UNREAD_COUNT > 0 OR $READ == ${ReadStatus.FORCED_UNREAD.serialize()}")
|
||||
}
|
||||
|
||||
if (!this.showMutedChats) {
|
||||
fullQuery.add("${RecipientTable.TABLE_NAME}.${RecipientTable.MUTE_UNTIL} = 0")
|
||||
}
|
||||
|
||||
return "AND ${fullQuery.joinToString(" AND ") { "($it)" }}"
|
||||
}
|
||||
|
||||
private fun ConversationFilter.toQuery(): String {
|
||||
return when (this) {
|
||||
ConversationFilter.OFF -> ""
|
||||
|
|
|
@ -109,6 +109,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V247_ClearUploadTim
|
|||
import org.thoughtcrime.securesms.database.helpers.migration.V250_ClearUploadTimestampV2
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V251_ArchiveTransferStateIndex
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V252_AttachmentOffloadRestoredAtColumn
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V253_CreateChatFolderTables
|
||||
|
||||
/**
|
||||
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
|
||||
|
@ -220,10 +221,11 @@ object SignalDatabaseMigrations {
|
|||
// 248 and 249 were originally in 7.18.0, but are now skipped because we needed to hotfix 7.17.6 after 7.18.0 was already released.
|
||||
250 to V250_ClearUploadTimestampV2,
|
||||
251 to V251_ArchiveTransferStateIndex,
|
||||
252 to V252_AttachmentOffloadRestoredAtColumn
|
||||
252 to V252_AttachmentOffloadRestoredAtColumn,
|
||||
253 to V253_CreateChatFolderTables
|
||||
)
|
||||
|
||||
const val DATABASE_VERSION = 252
|
||||
const val DATABASE_VERSION = 253
|
||||
|
||||
@JvmStatic
|
||||
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import org.thoughtcrime.securesms.database.ChatFolderTables
|
||||
|
||||
/**
|
||||
* Adds the tables for managing chat folders
|
||||
*/
|
||||
@Suppress("ClassName")
|
||||
object V253_CreateChatFolderTables : SignalDatabaseMigration {
|
||||
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE chat_folder (
|
||||
_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT DEFAULT NULL,
|
||||
position INTEGER DEFAULT 0,
|
||||
show_unread INTEGER DEFAULT 0,
|
||||
show_muted INTEGER DEFAULT 0,
|
||||
show_individual INTEGER DEFAULT 0,
|
||||
show_groups INTEGER DEFAULT 0,
|
||||
is_muted INTEGER DEFAULT 0,
|
||||
folder_type INTEGER DEFAULT 4
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE chat_folder_membership (
|
||||
_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_folder_id INTEGER NOT NULL REFERENCES chat_folder (_id) ON DELETE CASCADE,
|
||||
thread_id INTEGER NOT NULL REFERENCES thread (_id) ON DELETE CASCADE,
|
||||
membership_type INTEGER DEFAULT 1
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execSQL("CREATE INDEX chat_folder_position_index ON chat_folder (position)")
|
||||
db.execSQL("CREATE INDEX chat_folder_membership_chat_folder_id_index ON chat_folder_membership (chat_folder_id)")
|
||||
db.execSQL("CREATE INDEX chat_folder_membership_thread_id_index ON chat_folder_membership (thread_id)")
|
||||
db.execSQL("CREATE INDEX chat_folder_membership_membership_type_index ON chat_folder_membership (membership_type)")
|
||||
|
||||
ChatFolderTables.insertInitialChatFoldersAtCreationTime(db)
|
||||
}
|
||||
}
|
|
@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.ContactSelectionActivity;
|
|||
import org.thoughtcrime.securesms.ContactSelectionListFragment;
|
||||
import org.thoughtcrime.securesms.PushContactSelectionActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
@ -30,7 +31,6 @@ import org.thoughtcrime.securesms.util.Util;
|
|||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
@ -97,7 +97,7 @@ public class AddMembersActivity extends PushContactSelectionActivity implements
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
if (getGroupId().isV1() && recipientId.isPresent() && !Recipient.resolved(recipientId.get()).getHasE164()) {
|
||||
Toast.makeText(this, R.string.AddMembersActivity__this_person_cant_be_added_to_legacy_groups, Toast.LENGTH_SHORT).show();
|
||||
callback.accept(false);
|
||||
|
@ -139,7 +139,7 @@ public class AddMembersActivity extends PushContactSelectionActivity implements
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {
|
||||
if (contactsFragment.hasQueryFilter()) {
|
||||
getContactFilterView().clear();
|
||||
}
|
||||
|
|
|
@ -8,21 +8,18 @@ import android.view.View;
|
|||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.thoughtcrime.securesms.ContactSelectionActivity;
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupViewModel.Event;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientRepository;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
|
@ -116,7 +113,7 @@ public final class AddToGroupsActivity extends ContactSelectionActivity {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
if (contactsFragment.isMulti()) {
|
||||
throw new UnsupportedOperationException("Not yet built to handle multi-select.");
|
||||
// if (contactsFragment.hasQueryFilter()) {
|
||||
|
@ -136,7 +133,7 @@ public final class AddToGroupsActivity extends ContactSelectionActivity {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {
|
||||
if (contactsFragment.hasQueryFilter()) {
|
||||
getContactFilterView().clear();
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.ContactSelectionActivity;
|
|||
import org.thoughtcrime.securesms.ContactSelectionListFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.details.AddGroupDetailsActivity;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
|
@ -109,7 +110,7 @@ public class CreateGroupActivity extends ContactSelectionActivity implements Con
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
if (contactsFragment.hasQueryFilter()) {
|
||||
getContactFilterView().clear();
|
||||
}
|
||||
|
@ -145,7 +146,7 @@ public class CreateGroupActivity extends ContactSelectionActivity implements Con
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {
|
||||
if (contactsFragment.hasQueryFilter()) {
|
||||
getContactFilterView().clear();
|
||||
}
|
||||
|
|
|
@ -77,7 +77,7 @@ public class UserNotificationMigrationJob extends MigrationJob {
|
|||
|
||||
ThreadTable threadTable = SignalDatabase.threads();
|
||||
|
||||
int threadCount = threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF) +
|
||||
int threadCount = threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF, null) +
|
||||
threadTable.getArchivedConversationListCount(ConversationFilter.OFF);
|
||||
|
||||
if (threadCount >= 3) {
|
||||
|
|
|
@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.LoggingFragment;
|
|||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.payments.CanNotSendPaymentDialog;
|
||||
|
@ -71,7 +72,7 @@ public class PaymentRecipientSelectionFragment extends LoggingFragment implement
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
if (recipientId.isPresent()) {
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
|
||||
() -> Recipient.resolved(recipientId.get()),
|
||||
|
@ -82,7 +83,7 @@ public class PaymentRecipientSelectionFragment extends LoggingFragment implement
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number) {}
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Optional<ChatType> chatType) {}
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged() {
|
||||
|
|
|
@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.ContactSelectionListFragment
|
|||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
|
||||
import org.thoughtcrime.securesms.contacts.HeaderAction
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
|
@ -117,7 +118,7 @@ abstract class BaseStoryRecipientSelectionFragment : Fragment(R.layout.stories_b
|
|||
}
|
||||
}
|
||||
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>, callback: Consumer<Boolean>) {
|
||||
viewModel.addRecipient(recipientId.get())
|
||||
|
||||
if (searchField.text.isNotBlank()) {
|
||||
|
@ -127,7 +128,7 @@ abstract class BaseStoryRecipientSelectionFragment : Fragment(R.layout.stories_b
|
|||
callback.accept(true)
|
||||
}
|
||||
|
||||
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?) {
|
||||
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>) {
|
||||
viewModel.removeRecipient(recipientId.get())
|
||||
}
|
||||
|
||||
|
|
9
app/src/main/res/drawable/ic_chat_folder_24.xml
Normal file
9
app/src/main/res/drawable/ic_chat_folder_24.xml
Normal file
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M4.063,20.397C2.046,20.397 1,19.361 1,17.362V5.979C1,4.018 2.027,3 3.773,3H6.416C7.387,3 7.873,3.177 8.527,3.719L9.068,4.149C9.582,4.569 9.974,4.728 10.646,4.728H19.331C21.348,4.728 22.394,5.764 22.394,7.763V17.362C22.394,19.361 21.358,20.397 19.574,20.397H4.063ZM2.83,6.138V8.463H20.564V7.921C20.564,7.006 20.088,6.558 19.229,6.558H10.161C9.19,6.558 8.685,6.38 8.041,5.848L7.5,5.409C6.977,4.98 6.594,4.83 5.931,4.83H4.138C3.297,4.83 2.83,5.279 2.83,6.138ZM4.175,18.567H19.229C20.088,18.567 20.564,18.119 20.564,17.213V10.078H2.83V17.213C2.83,18.119 3.307,18.567 4.175,18.567Z"
|
||||
android:fillColor="#151D2C"/>
|
||||
</vector>
|
12
app/src/main/res/drawable/ic_drag_handle.xml
Normal file
12
app/src/main/res/drawable/ic_drag_handle.xml
Normal file
|
@ -0,0 +1,12 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M5,9.125C4.517,9.125 4.125,9.517 4.125,10C4.125,10.483 4.517,10.875 5,10.875H19C19.483,10.875 19.875,10.483 19.875,10C19.875,9.517 19.483,9.125 19,9.125H5Z"
|
||||
android:fillColor="#545863"/>
|
||||
<path
|
||||
android:pathData="M5,13.125C4.517,13.125 4.125,13.517 4.125,14C4.125,14.483 4.517,14.875 5,14.875H19C19.483,14.875 19.875,14.483 19.875,14C19.875,13.517 19.483,13.125 19,13.125H5Z"
|
||||
android:fillColor="#545863"/>
|
||||
</vector>
|
11
app/src/main/res/drawable/ic_pin_20.xml
Normal file
11
app/src/main/res/drawable/ic_pin_20.xml
Normal file
|
@ -0,0 +1,11 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path
|
||||
android:pathData="M13.901,1.482C13.504,1.086 12.961,0.88 12.429,0.919C11.878,0.96 11.343,1.27 11.075,1.864C10.644,2.821 10.53,3.888 10.721,4.916L8.518,6.615C6.864,6.038 5.088,6.079 3.618,6.846C3.059,7.139 2.775,7.67 2.746,8.209C2.718,8.732 2.924,9.26 3.312,9.647L6.207,12.543L2.153,16.597C1.97,16.78 1.832,17.003 1.751,17.248L1.382,18.355C1.327,18.517 1.482,18.672 1.645,18.618L2.752,18.249C2.997,18.167 3.22,18.03 3.403,17.847L7.457,13.793L10.353,16.689C10.74,17.076 11.269,17.282 11.791,17.254C12.33,17.225 12.862,16.942 13.154,16.382C13.921,14.912 13.963,13.136 13.385,11.483L15.085,9.279C16.112,9.47 17.179,9.356 18.137,8.925C18.73,8.658 19.04,8.122 19.081,7.571C19.121,7.039 18.915,6.496 18.518,6.1L13.901,1.482ZM12.405,2.462C12.423,2.422 12.442,2.406 12.454,2.398C12.469,2.388 12.495,2.377 12.537,2.374C12.628,2.367 12.76,2.404 12.869,2.513L17.487,7.131C17.596,7.24 17.633,7.372 17.627,7.463C17.624,7.505 17.612,7.532 17.602,7.546C17.594,7.559 17.578,7.577 17.538,7.595C16.755,7.948 15.851,8.006 14.994,7.762L14.519,7.626L11.723,11.25L11.885,11.641C12.482,13.077 12.457,14.565 11.861,15.707C11.841,15.746 11.82,15.763 11.804,15.773C11.786,15.784 11.757,15.796 11.713,15.798C11.62,15.803 11.491,15.764 11.384,15.658L4.343,8.616C4.236,8.51 4.197,8.38 4.202,8.287C4.205,8.244 4.216,8.215 4.227,8.196C4.237,8.18 4.254,8.16 4.293,8.139C5.435,7.543 6.923,7.518 8.36,8.115L8.75,8.277L12.375,5.482L12.239,5.006C11.994,4.149 12.052,3.246 12.405,2.462Z"
|
||||
android:fillColor="#3C3C43"
|
||||
android:fillAlpha="0.7"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
37
app/src/main/res/layout/chat_folder_item.xml
Normal file
37
app/src/main/res/layout/chat_folder_item.xml
Normal file
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="36dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:layout_marginVertical="8dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:backgroundTint="@color/signal_colorSurfaceVariant"
|
||||
android:background="@drawable/rounded_rectangle_surface_variant" >
|
||||
|
||||
<TextView
|
||||
android:id="@+id/name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/Signal.Text.LabelLarge"
|
||||
tools:text="All chats" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/unread_count"
|
||||
style="@style/Signal.Text.LabelSmall"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:background="@drawable/unread_count_background_new"
|
||||
android:includeFontPadding="false"
|
||||
android:minWidth="16dp"
|
||||
android:gravity="center"
|
||||
android:paddingHorizontal="4dp"
|
||||
android:paddingVertical="2dp"
|
||||
android:textColor="@color/signal_colorBackground"
|
||||
tools:text="4" />
|
||||
|
||||
</LinearLayout>
|
54
app/src/main/res/layout/choose_chats_fragment.xml
Normal file
54
app/src/main/res/layout/choose_chats_fragment.xml
Normal file
|
@ -0,0 +1,54 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:viewBindingIgnore="true"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="?actionBarSize"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:navigationIcon="@drawable/ic_arrow_left_24" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.ContactFilterView
|
||||
android:id="@+id/contact_filter_edit_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginVertical="10dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
||||
app:cfv_autoFocus="false"
|
||||
app:cfv_background="@drawable/rounded_rectangle_surface_variant" />
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/contact_selection_list"
|
||||
android:name="org.thoughtcrime.securesms.ContactSelectionListFragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/contact_filter_edit_text"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/done_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/Signal.Widget.Button.Medium.Tonal"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:text="@string/ChooseChatsFragment__done" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
56
app/src/main/res/layout/contact_search_chat_type_item.xml
Normal file
56
app/src/main/res/layout/contact_search_chat_type_item.xml
Normal file
|
@ -0,0 +1,56 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
tools:viewBindingIgnore="true"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/conversation_item_background"
|
||||
android:focusable="true"
|
||||
android:minHeight="@dimen/contact_selection_item_height"
|
||||
android:paddingStart="@dimen/dsl_settings_gutter"
|
||||
android:paddingEnd="@dimen/dsl_settings_gutter">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/image"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/color_indicator_circle"
|
||||
android:backgroundTint="@color/signal_colorSurfaceVariant"
|
||||
app:tint="@color/signal_colorOnSurface"
|
||||
android:scaleType="center"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:checkMark="?android:attr/listChoiceIndicatorMultiple"
|
||||
android:drawablePadding="4dp"
|
||||
android:singleLine="true"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body1"
|
||||
android:textColor="@color/signal_text_primary"
|
||||
app:layout_constraintEnd_toStartOf="@id/check_box"
|
||||
app:layout_constraintStart_toEndOf="@id/image"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
tools:text="Unreads" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatCheckBox
|
||||
android:id="@+id/check_box"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:background="?contactCheckboxBackground"
|
||||
android:button="@null"
|
||||
android:clickable="false"
|
||||
android:focusable="false"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -32,11 +32,21 @@
|
|||
app:barrierDirection="bottom"
|
||||
app:constraint_referenced_ids="voice_note_player,banner_compose_view" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/chat_folder_list"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="52dp"
|
||||
android:clipToPadding="false"
|
||||
android:paddingStart="16dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/banner_barrier" />
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/banner_barrier">
|
||||
app:layout_constraintTop_toBottomOf="@id/chat_folder_list">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/recycler_coordinator_app_bar"
|
||||
|
|
|
@ -194,6 +194,18 @@
|
|||
android:textColor="@color/signal_colorSurface"
|
||||
tools:visibility="gone" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:id="@+id/conversation_list_item_pinned"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:minWidth="18dp"
|
||||
android:minHeight="18dp"
|
||||
android:visibility="gone"
|
||||
app:srcCompat="@drawable/ic_pin_20"
|
||||
app:tint="@color/signal_colorOutline"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
|
|
|
@ -358,6 +358,20 @@
|
|||
app:exitAnim="@anim/fragment_open_exit"
|
||||
app:popEnterAnim="@anim/fragment_close_enter"
|
||||
app:popExitAnim="@anim/fragment_close_exit" />
|
||||
<action
|
||||
android:id="@+id/action_chatsSettingsFragment_to_chatFoldersFragment"
|
||||
app:destination="@id/chatFoldersFragment"
|
||||
app:enterAnim="@anim/fragment_open_enter"
|
||||
app:exitAnim="@anim/fragment_open_exit"
|
||||
app:popEnterAnim="@anim/fragment_close_enter"
|
||||
app:popExitAnim="@anim/fragment_close_exit" />
|
||||
<action
|
||||
android:id="@+id/action_chatsSettingsFragment_to_remoteBackupsSettingsFragment"
|
||||
app:destination="@id/remoteBackupsSettingsFragment"
|
||||
app:enterAnim="@anim/fragment_open_enter"
|
||||
app:exitAnim="@anim/fragment_open_exit"
|
||||
app:popEnterAnim="@anim/fragment_close_enter"
|
||||
app:popExitAnim="@anim/fragment_close_exit" />
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
|
@ -372,6 +386,41 @@
|
|||
android:label="edit_reactions_fragment"
|
||||
tools:layout="@layout/edit_reactions_fragment" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/chatFoldersFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFoldersFragment"
|
||||
android:label="chat_folders_fragment">
|
||||
<action
|
||||
android:id="@+id/action_chatFoldersFragment_to_createFoldersFragment"
|
||||
app:destination="@id/createFoldersFragment"
|
||||
app:enterAnim="@anim/fragment_open_enter"
|
||||
app:exitAnim="@anim/fragment_open_exit"
|
||||
app:popEnterAnim="@anim/fragment_close_enter"
|
||||
app:popExitAnim="@anim/fragment_close_exit" />
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/createFoldersFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.chats.folders.CreateFoldersFragment"
|
||||
android:label="create_folders_fragment">
|
||||
<action
|
||||
android:id="@+id/action_createFoldersFragment_to_chooseChatsFragment"
|
||||
app:destination="@id/chooseChatsFragment"
|
||||
app:enterAnim="@anim/fragment_open_enter"
|
||||
app:exitAnim="@anim/fragment_open_exit"
|
||||
app:popEnterAnim="@anim/fragment_close_enter"
|
||||
app:popExitAnim="@anim/fragment_close_exit">
|
||||
<argument
|
||||
android:name="include_chats"
|
||||
app:argType="boolean" />
|
||||
</action>
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/chooseChatsFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.chats.folders.ChooseChatsFragment"
|
||||
android:label="choose_chats_fragment" />
|
||||
|
||||
<!-- endregion -->
|
||||
|
||||
<!-- Notifications -->
|
||||
|
|
|
@ -245,4 +245,6 @@
|
|||
<dimen name="CallScreenParticipantItem__margin_end">4dp</dimen>
|
||||
<dimen name="CallScreenParticipantItem__margin_bottom">0dp</dimen>
|
||||
|
||||
<dimen name="chat_folder_row_height">64dp</dimen>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -369,6 +369,8 @@
|
|||
<string name="ContactsCursorLoader__chats">Chats</string>
|
||||
<!-- Header for conversation search section labeled "Messages" -->
|
||||
<string name="ContactsCursorLoader__messages">Messages</string>
|
||||
<!-- Header for conversation search section labeled "Chat types" -->
|
||||
<string name="ContactsCursorLoader__chat_types">Chat types</string>
|
||||
|
||||
<!-- ContactsDatabase -->
|
||||
<string name="ContactsDatabase_message_s">Message %s</string>
|
||||
|
@ -5035,6 +5037,107 @@
|
|||
<!-- ChatsSettingsFragment -->
|
||||
<string name="ChatsSettingsFragment__keyboard">Keyboard</string>
|
||||
<string name="ChatsSettingsFragment__send_with_enter">Send with enter</string>
|
||||
<!-- Heading within chats settings for chat folders -->
|
||||
<string name="ChatsSettingsFragment__chat_folders">Chat folders</string>
|
||||
<!-- Option within settings to add a new chat folder -->
|
||||
<string name="ChatsSettingsFragment__add_chat_folder">Add a chat folder</string>
|
||||
|
||||
<!-- ChatFoldersFragment -->
|
||||
<!-- Description of what chat folders are -->
|
||||
<string name="ChatFoldersFragment__organize_your_chats">Organize your chats into folders and quickly switch between them on your chat list.</string>
|
||||
<!-- Header for section showing current chat folders -->
|
||||
<string name="ChatFoldersFragment__folders">Folders</string>
|
||||
<!-- Text next to button that allows users to create a new chat folder -->
|
||||
<string name="ChatFoldersFragment__create_a_folder">Create a folder</string>
|
||||
<!-- Name of a chat folder that represents the folder containing all chats -->
|
||||
<string name="ChatFoldersFragment__all_chats">All chats</string>
|
||||
<!-- Header for section showing suggested chat folders for users to have -->
|
||||
<string name="ChatFoldersFragment__suggested_folders">Suggested folders</string>
|
||||
<!-- Name of a chat folder that contains all chats that currently have unread messages -->
|
||||
<string name="ChatFoldersFragment__unreads">Unreads</string>
|
||||
<!-- Description of the suggested unread chat folder -->
|
||||
<string name="ChatFoldersFragment__unread_messages">Unread messages from all chats</string>
|
||||
<!-- Name of a chat folder that contains all 1:1 (individual) chats -->
|
||||
<string name="ChatFoldersFragment__one_on_one_chats">1:1 chats</string>
|
||||
<!-- Description of the suggested 1:1 chat folder -->
|
||||
<string name="ChatFoldersFragment__only_direct_messages">Only messages from direct chats</string>
|
||||
<!-- Name of a chat folder that contains all groups chats -->
|
||||
<string name="ChatFoldersFragment__groups">Groups</string>
|
||||
<!-- Description of the suggested group chat folder -->
|
||||
<string name="ChatFoldersFragment__only_group_messages">Only message from group chats</string>
|
||||
<!-- Button text to add a suggested folder to a user's chat folders -->
|
||||
<string name="ChatFoldersFragment__add">Add</string>
|
||||
<!-- Toast shown when a folder gets added where %s is the name of the folder -->
|
||||
<string name="ChatFoldersFragment__folder_added">%1$s folder added.</string>
|
||||
<!-- Text describing the number of chat types in a folder -->
|
||||
<plurals name="ChatFoldersFragment__d_chat_types">
|
||||
<item quantity="one">%1$d chat type</item>
|
||||
<item quantity="other">%1$d chat types</item>
|
||||
</plurals>
|
||||
<!-- Text describing the number of chat in a folder -->
|
||||
<plurals name="ChatFoldersFragment__d_chats">
|
||||
<item quantity="one">%1$d chat</item>
|
||||
<item quantity="other">%1$d chats</item>
|
||||
</plurals>
|
||||
<!-- Text describing the number of chats that are excluded in a folder -->
|
||||
<plurals name="ChatFoldersFragment__d_chats_excluded">
|
||||
<item quantity="one">%1$d chat excluded</item>
|
||||
<item quantity="other">%1$d chats excluded</item>
|
||||
</plurals>
|
||||
|
||||
<!-- CreateFoldersFragment -->
|
||||
<!-- Title of the screen when creating a folder, displayed in the toolbar -->
|
||||
<string name="CreateFoldersFragment__create_a_folder">Create a folder</string>
|
||||
<!-- Hint text shown in text field to enter a name for the folder -->
|
||||
<string name="CreateFoldersFragment__folder_name">Folder name (required)</string>
|
||||
<!-- Section title representing what chats are included in the folder -->
|
||||
<string name="CreateFoldersFragment__included_chats">Included chats</string>
|
||||
<!-- Text next to button that allows users to add chats to the folder -->
|
||||
<string name="CreateFoldersFragment__add_chats">Add chats</string>
|
||||
<!-- Description explaining the purpose of the included chats section -->
|
||||
<string name="CreateFoldersFragment__choose_chats_you_want">Choose chats that you want to appear in this folder.</string>
|
||||
<!-- Section title representing what chats are excluded from the folder -->
|
||||
<string name="CreateFoldersFragment__exceptions">Exceptions</string>
|
||||
<!-- Text next to button that allows users to exclude chats from the folder -->
|
||||
<string name="CreateFoldersFragment__exclude_chats">Exclude chats</string>
|
||||
<!-- Description explaining the purpose of the excluded chats section -->
|
||||
<string name="CreateFoldersFragment__choose_chats_you_do_not_want">Choose chats that you do not want to appear in this folder.</string>
|
||||
<!-- Toggle switch for folder to show unread chats -->
|
||||
<string name="CreateFoldersFragment__only_show_unread_chats">Only show unread chats</string>
|
||||
<!-- Explanation of unread toggle option -->
|
||||
<string name="CreateFoldersFragment__when_enabled_only_chats">When enabled, only chats with unread messages will be shown in this folder.</string>
|
||||
<!-- Toggle switch to display muted chats in chat folders -->
|
||||
<string name="CreateFoldersFragment__include_muted_chats">Include muted chats</string>
|
||||
<!-- Button text to create a folder -->
|
||||
<string name="CreateFoldersFragment__create">Create</string>
|
||||
<!-- Alert dialog title to create a chat folder -->
|
||||
<string name="CreateFoldersFragment__create_folder_title">Create folder?</string>
|
||||
<!-- Alert dialog description when creating a chat folder where %s is the name of the folder -->
|
||||
<string name="CreateFoldersFragment__do_you_want_to_create">Do you want to create the chat folder \"%1$s\"?</string>
|
||||
<!-- Alert dialog confirmation button text to save the changes -->
|
||||
<string name="CreateFoldersFragment__create_folder">Create folder</string>
|
||||
<!-- Section title shown when editing an existing folder -->
|
||||
<string name="CreateFoldersFragment__edit_folder">Edit folder</string>
|
||||
<!-- Button text to save the changes to a folder after it has been edited -->
|
||||
<string name="CreateFoldersFragment__save">Save</string>
|
||||
<!-- Alert dialog title to save changes made to the current folder -->
|
||||
<string name="CreateFoldersFragment__save_changes_title">Save changes?</string>
|
||||
<!-- Alert dialog description when saving a folder that has had changes made to it -->
|
||||
<string name="CreateFoldersFragment__do_you_want_to_save">Do you want to save the changes you\'ve made to this chat folder?</string>
|
||||
<!-- Alert dialog confirmation button text to save the changes -->
|
||||
<string name="CreateFoldersFragment__save_changes">Save changes</string>
|
||||
<!-- Alert dialog button to dismiss the dialog and discard any changes -->
|
||||
<string name="CreateFoldersFragment__discard">Discard</string>
|
||||
<!-- Text that when pressed will delete the current folder -->
|
||||
<string name="CreateFoldersFragment__delete_folder">Delete folder</string>
|
||||
<!-- Alert dialog title to delete the current folder -->
|
||||
<string name="CreateFoldersFragment__delete_this_chat_folder">Delete this chat folder?</string>
|
||||
|
||||
<!-- ChooseChatsFragment -->
|
||||
<!-- Section title representing chat types that can be added to the folder -->
|
||||
<string name="ChooseChatsFragment__chat_types">Chat types</string>
|
||||
<!-- Done button label to save selected chats to folder -->
|
||||
<string name="ChooseChatsFragment__done">Done</string>
|
||||
|
||||
<!-- NotificationsSettingsFragment -->
|
||||
<string name="NotificationsSettingsFragment__messages">Messages</string>
|
||||
|
|
|
@ -13,6 +13,7 @@ import org.mockito.junit.MockitoJUnit;
|
|||
import org.mockito.junit.MockitoRule;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationReader;
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver;
|
||||
|
@ -20,6 +21,8 @@ import org.thoughtcrime.securesms.database.SignalDatabase;
|
|||
import org.thoughtcrime.securesms.database.ThreadTable;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
@ -45,6 +48,8 @@ public class UnarchivedConversationListDataSourceTest {
|
|||
|
||||
private ConversationListDataSource.UnarchivedConversationListDataSource testSubject;
|
||||
|
||||
private ChatFolderRecord allChatsFolder;
|
||||
|
||||
private ThreadTable threadTable;
|
||||
|
||||
@Before
|
||||
|
@ -54,9 +59,11 @@ public class UnarchivedConversationListDataSourceTest {
|
|||
when(SignalDatabase.threads()).thenReturn(threadTable);
|
||||
when(AppDependencies.getDatabaseObserver()).thenReturn(mock(DatabaseObserver.class));
|
||||
|
||||
testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(ConversationFilter.OFF, false);
|
||||
allChatsFolder = setupAllChatsFolder();
|
||||
testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(allChatsFolder, ConversationFilter.OFF, false);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void givenNoConversations_whenIGetTotalCount_thenIExpectZero() {
|
||||
// WHEN
|
||||
|
@ -64,9 +71,6 @@ public class UnarchivedConversationListDataSourceTest {
|
|||
|
||||
// THEN
|
||||
assertEquals(0, result);
|
||||
assertEquals(0, testSubject.getHeaderOffset());
|
||||
assertFalse(testSubject.hasPinnedHeader());
|
||||
assertFalse(testSubject.hasUnpinnedHeader());
|
||||
assertFalse(testSubject.hasConversationFilterFooter());
|
||||
assertFalse(testSubject.hasArchivedFooter());
|
||||
}
|
||||
|
@ -81,36 +85,15 @@ public class UnarchivedConversationListDataSourceTest {
|
|||
|
||||
// THEN
|
||||
assertEquals(1, result);
|
||||
assertEquals(0, testSubject.getHeaderOffset());
|
||||
assertFalse(testSubject.hasPinnedHeader());
|
||||
assertFalse(testSubject.hasUnpinnedHeader());
|
||||
assertFalse(testSubject.hasConversationFilterFooter());
|
||||
assertTrue(testSubject.hasArchivedFooter());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenSinglePinnedAndArchivedConversations_whenIGetTotalCount_thenIExpectThree() {
|
||||
public void givenSinglePinnedAndArchivedConversations_whenIGetTotalCount_thenIExpectTwo() {
|
||||
// GIVEN
|
||||
when(threadTable.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
|
||||
when(threadTable.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12);
|
||||
|
||||
// WHEN
|
||||
int result = testSubject.getTotalCount();
|
||||
|
||||
// THEN
|
||||
assertEquals(3, result);
|
||||
assertEquals(1, testSubject.getHeaderOffset());
|
||||
assertTrue(testSubject.hasPinnedHeader());
|
||||
assertFalse(testSubject.hasUnpinnedHeader());
|
||||
assertFalse(testSubject.hasConversationFilterFooter());
|
||||
assertTrue(testSubject.hasArchivedFooter());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenSingleUnpinnedAndArchivedConversations_whenIGetTotalCount_thenIExpectTwo() {
|
||||
// GIVEN
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
|
||||
when(threadTable.getPinnedConversationListCount(ConversationFilter.OFF, allChatsFolder)).thenReturn(1);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF, allChatsFolder)).thenReturn(1);
|
||||
when(threadTable.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12);
|
||||
|
||||
// WHEN
|
||||
|
@ -118,27 +101,36 @@ public class UnarchivedConversationListDataSourceTest {
|
|||
|
||||
// THEN
|
||||
assertEquals(2, result);
|
||||
assertEquals(0, testSubject.getHeaderOffset());
|
||||
assertFalse(testSubject.hasPinnedHeader());
|
||||
assertFalse(testSubject.hasUnpinnedHeader());
|
||||
assertFalse(testSubject.hasConversationFilterFooter());
|
||||
assertTrue(testSubject.hasArchivedFooter());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenSinglePinnedAndSingleUnpinned_whenIGetTotalCount_thenIExpectFour() {
|
||||
public void givenSingleUnpinnedAndArchivedConversations_whenIGetTotalCount_thenIExpectTwo() {
|
||||
// GIVEN
|
||||
when(threadTable.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(2);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF, allChatsFolder)).thenReturn(1);
|
||||
when(threadTable.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12);
|
||||
|
||||
// WHEN
|
||||
int result = testSubject.getTotalCount();
|
||||
|
||||
// THEN
|
||||
assertEquals(4, result);
|
||||
assertEquals(2, testSubject.getHeaderOffset());
|
||||
assertTrue(testSubject.hasPinnedHeader());
|
||||
assertTrue(testSubject.hasUnpinnedHeader());
|
||||
assertEquals(2, result);
|
||||
assertFalse(testSubject.hasConversationFilterFooter());
|
||||
assertTrue(testSubject.hasArchivedFooter());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenSinglePinnedAndSingleUnpinned_whenIGetTotalCount_thenIExpectTwo() {
|
||||
// GIVEN
|
||||
when(threadTable.getPinnedConversationListCount(ConversationFilter.OFF, allChatsFolder)).thenReturn(1);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF, allChatsFolder)).thenReturn(2);
|
||||
|
||||
// WHEN
|
||||
int result = testSubject.getTotalCount();
|
||||
|
||||
// THEN
|
||||
assertEquals(2, result);
|
||||
assertFalse(testSubject.hasConversationFilterFooter());
|
||||
assertFalse(testSubject.hasArchivedFooter());
|
||||
}
|
||||
|
@ -152,8 +144,8 @@ public class UnarchivedConversationListDataSourceTest {
|
|||
Cursor cursor = testSubject.getCursor(0, 100);
|
||||
|
||||
// THEN
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 100);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 100);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 100, allChatsFolder);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 100, allChatsFolder);
|
||||
assertEquals(0, cursor.getCount());
|
||||
}
|
||||
|
||||
|
@ -168,17 +160,17 @@ public class UnarchivedConversationListDataSourceTest {
|
|||
Cursor cursor = testSubject.getCursor(0, 100);
|
||||
|
||||
// THEN
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 100);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 100);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 100, allChatsFolder);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 100, allChatsFolder);
|
||||
assertEquals(1, cursor.getCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenSinglePinnedAndArchivedConversations_whenIGetCursor_thenIExpectThree() {
|
||||
public void givenSinglePinnedAndArchivedConversations_whenIGetCursor_thenIExpectTwo() {
|
||||
// GIVEN
|
||||
setupThreadDatabaseCursors(1, 0);
|
||||
when(threadTable.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
|
||||
when(threadTable.getPinnedConversationListCount(ConversationFilter.OFF, allChatsFolder)).thenReturn(1);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF, allChatsFolder)).thenReturn(1);
|
||||
when(threadTable.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12);
|
||||
testSubject.getTotalCount();
|
||||
|
||||
|
@ -186,16 +178,16 @@ public class UnarchivedConversationListDataSourceTest {
|
|||
Cursor cursor = testSubject.getCursor(0, 100);
|
||||
|
||||
// THEN
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 99);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 98);
|
||||
assertEquals(3, cursor.getCount());
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 100, allChatsFolder);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 99, allChatsFolder);
|
||||
assertEquals(2, cursor.getCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenSingleUnpinnedAndArchivedConversations_whenIGetCursor_thenIExpectTwo() {
|
||||
// GIVEN
|
||||
setupThreadDatabaseCursors(0, 1);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF, allChatsFolder)).thenReturn(1);
|
||||
when(threadTable.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12);
|
||||
testSubject.getTotalCount();
|
||||
|
||||
|
@ -203,42 +195,42 @@ public class UnarchivedConversationListDataSourceTest {
|
|||
Cursor cursor = testSubject.getCursor(0, 100);
|
||||
|
||||
// THEN
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 100);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 100);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 100, allChatsFolder);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 100, allChatsFolder);
|
||||
assertEquals(2, cursor.getCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenSinglePinnedAndSingleUnpinned_whenIGetCursor_thenIExpectFour() {
|
||||
public void givenSinglePinnedAndSingleUnpinned_whenIGetCursor_thenIExpectTwo() {
|
||||
// GIVEN
|
||||
setupThreadDatabaseCursors(1, 1);
|
||||
when(threadTable.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(1);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(2);
|
||||
when(threadTable.getPinnedConversationListCount(ConversationFilter.OFF, allChatsFolder)).thenReturn(1);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF, allChatsFolder)).thenReturn(2);
|
||||
testSubject.getTotalCount();
|
||||
|
||||
// WHEN
|
||||
Cursor cursor = testSubject.getCursor(0, 100);
|
||||
|
||||
// THEN
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 99);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 97);
|
||||
assertEquals(4, cursor.getCount());
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 100, allChatsFolder);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, false, 0, 99, allChatsFolder);
|
||||
assertEquals(2, cursor.getCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenLoadingSecondPage_whenIGetCursor_thenIExpectProperOffsetAndCursorCount() {
|
||||
// GIVEN
|
||||
setupThreadDatabaseCursors(0, 100);
|
||||
when(threadTable.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(4);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(104);
|
||||
when(threadTable.getPinnedConversationListCount(ConversationFilter.OFF, allChatsFolder)).thenReturn(4);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF, allChatsFolder)).thenReturn(104);
|
||||
testSubject.getTotalCount();
|
||||
|
||||
// WHEN
|
||||
Cursor cursor = testSubject.getCursor(50, 100);
|
||||
|
||||
// THEN
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, true, 50, 100);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, false, 44, 100);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, true, 50, 100, allChatsFolder);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, false, 46, 100, allChatsFolder);
|
||||
assertEquals(100, cursor.getCount());
|
||||
}
|
||||
|
||||
|
@ -246,8 +238,8 @@ public class UnarchivedConversationListDataSourceTest {
|
|||
public void givenHasArchivedAndLoadingLastPage_whenIGetCursor_thenIExpectProperOffsetAndCursorCount() {
|
||||
// GIVEN
|
||||
setupThreadDatabaseCursors(0, 99);
|
||||
when(threadTable.getPinnedConversationListCount(ConversationFilter.OFF)).thenReturn(4);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF)).thenReturn(103);
|
||||
when(threadTable.getPinnedConversationListCount(ConversationFilter.OFF, allChatsFolder)).thenReturn(4);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF, allChatsFolder)).thenReturn(103);
|
||||
when(threadTable.getArchivedConversationListCount(ConversationFilter.OFF)).thenReturn(12);
|
||||
testSubject.getTotalCount();
|
||||
|
||||
|
@ -255,8 +247,8 @@ public class UnarchivedConversationListDataSourceTest {
|
|||
Cursor cursor = testSubject.getCursor(50, 100);
|
||||
|
||||
// THEN
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, true, 50, 100);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, false, 44, 100);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, true, 50, 100, allChatsFolder);
|
||||
verify(threadTable).getUnarchivedConversationList(ConversationFilter.OFF, false, 46, 100, allChatsFolder);
|
||||
assertEquals(100, cursor.getCount());
|
||||
|
||||
cursor.moveToLast();
|
||||
|
@ -266,10 +258,10 @@ public class UnarchivedConversationListDataSourceTest {
|
|||
@Test
|
||||
public void givenHasNoArchivedAndIsFiltered_whenIGetCursor_thenIExpectConversationFilterFooter() {
|
||||
// GIVEN
|
||||
ConversationListDataSource.UnarchivedConversationListDataSource testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(ConversationFilter.UNREAD, false);
|
||||
ConversationListDataSource.UnarchivedConversationListDataSource testSubject = new ConversationListDataSource.UnarchivedConversationListDataSource(allChatsFolder, ConversationFilter.UNREAD, false);
|
||||
setupThreadDatabaseCursors(0, 3);
|
||||
when(threadTable.getPinnedConversationListCount(ConversationFilter.UNREAD)).thenReturn(0);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.UNREAD)).thenReturn(3);
|
||||
when(threadTable.getPinnedConversationListCount(ConversationFilter.UNREAD, allChatsFolder)).thenReturn(0);
|
||||
when(threadTable.getUnarchivedConversationListCount(ConversationFilter.UNREAD, allChatsFolder)).thenReturn(3);
|
||||
when(threadTable.getArchivedConversationListCount(ConversationFilter.UNREAD)).thenReturn(0);
|
||||
testSubject.getTotalCount();
|
||||
|
||||
|
@ -292,7 +284,26 @@ public class UnarchivedConversationListDataSourceTest {
|
|||
Cursor unpinnedCursor = mock(Cursor.class);
|
||||
when(unpinnedCursor.getCount()).thenReturn(unpinned);
|
||||
|
||||
when(threadTable.getUnarchivedConversationList(any(), eq(true), anyLong(), anyLong())).thenReturn(pinnedCursor);
|
||||
when(threadTable.getUnarchivedConversationList(any(), eq(false), anyLong(), anyLong())).thenReturn(unpinnedCursor);
|
||||
when(threadTable.getUnarchivedConversationList(any(), eq(true), anyLong(), anyLong(), any())).thenReturn(pinnedCursor);
|
||||
when(threadTable.getUnarchivedConversationList(any(), eq(false), anyLong(), anyLong(), any())).thenReturn(unpinnedCursor);
|
||||
}
|
||||
|
||||
private ChatFolderRecord setupAllChatsFolder() {
|
||||
return new ChatFolderRecord(
|
||||
1,
|
||||
"",
|
||||
-1,
|
||||
new ArrayList<>(),
|
||||
new ArrayList<>(),
|
||||
new HashSet<>(),
|
||||
new HashSet<>(),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChatFolderRecord.FolderType.ALL,
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
|
@ -75,6 +75,7 @@ object Dialogs {
|
|||
confirm: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onDismissRequest: () -> Unit = onDismiss,
|
||||
modifier: Modifier = Modifier,
|
||||
dismiss: String = NoDismiss,
|
||||
confirmColor: Color = Color.Unspecified,
|
||||
|
@ -82,8 +83,14 @@ object Dialogs {
|
|||
properties: DialogProperties = DialogProperties()
|
||||
) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(text = title) },
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = if (title.isNotEmpty()) {
|
||||
{
|
||||
Text(text = title)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
text = { Text(text = body) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
|
|
|
@ -0,0 +1,185 @@
|
|||
package org.signal.core.ui.copied.androidx.compose
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* From AndroidX Compose demo
|
||||
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt
|
||||
*
|
||||
* Allows for dragging and dropping to reorder within lazy columns
|
||||
*/
|
||||
@Composable
|
||||
fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state =
|
||||
remember(lazyListState) {
|
||||
DragDropState(state = lazyListState, onMove = onMove, scope = scope)
|
||||
}
|
||||
LaunchedEffect(state) {
|
||||
while (true) {
|
||||
val diff = state.scrollChannel.receive()
|
||||
lazyListState.scrollBy(diff)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
class DragDropState
|
||||
internal constructor(
|
||||
private val state: LazyListState,
|
||||
private val scope: CoroutineScope,
|
||||
private val onMove: (Int, Int) -> Unit
|
||||
) {
|
||||
var draggingItemIndex by mutableStateOf<Int?>(null)
|
||||
private set
|
||||
|
||||
internal val scrollChannel = Channel<Float>()
|
||||
|
||||
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
|
||||
private var draggingItemInitialOffset by mutableIntStateOf(0)
|
||||
internal val draggingItemOffset: Float
|
||||
get() =
|
||||
draggingItemLayoutInfo?.let { item ->
|
||||
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
|
||||
} ?: 0f
|
||||
|
||||
private val draggingItemLayoutInfo: LazyListItemInfo?
|
||||
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
|
||||
|
||||
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
|
||||
private set
|
||||
|
||||
internal var previousItemOffset = Animatable(0f)
|
||||
private set
|
||||
|
||||
internal fun onDragStart(offset: Offset) {
|
||||
state.layoutInfo.visibleItemsInfo
|
||||
.firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) }
|
||||
?.also {
|
||||
draggingItemIndex = it.index
|
||||
draggingItemInitialOffset = it.offset
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onDragInterrupted() {
|
||||
if (draggingItemIndex != null) {
|
||||
previousIndexOfDraggedItem = draggingItemIndex
|
||||
val startOffset = draggingItemOffset
|
||||
scope.launch {
|
||||
previousItemOffset.snapTo(startOffset)
|
||||
previousItemOffset.animateTo(
|
||||
0f,
|
||||
spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f)
|
||||
)
|
||||
previousIndexOfDraggedItem = null
|
||||
}
|
||||
}
|
||||
draggingItemDraggedDelta = 0f
|
||||
draggingItemIndex = null
|
||||
draggingItemInitialOffset = 0
|
||||
}
|
||||
|
||||
internal fun onDrag(offset: Offset) {
|
||||
draggingItemDraggedDelta += offset.y
|
||||
|
||||
val draggingItem = draggingItemLayoutInfo ?: return
|
||||
val startOffset = draggingItem.offset + draggingItemOffset
|
||||
val endOffset = startOffset + draggingItem.size
|
||||
val middleOffset = startOffset + (endOffset - startOffset) / 2f
|
||||
|
||||
val targetItem =
|
||||
state.layoutInfo.visibleItemsInfo.find { item ->
|
||||
middleOffset.toInt() in item.offset..item.offsetEnd &&
|
||||
draggingItem.index != item.index
|
||||
}
|
||||
if (targetItem != null) {
|
||||
if (
|
||||
draggingItem.index == state.firstVisibleItemIndex ||
|
||||
targetItem.index == state.firstVisibleItemIndex
|
||||
) {
|
||||
state.requestScrollToItem(
|
||||
state.firstVisibleItemIndex,
|
||||
state.firstVisibleItemScrollOffset
|
||||
)
|
||||
}
|
||||
onMove.invoke(draggingItem.index, targetItem.index)
|
||||
draggingItemIndex = targetItem.index
|
||||
} else {
|
||||
val overscroll =
|
||||
when {
|
||||
draggingItemDraggedDelta > 0 ->
|
||||
(endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
|
||||
draggingItemDraggedDelta < 0 ->
|
||||
(startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
|
||||
else -> 0f
|
||||
}
|
||||
if (overscroll != 0f) {
|
||||
scrollChannel.trySend(overscroll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val LazyListItemInfo.offsetEnd: Int
|
||||
get() = this.offset + this.size
|
||||
}
|
||||
|
||||
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
|
||||
return pointerInput(dragDropState) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDrag = { change, offset ->
|
||||
change.consume()
|
||||
dragDropState.onDrag(offset = offset)
|
||||
},
|
||||
onDragStart = { offset -> dragDropState.onDragStart(offset) },
|
||||
onDragEnd = { dragDropState.onDragInterrupted() },
|
||||
onDragCancel = { dragDropState.onDragInterrupted() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LazyItemScope.DraggableItem(
|
||||
dragDropState: DragDropState,
|
||||
index: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
|
||||
) {
|
||||
val dragging = index == dragDropState.draggingItemIndex
|
||||
val draggingModifier =
|
||||
if (dragging) {
|
||||
Modifier.zIndex(1f).graphicsLayer { translationY = dragDropState.draggingItemOffset }
|
||||
} else if (index == dragDropState.previousIndexOfDraggedItem) {
|
||||
Modifier.zIndex(1f).graphicsLayer {
|
||||
translationY = dragDropState.previousItemOffset.value
|
||||
}
|
||||
} else {
|
||||
Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
|
||||
}
|
||||
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
|
||||
}
|
Loading…
Add table
Reference in a new issue