diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFolderRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFolderRecord.kt index 9acc3be55f..7ffcafff0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFolderRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFolderRecord.kt @@ -1,17 +1,23 @@ package org.thoughtcrime.securesms.components.settings.app.chats.folders +import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize import org.thoughtcrime.securesms.recipients.Recipient /** * Represents an entry in the [org.thoughtcrime.securesms.database.ChatFolderTables]. */ +@Parcelize data class ChatFolderRecord( val id: Long = -1, val name: String = "", val position: Int = -1, val includedChats: List = emptyList(), val excludedChats: List = emptyList(), + @IgnoredOnParcel val includedRecipients: Set = emptySet(), + @IgnoredOnParcel val excludedRecipients: Set = emptySet(), val showUnread: Boolean = false, val showMutedChats: Boolean = true, @@ -20,7 +26,7 @@ data class ChatFolderRecord( val isMuted: Boolean = false, val folderType: FolderType = FolderType.CUSTOM, val unreadCount: Int = 0 -) { +) : Parcelable { enum class FolderType(val value: Int) { /** Folder containing all chats */ ALL(0), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt index 0e2a342685..cf29c06180 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/folders/ChatFoldersFragment.kt @@ -135,7 +135,7 @@ fun FoldersScreen( val elevation = if (isDragging) 1.dp else 0.dp val isAllChats = folder.folderType == ChatFolderRecord.FolderType.ALL FolderRow( - icon = R.drawable.ic_chat_folder_24, + icon = R.drawable.symbol_folder_24, title = if (isAllChats) stringResource(R.string.ChatFoldersFragment__all_chats) else folder.name, subtitle = getFolderDescription(folder), onClick = if (!isAllChats) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/AddToFolderBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/AddToFolderBottomSheet.kt new file mode 100644 index 0000000000..6b3984557b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/AddToFolderBottomSheet.kt @@ -0,0 +1,177 @@ +package org.thoughtcrime.securesms.conversationlist + +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.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +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.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import org.signal.core.ui.BottomSheets +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.signal.core.util.getParcelableArrayListCompat +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity +import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord +import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.util.viewModel + +/** + * Bottom sheet shown when choosing to add a chat to a folder + */ +class AddToFolderBottomSheet private constructor() : ComposeBottomSheetDialogFragment() { + + override val peekHeightPercentage: Float = 1f + + 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" + + @JvmStatic + fun showChatFolderSheet(folders: List, threadId: Long): ComposeBottomSheetDialogFragment { + return AddToFolderBottomSheet().apply { + arguments = bundleOf( + ARG_FOLDERS to folders, + ARG_THREAD_ID to threadId + ) + } + } + } + + @Composable + override fun SheetContent() { + val folders = arguments?.getParcelableArrayListCompat(ARG_FOLDERS, ChatFolderRecord::class.java)?.filter { it.folderType != ChatFolderRecord.FolderType.ALL } + val threadId = arguments?.getLong(ARG_THREAD_ID) + + AddToChatFolderSheetContent( + folders = remember { folders ?: emptyList() }, + onClick = { folder -> + if (threadId != null) { + viewModel.addToFolder(folder.id, threadId) + Toast.makeText(context, requireContext().getString(R.string.AddToFolderBottomSheet_added_to_s, folder.name), Toast.LENGTH_SHORT).show() + } + dismissAllowingStateLoss() + }, + onCreate = { + requireContext().startActivity(AppSettingsActivity.createChatFolder(requireContext(), -1)) + dismissAllowingStateLoss() + } + ) + } +} + +@Composable +private fun AddToChatFolderSheetContent( + folders: List, + onClick: (ChatFolderRecord) -> Unit = {}, + onCreate: () -> Unit = {} +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + BottomSheets.Handle() + + Text( + text = stringResource(R.string.AddToFolderBottomSheet_choose_a_folder), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 30.dp) + ) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, top = 36.dp, bottom = 60.dp) + .background(color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(18.dp)) + ) { + items(folders) { folder -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable(onClick = { onClick(folder) }) + .padding(start = 24.dp) + .fillMaxWidth() + .defaultMinSize(minHeight = dimensionResource(id = R.dimen.chat_folder_row_height)) + ) { + Image( + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + imageVector = ImageVector.vectorResource(id = R.drawable.symbol_folder_24), + contentDescription = null, + modifier = Modifier + .size(40.dp) + .background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape) + .padding(8.dp) + ) + + Text( + text = folder.name, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable(onClick = onCreate) + .padding(start = 24.dp) + .fillMaxWidth() + .defaultMinSize(minHeight = dimensionResource(id = R.dimen.chat_folder_row_height)) + ) { + Image( + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + imageVector = ImageVector.vectorResource(id = R.drawable.symbol_plus_24), + contentDescription = null, + modifier = Modifier + .size(40.dp) + .background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape) + .padding(8.dp) + ) + + Text( + text = stringResource(id = R.string.ChatFoldersFragment__create_a_folder), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + } +} + +@SignalPreview +@Composable +private fun AddToChatFolderSheetContentPreview() { + Previews.BottomSheetPreview { + AddToChatFolderSheetContent( + folders = listOf(ChatFolderRecord(name = "Friends"), ChatFolderRecord(name = "Work")) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ChatFolderAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ChatFolderAdapter.kt index 82dd3d9285..8130b648c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ChatFolderAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ChatFolderAdapter.kt @@ -33,7 +33,7 @@ class ChatFolderAdapter(val callbacks: Callbacks) : MappingAdapter() { val folder = model.chatFolder name.text = getName(itemView.context, folder) unreadCount.visible = folder.unreadCount > 0 - unreadCount.text = folder.unreadCount.toString() + unreadCount.text = if (folder.unreadCount > 99) itemView.context.getString(R.string.ChatFolderAdapter__99p) else folder.unreadCount.toString() itemView.setOnClickListener { callbacks.onChatFolderClicked(model.chatFolder) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 8bcdd683af..e21214edfe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -166,6 +166,7 @@ import org.thoughtcrime.securesms.stories.tabs.ConversationListTab; import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel; import org.thoughtcrime.securesms.util.AppForegroundObserver; import org.thoughtcrime.securesms.util.AppStartup; +import org.thoughtcrime.securesms.util.BottomSheetUtil; import org.thoughtcrime.securesms.util.CachedInflater; import org.thoughtcrime.securesms.util.ConversationUtil; import org.thoughtcrime.securesms.util.RemoteConfig; @@ -1466,6 +1467,14 @@ public class ConversationListFragment extends MainFragment implements ActionMode if (conversation.getThreadRecord().isArchived()) { items.add(new ActionItem(R.drawable.symbol_archive_up_24, getResources().getString(R.string.ConversationListFragment_unarchive), () -> handleArchive(id, false))); } else { + if (RemoteConfig.internalUser() && viewModel.getCurrentFolder().getFolderType() == ChatFolderRecord.FolderType.ALL) { + List folders = viewModel.getFolders().stream().map(ChatFolderMappingModel::getChatFolder).collect(Collectors.toList()); + items.add(new ActionItem(R.drawable.symbol_folder_add, getString(R.string.ConversationListFragment_add_to_folder), () -> + AddToFolderBottomSheet.showChatFolderSheet(folders, conversation.getThreadRecord().getThreadId()).show(getParentFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + )); + } else if (RemoteConfig.internalUser()){ + items.add(new ActionItem(R.drawable.symbol_folder_minus, getString(R.string.ConversationListFragment_remove_from_folder), () -> viewModel.removeChatFromFolder(conversation.getThreadRecord().getThreadId()))); + } items.add(new ActionItem(R.drawable.symbol_archive_24, getResources().getString(R.string.ConversationListFragment_archive), () -> handleArchive(id, false))); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt index 62a30fb54e..040c02ae1c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt @@ -297,6 +297,18 @@ class ConversationListViewModel( } } + fun removeChatFromFolder(threadId: Long) { + viewModelScope.launch(Dispatchers.IO) { + SignalDatabase.chatFolders.removeFromFolder(currentFolder.id, threadId) + } + } + + fun addToFolder(folderId: Long, threadId: Long) { + viewModelScope.launch(Dispatchers.IO) { + SignalDatabase.chatFolders.addToFolder(folderId, threadId) + } + } + private data class ConversationListState( val chatFolders: List = emptyList(), val currentFolder: ChatFolderRecord = ChatFolderRecord(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt index 17d182fe3c..d9a2ac8b7f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ChatFolderTables.kt @@ -99,12 +99,12 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat $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 + $MEMBERSHIP_TYPE INTEGER DEFAULT 1, + UNIQUE(${CHAT_FOLDER_ID}, ${THREAD_ID}) ON CONFLICT REPLACE ) """ 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)" ) @@ -347,6 +347,40 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat } } + /** + * Removes a thread from a chat folder + */ + fun removeFromFolder(folderId: Long, threadId: 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.EXCLUDED.value + ) + .run(SQLiteDatabase.CONFLICT_REPLACE) + + AppDependencies.databaseObserver.notifyChatFolderObservers() + } + } + + /** + * Adds a thread to a chat folder + */ + fun addToFolder(folderId: Long, threadId: 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) + + AppDependencies.databaseObserver.notifyChatFolderObservers() + } + } + private fun Collection.toContentValues(chatFolderId: Long, membershipType: MembershipType): List { return map { contentValuesOf( diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 8f1e4d6169..e8fceb8aed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -110,6 +110,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V250_ClearUploadTim 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 +import org.thoughtcrime.securesms.database.helpers.migration.V254_AddChatFolderConstraint /** * Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness. @@ -222,10 +223,11 @@ object SignalDatabaseMigrations { 250 to V250_ClearUploadTimestampV2, 251 to V251_ArchiveTransferStateIndex, 252 to V252_AttachmentOffloadRestoredAtColumn, - 253 to V253_CreateChatFolderTables + 253 to V253_CreateChatFolderTables, + 254 to V254_AddChatFolderConstraint ) - const val DATABASE_VERSION = 253 + const val DATABASE_VERSION = 254 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V254_AddChatFolderConstraint.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V254_AddChatFolderConstraint.kt new file mode 100644 index 0000000000..7ecd7d2609 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V254_AddChatFolderConstraint.kt @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import net.zetetic.database.sqlcipher.SQLiteDatabase + +/** + * Adds a unique constraint to chat folder membership + */ +@Suppress("ClassName") +object V254_AddChatFolderConstraint : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("DROP INDEX IF EXISTS chat_folder_membership_chat_folder_id_index") + db.execSQL("DROP INDEX IF EXISTS chat_folder_membership_thread_id_index") + db.execSQL("DROP INDEX IF EXISTS chat_folder_membership_membership_type_index") + + db.execSQL( + """ + CREATE TABLE chat_folder_membership_tmp ( + _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, + UNIQUE(chat_folder_id, thread_id) ON CONFLICT REPLACE + ) + """ + ) + + db.execSQL( + """ + INSERT INTO chat_folder_membership_tmp + SELECT + _id, + chat_folder_id, + thread_id, + membership_type + FROM chat_folder_membership + """ + ) + + db.execSQL("DROP TABLE chat_folder_membership") + db.execSQL("ALTER TABLE chat_folder_membership_tmp RENAME TO chat_folder_membership") + + 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)") + } +} diff --git a/app/src/main/res/drawable/ic_chat_folder_24.xml b/app/src/main/res/drawable/symbol_folder_24.xml similarity index 100% rename from app/src/main/res/drawable/ic_chat_folder_24.xml rename to app/src/main/res/drawable/symbol_folder_24.xml diff --git a/app/src/main/res/drawable/symbol_folder_add.xml b/app/src/main/res/drawable/symbol_folder_add.xml new file mode 100644 index 0000000000..58ac8b2d7f --- /dev/null +++ b/app/src/main/res/drawable/symbol_folder_add.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/symbol_folder_minus.xml b/app/src/main/res/drawable/symbol_folder_minus.xml new file mode 100644 index 0000000000..6fd3175de2 --- /dev/null +++ b/app/src/main/res/drawable/symbol_folder_minus.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c07ff8ddd1..0e68eea253 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -740,6 +740,15 @@ %d selected %d selected + + Add to folder + + Remove from folder + + + Choose a folder + + Added to \"%1$s\" Notification profile @@ -5117,6 +5126,8 @@ %1$d chat excluded %1$d chats excluded + + 99+