mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 18:00:02 +01:00
Release chat folders to internal users.
This commit is contained in:
committed by
Greyson Parrelli
parent
e5c122d972
commit
c4fc32988c
@@ -28,6 +28,7 @@ import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
@@ -127,12 +128,12 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
callback.accept(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {}
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {}
|
||||
|
||||
@Override
|
||||
public void onBeginScroll() {
|
||||
|
||||
@@ -29,7 +29,6 @@ import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
@@ -47,7 +46,6 @@ import androidx.transition.AutoTransition;
|
||||
import androidx.transition.TransitionManager;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.pnikosis.materialishprogress.ProgressWheel;
|
||||
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.RxExtensions;
|
||||
@@ -61,6 +59,7 @@ import org.thoughtcrime.securesms.contacts.HeaderAction;
|
||||
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContacts;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
|
||||
@@ -111,16 +110,17 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
public static final int NO_LIMIT = Integer.MAX_VALUE;
|
||||
|
||||
public static final String DISPLAY_MODE = "display_mode";
|
||||
public static final String REFRESHABLE = "refreshable";
|
||||
public static final String RECENTS = "recents";
|
||||
public static final String SELECTION_LIMITS = "selection_limits";
|
||||
public static final String CURRENT_SELECTION = "current_selection";
|
||||
public static final String HIDE_COUNT = "hide_count";
|
||||
public static final String CAN_SELECT_SELF = "can_select_self";
|
||||
public static final String DISPLAY_CHIPS = "display_chips";
|
||||
public static final String RV_PADDING_BOTTOM = "recycler_view_padding_bottom";
|
||||
public static final String RV_CLIP = "recycler_view_clipping";
|
||||
public static final String DISPLAY_MODE = "display_mode";
|
||||
public static final String REFRESHABLE = "refreshable";
|
||||
public static final String RECENTS = "recents";
|
||||
public static final String SELECTION_LIMITS = "selection_limits";
|
||||
public static final String CURRENT_SELECTION = "current_selection";
|
||||
public static final String HIDE_COUNT = "hide_count";
|
||||
public static final String CAN_SELECT_SELF = "can_select_self";
|
||||
public static final String DISPLAY_CHIPS = "display_chips";
|
||||
public static final String RV_PADDING_BOTTOM = "recycler_view_padding_bottom";
|
||||
public static final String RV_CLIP = "recycler_view_clipping";
|
||||
public static final String INCLUDE_CHAT_TYPES = "include_chat_types";
|
||||
|
||||
private ConstraintLayout constraintLayout;
|
||||
private TextView emptyText;
|
||||
@@ -421,6 +421,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
|
||||
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
|
||||
listClickListener.onItemClick(chatTypeRow.getContactSearchKey());
|
||||
}
|
||||
},
|
||||
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
|
||||
storyContextMenuCallbacks,
|
||||
@@ -679,6 +684,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedContact.hasChatType() && !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onBeforeContactSelected(true, Optional.empty(), null, Optional.of(selectedContact.getChatType()), allowed -> {
|
||||
if (allowed) {
|
||||
markContactSelected(selectedContact);
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else if (selectedContact.hasChatType()) {
|
||||
markContactUnselected(selectedContact);
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactDeselected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber(), Optional.of(selectedContact.getChatType()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMulti || !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
|
||||
if (selectionHardLimitReached()) {
|
||||
if (onSelectionLimitReachedListener != null) {
|
||||
@@ -709,7 +731,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), username);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onBeforeContactSelected(true, Optional.of(recipient.getId()), null, allowed -> {
|
||||
onContactSelectedListener.onBeforeContactSelected(true, Optional.of(recipient.getId()), null, Optional.empty(), allowed -> {
|
||||
if (allowed) {
|
||||
markContactSelected(selected);
|
||||
}
|
||||
@@ -731,6 +753,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
isUnknown,
|
||||
Optional.ofNullable(selectedContact.getRecipientId()),
|
||||
selectedContact.getNumber(),
|
||||
Optional.empty(),
|
||||
allowed -> {
|
||||
if (allowed) {
|
||||
markContactSelected(selectedContact);
|
||||
@@ -744,7 +767,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
markContactUnselected(selectedContact);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactDeselected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber());
|
||||
onContactSelectedListener.onContactDeselected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber(), Optional.empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -770,7 +793,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
return getChipCount() + currentSelection.size() > selectionLimit.getRecommendedLimit();
|
||||
}
|
||||
|
||||
private void markContactSelected(@NonNull SelectedContact selectedContact) {
|
||||
public void markContactSelected(@NonNull SelectedContact selectedContact) {
|
||||
contactSearchMediator.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
|
||||
if (isMulti) {
|
||||
addChipForSelectedContact(selectedContact);
|
||||
@@ -789,7 +812,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSelectedContactsChanged(@NonNull List<SelectedContacts.Model> selectedContacts) {
|
||||
private void handleSelectedContactsChanged(@NonNull List<SelectedContacts.Model<?>> selectedContacts) {
|
||||
contactChipAdapter.submitList(new MappingModelList(selectedContacts), this::smoothScrollChipsToEnd);
|
||||
|
||||
if (selectedContacts.isEmpty()) {
|
||||
@@ -808,15 +831,23 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) {
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
|
||||
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
|
||||
resolved -> contactChipViewModel.add(selectedContact));
|
||||
if (selectedContact.hasChatType()) {
|
||||
contactChipViewModel.add(selectedContact);
|
||||
} else {
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
|
||||
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
|
||||
resolved -> contactChipViewModel.add(selectedContact));
|
||||
}
|
||||
}
|
||||
|
||||
private Unit onChipCloseIconClicked(SelectedContacts.Model model) {
|
||||
private Unit onChipCloseIconClicked(SelectedContacts.Model<?> model) {
|
||||
markContactUnselected(model.getSelectedContact());
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactDeselected(Optional.of(model.getRecipient().getId()), model.getRecipient().getE164().orElse(null));
|
||||
if (model instanceof SelectedContacts.ChatTypeModel) {
|
||||
onContactSelectedListener.onContactDeselected(Optional.empty(), null, Optional.of(model.getSelectedContact().getChatType()));
|
||||
} else {
|
||||
onContactSelectedListener.onContactDeselected(Optional.of(((SelectedContacts.RecipientModel) model).getRecipient().getId()), ((SelectedContacts.RecipientModel) model).getRecipient().getE164().orElse(null), Optional.empty());
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.INSTANCE;
|
||||
@@ -870,6 +901,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
boolean includeGroupsAfterContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
|
||||
boolean blocked = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_BLOCK);
|
||||
boolean includeGroupMembers = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUP_MEMBERS);
|
||||
boolean includeChatTypes = safeArguments().getBoolean(INCLUDE_CHAT_TYPES);
|
||||
boolean hasQuery = !TextUtils.isEmpty(contactSearchState.getQuery());
|
||||
|
||||
ContactSearchConfiguration.TransportType transportType = resolveTransportType(includePushContacts, includeSmsContacts);
|
||||
@@ -895,6 +927,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_BY_PHONE_NUMBER.getCode());
|
||||
}
|
||||
|
||||
if (includeChatTypes && !hasQuery) {
|
||||
builder.addSection(new ContactSearchConfiguration.Section.ChatTypes(true, null));
|
||||
}
|
||||
|
||||
if (transportType != null) {
|
||||
if (!hasQuery && includeRecents) {
|
||||
builder.addSection(new ContactSearchConfiguration.Section.Recents(
|
||||
@@ -1027,9 +1063,9 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
/**
|
||||
* Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it.
|
||||
*/
|
||||
void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback);
|
||||
void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback);
|
||||
|
||||
void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number);
|
||||
void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Optional<ChatType> chatType);
|
||||
|
||||
void onSelectionChanged();
|
||||
}
|
||||
|
||||
@@ -27,15 +27,14 @@ import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView.OnFilterChangedListener;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarInviteTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
|
||||
@@ -131,13 +130,13 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
|
||||
callback.accept(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {
|
||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ import org.thoughtcrime.securesms.components.menu.ActionItem;
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
|
||||
import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository;
|
||||
import org.thoughtcrime.securesms.contacts.management.ContactsManagementViewModel;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
@@ -57,11 +57,9 @@ import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
@@ -121,7 +119,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
if (recipientId.isPresent()) {
|
||||
launch(Recipient.resolved(recipientId.get()));
|
||||
} else {
|
||||
|
||||
@@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.PassphraseRequiredActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
@@ -97,7 +98,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
final String displayName = recipientId.map(id -> Recipient.resolved(id).getDisplayName(this)).orElse(number);
|
||||
|
||||
AlertDialog confirmationDialog = new MaterialAlertDialogBuilder(this)
|
||||
@@ -126,7 +127,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.InviteActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -38,7 +39,7 @@ class NewCallActivity : ContactSelectionActivity(), ContactSelectionListFragment
|
||||
|
||||
override fun onSelectionChanged() = Unit
|
||||
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId?>, number: String?, callback: Consumer<Boolean?>) {
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId?>, number: String?, chatType: Optional<ChatType>, callback: Consumer<Boolean?>) {
|
||||
if (recipientId.isPresent) {
|
||||
launch(Recipient.resolved(recipientId.get()))
|
||||
} else {
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.settings.app.chats
|
||||
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
@@ -61,6 +60,19 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch
|
||||
|
||||
dividerPref()
|
||||
|
||||
if (RemoteConfig.internalUser) {
|
||||
sectionHeaderPref(R.string.ChatsSettingsFragment__chat_folders)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.ChatsSettingsFragment__add_chat_folder),
|
||||
onClick = {
|
||||
Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_chatFoldersFragment)
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
}
|
||||
|
||||
sectionHeaderPref(R.string.ChatsSettingsFragment__keyboard)
|
||||
|
||||
switchPref(
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats.folders
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
/**
|
||||
* Represents an entry in the [org.thoughtcrime.securesms.database.ChatFolderTables].
|
||||
*/
|
||||
data class ChatFolderRecord(
|
||||
val id: Long = -1,
|
||||
val name: String = "",
|
||||
val position: Int = -1,
|
||||
val includedChats: List<Long> = emptyList(),
|
||||
val excludedChats: List<Long> = emptyList(),
|
||||
val includedRecipients: Set<Recipient> = emptySet(),
|
||||
val excludedRecipients: Set<Recipient> = emptySet(),
|
||||
val showUnread: Boolean = false,
|
||||
val showMutedChats: Boolean = false,
|
||||
val showIndividualChats: Boolean = false,
|
||||
val showGroupChats: Boolean = false,
|
||||
val isMuted: Boolean = false,
|
||||
val folderType: FolderType = FolderType.CUSTOM,
|
||||
val unreadCount: Int = 0 // TODO [michelle]: unread count
|
||||
) {
|
||||
enum class FolderType(val value: Int) {
|
||||
/** Folder containing all chats */
|
||||
ALL(0),
|
||||
|
||||
/** Folder containing all 1:1 chats */
|
||||
INDIVIDUAL(1),
|
||||
|
||||
/** Folder containing group chats */
|
||||
GROUP(2),
|
||||
|
||||
/** Folder containing unread chats. */
|
||||
UNREAD(3),
|
||||
|
||||
/** Folder containing custom chosen chats */
|
||||
CUSTOM(4);
|
||||
|
||||
companion object {
|
||||
fun deserialize(value: Int): FolderType {
|
||||
return entries.firstOrNull { it.value == value } ?: CUSTOM
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats.folders
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.copied.androidx.compose.DraggableItem
|
||||
import org.signal.core.ui.copied.androidx.compose.dragContainer
|
||||
import org.signal.core.ui.copied.androidx.compose.rememberDragDropState
|
||||
import org.signal.core.util.toInt
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Fragment that displays current and suggested chat folders
|
||||
*/
|
||||
class ChatFoldersFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: ChatFoldersViewModel by activityViewModels()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val navController: NavController by remember { mutableStateOf(findNavController()) }
|
||||
viewModel.loadCurrentFolders(requireContext())
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.ChatsSettingsFragment__chat_folders),
|
||||
onNavigationClick = { navController.popBackStack() },
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) { contentPadding: PaddingValues ->
|
||||
FoldersScreen(
|
||||
state = state,
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
onFolderClicked = {
|
||||
viewModel.setCurrentFolder(it)
|
||||
navController.safeNavigate(R.id.action_chatFoldersFragment_to_createFoldersFragment)
|
||||
},
|
||||
onAdd = { folder ->
|
||||
Toast.makeText(requireContext(), getString(R.string.ChatFoldersFragment__folder_added, folder.name), Toast.LENGTH_SHORT).show()
|
||||
viewModel.createFolder(requireContext(), folder)
|
||||
},
|
||||
onPositionUpdated = { fromIndex, toIndex -> viewModel.updatePosition(fromIndex, toIndex) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FoldersScreen(
|
||||
state: ChatFoldersSettingsState,
|
||||
modifier: Modifier = Modifier,
|
||||
onFolderClicked: (ChatFolderRecord) -> Unit = {},
|
||||
onAdd: (ChatFolderRecord) -> Unit = {},
|
||||
onPositionUpdated: (Int, Int) -> Unit = { _, _ -> }
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val dragDropState =
|
||||
rememberDragDropState(listState) { fromIndex, toIndex ->
|
||||
onPositionUpdated(fromIndex, toIndex)
|
||||
}
|
||||
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
|
||||
Column(modifier = Modifier.padding(start = 24.dp)) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.ChatFoldersFragment__organize_your_chats),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, end = 12.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.ChatFoldersFragment__folders),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
|
||||
)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_plus_compact_16,
|
||||
title = stringResource(R.string.ChatFoldersFragment__create_a_folder),
|
||||
onClick = { onFolderClicked(ChatFolderRecord()) }
|
||||
)
|
||||
}
|
||||
|
||||
val columnHeight = dimensionResource(id = R.dimen.chat_folder_row_height).value * state.folders.size
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.height(columnHeight.dp)
|
||||
.dragContainer(dragDropState),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.folders) { index, folder ->
|
||||
DraggableItem(dragDropState, index) { isDragging ->
|
||||
val elevation = if (isDragging) 1.dp else 0.dp
|
||||
val isAllChats = folder.folderType == ChatFolderRecord.FolderType.ALL
|
||||
FolderRow(
|
||||
icon = R.drawable.ic_chat_folder_24,
|
||||
title = if (isAllChats) stringResource(R.string.ChatFoldersFragment__all_chats) else folder.name,
|
||||
subtitle = getFolderDescription(folder),
|
||||
onClick = if (!isAllChats) {
|
||||
{ onFolderClicked(folder) }
|
||||
} else null,
|
||||
elevation = elevation,
|
||||
showDragHandle = true,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.suggestedFolders.isNotEmpty()) {
|
||||
Dividers.Default()
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.ChatFoldersFragment__suggested_folders),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp, start = 24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
state.suggestedFolders.forEach { chatFolder ->
|
||||
when (chatFolder.folderType) {
|
||||
ChatFolderRecord.FolderType.UNREAD -> {
|
||||
val title: String = stringResource(R.string.ChatFoldersFragment__unreads)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_chat_badge_24,
|
||||
title = title,
|
||||
subtitle = stringResource(R.string.ChatFoldersFragment__unread_messages),
|
||||
onAdd = { onAdd(chatFolder) },
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
ChatFolderRecord.FolderType.INDIVIDUAL -> {
|
||||
val title: String = stringResource(R.string.ChatFoldersFragment__one_on_one_chats)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_person_light_24,
|
||||
title = title,
|
||||
subtitle = stringResource(R.string.ChatFoldersFragment__only_direct_messages),
|
||||
onAdd = { onAdd(chatFolder) },
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
ChatFolderRecord.FolderType.GROUP -> {
|
||||
val title: String = stringResource(R.string.ChatFoldersFragment__groups)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_group_light_20,
|
||||
title = title,
|
||||
subtitle = stringResource(R.string.ChatFoldersFragment__only_group_messages),
|
||||
onAdd = { onAdd(chatFolder) },
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
ChatFolderRecord.FolderType.ALL -> {
|
||||
throw IllegalStateException("All chats should not be suggested")
|
||||
}
|
||||
ChatFolderRecord.FolderType.CUSTOM -> {
|
||||
throw IllegalStateException("Custom folders should not be suggested")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getFolderDescription(folder: ChatFolderRecord): String {
|
||||
val chatTypeCount = folder.showIndividualChats.toInt() + folder.showGroupChats.toInt()
|
||||
val chatTypes = pluralStringResource(id = R.plurals.ChatFoldersFragment__d_chat_types, count = chatTypeCount, chatTypeCount)
|
||||
val includedChats = pluralStringResource(id = R.plurals.ChatFoldersFragment__d_chats, count = folder.includedChats.size, folder.includedChats.size)
|
||||
val excludedChats = pluralStringResource(id = R.plurals.ChatFoldersFragment__d_chats_excluded, count = folder.excludedChats.size, folder.excludedChats.size)
|
||||
|
||||
return remember(chatTypeCount, folder.includedChats.size, folder.excludedChats.size) {
|
||||
val description = mutableListOf<String>()
|
||||
if (chatTypeCount != 0) {
|
||||
description.add(chatTypes)
|
||||
}
|
||||
if (folder.includedChats.isNotEmpty()) {
|
||||
description.add(includedChats)
|
||||
}
|
||||
if (folder.excludedChats.isNotEmpty()) {
|
||||
description.add(excludedChats)
|
||||
}
|
||||
description.joinToString(separator = ", ")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FolderRow(
|
||||
modifier: Modifier = Modifier,
|
||||
icon: Int,
|
||||
title: String,
|
||||
subtitle: String = "",
|
||||
onClick: (() -> Unit)? = null,
|
||||
onAdd: (() -> Unit)? = null,
|
||||
elevation: Dp = 0.dp,
|
||||
showDragHandle: Boolean = false
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = if (onClick != null) {
|
||||
modifier
|
||||
.padding(end = 12.dp)
|
||||
.clickable(onClick = onClick)
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = dimensionResource(id = R.dimen.chat_folder_row_height))
|
||||
.shadow(elevation = elevation)
|
||||
} else {
|
||||
modifier
|
||||
.padding(end = 12.dp)
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = dimensionResource(id = R.dimen.chat_folder_row_height))
|
||||
.shadow(elevation = elevation)
|
||||
}
|
||||
) {
|
||||
Image(
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
|
||||
imageVector = ImageVector.vectorResource(id = icon),
|
||||
contentDescription = null,
|
||||
modifier = modifier
|
||||
.size(40.dp)
|
||||
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape)
|
||||
.padding(8.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
Text(text = title)
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (onAdd != null) {
|
||||
Buttons.Small(onClick = onAdd, modifier = modifier.padding(end = 12.dp)) {
|
||||
Text(stringResource(id = R.string.ChatFoldersFragment__add))
|
||||
}
|
||||
} else if (showDragHandle) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_drag_handle),
|
||||
contentDescription = null,
|
||||
modifier = modifier.padding(end = 12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun ChatFolderPreview() {
|
||||
val previewFolders = listOf(
|
||||
ChatFolderRecord(
|
||||
id = 1,
|
||||
name = "Work",
|
||||
position = 1,
|
||||
showUnread = true,
|
||||
showIndividualChats = true,
|
||||
showGroupChats = true,
|
||||
showMutedChats = true,
|
||||
isMuted = false,
|
||||
folderType = ChatFolderRecord.FolderType.CUSTOM
|
||||
),
|
||||
ChatFolderRecord(
|
||||
id = 2,
|
||||
name = "Fun People",
|
||||
position = 2,
|
||||
showUnread = true,
|
||||
showIndividualChats = true,
|
||||
showGroupChats = false,
|
||||
showMutedChats = false,
|
||||
isMuted = false,
|
||||
folderType = ChatFolderRecord.FolderType.CUSTOM
|
||||
)
|
||||
)
|
||||
|
||||
Previews.Preview {
|
||||
FoldersScreen(
|
||||
ChatFoldersSettingsState(
|
||||
folders = previewFolders
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats.folders
|
||||
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
|
||||
/**
|
||||
* Repository for chat folders that handles creation, deletion, listing, etc.,
|
||||
*/
|
||||
object ChatFoldersRepository {
|
||||
|
||||
fun getCurrentFolders(includeUnreadCount: Boolean = false): List<ChatFolderRecord> {
|
||||
return SignalDatabase.chatFolders.getChatFolders(includeUnreadCount)
|
||||
}
|
||||
|
||||
fun createFolder(folder: ChatFolderRecord) {
|
||||
val includedChats = folder.includedRecipients.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }
|
||||
val excludedChats = folder.excludedRecipients.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }
|
||||
val updatedFolder = folder.copy(
|
||||
includedChats = includedChats,
|
||||
excludedChats = excludedChats
|
||||
)
|
||||
|
||||
SignalDatabase.chatFolders.createFolder(updatedFolder)
|
||||
}
|
||||
|
||||
fun updateFolder(folder: ChatFolderRecord) {
|
||||
val includedChats = folder.includedRecipients.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }
|
||||
val excludedChats = folder.excludedRecipients.map { recipient -> SignalDatabase.threads.getOrCreateThreadIdFor(recipient) }
|
||||
val updatedFolder = folder.copy(
|
||||
includedChats = includedChats,
|
||||
excludedChats = excludedChats
|
||||
)
|
||||
|
||||
SignalDatabase.chatFolders.updateFolder(updatedFolder)
|
||||
}
|
||||
|
||||
fun deleteFolder(folder: ChatFolderRecord) {
|
||||
SignalDatabase.chatFolders.deleteChatFolder(folder)
|
||||
}
|
||||
|
||||
fun updatePositions(folders: List<ChatFolderRecord>) {
|
||||
SignalDatabase.chatFolders.updatePositions(folders)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats.folders
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Information about chat folders. Used in [ChatFoldersViewModel].
|
||||
*/
|
||||
data class ChatFoldersSettingsState(
|
||||
val folders: List<ChatFolderRecord> = emptyList(),
|
||||
val suggestedFolders: List<ChatFolderRecord> = emptyList(),
|
||||
val originalFolder: ChatFolderRecord = ChatFolderRecord(),
|
||||
val currentFolder: ChatFolderRecord = ChatFolderRecord(),
|
||||
val showDeleteDialog: Boolean = false,
|
||||
val showConfirmationDialog: Boolean = false,
|
||||
val pendingIncludedRecipients: Set<RecipientId> = emptySet(),
|
||||
val pendingExcludedRecipients: Set<RecipientId> = emptySet(),
|
||||
val pendingChatTypes: Set<ChatType> = emptySet()
|
||||
)
|
||||
@@ -0,0 +1,313 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats.folders
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Maintains the state of the [ChatFoldersFragment] and [CreateFoldersFragment]
|
||||
*/
|
||||
class ChatFoldersViewModel : ViewModel() {
|
||||
|
||||
private val internalState = MutableStateFlow(ChatFoldersSettingsState())
|
||||
val state = internalState.asStateFlow()
|
||||
|
||||
fun loadCurrentFolders(context: Context) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val folders = ChatFoldersRepository.getCurrentFolders(includeUnreadCount = false)
|
||||
val suggestedFolders = getSuggestedFolders(context, folders)
|
||||
|
||||
internalState.update {
|
||||
it.copy(folders = folders, suggestedFolders = suggestedFolders)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSuggestedFolders(context: Context, currentFolders: List<ChatFolderRecord>): List<ChatFolderRecord> {
|
||||
var showIndividualSuggestion = true
|
||||
var showGroupSuggestion = true
|
||||
var showUnreadSuggestion = true
|
||||
|
||||
currentFolders
|
||||
.filter { folder -> folder.includedChats.isEmpty() && folder.excludedChats.isEmpty() }
|
||||
.forEach { folder ->
|
||||
if (folder.showIndividualChats && !folder.showGroupChats) {
|
||||
showIndividualSuggestion = false
|
||||
} else if (folder.showGroupChats && !folder.showIndividualChats) {
|
||||
showGroupSuggestion = false
|
||||
} else if (folder.showUnread && folder.showIndividualChats && folder.showGroupChats) {
|
||||
showUnreadSuggestion = false
|
||||
}
|
||||
}
|
||||
|
||||
val suggestions: MutableList<ChatFolderRecord> = mutableListOf()
|
||||
if (showIndividualSuggestion) {
|
||||
suggestions.add(
|
||||
ChatFolderRecord(
|
||||
name = context.getString(R.string.ChatFoldersFragment__one_on_one_chats),
|
||||
showIndividualChats = true,
|
||||
folderType = ChatFolderRecord.FolderType.INDIVIDUAL,
|
||||
showMutedChats = true
|
||||
)
|
||||
)
|
||||
}
|
||||
if (showGroupSuggestion) {
|
||||
suggestions.add(
|
||||
ChatFolderRecord(
|
||||
name = context.getString(R.string.ChatFoldersFragment__groups),
|
||||
showGroupChats = true,
|
||||
folderType = ChatFolderRecord.FolderType.GROUP,
|
||||
showMutedChats = true
|
||||
)
|
||||
)
|
||||
}
|
||||
if (showUnreadSuggestion) {
|
||||
suggestions.add(
|
||||
ChatFolderRecord(
|
||||
name = context.getString(R.string.ChatFoldersFragment__unreads),
|
||||
showUnread = true,
|
||||
showIndividualChats = true,
|
||||
showGroupChats = true,
|
||||
showMutedChats = true,
|
||||
folderType = ChatFolderRecord.FolderType.UNREAD
|
||||
)
|
||||
)
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
fun setCurrentFolder(folder: ChatFolderRecord) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val includedRecipients = folder.includedChats.mapNotNull { threadId ->
|
||||
SignalDatabase.threads.getRecipientForThreadId(threadId)
|
||||
}
|
||||
val excludedRecipients = folder.excludedChats.mapNotNull { threadId ->
|
||||
SignalDatabase.threads.getRecipientForThreadId(threadId)
|
||||
}
|
||||
|
||||
val updatedFolder = folder.copy(
|
||||
includedRecipients = includedRecipients.toSet(),
|
||||
excludedRecipients = excludedRecipients.toSet()
|
||||
)
|
||||
|
||||
internalState.update {
|
||||
it.copy(originalFolder = updatedFolder, currentFolder = updatedFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateName(name: String) {
|
||||
val updatedFolder = internalState.value.currentFolder.copy(
|
||||
name = name.substring(0, minOf(name.length, 32))
|
||||
)
|
||||
|
||||
internalState.update {
|
||||
it.copy(currentFolder = updatedFolder)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleShowUnread(showUnread: Boolean) {
|
||||
val updatedFolder = internalState.value.currentFolder.copy(
|
||||
showUnread = showUnread
|
||||
)
|
||||
|
||||
internalState.update {
|
||||
it.copy(currentFolder = updatedFolder)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleShowMutedChats(showMuted: Boolean) {
|
||||
val updatedFolder = internalState.value.currentFolder.copy(
|
||||
showMutedChats = showMuted
|
||||
)
|
||||
|
||||
internalState.update {
|
||||
it.copy(currentFolder = updatedFolder)
|
||||
}
|
||||
}
|
||||
|
||||
fun showDeleteDialog(show: Boolean) {
|
||||
internalState.update {
|
||||
it.copy(showDeleteDialog = show)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteFolder() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ChatFoldersRepository.deleteFolder(internalState.value.originalFolder)
|
||||
|
||||
internalState.update {
|
||||
it.copy(showDeleteDialog = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showConfirmationDialog(show: Boolean) {
|
||||
internalState.update {
|
||||
it.copy(showConfirmationDialog = show)
|
||||
}
|
||||
}
|
||||
|
||||
fun createFolder(context: Context, folder: ChatFolderRecord? = null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val currentFolder = folder ?: internalState.value.currentFolder
|
||||
ChatFoldersRepository.createFolder(currentFolder)
|
||||
loadCurrentFolders(context)
|
||||
|
||||
internalState.update {
|
||||
it.copy(showConfirmationDialog = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePosition(fromIndex: Int, toIndex: Int) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val folders = state.value.folders.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
|
||||
val updatedFolders = folders.mapIndexed { index, chatFolderRecord ->
|
||||
chatFolderRecord.copy(position = index)
|
||||
}
|
||||
ChatFoldersRepository.updatePositions(updatedFolders)
|
||||
|
||||
internalState.update {
|
||||
it.copy(folders = updatedFolders)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFolder(context: Context) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ChatFoldersRepository.updateFolder(internalState.value.currentFolder)
|
||||
loadCurrentFolders(context)
|
||||
|
||||
internalState.update {
|
||||
it.copy(showConfirmationDialog = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setPendingChats() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val currentFolder = internalState.value.currentFolder
|
||||
val includedChats = currentFolder.includedRecipients.map { recipient -> recipient.id }.toMutableSet()
|
||||
val excludedChats = currentFolder.excludedRecipients.map { recipient -> recipient.id }.toMutableSet()
|
||||
|
||||
val chatTypes: MutableSet<ChatType> = mutableSetOf()
|
||||
if (currentFolder.showIndividualChats) {
|
||||
chatTypes.add(ChatType.INDIVIDUAL)
|
||||
}
|
||||
if (currentFolder.showGroupChats) {
|
||||
chatTypes.add(ChatType.GROUPS)
|
||||
}
|
||||
|
||||
internalState.update {
|
||||
it.copy(
|
||||
pendingIncludedRecipients = includedChats,
|
||||
pendingExcludedRecipients = excludedChats,
|
||||
pendingChatTypes = chatTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addIncludedChat(recipientId: RecipientId) {
|
||||
val includedChats = internalState.value.pendingIncludedRecipients.plus(recipientId)
|
||||
internalState.update {
|
||||
it.copy(pendingIncludedRecipients = includedChats)
|
||||
}
|
||||
}
|
||||
|
||||
fun addExcludedChat(recipientId: RecipientId) {
|
||||
val excludedChats = internalState.value.pendingExcludedRecipients.plus(recipientId)
|
||||
internalState.update {
|
||||
it.copy(pendingExcludedRecipients = excludedChats)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeIncludedChat(recipientId: RecipientId) {
|
||||
val includedChats = internalState.value.pendingIncludedRecipients.minus(recipientId)
|
||||
internalState.update {
|
||||
it.copy(pendingIncludedRecipients = includedChats)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeExcludedChat(recipientId: RecipientId) {
|
||||
val excludedChats = internalState.value.pendingExcludedRecipients.minus(recipientId)
|
||||
internalState.update {
|
||||
it.copy(pendingExcludedRecipients = excludedChats)
|
||||
}
|
||||
}
|
||||
|
||||
fun addChatType(chatType: ChatType) {
|
||||
val updatedChatTypes = internalState.value.pendingChatTypes.plus(chatType)
|
||||
internalState.update {
|
||||
it.copy(
|
||||
pendingChatTypes = updatedChatTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeChatType(chatType: ChatType) {
|
||||
val updatedChatTypes = internalState.value.pendingChatTypes.minus(chatType)
|
||||
internalState.update {
|
||||
it.copy(
|
||||
pendingChatTypes = updatedChatTypes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun savePendingChats() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val updatedFolder = internalState.value.currentFolder
|
||||
val includedChatIds = internalState.value.pendingIncludedRecipients
|
||||
val excludedChatIds = internalState.value.pendingExcludedRecipients
|
||||
val showIndividualChats = internalState.value.pendingChatTypes.contains(ChatType.INDIVIDUAL)
|
||||
val showGroupChats = internalState.value.pendingChatTypes.contains(ChatType.GROUPS)
|
||||
|
||||
val includedRecipients = includedChatIds.map(Recipient::resolved).toSet()
|
||||
val excludedRecipients = excludedChatIds.map(Recipient::resolved).toSet()
|
||||
|
||||
internalState.update {
|
||||
it.copy(
|
||||
currentFolder = updatedFolder.copy(
|
||||
includedRecipients = includedRecipients,
|
||||
excludedRecipients = excludedRecipients,
|
||||
showIndividualChats = showIndividualChats,
|
||||
showGroupChats = showGroupChats
|
||||
),
|
||||
pendingIncludedRecipients = emptySet(),
|
||||
pendingExcludedRecipients = emptySet()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun enableButton(): Boolean {
|
||||
return internalState.value.pendingIncludedRecipients.isNotEmpty() ||
|
||||
internalState.value.pendingChatTypes.isNotEmpty() ||
|
||||
internalState.value.pendingExcludedRecipients.isNotEmpty()
|
||||
}
|
||||
|
||||
fun hasChanges(): Boolean {
|
||||
val currentFolder = state.value.currentFolder
|
||||
val originalFolder = state.value.originalFolder
|
||||
|
||||
return if (currentFolder.id == -1L) {
|
||||
currentFolder.name.isNotEmpty() &&
|
||||
(currentFolder.includedRecipients.isNotEmpty() || currentFolder.showIndividualChats || currentFolder.showGroupChats)
|
||||
} else {
|
||||
originalFolder != currentFolder ||
|
||||
originalFolder.includedRecipients != currentFolder.includedRecipients ||
|
||||
originalFolder.excludedRecipients != currentFolder.excludedRecipients
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats.folders
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import java.util.Optional
|
||||
import java.util.function.Consumer
|
||||
|
||||
class ChooseChatsFragment : LoggingFragment(), ContactSelectionListFragment.OnContactSelectedListener {
|
||||
|
||||
private val viewModel: ChatFoldersViewModel by activityViewModels()
|
||||
|
||||
private var includeChatsMode: Boolean = true
|
||||
|
||||
private lateinit var contactFilterView: ContactFilterView
|
||||
private lateinit var doneButton: MaterialButton
|
||||
private lateinit var selectionFragment: ContactSelectionListFragment
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
includeChatsMode = arguments?.getBoolean(KEY_INCLUDE_CHATS) ?: true
|
||||
val currentSelection: Set<RecipientId> = if (includeChatsMode) {
|
||||
viewModel.state.value.pendingExcludedRecipients
|
||||
} else {
|
||||
viewModel.state.value.pendingIncludedRecipients
|
||||
}
|
||||
|
||||
childFragmentManager.addFragmentOnAttachListener { _, fragment ->
|
||||
fragment.arguments = Bundle().apply {
|
||||
putInt(ContactSelectionListFragment.DISPLAY_MODE, getDefaultDisplayMode())
|
||||
putBoolean(ContactSelectionListFragment.REFRESHABLE, false)
|
||||
putBoolean(ContactSelectionListFragment.RECENTS, true)
|
||||
putParcelable(ContactSelectionListFragment.SELECTION_LIMITS, SelectionLimits.NO_LIMITS)
|
||||
putParcelableArrayList(ContactSelectionListFragment.CURRENT_SELECTION, ArrayList<RecipientId>(currentSelection))
|
||||
putBoolean(ContactSelectionListFragment.INCLUDE_CHAT_TYPES, includeChatsMode)
|
||||
putBoolean(ContactSelectionListFragment.HIDE_COUNT, true)
|
||||
putBoolean(ContactSelectionListFragment.DISPLAY_CHIPS, true)
|
||||
putBoolean(ContactSelectionListFragment.CAN_SELECT_SELF, true)
|
||||
putBoolean(ContactSelectionListFragment.RV_CLIP, false)
|
||||
putInt(ContactSelectionListFragment.RV_PADDING_BOTTOM, ViewUtil.dpToPx(60))
|
||||
}
|
||||
}
|
||||
|
||||
return inflater.inflate(R.layout.choose_chats_fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
|
||||
if (includeChatsMode) {
|
||||
toolbar.setTitle(R.string.CreateFoldersFragment__included_chats)
|
||||
} else {
|
||||
toolbar.setTitle(R.string.CreateFoldersFragment__exceptions)
|
||||
}
|
||||
toolbar.setNavigationOnClickListener { findNavController().popBackStack() }
|
||||
|
||||
selectionFragment = childFragmentManager.findFragmentById(R.id.contact_selection_list) as ContactSelectionListFragment
|
||||
contactFilterView = view.findViewById(R.id.contact_filter_edit_text)
|
||||
contactFilterView.setOnFilterChangedListener {
|
||||
if (it.isNullOrEmpty()) {
|
||||
selectionFragment.resetQueryFilter()
|
||||
} else {
|
||||
selectionFragment.setQueryFilter(it)
|
||||
}
|
||||
}
|
||||
|
||||
doneButton = view.findViewById(R.id.done_button)
|
||||
doneButton.setOnClickListener {
|
||||
viewModel.savePendingChats()
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
updateEnabledButton()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
if (includeChatsMode && viewModel.state.value.pendingChatTypes.contains(ChatType.INDIVIDUAL)) {
|
||||
selectionFragment.markContactSelected(SelectedContact.forChatType(ChatType.INDIVIDUAL))
|
||||
}
|
||||
if (includeChatsMode && viewModel.state.value.pendingChatTypes.contains(ChatType.GROUPS)) {
|
||||
selectionFragment.markContactSelected(SelectedContact.forChatType(ChatType.GROUPS))
|
||||
}
|
||||
|
||||
val activeSelection: Set<RecipientId> = if (includeChatsMode) {
|
||||
viewModel.state.value.pendingIncludedRecipients
|
||||
} else {
|
||||
viewModel.state.value.pendingExcludedRecipients
|
||||
}
|
||||
|
||||
selectionFragment.markSelected(activeSelection)
|
||||
}
|
||||
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>, callback: Consumer<Boolean>) {
|
||||
if (recipientId.isPresent) {
|
||||
if (includeChatsMode) {
|
||||
viewModel.addIncludedChat(recipientId.get())
|
||||
} else {
|
||||
viewModel.addExcludedChat(recipientId.get())
|
||||
}
|
||||
callback.accept(true)
|
||||
} else if (chatType.isPresent) {
|
||||
viewModel.addChatType(chatType.get())
|
||||
callback.accept(true)
|
||||
} else {
|
||||
callback.accept(false)
|
||||
}
|
||||
updateEnabledButton()
|
||||
}
|
||||
|
||||
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>) {
|
||||
if (recipientId.isPresent) {
|
||||
if (includeChatsMode) {
|
||||
viewModel.removeIncludedChat(recipientId.get())
|
||||
} else {
|
||||
viewModel.removeExcludedChat(recipientId.get())
|
||||
}
|
||||
} else if (chatType.isPresent) {
|
||||
viewModel.removeChatType(chatType.get())
|
||||
}
|
||||
updateEnabledButton()
|
||||
}
|
||||
|
||||
override fun onSelectionChanged() = Unit
|
||||
|
||||
private fun getDefaultDisplayMode(): Int {
|
||||
return ContactSelectionDisplayMode.FLAG_PUSH or
|
||||
ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS or
|
||||
ContactSelectionDisplayMode.FLAG_HIDE_NEW or
|
||||
ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS or
|
||||
ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1 or
|
||||
ContactSelectionDisplayMode.FLAG_SELF
|
||||
}
|
||||
|
||||
private fun updateEnabledButton() {
|
||||
doneButton.isEnabled = viewModel.enableButton()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ChooseChatsFragment::class.java)
|
||||
private val KEY_INCLUDE_CHATS = "include_chats"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.chats.folders
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.AvatarImage
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Fragment that allows user to create, edit, or delete an individual folder
|
||||
*/
|
||||
class CreateFoldersFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: ChatFoldersViewModel by activityViewModels()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (viewModel.hasChanges()) {
|
||||
viewModel.showConfirmationDialog(true)
|
||||
} else {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val navController: NavController by remember { mutableStateOf(findNavController()) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val isNewFolder = state.originalFolder.id == -1L
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = if (isNewFolder) stringResource(id = R.string.CreateFoldersFragment__create_a_folder) else stringResource(id = R.string.CreateFoldersFragment__edit_folder),
|
||||
onNavigationClick = {
|
||||
if (viewModel.hasChanges()) {
|
||||
viewModel.showConfirmationDialog(true)
|
||||
} else {
|
||||
navController.popBackStack()
|
||||
}
|
||||
},
|
||||
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) { contentPadding: PaddingValues ->
|
||||
CreateFolderScreen(
|
||||
state = state,
|
||||
focusRequester = focusRequester,
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
isNewFolder = isNewFolder,
|
||||
hasChanges = viewModel.hasChanges(),
|
||||
onAddChat = {
|
||||
viewModel.setPendingChats()
|
||||
navController.safeNavigate(CreateFoldersFragmentDirections.actionCreateFoldersFragmentToChooseChatsFragment(true))
|
||||
},
|
||||
onRemoveChat = {
|
||||
viewModel.setPendingChats()
|
||||
navController.safeNavigate(CreateFoldersFragmentDirections.actionCreateFoldersFragmentToChooseChatsFragment(false))
|
||||
},
|
||||
onNameChange = { viewModel.updateName(it) },
|
||||
onToggleShowUnread = { viewModel.toggleShowUnread(it) },
|
||||
onToggleShowMuted = { viewModel.toggleShowMutedChats(it) },
|
||||
onDeleteClicked = { viewModel.showDeleteDialog(true) },
|
||||
onDeleteConfirmed = {
|
||||
viewModel.deleteFolder()
|
||||
navController.popBackStack()
|
||||
},
|
||||
onDeleteDismissed = {
|
||||
viewModel.showDeleteDialog(false)
|
||||
},
|
||||
onCreateConfirmed = { shouldExit ->
|
||||
if (isNewFolder) {
|
||||
viewModel.createFolder(requireContext())
|
||||
} else {
|
||||
viewModel.updateFolder(requireContext())
|
||||
}
|
||||
if (shouldExit) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
},
|
||||
onCreateDismissed = { shouldExit ->
|
||||
viewModel.showConfirmationDialog(false)
|
||||
if (shouldExit) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateFolderScreen(
|
||||
state: ChatFoldersSettingsState,
|
||||
focusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
isNewFolder: Boolean = true,
|
||||
hasChanges: Boolean = false,
|
||||
onAddChat: () -> Unit = {},
|
||||
onRemoveChat: () -> Unit = {},
|
||||
onNameChange: (String) -> Unit = {},
|
||||
onToggleShowUnread: (Boolean) -> Unit = {},
|
||||
onToggleShowMuted: (Boolean) -> Unit = {},
|
||||
onDeleteClicked: () -> Unit = {},
|
||||
onDeleteConfirmed: () -> Unit = {},
|
||||
onDeleteDismissed: () -> Unit = {},
|
||||
onCreateConfirmed: (Boolean) -> Unit = {},
|
||||
onCreateDismissed: (Boolean) -> Unit = {}
|
||||
) {
|
||||
if (state.showDeleteDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = "",
|
||||
body = stringResource(id = R.string.CreateFoldersFragment__delete_this_chat_folder),
|
||||
confirm = stringResource(id = R.string.delete),
|
||||
onConfirm = onDeleteConfirmed,
|
||||
dismiss = stringResource(id = android.R.string.cancel),
|
||||
onDismiss = onDeleteDismissed
|
||||
)
|
||||
} else if (state.showConfirmationDialog && isNewFolder) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(id = R.string.CreateFoldersFragment__create_folder_title),
|
||||
body = stringResource(id = R.string.CreateFoldersFragment__do_you_want_to_create, state.currentFolder.name),
|
||||
confirm = stringResource(id = R.string.CreateFoldersFragment__create_folder),
|
||||
onConfirm = { onCreateConfirmed(false) },
|
||||
dismiss = stringResource(id = R.string.CreateFoldersFragment__discard),
|
||||
onDismiss = { onCreateDismissed(true) },
|
||||
onDismissRequest = { onCreateDismissed(false) }
|
||||
)
|
||||
} else if (state.showConfirmationDialog) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(id = R.string.CreateFoldersFragment__save_changes_title),
|
||||
body = stringResource(id = R.string.CreateFoldersFragment__do_you_want_to_save),
|
||||
confirm = stringResource(id = R.string.CreateFoldersFragment__save_changes),
|
||||
onConfirm = { onCreateConfirmed(false) },
|
||||
dismiss = stringResource(id = R.string.CreateFoldersFragment__discard),
|
||||
onDismiss = { onCreateDismissed(true) },
|
||||
onDismissRequest = { onCreateDismissed(false) }
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn {
|
||||
item {
|
||||
TextField(
|
||||
value = state.currentFolder.name,
|
||||
label = { Text(text = stringResource(id = R.string.CreateFoldersFragment__folder_name)) },
|
||||
onValueChange = onNameChange,
|
||||
singleLine = true,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.padding(top = 16.dp, bottom = 12.dp, start = 20.dp, end = 28.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__included_chats),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp, start = 24.dp)
|
||||
)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_plus_compact_16,
|
||||
title = stringResource(R.string.CreateFoldersFragment__add_chats),
|
||||
onClick = onAddChat,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
|
||||
if (state.currentFolder.showIndividualChats) {
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_person_light_24,
|
||||
title = stringResource(R.string.ChatFoldersFragment__one_on_one_chats),
|
||||
onClick = onAddChat,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.currentFolder.showGroupChats) {
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_group_light_20,
|
||||
title = stringResource(R.string.ChatFoldersFragment__groups),
|
||||
onClick = onAddChat,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(state.currentFolder.includedRecipients.toList()) { recipient ->
|
||||
ChatRow(
|
||||
recipient = recipient,
|
||||
onClick = onAddChat
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__choose_chats_you_want),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, start = 24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__exceptions),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(top = 24.dp, bottom = 12.dp, end = 12.dp, start = 24.dp)
|
||||
)
|
||||
FolderRow(
|
||||
icon = R.drawable.symbol_plus_compact_16,
|
||||
title = stringResource(R.string.CreateFoldersFragment__exclude_chats),
|
||||
onClick = onRemoveChat,
|
||||
modifier = Modifier.padding(start = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items(state.currentFolder.excludedRecipients.toList()) { recipient ->
|
||||
ChatRow(
|
||||
recipient = recipient,
|
||||
onClick = onRemoveChat
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__choose_chats_you_do_not_want),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, start = 24.dp, end = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
ShowUnreadSection(state, onToggleShowUnread)
|
||||
ShowMutedSection(state, onToggleShowMuted)
|
||||
|
||||
if (!isNewFolder) {
|
||||
Dividers.Default()
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__delete_folder),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier
|
||||
.clickable { onDeleteClicked() }
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, top = 16.dp, bottom = 32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
item { Spacer(modifier = Modifier.height(60.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges && isNewFolder) {
|
||||
Buttons.MediumTonal(
|
||||
onClick = { onCreateConfirmed(true) },
|
||||
modifier = modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.CreateFoldersFragment__create))
|
||||
}
|
||||
} else if (!isNewFolder) {
|
||||
Buttons.MediumTonal(
|
||||
enabled = hasChanges,
|
||||
onClick = { onCreateConfirmed(true) },
|
||||
modifier = modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.CreateFoldersFragment__save))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShowUnreadSection(state: ChatFoldersSettingsState, onToggleShowUnread: (Boolean) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.defaultMinSize(minHeight = 92.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__only_show_unread_chats),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__when_enabled_only_chats),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.currentFolder.showUnread,
|
||||
onCheckedChange = onToggleShowUnread
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShowMutedSection(state: ChatFoldersSettingsState, onToggleShowMuted: (Boolean) -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.defaultMinSize(minHeight = 56.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.CreateFoldersFragment__include_muted_chats),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.currentFolder.showMutedChats,
|
||||
onCheckedChange = onToggleShowMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun CreateFolderPreview() {
|
||||
val previewFolder = ChatFolderRecord(id = 1, name = "WIP")
|
||||
|
||||
Previews.Preview {
|
||||
CreateFolderScreen(
|
||||
state = ChatFoldersSettingsState(currentFolder = previewFolder),
|
||||
focusRequester = FocusRequester(),
|
||||
isNewFolder = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun EditFolderPreview() {
|
||||
val previewFolder = ChatFolderRecord(id = 1, name = "Work")
|
||||
|
||||
Previews.Preview {
|
||||
CreateFolderScreen(
|
||||
state = ChatFoldersSettingsState(originalFolder = previewFolder),
|
||||
focusRequester = FocusRequester(),
|
||||
isNewFolder = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatRow(
|
||||
recipient: Recipient,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.clickable(onClick = onClick)
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 64.dp)
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 24.dp, end = 16.dp)
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = Color.Red,
|
||||
shape = CircleShape
|
||||
)
|
||||
)
|
||||
} else {
|
||||
AvatarImage(
|
||||
recipient = recipient,
|
||||
modifier = Modifier
|
||||
.padding(start = 24.dp, end = 16.dp)
|
||||
.size(40.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(text = recipient.getShortDisplayName(LocalContext.current))
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
@@ -106,7 +107,7 @@ class SelectRecipientsFragment : LoggingFragment(), ContactSelectionListFragment
|
||||
ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1
|
||||
}
|
||||
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>, callback: Consumer<Boolean>) {
|
||||
if (recipientId.isPresent) {
|
||||
viewModel.select(recipientId.get())
|
||||
callback.accept(true)
|
||||
@@ -116,7 +117,7 @@ class SelectRecipientsFragment : LoggingFragment(), ContactSelectionListFragment
|
||||
}
|
||||
}
|
||||
|
||||
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?) {
|
||||
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>) {
|
||||
if (recipientId.isPresent) {
|
||||
viewModel.deselect(recipientId.get())
|
||||
updateAddToProfile()
|
||||
|
||||
@@ -20,9 +20,9 @@ import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
*/
|
||||
class ContactChipViewModel : ViewModel() {
|
||||
|
||||
private val store = RxStore(emptyList<SelectedContacts.Model>())
|
||||
private val store = RxStore(emptyList<SelectedContacts.Model<*>>())
|
||||
|
||||
val state: Flowable<List<SelectedContacts.Model>> = store.stateFlowable
|
||||
val state: Flowable<List<SelectedContacts.Model<*>>> = store.stateFlowable
|
||||
.distinctUntilChanged()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
@@ -39,20 +39,27 @@ class ContactChipViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun add(selectedContact: SelectedContact) {
|
||||
disposables += getOrCreateRecipientId(selectedContact).map { Recipient.resolved(it) }.observeOn(Schedulers.io()).subscribe { recipient ->
|
||||
store.update { it + SelectedContacts.Model(selectedContact, recipient) }
|
||||
disposableMap[recipient.id]?.dispose()
|
||||
disposableMap[recipient.id] = store.update(recipient.live().observable().toFlowable(BackpressureStrategy.LATEST)) { changedRecipient, state ->
|
||||
val index = state.indexOfFirst { it.selectedContact.matches(selectedContact) }
|
||||
when {
|
||||
index == 0 -> {
|
||||
listOf(SelectedContacts.Model(selectedContact, changedRecipient)) + state.drop(index + 1)
|
||||
}
|
||||
index > 0 -> {
|
||||
state.take(index) + SelectedContacts.Model(selectedContact, changedRecipient) + state.drop(index + 1)
|
||||
}
|
||||
else -> {
|
||||
state
|
||||
if (selectedContact.hasChatType()) {
|
||||
store.update { it + SelectedContacts.ChatTypeModel(selectedContact) }
|
||||
} else {
|
||||
disposables += getOrCreateRecipientId(selectedContact).map { Recipient.resolved(it) }.observeOn(Schedulers.io()).subscribe { recipient ->
|
||||
store.update { it + SelectedContacts.RecipientModel(selectedContact, recipient) }
|
||||
disposableMap[recipient.id]?.dispose()
|
||||
disposableMap[recipient.id] = store.update(recipient.live().observable().toFlowable(BackpressureStrategy.LATEST)) { changedRecipient, state ->
|
||||
val index = state.indexOfFirst { it.selectedContact.matches(selectedContact) }
|
||||
|
||||
when {
|
||||
index == 0 -> {
|
||||
listOf(SelectedContacts.RecipientModel(selectedContact, changedRecipient)) + state.drop(index + 1)
|
||||
}
|
||||
|
||||
index > 0 -> {
|
||||
state.take(index) + SelectedContacts.RecipientModel(selectedContact, changedRecipient) + state.drop(index + 1)
|
||||
}
|
||||
|
||||
else -> {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
@@ -19,23 +20,29 @@ public final class SelectedContact {
|
||||
private final RecipientId recipientId;
|
||||
private final String number;
|
||||
private final String username;
|
||||
private final ChatType chatType;
|
||||
|
||||
public static @NonNull SelectedContact forPhone(@Nullable RecipientId recipientId, @NonNull String number) {
|
||||
return new SelectedContact(recipientId, number, null);
|
||||
return new SelectedContact(recipientId, number, null, null);
|
||||
}
|
||||
|
||||
public static @NonNull SelectedContact forUsername(@Nullable RecipientId recipientId, @NonNull String username) {
|
||||
return new SelectedContact(recipientId, null, username);
|
||||
return new SelectedContact(recipientId, null, username, null);
|
||||
}
|
||||
|
||||
public static @NonNull SelectedContact forChatType(@NonNull ChatType chatType) {
|
||||
return new SelectedContact(null, null, null, chatType);
|
||||
}
|
||||
|
||||
public static @NonNull SelectedContact forRecipientId(@NonNull RecipientId recipientId) {
|
||||
return new SelectedContact(recipientId, null, null);
|
||||
return new SelectedContact(recipientId, null, null, null);
|
||||
}
|
||||
|
||||
private SelectedContact(@Nullable RecipientId recipientId, @Nullable String number, @Nullable String username) {
|
||||
private SelectedContact(@Nullable RecipientId recipientId, @Nullable String number, @Nullable String username, @Nullable ChatType chatType) {
|
||||
this.recipientId = recipientId;
|
||||
this.number = number;
|
||||
this.username = username;
|
||||
this.chatType = chatType;
|
||||
}
|
||||
|
||||
public @NonNull RecipientId getOrCreateRecipientId(@NonNull Context context) {
|
||||
@@ -60,6 +67,14 @@ public final class SelectedContact {
|
||||
return username != null;
|
||||
}
|
||||
|
||||
public boolean hasChatType() {
|
||||
return chatType != null;
|
||||
}
|
||||
|
||||
public ChatType getChatType() {
|
||||
return chatType;
|
||||
}
|
||||
|
||||
public @NonNull ContactSearchKey toContactSearchKey() {
|
||||
if (recipientId != null) {
|
||||
return new ContactSearchKey.RecipientSearchKey(recipientId, false);
|
||||
@@ -67,6 +82,8 @@ public final class SelectedContact {
|
||||
return new ContactSearchKey.UnknownRecipientKey(ContactSearchConfiguration.SectionKey.PHONE_NUMBER, number);
|
||||
} else if (username != null) {
|
||||
return new ContactSearchKey.UnknownRecipientKey(ContactSearchConfiguration.SectionKey.USERNAME, username);
|
||||
} else if (chatType != null) {
|
||||
return new ContactSearchKey.ChatTypeSearchKey(chatType);
|
||||
} else {
|
||||
throw new IllegalStateException("Nothing to map!");
|
||||
}
|
||||
@@ -86,6 +103,7 @@ public final class SelectedContact {
|
||||
}
|
||||
|
||||
return number != null && number .equals(other.number) ||
|
||||
username != null && username.equals(other.username);
|
||||
username != null && username.equals(other.username) ||
|
||||
chatType != null && chatType.equals(other.chatType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package org.thoughtcrime.securesms.contacts
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.view.View
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.bumptech.glide.Glide
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
@@ -11,25 +15,28 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
|
||||
object SelectedContacts {
|
||||
@JvmStatic
|
||||
fun register(adapter: MappingAdapter, onCloseIconClicked: (Model) -> Unit) {
|
||||
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onCloseIconClicked) }, R.layout.contact_selection_list_chip))
|
||||
fun register(adapter: MappingAdapter, onCloseIconClicked: (Model<*>) -> Unit) {
|
||||
adapter.registerFactory(RecipientModel::class.java, LayoutFactory({ RecipientViewHolder(it, onCloseIconClicked) }, R.layout.contact_selection_list_chip))
|
||||
adapter.registerFactory(ChatTypeModel::class.java, LayoutFactory({ ChatTypeViewHolder(it, onCloseIconClicked) }, R.layout.contact_selection_list_chip))
|
||||
}
|
||||
|
||||
class Model(val selectedContact: SelectedContact, val recipient: Recipient) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
sealed class Model<T : Any>(val selectedContact: SelectedContact) : MappingModel<T>
|
||||
|
||||
class RecipientModel(selectedContact: SelectedContact, val recipient: Recipient) : Model<RecipientModel>(selectedContact = selectedContact) {
|
||||
override fun areItemsTheSame(newItem: RecipientModel): Boolean {
|
||||
return newItem.selectedContact.matches(selectedContact) && recipient == newItem.recipient
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
override fun areContentsTheSame(newItem: RecipientModel): Boolean {
|
||||
return areItemsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View, private val onCloseIconClicked: (Model) -> Unit) : MappingViewHolder<Model>(itemView) {
|
||||
private class RecipientViewHolder(itemView: View, private val onCloseIconClicked: (RecipientModel) -> Unit) : MappingViewHolder<RecipientModel>(itemView) {
|
||||
|
||||
private val chip: ContactChip = itemView.findViewById(R.id.contact_chip)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
override fun bind(model: RecipientModel) {
|
||||
chip.text = model.recipient.getShortDisplayName(context)
|
||||
chip.setContact(model.selectedContact)
|
||||
chip.isCloseIconVisible = true
|
||||
@@ -39,4 +46,36 @@ object SelectedContacts {
|
||||
chip.setAvatar(Glide.with(itemView), model.recipient, null)
|
||||
}
|
||||
}
|
||||
|
||||
class ChatTypeModel(selectedContact: SelectedContact) : Model<ChatTypeModel>(selectedContact = selectedContact) {
|
||||
override fun areItemsTheSame(newItem: ChatTypeModel): Boolean {
|
||||
return newItem.selectedContact.matches(selectedContact) && newItem.selectedContact.chatType == selectedContact.chatType
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: ChatTypeModel): Boolean {
|
||||
return areItemsTheSame(newItem)
|
||||
}
|
||||
}
|
||||
|
||||
private class ChatTypeViewHolder(itemView: View, private val onCloseIconClicked: (ChatTypeModel) -> Unit) : MappingViewHolder<ChatTypeModel>(itemView) {
|
||||
|
||||
private val chip: ContactChip = itemView.findViewById(R.id.contact_chip)
|
||||
|
||||
override fun bind(model: ChatTypeModel) {
|
||||
if (model.selectedContact.chatType == ChatType.INDIVIDUAL) {
|
||||
chip.text = context.getString(R.string.ChatFoldersFragment__one_on_one_chats)
|
||||
chip.chipIcon = AppCompatResources.getDrawable(context, R.drawable.symbol_person_light_24)
|
||||
chip.chipIconTint = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.signal_colorOnSurface))
|
||||
} else {
|
||||
chip.text = context.getString(R.string.ChatFoldersFragment__groups)
|
||||
chip.chipIcon = AppCompatResources.getDrawable(context, R.drawable.symbol_group_light_20)
|
||||
chip.chipIconTint = ColorStateList.valueOf(ContextCompat.getColor(context, R.color.signal_colorOnSurface))
|
||||
}
|
||||
chip.setContact(model.selectedContact)
|
||||
chip.isCloseIconVisible = true
|
||||
chip.setOnCloseIconClickListener {
|
||||
onCloseIconClicked(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.thoughtcrime.securesms.contacts.paged
|
||||
|
||||
/**
|
||||
* Enum class that represents the different chat types a chat folder can have
|
||||
*/
|
||||
enum class ChatType {
|
||||
INDIVIDUAL,
|
||||
GROUPS
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.text.SpannableStringBuilder
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CheckBox
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -57,6 +58,7 @@ open class ContactSearchAdapter(
|
||||
registerKnownRecipientItems(this, fixedContacts, displayOptions, onClickCallbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick, callButtonClickCallbacks)
|
||||
registerHeaders(this)
|
||||
registerExpands(this, onClickCallbacks::onExpandClicked)
|
||||
registerChatTypeItems(this, onClickCallbacks::onChatTypeClicked)
|
||||
registerFactory(UnknownRecipientModel::class.java, LayoutFactory({ UnknownRecipientViewHolder(it, onClickCallbacks::onUnknownRecipientClicked, displayOptions.displayCheckBox) }, R.layout.contact_search_unknown_item))
|
||||
}
|
||||
|
||||
@@ -117,6 +119,13 @@ open class ContactSearchAdapter(
|
||||
)
|
||||
}
|
||||
|
||||
fun registerChatTypeItems(mappingAdapter: MappingAdapter, chatTypeRowListener: OnClickedCallback<ContactSearchData.ChatTypeRow>) {
|
||||
mappingAdapter.registerFactory(
|
||||
ChatTypeModel::class.java,
|
||||
LayoutFactory({ ChatTypeViewHolder(it, chatTypeRowListener) }, R.layout.contact_search_chat_type_item)
|
||||
)
|
||||
}
|
||||
|
||||
fun toMappingModelList(contactSearchData: List<ContactSearchData?>, selection: Set<ContactSearchKey>, arbitraryRepository: ArbitraryRepository?): MappingModelList {
|
||||
return MappingModelList(
|
||||
contactSearchData.filterNotNull().map {
|
||||
@@ -132,6 +141,7 @@ open class ContactSearchAdapter(
|
||||
is ContactSearchData.Empty -> EmptyModel(it)
|
||||
is ContactSearchData.GroupWithMembers -> GroupWithMembersModel(it)
|
||||
is ContactSearchData.UnknownRecipient -> UnknownRecipientModel(it)
|
||||
is ContactSearchData.ChatTypeRow -> ChatTypeModel(it, selection.contains(it.contactSearchKey))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -675,6 +685,7 @@ open class ContactSearchAdapter(
|
||||
ContactSearchConfiguration.SectionKey.MESSAGES -> R.string.ContactsCursorLoader__messages
|
||||
ContactSearchConfiguration.SectionKey.GROUPS_WITH_MEMBERS -> R.string.ContactsCursorLoader_group_members
|
||||
ContactSearchConfiguration.SectionKey.CONTACTS_WITHOUT_THREADS -> R.string.ContactsCursorLoader_contacts
|
||||
ContactSearchConfiguration.SectionKey.CHAT_TYPES -> R.string.ContactsCursorLoader__chat_types
|
||||
else -> error("This section does not support HEADER")
|
||||
}
|
||||
)
|
||||
@@ -712,6 +723,42 @@ open class ContactSearchAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping Model for chat types.
|
||||
*/
|
||||
class ChatTypeModel(val data: ContactSearchData.ChatTypeRow, val isSelected: Boolean) : MappingModel<ChatTypeModel> {
|
||||
override fun areItemsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data
|
||||
override fun areContentsTheSame(newItem: ChatTypeModel): Boolean = data == newItem.data && isSelected == newItem.isSelected
|
||||
}
|
||||
|
||||
/**
|
||||
* View Holder for chat types
|
||||
*/
|
||||
private class ChatTypeViewHolder(
|
||||
itemView: View,
|
||||
val onClick: OnClickedCallback<ContactSearchData.ChatTypeRow>
|
||||
) : MappingViewHolder<ChatTypeModel>(itemView) {
|
||||
|
||||
val image: ImageView = itemView.findViewById(R.id.image)
|
||||
val name: TextView = itemView.findViewById(R.id.name)
|
||||
val checkbox: CheckBox = itemView.findViewById(R.id.check_box)
|
||||
|
||||
override fun bind(model: ChatTypeModel) {
|
||||
itemView.setOnClickListener { onClick.onClicked(itemView, model.data, model.isSelected) }
|
||||
|
||||
image.setImageResource(model.data.imageResId)
|
||||
|
||||
if (model.data.chatType == ChatType.INDIVIDUAL) {
|
||||
name.text = context.getString(R.string.ChatFoldersFragment__one_on_one_chats)
|
||||
}
|
||||
if (model.data.chatType == ChatType.GROUPS) {
|
||||
name.text = context.getString(R.string.ChatFoldersFragment__groups)
|
||||
}
|
||||
|
||||
checkbox.isChecked = model.isSelected
|
||||
}
|
||||
}
|
||||
|
||||
private class IsSelfComparator : Comparator<Recipient> {
|
||||
override fun compare(lhs: Recipient?, rhs: Recipient?): Int {
|
||||
val isLeftSelf = lhs?.isSelf == true
|
||||
@@ -764,6 +811,7 @@ open class ContactSearchAdapter(
|
||||
fun onUnknownRecipientClicked(view: View, unknownRecipient: ContactSearchData.UnknownRecipient, isSelected: Boolean) {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
fun onChatTypeClicked(view: View, chatTypeRow: ContactSearchData.ChatTypeRow, isSelected: Boolean)
|
||||
}
|
||||
|
||||
interface CallButtonClickCallbacks {
|
||||
|
||||
@@ -193,6 +193,18 @@ class ContactSearchConfiguration private constructor(
|
||||
override val includeHeader: Boolean = false
|
||||
override val expandConfig: ExpandConfig? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat types that are displayed when creating a chat folder.
|
||||
*
|
||||
* Key: [ContactSearchKey.ChatType]
|
||||
* Data: [ContactSearchData.ChatTypeRow]
|
||||
* Model: [ContactSearchAdapter.ChatTypeModel]
|
||||
*/
|
||||
data class ChatTypes(
|
||||
override val includeHeader: Boolean = true,
|
||||
override val expandConfig: ExpandConfig? = null
|
||||
) : Section(SectionKey.CHAT_TYPES)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,6 +246,11 @@ class ContactSearchConfiguration private constructor(
|
||||
*/
|
||||
CONTACTS_WITHOUT_THREADS,
|
||||
|
||||
/**
|
||||
* Chat types (ie unreads, 1:1, groups) that are used to customize folders
|
||||
*/
|
||||
CHAT_TYPES,
|
||||
|
||||
/**
|
||||
* Arbitrary row (think new group button, username row, etc)
|
||||
*/
|
||||
|
||||
@@ -69,6 +69,14 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
|
||||
val action: HeaderAction?
|
||||
) : ContactSearchData(ContactSearchKey.Header(sectionKey))
|
||||
|
||||
/**
|
||||
* A row containing a chat type (filters that can be applied to a chat folders)
|
||||
*/
|
||||
class ChatTypeRow(
|
||||
val imageResId: Int,
|
||||
val chatType: ChatType
|
||||
) : ContactSearchData(ContactSearchKey.ChatTypeSearchKey(chatType))
|
||||
|
||||
/**
|
||||
* A row which the user can click to view all entries for a given section.
|
||||
*/
|
||||
|
||||
@@ -76,5 +76,14 @@ sealed class ContactSearchKey {
|
||||
*/
|
||||
data class Message(val messageId: Long) : ContactSearchKey()
|
||||
|
||||
/**
|
||||
* Search key for a ChatType
|
||||
*/
|
||||
data class ChatTypeSearchKey(val chatType: ChatType) : ContactSearchKey() {
|
||||
override fun requireSelectedContact(): SelectedContact {
|
||||
return SelectedContact.forChatType(chatType)
|
||||
}
|
||||
}
|
||||
|
||||
object Empty : ContactSearchKey()
|
||||
}
|
||||
|
||||
@@ -87,6 +87,11 @@ class ContactSearchMediator(
|
||||
Log.d(TAG, "onExpandClicked()")
|
||||
viewModel.expandSection(expand.sectionKey)
|
||||
}
|
||||
|
||||
override fun onChatTypeClicked(view: View, chatTypeRow: ContactSearchData.ChatTypeRow, isSelected: Boolean) {
|
||||
Log.d(TAG, "onChatTypeClicked() chatType $chatTypeRow")
|
||||
toggleChatTypeSelection(view, chatTypeRow, isSelected)
|
||||
}
|
||||
},
|
||||
longClickCallbacks = ContactSearchAdapter.LongClickCallbacksAdapter(),
|
||||
storyContextMenuCallbacks = StoryContextMenuCallbacks(),
|
||||
@@ -188,6 +193,16 @@ class ContactSearchMediator(
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleChatTypeSelection(view: View, contactSearchData: ContactSearchData, isSelected: Boolean) {
|
||||
return if (isSelected) {
|
||||
Log.d(TAG, "toggleSelection(OFF) ${contactSearchData.contactSearchKey}")
|
||||
viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey))
|
||||
} else {
|
||||
Log.d(TAG, "toggleSelection(ON) ${contactSearchData.contactSearchKey}")
|
||||
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(view, setOf(contactSearchData.contactSearchKey)))
|
||||
}
|
||||
}
|
||||
|
||||
private inner class StoryContextMenuCallbacks : ContactSearchAdapter.StoryContextMenuCallbacks {
|
||||
override fun onOpenStorySettings(story: ContactSearchData.Story) {
|
||||
if (story.recipient.isMyStory) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.contacts.paged
|
||||
import android.database.Cursor
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.paging.PagedDataSource
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.ContactRepository
|
||||
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchCollection
|
||||
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator
|
||||
@@ -142,6 +143,7 @@ class ContactSearchPagedDataSource(
|
||||
is ContactSearchConfiguration.Section.PhoneNumber -> if (isPossiblyPhoneNumber(query)) 1 else 0
|
||||
is ContactSearchConfiguration.Section.Username -> if (isPossiblyUsername(query)) 1 else 0
|
||||
is ContactSearchConfiguration.Section.Empty -> 1
|
||||
is ContactSearchConfiguration.Section.ChatTypes -> getChatTypesData(section).size
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +183,7 @@ class ContactSearchPagedDataSource(
|
||||
is ContactSearchConfiguration.Section.PhoneNumber -> getPossiblePhoneNumber(section, query)
|
||||
is ContactSearchConfiguration.Section.Username -> getPossibleUsername(section, query)
|
||||
is ContactSearchConfiguration.Section.Empty -> listOf(ContactSearchData.Empty(query))
|
||||
is ContactSearchConfiguration.Section.ChatTypes -> getChatTypesData(section)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +351,22 @@ class ContactSearchPagedDataSource(
|
||||
}
|
||||
}
|
||||
|
||||
// TODO [michelle]: Replace hardcoding chat types after building db
|
||||
private fun getChatTypesData(section: ContactSearchConfiguration.Section.ChatTypes): List<ContactSearchData> {
|
||||
val data = mutableListOf<ContactSearchData>()
|
||||
|
||||
if (section.includeHeader) {
|
||||
data.add(ContactSearchData.Header(section.sectionKey, section.headerAction))
|
||||
}
|
||||
data.addAll(
|
||||
listOf(
|
||||
ContactSearchData.ChatTypeRow(R.drawable.symbol_person_light_24, ChatType.INDIVIDUAL),
|
||||
ContactSearchData.ChatTypeRow(R.drawable.symbol_group_light_20, ChatType.GROUPS)
|
||||
)
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
private fun getContactsWithoutThreadsContactData(section: ContactSearchConfiguration.Section.ContactsWithoutThreads, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
|
||||
return getContactsWithoutThreadsIterator(query).use { records ->
|
||||
readContactData(
|
||||
|
||||
@@ -21,6 +21,7 @@ class ContactSearchRepository {
|
||||
val isSelectable = when (it) {
|
||||
is ContactSearchKey.RecipientSearchKey -> canSelectRecipient(it.recipientId)
|
||||
is ContactSearchKey.UnknownRecipientKey -> it.sectionKey == ContactSearchConfiguration.SectionKey.PHONE_NUMBER
|
||||
is ContactSearchKey.ChatTypeSearchKey -> true
|
||||
else -> false
|
||||
}
|
||||
ContactSearchSelectionResult(it, isSelectable)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.thoughtcrime.securesms.conversationlist
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* RecyclerView adapter for the chat folders displayed on conversation list
|
||||
*/
|
||||
class ChatFolderAdapter(val callbacks: Callbacks) : MappingAdapter() {
|
||||
|
||||
init {
|
||||
registerFactory(ChatFolderMappingModel::class.java, LayoutFactory({ v -> ViewHolder(v, callbacks) }, R.layout.chat_folder_item))
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View, private val callbacks: Callbacks) : MappingViewHolder<ChatFolderMappingModel>(itemView) {
|
||||
|
||||
private val name: TextView = findViewById(R.id.name)
|
||||
private val unreadCount: TextView = findViewById(R.id.unread_count)
|
||||
|
||||
override fun bind(model: ChatFolderMappingModel) {
|
||||
itemView.isSelected = model.isSelected
|
||||
|
||||
val folder = model.chatFolder
|
||||
name.text = getName(itemView.context, folder)
|
||||
unreadCount.visible = folder.unreadCount > 0
|
||||
unreadCount.text = folder.unreadCount.toString()
|
||||
itemView.setOnClickListener {
|
||||
callbacks.onChatFolderClicked(model.chatFolder)
|
||||
}
|
||||
if (model.isSelected) {
|
||||
itemView.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(itemView.context, R.color.signal_colorSurfaceVariant))
|
||||
} else {
|
||||
itemView.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(itemView.context, R.color.transparent))
|
||||
}
|
||||
}
|
||||
|
||||
private fun getName(context: Context, folder: ChatFolderRecord): String {
|
||||
return if (folder.folderType == ChatFolderRecord.FolderType.ALL) {
|
||||
context.getString(R.string.ChatFoldersFragment__all_chats)
|
||||
} else {
|
||||
folder.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
fun onChatFolderClicked(chatFolder: ChatFolderRecord)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.thoughtcrime.securesms.conversationlist
|
||||
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
|
||||
data class ChatFolderMappingModel(
|
||||
val chatFolder: ChatFolderRecord,
|
||||
val isSelected: Boolean
|
||||
) : MappingModel<ChatFolderMappingModel> {
|
||||
override fun areItemsTheSame(newItem: ChatFolderMappingModel): Boolean {
|
||||
return chatFolder == newItem.chatFolder
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: ChatFolderMappingModel): Boolean {
|
||||
return areItemsTheSame(newItem) && isSelected == newItem.isSelected
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ public class ConversationListArchiveFragment extends ConversationListFragment im
|
||||
{
|
||||
private View coordinator;
|
||||
private RecyclerView list;
|
||||
private RecyclerView foldersList;
|
||||
private Stub<View> emptyState;
|
||||
private PulsingFloatingActionButton fab;
|
||||
private PulsingFloatingActionButton cameraFab;
|
||||
@@ -73,12 +74,14 @@ public class ConversationListArchiveFragment extends ConversationListFragment im
|
||||
emptyState = new Stub<>(view.findViewById(R.id.empty_state));
|
||||
fab = view.findViewById(R.id.fab);
|
||||
cameraFab = view.findViewById(R.id.camera_fab);
|
||||
foldersList = view.findViewById(R.id.chat_folder_list);
|
||||
|
||||
toolbar.get().setNavigationOnClickListener(v -> NavHostFragment.findNavController(this).popBackStack());
|
||||
toolbar.get().setTitle(R.string.AndroidManifest_archived_conversations);
|
||||
|
||||
fab.hide();
|
||||
cameraFab.hide();
|
||||
foldersList.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.annotation.VisibleForTesting;
|
||||
import org.signal.core.util.Stopwatch;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.paging.PagedDataSource;
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationReader;
|
||||
@@ -40,16 +41,18 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
||||
protected final ThreadTable threadTable;
|
||||
protected final ConversationFilter conversationFilter;
|
||||
protected final boolean showConversationFooterTip;
|
||||
protected final ChatFolderRecord chatFolder;
|
||||
|
||||
protected ConversationListDataSource(@NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
|
||||
protected ConversationListDataSource(ChatFolderRecord chatFolder, @NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
|
||||
this.chatFolder = chatFolder;
|
||||
this.threadTable = SignalDatabase.threads();
|
||||
this.conversationFilter = conversationFilter;
|
||||
this.showConversationFooterTip = showConversationFooterTip;
|
||||
}
|
||||
|
||||
public static ConversationListDataSource create(@NonNull ConversationFilter conversationFilter, boolean isArchived, boolean showConversationFooterTip) {
|
||||
if (!isArchived) return new UnarchivedConversationListDataSource(conversationFilter, showConversationFooterTip);
|
||||
else return new ArchivedConversationListDataSource(conversationFilter, showConversationFooterTip);
|
||||
public static ConversationListDataSource create(ChatFolderRecord chatFolder, @NonNull ConversationFilter conversationFilter, boolean isArchived, boolean showConversationFooterTip) {
|
||||
if (!isArchived) return new UnarchivedConversationListDataSource(chatFolder, conversationFilter, showConversationFooterTip);
|
||||
else return new ArchivedConversationListDataSource(chatFolder, conversationFilter, showConversationFooterTip);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -136,8 +139,8 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
||||
|
||||
private int totalCount;
|
||||
|
||||
ArchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
|
||||
super(conversationFilter, showConversationFooterTip);
|
||||
ArchivedConversationListDataSource(@NonNull ChatFolderRecord chatFolder, @NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
|
||||
super(chatFolder, conversationFilter, showConversationFooterTip);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -168,33 +171,23 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
||||
private int totalCount;
|
||||
private int pinnedCount;
|
||||
private int archivedCount;
|
||||
private int unpinnedCount;
|
||||
|
||||
UnarchivedConversationListDataSource(@NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
|
||||
super(conversationFilter, showConversationFooterTip);
|
||||
UnarchivedConversationListDataSource(@NonNull ChatFolderRecord chatFolder, @NonNull ConversationFilter conversationFilter, boolean showConversationFooterTip) {
|
||||
super(chatFolder, conversationFilter, showConversationFooterTip);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getTotalCount() {
|
||||
int unarchivedCount = threadTable.getUnarchivedConversationListCount(conversationFilter);
|
||||
int unarchivedCount = threadTable.getUnarchivedConversationListCount(conversationFilter, chatFolder);
|
||||
|
||||
pinnedCount = threadTable.getPinnedConversationListCount(conversationFilter);
|
||||
pinnedCount = threadTable.getPinnedConversationListCount(conversationFilter, chatFolder);
|
||||
archivedCount = threadTable.getArchivedConversationListCount(conversationFilter);
|
||||
unpinnedCount = unarchivedCount - pinnedCount;
|
||||
totalCount = unarchivedCount;
|
||||
|
||||
if (archivedCount != 0) {
|
||||
if (chatFolder.getFolderType() == ChatFolderRecord.FolderType.ALL && archivedCount != 0) {
|
||||
totalCount++;
|
||||
}
|
||||
|
||||
if (pinnedCount != 0) {
|
||||
if (unpinnedCount != 0) {
|
||||
totalCount += 2;
|
||||
} else {
|
||||
totalCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
@@ -203,26 +196,12 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
||||
List<Cursor> cursors = new ArrayList<>(5);
|
||||
long originalLimit = limit;
|
||||
|
||||
if (offset == 0 && hasPinnedHeader()) {
|
||||
MatrixCursor pinnedHeaderCursor = new MatrixCursor(ConversationReader.HEADER_COLUMN);
|
||||
pinnedHeaderCursor.addRow(ConversationReader.PINNED_HEADER);
|
||||
cursors.add(pinnedHeaderCursor);
|
||||
limit--;
|
||||
}
|
||||
|
||||
Cursor pinnedCursor = threadTable.getUnarchivedConversationList(conversationFilter, true, offset, limit);
|
||||
Cursor pinnedCursor = threadTable.getUnarchivedConversationList(conversationFilter, true, offset, limit, chatFolder);
|
||||
cursors.add(pinnedCursor);
|
||||
limit -= pinnedCursor.getCount();
|
||||
|
||||
if (offset == 0 && hasUnpinnedHeader()) {
|
||||
MatrixCursor unpinnedHeaderCursor = new MatrixCursor(ConversationReader.HEADER_COLUMN);
|
||||
unpinnedHeaderCursor.addRow(ConversationReader.UNPINNED_HEADER);
|
||||
cursors.add(unpinnedHeaderCursor);
|
||||
limit--;
|
||||
}
|
||||
|
||||
long unpinnedOffset = Math.max(0, offset - pinnedCount - getHeaderOffset());
|
||||
Cursor unpinnedCursor = threadTable.getUnarchivedConversationList(conversationFilter, false, unpinnedOffset, limit);
|
||||
long unpinnedOffset = Math.max(0, offset - pinnedCount);
|
||||
Cursor unpinnedCursor = threadTable.getUnarchivedConversationList(conversationFilter, false, unpinnedOffset, limit, chatFolder);
|
||||
cursors.add(unpinnedCursor);
|
||||
|
||||
boolean shouldInsertConversationFilterFooter = offset + originalLimit >= totalCount && hasConversationFilterFooter();
|
||||
@@ -242,24 +221,9 @@ abstract class ConversationListDataSource implements PagedDataSource<Long, Conve
|
||||
return new MergeCursor(cursors.toArray(new Cursor[]{}));
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
int getHeaderOffset() {
|
||||
return (hasPinnedHeader() ? 1 : 0) + (hasUnpinnedHeader() ? 1 : 0);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
boolean hasPinnedHeader() {
|
||||
return pinnedCount != 0;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
boolean hasUnpinnedHeader() {
|
||||
return hasPinnedHeader() && unpinnedCount != 0;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
boolean hasArchivedFooter() {
|
||||
return archivedCount != 0;
|
||||
return archivedCount != 0 && chatFolder.getFolderType() == ChatFolderRecord.FolderType.ALL;
|
||||
}
|
||||
|
||||
boolean hasConversationFilterFooter() {
|
||||
|
||||
@@ -114,6 +114,7 @@ import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar;
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
|
||||
import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord;
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation;
|
||||
@@ -167,6 +168,7 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.CachedInflater;
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalProxyUtil;
|
||||
@@ -200,7 +202,8 @@ import static android.app.Activity.RESULT_OK;
|
||||
public class ConversationListFragment extends MainFragment implements ActionMode.Callback,
|
||||
ConversationListAdapter.OnConversationClickListener,
|
||||
MegaphoneActionController,
|
||||
ClearFilterViewHolder.OnClearFilterClickListener
|
||||
ClearFilterViewHolder.OnClearFilterClickListener,
|
||||
ChatFolderAdapter.Callbacks
|
||||
{
|
||||
public static final short MESSAGE_REQUESTS_REQUEST_CODE_CREATE_NAME = 32562;
|
||||
public static final short SMS_ROLE_REQUEST_CODE = 32563;
|
||||
@@ -216,6 +219,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
|
||||
private ActionMode actionMode;
|
||||
private View coordinator;
|
||||
private RecyclerView chatFolderList;
|
||||
private RecyclerView list;
|
||||
private Stub<ComposeView> bannerView;
|
||||
private PulsingFloatingActionButton fab;
|
||||
@@ -236,6 +240,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
private SignalBottomActionBar bottomActionBar;
|
||||
private SignalContextMenu activeContextMenu;
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
private ChatFolderAdapter chatFolderAdapter;
|
||||
|
||||
protected ConversationListArchiveItemDecoration archiveDecoration;
|
||||
protected ConversationListItemAnimator itemAnimator;
|
||||
@@ -281,6 +286,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
lifecycleDisposable.bindTo(getViewLifecycleOwner());
|
||||
|
||||
coordinator = view.findViewById(R.id.coordinator);
|
||||
chatFolderList = view.findViewById(R.id.chat_folder_list);
|
||||
list = view.findViewById(R.id.list);
|
||||
bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar);
|
||||
bannerView = new Stub<>(view.findViewById(R.id.banner_compose_view));
|
||||
@@ -293,6 +299,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
|
||||
fab.setVisibility(View.VISIBLE);
|
||||
cameraFab.setVisibility(View.VISIBLE);
|
||||
chatFolderList.setVisibility(RemoteConfig.internalUser() ? View.VISIBLE : View.GONE);
|
||||
|
||||
contactSearchMediator = new ContactSearchMediator(this,
|
||||
Collections.emptySet(),
|
||||
@@ -381,6 +388,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
archiveDecoration = new ConversationListArchiveItemDecoration(new ColorDrawable(getResources().getColor(R.color.conversation_list_archive_background_end)));
|
||||
itemAnimator = new ConversationListItemAnimator();
|
||||
|
||||
chatFolderAdapter = new ChatFolderAdapter(this);
|
||||
|
||||
chatFolderList.setLayoutManager(new LinearLayoutManager(requireActivity(), LinearLayoutManager.HORIZONTAL, false));
|
||||
chatFolderList.setAdapter(chatFolderAdapter);
|
||||
chatFolderList.setItemAnimator(null);
|
||||
|
||||
list.setLayoutManager(new LinearLayoutManager(requireActivity()));
|
||||
list.setItemAnimator(itemAnimator);
|
||||
list.addItemDecoration(archiveDecoration);
|
||||
@@ -972,6 +985,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
lifecycleDisposable.add(viewModel.getHasNoConversations().subscribe(this::updateEmptyState));
|
||||
lifecycleDisposable.add(viewModel.getNotificationProfiles().subscribe(profiles -> requireCallback().updateNotificationProfileStatus(profiles)));
|
||||
lifecycleDisposable.add(viewModel.getWebSocketState().subscribe(pipeState -> requireCallback().updateProxyStatus(pipeState)));
|
||||
lifecycleDisposable.add(viewModel.getChatFolderState().subscribe(this::onChatFoldersChanged));
|
||||
|
||||
appForegroundObserver = new AppForegroundObserver.Listener() {
|
||||
@Override
|
||||
@@ -1031,6 +1045,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
});
|
||||
}
|
||||
|
||||
private void onChatFoldersChanged(List<ChatFolderMappingModel> folders) {
|
||||
chatFolderAdapter.submitList(new ArrayList<>(folders));
|
||||
}
|
||||
|
||||
private void onMegaphoneChanged(@NonNull Megaphone megaphone) {
|
||||
if (megaphone == Megaphone.NONE || isArchived() || getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
if (megaphoneContainer.resolved()) {
|
||||
@@ -1643,6 +1661,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
pullViewAppBarLayout.setExpanded(false, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChatFolderClicked(@NonNull ChatFolderRecord chatFolder) {
|
||||
viewModel.select(chatFolder);
|
||||
}
|
||||
|
||||
private class ArchiveListenerCallback extends ItemTouchHelper.SimpleCallback {
|
||||
|
||||
private static final long SWIPE_ANIMATION_DURATION = 175;
|
||||
@@ -1887,6 +1910,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChatTypeClicked(@NonNull View view, @NonNull ContactSearchData.ChatTypeRow chatTypeRow, boolean isSelected) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
|
||||
public interface Callback extends Material3OnScrollHelperBinder, SearchBinder {
|
||||
|
||||
@@ -134,6 +134,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
|
||||
private View uncheckedView;
|
||||
private View checkedView;
|
||||
private View unreadMentions;
|
||||
private View pinnedView;
|
||||
private int thumbSize;
|
||||
private GlideLiveDataTarget thumbTarget;
|
||||
|
||||
@@ -170,6 +171,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
|
||||
this.uncheckedView = findViewById(R.id.conversation_list_item_unchecked);
|
||||
this.checkedView = findViewById(R.id.conversation_list_item_checked);
|
||||
this.unreadMentions = findViewById(R.id.conversation_list_item_unread_mentions_indicator);
|
||||
this.pinnedView = findViewById(R.id.conversation_list_item_pinned);
|
||||
this.thumbSize = (int) DimensionUnit.SP.toPixels(16f);
|
||||
this.thumbTarget = new GlideLiveDataTarget(thumbSize, thumbSize);
|
||||
this.searchStyleFactory = () -> new CharacterStyle[] { new ForegroundColorSpan(ContextCompat.getColor(getContext(), R.color.signal_colorOnSurface)), SpanUtil.getBoldSpan() };
|
||||
@@ -279,6 +281,12 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
|
||||
this.archivedView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (thread.isPinned()) {
|
||||
this.pinnedView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
this.pinnedView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
setStatusIcons(thread);
|
||||
setSelectedConversations(selectedConversations);
|
||||
setBadgeFromRecipient(recipient.get());
|
||||
|
||||
@@ -2,16 +2,22 @@ package org.thoughtcrime.securesms.conversationlist
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.Flowables
|
||||
import io.reactivex.rxjava3.kotlin.addTo
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.paging.PagedData
|
||||
import org.signal.paging.PagingConfig
|
||||
import org.signal.paging.ProxyPagingController
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFoldersRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource
|
||||
@@ -53,10 +59,15 @@ class ConversationListViewModel(
|
||||
val megaphoneState: Flowable<Megaphone> = store.mapDistinctForUi { it.megaphone }
|
||||
val selectedState: Flowable<ConversationSet> = store.mapDistinctForUi { it.selectedConversations }
|
||||
val filterRequestState: Flowable<ConversationFilterRequest> = store.mapDistinctForUi { it.filterRequest }
|
||||
val chatFolderState: Flowable<List<ChatFolderMappingModel>> = store.mapDistinctForUi { it.chatFolders }
|
||||
val hasNoConversations: Flowable<Boolean>
|
||||
|
||||
val controller = ProxyPagingController<Long>()
|
||||
|
||||
val folders: List<ChatFolderMappingModel>
|
||||
get() = store.state.chatFolders
|
||||
val currentFolder: ChatFolderRecord
|
||||
get() = store.state.currentFolder
|
||||
val conversationFilterRequest: ConversationFilterRequest
|
||||
get() = store.state.filterRequest
|
||||
val megaphone: Megaphone
|
||||
@@ -74,13 +85,14 @@ class ConversationListViewModel(
|
||||
conversationListDataSource = store
|
||||
.stateFlowable
|
||||
.subscribeOn(Schedulers.io())
|
||||
.map { it.filterRequest }
|
||||
.map { it.filterRequest to it.currentFolder }
|
||||
.distinctUntilChanged()
|
||||
.map {
|
||||
.map { (filterRequest, folder) ->
|
||||
ConversationListDataSource.create(
|
||||
it.filter,
|
||||
folder,
|
||||
filterRequest.filter,
|
||||
isArchived,
|
||||
SignalStore.uiHints.canDisplayPullToFilterTip() && it.source === ConversationFilterSource.OVERFLOW
|
||||
SignalStore.uiHints.canDisplayPullToFilterTip() && filterRequest.source === ConversationFilterSource.OVERFLOW
|
||||
)
|
||||
}
|
||||
.replay(1)
|
||||
@@ -100,6 +112,17 @@ class ConversationListViewModel(
|
||||
.subscribe { controller.onDataInvalidated() }
|
||||
.addTo(disposables)
|
||||
|
||||
Flowables.combineLatest(
|
||||
RxDatabaseObserver
|
||||
.conversationList
|
||||
.debounce(250, TimeUnit.MILLISECONDS),
|
||||
RxDatabaseObserver
|
||||
.chatFolders
|
||||
.throttleLatest(500, TimeUnit.MILLISECONDS)
|
||||
)
|
||||
.subscribe { loadCurrentFolders() }
|
||||
.addTo(disposables)
|
||||
|
||||
val pinnedCount = RxDatabaseObserver
|
||||
.conversationList
|
||||
.map { SignalDatabase.threads.getPinnedConversationListCount(ConversationFilter.OFF) }
|
||||
@@ -191,6 +214,28 @@ class ConversationListViewModel(
|
||||
megaphoneRepository.markVisible(visible.event)
|
||||
}
|
||||
|
||||
private fun loadCurrentFolders() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val folders = ChatFoldersRepository.getCurrentFolders(includeUnreadCount = true)
|
||||
|
||||
val selectedFolderId = if (currentFolder.id == -1L) {
|
||||
folders.firstOrNull()?.id
|
||||
} else {
|
||||
currentFolder.id
|
||||
}
|
||||
val chatFolders = folders.map { folder ->
|
||||
ChatFolderMappingModel(folder, selectedFolderId == folder.id)
|
||||
}
|
||||
|
||||
store.update {
|
||||
it.copy(
|
||||
currentFolder = folders.find { folder -> folder.id == selectedFolderId } ?: ChatFolderRecord(),
|
||||
chatFolders = chatFolders
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getNotificationProfiles(): Flowable<List<NotificationProfile>> {
|
||||
return notificationProfilesRepository.getProfiles()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
@@ -203,7 +248,20 @@ class ConversationListViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun select(chatFolder: ChatFolderRecord) {
|
||||
store.update {
|
||||
it.copy(
|
||||
currentFolder = chatFolder,
|
||||
chatFolders = folders.map { model ->
|
||||
model.copy(isSelected = chatFolder.id == model.chatFolder.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class ConversationListState(
|
||||
val chatFolders: List<ChatFolderMappingModel> = emptyList(),
|
||||
val currentFolder: ChatFolderRecord = ChatFolderRecord(),
|
||||
val conversations: List<Conversation> = emptyList(),
|
||||
val megaphone: Megaphone = Megaphone.NONE,
|
||||
val selectedConversations: ConversationSet = ConversationSet(),
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.delete
|
||||
import org.signal.core.util.groupBy
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.readToSingleInt
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
|
||||
/**
|
||||
* Stores chat folders and the chats that belong in each chat folder
|
||||
*/
|
||||
class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : DatabaseTable(context, databaseHelper), ThreadIdDatabaseReference {
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val CREATE_TABLE: Array<String> = arrayOf(ChatFolderTable.CREATE_TABLE, ChatFolderMembershipTable.CREATE_TABLE)
|
||||
|
||||
@JvmField
|
||||
val CREATE_INDEXES: Array<String> = ChatFolderTable.CREATE_INDEX + ChatFolderMembershipTable.CREATE_INDEXES
|
||||
|
||||
fun insertInitialChatFoldersAtCreationTime(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
|
||||
db.insert(ChatFolderTable.TABLE_NAME, null, getAllChatsFolderContentValues())
|
||||
}
|
||||
|
||||
private fun getAllChatsFolderContentValues(): ContentValues {
|
||||
return contentValuesOf(
|
||||
ChatFolderTable.POSITION to 0,
|
||||
ChatFolderTable.FOLDER_TYPE to ChatFolderRecord.FolderType.ALL.value,
|
||||
ChatFolderTable.SHOW_INDIVIDUAL to 1,
|
||||
ChatFolderTable.SHOW_GROUPS to 1,
|
||||
ChatFolderTable.SHOW_MUTED to 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the components of a chat folder and any chat types it contains
|
||||
*/
|
||||
object ChatFolderTable {
|
||||
const val TABLE_NAME = "chat_folder"
|
||||
|
||||
const val ID = "_id"
|
||||
const val NAME = "name"
|
||||
const val POSITION = "position"
|
||||
const val SHOW_UNREAD = "show_unread"
|
||||
const val SHOW_MUTED = "show_muted"
|
||||
const val SHOW_INDIVIDUAL = "show_individual"
|
||||
const val SHOW_GROUPS = "show_groups"
|
||||
const val IS_MUTED = "is_muted"
|
||||
const val FOLDER_TYPE = "folder_type"
|
||||
|
||||
val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$NAME TEXT DEFAULT NULL,
|
||||
$POSITION INTEGER DEFAULT 0,
|
||||
$SHOW_UNREAD INTEGER DEFAULT 0,
|
||||
$SHOW_MUTED INTEGER DEFAULT 0,
|
||||
$SHOW_INDIVIDUAL INTEGER DEFAULT 0,
|
||||
$SHOW_GROUPS INTEGER DEFAULT 0,
|
||||
$IS_MUTED INTEGER DEFAULT 0,
|
||||
$FOLDER_TYPE INTEGER DEFAULT ${ChatFolderRecord.FolderType.CUSTOM.value}
|
||||
)
|
||||
"""
|
||||
|
||||
val CREATE_INDEX = arrayOf(
|
||||
"CREATE INDEX chat_folder_position_index ON $TABLE_NAME ($POSITION)"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a thread that is associated with this chat folder. They are
|
||||
* either included in the chat folder or explicitly excluded.
|
||||
*/
|
||||
object ChatFolderMembershipTable {
|
||||
const val TABLE_NAME = "chat_folder_membership"
|
||||
|
||||
const val ID = "_id"
|
||||
const val CHAT_FOLDER_ID = "chat_folder_id"
|
||||
const val THREAD_ID = "thread_id"
|
||||
const val MEMBERSHIP_TYPE = "membership_type"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$CHAT_FOLDER_ID INTEGER NOT NULL REFERENCES ${ChatFolderTable.TABLE_NAME} (${ChatFolderTable.ID}) ON DELETE CASCADE,
|
||||
$THREAD_ID INTEGER NOT NULL REFERENCES ${ThreadTable.TABLE_NAME} (${ThreadTable.ID}) ON DELETE CASCADE,
|
||||
$MEMBERSHIP_TYPE INTEGER DEFAULT 1
|
||||
)
|
||||
"""
|
||||
|
||||
val CREATE_INDEXES = arrayOf(
|
||||
"CREATE INDEX chat_folder_membership_chat_folder_id_index ON $TABLE_NAME ($CHAT_FOLDER_ID)",
|
||||
"CREATE INDEX chat_folder_membership_thread_id_index ON $TABLE_NAME ($THREAD_ID)",
|
||||
"CREATE INDEX chat_folder_membership_membership_type_index ON $TABLE_NAME ($MEMBERSHIP_TYPE)"
|
||||
)
|
||||
}
|
||||
|
||||
override fun remapThread(fromId: Long, toId: Long) {
|
||||
writableDatabase
|
||||
.update(ChatFolderMembershipTable.TABLE_NAME)
|
||||
.values(ChatFolderMembershipTable.THREAD_ID to toId)
|
||||
.where("${ChatFolderMembershipTable.THREAD_ID} = ?", fromId)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the chat folder ids to its corresponding chat folder
|
||||
*/
|
||||
fun getChatFolders(includeUnreads: Boolean = false): List<ChatFolderRecord> {
|
||||
val includedChats: Map<Long, List<Long>> = getIncludedChats()
|
||||
val excludedChats: Map<Long, List<Long>> = getExcludedChats()
|
||||
|
||||
val folders = readableDatabase
|
||||
.select()
|
||||
.from(ChatFolderTable.TABLE_NAME)
|
||||
.orderBy(ChatFolderTable.POSITION)
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
val id = cursor.requireLong(ChatFolderTable.ID)
|
||||
ChatFolderRecord(
|
||||
id = id,
|
||||
name = cursor.requireString(ChatFolderTable.NAME) ?: "",
|
||||
position = cursor.requireInt(ChatFolderTable.POSITION),
|
||||
showUnread = cursor.requireBoolean(ChatFolderTable.SHOW_UNREAD),
|
||||
showMutedChats = cursor.requireBoolean(ChatFolderTable.SHOW_MUTED),
|
||||
showIndividualChats = cursor.requireBoolean(ChatFolderTable.SHOW_INDIVIDUAL),
|
||||
showGroupChats = cursor.requireBoolean(ChatFolderTable.SHOW_GROUPS),
|
||||
isMuted = cursor.requireBoolean(ChatFolderTable.IS_MUTED),
|
||||
folderType = ChatFolderRecord.FolderType.deserialize(cursor.requireInt(ChatFolderTable.FOLDER_TYPE)),
|
||||
includedChats = includedChats[id] ?: emptyList(),
|
||||
excludedChats = excludedChats[id] ?: emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
if (includeUnreads) {
|
||||
return folders.map { folder ->
|
||||
folder.copy(
|
||||
unreadCount = SignalDatabase.threads.getUnreadCountByChatFolder(folder)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return folders
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps chat folder ids to all of its corresponding included chats
|
||||
*/
|
||||
private fun getIncludedChats(): Map<Long, List<Long>> {
|
||||
return readableDatabase
|
||||
.select()
|
||||
.from(ChatFolderMembershipTable.TABLE_NAME)
|
||||
.where("${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.INCLUDED.value}")
|
||||
.run()
|
||||
.groupBy { cursor ->
|
||||
cursor.requireLong(ChatFolderMembershipTable.CHAT_FOLDER_ID) to cursor.requireLong(ChatFolderMembershipTable.THREAD_ID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the chat folder ids to all of its corresponding excluded chats
|
||||
*/
|
||||
private fun getExcludedChats(): Map<Long, List<Long>> {
|
||||
return readableDatabase
|
||||
.select()
|
||||
.from(ChatFolderMembershipTable.TABLE_NAME)
|
||||
.where("${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.EXCLUDED.value}")
|
||||
.run()
|
||||
.groupBy { cursor ->
|
||||
cursor.requireLong(ChatFolderMembershipTable.CHAT_FOLDER_ID) to cursor.requireLong(ChatFolderMembershipTable.THREAD_ID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a chat folder and its corresponding included/excluded chats
|
||||
*/
|
||||
fun createFolder(chatFolder: ChatFolderRecord) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
val position: Int = db
|
||||
.select("MAX(${ChatFolderTable.POSITION})")
|
||||
.from(ChatFolderTable.TABLE_NAME)
|
||||
.run()
|
||||
.readToSingleInt(0) + 1
|
||||
|
||||
val id = db.insertInto(ChatFolderTable.TABLE_NAME)
|
||||
.values(
|
||||
contentValuesOf(
|
||||
ChatFolderTable.NAME to chatFolder.name,
|
||||
ChatFolderTable.SHOW_UNREAD to chatFolder.showUnread,
|
||||
ChatFolderTable.SHOW_MUTED to chatFolder.showMutedChats,
|
||||
ChatFolderTable.SHOW_INDIVIDUAL to chatFolder.showIndividualChats,
|
||||
ChatFolderTable.SHOW_GROUPS to chatFolder.showGroupChats,
|
||||
ChatFolderTable.IS_MUTED to chatFolder.isMuted,
|
||||
ChatFolderTable.POSITION to position
|
||||
)
|
||||
)
|
||||
.run(SQLiteDatabase.CONFLICT_IGNORE)
|
||||
|
||||
val includedChatsQueries = SqlUtil.buildBulkInsert(
|
||||
ChatFolderMembershipTable.TABLE_NAME,
|
||||
arrayOf(ChatFolderMembershipTable.CHAT_FOLDER_ID, ChatFolderMembershipTable.THREAD_ID, ChatFolderMembershipTable.MEMBERSHIP_TYPE),
|
||||
chatFolder.includedChats.toContentValues(chatFolderId = id, membershipType = MembershipType.INCLUDED)
|
||||
)
|
||||
|
||||
val excludedChatsQueries = SqlUtil.buildBulkInsert(
|
||||
ChatFolderMembershipTable.TABLE_NAME,
|
||||
arrayOf(ChatFolderMembershipTable.CHAT_FOLDER_ID, ChatFolderMembershipTable.THREAD_ID, ChatFolderMembershipTable.MEMBERSHIP_TYPE),
|
||||
chatFolder.excludedChats.toContentValues(chatFolderId = id, membershipType = MembershipType.EXCLUDED)
|
||||
)
|
||||
|
||||
includedChatsQueries.forEach {
|
||||
db.execSQL(it.where, it.whereArgs)
|
||||
}
|
||||
|
||||
excludedChatsQueries.forEach {
|
||||
db.execSQL(it.where, it.whereArgs)
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.notifyChatFolderObservers()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the details for an existing folder like name, chat types, etc.
|
||||
*/
|
||||
fun updateFolder(chatFolder: ChatFolderRecord) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
db.update(ChatFolderTable.TABLE_NAME)
|
||||
.values(
|
||||
ChatFolderTable.NAME to chatFolder.name,
|
||||
ChatFolderTable.SHOW_UNREAD to chatFolder.showUnread,
|
||||
ChatFolderTable.SHOW_MUTED to chatFolder.showMutedChats,
|
||||
ChatFolderTable.SHOW_INDIVIDUAL to chatFolder.showIndividualChats,
|
||||
ChatFolderTable.SHOW_GROUPS to chatFolder.showGroupChats,
|
||||
ChatFolderTable.IS_MUTED to chatFolder.isMuted
|
||||
)
|
||||
.where("${ChatFolderTable.ID} = ?", chatFolder.id)
|
||||
.run(SQLiteDatabase.CONFLICT_IGNORE)
|
||||
|
||||
db
|
||||
.delete(ChatFolderMembershipTable.TABLE_NAME)
|
||||
.where("${ChatFolderMembershipTable.CHAT_FOLDER_ID} = ?", chatFolder.id)
|
||||
.run()
|
||||
|
||||
val includedChats = SqlUtil.buildBulkInsert(
|
||||
ChatFolderMembershipTable.TABLE_NAME,
|
||||
arrayOf(ChatFolderMembershipTable.CHAT_FOLDER_ID, ChatFolderMembershipTable.THREAD_ID, ChatFolderMembershipTable.MEMBERSHIP_TYPE),
|
||||
chatFolder.includedChats.toContentValues(chatFolderId = chatFolder.id, membershipType = MembershipType.INCLUDED)
|
||||
)
|
||||
|
||||
val excludedChats = SqlUtil.buildBulkInsert(
|
||||
ChatFolderMembershipTable.TABLE_NAME,
|
||||
arrayOf(ChatFolderMembershipTable.CHAT_FOLDER_ID, ChatFolderMembershipTable.THREAD_ID, ChatFolderMembershipTable.MEMBERSHIP_TYPE),
|
||||
chatFolder.excludedChats.toContentValues(chatFolderId = chatFolder.id, membershipType = MembershipType.EXCLUDED)
|
||||
)
|
||||
|
||||
(includedChats + excludedChats).forEach {
|
||||
db.execSQL(it.where, it.whereArgs)
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.notifyChatFolderObservers()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a chat folder
|
||||
*/
|
||||
fun deleteChatFolder(chatFolder: ChatFolderRecord) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
db.delete(ChatFolderTable.TABLE_NAME, "${ChatFolderTable.ID} = ?", SqlUtil.buildArgs(chatFolder.id))
|
||||
AppDependencies.databaseObserver.notifyChatFolderObservers()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the position of the chat folders
|
||||
*/
|
||||
fun updatePositions(folders: List<ChatFolderRecord>) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
folders.forEach { folder ->
|
||||
db.update(ChatFolderTable.TABLE_NAME)
|
||||
.values(ChatFolderTable.POSITION to folder.position)
|
||||
.where("${ChatFolderTable.ID} = ?", folder.id)
|
||||
.run(SQLiteDatabase.CONFLICT_IGNORE)
|
||||
}
|
||||
AppDependencies.databaseObserver.notifyChatFolderObservers()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Collection<Long>.toContentValues(chatFolderId: Long, membershipType: MembershipType): List<ContentValues> {
|
||||
return map {
|
||||
contentValuesOf(
|
||||
ChatFolderMembershipTable.CHAT_FOLDER_ID to chatFolderId,
|
||||
ChatFolderMembershipTable.THREAD_ID to it,
|
||||
ChatFolderMembershipTable.MEMBERSHIP_TYPE to membershipType.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum class MembershipType(val value: Int) {
|
||||
/** Chat that should be included in the chat folder */
|
||||
INCLUDED(0),
|
||||
|
||||
/** Chat that should be excluded from the chat folder */
|
||||
EXCLUDED(1)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ public class DatabaseObserver {
|
||||
private static final String KEY_CALL_UPDATES = "CallUpdates";
|
||||
private static final String KEY_CALL_LINK_UPDATES = "CallLinkUpdates";
|
||||
private static final String KEY_IN_APP_PAYMENTS = "InAppPayments";
|
||||
private static final String KEY_CHAT_FOLDER = "ChatFolder";
|
||||
|
||||
private final Executor executor;
|
||||
|
||||
@@ -69,6 +70,7 @@ public class DatabaseObserver {
|
||||
private final Set<Observer> callUpdateObservers;
|
||||
private final Map<CallLinkRoomId, Set<Observer>> callLinkObservers;
|
||||
private final Set<InAppPaymentObserver> inAppPaymentObservers;
|
||||
private final Set<Observer> chatFolderObservers;
|
||||
|
||||
public DatabaseObserver() {
|
||||
this.executor = new SerialExecutor(SignalExecutors.BOUNDED);
|
||||
@@ -91,6 +93,7 @@ public class DatabaseObserver {
|
||||
this.callUpdateObservers = new HashSet<>();
|
||||
this.callLinkObservers = new HashMap<>();
|
||||
this.inAppPaymentObservers = new HashSet<>();
|
||||
this.chatFolderObservers = new HashSet<>();
|
||||
}
|
||||
|
||||
public void registerConversationListObserver(@NonNull Observer listener) {
|
||||
@@ -206,6 +209,10 @@ public class DatabaseObserver {
|
||||
executor.execute(() -> inAppPaymentObservers.add(observer));
|
||||
}
|
||||
|
||||
public void registerChatFolderObserver(@NonNull Observer observer) {
|
||||
executor.execute(() -> chatFolderObservers.add(observer));
|
||||
}
|
||||
|
||||
public void unregisterObserver(@NonNull Observer listener) {
|
||||
executor.execute(() -> {
|
||||
conversationListObservers.remove(listener);
|
||||
@@ -223,6 +230,7 @@ public class DatabaseObserver {
|
||||
unregisterMapped(conversationDeleteObservers, listener);
|
||||
callUpdateObservers.remove(listener);
|
||||
unregisterMapped(callLinkObservers, listener);
|
||||
chatFolderObservers.remove(listener);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -387,6 +395,10 @@ public class DatabaseObserver {
|
||||
});
|
||||
}
|
||||
|
||||
public void notifyChatFolderObservers() {
|
||||
runPostSuccessfulTransaction(KEY_CHAT_FOLDER, () -> notifySet(chatFolderObservers));
|
||||
}
|
||||
|
||||
private void runPostSuccessfulTransaction(@NonNull String dedupeKey, @NonNull Runnable runnable) {
|
||||
SignalDatabase.runPostSuccessfulTransaction(dedupeKey, () -> {
|
||||
executor.execute(runnable);
|
||||
|
||||
@@ -1483,6 +1483,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
for (id in ids) {
|
||||
AppDependencies.databaseObserver.notifyRecipientChanged(id)
|
||||
}
|
||||
AppDependencies.databaseObserver.notifyConversationListListeners()
|
||||
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ object RxDatabaseObserver {
|
||||
|
||||
val conversationList: Flowable<Unit> by lazy { conversationListFlowable() }
|
||||
val notificationProfiles: Flowable<Unit> by lazy { notificationProfilesFlowable() }
|
||||
val chatFolders: Flowable<Unit> by lazy { chatFoldersFlowable() }
|
||||
|
||||
private fun conversationListFlowable(): Flowable<Unit> {
|
||||
return databaseFlowable { listener ->
|
||||
@@ -36,6 +37,12 @@ object RxDatabaseObserver {
|
||||
) { _, _ -> Unit }
|
||||
}
|
||||
|
||||
private fun chatFoldersFlowable(): Flowable<Unit> {
|
||||
return databaseFlowable { listener ->
|
||||
AppDependencies.databaseObserver.registerChatFolderObserver(listener)
|
||||
}
|
||||
}
|
||||
|
||||
private fun databaseFlowable(registerObserver: (RxObserver) -> Unit): Flowable<Unit> {
|
||||
val flowable = Flowable.create(
|
||||
{
|
||||
|
||||
@@ -76,6 +76,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
val nameCollisionTables: NameCollisionTables = NameCollisionTables(context, this)
|
||||
val inAppPaymentTable: InAppPaymentTable = InAppPaymentTable(context, this)
|
||||
val inAppPaymentSubscriberTable: InAppPaymentSubscriberTable = InAppPaymentSubscriberTable(context, this)
|
||||
val chatFoldersTable: ChatFolderTables = ChatFolderTables(context, this)
|
||||
|
||||
override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
|
||||
db.setForeignKeyConstraintsEnabled(true)
|
||||
@@ -120,6 +121,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
executeStatements(db, MessageSendLogTables.CREATE_TABLE)
|
||||
executeStatements(db, NotificationProfileDatabase.CREATE_TABLE)
|
||||
executeStatements(db, DistributionListTables.CREATE_TABLE)
|
||||
executeStatements(db, ChatFolderTables.CREATE_TABLE)
|
||||
|
||||
executeStatements(db, RecipientTable.CREATE_INDEXS)
|
||||
executeStatements(db, MessageTable.CREATE_INDEXS)
|
||||
@@ -141,6 +143,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
executeStatements(db, CallTable.CREATE_INDEXES)
|
||||
executeStatements(db, ReactionTable.CREATE_INDEXES)
|
||||
executeStatements(db, KyberPreKeyTable.CREATE_INDEXES)
|
||||
executeStatements(db, ChatFolderTables.CREATE_INDEXES)
|
||||
|
||||
executeStatements(db, SearchTable.CREATE_TRIGGERS)
|
||||
executeStatements(db, MessageSendLogTables.CREATE_TRIGGERS)
|
||||
@@ -148,6 +151,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
NameCollisionTables.createIndexes(db)
|
||||
|
||||
DistributionListTables.insertInitialDistributionListAtCreationTime(db)
|
||||
ChatFolderTables.insertInitialChatFoldersAtCreationTime(db)
|
||||
|
||||
if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) {
|
||||
val legacyHelper = ClassicOpenHelper(context)
|
||||
@@ -558,5 +562,10 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
@get:JvmName("inAppPaymentSubscribers")
|
||||
val inAppPaymentSubscribers: InAppPaymentSubscriberTable
|
||||
get() = instance!!.inAppPaymentSubscriberTable
|
||||
|
||||
@get:JvmStatic
|
||||
@get:JvmName("chatFolders")
|
||||
val chatFolders: ChatFolderTables
|
||||
get() = instance!!.chatFoldersTable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.signal.core.util.exists
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.or
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.readToSingleInt
|
||||
import org.signal.core.util.readToSingleLong
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
@@ -30,6 +31,7 @@ import org.signal.core.util.updateAll
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
|
||||
import org.thoughtcrime.securesms.database.MessageTable.MarkedMessageInfo
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments
|
||||
@@ -629,6 +631,39 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
return allCount + forcedUnreadCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of unread messages across all threads within a chat folder
|
||||
* Threads that are forced-unread count as 1.
|
||||
*/
|
||||
fun getUnreadCountByChatFolder(folder: ChatFolderRecord): Int {
|
||||
val chatFolderQuery = folder.toQuery()
|
||||
|
||||
val allCountQuery =
|
||||
"""
|
||||
SELECT SUM($UNREAD_COUNT)
|
||||
FROM $TABLE_NAME
|
||||
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
|
||||
WHERE
|
||||
$ARCHIVED = 0
|
||||
$chatFolderQuery
|
||||
"""
|
||||
val allCount = readableDatabase.rawQuery(allCountQuery, null).readToSingleInt(0)
|
||||
|
||||
val forcedUnreadCountQuery =
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM $TABLE_NAME
|
||||
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
|
||||
WHERE
|
||||
$ARCHIVED = 0 AND
|
||||
$READ = ${ThreadTable.ReadStatus.FORCED_UNREAD.serialize()}
|
||||
$chatFolderQuery
|
||||
"""
|
||||
val forcedUnreadCount = readableDatabase.rawQuery(forcedUnreadCountQuery, null).readToSingleInt(0)
|
||||
|
||||
return allCount + forcedUnreadCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of unread messages in a given thread.
|
||||
*/
|
||||
@@ -915,12 +950,13 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
return readableDatabase.rawQuery(query, arrayOf("1"))
|
||||
}
|
||||
|
||||
fun getUnarchivedConversationList(conversationFilter: ConversationFilter, pinned: Boolean, offset: Long, limit: Long): Cursor {
|
||||
fun getUnarchivedConversationList(conversationFilter: ConversationFilter, pinned: Boolean, offset: Long, limit: Long, chatFolder: ChatFolderRecord): Cursor {
|
||||
val folderQuery = chatFolder.toQuery()
|
||||
val filterQuery = conversationFilter.toQuery()
|
||||
val where = if (pinned) {
|
||||
"$ARCHIVED = 0 AND $PINNED != 0 $filterQuery"
|
||||
"$ARCHIVED = 0 AND $PINNED != 0 $filterQuery $folderQuery"
|
||||
} else {
|
||||
"$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0 $filterQuery"
|
||||
"$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0 $filterQuery $folderQuery"
|
||||
}
|
||||
|
||||
val query = if (pinned) {
|
||||
@@ -948,36 +984,61 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
}
|
||||
}
|
||||
|
||||
fun getPinnedConversationListCount(conversationFilter: ConversationFilter): Int {
|
||||
fun getPinnedConversationListCount(conversationFilter: ConversationFilter, chatFolder: ChatFolderRecord? = null): Int {
|
||||
val filterQuery = conversationFilter.toQuery()
|
||||
return readableDatabase
|
||||
.select("COUNT(*)")
|
||||
.from(TABLE_NAME)
|
||||
.where("$ACTIVE = 1 AND $ARCHIVED = 0 AND $PINNED != 0 $filterQuery")
|
||||
.run()
|
||||
.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getInt(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
return if (chatFolder == null || chatFolder.folderType == ChatFolderRecord.FolderType.ALL) {
|
||||
readableDatabase
|
||||
.select("COUNT(*)")
|
||||
.from(TABLE_NAME)
|
||||
.where("$ACTIVE = 1 AND $ARCHIVED = 0 AND $PINNED != 0 $filterQuery")
|
||||
.run()
|
||||
.readToSingleInt(0)
|
||||
} else {
|
||||
val folderQuery = chatFolder.toQuery()
|
||||
val query =
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM $TABLE_NAME
|
||||
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
|
||||
WHERE
|
||||
$ACTIVE = 1 AND
|
||||
$ARCHIVED = 0 AND
|
||||
$PINNED != 0
|
||||
$filterQuery
|
||||
$folderQuery
|
||||
"""
|
||||
readableDatabase.rawQuery(query, null).readToSingleInt(0)
|
||||
}
|
||||
}
|
||||
|
||||
fun getUnarchivedConversationListCount(conversationFilter: ConversationFilter): Int {
|
||||
fun getUnarchivedConversationListCount(conversationFilter: ConversationFilter, chatFolder: ChatFolderRecord? = null): Int {
|
||||
val filterQuery = conversationFilter.toQuery()
|
||||
return readableDatabase
|
||||
.select("COUNT(*)")
|
||||
.from(TABLE_NAME)
|
||||
.where("$ACTIVE = 1 AND $ARCHIVED = 0 AND ($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0) $filterQuery")
|
||||
.run()
|
||||
.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
cursor.getInt(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
return if (chatFolder == null || chatFolder.folderType == ChatFolderRecord.FolderType.ALL) {
|
||||
readableDatabase
|
||||
.select("COUNT(*)")
|
||||
.from(TABLE_NAME)
|
||||
.where("$ACTIVE = 1 AND $ARCHIVED = 0 AND ($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0) $filterQuery")
|
||||
.run()
|
||||
.readToSingleInt(0)
|
||||
} else {
|
||||
val folderQuery = chatFolder.toQuery()
|
||||
|
||||
val query =
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM $TABLE_NAME
|
||||
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
|
||||
WHERE
|
||||
$ACTIVE = 1 AND
|
||||
$ARCHIVED = 0 AND
|
||||
($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0)
|
||||
$filterQuery
|
||||
$folderQuery
|
||||
"""
|
||||
readableDatabase.rawQuery(query, null).readToSingleInt(0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1979,6 +2040,42 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
return Reader(cursor)
|
||||
}
|
||||
|
||||
private fun ChatFolderRecord.toQuery(): String {
|
||||
if (this.id == -1L || this.folderType == ChatFolderRecord.FolderType.ALL) {
|
||||
return ""
|
||||
}
|
||||
|
||||
val includedChatsQuery: MutableList<String> = mutableListOf()
|
||||
includedChatsQuery.add("${TABLE_NAME}.$ID IN (${this.includedChats.joinToString(",")})")
|
||||
|
||||
if (this.showIndividualChats) {
|
||||
includedChatsQuery.add("${RecipientTable.TABLE_NAME}.${RecipientTable.TYPE} = ${RecipientTable.RecipientType.INDIVIDUAL.id}")
|
||||
}
|
||||
|
||||
if (this.showGroupChats) {
|
||||
includedChatsQuery.add("${RecipientTable.TABLE_NAME}.${RecipientTable.TYPE} = ${RecipientTable.RecipientType.GV2.id}")
|
||||
}
|
||||
|
||||
val includedQuery = includedChatsQuery.joinToString(" OR ") { "($it)" }
|
||||
|
||||
val fullQuery: MutableList<String> = mutableListOf()
|
||||
fullQuery.add(includedQuery)
|
||||
|
||||
if (this.excludedChats.isNotEmpty()) {
|
||||
fullQuery.add("${TABLE_NAME}.$ID NOT IN (${this.excludedChats.joinToString(",")})")
|
||||
}
|
||||
|
||||
if (this.showUnread) {
|
||||
fullQuery.add("$UNREAD_COUNT > 0 OR $READ == ${ReadStatus.FORCED_UNREAD.serialize()}")
|
||||
}
|
||||
|
||||
if (!this.showMutedChats) {
|
||||
fullQuery.add("${RecipientTable.TABLE_NAME}.${RecipientTable.MUTE_UNTIL} = 0")
|
||||
}
|
||||
|
||||
return "AND ${fullQuery.joinToString(" AND ") { "($it)" }}"
|
||||
}
|
||||
|
||||
private fun ConversationFilter.toQuery(): String {
|
||||
return when (this) {
|
||||
ConversationFilter.OFF -> ""
|
||||
|
||||
@@ -109,6 +109,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V247_ClearUploadTim
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V250_ClearUploadTimestampV2
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V251_ArchiveTransferStateIndex
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V252_AttachmentOffloadRestoredAtColumn
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V253_CreateChatFolderTables
|
||||
|
||||
/**
|
||||
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
|
||||
@@ -220,10 +221,11 @@ object SignalDatabaseMigrations {
|
||||
// 248 and 249 were originally in 7.18.0, but are now skipped because we needed to hotfix 7.17.6 after 7.18.0 was already released.
|
||||
250 to V250_ClearUploadTimestampV2,
|
||||
251 to V251_ArchiveTransferStateIndex,
|
||||
252 to V252_AttachmentOffloadRestoredAtColumn
|
||||
252 to V252_AttachmentOffloadRestoredAtColumn,
|
||||
253 to V253_CreateChatFolderTables
|
||||
)
|
||||
|
||||
const val DATABASE_VERSION = 252
|
||||
const val DATABASE_VERSION = 253
|
||||
|
||||
@JvmStatic
|
||||
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import org.thoughtcrime.securesms.database.ChatFolderTables
|
||||
|
||||
/**
|
||||
* Adds the tables for managing chat folders
|
||||
*/
|
||||
@Suppress("ClassName")
|
||||
object V253_CreateChatFolderTables : SignalDatabaseMigration {
|
||||
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE chat_folder (
|
||||
_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT DEFAULT NULL,
|
||||
position INTEGER DEFAULT 0,
|
||||
show_unread INTEGER DEFAULT 0,
|
||||
show_muted INTEGER DEFAULT 0,
|
||||
show_individual INTEGER DEFAULT 0,
|
||||
show_groups INTEGER DEFAULT 0,
|
||||
is_muted INTEGER DEFAULT 0,
|
||||
folder_type INTEGER DEFAULT 4
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE chat_folder_membership (
|
||||
_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_folder_id INTEGER NOT NULL REFERENCES chat_folder (_id) ON DELETE CASCADE,
|
||||
thread_id INTEGER NOT NULL REFERENCES thread (_id) ON DELETE CASCADE,
|
||||
membership_type INTEGER DEFAULT 1
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execSQL("CREATE INDEX chat_folder_position_index ON chat_folder (position)")
|
||||
db.execSQL("CREATE INDEX chat_folder_membership_chat_folder_id_index ON chat_folder_membership (chat_folder_id)")
|
||||
db.execSQL("CREATE INDEX chat_folder_membership_thread_id_index ON chat_folder_membership (thread_id)")
|
||||
db.execSQL("CREATE INDEX chat_folder_membership_membership_type_index ON chat_folder_membership (membership_type)")
|
||||
|
||||
ChatFolderTables.insertInitialChatFoldersAtCreationTime(db)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.ContactSelectionActivity;
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment;
|
||||
import org.thoughtcrime.securesms.PushContactSelectionActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
@@ -30,7 +31,6 @@ import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
@@ -97,7 +97,7 @@ public class AddMembersActivity extends PushContactSelectionActivity implements
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
if (getGroupId().isV1() && recipientId.isPresent() && !Recipient.resolved(recipientId.get()).getHasE164()) {
|
||||
Toast.makeText(this, R.string.AddMembersActivity__this_person_cant_be_added_to_legacy_groups, Toast.LENGTH_SHORT).show();
|
||||
callback.accept(false);
|
||||
@@ -139,7 +139,7 @@ public class AddMembersActivity extends PushContactSelectionActivity implements
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {
|
||||
if (contactsFragment.hasQueryFilter()) {
|
||||
getContactFilterView().clear();
|
||||
}
|
||||
|
||||
@@ -8,21 +8,18 @@ import android.view.View;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.thoughtcrime.securesms.ContactSelectionActivity;
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupViewModel.Event;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientRepository;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@@ -116,7 +113,7 @@ public final class AddToGroupsActivity extends ContactSelectionActivity {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
if (contactsFragment.isMulti()) {
|
||||
throw new UnsupportedOperationException("Not yet built to handle multi-select.");
|
||||
// if (contactsFragment.hasQueryFilter()) {
|
||||
@@ -136,7 +133,7 @@ public final class AddToGroupsActivity extends ContactSelectionActivity {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {
|
||||
if (contactsFragment.hasQueryFilter()) {
|
||||
getContactFilterView().clear();
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.ContactSelectionActivity;
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.details.AddGroupDetailsActivity;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
@@ -109,7 +110,7 @@ public class CreateGroupActivity extends ContactSelectionActivity implements Con
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
if (contactsFragment.hasQueryFilter()) {
|
||||
getContactFilterView().clear();
|
||||
}
|
||||
@@ -145,7 +146,7 @@ public class CreateGroupActivity extends ContactSelectionActivity implements Con
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Optional<ChatType> chatType) {
|
||||
if (contactsFragment.hasQueryFilter()) {
|
||||
getContactFilterView().clear();
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ public class UserNotificationMigrationJob extends MigrationJob {
|
||||
|
||||
ThreadTable threadTable = SignalDatabase.threads();
|
||||
|
||||
int threadCount = threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF) +
|
||||
int threadCount = threadTable.getUnarchivedConversationListCount(ConversationFilter.OFF, null) +
|
||||
threadTable.getArchivedConversationListCount(ConversationFilter.OFF);
|
||||
|
||||
if (threadCount >= 3) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.payments.CanNotSendPaymentDialog;
|
||||
@@ -71,7 +72,7 @@ public class PaymentRecipientSelectionFragment extends LoggingFragment implement
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback) {
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Optional<ChatType> chatType, @NonNull Consumer<Boolean> callback) {
|
||||
if (recipientId.isPresent()) {
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
|
||||
() -> Recipient.resolved(recipientId.get()),
|
||||
@@ -82,7 +83,7 @@ public class PaymentRecipientSelectionFragment extends LoggingFragment implement
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number) {}
|
||||
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Optional<ChatType> chatType) {}
|
||||
|
||||
@Override
|
||||
public void onSelectionChanged() {
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.ContactSelectionListFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
|
||||
import org.thoughtcrime.securesms.contacts.HeaderAction
|
||||
import org.thoughtcrime.securesms.contacts.paged.ChatType
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
@@ -117,7 +118,7 @@ abstract class BaseStoryRecipientSelectionFragment : Fragment(R.layout.stories_b
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
|
||||
override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>, callback: Consumer<Boolean>) {
|
||||
viewModel.addRecipient(recipientId.get())
|
||||
|
||||
if (searchField.text.isNotBlank()) {
|
||||
@@ -127,7 +128,7 @@ abstract class BaseStoryRecipientSelectionFragment : Fragment(R.layout.stories_b
|
||||
callback.accept(true)
|
||||
}
|
||||
|
||||
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?) {
|
||||
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?, chatType: Optional<ChatType>) {
|
||||
viewModel.removeRecipient(recipientId.get())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user