Recreating xml dialogs in Compose and moved logic in VM

pull/1518/head
ThomasSession 7 months ago
parent f73e022cfd
commit ed38e507ae

@ -33,12 +33,10 @@ import android.widget.Toast
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.drawToBitmap
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
@ -110,6 +108,7 @@ import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey
import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate
import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity
import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.*
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP
@ -119,9 +118,6 @@ import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companio
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_COPY import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_COPY
import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_SAVE import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.ON_SAVE
import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.BlockedDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.DeleteMessageDeviceOnlyDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.DeleteMessageDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.DeleteNoteToSelfDialog
import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog import org.thoughtcrime.securesms.conversation.v2.dialogs.LinkPreviewDialog
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarButton
import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate import org.thoughtcrime.securesms.conversation.v2.input_bar.InputBarDelegate
@ -176,14 +172,13 @@ import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment
import org.thoughtcrime.securesms.showSessionDialog import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.OpenURLAlertDialog
import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.ActivityDispatcher
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.NetworkUtils import org.thoughtcrime.securesms.util.NetworkUtils
import org.thoughtcrime.securesms.util.SaveAttachmentTask import org.thoughtcrime.securesms.util.SaveAttachmentTask
import org.thoughtcrime.securesms.util.drawToBitmap
import org.thoughtcrime.securesms.util.isScrolledToBottom import org.thoughtcrime.securesms.util.isScrolledToBottom
import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom import org.thoughtcrime.securesms.util.isScrolledToWithin30dpOfBottom
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
@ -246,8 +241,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
.get(LinkPreviewViewModel::class.java) .get(LinkPreviewViewModel::class.java)
} }
private var openLinkDialogUrl: String? by mutableStateOf(null)
private val threadId: Long by lazy { private val threadId: Long by lazy {
var threadId = intent.getLongExtra(THREAD_ID, -1L) var threadId = intent.getLongExtra(THREAD_ID, -1L)
if (threadId == -1L) { if (threadId == -1L) {
@ -412,7 +405,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// endregion // endregion
fun showOpenUrlDialog(url: String){ fun showOpenUrlDialog(url: String){
openLinkDialogUrl = url viewModel.onCommand(ShowOpenUrlDialog(url))
} }
// region Lifecycle // region Lifecycle
@ -425,16 +418,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
binding.dialogOpenUrl.apply { binding.dialogOpenUrl.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent { setContent {
SessionMaterialTheme { val dialogsState by viewModel.dialogsState.collectAsState()
if(!openLinkDialogUrl.isNullOrEmpty()){ ConversationV2Dialogs(
OpenURLAlertDialog( dialogsState = dialogsState,
url = openLinkDialogUrl!!, sendCommand = viewModel::onCommand
onDismissRequest = { )
openLinkDialogUrl = null
}
)
}
}
} }
} }
@ -1668,11 +1656,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
private fun isUserCommunityManager() = viewModel.openGroup?.let { openGroup ->
val userPublicKey = textSecurePreferences.getLocalNumber() ?: return@let false
OpenGroupManager.isUserModerator(this, openGroup.id, userPublicKey, viewModel.blindedPublicKey)
} ?: false
override fun playVoiceMessageAtIndexIfPossible(indexInAdapter: Int) { override fun playVoiceMessageAtIndexIfPossible(indexInAdapter: Int) {
if (!textSecurePreferences.autoplayAudioMessages()) return if (!textSecurePreferences.autoplayAudioMessages()) return
@ -2069,120 +2052,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// Note: The messages in the provided set may be a single message, or multiple if there are a // Note: The messages in the provided set may be a single message, or multiple if there are a
// group of selected messages. // group of selected messages.
override fun deleteMessages(messages: Set<MessageRecord>) { override fun deleteMessages(messages: Set<MessageRecord>) {
val conversation = viewModel.recipient endActionMode()
if (conversation == null) {
Log.w("ConversationActivityV2", "Asked to delete messages but could not obtain viewModel recipient - aborting.")
return
}
// Refer to our figma document for info on message deletion [https://www.figma.com/design/kau6LggVcMMWmZRMibEo8F/Standardise-Message-Deletion?node-id=0-1&t=dEPcU0SZ9G2s4gh2-0]
//todo DELETION delete for everyone
//todo DELETION delete all my devices
//todo DELETION handle control messages deletion ( and make clickable )
//todo DELETION handle multi select scenarios
//todo DELETION check that the unread status works as expected when deleting a message
//todo DELETION check attachments deleted
//todo DELETION check links deleted
//todo DELETION check notifications deleted
//todo DELETION handle errors: Toasts for errors, or deleting messages not fully sent yet
val allSentByCurrentUser = messages.all { it.isOutgoing }
// hashes are required if wanting to delete messages from the 'storage server' - they are not required for communities
val canDeleteForEveryone = conversation.isCommunityRecipient || messages.all { lokiMessageDb.getMessageServerHash(it.id, it.isMms) != null }
// Determining is the current user is an admin will depend on the kind of conversation we are in
val isAdmin = when {
//todo GROUPS V2 add logic where code is commented to determine if user is an admin - CAREFUL in the current old code:
// isClosedGroup refers to the existing legacy groups.
// With the groupsV2 changes, isClosedGroup refers to groupsV2 and isLegacyClosedGroup is a new property to refer to old groups
// for Groups V2
// conversation: check if it is a GroupsV2 conversation - then check if user is an admin
// for legacy groups, check if the user created the group
conversation.isClosedGroupRecipient -> { //todo GROUPS V2 this property will change for groups v2. Check for legacyGroup here
// for legacy groups, we check if the current user is the one who created the group
run {
val localUserAddress = textSecurePreferences.getLocalNumber() ?: return@run false
val group = storage.getGroup(conversation.address.toGroupString())
group?.admins?.contains(fromSerialized(localUserAddress)) ?: false
}
}
// for communities the the `isUserModerator` field
conversation.isCommunityRecipient -> isUserCommunityManager()
// false in other cases
else -> false
}
// creating a reusable callback
val deleteDeviceOnly = {
// delete the message locally
viewModel.markAsDeletedLocally(
messages = messages,
displayedMessage = resources.getString(R.string.deleteMessageDeletedLocally)
)
endActionMode()
// show confirmation toast
Toast.makeText(
this,
resources.getQuantityString(R.plurals.deleteMessageDeleted, messages.count(), messages.count()),
Toast.LENGTH_SHORT
).show()
}
// There are three types of dialogs for deletion:
// 1- Delete on device only OR all devices - Used for Note to self
// 2- Delete on device only OR for everyone - Used for 'admins' or a user's own messages, as long as the message have a server hash
// 3- Delete on device only - Used otherwise
when{
// the conversation is a note to self
conversation.isLocalNumber -> {
DeleteNoteToSelfDialog(
messageCount = messages.size,
onDeleteDeviceOnly = deleteDeviceOnly,
onDeleteAllDevices = {
endActionMode()
},
onCancel = { endActionMode() }
).show(supportFragmentManager, "DeleteNoteToSelfDialog")
}
// If the user is an admin or is interacting with their own message And are allowed to delete for everyone
(isAdmin || allSentByCurrentUser) && canDeleteForEveryone -> {
DeleteMessageDialog(
messageCount = messages.size,
defaultToEveryone = isAdmin,
onDeleteDeviceOnly = deleteDeviceOnly,
onDeleteForEveryone = {
endActionMode()
},
onCancel = { endActionMode() }
).show(supportFragmentManager, "DeleteMessageDialog")
}
// for non admins, users interacting with someone else's message, or control messages viewModel.handleMessagesDeletion(messages)
else -> {
//todo DELETION this should also happen for ControlMessages
DeleteMessageDeviceOnlyDialog(
messageCount = messages.size,
onDeleteDeviceOnly = deleteDeviceOnly,
onCancel = { endActionMode() }
).show(supportFragmentManager, "DeleteMessageDeviceOnlyDialog")
}
}
/* /*
// If the recipient is a community OR a Note-to-Self then we delete the message for everyone // If the recipient is a community OR a Note-to-Self then we delete the message for everyone

@ -0,0 +1,217 @@
package org.thoughtcrime.securesms.conversation.v2
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.squareup.phrase.Phrase
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType
import org.thoughtcrime.securesms.ui.OpenURLAlertDialog
import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.*
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.ui.AlertDialog
import org.thoughtcrime.securesms.ui.DialogButtonModel
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.RadioOption
import org.thoughtcrime.securesms.ui.components.TitledRadioButton
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
import kotlin.time.Duration.Companion.days
@Composable
fun ConversationV2Dialogs(
dialogsState: ConversationViewModel.DialogsState,
sendCommand: (ConversationViewModel.Commands) -> Unit
){
SessionMaterialTheme {
// open link confirmation
if(!dialogsState.openLinkDialogUrl.isNullOrEmpty()){
OpenURLAlertDialog(
url = dialogsState.openLinkDialogUrl,
onDismissRequest = {
// hide dialog
sendCommand(ShowOpenUrlDialog(null))
}
)
}
// delete message(s) on device only
if(dialogsState.deleteDeviceOnly != null){
AlertDialog(
onDismissRequest = {
// hide dialog
sendCommand(HideDeleteDeviceOnlyDialog)
},
title = pluralStringResource(
R.plurals.deleteMessage,
dialogsState.deleteDeviceOnly.size,
dialogsState.deleteDeviceOnly.size
),
text = stringResource(R.string.deleteMessageDescriptionDevice), //todo DELETION we need the plural version of this here, which currently is not set up in strings
buttons = listOf(
DialogButtonModel(
text = GetString(stringResource(id = R.string.delete)),
color = LocalColors.current.danger,
onClick = {
sendCommand(MarkAsDeletedLocally(dialogsState.deleteDeviceOnly))
}
),
DialogButtonModel(
GetString(stringResource(R.string.cancel))
)
)
)
}
// delete message(s) for everyone
if(dialogsState.deleteEveryone != null){
var deleteForEveryone by remember { mutableStateOf(dialogsState.deleteEveryone.defaultToEveryone)}
AlertDialog(
onDismissRequest = {
// hide dialog
sendCommand(HideDeleteEveryoneDialog)
},
title = pluralStringResource(
R.plurals.deleteMessage,
dialogsState.deleteEveryone.messages.size,
dialogsState.deleteEveryone.messages.size
),
text = stringResource(R.string.deleteMessageConfirm), //todo DELETION we need the plural version of this here, which currently is not set up in strings
content = {
TitledRadioButton(
contentPadding = PaddingValues(
horizontal = LocalDimensions.current.xxsSpacing,
vertical = 0.dp
),
option = RadioOption(
value = Unit,
title = GetString(stringResource(R.string.deleteMessageDeviceOnly)),
selected = !deleteForEveryone
)
) {
deleteForEveryone = false
}
TitledRadioButton(
contentPadding = PaddingValues(
horizontal = LocalDimensions.current.xxsSpacing,
vertical = 0.dp
),
option = RadioOption(
value = Unit,
title = GetString(stringResource(R.string.deleteMessageEveryone)),
selected = deleteForEveryone
)
) {
deleteForEveryone = true
}
},
buttons = listOf(
DialogButtonModel(
text = GetString(stringResource(id = R.string.delete)),
color = LocalColors.current.danger,
onClick = {
// delete messages based on chosen option
sendCommand(
if(deleteForEveryone) MarkAsDeletedForEveryone(dialogsState.deleteEveryone.messages)
else MarkAsDeletedLocally(dialogsState.deleteEveryone.messages)
)
}
),
DialogButtonModel(
GetString(stringResource(R.string.cancel))
)
)
)
}
// delete message(s) for all my devices
if(dialogsState.deleteAllDevices != null){
var deleteAllDevices by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = {
// hide dialog
sendCommand(HideDeleteAllDevicesDialog)
},
title = pluralStringResource(
R.plurals.deleteMessage,
dialogsState.deleteAllDevices.size,
dialogsState.deleteAllDevices.size
),
text = stringResource(R.string.deleteMessageConfirm), //todo DELETION we need the plural version of this here, which currently is not set up in strings
content = {
TitledRadioButton(
contentPadding = PaddingValues(
horizontal = LocalDimensions.current.xxsSpacing,
vertical = 0.dp
),
option = RadioOption(
value = Unit,
title = GetString(stringResource(R.string.deleteMessageDeviceOnly)),
selected = !deleteAllDevices
)
) {
deleteAllDevices = false
}
TitledRadioButton(
contentPadding = PaddingValues(
horizontal = LocalDimensions.current.xxsSpacing,
vertical = 0.dp
),
option = RadioOption(
value = Unit,
title = GetString(stringResource(R.string.deleteMessageDevicesAll)),
selected = deleteAllDevices
)
) {
deleteAllDevices = true
}
},
buttons = listOf(
DialogButtonModel(
text = GetString(stringResource(id = R.string.delete)),
color = LocalColors.current.danger,
onClick = {
// delete messages based on chosen option
sendCommand(
if(deleteAllDevices) MarkAsDeletedForEveryone(dialogsState.deleteAllDevices)
else MarkAsDeletedLocally(dialogsState.deleteAllDevices)
)
}
),
DialogButtonModel(
GetString(stringResource(R.string.cancel))
)
)
)
}
}
}
@Preview
@Composable
fun PreviewURLDialog(){
PreviewTheme {
ConversationV2Dialogs(
dialogsState = ConversationViewModel.DialogsState(
openLinkDialogUrl = "https://google.com"
),
sendCommand = {}
)
}
}

@ -1,6 +1,8 @@
package org.thoughtcrime.securesms.conversation.v2 package org.thoughtcrime.securesms.conversation.v2
import android.app.Application
import android.content.Context import android.content.Context
import android.widget.Toast
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -10,13 +12,13 @@ import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.MessageDataProvider
import org.session.libsession.messaging.messages.ExpirationConfiguration import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup import org.session.libsession.messaging.open_groups.OpenGroup
@ -25,25 +27,30 @@ import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAt
import org.session.libsession.messaging.utilities.AccountId import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address
import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.audio.AudioSlidePlayer import org.thoughtcrime.securesms.audio.AudioSlidePlayer
import org.thoughtcrime.securesms.database.LokiMessageDatabase
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.AudioSlide
import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.mms.AudioSlide
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import java.util.UUID import java.util.UUID
class ConversationViewModel( class ConversationViewModel(
val threadId: Long, val threadId: Long,
val edKeyPair: KeyPair?, val edKeyPair: KeyPair?,
private val application: Application,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: Storage, private val storage: Storage,
private val messageDataProvider: MessageDataProvider private val messageDataProvider: MessageDataProvider,
private val lokiMessageDb: LokiMessageDatabase,
private val textSecurePreferences: TextSecurePreferences
) : ViewModel() { ) : ViewModel() {
val showSendAfterApprovalText: Boolean val showSendAfterApprovalText: Boolean
@ -52,6 +59,9 @@ class ConversationViewModel(
private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true)) private val _uiState = MutableStateFlow(ConversationUiState(conversationExists = true))
val uiState: StateFlow<ConversationUiState> = _uiState val uiState: StateFlow<ConversationUiState> = _uiState
private val _dialogsState = MutableStateFlow(DialogsState())
val dialogsState: StateFlow<DialogsState> = _dialogsState
private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce { private var _recipient: RetrieveOnce<Recipient> = RetrieveOnce {
repository.maybeGetRecipientForThreadId(threadId) repository.maybeGetRecipientForThreadId(threadId)
} }
@ -180,6 +190,104 @@ class ConversationViewModel(
repository.deleteThread(threadId) repository.deleteThread(threadId)
} }
fun handleMessagesDeletion(messages: Set<MessageRecord>){
val conversation = recipient
if (conversation == null) {
Log.w("ConversationActivityV2", "Asked to delete messages but could not obtain viewModel recipient - aborting.")
return
}
// Refer to our figma document for info on message deletion [https://www.figma.com/design/kau6LggVcMMWmZRMibEo8F/Standardise-Message-Deletion?node-id=0-1&t=dEPcU0SZ9G2s4gh2-0]
//todo DELETION delete for everyone
//todo DELETION delete all my devices
//todo DELETION handle control messages deletion ( and make clickable )
//todo DELETION handle multi select scenarios
//todo DELETION check that the unread status works as expected when deleting a message
//todo DELETION handle errors: Toasts for errors, or deleting messages not fully sent yet
viewModelScope.launch(Dispatchers.IO) {
val allSentByCurrentUser = messages.all { it.isOutgoing }
// hashes are required if wanting to delete messages from the 'storage server' - they are not required for communities
val canDeleteForEveryone = conversation.isCommunityRecipient || messages.all {
lokiMessageDb.getMessageServerHash(
it.id,
it.isMms
) != null
}
// Determining is the current user is an admin will depend on the kind of conversation we are in
val isAdmin = when {
//todo GROUPS V2 add logic where code is commented to determine if user is an admin - CAREFUL in the current old code:
// isClosedGroup refers to the existing legacy groups.
// With the groupsV2 changes, isClosedGroup refers to groupsV2 and isLegacyClosedGroup is a new property to refer to old groups
// for Groups V2
// conversation: check if it is a GroupsV2 conversation - then check if user is an admin
// for legacy groups, check if the user created the group
conversation.isClosedGroupRecipient -> { //todo GROUPS V2 this property will change for groups v2. Check for legacyGroup here
// for legacy groups, we check if the current user is the one who created the group
run {
val localUserAddress =
textSecurePreferences.getLocalNumber() ?: return@run false
val group = storage.getGroup(conversation.address.toGroupString())
group?.admins?.contains(fromSerialized(localUserAddress)) ?: false
}
}
// for communities the the `isUserModerator` field
conversation.isCommunityRecipient -> isUserCommunityManager()
// false in other cases
else -> false
}
// There are three types of dialogs for deletion:
// 1- Delete on device only OR all devices - Used for Note to self
// 2- Delete on device only OR for everyone - Used for 'admins' or a user's own messages, as long as the message have a server hash
// 3- Delete on device only - Used otherwise
when {
// the conversation is a note to self
conversation.isLocalNumber -> {
_dialogsState.update {
it.copy(deleteAllDevices = messages)
}
}
// If the user is an admin or is interacting with their own message And are allowed to delete for everyone
(isAdmin || allSentByCurrentUser) && canDeleteForEveryone -> {
_dialogsState.update {
it.copy(
deleteEveryone = DeleteForEveryoneDialogData(
messages = messages,
defaultToEveryone = isAdmin
)
)
}
}
// for non admins, users interacting with someone else's message, or control messages
else -> {
//todo DELETION this should also happen for ControlMessages
_dialogsState.update {
it.copy(deleteDeviceOnly = messages)
}
}
}
}
}
private fun isUserCommunityManager() = openGroup?.let { openGroup ->
val userPublicKey = textSecurePreferences.getLocalNumber() ?: return@let false
OpenGroupManager.isUserModerator(application, openGroup.id, userPublicKey, blindedPublicKey)
} ?: false
/** /**
* This will delete these messages from the db * This will delete these messages from the db
* Not to be confused with 'marking messages as deleted' * Not to be confused with 'marking messages as deleted'
@ -194,17 +302,25 @@ class ConversationViewModel(
* but the messages themselves won't be removed from the db. * but the messages themselves won't be removed from the db.
* Instead they will appear as a special type of message * Instead they will appear as a special type of message
* that says something like "This message was deleted" * that says something like "This message was deleted"
*
* @displayedMessage is the message that will be displayed in place of the deleted message.
*/ */
fun markAsDeletedLocally(messages: Set<MessageRecord>, displayedMessage: String) { private fun markAsDeletedLocally(messages: Set<MessageRecord>) {
// make sure to stop audio messages, if any // make sure to stop audio messages, if any
messages.filterIsInstance<MmsMessageRecord>() messages.filterIsInstance<MmsMessageRecord>()
.mapNotNull { it.slideDeck.audioSlide } .mapNotNull { it.slideDeck.audioSlide }
.forEach(::stopMessageAudio) .forEach(::stopMessageAudio)
repository.markAsDeletedLocally(messages, displayedMessage) repository.markAsDeletedLocally(
messages = messages,
displayedMessage = application.getString(R.string.deleteMessageDeletedLocally)
)
// show confirmation toast
Toast.makeText(
application,
application.resources.getQuantityString(R.plurals.deleteMessageDeleted, messages.count(), messages.count()),
Toast.LENGTH_SHORT
).show()
} }
/** /**
@ -231,7 +347,7 @@ class ConversationViewModel(
* Instead they will appear as a special type of message * Instead they will appear as a special type of message
* that says something like "This message was deleted" * that says something like "This message was deleted"
*/ */
fun markAsDeletedForEveryone(message: MessageRecord) = viewModelScope.launch { private fun markAsDeletedForEveryone(message: MessageRecord) = viewModelScope.launch {
val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.") val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.")
stopMessageAudio(message) stopMessageAudio(message)
@ -329,6 +445,47 @@ class ConversationViewModel(
attachmentDownloadHandler.onAttachmentDownloadRequest(attachment) attachmentDownloadHandler.onAttachmentDownloadRequest(attachment)
} }
fun onCommand(command: Commands) {
when (command) {
is Commands.ShowOpenUrlDialog -> {
_dialogsState.update {
it.copy(openLinkDialogUrl = command.url)
}
}
is Commands.HideDeleteDeviceOnlyDialog -> {
_dialogsState.update {
it.copy(deleteDeviceOnly = null)
}
}
is Commands.HideDeleteEveryoneDialog -> {
_dialogsState.update {
it.copy(deleteEveryone = null)
}
}
is Commands.HideDeleteAllDevicesDialog -> {
_dialogsState.update {
it.copy(deleteAllDevices = null)
}
}
is Commands.MarkAsDeletedLocally -> {
// hide dialog first
_dialogsState.update {
it.copy(deleteDeviceOnly = null)
}
markAsDeletedLocally(command.messages)
}
is Commands.MarkAsDeletedForEveryone -> {
//todo DELETION mark as deleted for everyone here
//markAsDeletedForEveryone(command.messages)
}
}
}
@dagger.assisted.AssistedFactory @dagger.assisted.AssistedFactory
interface AssistedFactory { interface AssistedFactory {
fun create(threadId: Long, edKeyPair: KeyPair?): Factory fun create(threadId: Long, edKeyPair: KeyPair?): Factory
@ -338,21 +495,49 @@ class ConversationViewModel(
class Factory @AssistedInject constructor( class Factory @AssistedInject constructor(
@Assisted private val threadId: Long, @Assisted private val threadId: Long,
@Assisted private val edKeyPair: KeyPair?, @Assisted private val edKeyPair: KeyPair?,
private val application: Application,
private val repository: ConversationRepository, private val repository: ConversationRepository,
private val storage: Storage, private val storage: Storage,
private val messageDataProvider: MessageDataProvider, private val messageDataProvider: MessageDataProvider,
private val lokiMessageDb: LokiMessageDatabase,
private val textSecurePreferences: TextSecurePreferences
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ConversationViewModel( return ConversationViewModel(
threadId = threadId, threadId = threadId,
edKeyPair = edKeyPair, edKeyPair = edKeyPair,
application = application,
repository = repository, repository = repository,
storage = storage, storage = storage,
messageDataProvider = messageDataProvider messageDataProvider = messageDataProvider,
lokiMessageDb = lokiMessageDb,
textSecurePreferences = textSecurePreferences
) as T ) as T
} }
} }
data class DialogsState(
val openLinkDialogUrl: String? = null,
val deleteDeviceOnly: Set<MessageRecord>? = null,
val deleteEveryone: DeleteForEveryoneDialogData? = null,
val deleteAllDevices: Set<MessageRecord>? = null,
)
data class DeleteForEveryoneDialogData(
val messages: Set<MessageRecord>,
val defaultToEveryone: Boolean
)
sealed class Commands {
data class ShowOpenUrlDialog(val url: String?) : Commands()
data object HideDeleteDeviceOnlyDialog : Commands()
data object HideDeleteEveryoneDialog : Commands()
data object HideDeleteAllDevicesDialog : Commands()
data class MarkAsDeletedLocally(val messages: Set<MessageRecord>): Commands()
data class MarkAsDeletedForEveryone(val messages: Set<MessageRecord>): Commands()
}
} }
data class UiMessage(val id: Long, val message: String) data class UiMessage(val id: Long, val message: String)

@ -1,39 +0,0 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.app.Dialog
import android.os.Bundle
import android.util.TypedValue
import androidx.annotation.ColorInt
import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import org.thoughtcrime.securesms.createSessionDialog
/**
* Shown when deleting a message can only be deleted locally
*
* @param messageCount The number of messages to be deleted.
* @param onDeleteDeviceOnly Callback to be executed when the user chooses to delete only on their device.
* @param onCancel Callback to be executed when cancelling the dialog.
*/
class DeleteMessageDeviceOnlyDialog(
private val messageCount: Int,
private val onDeleteDeviceOnly: () -> Unit,
private val onCancel: () -> Unit
) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val typedValue = TypedValue()
val theme = context.theme
theme.resolveAttribute(R.attr.danger, typedValue, true)
@ColorInt val deleteColor = typedValue.data
title(resources.getQuantityString(R.plurals.deleteMessage, messageCount, messageCount))
text(resources.getString(R.string.deleteMessageDescriptionDevice)) //todo DELETION we need the plural version of this here, which currently is not set up in strings
button(
text = R.string.delete,
textColor = deleteColor,
listener = onDeleteDeviceOnly
)
cancelButton(onCancel)
}
}

@ -1,80 +0,0 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import androidx.annotation.ColorInt
import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import org.thoughtcrime.securesms.createSessionDialog
/**
* Shown when deleting a message that can be removed both locally and for everyone
*
* @param messageCount The number of messages to be deleted.
* @param defaultToEveryone Whether the dialog should default to deleting for everyone.
* @param onDeleteDeviceOnly Callback to be executed when the user chooses to delete only on their device.
* @param onDeleteForEveryone Callback to be executed when the user chooses to delete for everyone.
* @param onCancel Callback to be executed when cancelling the dialog.
*/
class DeleteMessageDialog(
private val messageCount: Int,
private val defaultToEveryone: Boolean,
private val onDeleteDeviceOnly: () -> Unit,
private val onDeleteForEveryone: () -> Unit,
private val onCancel: () -> Unit
) : DialogFragment() {
// tracking the user choice from the radio buttons
private var deleteForEveryone = defaultToEveryone
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val typedValue = TypedValue()
val theme = context.theme
theme.resolveAttribute(R.attr.danger, typedValue, true)
@ColorInt val deleteColor = typedValue.data
title(resources.getQuantityString(R.plurals.deleteMessage, messageCount, messageCount))
text(resources.getString(R.string.deleteMessageConfirm)) //todo DELETION we need the plural version of this here, which currently is not set up in strings
singleChoiceItems(
options = deleteOptions.map { it.label },
currentSelected = if (defaultToEveryone) 1 else 0, // some cases require the second option, "delete for everyone", to be the default selected
dismissOnRadioSelect = false
) { index ->
deleteForEveryone = (deleteOptions[index] is DeleteOption.DeleteForEveryone) // we delete for everyone if the selected index is 1
}
button(
text = R.string.delete,
textColor = deleteColor,
listener = {
if (deleteForEveryone) {
onDeleteForEveryone()
} else {
onDeleteDeviceOnly()
}
}
)
cancelButton(onCancel)
}
private val deleteOptions: List<DeleteOption> by lazy {
listOf(
DeleteOption.DeleteDeviceOnly(requireContext()), DeleteOption.DeleteForEveryone(requireContext())
)
}
private sealed class DeleteOption(
open val label: String
){
data class DeleteDeviceOnly(
val context: Context,
override val label: String = context.getString(R.string.deleteMessageDeviceOnly),
): DeleteOption(label)
data class DeleteForEveryone(
val context: Context,
override val label: String = context.getString(R.string.deleteMessageEveryone),
): DeleteOption(label)
}
}

@ -1,78 +0,0 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import androidx.annotation.ColorInt
import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import org.thoughtcrime.securesms.createSessionDialog
/**
* Shown when deleting a 'note to self'
*
* @param messageCount The number of messages to be deleted.
* @param onDeleteDeviceOnly Callback to be executed when the user chooses to delete only on their device.
* @param onDeleteAllDevices Callback to be executed when the user chooses to delete for everyone.
* @param onCancel Callback to be executed when cancelling the dialog.
*/
class DeleteNoteToSelfDialog(
private val messageCount: Int,
private val onDeleteDeviceOnly: () -> Unit,
private val onDeleteAllDevices: () -> Unit,
private val onCancel: () -> Unit
) : DialogFragment() {
// tracking the user choice from the radio buttons
private var deleteOnAllDevices = false
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val typedValue = TypedValue()
val theme = context.theme
theme.resolveAttribute(R.attr.danger, typedValue, true)
@ColorInt val deleteColor = typedValue.data
title(resources.getQuantityString(R.plurals.deleteMessage, messageCount, messageCount))
text(resources.getString(R.string.deleteMessageConfirm)) //todo DELETION we need the plural version of this here, which currently is not set up in strings
singleChoiceItems(
options = deleteOptions.map { it.label },
currentSelected = 0,
dismissOnRadioSelect = false
) { index ->
deleteOnAllDevices = (deleteOptions[index] is DeleteOption.DeleteOnAllMyDevices) // we delete for everyone if the selected index is 1
}
button(
text = R.string.delete,
textColor = deleteColor,
listener = {
if (deleteOnAllDevices) {
onDeleteAllDevices()
} else {
onDeleteDeviceOnly()
}
}
)
cancelButton(onCancel)
}
private val deleteOptions: List<DeleteOption> by lazy {
listOf(
DeleteOption.DeleteDeviceOnly(requireContext()), DeleteOption.DeleteOnAllMyDevices(requireContext())
)
}
private sealed class DeleteOption(
open val label: String
){
data class DeleteDeviceOnly(
val context: Context,
override val label: String = context.getString(R.string.deleteMessageDeviceOnly),
): DeleteOption(label)
data class DeleteOnAllMyDevices(
val context: Context,
override val label: String = context.getString(R.string.deleteMessageDevicesAll),
): DeleteOption(label)
}
}

@ -29,6 +29,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType
@ -116,16 +117,20 @@ private fun RadioButtonIndicator(
@Composable @Composable
fun <T> TitledRadioButton( fun <T> TitledRadioButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(
horizontal = LocalDimensions.current.spacing,
vertical = LocalDimensions.current.smallSpacing
),
option: RadioOption<T>, option: RadioOption<T>,
onClick: () -> Unit onClick: () -> Unit
) { ) {
RadioButton( RadioButton(
modifier = modifier.heightIn(min = 60.dp) modifier = modifier
.contentDescription(option.contentDescription), .contentDescription(option.contentDescription),
onClick = onClick, onClick = onClick,
selected = option.selected, selected = option.selected,
enabled = option.enabled, enabled = option.enabled,
contentPadding = PaddingValues(horizontal = LocalDimensions.current.spacing), contentPadding = contentPadding,
content = { content = {
Column( Column(
modifier = Modifier modifier = Modifier

Loading…
Cancel
Save