From 7311b56ecb51d94ba516156cec90956cef357715 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Sun, 31 May 2026 19:00:03 +0200 Subject: [PATCH 1/8] style: Migrate empty views to composable AI-assistant: Claude Code v2.1.152 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../com/nextcloud/talk/chat/ChatActivity.kt | 21 ++- .../nextcloud/talk/chat/ui/ChatEmptyState.kt | 175 ++++++++++++++++++ .../talk/chat/ui/ChatEmptyStateType.kt | 19 ++ app/src/main/res/layout/activity_chat.xml | 13 +- app/src/main/res/values/strings.xml | 1 + 5 files changed, 214 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/chat/ui/ChatEmptyState.kt create mode 100644 app/src/main/java/com/nextcloud/talk/chat/ui/ChatEmptyStateType.kt diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index e652104d61..7b57a55c72 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -132,6 +132,8 @@ import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.model.FileParameters +import com.nextcloud.talk.chat.ui.ChatEmptyState +import com.nextcloud.talk.chat.ui.ChatEmptyStateType import com.nextcloud.talk.chat.ui.MessageActionsBottomSheet import com.nextcloud.talk.chat.ui.ProfileModalBottomSheet import com.nextcloud.talk.chat.ui.ShowReactionsModalBottomSheet @@ -316,6 +318,7 @@ class ChatActivity : private var overflowMenuHostView: ComposeView? = null private var isThreadMenuExpanded by mutableStateOf(false) private val searchLoadingState = mutableStateOf(false) + private val chatEmptyStateType = mutableStateOf(null) private val upcomingEventUiState = mutableStateOf(ChatViewModel.UpcomingEventUIState.None) @@ -510,7 +513,7 @@ class ChatActivity : setupActionBar() setContentView(binding.root) - binding.offline.root.visibility = View.GONE + setupChatEmptyStateView() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { ViewCompat.setOnApplyWindowInsetsListener(binding.chatContainer) { view, insets -> @@ -2369,6 +2372,13 @@ class ChatActivity : currentConversation?.conversationReadOnlyState == ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_ONLY + private fun setupChatEmptyStateView() { + binding.chatEmptyStateComposeView.setContent { + val type by chatEmptyStateType + type?.let { ChatEmptyState(it) } + } + } + private fun checkLobbyState() { if (currentConversation != null && ConversationUtils.isLobbyViewApplicable(currentConversation!!, spreedCapabilities) && @@ -2376,15 +2386,14 @@ class ChatActivity : ) { showLobbyView() } else { - binding.lobby.lobbyView.visibility = View.GONE - // binding.messagesListView.visibility = View.VISIBLE + binding.chatEmptyStateComposeView.visibility = View.GONE + chatEmptyStateType.value = null checkShowMessageInputView() } } private fun showLobbyView() { - binding.lobby.lobbyView.visibility = View.VISIBLE - // binding.messagesListView.visibility = View.GONE + binding.chatEmptyStateComposeView.visibility = View.VISIBLE binding.fragmentContainerActivityChat.visibility = View.GONE val sb = StringBuilder() @@ -2407,7 +2416,7 @@ class ChatActivity : } sb.append(currentConversation!!.description) - binding.lobby.lobbyTextView.text = sb.toString() + chatEmptyStateType.value = ChatEmptyStateType.Lobby(sb.toString()) } private fun onRemoteFileBrowsingResult(intent: Intent?) { diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/ChatEmptyState.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatEmptyState.kt new file mode 100644 index 0000000000..92b6c29cd1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatEmptyState.kt @@ -0,0 +1,175 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nextcloud.talk.R + +@Composable +fun ChatEmptyState(type: ChatEmptyStateType, modifier: Modifier = Modifier) { + val iconRes: Int + val text: String + + when (type) { + is ChatEmptyStateType.Lobby -> { + iconRes = R.drawable.ic_room_service_black_24dp + text = type.text + } + ChatEmptyStateType.Offline -> { + iconRes = R.drawable.ic_signal_wifi_off_white_24dp + text = stringResource(R.string.no_offline_messages_saved) + } + ChatEmptyStateType.NoMessages -> { + iconRes = R.drawable.ic_comment + text = stringResource(R.string.nc_chat_no_messages) + } + } + + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(64.dp), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(colorResource(R.color.grey_600), BlendMode.SrcIn) + ) + Text( + text = text, + modifier = Modifier.padding(top = 16.dp), + color = colorResource(R.color.grey_600), + fontSize = 16.sp, + textAlign = TextAlign.Center + ) + } + } +} + +private const val PREVIEW_WIDTH_DP = 360 +private const val PREVIEW_HEIGHT_DP = 640 + +@Preview(name = "Lobby · Light", widthDp = PREVIEW_WIDTH_DP, heightDp = PREVIEW_HEIGHT_DP) +@Preview( + name = "Lobby · Dark", + widthDp = PREVIEW_WIDTH_DP, + heightDp = PREVIEW_HEIGHT_DP, + uiMode = Configuration.UI_MODE_NIGHT_YES +) +@Composable +private fun LobbyPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface(modifier = Modifier.fillMaxSize()) { + ChatEmptyState( + type = ChatEmptyStateType.Lobby( + text = "You are currently waiting in the lobby.\n\n" + + "This meeting is scheduled for Monday, 2 Jun 2026 at 10:00 – in 45 minutes" + ) + ) + } + } +} + +@Preview(name = "Lobby · RTL / Arabic", widthDp = PREVIEW_WIDTH_DP, heightDp = PREVIEW_HEIGHT_DP, locale = "ar") +@Composable +private fun LobbyRtlPreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + Surface(modifier = Modifier.fillMaxSize()) { + ChatEmptyState( + type = ChatEmptyStateType.Lobby( + text = "أنت في انتظار الدخول إلى القاعة." + ) + ) + } + } +} + +@Preview(name = "Offline · Light", widthDp = PREVIEW_WIDTH_DP, heightDp = PREVIEW_HEIGHT_DP) +@Preview( + name = "Offline · Dark", + widthDp = PREVIEW_WIDTH_DP, + heightDp = PREVIEW_HEIGHT_DP, + uiMode = Configuration.UI_MODE_NIGHT_YES +) +@Composable +private fun OfflinePreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface(modifier = Modifier.fillMaxSize()) { + ChatEmptyState(type = ChatEmptyStateType.Offline) + } + } +} + +@Preview(name = "Offline · RTL / Arabic", widthDp = PREVIEW_WIDTH_DP, heightDp = PREVIEW_HEIGHT_DP, locale = "ar") +@Composable +private fun OfflineRtlPreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + Surface(modifier = Modifier.fillMaxSize()) { + ChatEmptyState(type = ChatEmptyStateType.Offline) + } + } +} + +@Preview(name = "No messages · Light", widthDp = PREVIEW_WIDTH_DP, heightDp = PREVIEW_HEIGHT_DP) +@Preview( + name = "No messages · Dark", + widthDp = PREVIEW_WIDTH_DP, + heightDp = PREVIEW_HEIGHT_DP, + uiMode = Configuration.UI_MODE_NIGHT_YES +) +@Composable +private fun NoMessagesPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface(modifier = Modifier.fillMaxSize()) { + ChatEmptyState(type = ChatEmptyStateType.NoMessages) + } + } +} + +@Preview(name = "No messages · RTL / Arabic", widthDp = PREVIEW_WIDTH_DP, heightDp = PREVIEW_HEIGHT_DP, locale = "ar") +@Composable +private fun NoMessagesRtlPreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + Surface(modifier = Modifier.fillMaxSize()) { + ChatEmptyState(type = ChatEmptyStateType.NoMessages) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/ChatEmptyStateType.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatEmptyStateType.kt new file mode 100644 index 0000000000..e434ba9bde --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatEmptyStateType.kt @@ -0,0 +1,19 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.ui + +sealed class ChatEmptyStateType { + /** Waiting in lobby before a meeting starts. [text] is assembled by the caller and may include a timer. */ + data class Lobby(val text: String) : ChatEmptyStateType() + + /** Device is offline and no messages are cached locally. */ + data object Offline : ChatEmptyStateType() + + /** Conversation exists but has no messages yet. */ + data object NoMessages : ChatEmptyStateType() +} diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index 705920bb34..7f8c2ac5d8 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -102,15 +102,10 @@ android:layout_height="0dp" android:layout_weight="1"> - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e3010358f8..803d49d37e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -934,6 +934,7 @@ How to translate with transifex: Once a conversation is archived, it will be hidden by default. Select the filter \"Archived\" to view archived conversations. Direct mentions will still be received. Once a conversation is unarchived, it will be shown by default again. No offline messages saved + No messages yet. Be the first to say hello! Previously set Failed to set conversation Read-only Status Reverted From 1eb870142fe2c1e1b45cfa109f77d5684b54809d Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 1 Jun 2026 17:03:00 +0200 Subject: [PATCH 2/8] style: migrate toolbar to Compose ChatToolbar AI-assistant: Claude Code v2.1.152 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../com/nextcloud/talk/chat/ChatActivity.kt | 727 +++++------------- .../com/nextcloud/talk/chat/ui/ChatToolbar.kt | 477 ++++++++++++ .../talk/chat/ui/ChatToolbarCallbacks.kt | 25 + .../talk/chat/ui/ChatToolbarState.kt | 36 + app/src/main/res/layout/activity_chat.xml | 81 +- app/src/main/res/values/strings.xml | 1 + 6 files changed, 719 insertions(+), 628 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbar.kt create mode 100644 app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbarCallbacks.kt create mode 100644 app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbarState.kt diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 7b57a55c72..13900a5c4c 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -23,8 +23,6 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.res.AssetFileDescriptor import android.database.Cursor -import android.graphics.drawable.ColorDrawable -import android.graphics.drawable.Drawable import android.location.LocationManager import android.net.Uri import android.os.Build @@ -37,18 +35,11 @@ import android.provider.Settings import android.text.SpannableStringBuilder import android.text.TextUtils import android.util.Log -import android.view.Gravity -import android.view.Menu -import android.view.MenuItem import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup import android.view.WindowManager import android.view.animation.AccelerateDecelerateInterpolator -import android.view.inputmethod.InputMethodManager -import android.widget.FrameLayout -import android.widget.ImageView -import android.widget.PopupMenu import android.widget.PopupWindow import android.widget.TextView import android.widget.Toast @@ -60,16 +51,10 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog -import androidx.appcompat.view.ContextThemeWrapper -import androidx.appcompat.widget.SearchView -import androidx.compose.foundation.background import androidx.compose.foundation.gestures.scrollBy -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -84,17 +69,11 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.unit.dp import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.content.PermissionChecker import androidx.core.content.PermissionChecker.PERMISSION_GRANTED -import androidx.core.graphics.drawable.toBitmap -import androidx.core.graphics.drawable.toDrawable import androidx.core.net.toUri import androidx.core.os.bundleOf import androidx.core.text.bold @@ -112,15 +91,9 @@ import androidx.work.OneTimeWorkRequest import androidx.work.WorkInfo import androidx.work.WorkManager import autodagger.AutoInjector -import coil.imageLoader -import coil.request.CachePolicy -import coil.request.ImageRequest -import coil.target.Target -import coil.transform.CircleCropTransformation import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.nextcloud.android.common.ui.color.ColorUtil -import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity @@ -134,6 +107,9 @@ import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.model.FileParameters import com.nextcloud.talk.chat.ui.ChatEmptyState import com.nextcloud.talk.chat.ui.ChatEmptyStateType +import com.nextcloud.talk.chat.ui.ChatToolbar +import com.nextcloud.talk.chat.ui.ChatToolbarCallbacks +import com.nextcloud.talk.chat.ui.ChatToolbarState import com.nextcloud.talk.chat.ui.MessageActionsBottomSheet import com.nextcloud.talk.chat.ui.ProfileModalBottomSheet import com.nextcloud.talk.chat.ui.ShowReactionsModalBottomSheet @@ -180,7 +156,6 @@ import com.nextcloud.talk.ui.OutOfOfficeView import com.nextcloud.talk.ui.OutOfOfficeViewData import com.nextcloud.talk.ui.PinnedMessageView import com.nextcloud.talk.ui.PlaybackSpeed -import com.nextcloud.talk.ui.StatusDrawable import com.nextcloud.talk.ui.UpcomingEventView import com.nextcloud.talk.ui.chat.ChatMessageCallbacks import com.nextcloud.talk.ui.chat.ChatView @@ -262,7 +237,6 @@ import java.util.Locale import java.util.concurrent.ExecutionException import javax.inject.Inject import kotlin.math.abs -import kotlin.math.roundToInt @Suppress("TooManyFunctions", "LargeClass", "LongMethod") @AutoInjector(NextcloudTalkApplication::class) @@ -310,14 +284,11 @@ class ChatActivity : lateinit var conversationInfoViewModel: ConversationInfoViewModel val messageInputViewModel: MessageInputViewModel by viewModels() - private var chatMenu: Menu? = null - - private var scheduledMessagesMenuItem: MenuItem? = null private var hasScheduledMessages: Boolean = false private var overflowMenuHostView: ComposeView? = null private var isThreadMenuExpanded by mutableStateOf(false) - private val searchLoadingState = mutableStateOf(false) + private var chatToolbarState by mutableStateOf(ChatToolbarState()) private val chatEmptyStateType = mutableStateOf(null) private val upcomingEventUiState = mutableStateOf(ChatViewModel.UpcomingEventUIState.None) @@ -398,10 +369,6 @@ class ChatActivity : var myFirstMessage: CharSequence? = null var checkingLobbyStatus: Boolean = false - private var conversationVoiceCallMenuItem: MenuItem? = null - private var conversationVideoMenuItem: MenuItem? = null - private var eventConversationMenuItem: MenuItem? = null - private var searchView: SearchView? = null private var lastHandledHighlightNonce: Long? = null private var pendingHighlightedMessageId: Long? = null private var lastNoMoreResultsToastTime: Long = 0L @@ -432,7 +399,7 @@ class ChatActivity : override fun handleOnBackPressed() { if (chatViewModel.chatMode.value == ChatViewModel.ChatMode.SEARCH_MODE) { chatViewModel.exitSearchMode() - invalidateOptionsMenu() + chatToolbarState = chatToolbarState.copy(isSearchMode = false, searchQuery = "") return } @@ -510,9 +477,9 @@ class ChatActivity : NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) binding = ActivityChatBinding.inflate(layoutInflater) - setupActionBar() setContentView(binding.root) + setupChatToolbarView() setupChatEmptyStateView() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { @@ -1242,10 +1209,11 @@ class ChatActivity : Log.d(TAG, "initObservers Called") lifecycleScope.launch { - chatViewModel.chatMode.collectLatest { - invalidateOptionsMenu() + chatViewModel.chatMode.collectLatest { mode -> + val inSearchMode = mode == ChatViewModel.ChatMode.SEARCH_MODE + updateToolbarForSearchMode(inSearchMode) updateSearchLoadingIndicator( - isLoading = it == ChatViewModel.ChatMode.SEARCH_MODE && chatViewModel.searchUiState.value.isLoading + isLoading = inSearchMode && chatViewModel.searchUiState.value.isLoading ) } } @@ -1328,9 +1296,7 @@ class ChatActivity : chatApiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) participantPermissions = ParticipantPermissions(spreedCapabilities, currentConversation!!) - invalidateOptionsMenu() - isEventConversation() - checkShowCallButtons() + updateToolbarState() checkLobbyState() updateRoomTimerHandler() } else { @@ -1359,18 +1325,9 @@ class ChatActivity : joinRoomWithPassword() - if (conversationUser?.userId != "?" && - hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MENTION_FLAG) && - !isChatThread() - ) { - binding.chatToolbar.setOnClickListener { _ -> showConversationInfoScreen() } - } refreshScheduledMessages() - loadAvatarForStatusBar() - setActionBarTitle() - isEventConversation() - checkShowCallButtons() + updateToolbarState() checkLobbyState() if (state.conversationModel.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL && state.conversationModel.status == "dnd" @@ -1567,13 +1524,13 @@ class ChatActivity : is ChatViewModel.ScheduledMessagesSuccessState -> { hasScheduledMessages = state.messages.isNotEmpty() messageInputFragment.updateScheduledMessagesAvailability(hasScheduledMessages) - invalidateOptionsMenu() + updateToolbarState() } is ChatViewModel.ScheduledMessagesErrorState -> { hasScheduledMessages = false messageInputFragment.updateScheduledMessagesAvailability(false) - invalidateOptionsMenu() + updateToolbarState() } else -> {} @@ -1711,7 +1668,7 @@ class ChatActivity : Snackbar.LENGTH_LONG ).show() - chatMenu?.removeItem(R.id.conversation_event) + chatToolbarState = chatToolbarState.copy(showEventMenu = false) } is ChatViewModel.UnbindRoomUiState.Error -> { @@ -1771,7 +1728,7 @@ class ChatActivity : is ChatViewModel.ThreadRetrieveUiState.Success -> { conversationThreadInfo = uiState.thread - invalidateOptionsMenu() + updateToolbarState() } } } @@ -1851,43 +1808,42 @@ class ChatActivity : chatViewModel.getRoom(roomToken) - actionBar?.show() - binding.let { viewThemeUtils.material.themeFAB(it.voiceRecordingLock) } - loadAvatarForStatusBar() - setActionBarTitle() - viewThemeUtils.material.colorToolbarOverflowIcon(binding.chatToolbar) + updateToolbarState() } - private fun setupActionBar() { - setSupportActionBar(binding.chatToolbar) - binding.chatToolbar.setNavigationOnClickListener { - onBackPressedDispatcher.onBackPressed() - } - supportActionBar?.setDisplayHomeAsUpEnabled(true) - supportActionBar?.setDisplayShowHomeEnabled(true) - supportActionBar?.setIcon(resources!!.getColor(R.color.transparent, null).toDrawable()) - setActionBarTitle() - viewThemeUtils.material.themeToolbar(binding.chatToolbar) - val toolbarBackgroundColorInt = (binding.chatToolbar.background as? ColorDrawable)?.color - binding.searchLoadingIndicatorComposeView.setContent { + private fun setupChatToolbarView() { + binding.chatToolbarComposeView.setContent { MaterialTheme(colorScheme = viewThemeUtils.getColorScheme(this@ChatActivity)) { - val isLoading by searchLoadingState - val appBarBackgroundColor = toolbarBackgroundColorInt?.let(::Color) ?: MaterialTheme.colorScheme.surface - Box( - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .background(appBarBackgroundColor) - ) { - if (isLoading) { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.primary, - trackColor = appBarBackgroundColor + CompositionLocalProvider(LocalViewThemeUtils provides viewThemeUtils) { + ChatToolbar( + state = chatToolbarState, + callbacks = ChatToolbarCallbacks( + onNavigateUp = { onBackPressedDispatcher.onBackPressed() }, + onTitleClick = { showConversationInfoScreen() }, + onVoiceCall = { startACall(true, false) }, + onSilentVoiceCall = { startACall(true, true) }, + onVideoCall = { startACall(false, false) }, + onSilentVideoCall = { startACall(false, true) }, + onSearchOpen = { startMessageSearch() }, + onSearchClose = { + chatViewModel.exitSearchMode() + chatToolbarState = chatToolbarState.copy(isSearchMode = false, searchQuery = "") + }, + onSearchQueryChange = { query -> + chatToolbarState = chatToolbarState.copy(searchQuery = query) + chatViewModel.onSearchQueryChanged(query) + }, + onSearchSubmit = { + chatViewModel.jumpToSearchSelection() + }, + onSearchPrevious = { chatViewModel.selectNextSearchResult() }, + onSearchNext = { chatViewModel.selectPreviousSearchResult() }, + onThreadNotification = { showThreadNotificationMenu() }, + onEventMenu = { showConversationEventMenu(binding.chatToolbarComposeView) } ) - } + ) } } } @@ -2005,79 +1961,136 @@ class ChatActivity : webSocketInstance != null && !CapabilitiesUtil.isTypingStatusPrivate(conversationUser!!) - private fun loadAvatarForStatusBar() { - if (currentConversation == null) { - return - } + fun updateToolbarState() { + val conversation = currentConversation + val user = conversationUser + val isOneToOne = isOneToOneConversation() + val capabilitiesReady = ::spreedCapabilities.isInitialized + + chatToolbarState = chatToolbarState.copy( + title = buildToolbarTitle(conversation), + subtitle = buildToolbarSubtitle(conversation), + avatarUrl = buildAvatarUrl(user, conversation, isOneToOne), + credentials = user?.let { ApiUtils.getCredentials(it.username, it.token) }, + userStatus = if (isOneToOne) conversation?.status else null, + showVoiceCall = isCallsEnabled(capabilitiesReady, conversation), + showVideoCall = isCallsEnabled(capabilitiesReady, conversation), + showSearch = isSearchAvailable(capabilitiesReady, conversation), + titleClickable = user?.userId != "?" && !chatToolbarState.isSearchMode, + overflowItems = buildOverflowItems(), + threadNotificationIcon = buildThreadNotificationIcon(capabilitiesReady), + showEventMenu = conversation?.objectType == ConversationEnums.ObjectType.EVENT, + supportsSilentCall = capabilitiesReady && + hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.SILENT_CALL) && + !isChatThread() + ) + } - if (isOneToOneConversation()) { - val url = ApiUtils.getUrlForAvatar( - conversationUser!!.baseUrl!!, - currentConversation!!.name, - true, - darkMode = DisplayUtils.isDarkModeOn(supportActionBar?.themedContext!!) - ) + private fun buildToolbarTitle(conversation: ConversationModel?): String = + when { + isChatThread() -> conversationThreadInfo?.thread?.title.orEmpty() + conversation?.displayName != null -> { + try { + EmojiCompat.get().process(conversation.displayName!! as CharSequence).toString() + } catch (e: IllegalStateException) { + Log.e(TAG, "buildToolbarTitle EmojiCompat processing failed", e) + conversation.displayName!! + } + } + else -> "" + } - val target = object : Target { + private fun buildAvatarUrl(user: User?, conversation: ConversationModel?, isOneToOne: Boolean): String? = + if (user != null && conversation != null && isOneToOne) { + ApiUtils.getUrlForAvatar(user.baseUrl!!, conversation.name, true, DisplayUtils.isDarkModeOn(this)) + } else { + null + } - private fun setIcon(drawable: Drawable?) { - supportActionBar?.let { - val toolbarAvatar = binding.chatToolbar.findViewById(R.id.chat_toolbar_avatar) - val toolbarStatus = binding.chatToolbar.findViewById(R.id.chat_toolbar_status) - val avatarContainer = - binding.chatToolbar.findViewById(R.id.chat_toolbar_avatar_container) - if (toolbarAvatar == null || toolbarStatus == null || avatarContainer == null) { - return - } + private fun isCallsEnabled(capabilitiesReady: Boolean, conversation: ConversationModel?): Boolean = + capabilitiesReady && + CapabilitiesUtil.isAbleToCall(spreedCapabilities) && + !isChatThread() && + !ConversationUtils.isNoteToSelfConversation(conversation) && + !isReadOnlyConversation() && + !shouldShowLobby() + + private fun isSearchAvailable(capabilitiesReady: Boolean, conversation: ConversationModel?): Boolean = + capabilitiesReady && + networkMonitor.isOnline.value && + hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.UNIFIED_SEARCH) && + conversation?.remoteServer.isNullOrEmpty() == true && + !isChatThread() + + private fun buildThreadNotificationIcon(capabilitiesReady: Boolean): Int? { + if (!isChatThread() || + !capabilitiesReady || + !hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.THREADS) + ) { + return null + } + return when (conversationThreadInfo?.attendee?.notificationLevel) { + NOTIFICATION_LEVEL_ALWAYS -> R.drawable.outline_notifications_active_24 + NOTIFICATION_LEVEL_NEVER -> R.drawable.ic_baseline_notifications_off_24 + else -> R.drawable.baseline_notifications_24 + } + } - val avatarSize = (it.height / TOOLBAR_AVATAR_RATIO).roundToInt() - val size = DisplayUtils.convertDpToPixel(STATUS_SIZE_IN_DP, context) - if (drawable != null && avatarSize > 0) { - val bitmap = drawable.toBitmap(avatarSize, avatarSize) - val status = StatusDrawable( - currentConversation!!.status, - null, - size, - 0, - binding.chatToolbar.context - ) - viewThemeUtils.talk.themeStatusDrawable(context, status) - toolbarAvatar.setImageDrawable(bitmap.toDrawable(resources)) - toolbarStatus.setImageDrawable(status) - toolbarStatus.contentDescription = currentConversation?.status - avatarContainer.visibility = View.VISIBLE - } else { - Log.d(TAG, "loadAvatarForStatusBar avatarSize <= 0") - } - } - } + private fun buildToolbarSubtitle(conversation: ConversationModel?): String = + when { + isChatThread() -> { + val count = conversationThreadInfo?.thread?.numReplies ?: 0 + resources.getQuantityString(R.plurals.thread_replies, count, count) + } + conversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL -> { + val icon = conversation.statusIcon.orEmpty() + val msg = conversation.statusMessage.orEmpty() + "$icon$msg" + } + conversation?.type == ConversationEnums.ConversationType.ROOM_GROUP_CALL || + conversation?.type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL -> + conversation.description.orEmpty() + else -> "" + } - override fun onStart(placeholder: Drawable?) { - this.setIcon(placeholder) - } + private fun buildOverflowItems(): List { + val items = mutableListOf() + val isThread = isChatThread() + val capabilitiesReady = ::spreedCapabilities.isInitialized - override fun onSuccess(result: Drawable) { - this.setIcon(result) - } + if (conversationUser?.userId != "?" && !isThread) { + items += MenuItemData( + title = getString(R.string.nc_conversation_menu_conversation_info), + onClick = { showConversationInfoScreen() } + ) + } + if (!isThread) { + items += MenuItemData( + title = getString(R.string.nc_shared_items), + onClick = { showSharedItems() } + ) + if (capabilitiesReady && hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.THREADS)) { + items += MenuItemData( + title = getString(R.string.recent_threads), + onClick = { openThreadsOverview() } + ) } - - val credentials = ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token) - if (credentials != null) { - context.imageLoader.enqueue( - ImageRequest.Builder(context) - .data(url) - .addHeader("Authorization", credentials) - .transformations(CircleCropTransformation()) - .crossfade(true) - .target(target) - .memoryCachePolicy(CachePolicy.DISABLED) - .diskCachePolicy(CachePolicy.DISABLED) - .build() + if (networkMonitor.isOnline.value && hasScheduledMessages) { + items += MenuItemData( + title = getString(R.string.nc_scheduled_messages), + icon = R.drawable.baseline_schedule_24, + onClick = { openScheduledMessages() } ) } - } else { - binding.chatToolbar.findViewById(R.id.chat_toolbar_avatar_container).visibility = View.GONE } + if (currentConversation?.objectType == ConversationEnums.ObjectType.FILE) { + items += MenuItemData( + title = getString(R.string.nc_conversation_menu_conversation_go_to_file), + icon = R.drawable.ic_file_24px, + onClick = { launchFileShareLink() } + ) + } + return items } fun isOneToOneConversation() = @@ -2136,35 +2149,6 @@ class ChatActivity : } } - private fun showCallButtonMenu(isVoiceOnlyCall: Boolean) { - val anchor: View? = if (isVoiceOnlyCall) { - findViewById(R.id.conversation_voice_call) - } else { - findViewById(R.id.conversation_video_call) - } - - if (anchor != null) { - val popupMenu = PopupMenu( - ContextThemeWrapper(this, R.style.CallButtonMenu), - anchor, - Gravity.END - ) - popupMenu.inflate(R.menu.chat_call_menu) - - popupMenu.setOnMenuItemClickListener { item: MenuItem -> - when (item.itemId) { - R.id.call_without_notification -> startACall(isVoiceOnlyCall, true) - } - true - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - popupMenu.setForceShowIcon(true) - } - popupMenu.show() - } - } - // override fun updateMediaPlayerProgressBySlider(message: ChatMessage, progress: Int) { // chatViewModel.seekToMediaPlayer(progress) // } @@ -2299,14 +2283,7 @@ class ChatActivity : } private fun checkShowCallButtons() { - if (isReadOnlyConversation() || - shouldShowLobby() || - ConversationUtils.isNoteToSelfConversation(currentConversation) - ) { - disableCallButtons() - } else { - enableCallButtons() - } + updateToolbarState() } private fun checkShowMessageInputView() { @@ -2330,41 +2307,8 @@ class ChatActivity : return false } - private fun disableCallButtons() { - if (CapabilitiesUtil.isAbleToCall(spreedCapabilities)) { - if (conversationVoiceCallMenuItem != null && conversationVideoMenuItem != null) { - conversationVoiceCallMenuItem?.icon?.alpha = SEMI_TRANSPARENT_INT - conversationVideoMenuItem?.icon?.alpha = SEMI_TRANSPARENT_INT - conversationVoiceCallMenuItem?.isEnabled = false - conversationVideoMenuItem?.isEnabled = false - } else { - Log.e(TAG, "call buttons were null when trying to disable them") - } - } - } - - private fun enableCallButtons() { - if (CapabilitiesUtil.isAbleToCall(spreedCapabilities)) { - if (conversationVoiceCallMenuItem != null && conversationVideoMenuItem != null) { - conversationVoiceCallMenuItem?.icon?.alpha = FULLY_OPAQUE_INT - conversationVideoMenuItem?.icon?.alpha = FULLY_OPAQUE_INT - conversationVoiceCallMenuItem?.isEnabled = true - conversationVideoMenuItem?.isEnabled = true - } else { - Log.e(TAG, "call buttons were null when trying to enable them") - } - } - } - private fun isEventConversation() { - if (currentConversation?.objectType == ConversationEnums.ObjectType.EVENT) { - if (eventConversationMenuItem != null) { - eventConversationMenuItem?.icon?.alpha = FULLY_OPAQUE_INT - eventConversationMenuItem?.isEnabled = true - } - } else { - eventConversationMenuItem?.isEnabled = false - } + updateToolbarState() } private fun isReadOnlyConversation(): Boolean = @@ -2917,74 +2861,11 @@ class ChatActivity : !ApplicationWideCurrentRoomHolder.getInstance().isInCall && !ApplicationWideCurrentRoomHolder.getInstance().isDialing - private fun setActionBarTitle() { - val title = binding.chatToolbar.findViewById(R.id.chat_toolbar_title) - if (title == null) { - Log.w(TAG, "setActionBarTitle: title view not found, skipping") - return - } - viewThemeUtils.platform.colorTextView(title, ColorRole.ON_SURFACE) - - title.text = - if (isChatThread()) { - conversationThreadInfo?.thread?.title - } else if (currentConversation?.displayName != null) { - try { - EmojiCompat.get().process(currentConversation?.displayName as CharSequence).toString() - } catch (e: java.lang.IllegalStateException) { - Log.e(TAG, "setActionBarTitle failed $e") - currentConversation?.displayName - } - } else { - "" - } - - if (isChatThread()) { - val replyAmount = conversationThreadInfo?.thread?.numReplies ?: 0 - val repliesAmountTitle = resources.getQuantityString( - R.plurals.thread_replies, - replyAmount, - replyAmount - ) - - statusMessageViewContents(repliesAmountTitle) - } else if (currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) { - var statusMessage = "" - if (currentConversation?.statusIcon != null) { - statusMessage += currentConversation?.statusIcon - } - if (currentConversation?.statusMessage != null) { - statusMessage += currentConversation?.statusMessage - } - statusMessageViewContents(statusMessage) - } else { - if (currentConversation?.type == ConversationEnums.ConversationType.ROOM_GROUP_CALL || - currentConversation?.type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL - ) { - var descriptionMessage = "" - descriptionMessage += currentConversation?.description - statusMessageViewContents(descriptionMessage) - } - } - } - - private fun statusMessageViewContents(statusMessageContent: String) { - val statusMessageView = binding.chatToolbar.findViewById(R.id.chat_toolbar_status_message) - if (statusMessageContent.isNotEmpty()) { - viewThemeUtils.platform.colorTextView(statusMessageView, ColorRole.ON_SURFACE) - statusMessageView.text = statusMessageContent - statusMessageView.visibility = View.VISIBLE - } else { - statusMessageView.visibility = View.GONE - } - } - private fun updateToolbarForSearchMode(isSearchMode: Boolean) { - if (isSearchMode) { - binding.chatToolbar.setOnClickListener(null) - } else { - binding.chatToolbar.setOnClickListener { _ -> showConversationInfoScreen() } - } + chatToolbarState = chatToolbarState.copy( + isSearchMode = isSearchMode, + titleClickable = !isSearchMode && conversationUser?.userId != "?" + ) } public override fun onDestroy() { @@ -3131,206 +3012,6 @@ class ChatActivity : chatViewModel.loadMoreMessages(messageId, direction) } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - super.onCreateOptionsMenu(menu) - menuInflater.inflate(R.menu.menu_conversation, menu) - chatMenu = menu - - scheduledMessagesMenuItem = menu.findItem(R.id.conversation_scheduled_messages) - - if (currentConversation?.objectType == ConversationEnums.ObjectType.EVENT) { - eventConversationMenuItem = menu.findItem(R.id.conversation_event) - } else { - menu.removeItem(R.id.conversation_event) - } - - if (conversationUser?.userId == "?") { - menu.removeItem(R.id.conversation_info) - } else { - loadAvatarForStatusBar() - setActionBarTitle() - } - - return true - } - - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - super.onPrepareOptionsMenu(menu) - - val inSearchMode = chatViewModel.chatMode.value == ChatViewModel.ChatMode.SEARCH_MODE - updateToolbarForSearchMode(inSearchMode) - - val searchItem = menu.findItem(R.id.conversation_search) - val previousSearchItem = menu.findItem(R.id.conversation_search_previous) - val nextSearchItem = menu.findItem(R.id.conversation_search_next) - - if (inSearchMode) { - val searchState = chatViewModel.searchUiState.value - conversationVoiceCallMenuItem?.isVisible = false - conversationVideoMenuItem?.isVisible = false - menu.findItem(R.id.shared_items)?.isVisible = false - menu.findItem(R.id.conversation_go_to_file)?.isVisible = false - menu.findItem(R.id.conversation_info)?.isVisible = false - menu.findItem(R.id.show_threads)?.isVisible = false - menu.findItem(R.id.thread_notifications)?.isVisible = false - menu.findItem(R.id.conversation_scheduled_messages)?.isVisible = false - menu.findItem(R.id.conversation_event)?.isVisible = false - - searchItem?.isVisible = true - previousSearchItem?.isVisible = true - nextSearchItem?.isVisible = true - configureSearchActionView(searchItem) - return true - } - - previousSearchItem?.isVisible = false - nextSearchItem?.isVisible = false - searchItem?.collapseActionView() - - if (this::spreedCapabilities.isInitialized) { - if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.READ_ONLY_ROOMS)) { - checkShowCallButtons() - } - - scheduledMessagesMenuItem?.isVisible = networkMonitor.isOnline.value && - hasScheduledMessages - - searchItem.isVisible = - hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.UNIFIED_SEARCH) && - currentConversation!!.remoteServer.isNullOrEmpty() && - !isChatThread() - - val sharedItemsItem = menu.findItem(R.id.shared_items) - sharedItemsItem.isVisible = !isChatThread() - - val conversationFileItem = menu.findItem(R.id.conversation_go_to_file) - conversationFileItem.isVisible = currentConversation?.objectType == ConversationEnums.ObjectType.FILE - - val conversationInfoItem = menu.findItem(R.id.conversation_info) - conversationInfoItem.isVisible = !isChatThread() - - val showThreadsItem = menu.findItem(R.id.show_threads) - showThreadsItem.isVisible = !isChatThread() && - hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.THREADS) - - if (CapabilitiesUtil.isAbleToCall(spreedCapabilities) && - !isChatThread() && - !ConversationUtils.isNoteToSelfConversation(currentConversation) - ) { - conversationVoiceCallMenuItem = menu.findItem(R.id.conversation_voice_call) - conversationVideoMenuItem = menu.findItem(R.id.conversation_video_call) - - this.lifecycleScope.launch { - networkMonitor.isOnline.onEach { isOnline -> - conversationVoiceCallMenuItem?.isVisible = isOnline - searchItem?.isVisible = isOnline - conversationVideoMenuItem?.isVisible = isOnline - }.collect() - } - - if (hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.SILENT_CALL)) { - Handler().post { - findViewById(R.id.conversation_voice_call)?.setOnLongClickListener { - showCallButtonMenu(true) - true - } - } - - Handler().post { - findViewById(R.id.conversation_video_call)?.setOnLongClickListener { - showCallButtonMenu(false) - true - } - } - } - } else { - menu.removeItem(R.id.conversation_video_call) - menu.removeItem(R.id.conversation_voice_call) - } - - handleThreadNotificationIcon(menu.findItem(R.id.thread_notifications)) - } - return true - } - - private fun handleThreadNotificationIcon(threadNotificationItem: MenuItem) { - threadNotificationItem.isVisible = isChatThread() && - hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.THREADS) - - val threadNotificationIcon = when (conversationThreadInfo?.attendee?.notificationLevel) { - NOTIFICATION_LEVEL_ALWAYS -> R.drawable.outline_notifications_active_24 - NOTIFICATION_LEVEL_NEVER -> R.drawable.ic_baseline_notifications_off_24 - else -> R.drawable.baseline_notifications_24 - } - threadNotificationItem.icon = ContextCompat.getDrawable(context, threadNotificationIcon) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - R.id.conversation_video_call -> { - startACall(false, false) - true - } - - R.id.conversation_voice_call -> { - startACall(true, false) - true - } - - R.id.conversation_go_to_file -> { - launchFileShareLink() - true - } - - R.id.conversation_info -> { - showConversationInfoScreen() - true - } - - R.id.shared_items -> { - showSharedItems() - true - } - - R.id.conversation_search -> { - startMessageSearch() - true - } - - R.id.conversation_search_previous -> { - chatViewModel.selectNextSearchResult() - true - } - - R.id.conversation_search_next -> { - chatViewModel.selectPreviousSearchResult() - true - } - - R.id.conversation_scheduled_messages -> { - openScheduledMessages() - true - } - - R.id.conversation_event -> { - val anchorView = findViewById(R.id.conversation_event) - showConversationEventMenu(anchorView) - true - } - - R.id.show_threads -> { - openThreadsOverview() - true - } - - R.id.thread_notifications -> { - showThreadNotificationMenu() - true - } - - else -> super.onOptionsItemSelected(item) - } - private fun launchFileShareLink() { val intent = Intent(Intent.ACTION_VIEW).apply { data = (conversationUser.baseUrl + "/f/" + currentConversation?.objectId).toUri() @@ -3662,64 +3343,14 @@ class ChatActivity : private fun startMessageSearch() { chatViewModel.enterSearchMode() - invalidateOptionsMenu() + chatToolbarState = chatToolbarState.copy( + isSearchMode = true, + searchQuery = chatViewModel.searchUiState.value.query + ) } private fun updateSearchLoadingIndicator(isLoading: Boolean) { - searchLoadingState.value = isLoading - } - - private fun configureSearchActionView(searchItem: MenuItem?) { - val actionView = searchItem?.actionView as? SearchView ?: return - searchView = actionView - searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { - override fun onMenuItemActionExpand(item: MenuItem): Boolean = true - - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - if (chatViewModel.chatMode.value == ChatViewModel.ChatMode.SEARCH_MODE) { - chatViewModel.exitSearchMode() - invalidateOptionsMenu() - } - return true - } - }) - searchItem.expandActionView() - actionView.queryHint = getString(R.string.message_search_hint) - actionView.isIconified = false - actionView.maxWidth = Int.MAX_VALUE - viewThemeUtils.talk.themeSearchView(actionView) - actionView.requestFocus() - window.decorView.post { - val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.showSoftInput(actionView.findFocus(), InputMethodManager.SHOW_IMPLICIT) - } - - val currentQuery = chatViewModel.searchUiState.value.query - if (actionView.query.toString() != currentQuery) { - actionView.setQuery(currentQuery, false) - } - - actionView.setOnCloseListener { - chatViewModel.exitSearchMode() - invalidateOptionsMenu() - true - } - - actionView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String?): Boolean { - chatViewModel.onSearchQueryChanged(query.orEmpty()) - chatViewModel.jumpToSearchSelection() - actionView.clearFocus() - val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.hideSoftInputFromWindow(actionView.windowToken, 0) - return true - } - - override fun onQueryTextChange(newText: String?): Boolean { - chatViewModel.onSearchQueryChanged(newText.orEmpty()) - return true - } - }) + chatToolbarState = chatToolbarState.copy(isLoading = isLoading) } private fun logSearchNavigation(message: String) { @@ -4497,11 +4128,7 @@ class ChatActivity : private const val REQUEST_CAMERA_PERMISSION = 223 private const val FILE_DATE_PATTERN = "yyyy-MM-dd HH-mm-ss" private const val VIDEO_SUFFIX = ".mp4" - private const val FULLY_OPAQUE_INT: Int = 255 - private const val SEMI_TRANSPARENT_INT: Int = 99 private const val VOICE_MESSAGE_SEEKBAR_BASE = 1000 - private const val TOOLBAR_AVATAR_RATIO = 1.5 - private const val STATUS_SIZE_IN_DP = 9f private const val HTTP_BAD_REQUEST = 400 private const val HTTP_FORBIDDEN = 403 private const val HTTP_NOT_FOUND = 404 diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbar.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbar.kt new file mode 100644 index 0000000000..b0ed6133d9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbar.kt @@ -0,0 +1,477 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.ui + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.MenuItemData + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun ChatToolbar(state: ChatToolbarState, callbacks: ChatToolbarCallbacks, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + TopAppBar( + navigationIcon = { + IconButton(onClick = callbacks.onNavigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back_button)) + } + }, + title = { + if (state.isSearchMode) { + SearchField(state = state, callbacks = callbacks) + } else { + ConversationHeader(state = state, onClick = callbacks.onTitleClick.takeIf { state.titleClickable }) + } + }, + actions = { ToolbarActions(state = state, callbacks = callbacks) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurface + ), + windowInsets = WindowInsets(0, 0, 0, 0) + ) + + if (state.isLoading) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surface + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ToolbarActions(state: ChatToolbarState, callbacks: ChatToolbarCallbacks) { + if (state.isSearchMode) { + IconButton(onClick = callbacks.onSearchPrevious) { + Icon( + painterResource(R.drawable.ic_keyboard_arrow_up), + stringResource(R.string.message_search_previous_result) + ) + } + IconButton(onClick = callbacks.onSearchNext) { + Icon( + painterResource(R.drawable.ic_keyboard_arrow_down), + stringResource(R.string.message_search_next_result) + ) + } + IconButton(onClick = callbacks.onSearchClose) { + Icon(painterResource(R.drawable.ic_clear_24), stringResource(R.string.close_icon)) + } + } else { + if (state.threadNotificationIcon != null) { + IconButton(onClick = callbacks.onThreadNotification) { + Icon(painterResource(state.threadNotificationIcon), stringResource(R.string.thread_notifications)) + } + } + if (state.showEventMenu) { + IconButton(onClick = callbacks.onEventMenu) { + Icon( + painterResource(R.drawable.baseline_calendar_today_24), + stringResource(R.string.nc_event_conversation_menu) + ) + } + } + if (state.showVoiceCall) { + CallButton( + iconRes = R.drawable.ic_call_white_24dp, + contentDescription = stringResource(R.string.nc_conversation_menu_voice_call), + onClick = callbacks.onVoiceCall, + onLongClick = callbacks.onSilentVoiceCall.takeIf { state.supportsSilentCall } + ) + } + if (state.showVideoCall) { + CallButton( + iconRes = R.drawable.ic_videocam_white_24px, + contentDescription = stringResource(R.string.nc_conversation_menu_video_call), + onClick = callbacks.onVideoCall, + onLongClick = callbacks.onSilentVideoCall.takeIf { state.supportsSilentCall } + ) + } + if (state.showSearch) { + IconButton(onClick = callbacks.onSearchOpen) { + Icon(painterResource(R.drawable.ic_search_white_24dp), stringResource(R.string.nc_search)) + } + } + if (state.overflowItems.isNotEmpty()) { + OverflowMenuButton(items = state.overflowItems) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun CallButton(iconRes: Int, contentDescription: String, onClick: () -> Unit, onLongClick: (() -> Unit)?) { + Box( + modifier = Modifier + .size(48.dp) + .semantics { role = Role.Button } + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +private fun OverflowMenuButton(items: List) { + var expanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_more_vert_24px), + contentDescription = stringResource(R.string.nc_common_more) + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + items.forEach { item -> + DropdownMenuItem( + text = { Text(item.title) }, + onClick = { + item.onClick() + expanded = false + }, + leadingIcon = item.icon?.let { iconRes -> + { Icon(painterResource(iconRes), contentDescription = null) } + } + ) + } + } + } +} + +@Composable +private fun ConversationHeader(state: ChatToolbarState, onClick: (() -> Unit)?) { + val rowModifier = if (onClick != null) { + Modifier.combinedClickable(onClick = onClick) + } else { + Modifier + } + Row( + modifier = rowModifier, + verticalAlignment = Alignment.CenterVertically + ) { + if (state.avatarUrl != null) { + ConversationAvatar( + avatarUrl = state.avatarUrl, + credentials = state.credentials, + userStatus = state.userStatus, + modifier = Modifier.size(42.dp) + ) + Spacer(Modifier.width(8.dp)) + } + Column { + Text( + text = state.title, + fontSize = 18.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + if (state.subtitle.isNotEmpty()) { + Text( + text = state.subtitle, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +@Composable +private fun ConversationAvatar( + avatarUrl: String, + credentials: String?, + userStatus: String?, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + val context = LocalContext.current + val request = ImageRequest.Builder(context) + .data(avatarUrl) + .apply { credentials?.let { addHeader("Authorization", it) } } + .crossfade(true) + .build() + + AsyncImage( + model = request, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + ) + + if (userStatus != null) { + UserStatusBadge( + status = userStatus, + modifier = Modifier + .size(18.dp) + .align(Alignment.BottomEnd) + ) + } + } +} + +@Composable +private fun UserStatusBadge(status: String, modifier: Modifier = Modifier) { + val iconRes = when (status) { + "online" -> R.drawable.online_status + "away" -> R.drawable.ic_user_status_away + "busy" -> R.drawable.ic_user_status_busy + "dnd" -> R.drawable.ic_user_status_dnd + else -> null + } + if (iconRes != null) { + Image( + painter = painterResource(iconRes), + contentDescription = null, + modifier = modifier.clip(CircleShape), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.surface, BlendMode.DstOver) + ) + } +} + +@Composable +private fun SearchField(state: ChatToolbarState, callbacks: ChatToolbarCallbacks) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + BasicTextField( + value = state.searchQuery, + onValueChange = callbacks.onSearchQueryChange, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + singleLine = true, + textStyle = TextStyle( + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { callbacks.onSearchSubmit() }), + decorationBox = { innerTextField -> + if (state.searchQuery.isEmpty()) { + Text( + text = stringResource(R.string.message_search_hint), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + fontSize = 16.sp + ) + } + innerTextField() + } + ) +} + +private const val PREVIEW_WIDTH_DP = 360 + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "Normal mode · Light", widthDp = PREVIEW_WIDTH_DP) +@Preview(name = "Normal mode · Dark", widthDp = PREVIEW_WIDTH_DP, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun NormalModePreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + ChatToolbar( + state = ChatToolbarState( + title = "Alice", + subtitle = "🌵 On a trip", + avatarUrl = null, + userStatus = "online", + showVoiceCall = true, + showVideoCall = true, + showSearch = true, + overflowItems = listOf( + MenuItemData(title = "Conversation info", onClick = {}), + MenuItemData(title = "Shared items", onClick = {}) + ), + titleClickable = true + ), + callbacks = ChatToolbarCallbacks() + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "Normal mode · RTL / Arabic", widthDp = PREVIEW_WIDTH_DP, locale = "ar") +@Composable +private fun NormalModeRtlPreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + Surface { + ChatToolbar( + state = ChatToolbarState( + title = "أليس", + subtitle = "متصل", + showVoiceCall = true, + showVideoCall = true + ), + callbacks = ChatToolbarCallbacks() + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "Search mode · Light", widthDp = PREVIEW_WIDTH_DP) +@Preview(name = "Search mode · Dark", widthDp = PREVIEW_WIDTH_DP, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun SearchModePreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + ChatToolbar( + state = ChatToolbarState( + title = "Alice", + isSearchMode = true, + searchQuery = "hello" + ), + callbacks = ChatToolbarCallbacks() + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "Search mode · RTL / Arabic", widthDp = PREVIEW_WIDTH_DP, locale = "ar") +@Composable +private fun SearchModeRtlPreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + Surface { + ChatToolbar( + state = ChatToolbarState( + title = "أليس", + isSearchMode = true, + searchQuery = "" + ), + callbacks = ChatToolbarCallbacks() + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "Loading · Light", widthDp = PREVIEW_WIDTH_DP) +@Preview(name = "Loading · Dark", widthDp = PREVIEW_WIDTH_DP, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun LoadingPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + ChatToolbar( + state = ChatToolbarState( + title = "Team chat", + isSearchMode = true, + isLoading = true + ), + callbacks = ChatToolbarCallbacks() + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(name = "Thread view · Light", widthDp = PREVIEW_WIDTH_DP) +@Preview(name = "Thread view · Dark", widthDp = PREVIEW_WIDTH_DP, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ThreadViewPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + ChatToolbar( + state = ChatToolbarState( + title = "Q3 planning discussion", + subtitle = "12 replies", + threadNotificationIcon = R.drawable.baseline_notifications_24 + ), + callbacks = ChatToolbarCallbacks() + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbarCallbacks.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbarCallbacks.kt new file mode 100644 index 0000000000..782e27f33d --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbarCallbacks.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.ui + +data class ChatToolbarCallbacks( + val onNavigateUp: () -> Unit = {}, + val onTitleClick: () -> Unit = {}, + val onVoiceCall: () -> Unit = {}, + val onSilentVoiceCall: () -> Unit = {}, + val onVideoCall: () -> Unit = {}, + val onSilentVideoCall: () -> Unit = {}, + val onSearchOpen: () -> Unit = {}, + val onSearchClose: () -> Unit = {}, + val onSearchQueryChange: (String) -> Unit = {}, + val onSearchSubmit: () -> Unit = {}, + val onSearchPrevious: () -> Unit = {}, + val onSearchNext: () -> Unit = {}, + val onThreadNotification: () -> Unit = {}, + val onEventMenu: () -> Unit = {} +) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbarState.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbarState.kt new file mode 100644 index 0000000000..2b7d2dfa71 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbarState.kt @@ -0,0 +1,36 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.ui + +import com.nextcloud.talk.chat.MenuItemData + +data class ChatToolbarState( + val title: String = "", + val subtitle: String = "", + /** Non-null only for 1-to-1 conversations; drives avatar display. */ + val avatarUrl: String? = null, + /** HTTP Basic / Bearer credential string for the avatar request. */ + val credentials: String? = null, + /** "online" | "away" | "busy" | "dnd" — drives the status badge. Null = hide badge. */ + val userStatus: String? = null, + val isSearchMode: Boolean = false, + val isLoading: Boolean = false, + val showVoiceCall: Boolean = false, + val showVideoCall: Boolean = false, + /** Whether the server supports message search for this conversation. */ + val showSearch: Boolean = false, + val searchQuery: String = "", + val overflowItems: List = emptyList(), + /** Non-null in thread view; the drawable resource for the current notification level. */ + val threadNotificationIcon: Int? = null, + val showEventMenu: Boolean = false, + /** Whether tapping the title area should open conversation info. */ + val titleClickable: Boolean = false, + /** Whether the server capability SILENT_CALL is available (enables long-press on call buttons). */ + val supportsSilentCall: Boolean = false +) diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index 7f8c2ac5d8..f09ee379cd 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -17,85 +17,10 @@ android:orientation="vertical" tools:ignore="Overdraw"> - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="wrap_content" /> Copy Copied to clipboard More options + More Add to home screen From ad5139d210aac444debfc7be43a9082acb73bd79 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 2 Jun 2026 09:18:43 +0200 Subject: [PATCH 3/8] style: migrate typing indicator to Composable AI-assistant: Claude Code v2.1.152 (Claude Sonnet 4.6) Signed-off-by: Andy Scherzinger --- .../com/nextcloud/talk/chat/ChatActivity.kt | 95 +------ .../talk/chat/ui/TypingIndicatorBanner.kt | 242 ++++++++++++++++++ app/src/main/res/layout/activity_chat.xml | 23 +- 3 files changed, 258 insertions(+), 102 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/chat/ui/TypingIndicatorBanner.kt diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 13900a5c4c..6aeaf751b0 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -32,14 +32,12 @@ import android.os.SystemClock import android.provider.ContactsContract import android.provider.MediaStore import android.provider.Settings -import android.text.SpannableStringBuilder import android.text.TextUtils import android.util.Log import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup import android.view.WindowManager -import android.view.animation.AccelerateDecelerateInterpolator import android.widget.PopupWindow import android.widget.TextView import android.widget.Toast @@ -76,7 +74,6 @@ import androidx.core.content.PermissionChecker import androidx.core.content.PermissionChecker.PERMISSION_GRANTED import androidx.core.net.toUri import androidx.core.os.bundleOf -import androidx.core.text.bold import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.emoji2.text.EmojiCompat @@ -110,6 +107,7 @@ import com.nextcloud.talk.chat.ui.ChatEmptyStateType import com.nextcloud.talk.chat.ui.ChatToolbar import com.nextcloud.talk.chat.ui.ChatToolbarCallbacks import com.nextcloud.talk.chat.ui.ChatToolbarState +import com.nextcloud.talk.chat.ui.TypingIndicatorBanner import com.nextcloud.talk.chat.ui.MessageActionsBottomSheet import com.nextcloud.talk.chat.ui.ProfileModalBottomSheet import com.nextcloud.talk.chat.ui.ShowReactionsModalBottomSheet @@ -289,6 +287,7 @@ class ChatActivity : private var overflowMenuHostView: ComposeView? = null private var isThreadMenuExpanded by mutableStateOf(false) private var chatToolbarState by mutableStateOf(ChatToolbarState()) + private var typingParticipantNames by mutableStateOf>(emptyList()) private val chatEmptyStateType = mutableStateOf(null) private val upcomingEventUiState = mutableStateOf(ChatViewModel.UpcomingEventUIState.None) @@ -481,6 +480,7 @@ class ChatActivity : setupChatToolbarView() setupChatEmptyStateView() + setupTypingIndicatorView() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { ViewCompat.setOnApplyWindowInsetsListener(binding.chatContainer) { view, insets -> @@ -1881,80 +1881,9 @@ class ChatActivity : chatViewModel.syncVoiceMessageUiState(message) } - @Suppress("MagicNumber", "LongMethod") private fun updateTypingIndicator() { - fun ellipsize(text: String): String = DisplayUtils.ellipsize(text, TYPING_INDICATOR_MAX_NAME_LENGTH) - - val participantNames = ArrayList() - - for (typingParticipant in typingParticipants.values) { - participantNames.add(typingParticipant.name) - } - - val typingString: SpannableStringBuilder - when (typingParticipants.size) { - 0 -> typingString = SpannableStringBuilder().append(binding.typingIndicator.text) - - // person1 is typing - 1 -> typingString = SpannableStringBuilder() - .bold { append(ellipsize(participantNames[0])) } - .append(WHITESPACE + context.resources?.getString(R.string.typing_is_typing)) - - // person1 and person2 are typing - 2 -> typingString = SpannableStringBuilder() - .bold { append(ellipsize(participantNames[0])) } - .append(WHITESPACE + context.resources?.getString(R.string.nc_common_and) + WHITESPACE) - .bold { append(ellipsize(participantNames[1])) } - .append(WHITESPACE + context.resources?.getString(R.string.typing_are_typing)) - - // person1, person2 and person3 are typing - 3 -> typingString = SpannableStringBuilder() - .bold { append(ellipsize(participantNames[0])) } - .append(COMMA) - .bold { append(ellipsize(participantNames[1])) } - .append(WHITESPACE + context.resources?.getString(R.string.nc_common_and) + WHITESPACE) - .bold { append(ellipsize(participantNames[2])) } - .append(WHITESPACE + context.resources?.getString(R.string.typing_are_typing)) - - // person1, person2, person3 and 1 other is typing - 4 -> typingString = SpannableStringBuilder() - .bold { append(participantNames[0]) } - .append(COMMA) - .bold { append(participantNames[1]) } - .append(COMMA) - .bold { append(participantNames[2]) } - .append(WHITESPACE + context.resources?.getString(R.string.typing_1_other)) - - // person1, person2, person3 and x others are typing - else -> { - val moreTypersAmount = typingParticipants.size - 3 - val othersTyping = context.resources?.getString(R.string.typing_x_others)?.let { - String.format(it, moreTypersAmount) - } - typingString = SpannableStringBuilder() - .bold { append(participantNames[0]) } - .append(COMMA) - .bold { append(participantNames[1]) } - .append(COMMA) - .bold { append(participantNames[2]) } - .append(othersTyping) - } - } - - runOnUiThread { - binding.typingIndicator.text = typingString - - val typingIndicatorPositionY = if (participantNames.size > 0) { - TYPING_INDICATOR_POSITION_VISIBLE - } else { - TYPING_INDICATOR_POSITION_HIDDEN - } - - binding.typingIndicatorWrapper.animate() - .translationY(DisplayUtils.convertDpToPixel(typingIndicatorPositionY, context)) - .setInterpolator(AccelerateDecelerateInterpolator()) - .duration = TYPING_INDICATOR_ANIMATION_DURATION - } + val names = typingParticipants.values.map { it.name } + runOnUiThread { typingParticipantNames = names } } private fun isTypingStatusEnabled(): Boolean = @@ -2316,6 +2245,14 @@ class ChatActivity : currentConversation?.conversationReadOnlyState == ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_ONLY + private fun setupTypingIndicatorView() { + binding.typingIndicatorComposeView.setContent { + MaterialTheme(colorScheme = viewThemeUtils.getColorScheme(this@ChatActivity)) { + TypingIndicatorBanner(names = typingParticipantNames) + } + } + } + private fun setupChatEmptyStateView() { binding.chatEmptyStateComposeView.setContent { val type by chatEmptyStateType @@ -4140,12 +4077,6 @@ class ChatActivity : private const val NOTIFICATION_LEVEL_MENTION_AND_CALLS = 2 private const val NOTIFICATION_LEVEL_NEVER = 3 private const val ONE_SECOND_IN_MILLIS = 1000 - private const val WHITESPACE = " " - private const val COMMA = ", " - private const val TYPING_INDICATOR_ANIMATION_DURATION = 200L - private const val TYPING_INDICATOR_MAX_NAME_LENGTH = 14 - private const val TYPING_INDICATOR_POSITION_VISIBLE = -18f - private const val TYPING_INDICATOR_POSITION_HIDDEN = -1f private const val MILLISEC_15: Long = 15 private const val CURRENT_AUDIO_MESSAGE_KEY = "CURRENT_AUDIO_MESSAGE" private const val CURRENT_AUDIO_POSITION_KEY = "CURRENT_AUDIO_POSITION" diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/TypingIndicatorBanner.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/TypingIndicatorBanner.kt new file mode 100644 index 0000000000..17fc663843 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/TypingIndicatorBanner.kt @@ -0,0 +1,242 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextcloud.talk.R +import com.nextcloud.talk.utils.DisplayUtils + +private const val MAX_NAME_LENGTH = 14 +private const val ANIMATION_DURATION_MS = 200 +private const val MAX_NAMED_PARTICIPANTS = 3 +private const val FOUR_PARTICIPANTS = 4 + +private data class TypingStrings( + val isTyping: String, + val areTyping: String, + val and: String, + val typing1Other: String, + val typingXOthers: String +) + +@Composable +fun TypingIndicatorBanner(names: List, modifier: Modifier = Modifier) { + val strings = TypingStrings( + isTyping = stringResource(R.string.typing_is_typing), + areTyping = stringResource(R.string.typing_are_typing), + and = stringResource(R.string.nc_common_and), + typing1Other = stringResource(R.string.typing_1_other), + typingXOthers = stringResource(R.string.typing_x_others) + ) + + val text = remember(names, strings) { + buildTypingAnnotatedString(names, strings) + } + + AnimatedVisibility( + visible = names.isNotEmpty(), + modifier = modifier, + enter = slideInVertically(tween(ANIMATION_DURATION_MS)) { it }, + exit = slideOutVertically(tween(ANIMATION_DURATION_MS)) { it } + ) { + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 16.dp, vertical = 1.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +private fun buildTypingAnnotatedString(names: List, s: TypingStrings): AnnotatedString { + fun ellipsize(name: String) = DisplayUtils.ellipsize(name, MAX_NAME_LENGTH) + fun bold(builder: AnnotatedString.Builder, text: String) { + builder.withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text) } + } + + return buildAnnotatedString { + when (names.size) { + 0 -> Unit + + // Alice is typing … + 1 -> { + bold(this, ellipsize(names[0])) + append(" ${s.isTyping}") + } + + // Alice and Bob are typing … + 2 -> { + bold(this, ellipsize(names[0])) + append(" ${s.and} ") + bold(this, ellipsize(names[1])) + append(" ${s.areTyping}") + } + + // Alice, Bob and Carol are typing … + MAX_NAMED_PARTICIPANTS -> { + bold(this, ellipsize(names[0])) + append(", ") + bold(this, ellipsize(names[1])) + append(" ${s.and} ") + bold(this, ellipsize(names[2])) + append(" ${s.areTyping}") + } + + // Alice, Bob, Carol and 1 other is typing … + FOUR_PARTICIPANTS -> { + bold(this, names[0]) + append(", ") + bold(this, names[1]) + append(", ") + bold(this, names[2]) + append(" ${s.typing1Other}") + } + + // Alice, Bob, Carol and N others are typing … + else -> { + val moreAmount = names.size - MAX_NAMED_PARTICIPANTS + bold(this, names[0]) + append(", ") + bold(this, names[1]) + append(", ") + bold(this, names[2]) + append(" ${String.format(s.typingXOthers, moreAmount)}") + } + } + } +} + +private const val PREVIEW_WIDTH_DP = 360 + +@Preview(name = "1 participant · Light", widthDp = PREVIEW_WIDTH_DP) +@Preview(name = "1 participant · Dark", widthDp = PREVIEW_WIDTH_DP, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun OneParticipantPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + TypingIndicatorBanner(names = listOf("Marcel")) + } + } +} + +@Preview(name = "1 participant · RTL / Arabic", widthDp = PREVIEW_WIDTH_DP, locale = "ar") +@Composable +private fun OneParticipantRtlPreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + Surface { + TypingIndicatorBanner(names = listOf("مارسيل")) + } + } +} + +@Preview(name = "2 participants · Light", widthDp = PREVIEW_WIDTH_DP) +@Preview(name = "2 participants · Dark", widthDp = PREVIEW_WIDTH_DP, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TwoParticipantsPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + TypingIndicatorBanner(names = listOf("Marcel", "Julius")) + } + } +} + +@Preview(name = "2 participants · RTL / Arabic", widthDp = PREVIEW_WIDTH_DP, locale = "ar") +@Composable +private fun TwoParticipantsRtlPreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + Surface { + TypingIndicatorBanner(names = listOf("مارسيل", "يوليوس")) + } + } +} + +@Preview(name = "3 participants · Light", widthDp = PREVIEW_WIDTH_DP) +@Preview(name = "3 participants · Dark", widthDp = PREVIEW_WIDTH_DP, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ThreeParticipantsPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + TypingIndicatorBanner(names = listOf("Marcel", "Julius", "Andy")) + } + } +} + +@Preview(name = "3 participants · RTL / Arabic", widthDp = PREVIEW_WIDTH_DP, locale = "ar") +@Composable +private fun ThreeParticipantsRtlPreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + Surface { + TypingIndicatorBanner(names = listOf("مارسيل", "يوليوس", "أندي")) + } + } +} + +@Preview(name = "5+ participants · Light", widthDp = PREVIEW_WIDTH_DP) +@Preview(name = "5+ participants · Dark", widthDp = PREVIEW_WIDTH_DP, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ManyParticipantsPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + Surface { + TypingIndicatorBanner(names = listOf("Marcel", "Julius", "Andy", "Alice", "Bob")) + } + } +} + +@Preview(name = "5+ participants · RTL / Arabic", widthDp = PREVIEW_WIDTH_DP, locale = "ar") +@Composable +private fun ManyParticipantsRtlPreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + Surface { + TypingIndicatorBanner(names = listOf("مارسيل", "يوليوس", "أندي", "أليس", "بوب")) + } + } +} + +@Preview(name = "Hidden (no one typing) · Light", widthDp = PREVIEW_WIDTH_DP) +@Composable +private fun HiddenPreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + Surface { + TypingIndicatorBanner(names = emptyList()) + } + } +} diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index f09ee379cd..5b42861611 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -117,28 +117,11 @@ app:fabCustomSize="@dimen/min_size_clickable_area" app:srcCompat="@drawable/ic_lock_open_grey600_24dp" /> - - - - - + android:layout_alignParentBottom="true" /> From c339fd0928111607f9b873e82e215b76f2d9eaa5 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 2 Jun 2026 11:38:03 +0200 Subject: [PATCH 4/8] fix to hide messages in lobby AI-assistant: Claude Code v2.1.142 (Claude Sonnet 4.6) Signed-off-by: Marcel Hibbe --- app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 6aeaf751b0..a0a91727bf 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -752,7 +752,10 @@ class ChatActivity : val chatMode by chatViewModel.chatMode.collectAsStateWithLifecycle() currentConversation = uiState.conversation - binding.messagesListViewCompose.visibility = View.VISIBLE + val isLobbyViewActive = chatEmptyStateType.value is ChatEmptyStateType.Lobby + if (!isLobbyViewActive) { + binding.messagesListViewCompose.visibility = View.VISIBLE + } val listState = rememberLazyListState() val composeScope = rememberCoroutineScope() @@ -2268,6 +2271,7 @@ class ChatActivity : showLobbyView() } else { binding.chatEmptyStateComposeView.visibility = View.GONE + binding.messagesListViewCompose.visibility = View.VISIBLE chatEmptyStateType.value = null checkShowMessageInputView() } @@ -2275,6 +2279,7 @@ class ChatActivity : private fun showLobbyView() { binding.chatEmptyStateComposeView.visibility = View.VISIBLE + binding.messagesListViewCompose.visibility = View.GONE binding.fragmentContainerActivityChat.visibility = View.GONE val sb = StringBuilder() From c074fe0f0a4d9cd8b8913d1a864d896ef54aacc4 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 2 Jun 2026 22:05:33 +0200 Subject: [PATCH 5/8] add onLongClick menu for call buttons Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ui/ChatToolbar.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbar.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbar.kt index b0ed6133d9..587a001eaf 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbar.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbar.kt @@ -174,13 +174,14 @@ private fun ToolbarActions(state: ChatToolbarState, callbacks: ChatToolbarCallba @OptIn(ExperimentalFoundationApi::class) @Composable private fun CallButton(iconRes: Int, contentDescription: String, onClick: () -> Unit, onLongClick: (() -> Unit)?) { + var showMenu by remember { mutableStateOf(false) } Box( modifier = Modifier .size(48.dp) .semantics { role = Role.Button } .combinedClickable( onClick = onClick, - onLongClick = onLongClick + onLongClick = if (onLongClick != null) ({ showMenu = true }) else null ), contentAlignment = Alignment.Center ) { @@ -189,6 +190,20 @@ private fun CallButton(iconRes: Int, contentDescription: String, onClick: () -> contentDescription = contentDescription, tint = MaterialTheme.colorScheme.onSurface ) + if (onLongClick != null) { + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.call_without_notification)) }, + onClick = { + onLongClick() + showMenu = false + }, + leadingIcon = { + Icon(painterResource(R.drawable.ic_baseline_notifications_off_24), contentDescription = null) + } + ) + } + } } } From 05ed1e3c707254538045f0c5681f8ef485cda330 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 2 Jun 2026 22:12:35 +0200 Subject: [PATCH 6/8] remove unused resources Signed-off-by: Marcel Hibbe --- app/src/main/res/layout/lobby_view.xml | 38 ------ .../res/layout/no_saved_messages_view.xml | 37 ------ app/src/main/res/menu/chat_call_menu.xml | 13 -- app/src/main/res/menu/menu_conversation.xml | 118 ------------------ 4 files changed, 206 deletions(-) delete mode 100644 app/src/main/res/layout/lobby_view.xml delete mode 100644 app/src/main/res/layout/no_saved_messages_view.xml delete mode 100644 app/src/main/res/menu/chat_call_menu.xml delete mode 100644 app/src/main/res/menu/menu_conversation.xml diff --git a/app/src/main/res/layout/lobby_view.xml b/app/src/main/res/layout/lobby_view.xml deleted file mode 100644 index c86a67053d..0000000000 --- a/app/src/main/res/layout/lobby_view.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/no_saved_messages_view.xml b/app/src/main/res/layout/no_saved_messages_view.xml deleted file mode 100644 index da22be860c..0000000000 --- a/app/src/main/res/layout/no_saved_messages_view.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/menu/chat_call_menu.xml b/app/src/main/res/menu/chat_call_menu.xml deleted file mode 100644 index 2e7c5b16d2..0000000000 --- a/app/src/main/res/menu/chat_call_menu.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/app/src/main/res/menu/menu_conversation.xml b/app/src/main/res/menu/menu_conversation.xml deleted file mode 100644 index 279e5196e4..0000000000 --- a/app/src/main/res/menu/menu_conversation.xml +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 8b60cf34c65c7bd5599f8fb8c9bb85a171b2ebf4 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 2 Jun 2026 22:34:23 +0200 Subject: [PATCH 7/8] reimplement threads toolbar menu AI-assistant: Claude Code v2.1.142 (Claude Sonnet 4.6) Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 92 ++----------------- .../com/nextcloud/talk/chat/ui/ChatToolbar.kt | 52 ++++++++++- .../talk/chat/ui/ChatToolbarCallbacks.kt | 2 +- 3 files changed, 60 insertions(+), 86 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index a0a91727bf..174bfa04d8 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -67,8 +67,6 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.ComposeView -import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.FileProvider import androidx.core.content.PermissionChecker import androidx.core.content.PermissionChecker.PERMISSION_GRANTED @@ -284,8 +282,6 @@ class ChatActivity : private var hasScheduledMessages: Boolean = false - private var overflowMenuHostView: ComposeView? = null - private var isThreadMenuExpanded by mutableStateOf(false) private var chatToolbarState by mutableStateOf(ChatToolbarState()) private var typingParticipantNames by mutableStateOf>(emptyList()) private val chatEmptyStateType = mutableStateOf(null) @@ -1843,7 +1839,7 @@ class ChatActivity : }, onSearchPrevious = { chatViewModel.selectNextSearchResult() }, onSearchNext = { chatViewModel.selectPreviousSearchResult() }, - onThreadNotification = { showThreadNotificationMenu() }, + onThreadNotificationLevelChange = { level -> setThreadNotificationLevel(level) }, onEventMenu = { showConversationEventMenu(binding.chatToolbarComposeView) } ) ) @@ -3031,84 +3027,14 @@ class ChatActivity : ) } - @Suppress("Detekt.LongMethod") - private fun showThreadNotificationMenu() { - fun setThreadNotificationLevel(level: Int) { - val threadNotificationUrl = ApiUtils.getUrlForThreadNotificationLevel( - version = 1, - baseUrl = conversationUser!!.baseUrl, - token = roomToken, - threadId = conversationThreadId!!.toInt() - ) - chatViewModel.setThreadNotificationLevel(credentials!!, threadNotificationUrl, level) - } - - if (overflowMenuHostView == null) { - val threadNotificationsAnchor: View? = findViewById(R.id.thread_notifications) - - val colorScheme = viewThemeUtils.getColorScheme(this) - - overflowMenuHostView = ComposeView(this).apply { - setContent { - MaterialTheme( - colorScheme = colorScheme - ) { - val items = listOf( - MenuItemData( - title = context.resources.getString(R.string.notifications_default), - subtitle = context.resources.getString( - R.string.notifications_default_description - ), - icon = R.drawable.baseline_notifications_24, - onClick = { - setThreadNotificationLevel(0) - } - ), - MenuItemData( - title = context.resources.getString(R.string.notification_all_messages), - subtitle = null, - icon = R.drawable.outline_notifications_active_24, - onClick = { - setThreadNotificationLevel(NOTIFICATION_LEVEL_ALWAYS) - } - ), - MenuItemData( - title = context.resources.getString(R.string.notification_mention_only), - subtitle = null, - icon = R.drawable.baseline_notifications_24, - onClick = { - setThreadNotificationLevel(NOTIFICATION_LEVEL_MENTION_AND_CALLS) - } - ), - MenuItemData( - title = context.resources.getString(R.string.notification_off), - subtitle = null, - icon = R.drawable.ic_baseline_notifications_off_24, - onClick = { - setThreadNotificationLevel(NOTIFICATION_LEVEL_NEVER) - } - ) - ) - - OverflowMenu( - anchor = threadNotificationsAnchor, - expanded = isThreadMenuExpanded, - items = items, - onDismiss = { isThreadMenuExpanded = false } - ) - } - } - } - - addContentView( - overflowMenuHostView, - CoordinatorLayout.LayoutParams( - CoordinatorLayout.LayoutParams.MATCH_PARENT, - CoordinatorLayout.LayoutParams.MATCH_PARENT - ) - ) - } - isThreadMenuExpanded = true + private fun setThreadNotificationLevel(level: Int) { + val threadNotificationUrl = ApiUtils.getUrlForThreadNotificationLevel( + version = 1, + baseUrl = conversationUser!!.baseUrl, + token = roomToken, + threadId = conversationThreadId!!.toInt() + ) + chatViewModel.setThreadNotificationLevel(credentials!!, threadNotificationUrl, level) } @SuppressLint("InflateParams") diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbar.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbar.kt index 587a001eaf..0268714eed 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbar.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbar.kt @@ -132,8 +132,56 @@ private fun ToolbarActions(state: ChatToolbarState, callbacks: ChatToolbarCallba } } else { if (state.threadNotificationIcon != null) { - IconButton(onClick = callbacks.onThreadNotification) { - Icon(painterResource(state.threadNotificationIcon), stringResource(R.string.thread_notifications)) + var expanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { expanded = true }) { + Icon(painterResource(state.threadNotificationIcon), stringResource(R.string.thread_notifications)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.notifications_default)) }, + onClick = { + callbacks.onThreadNotificationLevelChange(0) + expanded = false + }, + leadingIcon = { + Icon(painterResource(R.drawable.baseline_notifications_24), contentDescription = null) + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.notification_all_messages)) }, + onClick = { + callbacks.onThreadNotificationLevelChange(1) + expanded = false + }, + leadingIcon = { + Icon(painterResource(R.drawable.outline_notifications_active_24), contentDescription = null) + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.notification_mention_only)) }, + onClick = { + callbacks.onThreadNotificationLevelChange(2) + expanded = false + }, + leadingIcon = { + Icon(painterResource(R.drawable.baseline_notifications_24), contentDescription = null) + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.notification_off)) }, + onClick = { + callbacks.onThreadNotificationLevelChange(3) + expanded = false + }, + leadingIcon = { + Icon( + painterResource(R.drawable.ic_baseline_notifications_off_24), + contentDescription = null + ) + } + ) + } } } if (state.showEventMenu) { diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbarCallbacks.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbarCallbacks.kt index 782e27f33d..ddbb2f4e71 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbarCallbacks.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/ChatToolbarCallbacks.kt @@ -20,6 +20,6 @@ data class ChatToolbarCallbacks( val onSearchSubmit: () -> Unit = {}, val onSearchPrevious: () -> Unit = {}, val onSearchNext: () -> Unit = {}, - val onThreadNotification: () -> Unit = {}, + val onThreadNotificationLevelChange: (Int) -> Unit = {}, val onEventMenu: () -> Unit = {} ) From 73cf71bba173351305ddb2d165bab6a55ed8e526 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Tue, 2 Jun 2026 22:52:20 +0200 Subject: [PATCH 8/8] remove icons for menu items .. to align with the other items who also have no icons Signed-off-by: Marcel Hibbe --- app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 174bfa04d8..ba39423bc7 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -2006,7 +2006,6 @@ class ChatActivity : if (networkMonitor.isOnline.value && hasScheduledMessages) { items += MenuItemData( title = getString(R.string.nc_scheduled_messages), - icon = R.drawable.baseline_schedule_24, onClick = { openScheduledMessages() } ) } @@ -2014,7 +2013,6 @@ class ChatActivity : if (currentConversation?.objectType == ConversationEnums.ObjectType.FILE) { items += MenuItemData( title = getString(R.string.nc_conversation_menu_conversation_go_to_file), - icon = R.drawable.ic_file_24px, onClick = { launchFileShareLink() } ) }