Release chat folders to internal users.

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

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.settings.app.chats
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
@@ -61,6 +60,19 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch
dividerPref()
if (RemoteConfig.internalUser) {
sectionHeaderPref(R.string.ChatsSettingsFragment__chat_folders)
clickPref(
title = DSLSettingsText.from(R.string.ChatsSettingsFragment__add_chat_folder),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_chatFoldersFragment)
}
)
dividerPref()
}
sectionHeaderPref(R.string.ChatsSettingsFragment__keyboard)
switchPref(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,449 @@
package org.thoughtcrime.securesms.components.settings.app.chats.folders
import android.os.Bundle
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.activityViewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Dividers
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Fragment that allows user to create, edit, or delete an individual folder
*/
class CreateFoldersFragment : ComposeFragment() {
private val viewModel: ChatFoldersViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (viewModel.hasChanges()) {
viewModel.showConfirmationDialog(true)
} else {
findNavController().popBackStack()
}
}
}
)
}
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsState()
val navController: NavController by remember { mutableStateOf(findNavController()) }
val focusRequester = remember { FocusRequester() }
val isNewFolder = state.originalFolder.id == -1L
Scaffolds.Settings(
title = if (isNewFolder) stringResource(id = R.string.CreateFoldersFragment__create_a_folder) else stringResource(id = R.string.CreateFoldersFragment__edit_folder),
onNavigationClick = {
if (viewModel.hasChanges()) {
viewModel.showConfirmationDialog(true)
} else {
navController.popBackStack()
}
},
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { contentPadding: PaddingValues ->
CreateFolderScreen(
state = state,
focusRequester = focusRequester,
modifier = Modifier.padding(contentPadding),
isNewFolder = isNewFolder,
hasChanges = viewModel.hasChanges(),
onAddChat = {
viewModel.setPendingChats()
navController.safeNavigate(CreateFoldersFragmentDirections.actionCreateFoldersFragmentToChooseChatsFragment(true))
},
onRemoveChat = {
viewModel.setPendingChats()
navController.safeNavigate(CreateFoldersFragmentDirections.actionCreateFoldersFragmentToChooseChatsFragment(false))
},
onNameChange = { viewModel.updateName(it) },
onToggleShowUnread = { viewModel.toggleShowUnread(it) },
onToggleShowMuted = { viewModel.toggleShowMutedChats(it) },
onDeleteClicked = { viewModel.showDeleteDialog(true) },
onDeleteConfirmed = {
viewModel.deleteFolder()
navController.popBackStack()
},
onDeleteDismissed = {
viewModel.showDeleteDialog(false)
},
onCreateConfirmed = { shouldExit ->
if (isNewFolder) {
viewModel.createFolder(requireContext())
} else {
viewModel.updateFolder(requireContext())
}
if (shouldExit) {
navController.popBackStack()
}
},
onCreateDismissed = { shouldExit ->
viewModel.showConfirmationDialog(false)
if (shouldExit) {
navController.popBackStack()
}
}
)
}
}
}
@Composable
fun CreateFolderScreen(
state: ChatFoldersSettingsState,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
isNewFolder: Boolean = true,
hasChanges: Boolean = false,
onAddChat: () -> Unit = {},
onRemoveChat: () -> Unit = {},
onNameChange: (String) -> Unit = {},
onToggleShowUnread: (Boolean) -> Unit = {},
onToggleShowMuted: (Boolean) -> Unit = {},
onDeleteClicked: () -> Unit = {},
onDeleteConfirmed: () -> Unit = {},
onDeleteDismissed: () -> Unit = {},
onCreateConfirmed: (Boolean) -> Unit = {},
onCreateDismissed: (Boolean) -> Unit = {}
) {
if (state.showDeleteDialog) {
Dialogs.SimpleAlertDialog(
title = "",
body = stringResource(id = R.string.CreateFoldersFragment__delete_this_chat_folder),
confirm = stringResource(id = R.string.delete),
onConfirm = onDeleteConfirmed,
dismiss = stringResource(id = android.R.string.cancel),
onDismiss = onDeleteDismissed
)
} else if (state.showConfirmationDialog && isNewFolder) {
Dialogs.SimpleAlertDialog(
title = stringResource(id = R.string.CreateFoldersFragment__create_folder_title),
body = stringResource(id = R.string.CreateFoldersFragment__do_you_want_to_create, state.currentFolder.name),
confirm = stringResource(id = R.string.CreateFoldersFragment__create_folder),
onConfirm = { onCreateConfirmed(false) },
dismiss = stringResource(id = R.string.CreateFoldersFragment__discard),
onDismiss = { onCreateDismissed(true) },
onDismissRequest = { onCreateDismissed(false) }
)
} else if (state.showConfirmationDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(id = R.string.CreateFoldersFragment__save_changes_title),
body = stringResource(id = R.string.CreateFoldersFragment__do_you_want_to_save),
confirm = stringResource(id = R.string.CreateFoldersFragment__save_changes),
onConfirm = { onCreateConfirmed(false) },
dismiss = stringResource(id = R.string.CreateFoldersFragment__discard),
onDismiss = { onCreateDismissed(true) },
onDismissRequest = { onCreateDismissed(false) }
)
}
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn {
item {
TextField(
value = state.currentFolder.name,
label = { Text(text = stringResource(id = R.string.CreateFoldersFragment__folder_name)) },
onValueChange = onNameChange,
singleLine = true,
modifier = modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.padding(top = 16.dp, bottom = 12.dp, start = 20.dp, end = 28.dp)
)
}
item {
Text(
text = stringResource(id = R.string.CreateFoldersFragment__included_chats),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp, start = 24.dp)
)
FolderRow(
icon = R.drawable.symbol_plus_compact_16,
title = stringResource(R.string.CreateFoldersFragment__add_chats),
onClick = onAddChat,
modifier = Modifier.padding(start = 12.dp)
)
if (state.currentFolder.showIndividualChats) {
FolderRow(
icon = R.drawable.symbol_person_light_24,
title = stringResource(R.string.ChatFoldersFragment__one_on_one_chats),
onClick = onAddChat,
modifier = Modifier.padding(start = 12.dp)
)
}
if (state.currentFolder.showGroupChats) {
FolderRow(
icon = R.drawable.symbol_group_light_20,
title = stringResource(R.string.ChatFoldersFragment__groups),
onClick = onAddChat,
modifier = Modifier.padding(start = 12.dp)
)
}
}
items(state.currentFolder.includedRecipients.toList()) { recipient ->
ChatRow(
recipient = recipient,
onClick = onAddChat
)
}
item {
Text(
text = stringResource(id = R.string.CreateFoldersFragment__choose_chats_you_want),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, start = 24.dp)
)
}
item {
Text(
text = stringResource(id = R.string.CreateFoldersFragment__exceptions),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 24.dp, bottom = 12.dp, end = 12.dp, start = 24.dp)
)
FolderRow(
icon = R.drawable.symbol_plus_compact_16,
title = stringResource(R.string.CreateFoldersFragment__exclude_chats),
onClick = onRemoveChat,
modifier = Modifier.padding(start = 12.dp)
)
}
items(state.currentFolder.excludedRecipients.toList()) { recipient ->
ChatRow(
recipient = recipient,
onClick = onRemoveChat
)
}
item {
Text(
text = stringResource(id = R.string.CreateFoldersFragment__choose_chats_you_do_not_want),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, start = 24.dp, end = 12.dp)
)
}
item {
Dividers.Default()
ShowUnreadSection(state, onToggleShowUnread)
ShowMutedSection(state, onToggleShowMuted)
if (!isNewFolder) {
Dividers.Default()
Text(
text = stringResource(id = R.string.CreateFoldersFragment__delete_folder),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.clickable { onDeleteClicked() }
.fillMaxWidth()
.padding(start = 24.dp, top = 16.dp, bottom = 32.dp)
)
}
}
if (hasChanges) {
item { Spacer(modifier = Modifier.height(60.dp)) }
}
}
if (hasChanges && isNewFolder) {
Buttons.MediumTonal(
onClick = { onCreateConfirmed(true) },
modifier = modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp)
) {
Text(text = stringResource(R.string.CreateFoldersFragment__create))
}
} else if (!isNewFolder) {
Buttons.MediumTonal(
enabled = hasChanges,
onClick = { onCreateConfirmed(true) },
modifier = modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp)
) {
Text(text = stringResource(R.string.CreateFoldersFragment__save))
}
}
}
}
@Composable
private fun ShowUnreadSection(state: ChatFoldersSettingsState, onToggleShowUnread: (Boolean) -> Unit) {
Row(
modifier = Modifier
.padding(horizontal = 24.dp)
.defaultMinSize(minHeight = 92.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(id = R.string.CreateFoldersFragment__only_show_unread_chats),
style = MaterialTheme.typography.bodyLarge
)
Text(
text = stringResource(id = R.string.CreateFoldersFragment__when_enabled_only_chats),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = state.currentFolder.showUnread,
onCheckedChange = onToggleShowUnread
)
}
}
@Composable
private fun ShowMutedSection(state: ChatFoldersSettingsState, onToggleShowMuted: (Boolean) -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(horizontal = 24.dp)
.defaultMinSize(minHeight = 56.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(id = R.string.CreateFoldersFragment__include_muted_chats),
style = MaterialTheme.typography.bodyLarge
)
}
Switch(
checked = state.currentFolder.showMutedChats,
onCheckedChange = onToggleShowMuted
)
}
}
@SignalPreview
@Composable
private fun CreateFolderPreview() {
val previewFolder = ChatFolderRecord(id = 1, name = "WIP")
Previews.Preview {
CreateFolderScreen(
state = ChatFoldersSettingsState(currentFolder = previewFolder),
focusRequester = FocusRequester(),
isNewFolder = true
)
}
}
@SignalPreview
@Composable
private fun EditFolderPreview() {
val previewFolder = ChatFolderRecord(id = 1, name = "Work")
Previews.Preview {
CreateFolderScreen(
state = ChatFoldersSettingsState(originalFolder = previewFolder),
focusRequester = FocusRequester(),
isNewFolder = false
)
}
}
@Composable
fun ChatRow(
recipient: Recipient,
modifier: Modifier = Modifier,
onClick: (() -> Unit)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.clickable(onClick = onClick)
.fillMaxWidth()
.defaultMinSize(minHeight = 64.dp)
) {
if (LocalInspectionMode.current) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
modifier = Modifier
.padding(start = 24.dp, end = 16.dp)
.size(40.dp)
.background(
color = Color.Red,
shape = CircleShape
)
)
} else {
AvatarImage(
recipient = recipient,
modifier = Modifier
.padding(start = 24.dp, end = 16.dp)
.size(40.dp)
)
}
Text(text = recipient.getShortDisplayName(LocalContext.current))
}
}

View File

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