[SES-3251] - Add recreate group UI and show/hide thread/message options accordingly (#919)

* Add recreate button

* CreateGroup as part of dialog

* Show/hide items

* Address feedback

* Icon spacing

* Address feedback

* Avoiding leaking the popup window

---------

Co-authored-by: ThomasSession <thomas.r@getsession.org>
pull/1709/head
SessionHero01 3 months ago committed by GitHub
parent fc914f2667
commit 7187b79663
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -509,7 +509,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
setUpSearchResultObserver() setUpSearchResultObserver()
scrollToFirstUnreadMessageIfNeeded() scrollToFirstUnreadMessageIfNeeded()
setUpOutdatedClientBanner() setUpOutdatedClientBanner()
setUpLegacyGroupBanner() setUpLegacyGroupUI()
if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { if (author != null && messageTimestamp >= 0 && targetPosition >= 0) {
binding.conversationRecyclerView.scrollToPosition(targetPosition) binding.conversationRecyclerView.scrollToPosition(targetPosition)
@ -543,6 +543,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
setupMentionView() setupMentionView()
setupUiEventsObserver()
}
private fun setupUiEventsObserver() {
lifecycleScope.launch {
viewModel.uiEvents.collect { event ->
when (event) {
is ConversationUiEvent.NavigateToConversation -> {
finish()
startActivity(Intent(this@ConversationActivityV2, ConversationActivityV2::class.java)
.putExtra(THREAD_ID, event.threadId)
)
}
}
}
}
} }
private fun setupMentionView() { private fun setupMentionView() {
@ -829,7 +845,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
private fun setUpLegacyGroupBanner() { private fun setUpLegacyGroupUI() {
lifecycleScope.launch { lifecycleScope.launch {
viewModel.legacyGroupBanner viewModel.legacyGroupBanner
.collectLatest { banner -> .collectLatest { banner ->
@ -843,12 +859,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
// we need to add the inline icon // we need to add the inline icon
val drawable = ContextCompat.getDrawable(this@ConversationActivityV2, R.drawable.ic_external)!! val drawable = ContextCompat.getDrawable(this@ConversationActivityV2, R.drawable.ic_external)!!
val imageSize = toPx(10, resources) val imageSize = toPx(10, resources)
val imagePaddingTop = toPx(4, resources) val imagePadding = toPx(4, resources)
drawable.setBounds(0, 0, imageSize, imageSize) drawable.setBounds(0, 0, imageSize, imageSize)
drawable.setTint(getColorFromAttr(R.attr.message_sent_text_color)) drawable.setTint(getColorFromAttr(R.attr.message_sent_text_color))
setSpan( setSpan(
PaddedImageSpan(drawable, ImageSpan.ALIGN_BASELINE, imagePaddingTop), PaddedImageSpan(drawable, ImageSpan.ALIGN_BASELINE,
paddingStart = imagePadding,
paddingTop = imagePadding
),
length - 1, length - 1,
length, length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
@ -861,6 +880,17 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
} }
} }
lifecycleScope.launch {
viewModel.showRecreateGroupButton
.collectLatest { show ->
binding.recreateGroupButtonContainer.isVisible = show
}
}
binding.recreateGroupButton.setOnClickListener {
viewModel.onCommand(ConversationViewModel.Commands.RecreateGroup)
}
} }
private fun setUpLinkPreviewObserver() { private fun setUpLinkPreviewObserver() {
@ -963,6 +993,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
thread = recipient, thread = recipient,
context = this, context = this,
configFactory = configFactory, configFactory = configFactory,
deprecationManager = viewModel.legacyGroupDeprecationManager
) )
} }
maybeUpdateToolbar(recipient) maybeUpdateToolbar(recipient)

@ -33,19 +33,23 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.LocalisedTimeUtil.toShortTwoPartString import org.session.libsession.LocalisedTimeUtil.toShortTwoPartString
import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager
import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY import org.session.libsession.utilities.StringSubstitutionConstants.TIME_LARGE_KEY
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber
import org.session.libsession.utilities.ThemeUtil import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.components.emoji.EmojiImageView import org.thoughtcrime.securesms.components.emoji.EmojiImageView
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
import org.thoughtcrime.securesms.components.menu.ActionItem import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanBanSelectedUsers import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuItemHelper.userCanBanSelectedUsers
import org.thoughtcrime.securesms.database.LokiThreadDatabase
import org.thoughtcrime.securesms.database.MmsSmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent.Companion.get
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
import org.thoughtcrime.securesms.util.AnimationCompleteListener import org.thoughtcrime.securesms.util.AnimationCompleteListener
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
@ -95,6 +99,11 @@ class ConversationReactionOverlay : FrameLayout {
@Inject lateinit var mmsSmsDatabase: MmsSmsDatabase @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase
@Inject lateinit var repository: ConversationRepository @Inject lateinit var repository: ConversationRepository
@Inject lateinit var lokiThreadDatabase: LokiThreadDatabase
@Inject lateinit var threadDatabase: ThreadDatabase
@Inject lateinit var textSecurePreferences: TextSecurePreferences
@Inject lateinit var deprecationManager: LegacyGroupDeprecationManager
private val scope = CoroutineScope(Dispatchers.Default) private val scope = CoroutineScope(Dispatchers.Default)
private var job: Job? = null private var job: Job? = null
@ -163,7 +172,8 @@ class ConversationReactionOverlay : FrameLayout {
private fun showAfterLayout(messageRecord: MessageRecord, private fun showAfterLayout(messageRecord: MessageRecord,
lastSeenDownPoint: PointF, lastSeenDownPoint: PointF,
isMessageOnLeft: Boolean) { isMessageOnLeft: Boolean) {
val contextMenu = ConversationContextMenu(dropdownAnchor, getMenuActionItems(messageRecord)) val recipient = threadDatabase.getRecipientForThreadId(messageRecord.threadId)
val contextMenu = ConversationContextMenu(dropdownAnchor, recipient?.let { getMenuActionItems(messageRecord, it) }.orEmpty())
this.contextMenu = contextMenu this.contextMenu = contextMenu
var endX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX - conversationItem.width + selectedConversationModel.bubbleWidth var endX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX - conversationItem.width + selectedConversationModel.bubbleWidth
var endY = selectedConversationModel.bubbleY - statusBarHeight var endY = selectedConversationModel.bubbleY - statusBarHeight
@ -260,6 +270,12 @@ class ConversationReactionOverlay : FrameLayout {
} else { } else {
(width - scrubberWidth - scrubberHorizontalMargin).toFloat() (width - scrubberWidth - scrubberHorizontalMargin).toFloat()
} }
val isDeprecatedLegacyGroup =
recipient?.isLegacyGroupRecipient == true &&
deprecationManager.deprecationState.value == LegacyGroupDeprecationManager.DeprecationState.DEPRECATED
foregroundView.isVisible = !isDeprecatedLegacyGroup
backgroundView.isVisible = !isDeprecatedLegacyGroup
foregroundView.x = scrubberX foregroundView.x = scrubberX
foregroundView.y = reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.height / 2f foregroundView.y = reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.height / 2f
backgroundView.x = scrubberX backgroundView.x = scrubberX
@ -359,6 +375,12 @@ class ConversationReactionOverlay : FrameLayout {
updateBoundsOnLayoutChanged() updateBoundsOnLayoutChanged()
} }
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
hide()
}
private fun updateBoundsOnLayoutChanged() { private fun updateBoundsOnLayoutChanged() {
backgroundView.getGlobalVisibleRect(emojiStripViewBounds) backgroundView.getGlobalVisibleRect(emojiStripViewBounds)
emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect) emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect)
@ -521,16 +543,14 @@ class ConversationReactionOverlay : FrameLayout {
.firstOrNull() .firstOrNull()
?.let(ReactionRecord::emoji) ?.let(ReactionRecord::emoji)
private fun getMenuActionItems(message: MessageRecord): List<ActionItem> { private fun getMenuActionItems(message: MessageRecord, recipient: Recipient): List<ActionItem> {
val items: MutableList<ActionItem> = ArrayList() val items: MutableList<ActionItem> = ArrayList()
// Prepare // Prepare
val containsControlMessage = message.isUpdate val containsControlMessage = message.isUpdate
val hasText = !message.body.isEmpty() val hasText = !message.body.isEmpty()
val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(message.threadId) val openGroup = lokiThreadDatabase.getOpenGroupChat(message.threadId)
val recipient = get(context).threadDatabase().getRecipientForThreadId(message.threadId) val userPublicKey = textSecurePreferences.getLocalNumber()!!
?: return emptyList()
val userPublicKey = getLocalNumber(context)!!
// control messages and "marked as deleted" messages can only delete // control messages and "marked as deleted" messages can only delete
val isDeleteOnly = message.isDeleted || message.isControlMessage val isDeleteOnly = message.isDeleted || message.isControlMessage
@ -544,9 +564,15 @@ class ConversationReactionOverlay : FrameLayout {
R.string.AccessibilityId_select R.string.AccessibilityId_select
) )
} }
val isDeprecatedLegacyGroup = recipient.isLegacyGroupRecipient &&
deprecationManager.deprecationState.value == LegacyGroupDeprecationManager.DeprecationState.DEPRECATED
// Reply // Reply
val canWrite = openGroup == null || openGroup.canWrite val canWrite = openGroup == null || openGroup.canWrite
if (canWrite && !message.isPending && !message.isFailed && !message.isOpenGroupInvitation && !isDeleteOnly) { if (canWrite && !message.isPending && !message.isFailed && !message.isOpenGroupInvitation && !isDeleteOnly
&& !isDeprecatedLegacyGroup) {
items += ActionItem(R.attr.menu_reply_icon, R.string.reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply) items += ActionItem(R.attr.menu_reply_icon, R.string.reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply)
} }
// Copy message text // Copy message text
@ -558,14 +584,23 @@ class ConversationReactionOverlay : FrameLayout {
items += ActionItem(R.attr.menu_copy_icon, R.string.accountIDCopy, { handleActionItemClicked(Action.COPY_ACCOUNT_ID) }) items += ActionItem(R.attr.menu_copy_icon, R.string.accountIDCopy, { handleActionItemClicked(Action.COPY_ACCOUNT_ID) })
} }
// Delete message // Delete message
items += ActionItem(R.attr.menu_trash_icon, R.string.delete, { handleActionItemClicked(Action.DELETE) }, if (!isDeprecatedLegacyGroup) {
R.string.AccessibilityId_deleteMessage, message.subtitle, ThemeUtil.getThemedColor(context, R.attr.danger)) items += ActionItem(
R.attr.menu_trash_icon,
R.string.delete,
{ handleActionItemClicked(Action.DELETE) },
R.string.AccessibilityId_deleteMessage,
message.subtitle,
ThemeUtil.getThemedColor(context, R.attr.danger)
)
}
// Ban user // Ban user
if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey) && !isDeleteOnly) { if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey) && !isDeleteOnly && !isDeprecatedLegacyGroup) {
items += ActionItem(R.attr.menu_block_icon, R.string.banUser, { handleActionItemClicked(Action.BAN_USER) }) items += ActionItem(R.attr.menu_block_icon, R.string.banUser, { handleActionItemClicked(Action.BAN_USER) })
} }
// Ban and delete all // Ban and delete all
if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey) && !isDeleteOnly) { if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey) && !isDeleteOnly && !isDeprecatedLegacyGroup) {
items += ActionItem(R.attr.menu_trash_icon, R.string.banDeleteAll, { handleActionItemClicked(Action.BAN_AND_DELETE_ALL) }) items += ActionItem(R.attr.menu_trash_icon, R.string.banDeleteAll, { handleActionItemClicked(Action.BAN_AND_DELETE_ALL) })
} }
// Message detail // Message detail
@ -576,11 +611,11 @@ class ConversationReactionOverlay : FrameLayout {
{ handleActionItemClicked(Action.VIEW_INFO) }) { handleActionItemClicked(Action.VIEW_INFO) })
} }
// Resend // Resend
if (message.isFailed) { if (message.isFailed && !isDeprecatedLegacyGroup) {
items += ActionItem(R.attr.menu_reply_icon, R.string.resend, { handleActionItemClicked(Action.RESEND) }) items += ActionItem(R.attr.menu_reply_icon, R.string.resend, { handleActionItemClicked(Action.RESEND) })
} }
// Resync // Resync
if (message.isSyncFailed) { if (message.isSyncFailed && !isDeprecatedLegacyGroup) {
items += ActionItem(R.attr.menu_reply_icon, R.string.resync, { handleActionItemClicked(Action.RESYNC) }) items += ActionItem(R.attr.menu_reply_icon, R.string.resync, { handleActionItemClicked(Action.RESYNC) })
} }
// Save media.. // Save media..

@ -2,7 +2,10 @@ package org.thoughtcrime.securesms.conversation.v2
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -16,11 +19,16 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.squareup.phrase.Phrase import com.squareup.phrase.Phrase
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY
import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY
import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.* import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.ClearEmoji
import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.DeleteForEveryoneDialogData import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.ConfirmRecreateGroup
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.HideClearEmoji
import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.HideDeleteEveryoneDialog
import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.HideRecreateGroupConfirm
import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.MarkAsDeletedForEveryone
import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.MarkAsDeletedLocally
import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.ShowOpenUrlDialog
import org.thoughtcrime.securesms.groups.compose.CreateGroupScreen
import org.thoughtcrime.securesms.ui.AlertDialog import org.thoughtcrime.securesms.ui.AlertDialog
import org.thoughtcrime.securesms.ui.DialogButtonModel import org.thoughtcrime.securesms.ui.DialogButtonModel
import org.thoughtcrime.securesms.ui.GetString import org.thoughtcrime.securesms.ui.GetString
@ -33,6 +41,7 @@ import org.thoughtcrime.securesms.ui.theme.LocalType
import org.thoughtcrime.securesms.ui.theme.PreviewTheme import org.thoughtcrime.securesms.ui.theme.PreviewTheme
import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ConversationV2Dialogs( fun ConversationV2Dialogs(
dialogsState: ConversationViewModel.DialogsState, dialogsState: ConversationViewModel.DialogsState,
@ -162,6 +171,54 @@ fun ConversationV2Dialogs(
) )
) )
} }
if (dialogsState.recreateGroupConfirm) {
AlertDialog(
onDismissRequest = {
// hide dialog
sendCommand(HideRecreateGroupConfirm)
},
title = stringResource(R.string.recreateGroup),
text = stringResource(R.string.legacyGroupChatHistory),
buttons = listOf(
DialogButtonModel(
text = GetString(stringResource(id = R.string.theContinue)),
color = LocalColors.current.danger,
onClick = {
sendCommand(ConfirmRecreateGroup)
}
),
DialogButtonModel(
GetString(stringResource(R.string.cancel))
)
)
)
}
if (dialogsState.recreateGroupData != null) {
val state = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = {
sendCommand(ConversationViewModel.Commands.HideRecreateGroup)
},
sheetState = state,
dragHandle = null
) {
CreateGroupScreen(
fromLegacyGroupId = dialogsState.recreateGroupData.legacyGroupId,
onNavigateToConversationScreen = { threadId ->
sendCommand(ConversationViewModel.Commands.NavigateToConversation(threadId))
},
onBack = {
sendCommand(ConversationViewModel.Commands.HideRecreateGroup)
},
onClose = {
sendCommand(ConversationViewModel.Commands.HideRecreateGroup)
},
)
}
}
} }
} }

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.Intent
import android.view.MenuItem import android.view.MenuItem
import android.widget.Toast import android.widget.Toast
import androidx.annotation.StringRes import androidx.annotation.StringRes
@ -14,10 +15,11 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -31,6 +33,7 @@ import network.loki.messenger.R
import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.MessageDataProvider
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.GroupManagerV2
import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager
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
import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi
@ -58,7 +61,6 @@ import org.thoughtcrime.securesms.database.model.MessageId
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.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager
import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.mms.AudioSlide import org.thoughtcrime.securesms.mms.AudioSlide
import org.thoughtcrime.securesms.repository.ConversationRepository import org.thoughtcrime.securesms.repository.ConversationRepository
@ -66,7 +68,6 @@ import org.thoughtcrime.securesms.util.DateUtils
import java.time.ZoneId import java.time.ZoneId
import java.util.UUID import java.util.UUID
@OptIn(ExperimentalCoroutinesApi::class)
class ConversationViewModel( class ConversationViewModel(
val threadId: Long, val threadId: Long,
val edKeyPair: KeyPair?, val edKeyPair: KeyPair?,
@ -81,7 +82,7 @@ class ConversationViewModel(
private val textSecurePreferences: TextSecurePreferences, private val textSecurePreferences: TextSecurePreferences,
private val configFactory: ConfigFactory, private val configFactory: ConfigFactory,
private val groupManagerV2: GroupManagerV2, private val groupManagerV2: GroupManagerV2,
private val legacyGroupDeprecationManager: LegacyGroupDeprecationManager, val legacyGroupDeprecationManager: LegacyGroupDeprecationManager,
) : ViewModel() { ) : ViewModel() {
val showSendAfterApprovalText: Boolean val showSendAfterApprovalText: Boolean
@ -90,6 +91,9 @@ class ConversationViewModel(
private val _uiState = MutableStateFlow(ConversationUiState()) private val _uiState = MutableStateFlow(ConversationUiState())
val uiState: StateFlow<ConversationUiState> get() = _uiState val uiState: StateFlow<ConversationUiState> get() = _uiState
private val _uiEvents = MutableSharedFlow<ConversationUiEvent>(extraBufferCapacity = 1)
val uiEvents: SharedFlow<ConversationUiEvent> get() = _uiEvents
private val _dialogsState = MutableStateFlow(DialogsState()) private val _dialogsState = MutableStateFlow(DialogsState())
val dialogsState: StateFlow<DialogsState> = _dialogsState val dialogsState: StateFlow<DialogsState> = _dialogsState
@ -218,6 +222,10 @@ class ConversationViewModel(
} }
}.stateIn(viewModelScope, SharingStarted.Lazily, null) }.stateIn(viewModelScope, SharingStarted.Lazily, null)
val showRecreateGroupButton: StateFlow<Boolean> = isAdmin
.map { admin ->
admin && recipient?.isLegacyGroupRecipient == true
}.stateIn(viewModelScope, SharingStarted.Lazily, false)
private val attachmentDownloadHandler = AttachmentDownloadHandler( private val attachmentDownloadHandler = AttachmentDownloadHandler(
storage = storage, storage = storage,
@ -949,6 +957,37 @@ class ConversationViewModel(
is Commands.ClearEmoji -> { is Commands.ClearEmoji -> {
clearEmoji(command.emoji, command.messageId) clearEmoji(command.emoji, command.messageId)
} }
Commands.RecreateGroup -> {
_dialogsState.update {
it.copy(recreateGroupConfirm = true)
}
}
Commands.HideRecreateGroupConfirm -> {
_dialogsState.update {
it.copy(recreateGroupConfirm = false)
}
}
Commands.ConfirmRecreateGroup -> {
_dialogsState.update {
it.copy(
recreateGroupConfirm = false,
recreateGroupData = recipient?.address?.serialize()?.let { addr -> RecreateGroupDialogData(legacyGroupId = addr) }
)
}
}
Commands.HideRecreateGroup -> {
_dialogsState.update {
it.copy(recreateGroupData = null)
}
}
is Commands.NavigateToConversation -> {
_uiEvents.tryEmit(ConversationUiEvent.NavigateToConversation(command.threadId))
}
} }
} }
@ -1056,7 +1095,13 @@ class ConversationViewModel(
data class DialogsState( data class DialogsState(
val openLinkDialogUrl: String? = null, val openLinkDialogUrl: String? = null,
val clearAllEmoji: ClearAllEmoji? = null, val clearAllEmoji: ClearAllEmoji? = null,
val deleteEveryone: DeleteForEveryoneDialogData? = null val deleteEveryone: DeleteForEveryoneDialogData? = null,
val recreateGroupConfirm: Boolean = false,
val recreateGroupData: RecreateGroupDialogData? = null,
)
data class RecreateGroupDialogData(
val legacyGroupId: String,
) )
data class DeleteForEveryoneDialogData( data class DeleteForEveryoneDialogData(
@ -1073,16 +1118,22 @@ class ConversationViewModel(
val messageId: MessageId val messageId: MessageId
) )
sealed class Commands { sealed interface Commands {
data class ShowOpenUrlDialog(val url: String?) : Commands() data class ShowOpenUrlDialog(val url: String?) : Commands
data class ClearEmoji(val emoji:String, val messageId: MessageId) : Commands() data class ClearEmoji(val emoji:String, val messageId: MessageId) : Commands
data object HideDeleteEveryoneDialog : Commands() data object HideDeleteEveryoneDialog : Commands
data object HideClearEmoji : Commands() data object HideClearEmoji : Commands
data class MarkAsDeletedLocally(val messages: Set<MessageRecord>): Commands() data class MarkAsDeletedLocally(val messages: Set<MessageRecord>): Commands
data class MarkAsDeletedForEveryone(val data: DeleteForEveryoneDialogData): Commands() data class MarkAsDeletedForEveryone(val data: DeleteForEveryoneDialogData): Commands
data object RecreateGroup : Commands
data object ConfirmRecreateGroup : Commands
data object HideRecreateGroupConfirm : Commands
data object HideRecreateGroup : Commands
data class NavigateToConversation(val threadId: Long) : Commands
} }
} }
@ -1097,6 +1148,10 @@ data class ConversationUiState(
val showLoader: Boolean = false, val showLoader: Boolean = false,
) )
sealed interface ConversationUiEvent {
data class NavigateToConversation(val threadId: Long) : ConversationUiEvent
}
sealed interface MessageRequestUiState { sealed interface MessageRequestUiState {
data object Invisible : MessageRequestUiState data object Invisible : MessageRequestUiState

@ -28,6 +28,7 @@ import java.io.IOException
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.GroupManagerV2
import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager
import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.MessageSender
import org.session.libsession.messaging.sending_receiving.leave import org.session.libsession.messaging.sending_receiving.leave
import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID
@ -70,14 +71,21 @@ object ConversationMenuHelper {
thread: Recipient, thread: Recipient,
context: Context, context: Context,
configFactory: ConfigFactory, configFactory: ConfigFactory,
deprecationManager: LegacyGroupDeprecationManager,
) { ) {
val isDeprecatedLegacyGroup = thread.isLegacyGroupRecipient &&
deprecationManager.deprecationState.value == LegacyGroupDeprecationManager.DeprecationState.DEPRECATED
// Prepare // Prepare
menu.clear() menu.clear()
val isCommunity = thread.isCommunityRecipient val isCommunity = thread.isCommunityRecipient
// Base menu (options that should always be present) // Base menu (options that should always be present)
inflater.inflate(R.menu.menu_conversation, menu) inflater.inflate(R.menu.menu_conversation, menu)
menu.findItem(R.id.menu_add_shortcut).isVisible = !isDeprecatedLegacyGroup
// Expiring messages // Expiring messages
if (!isCommunity && (thread.hasApprovedMe() || thread.isLegacyGroupRecipient || thread.isLocalNumber)) { if (!isCommunity && (thread.hasApprovedMe() || thread.isLegacyGroupRecipient || thread.isLocalNumber)
&& !isDeprecatedLegacyGroup) {
inflater.inflate(R.menu.menu_conversation_expiration, menu) inflater.inflate(R.menu.menu_conversation_expiration, menu)
} }
// One-on-one chat menu allows copying the account id // One-on-one chat menu allows copying the account id
@ -95,6 +103,8 @@ object ConversationMenuHelper {
// (Legacy) Closed group menu (options that should only be present in closed groups) // (Legacy) Closed group menu (options that should only be present in closed groups)
if (thread.isLegacyGroupRecipient) { if (thread.isLegacyGroupRecipient) {
inflater.inflate(R.menu.menu_conversation_legacy_group, menu) inflater.inflate(R.menu.menu_conversation_legacy_group, menu)
menu.findItem(R.id.menu_edit_group).isVisible = !isDeprecatedLegacyGroup
} }
// Groups v2 menu // Groups v2 menu
@ -118,13 +128,15 @@ object ConversationMenuHelper {
inflater.inflate(R.menu.menu_conversation_open_group, menu) inflater.inflate(R.menu.menu_conversation_open_group, menu)
} }
// Muting // Muting
if (thread.isMuted) { if (!isDeprecatedLegacyGroup) {
inflater.inflate(R.menu.menu_conversation_muted, menu) if (thread.isMuted) {
} else { inflater.inflate(R.menu.menu_conversation_muted, menu)
inflater.inflate(R.menu.menu_conversation_unmuted, menu) } else {
inflater.inflate(R.menu.menu_conversation_unmuted, menu)
}
} }
if (thread.isGroupOrCommunityRecipient && !thread.isMuted) { if (thread.isGroupOrCommunityRecipient && !thread.isMuted && !isDeprecatedLegacyGroup) {
inflater.inflate(R.menu.menu_conversation_notification_settings, menu) inflater.inflate(R.menu.menu_conversation_notification_settings, menu)
} }

@ -33,7 +33,8 @@ class CreateGroupFragment : Fragment() {
) )
}, },
onBack = delegate::onDialogBackPressed, onBack = delegate::onDialogBackPressed,
onClose = delegate::onDialogClosePressed onClose = delegate::onDialogClosePressed,
fromLegacyGroupId = null,
) )
} }
} }

@ -3,6 +3,9 @@ package org.thoughtcrime.securesms.groups
import android.content.Context import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -15,17 +18,21 @@ import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.database.StorageProtocol import org.session.libsession.database.StorageProtocol
import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.groups.GroupManagerV2
import org.session.libsignal.utilities.AccountId
import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.textSizeInBytes import org.thoughtcrime.securesms.conversation.v2.utilities.TextUtilities.textSizeInBytes
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel(assistedFactory = CreateGroupViewModel.Factory::class)
class CreateGroupViewModel @Inject constructor( class CreateGroupViewModel @AssistedInject constructor(
configFactory: ConfigFactory, configFactory: ConfigFactory,
@ApplicationContext private val appContext: Context, @ApplicationContext private val appContext: Context,
private val storage: StorageProtocol, private val storage: StorageProtocol,
private val groupManagerV2: GroupManagerV2, private val groupManagerV2: GroupManagerV2,
groupDatabase: GroupDatabase,
@Assisted createFromLegacyGroupId: String?,
): ViewModel() { ): ViewModel() {
// Child view model to handle contact selection logic // Child view model to handle contact selection logic
val selectContactsViewModel = SelectContactsViewModel( val selectContactsViewModel = SelectContactsViewModel(
@ -51,6 +58,30 @@ class CreateGroupViewModel @Inject constructor(
private val mutableEvents = MutableSharedFlow<CreateGroupEvent>() private val mutableEvents = MutableSharedFlow<CreateGroupEvent>()
val events: SharedFlow<CreateGroupEvent> get() = mutableEvents val events: SharedFlow<CreateGroupEvent> get() = mutableEvents
init {
// When a legacy group ID is given, fetch the group details and pre-fill the name and members
createFromLegacyGroupId?.let { id ->
mutableIsLoading.value = true
viewModelScope.launch(Dispatchers.Default) {
try {
groupDatabase.getGroup(id).orNull()?.let { group ->
mutableGroupName.value = group.title
val myPublicKey = storage.getUserPublicKey()
selectContactsViewModel.selectAccountIDs(
group.members
.asSequence()
.filter { it.serialize() != myPublicKey }
.mapTo(mutableSetOf()) { AccountId(it.serialize()) }
)
}
} finally {
mutableIsLoading.value = false
}
}
}
}
fun onCreateClicked() { fun onCreateClicked() {
viewModelScope.launch { viewModelScope.launch {
val groupName = groupName.value.trim() val groupName = groupName.value.trim()
@ -104,6 +135,11 @@ class CreateGroupViewModel @Inject constructor(
mutableGroupNameError.value = "" mutableGroupNameError.value = ""
} }
@AssistedFactory
interface Factory {
fun create(createFromLegacyGroupId: String?): CreateGroupViewModel
}
} }
sealed interface CreateGroupEvent { sealed interface CreateGroupEvent {

@ -35,7 +35,7 @@ class SelectContactsViewModel @AssistedInject constructor(
private val configFactory: ConfigFactory, private val configFactory: ConfigFactory,
@ApplicationContext private val appContext: Context, @ApplicationContext private val appContext: Context,
@Assisted private val excludingAccountIDs: Set<AccountId>, @Assisted private val excludingAccountIDs: Set<AccountId>,
@Assisted private val scope: CoroutineScope @Assisted private val scope: CoroutineScope,
) : ViewModel() { ) : ViewModel() {
// Input: The search query // Input: The search query
private val mutableSearchQuery = MutableStateFlow("") private val mutableSearchQuery = MutableStateFlow("")
@ -114,6 +114,10 @@ class SelectContactsViewModel @AssistedInject constructor(
mutableSelectedContactAccountIDs.value = newSet mutableSelectedContactAccountIDs.value = newSet
} }
fun selectAccountIDs(accountIDs: Set<AccountId>) {
mutableSelectedContactAccountIDs.value += accountIDs
}
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create( fun create(

@ -50,11 +50,15 @@ import org.thoughtcrime.securesms.ui.theme.PreviewTheme
@Composable @Composable
fun CreateGroupScreen( fun CreateGroupScreen(
fromLegacyGroupId: String?,
onNavigateToConversationScreen: (threadID: Long) -> Unit, onNavigateToConversationScreen: (threadID: Long) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
) { ) {
val viewModel: CreateGroupViewModel = hiltViewModel() val viewModel = hiltViewModel<CreateGroupViewModel, CreateGroupViewModel.Factory> { factory ->
factory.create(fromLegacyGroupId)
}
val context = LocalContext.current val context = LocalContext.current
LaunchedEffect(viewModel) { LaunchedEffect(viewModel) {

@ -11,6 +11,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding
import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager
import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.getGroup
import org.session.libsession.utilities.wasKickedFromGroupV2 import org.session.libsession.utilities.wasKickedFromGroupV2
@ -32,6 +33,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
var group: GroupRecord? = null var group: GroupRecord? = null
@Inject lateinit var configFactory: ConfigFactory @Inject lateinit var configFactory: ConfigFactory
@Inject lateinit var deprecationManager: LegacyGroupDeprecationManager
var onViewDetailsTapped: (() -> Unit?)? = null var onViewDetailsTapped: (() -> Unit?)? = null
var onCopyConversationId: (() -> Unit?)? = null var onCopyConversationId: (() -> Unit?)? = null
@ -80,15 +82,28 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
} else { } else {
binding.detailsTextView.visibility = View.GONE binding.detailsTextView.visibility = View.GONE
} }
binding.copyConversationId.visibility = if (!recipient.isGroupOrCommunityRecipient && !recipient.isLocalNumber) View.VISIBLE else View.GONE
val isDeprecatedLegacyGroup = recipient.isLegacyGroupRecipient &&
deprecationManager.deprecationState.value == LegacyGroupDeprecationManager.DeprecationState.DEPRECATED
binding.copyConversationId.isVisible = !recipient.isGroupOrCommunityRecipient
&& !recipient.isLocalNumber
&& !isDeprecatedLegacyGroup
binding.copyConversationId.setOnClickListener(this) binding.copyConversationId.setOnClickListener(this)
binding.copyCommunityUrl.visibility = if (recipient.isCommunityRecipient) View.VISIBLE else View.GONE binding.copyCommunityUrl.isVisible = recipient.isCommunityRecipient
binding.copyCommunityUrl.setOnClickListener(this) binding.copyCommunityUrl.setOnClickListener(this)
binding.unMuteNotificationsTextView.isVisible = recipient.isMuted && !recipient.isLocalNumber binding.unMuteNotificationsTextView.isVisible = recipient.isMuted && !recipient.isLocalNumber
&& !isDeprecatedLegacyGroup
binding.muteNotificationsTextView.isVisible = !recipient.isMuted && !recipient.isLocalNumber binding.muteNotificationsTextView.isVisible = !recipient.isMuted && !recipient.isLocalNumber
&& !isDeprecatedLegacyGroup
binding.unMuteNotificationsTextView.setOnClickListener(this) binding.unMuteNotificationsTextView.setOnClickListener(this)
binding.muteNotificationsTextView.setOnClickListener(this) binding.muteNotificationsTextView.setOnClickListener(this)
binding.notificationsTextView.isVisible = recipient.isGroupOrCommunityRecipient && !recipient.isMuted binding.notificationsTextView.isVisible = recipient.isGroupOrCommunityRecipient && !recipient.isMuted
&& !isDeprecatedLegacyGroup
binding.notificationsTextView.setOnClickListener(this) binding.notificationsTextView.setOnClickListener(this)
// delete // delete
@ -132,11 +147,12 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(this, drawableStartRes, 0, 0, 0) TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(this, drawableStartRes, 0, 0, 0)
} }
binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || binding.markAllAsReadTextView.isVisible = (thread.unreadCount > 0 ||
configFactory.withUserConfigs { it.convoInfoVolatile.getConversationUnread(thread) } configFactory.withUserConfigs { it.convoInfoVolatile.getConversationUnread(thread) })
&& !isDeprecatedLegacyGroup
binding.markAllAsReadTextView.setOnClickListener(this) binding.markAllAsReadTextView.setOnClickListener(this)
binding.pinTextView.isVisible = !thread.isPinned binding.pinTextView.isVisible = !thread.isPinned && !isDeprecatedLegacyGroup
binding.unpinTextView.isVisible = thread.isPinned binding.unpinTextView.isVisible = thread.isPinned && !isDeprecatedLegacyGroup
binding.pinTextView.setOnClickListener(this) binding.pinTextView.setOnClickListener(this)
binding.unpinTextView.setOnClickListener(this) binding.unpinTextView.setOnClickListener(this)
} }

@ -8,7 +8,8 @@ import android.text.style.ImageSpan
class PaddedImageSpan( class PaddedImageSpan(
drawable: Drawable, drawable: Drawable,
verticalAlignment: Int, verticalAlignment: Int,
private val paddingTop: Int private val paddingTop: Int,
private val paddingStart: Int
) : ImageSpan(drawable, verticalAlignment) { ) : ImageSpan(drawable, verticalAlignment) {
override fun draw( override fun draw(
@ -25,10 +26,11 @@ class PaddedImageSpan(
val drawable = drawable val drawable = drawable
canvas.save() canvas.save()
// Adjust the image's vertical position by adding the top padding // Adjust the image's top and start with padding
val transX = x + paddingStart
val transY = top + paddingTop val transY = top + paddingTop
canvas.translate(x, transY.toFloat()) canvas.translate(transX, transY.toFloat())
drawable.draw(canvas) drawable.draw(canvas)
canvas.restore() canvas.restore()
} }

@ -60,7 +60,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
tools:layout_height="60dp" tools:layout_height="60dp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageRequestBar" app:layout_constraintTop_toBottomOf="@+id/recreateGroupButtonContainer"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
/> />
@ -306,7 +306,7 @@
android:id="@+id/messageRequestBar" android:id="@+id/messageRequestBar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@+id/inputBar" app:layout_constraintBottom_toTopOf="@+id/recreateGroupButtonContainer"
app:layout_constraintTop_toBottomOf="@+id/textSendAfterApproval" app:layout_constraintTop_toBottomOf="@+id/textSendAfterApproval"
android:layout_marginBottom="@dimen/large_spacing" android:layout_marginBottom="@dimen/large_spacing"
android:orientation="vertical" android:orientation="vertical"
@ -366,6 +366,26 @@
</LinearLayout> </LinearLayout>
<FrameLayout
android:id="@+id/recreateGroupButtonContainer"
app:layout_constraintBottom_toTopOf="@+id/inputBar"
app:layout_constraintTop_toBottomOf="@+id/messageRequestBar"
android:padding="@dimen/medium_spacing"
android:visibility="gone"
tools:visibility="visible"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/recreateGroupButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/Widget.Session.Button.Common.ProminentOutline"
android:contentDescription="@string/AccessibilityId_messageRequestsAccept"
android:text="@string/recreateGroup" />
</FrameLayout>
<androidx.compose.ui.platform.ComposeView <androidx.compose.ui.platform.ComposeView
android:id="@+id/dialog_open_url" android:id="@+id/dialog_open_url"
tools:visibility="gone" tools:visibility="gone"

Loading…
Cancel
Save