Support selecting multiple threads to add to chat folder.

Resolves #13973
This commit is contained in:
Sagar
2025-02-06 22:55:19 +05:30
committed by Greyson Parrelli
parent de4b653554
commit d938906d3e
10 changed files with 172 additions and 72 deletions

View File

@@ -72,7 +72,7 @@ class AppSettingsActivity : DSLSettingsActivity(), InAppPaymentComponent {
StartLocation.CHAT_FOLDERS -> AppSettingsFragmentDirections.actionDirectToChatFoldersFragment()
StartLocation.CREATE_CHAT_FOLDER -> AppSettingsFragmentDirections.actionDirectToCreateFoldersFragment(
CreateFoldersFragmentArgs.fromBundle(intent.getBundleExtra(START_ARGUMENTS)!!).folderId,
CreateFoldersFragmentArgs.fromBundle(intent.getBundleExtra(START_ARGUMENTS)!!).threadId
CreateFoldersFragmentArgs.fromBundle(intent.getBundleExtra(START_ARGUMENTS)!!).threadIds
)
StartLocation.BACKUPS_SETTINGS -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment()
}
@@ -209,8 +209,8 @@ class AppSettingsActivity : DSLSettingsActivity(), InAppPaymentComponent {
fun chatFolders(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CHAT_FOLDERS)
@JvmStatic
fun createChatFolder(context: Context, id: Long = -1, threadId: Long?): Intent {
val arguments = CreateFoldersFragmentArgs.Builder(id, threadId ?: -1)
fun createChatFolder(context: Context, id: Long = -1, threadIds: LongArray?): Intent {
val arguments = CreateFoldersFragmentArgs.Builder(id, threadIds ?: longArrayOf())
.build()
.toBundle()

View File

@@ -84,7 +84,7 @@ class ChatFoldersFragment : ComposeFragment() {
navController = navController,
modifier = Modifier.padding(contentPadding),
onFolderClicked = {
navController.safeNavigate(ChatFoldersFragmentDirections.actionChatFoldersFragmentToCreateFoldersFragment(it.id, -1))
navController.safeNavigate(ChatFoldersFragmentDirections.actionChatFoldersFragmentToCreateFoldersFragment(it.id, null))
},
onAdd = { folder ->
Toast.makeText(requireContext(), getString(R.string.ChatFoldersFragment__folder_added, folder.name), Toast.LENGTH_SHORT).show()

View File

@@ -225,22 +225,27 @@ class ChatFoldersViewModel : ViewModel() {
}
}
fun addThreadToIncludedChat(threadId: Long?) {
if (threadId == null || threadId == -1L) {
fun addThreadsToFolder(threadIds: LongArray?) {
if (threadIds == null || threadIds.isEmpty()) {
return
}
viewModelScope.launch {
val updatedFolder = internalState.value.currentFolder
val recipient = SignalDatabase.threads.getRecipientForThreadId(threadId)
if (recipient != null) {
internalState.update {
it.copy(
currentFolder = updatedFolder.copy(
includedRecipients = setOf(recipient)
)
)
val includedRecipients = mutableSetOf<Recipient>()
threadIds.forEach { threadId ->
val recipient = SignalDatabase.threads.getRecipientForThreadId(threadId)
if (recipient != null) {
includedRecipients.add(recipient)
}
}
internalState.update {
it.copy(
currentFolder = updatedFolder.copy(
includedRecipients = includedRecipients
)
)
}
}
}

View File

@@ -99,7 +99,7 @@ class CreateFoldersFragment : ComposeFragment() {
LaunchedEffect(Unit) {
if (state.originalFolder == state.currentFolder) {
viewModel.setCurrentFolderId(arguments?.getLong(KEY_FOLDER_ID) ?: -1)
viewModel.addThreadToIncludedChat(arguments?.getLong(KEY_THREAD_ID))
viewModel.addThreadsToFolder(arguments?.getLongArray(KEY_THREAD_IDS))
}
}
@@ -170,7 +170,7 @@ class CreateFoldersFragment : ComposeFragment() {
companion object {
private val KEY_FOLDER_ID = "folder_id"
private val KEY_THREAD_ID = "thread_id"
private val KEY_THREAD_IDS = "thread_ids"
}
}

View File

@@ -45,31 +45,41 @@ import org.thoughtcrime.securesms.util.viewModel
/**
* Bottom sheet shown when choosing to add a chat to a folder
*/
class AddToFolderBottomSheet private constructor() : ComposeBottomSheetDialogFragment() {
class AddToFolderBottomSheet private constructor(private val onDismissListener: OnDismissListener) : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 1f
interface OnDismissListener {
fun onDismiss()
}
enum class ThreadType(val value: Int) {
INDIVIDUAL(1),
GROUP(2),
OTHER(3)
}
private val viewModel by viewModel { ConversationListViewModel(isArchived = false) }
companion object {
private const val ARG_FOLDERS = "argument.folders"
private const val ARG_THREAD_ID = "argument.thread.id"
private const val ARG_IS_INDIVIDUAL_CHAT = "argument.is.individual.chat"
private const val ARG_THREAD_IDS = "argument.thread.ids"
private const val ARG_THREAD_TYPES = "argument.thread.types"
/**
* Shows a bottom sheet that allows a thread to be added to a folder.
*
* @param folders list of available folders to add a thread to
* @param threadId the thread that is going to be added
* @param isIndividualChat whether the thread is an individual/1:1 chat as opposed to a group chat
* @param threadIds list of threads that are going to be added
* @param threadTypes list of ThreadType of threads present in threadIds
*/
@JvmStatic
fun showChatFolderSheet(folders: List<ChatFolderRecord>, threadId: Long, isIndividualChat: Boolean): ComposeBottomSheetDialogFragment {
return AddToFolderBottomSheet().apply {
fun showChatFolderSheet(folders: List<ChatFolderRecord>, threadIds: List<Long>, threadTypes: List<Int>, onDismissListener: OnDismissListener): ComposeBottomSheetDialogFragment {
return AddToFolderBottomSheet(onDismissListener).apply {
arguments = bundleOf(
ARG_FOLDERS to folders,
ARG_THREAD_ID to threadId,
ARG_IS_INDIVIDUAL_CHAT to isIndividualChat
ARG_THREAD_IDS to threadIds.toLongArray(),
ARG_THREAD_TYPES to threadTypes.toIntArray()
)
}
}
@@ -78,25 +88,31 @@ class AddToFolderBottomSheet private constructor() : ComposeBottomSheetDialogFra
@Composable
override fun SheetContent() {
val folders = requireArguments().getParcelableArrayListCompat(ARG_FOLDERS, ChatFolderRecord::class.java)?.filter { it.folderType != ChatFolderRecord.FolderType.ALL }
val threadId = requireArguments().getLong(ARG_THREAD_ID)
val isIndividualChat = requireArguments().getBoolean(ARG_IS_INDIVIDUAL_CHAT)
val threadIds = requireArguments().getLongArray(ARG_THREAD_IDS)?.asList() ?: throw IllegalArgumentException("At least one ThreadId is expected!")
val threadTypes = requireArguments().getIntArray(ARG_THREAD_TYPES)?.asList() ?: throw IllegalArgumentException("At least one ThreadId is expected!")
AddToChatFolderSheetContent(
threadId = threadId,
isIndividualChat = isIndividualChat,
threadIds = threadIds,
threadTypes = threadTypes,
folders = remember { folders ?: emptyList() },
onClick = { folder, isAlreadyAdded ->
if (isAlreadyAdded) {
Toast.makeText(context, requireContext().getString(R.string.AddToFolderBottomSheet_this_chat_is_already, folder.name), Toast.LENGTH_SHORT).show()
val message = requireContext().resources.getQuantityString(
R.plurals.AddToFolderBottomSheet_these_chat_are_already_in_s,
threadIds.size,
folder.name
)
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
} else {
viewModel.addToFolder(folder.id, threadId)
viewModel.addToFolder(folder.id, threadIds)
Toast.makeText(context, requireContext().getString(R.string.AddToFolderBottomSheet_added_to_s, folder.name), Toast.LENGTH_SHORT).show()
dismissAllowingStateLoss()
onDismissListener.onDismiss()
}
},
onCreate = {
requireContext().startActivity(AppSettingsActivity.createChatFolder(requireContext(), -1, threadId))
requireContext().startActivity(AppSettingsActivity.createChatFolder(requireContext(), -1, threadIds.toLongArray()))
dismissAllowingStateLoss()
onDismissListener.onDismiss()
}
)
}
@@ -104,8 +120,8 @@ class AddToFolderBottomSheet private constructor() : ComposeBottomSheetDialogFra
@Composable
private fun AddToChatFolderSheetContent(
threadId: Long,
isIndividualChat: Boolean,
threadIds: List<Long>,
threadTypes: List<Int>,
folders: List<ChatFolderRecord>,
onClick: (ChatFolderRecord, Boolean) -> Unit = { _, _ -> },
onCreate: () -> Unit = {}
@@ -130,11 +146,7 @@ private fun AddToChatFolderSheetContent(
.background(color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(18.dp))
) {
items(folders) { folder ->
val isIncludedViaChatType = (isIndividualChat && folder.showIndividualChats) || (!isIndividualChat && folder.showGroupChats)
val isIncludedExplicitly = folder.includedChats.contains(threadId)
val isExcludedExplicitly = folder.excludedChats.contains(threadId)
val isAlreadyAdded = (isIncludedExplicitly || isIncludedViaChatType) && !isExcludedExplicitly
val isAlreadyAdded = isThreadListAlreadyAdded(folder, threadIds, threadTypes)
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -210,14 +222,36 @@ private fun AddToChatFolderSheetContent(
}
}
private fun isThreadListAlreadyAdded(folder: ChatFolderRecord, threadIds: List<Long>, threadTypes: List<Int>): Boolean {
val isAnyExcluded = threadIds.any {
folder.excludedChats.contains(it)
}
if (isAnyExcluded) {
return false
}
if (folder.showIndividualChats) {
return threadTypes.indices.all { index ->
threadTypes[index] == AddToFolderBottomSheet.ThreadType.INDIVIDUAL.value || folder.includedChats.contains(threadIds[index])
}
}
if (folder.showGroupChats) {
return threadTypes.indices.all { index ->
threadTypes[index] == AddToFolderBottomSheet.ThreadType.GROUP.value || folder.includedChats.contains(threadIds[index])
}
}
return folder.includedChats.containsAll(threadIds)
}
@SignalPreview
@Composable
private fun AddToChatFolderSheetContentPreview() {
Previews.BottomSheetPreview {
AddToChatFolderSheetContent(
folders = listOf(ChatFolderRecord(name = "Friends"), ChatFolderRecord(name = "Work")),
threadId = 1,
isIndividualChat = false
threadIds = listOf(1),
threadTypes = listOf(0)
)
}
}

View File

@@ -160,7 +160,6 @@ import org.thoughtcrime.securesms.megaphone.MegaphoneActionController;
import org.thoughtcrime.securesms.megaphone.MegaphoneViewBuilder;
import org.thoughtcrime.securesms.megaphone.Megaphones;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.manage.UsernameEditFragment;
import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment;
@@ -220,9 +219,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private static final String TAG = Log.tag(ConversationListFragment.class);
private static final int MAXIMUM_PINNED_CONVERSATIONS = 4;
private static final int MAX_CHATS_ABOVE_FOLD = 7;
private static final int MAX_CONTACTS_ABOVE_FOLD = 5;
private static final int MAXIMUM_PINNED_CONVERSATIONS = 4;
private static final int MAX_CHATS_ABOVE_FOLD = 7;
private static final int MAX_CONTACTS_ABOVE_FOLD = 5;
private static final int MAX_GROUP_MEMBERSHIPS_ABOVE_FOLD = 5;
private ActionMode actionMode;
@@ -396,7 +395,7 @@ 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);
chatFolderAdapter = new ChatFolderAdapter(this);
DefaultItemAnimator chatFolderItemAnimator = getChatFolderItemAnimator();
chatFolderList.setLayoutManager(new LinearLayoutManager(requireActivity(), LinearLayoutManager.HORIZONTAL, false));
@@ -484,7 +483,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
}
private @NonNull DefaultItemAnimator getChatFolderItemAnimator() {
int duration = 150;
int duration = 150;
DefaultItemAnimator animator = new DefaultItemAnimator();
animator.setAddDuration(duration);
animator.setMoveDuration(duration);
@@ -669,10 +668,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
builder.addSection(new ContactSearchConfiguration.Section.Chats(
unreadOnly,
true,
new ContactSearchConfiguration.ExpandConfig(
state.getExpandedSections().contains(ContactSearchConfiguration.SectionKey.CHATS),
(a) -> MAX_CHATS_ABOVE_FOLD
)
new ContactSearchConfiguration.ExpandConfig(
state.getExpandedSections().contains(ContactSearchConfiguration.SectionKey.CHATS),
(a) -> MAX_CHATS_ABOVE_FOLD
)
));
if (!unreadOnly) {
@@ -704,8 +703,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
} else {
builder.arbitrary(
conversationFilterRequest.getSource() == ConversationFilterSource.DRAG
? ConversationListSearchAdapter.ChatFilterOptions.WITHOUT_TIP.getCode()
: ConversationListSearchAdapter.ChatFilterOptions.WITH_TIP.getCode()
? ConversationListSearchAdapter.ChatFilterOptions.WITHOUT_TIP.getCode()
: ConversationListSearchAdapter.ChatFilterOptions.WITH_TIP.getCode()
);
}
@@ -958,7 +957,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private void initializeListAdapters() {
defaultAdapter = new ConversationListAdapter(getViewLifecycleOwner(), Glide.with(this), this, this, this);
defaultAdapter = new ConversationListAdapter(getViewLifecycleOwner(), Glide.with(this), this, this, this);
setAdapter(defaultAdapter);
@@ -1509,10 +1508,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
} else {
if (viewModel.getCurrentFolder().getFolderType() == ChatFolderRecord.FolderType.ALL &&
(conversation.getThreadRecord().getRecipient().isIndividual() ||
conversation.getThreadRecord().getRecipient().isPushV2Group())) {
List<ChatFolderRecord> folders = viewModel.getFolders().stream().map(ChatFolderMappingModel::getChatFolder).collect(Collectors.toList());
conversation.getThreadRecord().getRecipient().isPushV2Group()))
{
items.add(new ActionItem(R.drawable.symbol_folder_add, getString(R.string.ConversationListFragment_add_to_folder), () ->
AddToFolderBottomSheet.showChatFolderSheet(folders, conversation.getThreadRecord().getThreadId(), conversation.getThreadRecord().getRecipient().isIndividual()).show(getParentFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
showAddToFolderBottomSheet(conversation)
));
} else if (viewModel.getCurrentFolder().getFolderType() != ChatFolderRecord.FolderType.ALL) {
items.add(new ActionItem(R.drawable.symbol_folder_minus, getString(R.string.ConversationListFragment_remove_from_folder), () -> viewModel.removeChatFromFolder(conversation.getThreadRecord().getThreadId())));
@@ -1582,6 +1581,52 @@ public class ConversationListFragment extends MainFragment implements ActionMode
closeSearchIfOpen();
}
private void showAddToFolderBottomSheet(Conversation conversation) {
showAddToFolderBottomSheet(
Collections.singletonList(conversation.getThreadRecord().getThreadId()),
Collections.singletonList(getThreadType(conversation))
);
}
private void showAddToFolderBottomSheet(Set<Conversation> conversations) {
List<Long> threadIds = new ArrayList<>();
List<Integer> threadTypes = new ArrayList<>();
for (Conversation conversation : conversations) {
threadIds.add(conversation.getThreadRecord().getThreadId());
threadTypes.add(getThreadType(conversation));
}
showAddToFolderBottomSheet(
threadIds,
threadTypes
);
}
private int getThreadType(Conversation conversation) {
boolean isIndividual = conversation.getThreadRecord().getRecipient().isIndividual();
boolean isGroup = conversation.getThreadRecord().getRecipient().isPushGroup();
int type;
if (isIndividual) {
type = AddToFolderBottomSheet.ThreadType.INDIVIDUAL.getValue();
} else if (isGroup) {
type = AddToFolderBottomSheet.ThreadType.GROUP.getValue();
} else {
type = AddToFolderBottomSheet.ThreadType.OTHER.getValue();
}
return type;
}
private void showAddToFolderBottomSheet(List<Long> threadIds, List<Integer> threadTypes) {
List<ChatFolderRecord> folders = viewModel.getFolders().stream().map(ChatFolderMappingModel::getChatFolder).collect(Collectors.toList());
AddToFolderBottomSheet.showChatFolderSheet(
folders,
threadIds,
threadTypes,
this::endActionModeIfActive
).show(getParentFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG);
}
private void updateMultiSelectState() {
int count = viewModel.currentSelectedConversations().size();
boolean hasUnread = Stream.of(viewModel.currentSelectedConversations()).anyMatch(conversation -> !conversation.getThreadRecord().isRead());
@@ -1628,6 +1673,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode
items.add(new ActionItem(R.drawable.symbol_check_circle_24, getString(R.string.ConversationListFragment_select_all), viewModel::onSelectAllClick));
if (!isArchived()) {
items.add(new ActionItem(R.drawable.symbol_folder_add, getString(R.string.ConversationListFragment_add_to_folder), () -> {
showAddToFolderBottomSheet(viewModel.currentSelectedConversations());
}));
}
bottomActionBar.setItems(items);
}

View File

@@ -283,9 +283,13 @@ class ConversationListViewModel(
}
}
fun addToFolder(folderId: Long, threadId: Long) {
fun addToFolder(folderId: Long, threadIds: List<Long>) {
viewModelScope.launch(Dispatchers.IO) {
SignalDatabase.chatFolders.addToFolder(folderId, threadId)
val includedChats = folders.find { it.chatFolder.id == folderId }?.chatFolder?.includedChats
val threadIdsNotIncluded = threadIds.filterNot { threadId ->
includedChats?.contains(threadId) ?: false
}
SignalDatabase.chatFolders.addToFolder(folderId, threadIdsNotIncluded)
}
}

View File

@@ -380,15 +380,17 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
/**
* Adds a thread to a chat folder
*/
fun addToFolder(folderId: Long, threadId: Long) {
fun addToFolder(folderId: Long, threadIds: List<Long>) {
writableDatabase.withinTransaction { db ->
db.insertInto(ChatFolderMembershipTable.TABLE_NAME)
.values(
ChatFolderMembershipTable.CHAT_FOLDER_ID to folderId,
ChatFolderMembershipTable.THREAD_ID to threadId,
ChatFolderMembershipTable.MEMBERSHIP_TYPE to MembershipType.INCLUDED.value
)
.run(SQLiteDatabase.CONFLICT_REPLACE)
threadIds.forEach { threadId ->
db.insertInto(ChatFolderMembershipTable.TABLE_NAME)
.values(
ChatFolderMembershipTable.CHAT_FOLDER_ID to folderId,
ChatFolderMembershipTable.THREAD_ID to threadId,
ChatFolderMembershipTable.MEMBERSHIP_TYPE to MembershipType.INCLUDED.value
)
.run(SQLiteDatabase.CONFLICT_REPLACE)
}
AppDependencies.databaseObserver.notifyChatFolderObservers()
}

View File

@@ -427,8 +427,9 @@
app:argType="long" />
<argument
android:name="thread_id"
app:argType="long" />
android:name="thread_ids"
app:argType="long[]"
app:nullable="true" />
<action
android:id="@+id/action_createFoldersFragment_to_chooseChatsFragment"

View File

@@ -787,8 +787,11 @@
<string name="AddToFolderBottomSheet_choose_a_folder">Choose a folder</string>
<!-- Toast shown when a chat has been added to a folder, where %s is the name of the folder -->
<string name="AddToFolderBottomSheet_added_to_s">Added to \"%1$s\"</string>
<!-- Toast shown when a user tries to add a chat to a folder, but the folder already has that chat. %s is the name of the folder -->
<string name="AddToFolderBottomSheet_this_chat_is_already">This chat is already in the folder \"%1$s\"</string>
<!-- Toast shown when a user tries to add chats to a folder, but the folder already has those chats. %s is the name of the folder -->
<plurals name="AddToFolderBottomSheet_these_chat_are_already_in_s">
<item quantity="one">This chat is already in \"%1$s\"</item>
<item quantity="other">These chats are already in \"%1$s\"</item>
</plurals>
<!-- Show in conversation list overflow menu to open selection bottom sheet -->
<string name="ConversationListFragment__notification_profile">Notification profile</string>