Release chat folders to internal users.

This commit is contained in:
Michelle Tang 2024-10-11 09:38:53 -07:00 committed by Greyson Parrelli
parent e5c122d972
commit c4fc32988c
64 changed files with 3166 additions and 251 deletions

View file

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

View file

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

View file

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

View file

@ -28,6 +28,7 @@ import org.signal.core.util.DimensionUnit;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
@ -127,12 +128,12 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
}
@Override
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
callback.accept(true);
}
@Override
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {}
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {}
@Override
public void onBeginScroll() {

View file

@ -29,7 +29,6 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;
@ -47,7 +46,6 @@ import androidx.transition.AutoTransition;
import androidx.transition.TransitionManager;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.pnikosis.materialishprogress.ProgressWheel;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.RxExtensions;
@ -61,6 +59,7 @@ import org.thoughtcrime.securesms.contacts.HeaderAction;
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.SelectedContacts;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
@ -111,16 +110,17 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public static final int NO_LIMIT = Integer.MAX_VALUE;
public static final String DISPLAY_MODE = "display_mode";
public static final String REFRESHABLE = "refreshable";
public static final String RECENTS = "recents";
public static final String SELECTION_LIMITS = "selection_limits";
public static final String CURRENT_SELECTION = "current_selection";
public static final String HIDE_COUNT = "hide_count";
public static final String CAN_SELECT_SELF = "can_select_self";
public static final String DISPLAY_CHIPS = "display_chips";
public static final String RV_PADDING_BOTTOM = "recycler_view_padding_bottom";
public static final String RV_CLIP = "recycler_view_clipping";
public static final String DISPLAY_MODE = "display_mode";
public static final String REFRESHABLE = "refreshable";
public static final String RECENTS = "recents";
public static final String SELECTION_LIMITS = "selection_limits";
public static final String CURRENT_SELECTION = "current_selection";
public static final String HIDE_COUNT = "hide_count";
public static final String CAN_SELECT_SELF = "can_select_self";
public static final String DISPLAY_CHIPS = "display_chips";
public static final String RV_PADDING_BOTTOM = "recycler_view_padding_bottom";
public static final String RV_CLIP = "recycler_view_clipping";
public static final String INCLUDE_CHAT_TYPES = "include_chat_types";
private ConstraintLayout constraintLayout;
private TextView emptyText;
@ -421,6 +421,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
@Override
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks,
@ -679,6 +684,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
return;
}
if (selectedContact.hasChatType() && !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(true, Optional.empty(), null, Optional.of(selectedContact.getChatType()), allowed -> {
if (allowed) {
markContactSelected(selectedContact);
}
});
}
return;
} else if (selectedContact.hasChatType()) {
markContactUnselected(selectedContact);
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactDeselected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber(), Optional.of(selectedContact.getChatType()));
}
return;
}
if (!isMulti || !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (selectionHardLimitReached()) {
if (onSelectionLimitReachedListener != null) {
@ -709,7 +731,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), username);
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(true, Optional.of(recipient.getId()), null, allowed -> {
onContactSelectedListener.onBeforeContactSelected(true, Optional.of(recipient.getId()), null, Optional.empty(), allowed -> {
if (allowed) {
markContactSelected(selected);
}
@ -731,6 +753,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
isUnknown,
Optional.ofNullable(selectedContact.getRecipientId()),
selectedContact.getNumber(),
Optional.empty(),
allowed -> {
if (allowed) {
markContactSelected(selectedContact);
@ -744,7 +767,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
markContactUnselected(selectedContact);
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactDeselected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber());
onContactSelectedListener.onContactDeselected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber(), Optional.empty());
}
}
}
@ -770,7 +793,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
return getChipCount() + currentSelection.size() > selectionLimit.getRecommendedLimit();
}
private void markContactSelected(@NonNull SelectedContact selectedContact) {
public void markContactSelected(@NonNull SelectedContact selectedContact) {
contactSearchMediator.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
if (isMulti) {
addChipForSelectedContact(selectedContact);
@ -789,7 +812,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
}
private void handleSelectedContactsChanged(@NonNull List<SelectedContacts.Model> selectedContacts) {
private void handleSelectedContactsChanged(@NonNull List<SelectedContacts.Model<?>> selectedContacts) {
contactChipAdapter.submitList(new MappingModelList(selectedContacts), this::smoothScrollChipsToEnd);
if (selectedContacts.isEmpty()) {
@ -808,15 +831,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
resolved -> contactChipViewModel.add(selectedContact));
if (selectedContact.hasChatType()) {
contactChipViewModel.add(selectedContact);
} else {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
resolved -> contactChipViewModel.add(selectedContact));
}
}
private Unit onChipCloseIconClicked(SelectedContacts.Model model) {
private Unit onChipCloseIconClicked(SelectedContacts.Model<?> model) {
markContactUnselected(model.getSelectedContact());
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactDeselected(Optional.of(model.getRecipient().getId()), model.getRecipient().getE164().orElse(null));
if (model instanceof SelectedContacts.ChatTypeModel) {
onContactSelectedListener.onContactDeselected(Optional.empty(), null, Optional.of(model.getSelectedContact().getChatType()));
} else {
onContactSelectedListener.onContactDeselected(Optional.of(((SelectedContacts.RecipientModel) model).getRecipient().getId()), ((SelectedContacts.RecipientModel) model).getRecipient().getE164().orElse(null), Optional.empty());
}
}
return Unit.INSTANCE;
@ -870,6 +901,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
boolean includeGroupsAfterContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
boolean blocked = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_BLOCK);
boolean includeGroupMembers = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUP_MEMBERS);
boolean includeChatTypes = safeArguments().getBoolean(INCLUDE_CHAT_TYPES);
boolean hasQuery = !TextUtils.isEmpty(contactSearchState.getQuery());
ContactSearchConfiguration.TransportType transportType = resolveTransportType(includePushContacts, includeSmsContacts);
@ -895,6 +927,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
}
if (includeChatTypes && !hasQuery) {
builder.addSection(new ContactSearchConfiguration.Section.ChatTypes(true, null));
}
if (transportType != null) {
if (!hasQuery && includeRecents) {
builder.addSection(new ContactSearchConfiguration.Section.Recents(
@ -1027,9 +1063,9 @@ public final class ContactSelectionListFragment extends LoggingFragment {
/**
* Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it.
*/
void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback);
void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback);
void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number);
void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Optional<ChatType> chatType);
void onSelectionChanged();
}

View file

@ -27,15 +27,14 @@ import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.components.ContactFilterView.OnFilterChangedListener;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.OutgoingMessage;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.DynamicNoActionBarInviteTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
@ -131,13 +130,13 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
}
@Override
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
callback.accept(true);
}
@Override
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
}

View file

@ -44,8 +44,8 @@ import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository;
import org.thoughtcrime.securesms.contacts.management.ContactsManagementViewModel;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@ -57,11 +57,9 @@ import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -121,7 +119,7 @@ public class NewConversationActivity extends ContactSelectionActivity
}
@Override
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
if (recipientId.isPresent()) {
launch(Recipient.resolved(recipientId.get()));
} else {

View file

@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
@ -97,7 +98,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
}
@Override
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
final String displayName = recipientId.map(id -> Recipient.resolved(id).getDisplayName(this)).orElse(number);
AlertDialog confirmationDialog = new MaterialAlertDialogBuilder(this)
@ -126,7 +127,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
}
@Override
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {
}

View file

@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.InviteActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
import org.thoughtcrime.securesms.contacts.paged.ChatType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@ -38,7 +39,7 @@ class NewCallActivity : ContactSelectionActivity(), ContactSelectionListFragment
override fun onSelectionChanged() = Unit
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId?>, number: String?, callback: Consumer<Boolean?>) {
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId?>, number: String?, chatType: Optional<ChatType>, callback: Consumer<Boolean?>) {
if (recipientId.isPresent) {
launch(Recipient.resolved(recipientId.get()))
} else {

View file

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

View file

@ -0,0 +1,46 @@
package org.thoughtcrime.securesms.components.settings.app.chats.folders
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Represents an entry in the [org.thoughtcrime.securesms.database.ChatFolderTables].
*/
data class ChatFolderRecord(
val id: Long = -1,
val name: String = "",
val position: Int = -1,
val includedChats: List<Long> = emptyList(),
val excludedChats: List<Long> = emptyList(),
val includedRecipients: Set<Recipient> = emptySet(),
val excludedRecipients: Set<Recipient> = emptySet(),
val showUnread: Boolean = false,
val showMutedChats: Boolean = false,
val showIndividualChats: Boolean = false,
val showGroupChats: Boolean = false,
val isMuted: Boolean = false,
val folderType: FolderType = FolderType.CUSTOM,
val unreadCount: Int = 0 // TODO [michelle]: unread count
) {
enum class FolderType(val value: Int) {
/** Folder containing all chats */
ALL(0),
/** Folder containing all 1:1 chats */
INDIVIDUAL(1),
/** Folder containing group chats */
GROUP(2),
/** Folder containing unread chats. */
UNREAD(3),
/** Folder containing custom chosen chats */
CUSTOM(4);
companion object {
fun deserialize(value: Int): FolderType {
return entries.firstOrNull { it.value == value } ?: CUSTOM
}
}
}
}

View file

@ -0,0 +1,330 @@
package org.thoughtcrime.securesms.components.settings.app.chats.folders
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.fragment.app.activityViewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.copied.androidx.compose.DraggableItem
import org.signal.core.ui.copied.androidx.compose.dragContainer
import org.signal.core.ui.copied.androidx.compose.rememberDragDropState
import org.signal.core.util.toInt
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Fragment that displays current and suggested chat folders
*/
class ChatFoldersFragment : ComposeFragment() {
private val viewModel: ChatFoldersViewModel by activityViewModels()
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsState()
val navController: NavController by remember { mutableStateOf(findNavController()) }
viewModel.loadCurrentFolders(requireContext())
Scaffolds.Settings(
title = stringResource(id = R.string.ChatsSettingsFragment__chat_folders),
onNavigationClick = { navController.popBackStack() },
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { contentPadding: PaddingValues ->
FoldersScreen(
state = state,
modifier = Modifier.padding(contentPadding),
onFolderClicked = {
viewModel.setCurrentFolder(it)
navController.safeNavigate(R.id.action_chatFoldersFragment_to_createFoldersFragment)
},
onAdd = { folder ->
Toast.makeText(requireContext(), getString(R.string.ChatFoldersFragment__folder_added, folder.name), Toast.LENGTH_SHORT).show()
viewModel.createFolder(requireContext(), folder)
},
onPositionUpdated = { fromIndex, toIndex -> viewModel.updatePosition(fromIndex, toIndex) }
)
}
}
}
@Composable
fun FoldersScreen(
state: ChatFoldersSettingsState,
modifier: Modifier = Modifier,
onFolderClicked: (ChatFolderRecord) -> Unit = {},
onAdd: (ChatFolderRecord) -> Unit = {},
onPositionUpdated: (Int, Int) -> Unit = { _, _ -> }
) {
val listState = rememberLazyListState()
val dragDropState =
rememberDragDropState(listState) { fromIndex, toIndex ->
onPositionUpdated(fromIndex, toIndex)
}
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
Column(modifier = Modifier.padding(start = 24.dp)) {
Text(
text = stringResource(id = R.string.ChatFoldersFragment__organize_your_chats),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, end = 12.dp)
)
Text(
text = stringResource(id = R.string.ChatFoldersFragment__folders),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
)
FolderRow(
icon = R.drawable.symbol_plus_compact_16,
title = stringResource(R.string.ChatFoldersFragment__create_a_folder),
onClick = { onFolderClicked(ChatFolderRecord()) }
)
}
val columnHeight = dimensionResource(id = R.dimen.chat_folder_row_height).value * state.folders.size
LazyColumn(
modifier = Modifier
.height(columnHeight.dp)
.dragContainer(dragDropState),
state = listState
) {
itemsIndexed(state.folders) { index, folder ->
DraggableItem(dragDropState, index) { isDragging ->
val elevation = if (isDragging) 1.dp else 0.dp
val isAllChats = folder.folderType == ChatFolderRecord.FolderType.ALL
FolderRow(
icon = R.drawable.ic_chat_folder_24,
title = if (isAllChats) stringResource(R.string.ChatFoldersFragment__all_chats) else folder.name,
subtitle = getFolderDescription(folder),
onClick = if (!isAllChats) {
{ onFolderClicked(folder) }
} else null,
elevation = elevation,
showDragHandle = true,
modifier = Modifier.padding(start = 12.dp)
)
}
}
}
if (state.suggestedFolders.isNotEmpty()) {
Dividers.Default()
Text(
text = stringResource(id = R.string.ChatFoldersFragment__suggested_folders),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp, start = 24.dp)
)
}
state.suggestedFolders.forEach { chatFolder ->
when (chatFolder.folderType) {
ChatFolderRecord.FolderType.UNREAD -> {
val title: String = stringResource(R.string.ChatFoldersFragment__unreads)
FolderRow(
icon = R.drawable.symbol_chat_badge_24,
title = title,
subtitle = stringResource(R.string.ChatFoldersFragment__unread_messages),
onAdd = { onAdd(chatFolder) },
modifier = Modifier.padding(start = 12.dp)
)
}
ChatFolderRecord.FolderType.INDIVIDUAL -> {
val title: String = stringResource(R.string.ChatFoldersFragment__one_on_one_chats)
FolderRow(
icon = R.drawable.symbol_person_light_24,
title = title,
subtitle = stringResource(R.string.ChatFoldersFragment__only_direct_messages),
onAdd = { onAdd(chatFolder) },
modifier = Modifier.padding(start = 12.dp)
)
}
ChatFolderRecord.FolderType.GROUP -> {
val title: String = stringResource(R.string.ChatFoldersFragment__groups)
FolderRow(
icon = R.drawable.symbol_group_light_20,
title = title,
subtitle = stringResource(R.string.ChatFoldersFragment__only_group_messages),
onAdd = { onAdd(chatFolder) },
modifier = Modifier.padding(start = 12.dp)
)
}
ChatFolderRecord.FolderType.ALL -> {
throw IllegalStateException("All chats should not be suggested")
}
ChatFolderRecord.FolderType.CUSTOM -> {
throw IllegalStateException("Custom folders should not be suggested")
}
}
}
}
}
@Composable
private fun getFolderDescription(folder: ChatFolderRecord): String {
val chatTypeCount = folder.showIndividualChats.toInt() + folder.showGroupChats.toInt()
val chatTypes = pluralStringResource(id = R.plurals.ChatFoldersFragment__d_chat_types, count = chatTypeCount, chatTypeCount)
val includedChats = pluralStringResource(id = R.plurals.ChatFoldersFragment__d_chats, count = folder.includedChats.size, folder.includedChats.size)
val excludedChats = pluralStringResource(id = R.plurals.ChatFoldersFragment__d_chats_excluded, count = folder.excludedChats.size, folder.excludedChats.size)
return remember(chatTypeCount, folder.includedChats.size, folder.excludedChats.size) {
val description = mutableListOf<String>()
if (chatTypeCount != 0) {
description.add(chatTypes)
}
if (folder.includedChats.isNotEmpty()) {
description.add(includedChats)
}
if (folder.excludedChats.isNotEmpty()) {
description.add(excludedChats)
}
description.joinToString(separator = ", ")
}
}
@Composable
fun FolderRow(
modifier: Modifier = Modifier,
icon: Int,
title: String,
subtitle: String = "",
onClick: (() -> Unit)? = null,
onAdd: (() -> Unit)? = null,
elevation: Dp = 0.dp,
showDragHandle: Boolean = false
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = if (onClick != null) {
modifier
.padding(end = 12.dp)
.clickable(onClick = onClick)
.fillMaxWidth()
.defaultMinSize(minHeight = dimensionResource(id = R.dimen.chat_folder_row_height))
.shadow(elevation = elevation)
} else {
modifier
.padding(end = 12.dp)
.fillMaxWidth()
.defaultMinSize(minHeight = dimensionResource(id = R.dimen.chat_folder_row_height))
.shadow(elevation = elevation)
}
) {
Image(
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
imageVector = ImageVector.vectorResource(id = icon),
contentDescription = null,
modifier = modifier
.size(40.dp)
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape)
.padding(8.dp)
)
Column(
modifier = Modifier
.padding(start = 12.dp)
.weight(1f)
) {
Text(text = title)
if (subtitle.isNotEmpty()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (onAdd != null) {
Buttons.Small(onClick = onAdd, modifier = modifier.padding(end = 12.dp)) {
Text(stringResource(id = R.string.ChatFoldersFragment__add))
}
} else if (showDragHandle) {
Icon(
painter = painterResource(id = R.drawable.ic_drag_handle),
contentDescription = null,
modifier = modifier.padding(end = 12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@SignalPreview
@Composable
private fun ChatFolderPreview() {
val previewFolders = listOf(
ChatFolderRecord(
id = 1,
name = "Work",
position = 1,
showUnread = true,
showIndividualChats = true,
showGroupChats = true,
showMutedChats = true,
isMuted = false,
folderType = ChatFolderRecord.FolderType.CUSTOM
),
ChatFolderRecord(
id = 2,
name = "Fun People",
position = 2,
showUnread = true,
showIndividualChats = true,
showGroupChats = false,
showMutedChats = false,
isMuted = false,
folderType = ChatFolderRecord.FolderType.CUSTOM
)
)
Previews.Preview {
FoldersScreen(
ChatFoldersSettingsState(
folders = previewFolders
)
)
}
}

View file

@ -0,0 +1,43 @@
package org.thoughtcrime.securesms.components.settings.app.chats.folders
import org.thoughtcrime.securesms.database.SignalDatabase
/**
* Repository for chat folders that handles creation, deletion, listing, etc.,
*/
object ChatFoldersRepository {
fun getCurrentFolders(includeUnreadCount: Boolean = false): List<ChatFolderRecord> {
return SignalDatabase.chatFolders.getChatFolders(includeUnreadCount)
}
fun createFolder(folder: ChatFolderRecord) {
val includedChats = folder.includedRecipients.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }
val excludedChats = folder.excludedRecipients.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }
val updatedFolder = folder.copy(
includedChats = includedChats,
excludedChats = excludedChats
)
SignalDatabase.chatFolders.createFolder(updatedFolder)
}
fun updateFolder(folder: ChatFolderRecord) {
val includedChats = folder.includedRecipients.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }
val excludedChats = folder.excludedRecipients.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }
val updatedFolder = folder.copy(
includedChats = includedChats,
excludedChats = excludedChats
)
SignalDatabase.chatFolders.updateFolder(updatedFolder)
}
fun deleteFolder(folder: ChatFolderRecord) {
SignalDatabase.chatFolders.deleteChatFolder(folder)
}
fun updatePositions(folders: List<ChatFolderRecord>) {
SignalDatabase.chatFolders.updatePositions(folders)
}
}

View file

@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.components.settings.app.chats.folders
import org.thoughtcrime.securesms.contacts.paged.ChatType
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Information about chat folders. Used in [ChatFoldersViewModel].
*/
data class ChatFoldersSettingsState(
val folders: List<ChatFolderRecord> = emptyList(),
val suggestedFolders: List<ChatFolderRecord> = emptyList(),
val originalFolder: ChatFolderRecord = ChatFolderRecord(),
val currentFolder: ChatFolderRecord = ChatFolderRecord(),
val showDeleteDialog: Boolean = false,
val showConfirmationDialog: Boolean = false,
val pendingIncludedRecipients: Set<RecipientId> = emptySet(),
val pendingExcludedRecipients: Set<RecipientId> = emptySet(),
val pendingChatTypes: Set<ChatType> = emptySet()
)

View file

@ -0,0 +1,313 @@
package org.thoughtcrime.securesms.components.settings.app.chats.folders
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ChatType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Maintains the state of the [ChatFoldersFragment] and [CreateFoldersFragment]
*/
class ChatFoldersViewModel : ViewModel() {
private val internalState = MutableStateFlow(ChatFoldersSettingsState())
val state = internalState.asStateFlow()
fun loadCurrentFolders(context: Context) {
viewModelScope.launch(Dispatchers.IO) {
val folders = ChatFoldersRepository.getCurrentFolders(includeUnreadCount = false)
val suggestedFolders = getSuggestedFolders(context, folders)
internalState.update {
it.copy(folders = folders, suggestedFolders = suggestedFolders)
}
}
}
private fun getSuggestedFolders(context: Context, currentFolders: List<ChatFolderRecord>): List<ChatFolderRecord> {
var showIndividualSuggestion = true
var showGroupSuggestion = true
var showUnreadSuggestion = true
currentFolders
.filter { folder -> folder.includedChats.isEmpty() && folder.excludedChats.isEmpty() }
.forEach { folder ->
if (folder.showIndividualChats && !folder.showGroupChats) {
showIndividualSuggestion = false
} else if (folder.showGroupChats && !folder.showIndividualChats) {
showGroupSuggestion = false
} else if (folder.showUnread && folder.showIndividualChats && folder.showGroupChats) {
showUnreadSuggestion = false
}
}
val suggestions: MutableList<ChatFolderRecord> = mutableListOf()
if (showIndividualSuggestion) {
suggestions.add(
ChatFolderRecord(
name = context.getString(R.string.ChatFoldersFragment__one_on_one_chats),
showIndividualChats = true,
folderType = ChatFolderRecord.FolderType.INDIVIDUAL,
showMutedChats = true
)
)
}
if (showGroupSuggestion) {
suggestions.add(
ChatFolderRecord(
name = context.getString(R.string.ChatFoldersFragment__groups),
showGroupChats = true,
folderType = ChatFolderRecord.FolderType.GROUP,
showMutedChats = true
)
)
}
if (showUnreadSuggestion) {
suggestions.add(
ChatFolderRecord(
name = context.getString(R.string.ChatFoldersFragment__unreads),
showUnread = true,
showIndividualChats = true,
showGroupChats = true,
showMutedChats = true,
folderType = ChatFolderRecord.FolderType.UNREAD
)
)
}
return suggestions
}
fun setCurrentFolder(folder: ChatFolderRecord) {
viewModelScope.launch(Dispatchers.IO) {
val includedRecipients = folder.includedChats.mapNotNull { threadId ->
SignalDatabase.threads.getRecipientForThreadId(threadId)
}
val excludedRecipients = folder.excludedChats.mapNotNull { threadId ->
SignalDatabase.threads.getRecipientForThreadId(threadId)
}
val updatedFolder = folder.copy(
includedRecipients = includedRecipients.toSet(),
excludedRecipients = excludedRecipients.toSet()
)
internalState.update {
it.copy(originalFolder = updatedFolder, currentFolder = updatedFolder)
}
}
}
fun updateName(name: String) {
val updatedFolder = internalState.value.currentFolder.copy(
name = name.substring(0, minOf(name.length, 32))
)
internalState.update {
it.copy(currentFolder = updatedFolder)
}
}
fun toggleShowUnread(showUnread: Boolean) {
val updatedFolder = internalState.value.currentFolder.copy(
showUnread = showUnread
)
internalState.update {
it.copy(currentFolder = updatedFolder)
}
}
fun toggleShowMutedChats(showMuted: Boolean) {
val updatedFolder = internalState.value.currentFolder.copy(
showMutedChats = showMuted
)
internalState.update {
it.copy(currentFolder = updatedFolder)
}
}
fun showDeleteDialog(show: Boolean) {
internalState.update {
it.copy(showDeleteDialog = show)
}
}
fun deleteFolder() {
viewModelScope.launch(Dispatchers.IO) {
ChatFoldersRepository.deleteFolder(internalState.value.originalFolder)
internalState.update {
it.copy(showDeleteDialog = false)
}
}
}
fun showConfirmationDialog(show: Boolean) {
internalState.update {
it.copy(showConfirmationDialog = show)
}
}
fun createFolder(context: Context, folder: ChatFolderRecord? = null) {
viewModelScope.launch(Dispatchers.IO) {
val currentFolder = folder ?: internalState.value.currentFolder
ChatFoldersRepository.createFolder(currentFolder)
loadCurrentFolders(context)
internalState.update {
it.copy(showConfirmationDialog = false)
}
}
}
fun updatePosition(fromIndex: Int, toIndex: Int) {
viewModelScope.launch(Dispatchers.IO) {
val folders = state.value.folders.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
val updatedFolders = folders.mapIndexed { index, chatFolderRecord ->
chatFolderRecord.copy(position = index)
}
ChatFoldersRepository.updatePositions(updatedFolders)
internalState.update {
it.copy(folders = updatedFolders)
}
}
}
fun updateFolder(context: Context) {
viewModelScope.launch(Dispatchers.IO) {
ChatFoldersRepository.updateFolder(internalState.value.currentFolder)
loadCurrentFolders(context)
internalState.update {
it.copy(showConfirmationDialog = false)
}
}
}
fun setPendingChats() {
viewModelScope.launch(Dispatchers.IO) {
val currentFolder = internalState.value.currentFolder
val includedChats = currentFolder.includedRecipients.map { recipient -> recipient.id }.toMutableSet()
val excludedChats = currentFolder.excludedRecipients.map { recipient -> recipient.id }.toMutableSet()
val chatTypes: MutableSet<ChatType> = mutableSetOf()
if (currentFolder.showIndividualChats) {
chatTypes.add(ChatType.INDIVIDUAL)
}
if (currentFolder.showGroupChats) {
chatTypes.add(ChatType.GROUPS)
}
internalState.update {
it.copy(
pendingIncludedRecipients = includedChats,
pendingExcludedRecipients = excludedChats,
pendingChatTypes = chatTypes
)
}
}
}
fun addIncludedChat(recipientId: RecipientId) {
val includedChats = internalState.value.pendingIncludedRecipients.plus(recipientId)
internalState.update {
it.copy(pendingIncludedRecipients = includedChats)
}
}
fun addExcludedChat(recipientId: RecipientId) {
val excludedChats = internalState.value.pendingExcludedRecipients.plus(recipientId)
internalState.update {
it.copy(pendingExcludedRecipients = excludedChats)
}
}
fun removeIncludedChat(recipientId: RecipientId) {
val includedChats = internalState.value.pendingIncludedRecipients.minus(recipientId)
internalState.update {
it.copy(pendingIncludedRecipients = includedChats)
}
}
fun removeExcludedChat(recipientId: RecipientId) {
val excludedChats = internalState.value.pendingExcludedRecipients.minus(recipientId)
internalState.update {
it.copy(pendingExcludedRecipients = excludedChats)
}
}
fun addChatType(chatType: ChatType) {
val updatedChatTypes = internalState.value.pendingChatTypes.plus(chatType)
internalState.update {
it.copy(
pendingChatTypes = updatedChatTypes
)
}
}
fun removeChatType(chatType: ChatType) {
val updatedChatTypes = internalState.value.pendingChatTypes.minus(chatType)
internalState.update {
it.copy(
pendingChatTypes = updatedChatTypes
)
}
}
fun savePendingChats() {
viewModelScope.launch(Dispatchers.IO) {
val updatedFolder = internalState.value.currentFolder
val includedChatIds = internalState.value.pendingIncludedRecipients
val excludedChatIds = internalState.value.pendingExcludedRecipients
val showIndividualChats = internalState.value.pendingChatTypes.contains(ChatType.INDIVIDUAL)
val showGroupChats = internalState.value.pendingChatTypes.contains(ChatType.GROUPS)
val includedRecipients = includedChatIds.map(Recipient::resolved).toSet()
val excludedRecipients = excludedChatIds.map(Recipient::resolved).toSet()
internalState.update {
it.copy(
currentFolder = updatedFolder.copy(
includedRecipients = includedRecipients,
excludedRecipients = excludedRecipients,
showIndividualChats = showIndividualChats,
showGroupChats = showGroupChats
),
pendingIncludedRecipients = emptySet(),
pendingExcludedRecipients = emptySet()
)
}
}
}
fun enableButton(): Boolean {
return internalState.value.pendingIncludedRecipients.isNotEmpty() ||
internalState.value.pendingChatTypes.isNotEmpty() ||
internalState.value.pendingExcludedRecipients.isNotEmpty()
}
fun hasChanges(): Boolean {
val currentFolder = state.value.currentFolder
val originalFolder = state.value.originalFolder
return if (currentFolder.id == -1L) {
currentFolder.name.isNotEmpty() &&
(currentFolder.includedRecipients.isNotEmpty() || currentFolder.showIndividualChats || currentFolder.showGroupChats)
} else {
originalFolder != currentFolder ||
originalFolder.includedRecipients != currentFolder.includedRecipients ||
originalFolder.excludedRecipients != currentFolder.excludedRecipients
}
}
}

View file

@ -0,0 +1,158 @@
package org.thoughtcrime.securesms.components.settings.app.chats.folders
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.button.MaterialButton
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.ContactSelectionListFragment
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ContactFilterView
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
import org.thoughtcrime.securesms.contacts.SelectedContact
import org.thoughtcrime.securesms.contacts.paged.ChatType
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.ViewUtil
import java.util.Optional
import java.util.function.Consumer
class ChooseChatsFragment : LoggingFragment(), ContactSelectionListFragment.OnContactSelectedListener {
private val viewModel: ChatFoldersViewModel by activityViewModels()
private var includeChatsMode: Boolean = true
private lateinit var contactFilterView: ContactFilterView
private lateinit var doneButton: MaterialButton
private lateinit var selectionFragment: ContactSelectionListFragment
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
includeChatsMode = arguments?.getBoolean(KEY_INCLUDE_CHATS) ?: true
val currentSelection: Set<RecipientId> = if (includeChatsMode) {
viewModel.state.value.pendingExcludedRecipients
} else {
viewModel.state.value.pendingIncludedRecipients
}
childFragmentManager.addFragmentOnAttachListener { _, fragment ->
fragment.arguments = Bundle().apply {
putInt(ContactSelectionListFragment.DISPLAY_MODE, getDefaultDisplayMode())
putBoolean(ContactSelectionListFragment.REFRESHABLE, false)
putBoolean(ContactSelectionListFragment.RECENTS, true)
putParcelable(ContactSelectionListFragment.SELECTION_LIMITS, SelectionLimits.NO_LIMITS)
putParcelableArrayList(ContactSelectionListFragment.CURRENT_SELECTION, ArrayList<RecipientId>(currentSelection))
putBoolean(ContactSelectionListFragment.INCLUDE_CHAT_TYPES, includeChatsMode)
putBoolean(ContactSelectionListFragment.HIDE_COUNT, true)
putBoolean(ContactSelectionListFragment.DISPLAY_CHIPS, true)
putBoolean(ContactSelectionListFragment.CAN_SELECT_SELF, true)
putBoolean(ContactSelectionListFragment.RV_CLIP, false)
putInt(ContactSelectionListFragment.RV_PADDING_BOTTOM, ViewUtil.dpToPx(60))
}
}
return inflater.inflate(R.layout.choose_chats_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
if (includeChatsMode) {
toolbar.setTitle(R.string.CreateFoldersFragment__included_chats)
} else {
toolbar.setTitle(R.string.CreateFoldersFragment__exceptions)
}
toolbar.setNavigationOnClickListener { findNavController().popBackStack() }
selectionFragment = childFragmentManager.findFragmentById(R.id.contact_selection_list) as ContactSelectionListFragment
contactFilterView = view.findViewById(R.id.contact_filter_edit_text)
contactFilterView.setOnFilterChangedListener {
if (it.isNullOrEmpty()) {
selectionFragment.resetQueryFilter()
} else {
selectionFragment.setQueryFilter(it)
}
}
doneButton = view.findViewById(R.id.done_button)
doneButton.setOnClickListener {
viewModel.savePendingChats()
findNavController().popBackStack()
}
updateEnabledButton()
}
override fun onStart() {
super.onStart()
if (includeChatsMode && viewModel.state.value.pendingChatTypes.contains(ChatType.INDIVIDUAL)) {
selectionFragment.markContactSelected(SelectedContact.forChatType(ChatType.INDIVIDUAL))
}
if (includeChatsMode && viewModel.state.value.pendingChatTypes.contains(ChatType.GROUPS)) {
selectionFragment.markContactSelected(SelectedContact.forChatType(ChatType.GROUPS))
}
val activeSelection: Set<RecipientId> = if (includeChatsMode) {
viewModel.state.value.pendingIncludedRecipients
} else {
viewModel.state.value.pendingExcludedRecipients
}
selectionFragment.markSelected(activeSelection)
}
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>, callback: Consumer<Boolean>) {
if (recipientId.isPresent) {
if (includeChatsMode) {
viewModel.addIncludedChat(recipientId.get())
} else {
viewModel.addExcludedChat(recipientId.get())
}
callback.accept(true)
} else if (chatType.isPresent) {
viewModel.addChatType(chatType.get())
callback.accept(true)
} else {
callback.accept(false)
}
updateEnabledButton()
}
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>) {
if (recipientId.isPresent) {
if (includeChatsMode) {
viewModel.removeIncludedChat(recipientId.get())
} else {
viewModel.removeExcludedChat(recipientId.get())
}
} else if (chatType.isPresent) {
viewModel.removeChatType(chatType.get())
}
updateEnabledButton()
}
override fun onSelectionChanged() = Unit
private fun getDefaultDisplayMode(): Int {
return ContactSelectionDisplayMode.FLAG_PUSH or
ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS or
ContactSelectionDisplayMode.FLAG_HIDE_NEW or
ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS or
ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1 or
ContactSelectionDisplayMode.FLAG_SELF
}
private fun updateEnabledButton() {
doneButton.isEnabled = viewModel.enableButton()
}
companion object {
private val TAG = Log.tag(ChooseChatsFragment::class.java)
private val KEY_INCLUDE_CHATS = "include_chats"
}
}

View file

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

View file

@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ContactFilterView
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
import org.thoughtcrime.securesms.contacts.paged.ChatType
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.ViewUtil
@ -106,7 +107,7 @@ class SelectRecipientsFragment : LoggingFragment(), ContactSelectionListFragment
ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1
}
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>, callback: Consumer<Boolean>) {
if (recipientId.isPresent) {
viewModel.select(recipientId.get())
callback.accept(true)
@ -116,7 +117,7 @@ class SelectRecipientsFragment : LoggingFragment(), ContactSelectionListFragment
}
}
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?) {
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>) {
if (recipientId.isPresent) {
viewModel.deselect(recipientId.get())
updateAddToProfile()

View file

@ -20,9 +20,9 @@ import org.thoughtcrime.securesms.util.rx.RxStore
*/
class ContactChipViewModel : ViewModel() {
private val store = RxStore(emptyList<SelectedContacts.Model>())
private val store = RxStore(emptyList<SelectedContacts.Model<*>>())
val state: Flowable<List<SelectedContacts.Model>> = store.stateFlowable
val state: Flowable<List<SelectedContacts.Model<*>>> = store.stateFlowable
.distinctUntilChanged()
.observeOn(AndroidSchedulers.mainThread())
@ -39,20 +39,27 @@ class ContactChipViewModel : ViewModel() {
}
fun add(selectedContact: SelectedContact) {
disposables += getOrCreateRecipientId(selectedContact).map { Recipient.resolved(it) }.observeOn(Schedulers.io()).subscribe { recipient ->
store.update { it + SelectedContacts.Model(selectedContact, recipient) }
disposableMap[recipient.id]?.dispose()
disposableMap[recipient.id] = store.update(recipient.live().observable().toFlowable(BackpressureStrategy.LATEST)) { changedRecipient, state ->
val index = state.indexOfFirst { it.selectedContact.matches(selectedContact) }
when {
index == 0 -> {
listOf(SelectedContacts.Model(selectedContact, changedRecipient)) + state.drop(index + 1)
}
index > 0 -> {
state.take(index) + SelectedContacts.Model(selectedContact, changedRecipient) + state.drop(index + 1)
}
else -> {
state
if (selectedContact.hasChatType()) {
store.update { it + SelectedContacts.ChatTypeModel(selectedContact) }
} else {
disposables += getOrCreateRecipientId(selectedContact).map { Recipient.resolved(it) }.observeOn(Schedulers.io()).subscribe { recipient ->
store.update { it + SelectedContacts.RecipientModel(selectedContact, recipient) }
disposableMap[recipient.id]?.dispose()
disposableMap[recipient.id] = store.update(recipient.live().observable().toFlowable(BackpressureStrategy.LATEST)) { changedRecipient, state ->
val index = state.indexOfFirst { it.selectedContact.matches(selectedContact) }
when {
index == 0 -> {
listOf(SelectedContacts.RecipientModel(selectedContact, changedRecipient)) + state.drop(index + 1)
}
index > 0 -> {
state.take(index) + SelectedContacts.RecipientModel(selectedContact, changedRecipient) + state.drop(index + 1)
}
else -> {
state
}
}
}
}

View file

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

View file

@ -1,8 +1,12 @@
package org.thoughtcrime.securesms.contacts
import android.content.res.ColorStateList
import android.view.View
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import com.bumptech.glide.Glide
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ChatType
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@ -11,25 +15,28 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
object SelectedContacts {
@JvmStatic
fun register(adapter: MappingAdapter, onCloseIconClicked: (Model) -> Unit) {
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onCloseIconClicked) }, R.layout.contact_selection_list_chip))
fun register(adapter: MappingAdapter, onCloseIconClicked: (Model<*>) -> Unit) {
adapter.registerFactory(RecipientModel::class.java, LayoutFactory({ RecipientViewHolder(it, onCloseIconClicked) }, R.layout.contact_selection_list_chip))
adapter.registerFactory(ChatTypeModel::class.java, LayoutFactory({ ChatTypeViewHolder(it, onCloseIconClicked) }, R.layout.contact_selection_list_chip))
}
class Model(val selectedContact: SelectedContact, val recipient: Recipient) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean {
sealed class Model<T : Any>(val selectedContact: SelectedContact) : MappingModel<T>
class RecipientModel(selectedContact: SelectedContact, val recipient: Recipient) : Model<RecipientModel>(selectedContact = selectedContact) {
override fun areItemsTheSame(newItem: RecipientModel): Boolean {
return newItem.selectedContact.matches(selectedContact) && recipient == newItem.recipient
}
override fun areContentsTheSame(newItem: Model): Boolean {
override fun areContentsTheSame(newItem: RecipientModel): Boolean {
return areItemsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
}
}
private class ViewHolder(itemView: View, private val onCloseIconClicked: (Model) -> Unit) : MappingViewHolder<Model>(itemView) {
private class RecipientViewHolder(itemView: View, private val onCloseIconClicked: (RecipientModel) -> Unit) : MappingViewHolder<RecipientModel>(itemView) {
private val chip: ContactChip = itemView.findViewById(R.id.contact_chip)
override fun bind(model: Model) {
override fun bind(model: RecipientModel) {
chip.text = model.recipient.getShortDisplayName(context)
chip.setContact(model.selectedContact)
chip.isCloseIconVisible = true
@ -39,4 +46,36 @@ object SelectedContacts {
chip.setAvatar(Glide.with(itemView), model.recipient, null)
}
}
class ChatTypeModel(selectedContact: SelectedContact) : Model<ChatTypeModel>(selectedContact = selectedContact) {
override fun areItemsTheSame(newItem: ChatTypeModel): Boolean {
return newItem.selectedContact.matches(selectedContact) && newItem.selectedContact.chatType == selectedContact.chatType
}
override fun areContentsTheSame(newItem: ChatTypeModel): Boolean {
return areItemsTheSame(newItem)
}
}
private class ChatTypeViewHolder(itemView: View, private val onCloseIconClicked: (ChatTypeModel) -> Unit) : MappingViewHolder<ChatTypeModel>(itemView) {
private val chip: ContactChip = itemView.findViewById(R.id.contact_chip)
override fun bind(model: ChatTypeModel) {
if (model.selectedContact.chatType == ChatType.INDIVIDUAL) {
chip.text = context.getString(R.string.ChatFoldersFragment__one_on_one_chats)
chip.chipIcon = AppCompatResources.getDrawable(context, R.drawable.symbol_person_light_24)
chip.chipIconTint = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.signal_colorOnSurface))
} else {
chip.text = context.getString(R.string.ChatFoldersFragment__groups)
chip.chipIcon = AppCompatResources.getDrawable(context, R.drawable.symbol_group_light_20)
chip.chipIconTint = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.signal_colorOnSurface))
}
chip.setContact(model.selectedContact)
chip.isCloseIconVisible = true
chip.setOnCloseIconClickListener {
onCloseIconClicked(model)
}
}
}
}

View file

@ -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
}

View file

@ -5,6 +5,7 @@ import android.text.SpannableStringBuilder
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.ContextCompat
@ -57,6 +58,7 @@ open class ContactSearchAdapter(
registerKnownRecipientItems(this, fixedContacts, displayOptions, onClickCallbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick, callButtonClickCallbacks)
registerHeaders(this)
registerExpands(this, onClickCallbacks::onExpandClicked)
registerChatTypeItems(this, onClickCallbacks::onChatTypeClicked)
registerFactory(UnknownRecipientModel::class.java, LayoutFactory({ UnknownRecipientViewHolder(it, onClickCallbacks::onUnknownRecipientClicked, displayOptions.displayCheckBox) }, R.layout.contact_search_unknown_item))
}
@ -117,6 +119,13 @@ open class ContactSearchAdapter(
)
}
fun registerChatTypeItems(mappingAdapter: MappingAdapter, chatTypeRowListener: OnClickedCallback<ContactSearchData.ChatTypeRow>) {
mappingAdapter.registerFactory(
ChatTypeModel::class.java,
LayoutFactory({ ChatTypeViewHolder(it, chatTypeRowListener) }, R.layout.contact_search_chat_type_item)
)
}
fun toMappingModelList(contactSearchData: List<ContactSearchData?>, selection: Set<ContactSearchKey>, arbitraryRepository: ArbitraryRepository?): MappingModelList {
return MappingModelList(
contactSearchData.filterNotNull().map {
@ -132,6 +141,7 @@ open class ContactSearchAdapter(
is ContactSearchData.Empty -> EmptyModel(it)
is ContactSearchData.GroupWithMembers -> GroupWithMembersModel(it)
is ContactSearchData.UnknownRecipient -> UnknownRecipientModel(it)
is ContactSearchData.ChatTypeRow -> ChatTypeModel(it, selection.contains(it.contactSearchKey))
}
}
)
@ -675,6 +685,7 @@ open class ContactSearchAdapter(
ContactSearchConfiguration.SectionKey.MESSAGES -> R.string.ContactsCursorLoader__messages
ContactSearchConfiguration.SectionKey.GROUPS_WITH_MEMBERS -> R.string.ContactsCursorLoader_group_members
ContactSearchConfiguration.SectionKey.CONTACTS_WITHOUT_THREADS -> R.string.ContactsCursorLoader_contacts
ContactSearchConfiguration.SectionKey.CHAT_TYPES -> R.string.ContactsCursorLoader__chat_types
else -> error("This section does not support HEADER")
}
)
@ -712,6 +723,42 @@ open class ContactSearchAdapter(
}
}
/**
* Mapping Model for chat types.
*/
class ChatTypeModel(val data: ContactSearchData.ChatTypeRow, val isSelected: Boolean) : MappingModel<ChatTypeModel> {
override fun areItemsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data
override fun areContentsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data && isSelected == newItem.isSelected
}
/**
* View Holder for chat types
*/
private class ChatTypeViewHolder(
itemView: View,
val onClick: OnClickedCallback<ContactSearchData.ChatTypeRow>
) : MappingViewHolder<ChatTypeModel>(itemView) {
val image: ImageView = itemView.findViewById(R.id.image)
val name: TextView = itemView.findViewById(R.id.name)
val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
override fun bind(model: ChatTypeModel) {
itemView.setOnClickListener { onClick.onClicked(itemView, model.data, model.isSelected) }
image.setImageResource(model.data.imageResId)
if (model.data.chatType == ChatType.INDIVIDUAL) {
name.text = context.getString(R.string.ChatFoldersFragment__one_on_one_chats)
}
if (model.data.chatType == ChatType.GROUPS) {
name.text = context.getString(R.string.ChatFoldersFragment__groups)
}
checkbox.isChecked = model.isSelected
}
}
private class IsSelfComparator : Comparator<Recipient> {
override fun compare(lhs: Recipient?, rhs: Recipient?): Int {
val isLeftSelf = lhs?.isSelf == true
@ -764,6 +811,7 @@ open class ContactSearchAdapter(
fun onUnknownRecipientClicked(view: View, unknownRecipient: ContactSearchData.UnknownRecipient, isSelected: Boolean) {
throw NotImplementedError()
}
fun onChatTypeClicked(view: View, chatTypeRow: ContactSearchData.ChatTypeRow, isSelected: Boolean)
}
interface CallButtonClickCallbacks {

View file

@ -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)
*/

View file

@ -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.
*/

View file

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

View file

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

View file

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.contacts.paged
import android.database.Cursor
import org.signal.core.util.requireLong
import org.signal.paging.PagedDataSource
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.ContactRepository
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchCollection
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator
@ -142,6 +143,7 @@ class ContactSearchPagedDataSource(
is ContactSearchConfiguration.Section.PhoneNumber -> if (isPossiblyPhoneNumber(query)) 1 else 0
is ContactSearchConfiguration.Section.Username -> if (isPossiblyUsername(query)) 1 else 0
is ContactSearchConfiguration.Section.Empty -> 1
is ContactSearchConfiguration.Section.ChatTypes -> getChatTypesData(section).size
}
}
@ -181,6 +183,7 @@ class ContactSearchPagedDataSource(
is ContactSearchConfiguration.Section.PhoneNumber -> getPossiblePhoneNumber(section, query)
is ContactSearchConfiguration.Section.Username -> getPossibleUsername(section, query)
is ContactSearchConfiguration.Section.Empty -> listOf(ContactSearchData.Empty(query))
is ContactSearchConfiguration.Section.ChatTypes -> getChatTypesData(section)
}
}
@ -348,6 +351,22 @@ class ContactSearchPagedDataSource(
}
}
// TODO [michelle]: Replace hardcoding chat types after building db
private fun getChatTypesData(section: ContactSearchConfiguration.Section.ChatTypes): List<ContactSearchData> {
val data = mutableListOf<ContactSearchData>()
if (section.includeHeader) {
data.add(ContactSearchData.Header(section.sectionKey, section.headerAction))
}
data.addAll(
listOf(
ContactSearchData.ChatTypeRow(R.drawable.symbol_person_light_24, ChatType.INDIVIDUAL),
ContactSearchData.ChatTypeRow(R.drawable.symbol_group_light_20, ChatType.GROUPS)
)
)
return data
}
private fun getContactsWithoutThreadsContactData(section: ContactSearchConfiguration.Section.ContactsWithoutThreads, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getContactsWithoutThreadsIterator(query).use { records ->
readContactData(

View file

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

View file

@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.conversationlist
import android.content.Context
import android.content.res.ColorStateList
import android.view.View
import android.widget.TextView
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
/**
* RecyclerView adapter for the chat folders displayed on conversation list
*/
class ChatFolderAdapter(val callbacks: Callbacks) : MappingAdapter() {
init {
registerFactory(ChatFolderMappingModel::class.java, LayoutFactory({ v -> ViewHolder(v, callbacks) }, R.layout.chat_folder_item))
}
class ViewHolder(itemView: View, private val callbacks: Callbacks) : MappingViewHolder<ChatFolderMappingModel>(itemView) {
private val name: TextView = findViewById(R.id.name)
private val unreadCount: TextView = findViewById(R.id.unread_count)
override fun bind(model: ChatFolderMappingModel) {
itemView.isSelected = model.isSelected
val folder = model.chatFolder
name.text = getName(itemView.context, folder)
unreadCount.visible = folder.unreadCount > 0
unreadCount.text = folder.unreadCount.toString()
itemView.setOnClickListener {
callbacks.onChatFolderClicked(model.chatFolder)
}
if (model.isSelected) {
itemView.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(itemView.context, R.color.signal_colorSurfaceVariant))
} else {
itemView.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(itemView.context, R.color.transparent))
}
}
private fun getName(context: Context, folder: ChatFolderRecord): String {
return if (folder.folderType == ChatFolderRecord.FolderType.ALL) {
context.getString(R.string.ChatFoldersFragment__all_chats)
} else {
folder.name
}
}
}
interface Callbacks {
fun onChatFolderClicked(chatFolder: ChatFolderRecord)
}
}

View file

@ -0,0 +1,17 @@
package org.thoughtcrime.securesms.conversationlist
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
data class ChatFolderMappingModel(
val chatFolder: ChatFolderRecord,
val isSelected: Boolean
) : MappingModel<ChatFolderMappingModel> {
override fun areItemsTheSame(newItem: ChatFolderMappingModel): Boolean {
return chatFolder == newItem.chatFolder
}
override fun areContentsTheSame(newItem: ChatFolderMappingModel): Boolean {
return areItemsTheSame(newItem) && isSelected == newItem.isSelected
}
}

View file

@ -47,6 +47,7 @@ public class ConversationListArchiveFragment extends ConversationListFragment im
{
private View coordinator;
private RecyclerView list;
private RecyclerView foldersList;
private Stub<View> emptyState;
private PulsingFloatingActionButton fab;
private PulsingFloatingActionButton cameraFab;
@ -73,12 +74,14 @@ public class ConversationListArchiveFragment extends ConversationListFragment im
emptyState = new Stub<>(view.findViewById(R.id.empty_state));
fab = view.findViewById(R.id.fab);
cameraFab = view.findViewById(R.id.camera_fab);
foldersList = view.findViewById(R.id.chat_folder_list);
toolbar.get().setNavigationOnClickListener(v -> NavHostFragment.findNavController(this).popBackStack());
toolbar.get().setTitle(R.string.AndroidManifest_archived_conversations);
fab.hide();
cameraFab.hide();
foldersList.setVisibility(View.GONE);
}
@Override

View file

@ -11,6 +11,7 @@ import androidx.annotation.VisibleForTesting;
import org.signal.core.util.Stopwatch;
import org.signal.core.util.logging.Log;
import org.signal.paging.PagedDataSource;
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
import org.thoughtcrime.securesms.conversationlist.model.ConversationReader;
@ -40,16 +41,18 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
protected final ThreadTable threadTable;
protected final ConversationFilter conversationFilter;
protected final boolean showConversationFooterTip;
protected final ChatFolderRecord chatFolder;
protected ConversationListDataSource(@NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
protected ConversationListDataSource(ChatFolderRecord chatFolder, @NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
this.chatFolder = chatFolder;
this.threadTable = SignalDatabase.threads();
this.conversationFilter = conversationFilter;
this.showConversationFooterTip = showConversationFooterTip;
}
public static ConversationListDataSource create(@NonNull ConversationFilter conversationFilter, boolean isArchived, boolean showConversationFooterTip) {
if (!isArchived) return new UnarchivedConversationListDataSource(conversationFilter, showConversationFooterTip);
else return new ArchivedConversationListDataSource(conversationFilter, showConversationFooterTip);
public static ConversationListDataSource create(ChatFolderRecord chatFolder, @NonNull ConversationFilter conversationFilter, boolean isArchived, boolean showConversationFooterTip) {
if (!isArchived) return new UnarchivedConversationListDataSource(chatFolder, conversationFilter, showConversationFooterTip);
else return new ArchivedConversationListDataSource(chatFolder, conversationFilter, showConversationFooterTip);
}
@Override
@ -136,8 +139,8 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
private int totalCount;
ArchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
super(conversationFilter, showConversationFooterTip);
ArchivedConversationListDataSource(@NonNull ChatFolderRecord chatFolder, @NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
super(chatFolder, conversationFilter, showConversationFooterTip);
}
@Override
@ -168,33 +171,23 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
private int totalCount;
private int pinnedCount;
private int archivedCount;
private int unpinnedCount;
UnarchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
super(conversationFilter, showConversationFooterTip);
UnarchivedConversationListDataSource(@NonNull ChatFolderRecord chatFolder, @NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
super(chatFolder, conversationFilter, showConversationFooterTip);
}
@Override
protected int getTotalCount() {
int unarchivedCount = threadTable.getUnarchivedConversationListCount(conversationFilter);
int unarchivedCount = threadTable.getUnarchivedConversationListCount(conversationFilter, chatFolder);
pinnedCount = threadTable.getPinnedConversationListCount(conversationFilter);
pinnedCount = threadTable.getPinnedConversationListCount(conversationFilter, chatFolder);
archivedCount = threadTable.getArchivedConversationListCount(conversationFilter);
unpinnedCount = unarchivedCount - pinnedCount;
totalCount = unarchivedCount;
if (archivedCount != 0) {
if (chatFolder.getFolderType() == ChatFolderRecord.FolderType.ALL && archivedCount != 0) {
totalCount++;
}
if (pinnedCount != 0) {
if (unpinnedCount != 0) {
totalCount += 2;
} else {
totalCount += 1;
}
}
return totalCount;
}
@ -203,26 +196,12 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
List<Cursor> cursors = new ArrayList<>(5);
long originalLimit = limit;
if (offset == 0 && hasPinnedHeader()) {
MatrixCursor pinnedHeaderCursor = new MatrixCursor(ConversationReader.HEADER_COLUMN);
pinnedHeaderCursor.addRow(ConversationReader.PINNED_HEADER);
cursors.add(pinnedHeaderCursor);
limit--;
}
Cursor pinnedCursor = threadTable.getUnarchivedConversationList(conversationFilter, true, offset, limit);
Cursor pinnedCursor = threadTable.getUnarchivedConversationList(conversationFilter, true, offset, limit, chatFolder);
cursors.add(pinnedCursor);
limit -= pinnedCursor.getCount();
if (offset == 0 && hasUnpinnedHeader()) {
MatrixCursor unpinnedHeaderCursor = new MatrixCursor(ConversationReader.HEADER_COLUMN);
unpinnedHeaderCursor.addRow(ConversationReader.UNPINNED_HEADER);
cursors.add(unpinnedHeaderCursor);
limit--;
}
long unpinnedOffset = Math.max(0, offset - pinnedCount - getHeaderOffset());
Cursor unpinnedCursor = threadTable.getUnarchivedConversationList(conversationFilter, false, unpinnedOffset, limit);
long unpinnedOffset = Math.max(0, offset - pinnedCount);
Cursor unpinnedCursor = threadTable.getUnarchivedConversationList(conversationFilter, false, unpinnedOffset, limit, chatFolder);
cursors.add(unpinnedCursor);
boolean shouldInsertConversationFilterFooter = offset + originalLimit >= totalCount && hasConversationFilterFooter();
@ -242,24 +221,9 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
return new MergeCursor(cursors.toArray(new Cursor[]{}));
}
@VisibleForTesting
int getHeaderOffset() {
return (hasPinnedHeader() ? 1 : 0) + (hasUnpinnedHeader() ? 1 : 0);
}
@VisibleForTesting
boolean hasPinnedHeader() {
return pinnedCount != 0;
}
@VisibleForTesting
boolean hasUnpinnedHeader() {
return hasPinnedHeader() && unpinnedCount != 0;
}
@VisibleForTesting
boolean hasArchivedFooter() {
return archivedCount != 0;
return archivedCount != 0 && chatFolder.getFolderType() == ChatFolderRecord.FolderType.ALL;
}
boolean hasConversationFilterFooter() {

View file

@ -114,6 +114,7 @@ import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar;
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord;
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment;
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation;
@ -167,6 +168,7 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalProxyUtil;
@ -200,7 +202,8 @@ import static android.app.Activity.RESULT_OK;
public class ConversationListFragment extends MainFragment implements ActionMode.Callback,
ConversationListAdapter.OnConversationClickListener,
MegaphoneActionController,
ClearFilterViewHolder.OnClearFilterClickListener
ClearFilterViewHolder.OnClearFilterClickListener,
ChatFolderAdapter.Callbacks
{
public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562;
public static final short SMS_ROLE_REQUEST_CODE = 32563;
@ -216,6 +219,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private ActionMode actionMode;
private View coordinator;
private RecyclerView chatFolderList;
private RecyclerView list;
private Stub<ComposeView> bannerView;
private PulsingFloatingActionButton fab;
@ -236,6 +240,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private SignalBottomActionBar bottomActionBar;
private SignalContextMenu activeContextMenu;
private LifecycleDisposable lifecycleDisposable;
private ChatFolderAdapter chatFolderAdapter;
protected ConversationListArchiveItemDecoration archiveDecoration;
protected ConversationListItemAnimator itemAnimator;
@ -281,6 +286,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
lifecycleDisposable.bindTo(getViewLifecycleOwner());
coordinator = view.findViewById(R.id.coordinator);
chatFolderList = view.findViewById(R.id.chat_folder_list);
list = view.findViewById(R.id.list);
bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar);
bannerView = new Stub<>(view.findViewById(R.id.banner_compose_view));
@ -293,6 +299,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
fab.setVisibility(View.VISIBLE);
cameraFab.setVisibility(View.VISIBLE);
chatFolderList.setVisibility(RemoteConfig.internalUser() ? View.VISIBLE : View.GONE);
contactSearchMediator = new ContactSearchMediator(this,
Collections.emptySet(),
@ -381,6 +388,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode
archiveDecoration = new ConversationListArchiveItemDecoration(new ColorDrawable(getResources().getColor(R.color.conversation_list_archive_background_end)));
itemAnimator = new ConversationListItemAnimator();
chatFolderAdapter = new ChatFolderAdapter(this);
chatFolderList.setLayoutManager(new LinearLayoutManager(requireActivity(), LinearLayoutManager.HORIZONTAL, false));
chatFolderList.setAdapter(chatFolderAdapter);
chatFolderList.setItemAnimator(null);
list.setLayoutManager(new LinearLayoutManager(requireActivity()));
list.setItemAnimator(itemAnimator);
list.addItemDecoration(archiveDecoration);
@ -972,6 +985,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
lifecycleDisposable.add(viewModel.getHasNoConversations().subscribe(this::updateEmptyState));
lifecycleDisposable.add(viewModel.getNotificationProfiles().subscribe(profiles -> requireCallback().updateNotificationProfileStatus(profiles)));
lifecycleDisposable.add(viewModel.getWebSocketState().subscribe(pipeState -> requireCallback().updateProxyStatus(pipeState)));
lifecycleDisposable.add(viewModel.getChatFolderState().subscribe(this::onChatFoldersChanged));
appForegroundObserver = new AppForegroundObserver.Listener() {
@Override
@ -1031,6 +1045,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
});
}
private void onChatFoldersChanged(List<ChatFolderMappingModel> folders) {
chatFolderAdapter.submitList(new ArrayList<>(folders));
}
private void onMegaphoneChanged(@NonNull Megaphone megaphone) {
if (megaphone == Megaphone.NONE || isArchived() || getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
if (megaphoneContainer.resolved()) {
@ -1643,6 +1661,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
pullViewAppBarLayout.setExpanded(false, true);
}
@Override
public void onChatFolderClicked(@NonNull ChatFolderRecord chatFolder) {
viewModel.select(chatFolder);
}
private class ArchiveListenerCallback extends ItemTouchHelper.SimpleCallback {
private static final long SWIPE_ANIMATION_DURATION = 175;
@ -1887,6 +1910,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
throw new UnsupportedOperationException();
}
}
public interface Callback extends Material3OnScrollHelperBinder, SearchBinder {

View file

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

View file

@ -2,16 +2,22 @@ package org.thoughtcrime.securesms.conversationlist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.Flowables
import io.reactivex.rxjava3.kotlin.addTo
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.signal.paging.PagedData
import org.signal.paging.PagingConfig
import org.signal.paging.ProxyPagingController
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFoldersRepository
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource
@ -53,10 +59,15 @@ class ConversationListViewModel(
val megaphoneState: Flowable<Megaphone> = store.mapDistinctForUi { it.megaphone }
val selectedState: Flowable<ConversationSet> = store.mapDistinctForUi { it.selectedConversations }
val filterRequestState: Flowable<ConversationFilterRequest> = store.mapDistinctForUi { it.filterRequest }
val chatFolderState: Flowable<List<ChatFolderMappingModel>> = store.mapDistinctForUi { it.chatFolders }
val hasNoConversations: Flowable<Boolean>
val controller = ProxyPagingController<Long>()
val folders: List<ChatFolderMappingModel>
get() = store.state.chatFolders
val currentFolder: ChatFolderRecord
get() = store.state.currentFolder
val conversationFilterRequest: ConversationFilterRequest
get() = store.state.filterRequest
val megaphone: Megaphone
@ -74,13 +85,14 @@ class ConversationListViewModel(
conversationListDataSource = store
.stateFlowable
.subscribeOn(Schedulers.io())
.map { it.filterRequest }
.map { it.filterRequest to it.currentFolder }
.distinctUntilChanged()
.map {
.map { (filterRequest, folder) ->
ConversationListDataSource.create(
it.filter,
folder,
filterRequest.filter,
isArchived,
SignalStore.uiHints.canDisplayPullToFilterTip() && it.source === ConversationFilterSource.OVERFLOW
SignalStore.uiHints.canDisplayPullToFilterTip() && filterRequest.source === ConversationFilterSource.OVERFLOW
)
}
.replay(1)
@ -100,6 +112,17 @@ class ConversationListViewModel(
.subscribe { controller.onDataInvalidated() }
.addTo(disposables)
Flowables.combineLatest(
RxDatabaseObserver
.conversationList
.debounce(250, TimeUnit.MILLISECONDS),
RxDatabaseObserver
.chatFolders
.throttleLatest(500, TimeUnit.MILLISECONDS)
)
.subscribe { loadCurrentFolders() }
.addTo(disposables)
val pinnedCount = RxDatabaseObserver
.conversationList
.map { SignalDatabase.threads.getPinnedConversationListCount(ConversationFilter.OFF) }
@ -191,6 +214,28 @@ class ConversationListViewModel(
megaphoneRepository.markVisible(visible.event)
}
private fun loadCurrentFolders() {
viewModelScope.launch(Dispatchers.IO) {
val folders = ChatFoldersRepository.getCurrentFolders(includeUnreadCount = true)
val selectedFolderId = if (currentFolder.id == -1L) {
folders.firstOrNull()?.id
} else {
currentFolder.id
}
val chatFolders = folders.map { folder ->
ChatFolderMappingModel(folder, selectedFolderId == folder.id)
}
store.update {
it.copy(
currentFolder = folders.find { folder -> folder.id == selectedFolderId } ?: ChatFolderRecord(),
chatFolders = chatFolders
)
}
}
}
fun getNotificationProfiles(): Flowable<List<NotificationProfile>> {
return notificationProfilesRepository.getProfiles()
.observeOn(AndroidSchedulers.mainThread())
@ -203,7 +248,20 @@ class ConversationListViewModel(
}
}
fun select(chatFolder: ChatFolderRecord) {
store.update {
it.copy(
currentFolder = chatFolder,
chatFolders = folders.map { model ->
model.copy(isSelected = chatFolder.id == model.chatFolder.id)
}
)
}
}
private data class ConversationListState(
val chatFolders: List<ChatFolderMappingModel> = emptyList(),
val currentFolder: ChatFolderRecord = ChatFolderRecord(),
val conversations: List<Conversation> = emptyList(),
val megaphone: Megaphone = Megaphone.NONE,
val selectedConversations: ConversationSet = ConversationSet(),

View file

@ -0,0 +1,321 @@
package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import androidx.core.content.contentValuesOf
import org.signal.core.util.SqlUtil
import org.signal.core.util.delete
import org.signal.core.util.groupBy
import org.signal.core.util.insertInto
import org.signal.core.util.readToList
import org.signal.core.util.readToSingleInt
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireString
import org.signal.core.util.select
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
/**
* Stores chat folders and the chats that belong in each chat folder
*/
class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : DatabaseTable(context, databaseHelper), ThreadIdDatabaseReference {
companion object {
@JvmField
val CREATE_TABLE: Array<String> = arrayOf(ChatFolderTable.CREATE_TABLE, ChatFolderMembershipTable.CREATE_TABLE)
@JvmField
val CREATE_INDEXES: Array<String> = ChatFolderTable.CREATE_INDEX + ChatFolderMembershipTable.CREATE_INDEXES
fun insertInitialChatFoldersAtCreationTime(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
db.insert(ChatFolderTable.TABLE_NAME, null, getAllChatsFolderContentValues())
}
private fun getAllChatsFolderContentValues(): ContentValues {
return contentValuesOf(
ChatFolderTable.POSITION to 0,
ChatFolderTable.FOLDER_TYPE to ChatFolderRecord.FolderType.ALL.value,
ChatFolderTable.SHOW_INDIVIDUAL to 1,
ChatFolderTable.SHOW_GROUPS to 1,
ChatFolderTable.SHOW_MUTED to 1
)
}
}
/**
* Represents the components of a chat folder and any chat types it contains
*/
object ChatFolderTable {
const val TABLE_NAME = "chat_folder"
const val ID = "_id"
const val NAME = "name"
const val POSITION = "position"
const val SHOW_UNREAD = "show_unread"
const val SHOW_MUTED = "show_muted"
const val SHOW_INDIVIDUAL = "show_individual"
const val SHOW_GROUPS = "show_groups"
const val IS_MUTED = "is_muted"
const val FOLDER_TYPE = "folder_type"
val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$NAME TEXT DEFAULT NULL,
$POSITION INTEGER DEFAULT 0,
$SHOW_UNREAD INTEGER DEFAULT 0,
$SHOW_MUTED INTEGER DEFAULT 0,
$SHOW_INDIVIDUAL INTEGER DEFAULT 0,
$SHOW_GROUPS INTEGER DEFAULT 0,
$IS_MUTED INTEGER DEFAULT 0,
$FOLDER_TYPE INTEGER DEFAULT ${ChatFolderRecord.FolderType.CUSTOM.value}
)
"""
val CREATE_INDEX = arrayOf(
"CREATE INDEX chat_folder_position_index ON $TABLE_NAME ($POSITION)"
)
}
/**
* Represents a thread that is associated with this chat folder. They are
* either included in the chat folder or explicitly excluded.
*/
object ChatFolderMembershipTable {
const val TABLE_NAME = "chat_folder_membership"
const val ID = "_id"
const val CHAT_FOLDER_ID = "chat_folder_id"
const val THREAD_ID = "thread_id"
const val MEMBERSHIP_TYPE = "membership_type"
const val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$CHAT_FOLDER_ID INTEGER NOT NULL REFERENCES ${ChatFolderTable.TABLE_NAME} (${ChatFolderTable.ID}) ON DELETE CASCADE,
$THREAD_ID INTEGER NOT NULL REFERENCES ${ThreadTable.TABLE_NAME} (${ThreadTable.ID}) ON DELETE CASCADE,
$MEMBERSHIP_TYPE INTEGER DEFAULT 1
)
"""
val CREATE_INDEXES = arrayOf(
"CREATE INDEX chat_folder_membership_chat_folder_id_index ON $TABLE_NAME ($CHAT_FOLDER_ID)",
"CREATE INDEX chat_folder_membership_thread_id_index ON $TABLE_NAME ($THREAD_ID)",
"CREATE INDEX chat_folder_membership_membership_type_index ON $TABLE_NAME ($MEMBERSHIP_TYPE)"
)
}
override fun remapThread(fromId: Long, toId: Long) {
writableDatabase
.update(ChatFolderMembershipTable.TABLE_NAME)
.values(ChatFolderMembershipTable.THREAD_ID to toId)
.where("${ChatFolderMembershipTable.THREAD_ID} = ?", fromId)
.run()
}
/**
* Maps the chat folder ids to its corresponding chat folder
*/
fun getChatFolders(includeUnreads: Boolean = false): List<ChatFolderRecord> {
val includedChats: Map<Long, List<Long>> = getIncludedChats()
val excludedChats: Map<Long, List<Long>> = getExcludedChats()
val folders = readableDatabase
.select()
.from(ChatFolderTable.TABLE_NAME)
.orderBy(ChatFolderTable.POSITION)
.run()
.readToList { cursor ->
val id = cursor.requireLong(ChatFolderTable.ID)
ChatFolderRecord(
id = id,
name = cursor.requireString(ChatFolderTable.NAME) ?: "",
position = cursor.requireInt(ChatFolderTable.POSITION),
showUnread = cursor.requireBoolean(ChatFolderTable.SHOW_UNREAD),
showMutedChats = cursor.requireBoolean(ChatFolderTable.SHOW_MUTED),
showIndividualChats = cursor.requireBoolean(ChatFolderTable.SHOW_INDIVIDUAL),
showGroupChats = cursor.requireBoolean(ChatFolderTable.SHOW_GROUPS),
isMuted = cursor.requireBoolean(ChatFolderTable.IS_MUTED),
folderType = ChatFolderRecord.FolderType.deserialize(cursor.requireInt(ChatFolderTable.FOLDER_TYPE)),
includedChats = includedChats[id] ?: emptyList(),
excludedChats = excludedChats[id] ?: emptyList()
)
}
if (includeUnreads) {
return folders.map { folder ->
folder.copy(
unreadCount = SignalDatabase.threads.getUnreadCountByChatFolder(folder)
)
}
}
return folders
}
/**
* Maps chat folder ids to all of its corresponding included chats
*/
private fun getIncludedChats(): Map<Long, List<Long>> {
return readableDatabase
.select()
.from(ChatFolderMembershipTable.TABLE_NAME)
.where("${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.INCLUDED.value}")
.run()
.groupBy { cursor ->
cursor.requireLong(ChatFolderMembershipTable.CHAT_FOLDER_ID) to cursor.requireLong(ChatFolderMembershipTable.THREAD_ID)
}
}
/**
* Maps the chat folder ids to all of its corresponding excluded chats
*/
private fun getExcludedChats(): Map<Long, List<Long>> {
return readableDatabase
.select()
.from(ChatFolderMembershipTable.TABLE_NAME)
.where("${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.EXCLUDED.value}")
.run()
.groupBy { cursor ->
cursor.requireLong(ChatFolderMembershipTable.CHAT_FOLDER_ID) to cursor.requireLong(ChatFolderMembershipTable.THREAD_ID)
}
}
/**
* Adds a chat folder and its corresponding included/excluded chats
*/
fun createFolder(chatFolder: ChatFolderRecord) {
writableDatabase.withinTransaction { db ->
val position: Int = db
.select("MAX(${ChatFolderTable.POSITION})")
.from(ChatFolderTable.TABLE_NAME)
.run()
.readToSingleInt(0) + 1
val id = db.insertInto(ChatFolderTable.TABLE_NAME)
.values(
contentValuesOf(
ChatFolderTable.NAME to chatFolder.name,
ChatFolderTable.SHOW_UNREAD to chatFolder.showUnread,
ChatFolderTable.SHOW_MUTED to chatFolder.showMutedChats,
ChatFolderTable.SHOW_INDIVIDUAL to chatFolder.showIndividualChats,
ChatFolderTable.SHOW_GROUPS to chatFolder.showGroupChats,
ChatFolderTable.IS_MUTED to chatFolder.isMuted,
ChatFolderTable.POSITION to position
)
)
.run(SQLiteDatabase.CONFLICT_IGNORE)
val includedChatsQueries = SqlUtil.buildBulkInsert(
ChatFolderMembershipTable.TABLE_NAME,
arrayOf(ChatFolderMembershipTable.CHAT_FOLDER_ID, ChatFolderMembershipTable.THREAD_ID, ChatFolderMembershipTable.MEMBERSHIP_TYPE),
chatFolder.includedChats.toContentValues(chatFolderId = id, membershipType = MembershipType.INCLUDED)
)
val excludedChatsQueries = SqlUtil.buildBulkInsert(
ChatFolderMembershipTable.TABLE_NAME,
arrayOf(ChatFolderMembershipTable.CHAT_FOLDER_ID, ChatFolderMembershipTable.THREAD_ID, ChatFolderMembershipTable.MEMBERSHIP_TYPE),
chatFolder.excludedChats.toContentValues(chatFolderId = id, membershipType = MembershipType.EXCLUDED)
)
includedChatsQueries.forEach {
db.execSQL(it.where, it.whereArgs)
}
excludedChatsQueries.forEach {
db.execSQL(it.where, it.whereArgs)
}
AppDependencies.databaseObserver.notifyChatFolderObservers()
}
}
/**
* Updates the details for an existing folder like name, chat types, etc.
*/
fun updateFolder(chatFolder: ChatFolderRecord) {
writableDatabase.withinTransaction { db ->
db.update(ChatFolderTable.TABLE_NAME)
.values(
ChatFolderTable.NAME to chatFolder.name,
ChatFolderTable.SHOW_UNREAD to chatFolder.showUnread,
ChatFolderTable.SHOW_MUTED to chatFolder.showMutedChats,
ChatFolderTable.SHOW_INDIVIDUAL to chatFolder.showIndividualChats,
ChatFolderTable.SHOW_GROUPS to chatFolder.showGroupChats,
ChatFolderTable.IS_MUTED to chatFolder.isMuted
)
.where("${ChatFolderTable.ID} = ?", chatFolder.id)
.run(SQLiteDatabase.CONFLICT_IGNORE)
db
.delete(ChatFolderMembershipTable.TABLE_NAME)
.where("${ChatFolderMembershipTable.CHAT_FOLDER_ID} = ?", chatFolder.id)
.run()
val includedChats = SqlUtil.buildBulkInsert(
ChatFolderMembershipTable.TABLE_NAME,
arrayOf(ChatFolderMembershipTable.CHAT_FOLDER_ID, ChatFolderMembershipTable.THREAD_ID, ChatFolderMembershipTable.MEMBERSHIP_TYPE),
chatFolder.includedChats.toContentValues(chatFolderId = chatFolder.id, membershipType = MembershipType.INCLUDED)
)
val excludedChats = SqlUtil.buildBulkInsert(
ChatFolderMembershipTable.TABLE_NAME,
arrayOf(ChatFolderMembershipTable.CHAT_FOLDER_ID, ChatFolderMembershipTable.THREAD_ID, ChatFolderMembershipTable.MEMBERSHIP_TYPE),
chatFolder.excludedChats.toContentValues(chatFolderId = chatFolder.id, membershipType = MembershipType.EXCLUDED)
)
(includedChats + excludedChats).forEach {
db.execSQL(it.where, it.whereArgs)
}
AppDependencies.databaseObserver.notifyChatFolderObservers()
}
}
/**
* Deletes a chat folder
*/
fun deleteChatFolder(chatFolder: ChatFolderRecord) {
writableDatabase.withinTransaction { db ->
db.delete(ChatFolderTable.TABLE_NAME, "${ChatFolderTable.ID} = ?", SqlUtil.buildArgs(chatFolder.id))
AppDependencies.databaseObserver.notifyChatFolderObservers()
}
}
/**
* Updates the position of the chat folders
*/
fun updatePositions(folders: List<ChatFolderRecord>) {
writableDatabase.withinTransaction { db ->
folders.forEach { folder ->
db.update(ChatFolderTable.TABLE_NAME)
.values(ChatFolderTable.POSITION to folder.position)
.where("${ChatFolderTable.ID} = ?", folder.id)
.run(SQLiteDatabase.CONFLICT_IGNORE)
}
AppDependencies.databaseObserver.notifyChatFolderObservers()
}
}
private fun Collection<Long>.toContentValues(chatFolderId: Long, membershipType: MembershipType): List<ContentValues> {
return map {
contentValuesOf(
ChatFolderMembershipTable.CHAT_FOLDER_ID to chatFolderId,
ChatFolderMembershipTable.THREAD_ID to it,
ChatFolderMembershipTable.MEMBERSHIP_TYPE to membershipType.value
)
}
}
enum class MembershipType(val value: Int) {
/** Chat that should be included in the chat folder */
INCLUDED(0),
/** Chat that should be excluded from the chat folder */
EXCLUDED(1)
}
}

View file

@ -47,6 +47,7 @@ public class DatabaseObserver {
private static final String KEY_CALL_UPDATES = "CallUpdates";
private static final String KEY_CALL_LINK_UPDATES = "CallLinkUpdates";
private static final String KEY_IN_APP_PAYMENTS = "InAppPayments";
private static final String KEY_CHAT_FOLDER = "ChatFolder";
private final Executor executor;
@ -69,6 +70,7 @@ public class DatabaseObserver {
private final Set<Observer> callUpdateObservers;
private final Map<CallLinkRoomId, Set<Observer>> callLinkObservers;
private final Set<InAppPaymentObserver> inAppPaymentObservers;
private final Set<Observer> chatFolderObservers;
public DatabaseObserver() {
this.executor = new SerialExecutor(SignalExecutors.BOUNDED);
@ -91,6 +93,7 @@ public class DatabaseObserver {
this.callUpdateObservers = new HashSet<>();
this.callLinkObservers = new HashMap<>();
this.inAppPaymentObservers = new HashSet<>();
this.chatFolderObservers = new HashSet<>();
}
public void registerConversationListObserver(@NonNull Observer listener) {
@ -206,6 +209,10 @@ public class DatabaseObserver {
executor.execute(() -> inAppPaymentObservers.add(observer));
}
public void registerChatFolderObserver(@NonNull Observer observer) {
executor.execute(() -> chatFolderObservers.add(observer));
}
public void unregisterObserver(@NonNull Observer listener) {
executor.execute(() -> {
conversationListObservers.remove(listener);
@ -223,6 +230,7 @@ public class DatabaseObserver {
unregisterMapped(conversationDeleteObservers, listener);
callUpdateObservers.remove(listener);
unregisterMapped(callLinkObservers, listener);
chatFolderObservers.remove(listener);
});
}
@ -387,6 +395,10 @@ public class DatabaseObserver {
});
}
public void notifyChatFolderObservers() {
runPostSuccessfulTransaction(KEY_CHAT_FOLDER, () -> notifySet(chatFolderObservers));
}
private void runPostSuccessfulTransaction(@NonNull String dedupeKey, @NonNull Runnable runnable) {
SignalDatabase.runPostSuccessfulTransaction(dedupeKey, () -> {
executor.execute(runnable);

View file

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

View file

@ -15,6 +15,7 @@ object RxDatabaseObserver {
val conversationList: Flowable<Unit> by lazy { conversationListFlowable() }
val notificationProfiles: Flowable<Unit> by lazy { notificationProfilesFlowable() }
val chatFolders: Flowable<Unit> by lazy { chatFoldersFlowable() }
private fun conversationListFlowable(): Flowable<Unit> {
return databaseFlowable { listener ->
@ -36,6 +37,12 @@ object RxDatabaseObserver {
) { _, _ -> Unit }
}
private fun chatFoldersFlowable(): Flowable<Unit> {
return databaseFlowable { listener ->
AppDependencies.databaseObserver.registerChatFolderObserver(listener)
}
}
private fun databaseFlowable(registerObserver: (RxObserver) -> Unit): Flowable<Unit> {
val flowable = Flowable.create(
{

View file

@ -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
}
}

View file

@ -18,6 +18,7 @@ import org.signal.core.util.exists
import org.signal.core.util.logging.Log
import org.signal.core.util.or
import org.signal.core.util.readToList
import org.signal.core.util.readToSingleInt
import org.signal.core.util.readToSingleLong
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
@ -30,6 +31,7 @@ import org.signal.core.util.updateAll
import org.signal.core.util.withinTransaction
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.database.MessageTable.MarkedMessageInfo
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments
@ -629,6 +631,39 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
return allCount + forcedUnreadCount
}
/**
* Returns the number of unread messages across all threads within a chat folder
* Threads that are forced-unread count as 1.
*/
fun getUnreadCountByChatFolder(folder: ChatFolderRecord): Int {
val chatFolderQuery = folder.toQuery()
val allCountQuery =
"""
SELECT SUM($UNREAD_COUNT)
FROM $TABLE_NAME
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
WHERE
$ARCHIVED = 0
$chatFolderQuery
"""
val allCount = readableDatabase.rawQuery(allCountQuery, null).readToSingleInt(0)
val forcedUnreadCountQuery =
"""
SELECT COUNT(*)
FROM $TABLE_NAME
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
WHERE
$ARCHIVED = 0 AND
$READ = ${ThreadTable.ReadStatus.FORCED_UNREAD.serialize()}
$chatFolderQuery
"""
val forcedUnreadCount = readableDatabase.rawQuery(forcedUnreadCountQuery, null).readToSingleInt(0)
return allCount + forcedUnreadCount
}
/**
* Returns the number of unread messages in a given thread.
*/
@ -915,12 +950,13 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
return readableDatabase.rawQuery(query, arrayOf("1"))
}
fun getUnarchivedConversationList(conversationFilter: ConversationFilter, pinned: Boolean, offset: Long, limit: Long): Cursor {
fun getUnarchivedConversationList(conversationFilter: ConversationFilter, pinned: Boolean, offset: Long, limit: Long, chatFolder: ChatFolderRecord): Cursor {
val folderQuery = chatFolder.toQuery()
val filterQuery = conversationFilter.toQuery()
val where = if (pinned) {
"$ARCHIVED = 0 AND $PINNED != 0 $filterQuery"
"$ARCHIVED = 0 AND $PINNED != 0 $filterQuery $folderQuery"
} else {
"$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0 $filterQuery"
"$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0 $filterQuery $folderQuery"
}
val query = if (pinned) {
@ -948,36 +984,61 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
}
}
fun getPinnedConversationListCount(conversationFilter: ConversationFilter): Int {
fun getPinnedConversationListCount(conversationFilter: ConversationFilter, chatFolder: ChatFolderRecord? = null): Int {
val filterQuery = conversationFilter.toQuery()
return readableDatabase
.select("COUNT(*)")
.from(TABLE_NAME)
.where("$ACTIVE = 1 AND $ARCHIVED = 0 AND $PINNED != 0 $filterQuery")
.run()
.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getInt(0)
} else {
0
}
}
return if (chatFolder == null || chatFolder.folderType == ChatFolderRecord.FolderType.ALL) {
readableDatabase
.select("COUNT(*)")
.from(TABLE_NAME)
.where("$ACTIVE = 1 AND $ARCHIVED = 0 AND $PINNED != 0 $filterQuery")
.run()
.readToSingleInt(0)
} else {
val folderQuery = chatFolder.toQuery()
val query =
"""
SELECT COUNT(*)
FROM $TABLE_NAME
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
WHERE
$ACTIVE = 1 AND
$ARCHIVED = 0 AND
$PINNED != 0
$filterQuery
$folderQuery
"""
readableDatabase.rawQuery(query, null).readToSingleInt(0)
}
}
fun getUnarchivedConversationListCount(conversationFilter: ConversationFilter): Int {
fun getUnarchivedConversationListCount(conversationFilter: ConversationFilter, chatFolder: ChatFolderRecord? = null): Int {
val filterQuery = conversationFilter.toQuery()
return readableDatabase
.select("COUNT(*)")
.from(TABLE_NAME)
.where("$ACTIVE = 1 AND $ARCHIVED = 0 AND ($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0) $filterQuery")
.run()
.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getInt(0)
} else {
0
}
}
return if (chatFolder == null || chatFolder.folderType == ChatFolderRecord.FolderType.ALL) {
readableDatabase
.select("COUNT(*)")
.from(TABLE_NAME)
.where("$ACTIVE = 1 AND $ARCHIVED = 0 AND ($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0) $filterQuery")
.run()
.readToSingleInt(0)
} else {
val folderQuery = chatFolder.toQuery()
val query =
"""
SELECT COUNT(*)
FROM $TABLE_NAME
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
WHERE
$ACTIVE = 1 AND
$ARCHIVED = 0 AND
($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0)
$filterQuery
$folderQuery
"""
readableDatabase.rawQuery(query, null).readToSingleInt(0)
}
}
/**
@ -1979,6 +2040,42 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
return Reader(cursor)
}
private fun ChatFolderRecord.toQuery(): String {
if (this.id == -1L || this.folderType == ChatFolderRecord.FolderType.ALL) {
return ""
}
val includedChatsQuery: MutableList<String> = mutableListOf()
includedChatsQuery.add("${TABLE_NAME}.$ID IN (${this.includedChats.joinToString(",")})")
if (this.showIndividualChats) {
includedChatsQuery.add("${RecipientTable.TABLE_NAME}.${RecipientTable.TYPE} = ${RecipientTable.RecipientType.INDIVIDUAL.id}")
}
if (this.showGroupChats) {
includedChatsQuery.add("${RecipientTable.TABLE_NAME}.${RecipientTable.TYPE} = ${RecipientTable.RecipientType.GV2.id}")
}
val includedQuery = includedChatsQuery.joinToString(" OR ") { "($it)" }
val fullQuery: MutableList<String> = mutableListOf()
fullQuery.add(includedQuery)
if (this.excludedChats.isNotEmpty()) {
fullQuery.add("${TABLE_NAME}.$ID NOT IN (${this.excludedChats.joinToString(",")})")
}
if (this.showUnread) {
fullQuery.add("$UNREAD_COUNT > 0 OR $READ == ${ReadStatus.FORCED_UNREAD.serialize()}")
}
if (!this.showMutedChats) {
fullQuery.add("${RecipientTable.TABLE_NAME}.${RecipientTable.MUTE_UNTIL} = 0")
}
return "AND ${fullQuery.joinToString(" AND ") { "($it)" }}"
}
private fun ConversationFilter.toQuery(): String {
return when (this) {
ConversationFilter.OFF -> ""

View file

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

View file

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

View file

@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.ContactSelectionActivity;
import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.PushContactSelectionActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -30,7 +31,6 @@ import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
@ -97,7 +97,7 @@ public class AddMembersActivity extends PushContactSelectionActivity implements
}
@Override
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
if (getGroupId().isV1() && recipientId.isPresent() && !Recipient.resolved(recipientId.get()).getHasE164()) {
Toast.makeText(this, R.string.AddMembersActivity__this_person_cant_be_added_to_legacy_groups, Toast.LENGTH_SHORT).show();
callback.accept(false);
@ -139,7 +139,7 @@ public class AddMembersActivity extends PushContactSelectionActivity implements
}
@Override
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {
if (contactsFragment.hasQueryFilter()) {
getContactFilterView().clear();
}

View file

@ -8,21 +8,18 @@ import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.ContactSelectionActivity;
import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupViewModel.Event;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientRepository;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.util.ArrayList;
import java.util.Collections;
@ -116,7 +113,7 @@ public final class AddToGroupsActivity extends ContactSelectionActivity {
}
@Override
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
if (contactsFragment.isMulti()) {
throw new UnsupportedOperationException("Not yet built to handle multi-select.");
// if (contactsFragment.hasQueryFilter()) {
@ -136,7 +133,7 @@ public final class AddToGroupsActivity extends ContactSelectionActivity {
}
@Override
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {
if (contactsFragment.hasQueryFilter()) {
getContactFilterView().clear();
}

View file

@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.ContactSelectionActivity;
import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.groups.ui.creategroup.details.AddGroupDetailsActivity;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -109,7 +110,7 @@ public class CreateGroupActivity extends ContactSelectionActivity implements Con
}
@Override
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
if (contactsFragment.hasQueryFilter()) {
getContactFilterView().clear();
}
@ -145,7 +146,7 @@ public class CreateGroupActivity extends ContactSelectionActivity implements Con
}
@Override
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {
if (contactsFragment.hasQueryFilter()) {
getContactFilterView().clear();
}

View file

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

View file

@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.paged.ChatType;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.payments.CanNotSendPaymentDialog;
@ -71,7 +72,7 @@ public class PaymentRecipientSelectionFragment extends LoggingFragment implement
}
@Override
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback) {
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
if (recipientId.isPresent()) {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
() -> Recipient.resolved(recipientId.get()),
@ -82,7 +83,7 @@ public class PaymentRecipientSelectionFragment extends LoggingFragment implement
}
@Override
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number) {}
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Optional<ChatType> chatType) {}
@Override
public void onSelectionChanged() {

View file

@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.ContactSelectionListFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
import org.thoughtcrime.securesms.contacts.HeaderAction
import org.thoughtcrime.securesms.contacts.paged.ChatType
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.groups.SelectionLimits
@ -117,7 +118,7 @@ abstract class BaseStoryRecipientSelectionFragment : Fragment(R.layout.stories_b
}
}
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>, callback: Consumer<Boolean>) {
viewModel.addRecipient(recipientId.get())
if (searchField.text.isNotBlank()) {
@ -127,7 +128,7 @@ abstract class BaseStoryRecipientSelectionFragment : Fragment(R.layout.stories_b
callback.accept(true)
}
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?) {
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>) {
viewModel.removeRecipient(recipientId.get())
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4.063,20.397C2.046,20.397 1,19.361 1,17.362V5.979C1,4.018 2.027,3 3.773,3H6.416C7.387,3 7.873,3.177 8.527,3.719L9.068,4.149C9.582,4.569 9.974,4.728 10.646,4.728H19.331C21.348,4.728 22.394,5.764 22.394,7.763V17.362C22.394,19.361 21.358,20.397 19.574,20.397H4.063ZM2.83,6.138V8.463H20.564V7.921C20.564,7.006 20.088,6.558 19.229,6.558H10.161C9.19,6.558 8.685,6.38 8.041,5.848L7.5,5.409C6.977,4.98 6.594,4.83 5.931,4.83H4.138C3.297,4.83 2.83,5.279 2.83,6.138ZM4.175,18.567H19.229C20.088,18.567 20.564,18.119 20.564,17.213V10.078H2.83V17.213C2.83,18.119 3.307,18.567 4.175,18.567Z"
android:fillColor="#151D2C"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M5,9.125C4.517,9.125 4.125,9.517 4.125,10C4.125,10.483 4.517,10.875 5,10.875H19C19.483,10.875 19.875,10.483 19.875,10C19.875,9.517 19.483,9.125 19,9.125H5Z"
android:fillColor="#545863"/>
<path
android:pathData="M5,13.125C4.517,13.125 4.125,13.517 4.125,14C4.125,14.483 4.517,14.875 5,14.875H19C19.483,14.875 19.875,14.483 19.875,14C19.875,13.517 19.483,13.125 19,13.125H5Z"
android:fillColor="#545863"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M13.901,1.482C13.504,1.086 12.961,0.88 12.429,0.919C11.878,0.96 11.343,1.27 11.075,1.864C10.644,2.821 10.53,3.888 10.721,4.916L8.518,6.615C6.864,6.038 5.088,6.079 3.618,6.846C3.059,7.139 2.775,7.67 2.746,8.209C2.718,8.732 2.924,9.26 3.312,9.647L6.207,12.543L2.153,16.597C1.97,16.78 1.832,17.003 1.751,17.248L1.382,18.355C1.327,18.517 1.482,18.672 1.645,18.618L2.752,18.249C2.997,18.167 3.22,18.03 3.403,17.847L7.457,13.793L10.353,16.689C10.74,17.076 11.269,17.282 11.791,17.254C12.33,17.225 12.862,16.942 13.154,16.382C13.921,14.912 13.963,13.136 13.385,11.483L15.085,9.279C16.112,9.47 17.179,9.356 18.137,8.925C18.73,8.658 19.04,8.122 19.081,7.571C19.121,7.039 18.915,6.496 18.518,6.1L13.901,1.482ZM12.405,2.462C12.423,2.422 12.442,2.406 12.454,2.398C12.469,2.388 12.495,2.377 12.537,2.374C12.628,2.367 12.76,2.404 12.869,2.513L17.487,7.131C17.596,7.24 17.633,7.372 17.627,7.463C17.624,7.505 17.612,7.532 17.602,7.546C17.594,7.559 17.578,7.577 17.538,7.595C16.755,7.948 15.851,8.006 14.994,7.762L14.519,7.626L11.723,11.25L11.885,11.641C12.482,13.077 12.457,14.565 11.861,15.707C11.841,15.746 11.82,15.763 11.804,15.773C11.786,15.784 11.757,15.796 11.713,15.798C11.62,15.803 11.491,15.764 11.384,15.658L4.343,8.616C4.236,8.51 4.197,8.38 4.202,8.287C4.205,8.244 4.216,8.215 4.227,8.196C4.237,8.18 4.254,8.16 4.293,8.139C5.435,7.543 6.923,7.518 8.36,8.115L8.75,8.277L12.375,5.482L12.239,5.006C11.994,4.149 12.052,3.246 12.405,2.462Z"
android:fillColor="#3C3C43"
android:fillAlpha="0.7"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="36dp"
android:orientation="horizontal"
android:gravity="center"
android:paddingHorizontal="16dp"
android:layout_marginVertical="8dp"
android:layout_marginEnd="16dp"
android:backgroundTint="@color/signal_colorSurfaceVariant"
android:background="@drawable/rounded_rectangle_surface_variant" >
<TextView
android:id="@+id/name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Signal.Text.LabelLarge"
tools:text="All chats" />
<TextView
android:id="@+id/unread_count"
style="@style/Signal.Text.LabelSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:background="@drawable/unread_count_background_new"
android:includeFontPadding="false"
android:minWidth="16dp"
android:gravity="center"
android:paddingHorizontal="4dp"
android:paddingVertical="2dp"
android:textColor="@color/signal_colorBackground"
tools:text="4" />
</LinearLayout>

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="?actionBarSize"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navigationIcon="@drawable/ic_arrow_left_24" />
<org.thoughtcrime.securesms.components.ContactFilterView
android:id="@+id/contact_filter_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginVertical="10dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:cfv_autoFocus="false"
app:cfv_background="@drawable/rounded_rectangle_surface_variant" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/contact_selection_list"
android:name="org.thoughtcrime.securesms.ContactSelectionListFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipChildren="false"
android:clipToPadding="false"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/contact_filter_edit_text"
app:layout_constraintBottom_toBottomOf="parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
<com.google.android.material.button.MaterialButton
android:id="@+id/done_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Signal.Widget.Button.Medium.Tonal"
android:layout_marginEnd="24dp"
android:layout_marginBottom="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:text="@string/ChooseChatsFragment__done" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/conversation_item_background"
android:focusable="true"
android:minHeight="@dimen/contact_selection_item_height"
android:paddingStart="@dimen/dsl_settings_gutter"
android:paddingEnd="@dimen/dsl_settings_gutter">
<ImageView
android:id="@+id/image"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/color_indicator_circle"
android:backgroundTint="@color/signal_colorSurfaceVariant"
app:tint="@color/signal_colorOnSurface"
android:scaleType="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:checkMark="?android:attr/listChoiceIndicatorMultiple"
android:drawablePadding="4dp"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Signal.Body1"
android:textColor="@color/signal_text_primary"
app:layout_constraintEnd_toStartOf="@id/check_box"
app:layout_constraintStart_toEndOf="@id/image"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Unreads" />
<androidx.appcompat.widget.AppCompatCheckBox
android:id="@+id/check_box"
android:layout_width="24dp"
android:layout_height="24dp"
android:background="?contactCheckboxBackground"
android:button="@null"
android:clickable="false"
android:focusable="false"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -32,11 +32,21 @@
app:barrierDirection="bottom"
app:constraint_referenced_ids="voice_note_player,banner_compose_view" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chat_folder_list"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="52dp"
android:clipToPadding="false"
android:paddingStart="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/banner_barrier" />
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/banner_barrier">
app:layout_constraintTop_toBottomOf="@id/chat_folder_list">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/recycler_coordinator_app_bar"

View file

@ -194,6 +194,18 @@
android:textColor="@color/signal_colorSurface"
tools:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/conversation_list_item_pinned"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:minWidth="18dp"
android:minHeight="18dp"
android:visibility="gone"
app:srcCompat="@drawable/ic_pin_20"
app:tint="@color/signal_colorOutline"
tools:visibility="visible" />
</LinearLayout>
<org.thoughtcrime.securesms.badges.BadgeImageView

View file

@ -358,6 +358,20 @@
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_chatsSettingsFragment_to_chatFoldersFragment"
app:destination="@id/chatFoldersFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_chatsSettingsFragment_to_remoteBackupsSettingsFragment"
app:destination="@id/remoteBackupsSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
@ -372,6 +386,41 @@
android:label="edit_reactions_fragment"
tools:layout="@layout/edit_reactions_fragment" />
<fragment
android:id="@+id/chatFoldersFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFoldersFragment"
android:label="chat_folders_fragment">
<action
android:id="@+id/action_chatFoldersFragment_to_createFoldersFragment"
app:destination="@id/createFoldersFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/createFoldersFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.chats.folders.CreateFoldersFragment"
android:label="create_folders_fragment">
<action
android:id="@+id/action_createFoldersFragment_to_chooseChatsFragment"
app:destination="@id/chooseChatsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit">
<argument
android:name="include_chats"
app:argType="boolean" />
</action>
</fragment>
<fragment
android:id="@+id/chooseChatsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.chats.folders.ChooseChatsFragment"
android:label="choose_chats_fragment" />
<!-- endregion -->
<!-- Notifications -->

View file

@ -245,4 +245,6 @@
<dimen name="CallScreenParticipantItem__margin_end">4dp</dimen>
<dimen name="CallScreenParticipantItem__margin_bottom">0dp</dimen>
<dimen name="chat_folder_row_height">64dp</dimen>
</resources>

View file

@ -369,6 +369,8 @@
<string name="ContactsCursorLoader__chats">Chats</string>
<!-- Header for conversation search section labeled "Messages" -->
<string name="ContactsCursorLoader__messages">Messages</string>
<!-- Header for conversation search section labeled "Chat types" -->
<string name="ContactsCursorLoader__chat_types">Chat types</string>
<!-- ContactsDatabase -->
<string name="ContactsDatabase_message_s">Message %s</string>
@ -5035,6 +5037,107 @@
<!-- ChatsSettingsFragment -->
<string name="ChatsSettingsFragment__keyboard">Keyboard</string>
<string name="ChatsSettingsFragment__send_with_enter">Send with enter</string>
<!-- Heading within chats settings for chat folders -->
<string name="ChatsSettingsFragment__chat_folders">Chat folders</string>
<!-- Option within settings to add a new chat folder -->
<string name="ChatsSettingsFragment__add_chat_folder">Add a chat folder</string>
<!-- ChatFoldersFragment -->
<!-- Description of what chat folders are -->
<string name="ChatFoldersFragment__organize_your_chats">Organize your chats into folders and quickly switch between them on your chat list.</string>
<!-- Header for section showing current chat folders -->
<string name="ChatFoldersFragment__folders">Folders</string>
<!-- Text next to button that allows users to create a new chat folder -->
<string name="ChatFoldersFragment__create_a_folder">Create a folder</string>
<!-- Name of a chat folder that represents the folder containing all chats -->
<string name="ChatFoldersFragment__all_chats">All chats</string>
<!-- Header for section showing suggested chat folders for users to have -->
<string name="ChatFoldersFragment__suggested_folders">Suggested folders</string>
<!-- Name of a chat folder that contains all chats that currently have unread messages -->
<string name="ChatFoldersFragment__unreads">Unreads</string>
<!-- Description of the suggested unread chat folder -->
<string name="ChatFoldersFragment__unread_messages">Unread messages from all chats</string>
<!-- Name of a chat folder that contains all 1:1 (individual) chats -->
<string name="ChatFoldersFragment__one_on_one_chats">1:1 chats</string>
<!-- Description of the suggested 1:1 chat folder -->
<string name="ChatFoldersFragment__only_direct_messages">Only messages from direct chats</string>
<!-- Name of a chat folder that contains all groups chats -->
<string name="ChatFoldersFragment__groups">Groups</string>
<!-- Description of the suggested group chat folder -->
<string name="ChatFoldersFragment__only_group_messages">Only message from group chats</string>
<!-- Button text to add a suggested folder to a user's chat folders -->
<string name="ChatFoldersFragment__add">Add</string>
<!-- Toast shown when a folder gets added where %s is the name of the folder -->
<string name="ChatFoldersFragment__folder_added">%1$s folder added.</string>
<!-- Text describing the number of chat types in a folder -->
<plurals name="ChatFoldersFragment__d_chat_types">
<item quantity="one">%1$d chat type</item>
<item quantity="other">%1$d chat types</item>
</plurals>
<!-- Text describing the number of chat in a folder -->
<plurals name="ChatFoldersFragment__d_chats">
<item quantity="one">%1$d chat</item>
<item quantity="other">%1$d chats</item>
</plurals>
<!-- Text describing the number of chats that are excluded in a folder -->
<plurals name="ChatFoldersFragment__d_chats_excluded">
<item quantity="one">%1$d chat excluded</item>
<item quantity="other">%1$d chats excluded</item>
</plurals>
<!-- CreateFoldersFragment -->
<!-- Title of the screen when creating a folder, displayed in the toolbar -->
<string name="CreateFoldersFragment__create_a_folder">Create a folder</string>
<!-- Hint text shown in text field to enter a name for the folder -->
<string name="CreateFoldersFragment__folder_name">Folder name (required)</string>
<!-- Section title representing what chats are included in the folder -->
<string name="CreateFoldersFragment__included_chats">Included chats</string>
<!-- Text next to button that allows users to add chats to the folder -->
<string name="CreateFoldersFragment__add_chats">Add chats</string>
<!-- Description explaining the purpose of the included chats section -->
<string name="CreateFoldersFragment__choose_chats_you_want">Choose chats that you want to appear in this folder.</string>
<!-- Section title representing what chats are excluded from the folder -->
<string name="CreateFoldersFragment__exceptions">Exceptions</string>
<!-- Text next to button that allows users to exclude chats from the folder -->
<string name="CreateFoldersFragment__exclude_chats">Exclude chats</string>
<!-- Description explaining the purpose of the excluded chats section -->
<string name="CreateFoldersFragment__choose_chats_you_do_not_want">Choose chats that you do not want to appear in this folder.</string>
<!-- Toggle switch for folder to show unread chats -->
<string name="CreateFoldersFragment__only_show_unread_chats">Only show unread chats</string>
<!-- Explanation of unread toggle option -->
<string name="CreateFoldersFragment__when_enabled_only_chats">When enabled, only chats with unread messages will be shown in this folder.</string>
<!-- Toggle switch to display muted chats in chat folders -->
<string name="CreateFoldersFragment__include_muted_chats">Include muted chats</string>
<!-- Button text to create a folder -->
<string name="CreateFoldersFragment__create">Create</string>
<!-- Alert dialog title to create a chat folder -->
<string name="CreateFoldersFragment__create_folder_title">Create folder?</string>
<!-- Alert dialog description when creating a chat folder where %s is the name of the folder -->
<string name="CreateFoldersFragment__do_you_want_to_create">Do you want to create the chat folder \"%1$s\"?</string>
<!-- Alert dialog confirmation button text to save the changes -->
<string name="CreateFoldersFragment__create_folder">Create folder</string>
<!-- Section title shown when editing an existing folder -->
<string name="CreateFoldersFragment__edit_folder">Edit folder</string>
<!-- Button text to save the changes to a folder after it has been edited -->
<string name="CreateFoldersFragment__save">Save</string>
<!-- Alert dialog title to save changes made to the current folder -->
<string name="CreateFoldersFragment__save_changes_title">Save changes?</string>
<!-- Alert dialog description when saving a folder that has had changes made to it -->
<string name="CreateFoldersFragment__do_you_want_to_save">Do you want to save the changes you\'ve made to this chat folder?</string>
<!-- Alert dialog confirmation button text to save the changes -->
<string name="CreateFoldersFragment__save_changes">Save changes</string>
<!-- Alert dialog button to dismiss the dialog and discard any changes -->
<string name="CreateFoldersFragment__discard">Discard</string>
<!-- Text that when pressed will delete the current folder -->
<string name="CreateFoldersFragment__delete_folder">Delete folder</string>
<!-- Alert dialog title to delete the current folder -->
<string name="CreateFoldersFragment__delete_this_chat_folder">Delete this chat folder?</string>
<!-- ChooseChatsFragment -->
<!-- Section title representing chat types that can be added to the folder -->
<string name="ChooseChatsFragment__chat_types">Chat types</string>
<!-- Done button label to save selected chats to folder -->
<string name="ChooseChatsFragment__done">Done</string>
<!-- NotificationsSettingsFragment -->
<string name="NotificationsSettingsFragment__messages">Messages</string>

View file

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

View file

@ -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 = {

View file

@ -0,0 +1,185 @@
package org.signal.core.ui.copied.androidx.compose
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
/**
* From AndroidX Compose demo
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt
*
* Allows for dragging and dropping to reorder within lazy columns
*/
@Composable
fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState {
val scope = rememberCoroutineScope()
val state =
remember(lazyListState) {
DragDropState(state = lazyListState, onMove = onMove, scope = scope)
}
LaunchedEffect(state) {
while (true) {
val diff = state.scrollChannel.receive()
lazyListState.scrollBy(diff)
}
}
return state
}
class DragDropState
internal constructor(
private val state: LazyListState,
private val scope: CoroutineScope,
private val onMove: (Int, Int) -> Unit
) {
var draggingItemIndex by mutableStateOf<Int?>(null)
private set
internal val scrollChannel = Channel<Float>()
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset by mutableIntStateOf(0)
internal val draggingItemOffset: Float
get() =
draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
internal var previousItemOffset = Animatable(0f)
private set
internal fun onDragStart(offset: Offset) {
state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) }
?.also {
draggingItemIndex = it.index
draggingItemInitialOffset = it.offset
}
}
internal fun onDragInterrupted() {
if (draggingItemIndex != null) {
previousIndexOfDraggedItem = draggingItemIndex
val startOffset = draggingItemOffset
scope.launch {
previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
0f,
spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f)
)
previousIndexOfDraggedItem = null
}
}
draggingItemDraggedDelta = 0f
draggingItemIndex = null
draggingItemInitialOffset = 0
}
internal fun onDrag(offset: Offset) {
draggingItemDraggedDelta += offset.y
val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem =
state.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.toInt() in item.offset..item.offsetEnd &&
draggingItem.index != item.index
}
if (targetItem != null) {
if (
draggingItem.index == state.firstVisibleItemIndex ||
targetItem.index == state.firstVisibleItemIndex
) {
state.requestScrollToItem(
state.firstVisibleItemIndex,
state.firstVisibleItemScrollOffset
)
}
onMove.invoke(draggingItem.index, targetItem.index)
draggingItemIndex = targetItem.index
} else {
val overscroll =
when {
draggingItemDraggedDelta > 0 ->
(endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemDraggedDelta < 0 ->
(startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) {
scrollChannel.trySend(overscroll)
}
}
}
private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
}
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
return pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
},
onDragStart = { offset -> dragDropState.onDragStart(offset) },
onDragEnd = { dragDropState.onDragInterrupted() },
onDragCancel = { dragDropState.onDragInterrupted() }
)
}
}
@Composable
fun LazyItemScope.DraggableItem(
dragDropState: DragDropState,
index: Int,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
) {
val dragging = index == dragDropState.draggingItemIndex
val draggingModifier =
if (dragging) {
Modifier.zIndex(1f).graphicsLayer { translationY = dragDropState.draggingItemOffset }
} else if (index == dragDropState.previousIndexOfDraggedItem) {
Modifier.zIndex(1f).graphicsLayer {
translationY = dragDropState.previousItemOffset.value
}
} else {
Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
}
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
}