Allow adding and removing from context menu.

This commit is contained in:
Michelle Tang
2024-10-16 13:57:49 -07:00
committed by Greyson Parrelli
parent 94d6bfd9ad
commit 6fcfd8fdb1
13 changed files with 330 additions and 7 deletions

View File

@@ -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<Long> = emptyList(),
val excludedChats: List<Long> = emptyList(),
@IgnoredOnParcel
val includedRecipients: Set<Recipient> = emptySet(),
@IgnoredOnParcel
val excludedRecipients: Set<Recipient> = 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),

View File

@@ -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) {

View File

@@ -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<ChatFolderRecord>, 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<ChatFolderRecord>,
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"))
)
}
}

View File

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

View File

@@ -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<ChatFolderRecord> 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)));
}

View File

@@ -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<ChatFolderMappingModel> = emptyList(),
val currentFolder: ChatFolderRecord = ChatFolderRecord(),

View File

@@ -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<Long>.toContentValues(chatFolderId: Long, membershipType: MembershipType): List<ContentValues> {
return map {
contentValuesOf(

View File

@@ -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) {

View File

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