From c4fc32988c950fbb6df7f4e39bb2073ed7bb3172 Mon Sep 17 00:00:00 2001 From: Michelle Tang Date: Fri, 11 Oct 2024 09:38:53 -0700 Subject: [PATCH] Release chat folders to internal users. --- .../database/ChatFolderTablesTest.kt | 121 +++++ .../database/ThreadTableTest_active.kt | 11 +- .../database/ThreadTableTest_pinned.kt | 6 +- .../securesms/ContactSelectionActivity.java | 5 +- .../ContactSelectionListFragment.java | 82 +++- .../securesms/InviteActivity.java | 7 +- .../securesms/NewConversationActivity.java | 6 +- .../blocked/BlockedUsersActivity.java | 5 +- .../securesms/calls/new/NewCallActivity.kt | 3 +- .../app/chats/ChatsSettingsFragment.kt | 14 +- .../app/chats/folders/ChatFolderRecord.kt | 46 ++ .../app/chats/folders/ChatFoldersFragment.kt | 330 +++++++++++++ .../chats/folders/ChatFoldersRepository.kt | 43 ++ .../chats/folders/ChatFoldersSettingsState.kt | 19 + .../app/chats/folders/ChatFoldersViewModel.kt | 313 ++++++++++++ .../app/chats/folders/ChooseChatsFragment.kt | 158 ++++++ .../chats/folders/CreateFoldersFragment.kt | 449 ++++++++++++++++++ .../profiles/SelectRecipientsFragment.kt | 5 +- .../contacts/ContactChipViewModel.kt | 39 +- .../securesms/contacts/SelectedContact.java | 28 +- .../securesms/contacts/SelectedContacts.kt | 53 ++- .../securesms/contacts/paged/ChatType.kt | 9 + .../contacts/paged/ContactSearchAdapter.kt | 48 ++ .../paged/ContactSearchConfiguration.kt | 17 + .../contacts/paged/ContactSearchData.kt | 8 + .../contacts/paged/ContactSearchKey.kt | 9 + .../contacts/paged/ContactSearchMediator.kt | 15 + .../paged/ContactSearchPagedDataSource.kt | 19 + .../contacts/paged/ContactSearchRepository.kt | 1 + .../conversationlist/ChatFolderAdapter.kt | 58 +++ .../ChatFolderMappingModel.kt | 17 + .../ConversationListArchiveFragment.java | 3 + .../ConversationListDataSource.java | 72 +-- .../ConversationListFragment.java | 30 +- .../ConversationListItem.java | 8 + .../ConversationListViewModel.kt | 66 ++- .../securesms/database/ChatFolderTables.kt | 321 +++++++++++++ .../securesms/database/DatabaseObserver.java | 12 + .../securesms/database/RecipientTable.kt | 1 + .../securesms/database/RxDatabaseObserver.kt | 7 + .../securesms/database/SignalDatabase.kt | 9 + .../securesms/database/ThreadTable.kt | 155 ++++-- .../helpers/SignalDatabaseMigrations.kt | 6 +- .../migration/V253_CreateChatFolderTables.kt | 47 ++ .../ui/addmembers/AddMembersActivity.java | 6 +- .../ui/addtogroup/AddToGroupsActivity.java | 9 +- .../ui/creategroup/CreateGroupActivity.java | 5 +- .../UserNotificationMigrationJob.java | 2 +- .../PaymentRecipientSelectionFragment.java | 5 +- .../BaseStoryRecipientSelectionFragment.kt | 5 +- .../main/res/drawable/ic_chat_folder_24.xml | 9 + app/src/main/res/drawable/ic_drag_handle.xml | 12 + app/src/main/res/drawable/ic_pin_20.xml | 11 + app/src/main/res/layout/chat_folder_item.xml | 37 ++ .../main/res/layout/choose_chats_fragment.xml | 54 +++ .../layout/contact_search_chat_type_item.xml | 56 +++ .../res/layout/conversation_list_fragment.xml | 12 +- .../layout/conversation_list_item_view.xml | 12 + .../app_settings_with_change_number.xml | 49 ++ app/src/main/res/values/dimens.xml | 2 + app/src/main/res/values/strings.xml | 103 ++++ ...rchivedConversationListDataSourceTest.java | 151 +++--- .../main/java/org/signal/core/ui/Dialogs.kt | 11 +- .../ui/copied/androidx/compose/DragAndDrop.kt | 185 ++++++++ 64 files changed, 3166 insertions(+), 251 deletions(-) create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/database/ChatFolderTablesTest.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFolderRecord.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersSettingsState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChooseChatsFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/CreateFoldersFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ChatType.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversationlist/ChatFolderAdapter.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversationlist/ChatFolderMappingModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V253_CreateChatFolderTables.kt create mode 100644 app/src/main/res/drawable/ic_chat_folder_24.xml create mode 100644 app/src/main/res/drawable/ic_drag_handle.xml create mode 100644 app/src/main/res/drawable/ic_pin_20.xml create mode 100644 app/src/main/res/layout/chat_folder_item.xml create mode 100644 app/src/main/res/layout/choose_chats_fragment.xml create mode 100644 app/src/main/res/layout/contact_search_chat_type_item.xml create mode 100644 core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/DragAndDrop.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/ChatFolderTablesTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/ChatFolderTablesTest.kt new file mode 100644 index 0000000000..564f612131 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/ChatFolderTablesTest.kt @@ -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) + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/ThreadTableTest_active.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/ThreadTableTest_active.kt index 0385e39175..d6f938adaa 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/ThreadTableTest_active.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/ThreadTableTest_active.kt @@ -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) } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/ThreadTableTest_pinned.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/ThreadTableTest_pinned.kt index 5efcac8834..beee4b74ca 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/ThreadTableTest_pinned.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/ThreadTableTest_pinned.kt @@ -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)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java index b03279c122..676cc05200 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java @@ -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, String number, @NonNull Consumer callback) { + public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional recipientId, String number, @NonNull Optional chatType, @NonNull Consumer callback) { callback.accept(true); } @Override - public void onContactDeselected(@NonNull Optional recipientId, String number) {} + public void onContactDeselected(@NonNull Optional recipientId, String number, @NonNull Optional chatType) {} @Override public void onBeginScroll() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index 2a9df86a22..1a9978b5ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -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) { + private void handleSelectedContactsChanged(@NonNull List> 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, @Nullable String number, @NonNull Consumer callback); + void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional recipientId, @Nullable String number, @NonNull Optional chatType, @NonNull Consumer callback); - void onContactDeselected(@NonNull Optional recipientId, @Nullable String number); + void onContactDeselected(@NonNull Optional recipientId, @Nullable String number, @NonNull Optional chatType); void onSelectionChanged(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java b/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java index e249c38eb6..aabdefa56f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java @@ -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, String number, @NonNull Consumer callback) { + public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional recipientId, String number, @NonNull Optional chatType, @NonNull Consumer callback) { updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1); callback.accept(true); } @Override - public void onContactDeselected(@NonNull Optional recipientId, String number) { + public void onContactDeselected(@NonNull Optional recipientId, String number, @NonNull Optional chatType) { updateSmsButtonText(contactsFragment.getSelectedContacts().size()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java index 319ca042cf..2dfb02dd8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java @@ -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, String number, @NonNull Consumer callback) { + public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional recipientId, String number, @NonNull Optional chatType, @NonNull Consumer callback) { if (recipientId.isPresent()) { launch(Recipient.resolved(recipientId.get())); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java index ce6489879d..3c5492f053 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java @@ -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, String number, @NonNull Consumer callback) { + public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional recipientId, String number, @NonNull Optional chatType, @NonNull Consumer 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, String number) { + public void onContactDeselected(@NonNull Optional recipientId, String number, @NonNull Optional chatType) { } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt index 860709564c..6cdd69ca95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt @@ -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, number: String?, callback: Consumer) { + override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional, number: String?, chatType: Optional, callback: Consumer) { if (recipientId.isPresent) { launch(Recipient.resolved(recipientId.get())) } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt index d4914134b9..889d7e0a15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFolderRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFolderRecord.kt new file mode 100644 index 0000000000..bb55279fa6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFolderRecord.kt @@ -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 = emptyList(), + val excludedChats: List = emptyList(), + val includedRecipients: Set = emptySet(), + val excludedRecipients: Set = 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 + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt new file mode 100644 index 0000000000..c6079035ab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt @@ -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() + 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 + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersRepository.kt new file mode 100644 index 0000000000..54d65b7673 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersRepository.kt @@ -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 { + 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) { + SignalDatabase.chatFolders.updatePositions(folders) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersSettingsState.kt new file mode 100644 index 0000000000..07a48338cb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersSettingsState.kt @@ -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 = emptyList(), + val suggestedFolders: List = emptyList(), + val originalFolder: ChatFolderRecord = ChatFolderRecord(), + val currentFolder: ChatFolderRecord = ChatFolderRecord(), + val showDeleteDialog: Boolean = false, + val showConfirmationDialog: Boolean = false, + val pendingIncludedRecipients: Set = emptySet(), + val pendingExcludedRecipients: Set = emptySet(), + val pendingChatTypes: Set = emptySet() +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersViewModel.kt new file mode 100644 index 0000000000..fe85436910 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersViewModel.kt @@ -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): List { + 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 = 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 = 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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChooseChatsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChooseChatsFragment.kt new file mode 100644 index 0000000000..cf4ccde1c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChooseChatsFragment.kt @@ -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 = 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(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 = if (includeChatsMode) { + viewModel.state.value.pendingIncludedRecipients + } else { + viewModel.state.value.pendingExcludedRecipients + } + + selectionFragment.markSelected(activeSelection) + } + + override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional, number: String?, chatType: Optional, callback: Consumer) { + 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, number: String?, chatType: Optional) { + 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" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/CreateFoldersFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/CreateFoldersFragment.kt new file mode 100644 index 0000000000..296c22ed71 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/CreateFoldersFragment.kt @@ -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)) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/SelectRecipientsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/SelectRecipientsFragment.kt index e24b620033..c3bb74bc93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/SelectRecipientsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/notifications/profiles/SelectRecipientsFragment.kt @@ -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, number: String?, callback: Consumer) { + override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional, number: String?, chatType: Optional, callback: Consumer) { if (recipientId.isPresent) { viewModel.select(recipientId.get()) callback.accept(true) @@ -116,7 +117,7 @@ class SelectRecipientsFragment : LoggingFragment(), ContactSelectionListFragment } } - override fun onContactDeselected(recipientId: Optional, number: String?) { + override fun onContactDeselected(recipientId: Optional, number: String?, chatType: Optional) { if (recipientId.isPresent) { viewModel.deselect(recipientId.get()) updateAddToProfile() diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactChipViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactChipViewModel.kt index 191df68b2b..15ae8b2a8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactChipViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactChipViewModel.kt @@ -20,9 +20,9 @@ import org.thoughtcrime.securesms.util.rx.RxStore */ class ContactChipViewModel : ViewModel() { - private val store = RxStore(emptyList()) + private val store = RxStore(emptyList>()) - val state: Flowable> = store.stateFlowable + val state: Flowable>> = 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 + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContact.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContact.java index 9957082356..c5ff892426 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContact.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContact.java @@ -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); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContacts.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContacts.kt index 243b97f7c1..85024b5257 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContacts.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/SelectedContacts.kt @@ -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 { - override fun areItemsTheSame(newItem: Model): Boolean { + sealed class Model(val selectedContact: SelectedContact) : MappingModel + + class RecipientModel(selectedContact: SelectedContact, val recipient: Recipient) : Model(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(itemView) { + private class RecipientViewHolder(itemView: View, private val onCloseIconClicked: (RecipientModel) -> Unit) : MappingViewHolder(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(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(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) + } + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ChatType.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ChatType.kt new file mode 100644 index 0000000000..14b3ca536c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ChatType.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt index 193d622fed..23a3bc3022 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt @@ -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) { + mappingAdapter.registerFactory( + ChatTypeModel::class.java, + LayoutFactory({ ChatTypeViewHolder(it, chatTypeRowListener) }, R.layout.contact_search_chat_type_item) + ) + } + fun toMappingModelList(contactSearchData: List, selection: Set, 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 { + 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 + ) : MappingViewHolder(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 { 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt index e3006d7e63..e17a2575bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt @@ -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) */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt index 36af4f2aa4..a6e191041f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt @@ -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. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt index 08d91cd8db..bed4eec8a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt index 4ec13532f3..6177759d4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt index 8b0a332d68..8deb266076 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt @@ -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 { + val data = mutableListOf() + + 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 { return getContactsWithoutThreadsIterator(query).use { records -> readContactData( diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchRepository.kt index b24584a315..917c080889 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchRepository.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ChatFolderAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ChatFolderAdapter.kt new file mode 100644 index 0000000000..10f33091f5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ChatFolderAdapter.kt @@ -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(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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ChatFolderMappingModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ChatFolderMappingModel.kt new file mode 100644 index 0000000000..14d3d0e8d0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ChatFolderMappingModel.kt @@ -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 { + override fun areItemsTheSame(newItem: ChatFolderMappingModel): Boolean { + return chatFolder == newItem.chatFolder + } + + override fun areContentsTheSame(newItem: ChatFolderMappingModel): Boolean { + return areItemsTheSame(newItem) && isSelected == newItem.isSelected + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java index a37a60c5e9..e764da05d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java @@ -47,6 +47,7 @@ public class ConversationListArchiveFragment extends ConversationListFragment im { private View coordinator; private RecyclerView list; + private RecyclerView foldersList; private Stub 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java index a6b1eed2e6..5d4181dd83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListDataSource.java @@ -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 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 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 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java index d83d05e331..7700b99238 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt index 09ed179957..8aa2abe23e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt @@ -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 = store.mapDistinctForUi { it.megaphone } val selectedState: Flowable = store.mapDistinctForUi { it.selectedConversations } val filterRequestState: Flowable = store.mapDistinctForUi { it.filterRequest } + val chatFolderState: Flowable> = store.mapDistinctForUi { it.chatFolders } val hasNoConversations: Flowable val controller = ProxyPagingController() + val folders: List + 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> { 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 = emptyList(), + val currentFolder: ChatFolderRecord = ChatFolderRecord(), val conversations: List = emptyList(), val megaphone: Megaphone = Megaphone.NONE, val selectedConversations: ConversationSet = ConversationSet(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt new file mode 100644 index 0000000000..5fdcbe013e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt @@ -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 = arrayOf(ChatFolderTable.CREATE_TABLE, ChatFolderMembershipTable.CREATE_TABLE) + + @JvmField + val CREATE_INDEXES: Array = 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 { + val includedChats: Map> = getIncludedChats() + val excludedChats: Map> = 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> { + 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> { + 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) { + 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.toContentValues(chatFolderId: Long, membershipType: MembershipType): List { + 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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java index 5b643c1566..05f8a29424 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java @@ -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 callUpdateObservers; private final Map> callLinkObservers; private final Set inAppPaymentObservers; + private final Set 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index 3b7aed292f..33d3ae6456 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RxDatabaseObserver.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RxDatabaseObserver.kt index ae8555ad96..ef829b33ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RxDatabaseObserver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RxDatabaseObserver.kt @@ -15,6 +15,7 @@ object RxDatabaseObserver { val conversationList: Flowable by lazy { conversationListFlowable() } val notificationProfiles: Flowable by lazy { notificationProfilesFlowable() } + val chatFolders: Flowable by lazy { chatFoldersFlowable() } private fun conversationListFlowable(): Flowable { return databaseFlowable { listener -> @@ -36,6 +37,12 @@ object RxDatabaseObserver { ) { _, _ -> Unit } } + private fun chatFoldersFlowable(): Flowable { + return databaseFlowable { listener -> + AppDependencies.databaseObserver.registerChatFolderObserver(listener) + } + } + private fun databaseFlowable(registerObserver: (RxObserver) -> Unit): Flowable { val flowable = Flowable.create( { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt index 1bb02120f1..0d581c71b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt index 0e2593c4f9..cfc51dc966 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -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 = 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 = 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 -> "" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 5ea30a9f17..8f1e4d6169 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V253_CreateChatFolderTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V253_CreateChatFolderTables.kt new file mode 100644 index 0000000000..c89e14ef91 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V253_CreateChatFolderTables.kt @@ -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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java index 153bbe7bb6..e9dcd145a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java @@ -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, String number, @NonNull Consumer callback) { + public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional recipientId, String number, @NonNull Optional chatType, @NonNull Consumer 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, String number) { + public void onContactDeselected(@NonNull Optional recipientId, String number, @NonNull Optional chatType) { if (contactsFragment.hasQueryFilter()) { getContactFilterView().clear(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.java index 4720470231..6f27c54ee7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.java @@ -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, String number, @NonNull Consumer callback) { + public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional recipientId, String number, @NonNull Optional chatType, @NonNull Consumer 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, String number) { + public void onContactDeselected(@NonNull Optional recipientId, String number, @NonNull Optional chatType) { if (contactsFragment.hasQueryFilter()) { getContactFilterView().clear(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java index 51bf453d5a..1c4d280d33 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.java @@ -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, String number, @NonNull Consumer callback) { + public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional recipientId, String number, @NonNull Optional chatType, @NonNull Consumer callback) { if (contactsFragment.hasQueryFilter()) { getContactFilterView().clear(); } @@ -145,7 +146,7 @@ public class CreateGroupActivity extends ContactSelectionActivity implements Con } @Override - public void onContactDeselected(@NonNull Optional recipientId, String number) { + public void onContactDeselected(@NonNull Optional recipientId, String number, @NonNull Optional chatType) { if (contactsFragment.hasQueryFilter()) { getContactFilterView().clear(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/UserNotificationMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/UserNotificationMigrationJob.java index 9caf693e09..1f8c92d1c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/UserNotificationMigrationJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/UserNotificationMigrationJob.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java index c96f9f1f54..fb05566b95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/PaymentRecipientSelectionFragment.java @@ -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, @Nullable String number, @NonNull Consumer callback) { + public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional recipientId, @Nullable String number, @NonNull Optional chatType, @NonNull Consumer 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, @Nullable String number) {} + public void onContactDeselected(@NonNull Optional recipientId, @Nullable String number, @NonNull Optional chatType) {} @Override public void onSelectionChanged() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionFragment.kt index f483557d6f..f00aed14fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/select/BaseStoryRecipientSelectionFragment.kt @@ -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, number: String?, callback: Consumer) { + override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional, number: String?, chatType: Optional, callback: Consumer) { 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, number: String?) { + override fun onContactDeselected(recipientId: Optional, number: String?, chatType: Optional) { viewModel.removeRecipient(recipientId.get()) } diff --git a/app/src/main/res/drawable/ic_chat_folder_24.xml b/app/src/main/res/drawable/ic_chat_folder_24.xml new file mode 100644 index 0000000000..31a6fa6131 --- /dev/null +++ b/app/src/main/res/drawable/ic_chat_folder_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_drag_handle.xml b/app/src/main/res/drawable/ic_drag_handle.xml new file mode 100644 index 0000000000..dbe204edf0 --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_handle.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_pin_20.xml b/app/src/main/res/drawable/ic_pin_20.xml new file mode 100644 index 0000000000..c58ee51556 --- /dev/null +++ b/app/src/main/res/drawable/ic_pin_20.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/chat_folder_item.xml b/app/src/main/res/layout/chat_folder_item.xml new file mode 100644 index 0000000000..5c1ff21514 --- /dev/null +++ b/app/src/main/res/layout/chat_folder_item.xml @@ -0,0 +1,37 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/choose_chats_fragment.xml b/app/src/main/res/layout/choose_chats_fragment.xml new file mode 100644 index 0000000000..b3813decbb --- /dev/null +++ b/app/src/main/res/layout/choose_chats_fragment.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/contact_search_chat_type_item.xml b/app/src/main/res/layout/contact_search_chat_type_item.xml new file mode 100644 index 0000000000..5a1b8d25be --- /dev/null +++ b/app/src/main/res/layout/contact_search_chat_type_item.xml @@ -0,0 +1,56 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/conversation_list_fragment.xml b/app/src/main/res/layout/conversation_list_fragment.xml index c85b052cf5..673eaf3cac 100644 --- a/app/src/main/res/layout/conversation_list_fragment.xml +++ b/app/src/main/res/layout/conversation_list_fragment.xml @@ -32,11 +32,21 @@ app:barrierDirection="bottom" app:constraint_referenced_ids="voice_note_player,banner_compose_view" /> + + + app:layout_constraintTop_toBottomOf="@id/chat_folder_list"> + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index a8d4bcb2a1..02e2bcd1c7 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -245,4 +245,6 @@ 4dp 0dp + 64dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 18b8e791fb..dddfda66d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -369,6 +369,8 @@ Chats Messages + + Chat types Message %s @@ -5035,6 +5037,107 @@ Keyboard Send with enter + + Chat folders + + Add a chat folder + + + + Organize your chats into folders and quickly switch between them on your chat list. + + Folders + + Create a folder + + All chats + + Suggested folders + + Unreads + + Unread messages from all chats + + 1:1 chats + + Only messages from direct chats + + Groups + + Only message from group chats + + Add + + %1$s folder added. + + + %1$d chat type + %1$d chat types + + + + %1$d chat + %1$d chats + + + + %1$d chat excluded + %1$d chats excluded + + + + + Create a folder + + Folder name (required) + + Included chats + + Add chats + + Choose chats that you want to appear in this folder. + + Exceptions + + Exclude chats + + Choose chats that you do not want to appear in this folder. + + Only show unread chats + + When enabled, only chats with unread messages will be shown in this folder. + + Include muted chats + + Create + + Create folder? + + Do you want to create the chat folder \"%1$s\"? + + Create folder + + Edit folder + + Save + + Save changes? + + Do you want to save the changes you\'ve made to this chat folder? + + Save changes + + Discard + + Delete folder + + Delete this chat folder? + + + + Chat types + + Done Messages diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversationlist/UnarchivedConversationListDataSourceTest.java b/app/src/test/java/org/thoughtcrime/securesms/conversationlist/UnarchivedConversationListDataSourceTest.java index 6530c5ffd1..031122ce58 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversationlist/UnarchivedConversationListDataSourceTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/conversationlist/UnarchivedConversationListDataSourceTest.java @@ -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 + ); } } \ No newline at end of file diff --git a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt index b0050b5b39..ce5ac68cce 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt @@ -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 = { diff --git a/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/DragAndDrop.kt b/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/DragAndDrop.kt new file mode 100644 index 0000000000..053fd0ff9c --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/DragAndDrop.kt @@ -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(null) + private set + + internal val scrollChannel = Channel() + + 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(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) } +}