diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index 24cd7f49d79..816a9d760d7 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -8,7 +8,6 @@ package com.nextcloud.talk.chat import android.content.res.Resources -import android.graphics.drawable.Drawable import android.os.Build import android.os.Bundle import android.os.CountDownTimer @@ -36,8 +35,8 @@ import android.widget.LinearLayout import android.widget.PopupMenu import android.widget.RelativeLayout import android.widget.SeekBar -import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.view.ContextThemeWrapper +import androidx.compose.material3.MaterialTheme import androidx.core.graphics.drawable.toDrawable import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged @@ -48,11 +47,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import autodagger.AutoInjector -import coil.Coil.imageLoader import coil.load -import coil.request.ImageRequest -import coil.target.Target -import coil.transform.CircleCropTransformation import com.google.android.flexbox.FlexboxLayout import com.google.android.material.button.MaterialButton import com.google.android.material.snackbar.Snackbar @@ -72,6 +67,7 @@ import com.nextcloud.talk.models.json.chat.ChatUtils import com.nextcloud.talk.models.json.mention.Mention import com.nextcloud.talk.models.json.signaling.NCSignalingMessage import com.nextcloud.talk.presenters.MentionAutocompletePresenter +import com.nextcloud.talk.ui.CallStartedBanner import com.nextcloud.talk.ui.MicInputCloud import com.nextcloud.talk.ui.dialog.AttachmentDialog import com.nextcloud.talk.ui.theme.ViewThemeUtils @@ -81,12 +77,10 @@ import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.CharPolicy import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.DateUtils -import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.EmojiTextInputEditText import com.nextcloud.talk.utils.ImageEmojiEditText import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.bundle.BundleKeys -import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.message.MessageUtils import com.nextcloud.talk.utils.text.Spans import com.otaliastudios.autocomplete.Autocomplete @@ -108,9 +102,6 @@ class MessageInputFragment : Fragment() { @Inject lateinit var userManager: UserManager - @Inject - lateinit var currentUserProvider: CurrentUserProviderOld - @Inject lateinit var networkMonitor: NetworkMonitor @@ -129,7 +120,6 @@ class MessageInputFragment : Fragment() { private var mentionAutocomplete: Autocomplete<*>? = null private var xcounter = 0f private var ycounter = 0f - private var collapsed = false private var hasScheduledMessages = false private lateinit var spreedCapabilities: SpreedCapability private var hasSharedText = false @@ -295,46 +285,19 @@ class MessageInputFragment : Fragment() { }.collect() } - viewLifecycleOwner.lifecycleScope.launch { - chatActivity.chatViewModel.lastCallSystemMessage.collect { - if (it.shouldShow) { - binding.fragmentCallStarted.callAuthorChip.text = it.actorDisplayName - val user = currentUserProvider.currentUser.blockingGet() - val url: String = if (it.actorType == "guests" || it.actorType == "guest") { - ApiUtils.getUrlForGuestAvatar(user!!.baseUrl!!, it.actorDisplayName, true) - } else { - ApiUtils.getUrlForAvatar( - user!!.baseUrl!!, - it.actorId, - false, - darkMode = DisplayUtils.isDarkModeOn(requireContext()) - ) - } - - val imageRequest: ImageRequest = ImageRequest.Builder(requireContext()) - .data(url) - .crossfade(true) - .transformations(CircleCropTransformation()) - .target(object : Target { - override fun onStart(placeholder: Drawable?) { - // unused atm - } - - override fun onError(error: Drawable?) { - // unused atm - } - - override fun onSuccess(result: Drawable) { - binding.fragmentCallStarted.callAuthorChip.chipIcon = result - } - }) - .build() + binding.fragmentCallStarted.setContent { + MaterialTheme(colorScheme = viewThemeUtils.getColorScheme(requireContext())) { + CallStartedBanner( + viewThemeUtils = viewThemeUtils, + onJoinVideoCall = { chatActivity.joinVideoCall() }, + onJoinAudioCall = { chatActivity.joinAudioCall() } + ) + } + } - imageLoader(requireContext()).enqueue(imageRequest) - binding.fragmentCallStarted.root.visibility = View.VISIBLE - } else { - binding.fragmentCallStarted.root.visibility = View.GONE - } + viewLifecycleOwner.lifecycleScope.launch { + chatActivity.chatViewModel.hasCall.collect { hasCall -> + binding.fragmentCallStarted.visibility = if (hasCall) View.VISIBLE else View.GONE } } } @@ -565,36 +528,11 @@ class MessageInputFragment : Fragment() { binding.fragmentMessageInputView.button?.contentDescription = resources.getString(R.string.nc_description_send_message_button) - binding.fragmentCallStarted.joinAudioCall.setOnClickListener { - chatActivity.joinAudioCall() - } - - binding.fragmentCallStarted.joinVideoCall.setOnClickListener { - chatActivity.joinVideoCall() - } - - binding.fragmentCallStarted.callStartedCloseBtn.setOnClickListener { - collapsed = !collapsed - binding.fragmentCallStarted.callBtnLayout.visibility = if (collapsed) View.GONE else View.VISIBLE - setDropDown(collapsed) - } - binding.fragmentMessageInputView.findViewById(R.id.cancelReplyButton)?.setOnClickListener { cancelReply() } } - private fun setDropDown(collapsed: Boolean) { - val drawable = if (collapsed) { - AppCompatResources.getDrawable(requireContext(), R.drawable.ic_keyboard_arrow_up) - } else { - AppCompatResources.getDrawable(requireContext(), R.drawable.ic_keyboard_arrow_down) - } - - binding.fragmentCallStarted.callStartedCloseBtn.setImageDrawable(drawable) - viewThemeUtils.platform.colorImageView(binding.fragmentCallStarted.callStartedCloseBtn, ColorRole.PRIMARY) - } - @Suppress("ClickableViewAccessibility", "CyclomaticComplexMethod", "LongMethod") private fun initVoiceRecordButton() { handleButtonsVisibility() @@ -1191,18 +1129,6 @@ class MessageInputFragment : Fragment() { viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) } - binding.fragmentCallStarted.callStartedBackground.apply { - viewThemeUtils.talk.themeOutgoingMessageBubble(this, grouped = true, false) - } - - binding.fragmentCallStarted.callAuthorChip.apply { - viewThemeUtils.material.colorChipBackground(this) - } - - binding.fragmentCallStarted.callStartedCloseBtn.apply { - viewThemeUtils.platform.colorImageView(this, ColorRole.PRIMARY) - } - binding.fragmentMessageInputView.submitThreadButton.apply { viewThemeUtils.platform.colorImageView(this, ColorRole.SECONDARY) } diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatMessageSyncer.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatMessageSyncer.kt index 53509965d87..66b1e0cad4d 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatMessageSyncer.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/ChatMessageSyncer.kt @@ -704,10 +704,6 @@ class ChatMessageSyncer @Inject constructor( ChatMessage.SystemMessageType.MESSAGE_EDITED -> upsertParentMessage(target, messageJson) - ChatMessage.SystemMessageType.LOBBY_NONE, - ChatMessage.SystemMessageType.LOBBY_NON_MODERATORS, - ChatMessage.SystemMessageType.LOBBY_OPEN_TO_EVERYONE -> needsRoomRefresh = true - ChatMessage.SystemMessageType.CLEARED_CHAT -> { // for lookIntoFuture just deleting everything would be fine. // But lets say we did not open the chat for a while and in between it was cleared. @@ -719,8 +715,21 @@ class ChatMessageSyncer @Inject constructor( chatBlocksDao.deleteChatBlocksOlderThan(target.internalConversationId, messageJson.id) } + ChatMessage.SystemMessageType.LOBBY_NONE, + ChatMessage.SystemMessageType.LOBBY_NON_MODERATORS, + ChatMessage.SystemMessageType.LOBBY_OPEN_TO_EVERYONE, ChatMessage.SystemMessageType.MESSAGE_PINNED, - ChatMessage.SystemMessageType.MESSAGE_UNPINNED -> needsRoomRefresh = true + ChatMessage.SystemMessageType.MESSAGE_UNPINNED, + ChatMessage.SystemMessageType.CALL_STARTED, + ChatMessage.SystemMessageType.CALL_ENDED, + ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE, + ChatMessage.SystemMessageType.CALL_MISSED, + ChatMessage.SystemMessageType.CALL_TRIED -> { + val messageAgeMillis = System.currentTimeMillis() - messageJson.timestamp * MILLIS_PER_SECOND + if (messageAgeMillis <= ROOM_REFRESH_MAX_AGE_MILLIS) { + needsRoomRefresh = true + } + } else -> {} } @@ -837,6 +846,7 @@ class ChatMessageSyncer @Inject constructor( private const val DEFAULT_MESSAGES_LIMIT = 100 private const val MILLIS_PER_SECOND = 1000L + private const val ROOM_REFRESH_MAX_AGE_MILLIS = 3 * 60 * 60 * 1000L // 3 hours private const val CATCH_UP_COOLDOWN_MILLIS = 5_000L private const val MAX_CATCH_UP_RUNS_PER_BURST = 3 private const val MAX_BACKLOG_ROUNDS = 5 diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index b59aa01f706..c041ffda1f1 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -468,7 +468,9 @@ class ChatViewModel @AssistedInject constructor( private val nonNullUserFlow = currentUserFlow.filterNotNull() - private val conversationFlow: Flow = + // Unlike conversationFlow below, this is not deduped by lastReadMessage/lastCommonReadMessage, + // so it also reacts to fields those two ignore, e.g. hasCall (see [hasCall]). + private val rawConversationFlow: Flow = nonNullUserFlow .flatMapLatest { user -> val userId = requireNotNull(user.id) @@ -483,7 +485,9 @@ class ChatViewModel @AssistedInject constructor( null } } - .distinctUntilChangedBy { it.lastReadMessage to it.lastCommonReadMessage } + + private val conversationFlow: Flow = + rawConversationFlow.distinctUntilChangedBy { it.lastReadMessage to it.lastCommonReadMessage } private val conversationAndUserFlow = combine(conversationFlow, nonNullUserFlow) { c, u -> c to u } @@ -1021,23 +1025,11 @@ class ChatViewModel @AssistedInject constructor( val capabilities: SpreedCapability? = null ) - data class CallStartedIndicatorData( - val actorDisplayName: String, - val actorType: String, - val actorId: String, - val shouldShow: Boolean - ) - - private val _lastCallSystemMessage = MutableStateFlow(null) - - val lastCallSystemMessage = _lastCallSystemMessage.map { msg -> - CallStartedIndicatorData( - msg?.actorDisplayName ?: "", - msg?.actorType ?: "", - msg?.actorId ?: "", - msg != null - ) - } + // The conversation's own hasCall is the server's authoritative answer to "is a call currently + // active" — unlike a scan over whatever chat message window happens to be loaded (which used to + // make the call-started banner reappear for calls that had long since ended, since a loaded + // window's contents don't necessarily reflect what's actually happening right now). + val hasCall: Flow = rawConversationFlow.map { it.hasCall }.distinctUntilChanged() private val _callEndedSystemMessage = MutableSharedFlow(extraBufferCapacity = 1) val callEndedSystemMessage: SharedFlow @@ -1384,16 +1376,13 @@ class ChatViewModel @AssistedInject constructor( val chatMessageMap = chatMessageList.associateBy { it.jsonMessageId }.toMutableMap() val chatMessageIterator = chatMessageMap.iterator() + // Only drives the "conversation will be deleted after the call" warning (callEndedSystemMessage) + // — the call-started banner itself comes from hasCall / conversation.hasCall now. chatMessageList.lastOrNull { it.systemMessageType in listOf( - ChatMessage.SystemMessageType.CALL_STARTED, - ChatMessage.SystemMessageType.CALL_JOINED, - ChatMessage.SystemMessageType.CALL_LEFT, ChatMessage.SystemMessageType.CALL_ENDED, - ChatMessage.SystemMessageType.CALL_TRIED, - ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE, - ChatMessage.SystemMessageType.CALL_MISSED + ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE ) }?.let { callMessage -> processCallSystemMessage(callMessage) @@ -1416,24 +1405,10 @@ class ChatViewModel @AssistedInject constructor( val isInitialSnapshot = !hasSeenInitialCallSystemMessage hasSeenInitialCallSystemMessage = true - when (recent.systemMessageType) { - ChatMessage.SystemMessageType.CALL_STARTED -> { - _lastCallSystemMessage.tryEmit(recent) - } - ChatMessage.SystemMessageType.CALL_ENDED, - ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE -> { - _lastCallSystemMessage.tryEmit(null) - if (!isInitialSnapshot && lastNotifiedCallEndedMessageId != recent.jsonMessageId) { - _callEndedSystemMessage.tryEmit(recent.systemMessageType!!) - } - lastNotifiedCallEndedMessageId = recent.jsonMessageId - } - ChatMessage.SystemMessageType.CALL_MISSED, - ChatMessage.SystemMessageType.CALL_TRIED -> { - _lastCallSystemMessage.tryEmit(null) - } - else -> {} + if (!isInitialSnapshot && lastNotifiedCallEndedMessageId != recent.jsonMessageId) { + _callEndedSystemMessage.tryEmit(recent.systemMessageType!!) } + lastNotifiedCallEndedMessageId = recent.jsonMessageId } private fun isInfoMessageAboutDeletion(currentMessage: MutableMap.MutableEntry): Boolean = diff --git a/app/src/main/java/com/nextcloud/talk/ui/CallStartedBanner.kt b/app/src/main/java/com/nextcloud/talk/ui/CallStartedBanner.kt new file mode 100644 index 00000000000..01e8ea5d0cb --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/CallStartedBanner.kt @@ -0,0 +1,138 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextcloud.talk.R +import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.utils.preview.ComposePreviewUtils + +@Composable +fun CallStartedBanner(viewThemeUtils: ViewThemeUtils, onJoinVideoCall: () -> Unit, onJoinAudioCall: () -> Unit) { + val context = LocalContext.current + val colorScheme = remember { viewThemeUtils.getColorScheme(context) } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(4.dp), + colors = CardDefaults.cardColors(containerColor = colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Column(modifier = Modifier.padding(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_phone), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(R.string.call_in_progress), + style = MaterialTheme.typography.bodyMedium + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.Center + ) { + CallStartedActionButton( + icon = R.drawable.ic_videocam_grey_600_24dp, + text = stringResource(R.string.video_call), + onClick = onJoinVideoCall, + modifier = Modifier.padding(end = 8.dp) + ) + CallStartedActionButton( + icon = R.drawable.ic_phone, + text = stringResource(R.string.audio_call), + onClick = onJoinAudioCall + ) + } + } + } +} + +@Composable +private fun CallStartedActionButton(icon: Int, text: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + Button( + onClick = onClick, + modifier = modifier, + colors = ButtonDefaults.buttonColors(containerColor = colorResource(R.color.nc_darkGreen)) + ) { + Icon( + imageVector = ImageVector.vectorResource(icon), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.size(4.dp)) + Text(text = text, color = Color.White) + } +} + +@Preview(name = "Light Mode") +@Composable +fun CallStartedBannerPreview() { + val context = LocalContext.current + val previewUtils = ComposePreviewUtils.getInstance(context) + val viewThemeUtils = previewUtils.viewThemeUtils + val colorScheme = viewThemeUtils.getColorScheme(context) + + MaterialTheme(colorScheme = colorScheme) { + CallStartedBanner( + viewThemeUtils = viewThemeUtils, + onJoinVideoCall = {}, + onJoinAudioCall = {} + ) + } +} + +@Preview(name = "Dark Mode", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES) +@Composable +fun CallStartedBannerPreviewDark() { + CallStartedBannerPreview() +} + +@Preview(name = "R-t-L", locale = "ar") +@Composable +fun CallStartedBannerPreviewRtl() { + CallStartedBannerPreview() +} diff --git a/app/src/main/res/layout/call_started_message.xml b/app/src/main/res/layout/call_started_message.xml deleted file mode 100644 index 270977482dc..00000000000 --- a/app/src/main/res/layout/call_started_message.xml +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_message_input.xml b/app/src/main/res/layout/fragment_message_input.xml index 5b35ed0b757..189eb5f845c 100644 --- a/app/src/main/res/layout/fragment_message_input.xml +++ b/app/src/main/res/layout/fragment_message_input.xml @@ -13,9 +13,8 @@ android:layout_height="wrap_content" android:orientation="vertical"> - #FFFFFF #121212 - #00AA00 - #373737 #D8D8D8 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b106ced931f..2378da4efe0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -950,6 +950,7 @@ How to translate with transifex: Video call Audio call started a call + A call is in progress Error 429 Too Many Requests Caption Compress images and videos