Add context menus to chat folders.

This commit is contained in:
Michelle Tang
2024-10-11 10:59:07 -07:00
committed by Greyson Parrelli
parent 6b66e4666b
commit bfa5703aaa
16 changed files with 474 additions and 10 deletions

View File

@@ -11,6 +11,7 @@ import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.chats.folders.CreateFoldersFragmentArgs
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleFragmentArgs
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
@@ -68,6 +69,10 @@ class AppSettingsActivity : DSLSettingsActivity(), InAppPaymentComponent {
StartLocation.USERNAME_LINK -> AppSettingsFragmentDirections.actionDirectToUsernameLinkSettings()
StartLocation.RECOVER_USERNAME -> AppSettingsFragmentDirections.actionDirectToUsernameRecovery()
StartLocation.REMOTE_BACKUPS -> AppSettingsFragmentDirections.actionDirectToRemoteBackupsSettingsFragment()
StartLocation.CHAT_FOLDERS -> AppSettingsFragmentDirections.actionDirectToChatFoldersFragment()
StartLocation.CREATE_CHAT_FOLDER -> AppSettingsFragmentDirections.actionDirectToCreateFoldersFragment(
CreateFoldersFragmentArgs.fromBundle(intent.getBundleExtra(START_ARGUMENTS)!!).folderId
)
}
}
@@ -198,6 +203,18 @@ class AppSettingsActivity : DSLSettingsActivity(), InAppPaymentComponent {
@JvmStatic
fun remoteBackups(context: Context): Intent = getIntentForStartLocation(context, StartLocation.REMOTE_BACKUPS)
@JvmStatic
fun chatFolders(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CHAT_FOLDERS)
@JvmStatic
fun createChatFolder(context: Context, id: Long = -1): Intent {
val arguments = CreateFoldersFragmentArgs.Builder(id)
.build()
.toBundle()
return getIntentForStartLocation(context, StartLocation.CREATE_CHAT_FOLDER).putExtra(START_ARGUMENTS, arguments)
}
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
return Intent(context, AppSettingsActivity::class.java)
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings_with_change_number)
@@ -222,7 +239,9 @@ class AppSettingsActivity : DSLSettingsActivity(), InAppPaymentComponent {
LINKED_DEVICES(13),
USERNAME_LINK(14),
RECOVER_USERNAME(15),
REMOTE_BACKUPS(16);
REMOTE_BACKUPS(16),
CHAT_FOLDERS(17),
CREATE_CHAT_FOLDER(18);
companion object {
fun fromCode(code: Int?): StartLocation {

View File

@@ -0,0 +1,122 @@
package org.thoughtcrime.securesms.components.settings.app.chats.folders
import android.content.Context
import android.view.View
import android.view.ViewGroup
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
/**
* A context menu shown when long pressing on a chat folder.
*/
object ChatFolderContextMenu {
fun show(
context: Context,
anchorView: View,
rootView: ViewGroup = anchorView.rootView as ViewGroup,
folderType: ChatFolderRecord.FolderType,
onEdit: () -> Unit = {},
onAdd: () -> Unit = {},
onMuteAll: () -> Unit = {},
onReadAll: () -> Unit = {},
onDelete: () -> Unit = {},
onReorder: () -> Unit = {}
) {
show(
context = context,
anchorView = anchorView,
rootView = rootView,
folderType = folderType,
callbacks = object : Callbacks {
override fun onEdit() = onEdit()
override fun onAdd() = onAdd()
override fun onMuteAll() = onMuteAll()
override fun onReadAll() = onReadAll()
override fun onDelete() = onDelete()
override fun onReorder() = onReorder()
}
)
}
private fun show(
context: Context,
anchorView: View,
rootView: ViewGroup,
folderType: ChatFolderRecord.FolderType,
callbacks: Callbacks
) {
val actions = mutableListOf<ActionItem>().apply {
if (folderType == ChatFolderRecord.FolderType.ALL) {
add(
ActionItem(R.drawable.symbol_plus_24, context.getString(R.string.ChatFoldersFragment__add_new_folder)) {
callbacks.onAdd()
}
)
add(
ActionItem(R.drawable.symbol_bell_slash_24, context.getString(R.string.ChatFoldersFragment__mute_all)) {
callbacks.onMuteAll()
}
)
add(
ActionItem(R.drawable.symbol_chat_check, context.getString(R.string.ChatFoldersFragment__mark_all_read)) {
callbacks.onReadAll()
}
)
add(
ActionItem(R.drawable.symbol_exchange_24, context.getString(R.string.ChatFoldersFragment__reorder_folder)) {
callbacks.onReorder()
}
)
} else {
add(
ActionItem(R.drawable.symbol_edit_24, context.getString(R.string.ChatFoldersFragment__edit_folder)) {
callbacks.onEdit()
}
)
add(
ActionItem(R.drawable.symbol_plus_24, context.getString(R.string.ChatFoldersFragment__add_new_folder)) {
callbacks.onAdd()
}
)
add(
ActionItem(R.drawable.symbol_bell_slash_24, context.getString(R.string.ChatFoldersFragment__mute_all)) {
callbacks.onMuteAll()
}
)
add(
ActionItem(R.drawable.symbol_chat_check, context.getString(R.string.ChatFoldersFragment__mark_all_read)) {
callbacks.onReadAll()
}
)
add(
ActionItem(R.drawable.symbol_trash_24, context.getString(R.string.ChatFoldersFragment__delete_folder)) {
callbacks.onDelete()
}
)
add(
ActionItem(R.drawable.symbol_exchange_24, context.getString(R.string.ChatFoldersFragment__reorder_folder)) {
callbacks.onReorder()
}
)
}
}
SignalContextMenu.Builder(anchorView, rootView)
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.offsetY(DimensionUnit.DP.toPixels(8f).toInt())
.show(actions)
}
private interface Callbacks {
fun onEdit()
fun onAdd()
fun onMuteAll()
fun onReadAll()
fun onDelete()
fun onReorder()
}
}

View File

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

View File

@@ -40,4 +40,8 @@ object ChatFoldersRepository {
fun updatePositions(folders: List<ChatFolderRecord>) {
SignalDatabase.chatFolders.updatePositions(folders)
}
fun getFolder(id: Long): ChatFolderRecord {
return SignalDatabase.chatFolders.getChatFolder(id)
}
}

View File

@@ -28,7 +28,12 @@ class ChatFoldersViewModel : ViewModel() {
val suggestedFolders = getSuggestedFolders(context, folders)
internalState.update {
it.copy(folders = folders, suggestedFolders = suggestedFolders)
it.copy(
folders = folders,
suggestedFolders = suggestedFolders,
currentFolder = ChatFolderRecord(),
originalFolder = ChatFolderRecord()
)
}
}
}
@@ -310,4 +315,13 @@ class ChatFoldersViewModel : ViewModel() {
originalFolder.excludedRecipients != currentFolder.excludedRecipients
}
}
fun setCurrentFolderId(folderId: Long) {
if (folderId != -1L) {
viewModelScope.launch(Dispatchers.IO) {
val folder = ChatFoldersRepository.getFolder(folderId)
setCurrentFolder(folder)
}
}
}
}

View File

@@ -27,6 +27,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -87,6 +88,12 @@ class CreateFoldersFragment : ComposeFragment() {
val focusRequester = remember { FocusRequester() }
val isNewFolder = state.originalFolder.id == -1L
LaunchedEffect(Unit) {
if (state.originalFolder == state.currentFolder) {
viewModel.setCurrentFolderId(arguments?.getLong(KEY_FOLDER_ID) ?: -1)
}
}
Scaffolds.Settings(
title = if (isNewFolder) stringResource(id = R.string.CreateFoldersFragment__create_a_folder) else stringResource(id = R.string.CreateFoldersFragment__edit_folder),
onNavigationClick = {
@@ -143,6 +150,10 @@ class CreateFoldersFragment : ComposeFragment() {
)
}
}
companion object {
private val KEY_FOLDER_ID = "folder_id"
}
}
@Composable

View File

@@ -6,6 +6,7 @@ import android.view.View
import android.widget.TextView
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderContextMenu
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -36,6 +37,20 @@ class ChatFolderAdapter(val callbacks: Callbacks) : MappingAdapter() {
itemView.setOnClickListener {
callbacks.onChatFolderClicked(model.chatFolder)
}
itemView.setOnLongClickListener { view ->
ChatFolderContextMenu.show(
context = itemView.context,
anchorView = view,
folderType = model.chatFolder.folderType,
onEdit = { callbacks.onEdit(model.chatFolder) },
onAdd = { callbacks.onAdd() },
onMuteAll = { callbacks.onMuteAll(model.chatFolder) },
onReadAll = { callbacks.onReadAll(model.chatFolder) },
onDelete = { callbacks.onDelete(model.chatFolder) },
onReorder = { callbacks.onReorder() }
)
true
}
if (model.isSelected) {
itemView.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(itemView.context, R.color.signal_colorSurfaceVariant))
} else {
@@ -54,5 +69,11 @@ class ChatFolderAdapter(val callbacks: Callbacks) : MappingAdapter() {
interface Callbacks {
fun onChatFolderClicked(chatFolder: ChatFolderRecord)
fun onEdit(chatFolder: ChatFolderRecord)
fun onAdd()
fun onMuteAll(chatFolder: ChatFolderRecord)
fun onReadAll(chatFolder: ChatFolderRecord)
fun onDelete(chatFolder: ChatFolderRecord)
fun onReorder()
}
}

View File

@@ -1666,6 +1666,44 @@ public class ConversationListFragment extends MainFragment implements ActionMode
viewModel.select(chatFolder);
}
@Override
public void onEdit(@NonNull ChatFolderRecord chatFolder) {
startActivity(AppSettingsActivity.createChatFolder(requireContext(), chatFolder.getId()));
}
@Override
public void onAdd() {
startActivity(AppSettingsActivity.createChatFolder(requireContext(), -1));
}
@Override
public void onMuteAll(@NonNull ChatFolderRecord chatFolder) {
MuteDialog.show(requireContext(), until -> viewModel.onMuteChatFolder(chatFolder, until));
}
@Override
public void onReadAll(@NonNull ChatFolderRecord chatFolder) {
if (chatFolder.getFolderType() == ChatFolderRecord.FolderType.ALL) {
handleMarkAllRead();
} else {
viewModel.markChatFolderRead(chatFolder);
}
}
@Override
public void onDelete(@NonNull ChatFolderRecord chatFolder) {
new MaterialAlertDialogBuilder(requireActivity())
.setMessage(getString(R.string.CreateFoldersFragment__delete_this_chat_folder))
.setPositiveButton(R.string.delete, (dialog, which) -> viewModel.deleteChatFolder(chatFolder))
.setNegativeButton(android.R.string.cancel, null)
.show();
}
@Override
public void onReorder() {
startActivity(AppSettingsActivity.chatFolders(requireContext()));
}
private class ArchiveListenerCallback extends ItemTouchHelper.SimpleCallback {
private static final long SWIPE_ANIMATION_DURATION = 175;

View File

@@ -31,7 +31,10 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository
import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.notifications.MarkReadReceiver
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import java.util.concurrent.TimeUnit
@@ -259,6 +262,41 @@ class ConversationListViewModel(
}
}
fun onMuteChatFolder(chatFolder: ChatFolderRecord, until: Long) {
viewModelScope.launch(Dispatchers.IO) {
val ids = SignalDatabase.threads.getRecipientIdsByChatFolder(chatFolder)
val recipientIds: List<RecipientId> = ids.filter { id ->
Recipient.resolved(id).muteUntil != until
}
if (recipientIds.isNotEmpty()) {
SignalDatabase.recipients.setMuted(recipientIds, until)
}
}
}
fun deleteChatFolder(chatFolder: ChatFolderRecord) {
viewModelScope.launch(Dispatchers.IO) {
SignalDatabase.chatFolders.deleteChatFolder(chatFolder)
val updatedFolders = folders.filter { folder -> folder.chatFolder.id != chatFolder.id }
store.update {
it.copy(
currentFolder = updatedFolders.first().chatFolder,
chatFolders = updatedFolders
)
}
}
}
fun markChatFolderRead(chatFolder: ChatFolderRecord) {
viewModelScope.launch(Dispatchers.IO) {
val ids = SignalDatabase.threads.getThreadIdsByChatFolder(chatFolder)
val messageIds = SignalDatabase.threads.setRead(ids, false)
AppDependencies.messageNotifier.updateNotification(AppDependencies.application)
MarkReadReceiver.process(messageIds)
}
}
private data class ConversationListState(
val chatFolders: List<ChatFolderMappingModel> = emptyList(),
val currentFolder: ChatFolderRecord = ChatFolderRecord(),

View File

@@ -9,6 +9,7 @@ import org.signal.core.util.groupBy
import org.signal.core.util.insertInto
import org.signal.core.util.readToList
import org.signal.core.util.readToSingleInt
import org.signal.core.util.readToSingleObject
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
@@ -117,6 +118,37 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
.run()
}
/**
* Returns a single chat folder that corresponds to that id
*/
fun getChatFolder(id: Long): ChatFolderRecord {
val includedChats: Map<Long, List<Long>> = getIncludedChats(id)
val excludedChats: Map<Long, List<Long>> = getExcludedChats(id)
val folder = readableDatabase
.select()
.from(ChatFolderTable.TABLE_NAME)
.where("${ChatFolderTable.ID} = ?", id)
.run()
.readToSingleObject { cursor ->
ChatFolderRecord(
id = id,
name = cursor.requireString(ChatFolderTable.NAME) ?: "",
position = cursor.requireInt(ChatFolderTable.POSITION),
showUnread = cursor.requireBoolean(ChatFolderTable.SHOW_UNREAD),
showMutedChats = cursor.requireBoolean(ChatFolderTable.SHOW_MUTED),
showIndividualChats = cursor.requireBoolean(ChatFolderTable.SHOW_INDIVIDUAL),
showGroupChats = cursor.requireBoolean(ChatFolderTable.SHOW_GROUPS),
isMuted = cursor.requireBoolean(ChatFolderTable.IS_MUTED),
folderType = ChatFolderRecord.FolderType.deserialize(cursor.requireInt(ChatFolderTable.FOLDER_TYPE)),
includedChats = includedChats[id] ?: emptyList(),
excludedChats = excludedChats[id] ?: emptyList()
)
}
return folder ?: ChatFolderRecord()
}
/**
* Maps the chat folder ids to its corresponding chat folder
*/
@@ -158,13 +190,20 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
}
/**
* Maps chat folder ids to all of its corresponding included chats
* Maps a chat folder id to all of its corresponding included chats.
* If an id is not specified, all chat folder ids will be mapped.
*/
private fun getIncludedChats(): Map<Long, List<Long>> {
private fun getIncludedChats(id: Long? = null): Map<Long, List<Long>> {
val whereQuery = if (id != null) {
"${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.INCLUDED.value} AND ${ChatFolderMembershipTable.CHAT_FOLDER_ID} = $id"
} else {
"${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.INCLUDED.value}"
}
return readableDatabase
.select()
.from(ChatFolderMembershipTable.TABLE_NAME)
.where("${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.INCLUDED.value}")
.where(whereQuery)
.run()
.groupBy { cursor ->
cursor.requireLong(ChatFolderMembershipTable.CHAT_FOLDER_ID) to cursor.requireLong(ChatFolderMembershipTable.THREAD_ID)
@@ -172,13 +211,20 @@ class ChatFolderTables(context: Context?, databaseHelper: SignalDatabase?) : Dat
}
/**
* Maps the chat folder ids to all of its corresponding excluded chats
* Maps a chat folder id to all of its corresponding excluded chats.
* If an id is not specified, all chat folder ids will be mapped.
*/
private fun getExcludedChats(): Map<Long, List<Long>> {
private fun getExcludedChats(id: Long? = null): Map<Long, List<Long>> {
val whereQuery = if (id != null) {
"${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.EXCLUDED.value} AND ${ChatFolderMembershipTable.CHAT_FOLDER_ID} = $id"
} else {
"${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.EXCLUDED.value}"
}
return readableDatabase
.select()
.from(ChatFolderMembershipTable.TABLE_NAME)
.where("${ChatFolderMembershipTable.MEMBERSHIP_TYPE} = ${MembershipType.EXCLUDED.value}")
.where(whereQuery)
.run()
.groupBy { cursor ->
cursor.requireLong(ChatFolderMembershipTable.CHAT_FOLDER_ID) to cursor.requireLong(ChatFolderMembershipTable.THREAD_ID)

View File

@@ -1041,6 +1041,47 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
}
}
fun getThreadIdsByChatFolder(chatFolder: ChatFolderRecord): List<Long> {
val folderQuery = chatFolder.toQuery()
val query =
"""
SELECT ${TABLE_NAME}.$ID
FROM $TABLE_NAME
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
WHERE
$ACTIVE = 1
$folderQuery
"""
return readableDatabase.rawQuery(query, null).readToList { cursor -> cursor.requireLong(ID) }
}
fun getRecipientIdsByChatFolder(chatFolder: ChatFolderRecord): List<RecipientId> {
return if (chatFolder.folderType == ChatFolderRecord.FolderType.ALL) {
readableDatabase
.select(RECIPIENT_ID)
.from(TABLE_NAME)
.where("$ACTIVE = 1")
.run()
.readToList { cursor ->
RecipientId.from(cursor.requireLong(RECIPIENT_ID))
}
} else {
val folderQuery = chatFolder.toQuery()
val query =
"""
SELECT $RECIPIENT_ID
FROM $TABLE_NAME
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
WHERE
$ACTIVE = 1
$folderQuery
"""
readableDatabase.rawQuery(query, null).readToList { cursor ->
RecipientId.from(cursor.requireLong(RECIPIENT_ID))
}
}
}
/**
* @return Pinned recipients, in order from top to bottom.
*/