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

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