diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index 6c55ef04b6..4eb1ef7299 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -509,7 +509,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe setUpSearchResultObserver() scrollToFirstUnreadMessageIfNeeded() setUpOutdatedClientBanner() - setUpLegacyGroupBanner() + setUpLegacyGroupUI() if (author != null && messageTimestamp >= 0 && targetPosition >= 0) { binding.conversationRecyclerView.scrollToPosition(targetPosition) @@ -543,6 +543,22 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } 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() { @@ -829,7 +845,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - private fun setUpLegacyGroupBanner() { + private fun setUpLegacyGroupUI() { lifecycleScope.launch { viewModel.legacyGroupBanner .collectLatest { banner -> @@ -843,12 +859,15 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // we need to add the inline icon val drawable = ContextCompat.getDrawable(this@ConversationActivityV2, R.drawable.ic_external)!! val imageSize = toPx(10, resources) - val imagePaddingTop = toPx(4, resources) + val imagePadding = toPx(4, resources) drawable.setBounds(0, 0, imageSize, imageSize) drawable.setTint(getColorFromAttr(R.attr.message_sent_text_color)) setSpan( - PaddedImageSpan(drawable, ImageSpan.ALIGN_BASELINE, imagePaddingTop), + PaddedImageSpan(drawable, ImageSpan.ALIGN_BASELINE, + paddingStart = imagePadding, + paddingTop = imagePadding + ), length - 1, length, 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() { @@ -963,6 +993,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe thread = recipient, context = this, configFactory = configFactory, + deprecationManager = viewModel.legacyGroupDeprecationManager ) } maybeUpdateToolbar(recipient) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index 3aa19af994..3903e5a555 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -33,19 +33,23 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.LocalisedTimeUtil.toShortTwoPartString +import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.snode.SnodeAPI 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.ThemeUtil +import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.components.emoji.EmojiImageView import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel import org.thoughtcrime.securesms.components.menu.ActionItem 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.ThreadDatabase import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord 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.util.AnimationCompleteListener import org.thoughtcrime.securesms.util.DateUtils @@ -95,6 +99,11 @@ class ConversationReactionOverlay : FrameLayout { @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase @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 var job: Job? = null @@ -163,7 +172,8 @@ class ConversationReactionOverlay : FrameLayout { private fun showAfterLayout(messageRecord: MessageRecord, lastSeenDownPoint: PointF, 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 var endX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX - conversationItem.width + selectedConversationModel.bubbleWidth var endY = selectedConversationModel.bubbleY - statusBarHeight @@ -260,6 +270,12 @@ class ConversationReactionOverlay : FrameLayout { } else { (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.y = reactionBarBackgroundY + reactionBarHeight / 2f - foregroundView.height / 2f backgroundView.x = scrubberX @@ -359,6 +375,12 @@ class ConversationReactionOverlay : FrameLayout { updateBoundsOnLayoutChanged() } + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + + hide() + } + private fun updateBoundsOnLayoutChanged() { backgroundView.getGlobalVisibleRect(emojiStripViewBounds) emojiViews[0].getGlobalVisibleRect(emojiViewGlobalRect) @@ -521,16 +543,14 @@ class ConversationReactionOverlay : FrameLayout { .firstOrNull() ?.let(ReactionRecord::emoji) - private fun getMenuActionItems(message: MessageRecord): List { + private fun getMenuActionItems(message: MessageRecord, recipient: Recipient): List { val items: MutableList = ArrayList() // Prepare val containsControlMessage = message.isUpdate val hasText = !message.body.isEmpty() - val openGroup = get(context).lokiThreadDatabase().getOpenGroupChat(message.threadId) - val recipient = get(context).threadDatabase().getRecipientForThreadId(message.threadId) - ?: return emptyList() - val userPublicKey = getLocalNumber(context)!! + val openGroup = lokiThreadDatabase.getOpenGroupChat(message.threadId) + val userPublicKey = textSecurePreferences.getLocalNumber()!! // control messages and "marked as deleted" messages can only delete val isDeleteOnly = message.isDeleted || message.isControlMessage @@ -544,9 +564,15 @@ class ConversationReactionOverlay : FrameLayout { R.string.AccessibilityId_select ) } + + + val isDeprecatedLegacyGroup = recipient.isLegacyGroupRecipient && + deprecationManager.deprecationState.value == LegacyGroupDeprecationManager.DeprecationState.DEPRECATED + // Reply 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) } // 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) }) } // Delete message - 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)) + if (!isDeprecatedLegacyGroup) { + 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 - 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) }) } // 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) }) } // Message detail @@ -576,11 +611,11 @@ class ConversationReactionOverlay : FrameLayout { { handleActionItemClicked(Action.VIEW_INFO) }) } // Resend - if (message.isFailed) { + if (message.isFailed && !isDeprecatedLegacyGroup) { items += ActionItem(R.attr.menu_reply_icon, R.string.resend, { handleActionItemClicked(Action.RESEND) }) } // Resync - if (message.isSyncFailed) { + if (message.isSyncFailed && !isDeprecatedLegacyGroup) { items += ActionItem(R.attr.menu_reply_icon, R.string.resync, { handleActionItemClicked(Action.RESYNC) }) } // Save media.. diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt index a958f9d84d..ea57192cff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt @@ -2,7 +2,10 @@ package org.thoughtcrime.securesms.conversation.v2 import androidx.compose.foundation.layout.PaddingValues 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.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -16,11 +19,16 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.squareup.phrase.Phrase import network.loki.messenger.R -import org.session.libsession.utilities.StringSubstitutionConstants.APP_NAME_KEY import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY -import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.* -import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.DeleteForEveryoneDialogData -import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.ClearEmoji +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.ConfirmRecreateGroup +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.DialogButtonModel 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.SessionMaterialTheme +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ConversationV2Dialogs( 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) + }, + ) + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 3695f67316..03ef0a5938 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation.v2 import android.app.Application import android.content.Context +import android.content.Intent import android.view.MenuItem import android.widget.Toast import androidx.annotation.StringRes @@ -14,10 +15,11 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow 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.StorageProtocol 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.open_groups.OpenGroup 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.MmsMessageRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory -import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.thoughtcrime.securesms.groups.OpenGroupManager import org.thoughtcrime.securesms.mms.AudioSlide import org.thoughtcrime.securesms.repository.ConversationRepository @@ -66,7 +68,6 @@ import org.thoughtcrime.securesms.util.DateUtils import java.time.ZoneId import java.util.UUID -@OptIn(ExperimentalCoroutinesApi::class) class ConversationViewModel( val threadId: Long, val edKeyPair: KeyPair?, @@ -81,7 +82,7 @@ class ConversationViewModel( private val textSecurePreferences: TextSecurePreferences, private val configFactory: ConfigFactory, private val groupManagerV2: GroupManagerV2, - private val legacyGroupDeprecationManager: LegacyGroupDeprecationManager, + val legacyGroupDeprecationManager: LegacyGroupDeprecationManager, ) : ViewModel() { val showSendAfterApprovalText: Boolean @@ -90,6 +91,9 @@ class ConversationViewModel( private val _uiState = MutableStateFlow(ConversationUiState()) val uiState: StateFlow get() = _uiState + private val _uiEvents = MutableSharedFlow(extraBufferCapacity = 1) + val uiEvents: SharedFlow get() = _uiEvents + private val _dialogsState = MutableStateFlow(DialogsState()) val dialogsState: StateFlow = _dialogsState @@ -218,6 +222,10 @@ class ConversationViewModel( } }.stateIn(viewModelScope, SharingStarted.Lazily, null) + val showRecreateGroupButton: StateFlow = isAdmin + .map { admin -> + admin && recipient?.isLegacyGroupRecipient == true + }.stateIn(viewModelScope, SharingStarted.Lazily, false) private val attachmentDownloadHandler = AttachmentDownloadHandler( storage = storage, @@ -949,6 +957,37 @@ class ConversationViewModel( is Commands.ClearEmoji -> { 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( val openLinkDialogUrl: String? = 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( @@ -1073,16 +1118,22 @@ class ConversationViewModel( val messageId: MessageId ) - sealed class Commands { - data class ShowOpenUrlDialog(val url: String?) : Commands() + sealed interface 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 HideClearEmoji : Commands() + data object HideDeleteEveryoneDialog : Commands + data object HideClearEmoji : Commands - data class MarkAsDeletedLocally(val messages: Set): Commands() - data class MarkAsDeletedForEveryone(val data: DeleteForEveryoneDialogData): Commands() + data class MarkAsDeletedLocally(val messages: Set): 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, ) +sealed interface ConversationUiEvent { + data class NavigateToConversation(val threadId: Long) : ConversationUiEvent +} + sealed interface MessageRequestUiState { data object Invisible : MessageRequestUiState diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt index 45957a28c7..cee15cd1cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationMenuHelper.kt @@ -28,6 +28,7 @@ import java.io.IOException import network.loki.messenger.R import org.session.libsession.database.StorageProtocol 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.leave import org.session.libsession.utilities.GroupUtil.doubleDecodeGroupID @@ -70,14 +71,21 @@ object ConversationMenuHelper { thread: Recipient, context: Context, configFactory: ConfigFactory, + deprecationManager: LegacyGroupDeprecationManager, ) { + val isDeprecatedLegacyGroup = thread.isLegacyGroupRecipient && + deprecationManager.deprecationState.value == LegacyGroupDeprecationManager.DeprecationState.DEPRECATED + // Prepare menu.clear() val isCommunity = thread.isCommunityRecipient // Base menu (options that should always be present) inflater.inflate(R.menu.menu_conversation, menu) + menu.findItem(R.id.menu_add_shortcut).isVisible = !isDeprecatedLegacyGroup + // 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) } // 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) if (thread.isLegacyGroupRecipient) { inflater.inflate(R.menu.menu_conversation_legacy_group, menu) + + menu.findItem(R.id.menu_edit_group).isVisible = !isDeprecatedLegacyGroup } // Groups v2 menu @@ -118,13 +128,15 @@ object ConversationMenuHelper { inflater.inflate(R.menu.menu_conversation_open_group, menu) } // Muting - if (thread.isMuted) { - inflater.inflate(R.menu.menu_conversation_muted, menu) - } else { - inflater.inflate(R.menu.menu_conversation_unmuted, menu) + if (!isDeprecatedLegacyGroup) { + if (thread.isMuted) { + inflater.inflate(R.menu.menu_conversation_muted, 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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt index e3b656dc5e..6a27b213a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupFragment.kt @@ -33,7 +33,8 @@ class CreateGroupFragment : Fragment() { ) }, onBack = delegate::onDialogBackPressed, - onClose = delegate::onDialogClosePressed + onClose = delegate::onDialogClosePressed, + fromLegacyGroupId = null, ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt index c95c4efd24..27ab046fbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/CreateGroupViewModel.kt @@ -3,6 +3,9 @@ package org.thoughtcrime.securesms.groups import android.content.Context import androidx.lifecycle.ViewModel 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.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -15,17 +18,21 @@ import kotlinx.coroutines.withContext import network.loki.messenger.R import org.session.libsession.database.StorageProtocol 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.database.GroupDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory import javax.inject.Inject -@HiltViewModel -class CreateGroupViewModel @Inject constructor( +@HiltViewModel(assistedFactory = CreateGroupViewModel.Factory::class) +class CreateGroupViewModel @AssistedInject constructor( configFactory: ConfigFactory, @ApplicationContext private val appContext: Context, private val storage: StorageProtocol, private val groupManagerV2: GroupManagerV2, + groupDatabase: GroupDatabase, + @Assisted createFromLegacyGroupId: String?, ): ViewModel() { // Child view model to handle contact selection logic val selectContactsViewModel = SelectContactsViewModel( @@ -51,6 +58,30 @@ class CreateGroupViewModel @Inject constructor( private val mutableEvents = MutableSharedFlow() val events: SharedFlow 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() { viewModelScope.launch { val groupName = groupName.value.trim() @@ -104,6 +135,11 @@ class CreateGroupViewModel @Inject constructor( mutableGroupNameError.value = "" } + + @AssistedFactory + interface Factory { + fun create(createFromLegacyGroupId: String?): CreateGroupViewModel + } } sealed interface CreateGroupEvent { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt index 26bb9d87a3..d7634abb6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt @@ -35,7 +35,7 @@ class SelectContactsViewModel @AssistedInject constructor( private val configFactory: ConfigFactory, @ApplicationContext private val appContext: Context, @Assisted private val excludingAccountIDs: Set, - @Assisted private val scope: CoroutineScope + @Assisted private val scope: CoroutineScope, ) : ViewModel() { // Input: The search query private val mutableSearchQuery = MutableStateFlow("") @@ -114,6 +114,10 @@ class SelectContactsViewModel @AssistedInject constructor( mutableSelectedContactAccountIDs.value = newSet } + fun selectAccountIDs(accountIDs: Set) { + mutableSelectedContactAccountIDs.value += accountIDs + } + @AssistedFactory interface Factory { fun create( diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt index 3395443785..2be4aef546 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/CreateGroupScreen.kt @@ -50,11 +50,15 @@ import org.thoughtcrime.securesms.ui.theme.PreviewTheme @Composable fun CreateGroupScreen( + fromLegacyGroupId: String?, onNavigateToConversationScreen: (threadID: Long) -> Unit, onBack: () -> Unit, onClose: () -> Unit, ) { - val viewModel: CreateGroupViewModel = hiltViewModel() + val viewModel = hiltViewModel { factory -> + factory.create(fromLegacyGroupId) + } + val context = LocalContext.current LaunchedEffect(viewModel) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt index e5f46a79c7..b67495e82e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/ConversationOptionsBottomSheet.kt @@ -11,6 +11,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint import network.loki.messenger.R import network.loki.messenger.databinding.FragmentConversationBottomSheetBinding +import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.utilities.GroupRecord import org.session.libsession.utilities.getGroup import org.session.libsession.utilities.wasKickedFromGroupV2 @@ -32,6 +33,7 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto var group: GroupRecord? = null @Inject lateinit var configFactory: ConfigFactory + @Inject lateinit var deprecationManager: LegacyGroupDeprecationManager var onViewDetailsTapped: (() -> Unit?)? = null var onCopyConversationId: (() -> Unit?)? = null @@ -80,15 +82,28 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto } else { 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.copyCommunityUrl.visibility = if (recipient.isCommunityRecipient) View.VISIBLE else View.GONE + binding.copyCommunityUrl.isVisible = recipient.isCommunityRecipient binding.copyCommunityUrl.setOnClickListener(this) + binding.unMuteNotificationsTextView.isVisible = recipient.isMuted && !recipient.isLocalNumber + && !isDeprecatedLegacyGroup binding.muteNotificationsTextView.isVisible = !recipient.isMuted && !recipient.isLocalNumber + && !isDeprecatedLegacyGroup + binding.unMuteNotificationsTextView.setOnClickListener(this) binding.muteNotificationsTextView.setOnClickListener(this) binding.notificationsTextView.isVisible = recipient.isGroupOrCommunityRecipient && !recipient.isMuted + && !isDeprecatedLegacyGroup + binding.notificationsTextView.setOnClickListener(this) // delete @@ -132,11 +147,12 @@ class ConversationOptionsBottomSheet(private val parentContext: Context) : Botto TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(this, drawableStartRes, 0, 0, 0) } - binding.markAllAsReadTextView.isVisible = thread.unreadCount > 0 || - configFactory.withUserConfigs { it.convoInfoVolatile.getConversationUnread(thread) } + binding.markAllAsReadTextView.isVisible = (thread.unreadCount > 0 || + configFactory.withUserConfigs { it.convoInfoVolatile.getConversationUnread(thread) }) + && !isDeprecatedLegacyGroup binding.markAllAsReadTextView.setOnClickListener(this) - binding.pinTextView.isVisible = !thread.isPinned - binding.unpinTextView.isVisible = thread.isPinned + binding.pinTextView.isVisible = !thread.isPinned && !isDeprecatedLegacyGroup + binding.unpinTextView.isVisible = thread.isPinned && !isDeprecatedLegacyGroup binding.pinTextView.setOnClickListener(this) binding.unpinTextView.setOnClickListener(this) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/PaddedImageSpan.kt b/app/src/main/java/org/thoughtcrime/securesms/util/PaddedImageSpan.kt index d7f199098c..7a23632a9d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/PaddedImageSpan.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/PaddedImageSpan.kt @@ -8,7 +8,8 @@ import android.text.style.ImageSpan class PaddedImageSpan( drawable: Drawable, verticalAlignment: Int, - private val paddingTop: Int + private val paddingTop: Int, + private val paddingStart: Int ) : ImageSpan(drawable, verticalAlignment) { override fun draw( @@ -25,10 +26,11 @@ class PaddedImageSpan( val drawable = drawable 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 - canvas.translate(x, transY.toFloat()) + canvas.translate(transX, transY.toFloat()) drawable.draw(canvas) canvas.restore() } diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 0e91855589..e1cbfd83d2 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -60,7 +60,7 @@ android:layout_height="wrap_content" tools:layout_height="60dp" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/messageRequestBar" + app:layout_constraintTop_toBottomOf="@+id/recreateGroupButtonContainer" app:layout_constraintBottom_toBottomOf="parent" /> @@ -306,7 +306,7 @@ android:id="@+id/messageRequestBar" android:layout_width="match_parent" android:layout_height="wrap_content" - app:layout_constraintBottom_toTopOf="@+id/inputBar" + app:layout_constraintBottom_toTopOf="@+id/recreateGroupButtonContainer" app:layout_constraintTop_toBottomOf="@+id/textSendAfterApproval" android:layout_marginBottom="@dimen/large_spacing" android:orientation="vertical" @@ -366,6 +366,26 @@ + + +