From b0e6a7bfc12f0e08a84f939981fb731c0967e0e7 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 28 Mar 2026 07:26:16 +0000 Subject: [PATCH] ui: call button in toolbar with audio/video submenu, filter in menu on mobile (#6700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ui: call button in toolbar with audio/video submenu, filter in menu on mobile - Call button always outside three-dots menu; tapping it opens a submenu with Audio call and Video call options (both Android/iOS and desktop) - Desktop: two toolbar buttons — Filter + Call - Android/iOS: call button in toolbar; filter options move into the three-dots menu below a divider line - Groups: filter button always in toolbar on all platforms - Edge case: when calls are disabled/unavailable for a contact, the call button is hidden and the filter button is shown in its place in the toolbar instead Co-Authored-By: Claude Sonnet 4.6 * ui: call button before filter button on desktop toolbar --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Views/Chat/ChatView.swift | 60 ++++++-- .../simplex/common/views/chat/ChatView.kt | 140 +++++++++++++----- 2 files changed, 152 insertions(+), 48 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 057bf7f75f..c808e9432f 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -533,32 +533,51 @@ struct ChatView: View { case let .direct(contact): HStack { let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser + let canStartCall = callsPrefEnabled && contact.ready && contact.active && chatModel.activeCall == nil if let call = chatModel.activeCall, call.contact.id == cInfo.id { endCallButton(call) - } else { - contentFilterMenu(withLabel: false) - } - Menu { - if callsPrefEnabled && chatModel.activeCall == nil { + } else if canStartCall { + // Call button always in toolbar; tap opens Audio/Video submenu + Menu { Button { CallController.shared.startCall(contact, .audio) } label: { Label("Audio call", systemImage: "phone") } - .disabled(!contact.ready || !contact.active) Button { CallController.shared.startCall(contact, .video) } label: { Label("Video call", systemImage: "video") } - .disabled(!contact.ready || !contact.active) - } - if let call = chatModel.activeCall, call.contact.id == cInfo.id { - contentFilterMenu(withLabel: true) + } label: { + Image(systemName: "phone") } + } else if chatModel.activeCall == nil { + // Calls unavailable: show filter button in place of call button + contentFilterMenu(withLabel: false) + } + Menu { searchButton() ToggleNtfsButton(chat: chat) .disabled(!contact.ready || !contact.active) + // Filter options in menu when call button is shown (or during any active call) + if !availableContent.isEmpty && (canStartCall || chatModel.activeCall != nil) { + Divider() + ForEach(availableContent, id: \.self) { type in + Button { + setContentFilter(type) + } label: { + Label(type.label, systemImage: contentFilter == type ? type.iconFilled : type.icon) + } + } + if contentFilter != nil { + Button { + closeSearch() + } label: { + Label("All messages", systemImage: "bubble.left.and.text.bubble.right") + } + } + } } label: { Image(systemName: "ellipsis") } @@ -591,7 +610,26 @@ struct ChatView: View { } case .local: HStack { - contentFilterMenu(withLabel: false) + if !availableContent.isEmpty { + Menu { + ForEach(availableContent, id: \.self) { type in + Button { + setContentFilter(type) + } label: { + Label(type.label, systemImage: contentFilter == type ? type.iconFilled : type.icon) + } + } + if contentFilter != nil { + Button { + closeSearch() + } label: { + Label("All messages", systemImage: "bubble.left.and.text.bubble.right") + } + } + } label: { + Image(systemName: "ellipsis") + } + } searchButton() } default: diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index c518b156d9..f8ae03c5dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -1145,6 +1145,7 @@ fun BoxScope.ChatInfoToolbar( val scope = rememberCoroutineScope() val showMenu = rememberSaveable { mutableStateOf(false) } val showContentFilterMenu = rememberSaveable { mutableStateOf(false) } + val showCallMenu = rememberSaveable { mutableStateOf(false) } val onBackClicked = { if (!showSearch.value) { @@ -1163,35 +1164,28 @@ fun BoxScope.ChatInfoToolbar( val activeCall by remember { chatModel.activeCall } val showContentFilterButton = availableContent.value.isNotEmpty() - val activeCallInChat = chatInfo is ChatInfo.Direct && activeCall?.contact?.id == chatInfo.id + val canStartCall = chatInfo is ChatInfo.Direct && + chatInfo.contact.mergedPreferences.calls.enabled.forUser && + chatInfo.contact.ready && + chatInfo.contact.active && + activeCall == null - // Content filter button - shown in bar, or moved to menu during active call - if (showContentFilterButton) { + // Content filter button: always in bar on desktop and for groups; on Android for direct chats it + // goes into the three-dots menu UNLESS calls are unavailable, in which case it appears in the bar + if (showContentFilterButton && (appPlatform.isDesktop || chatInfo is ChatInfo.Group || + (appPlatform.isAndroid && chatInfo is ChatInfo.Direct && !canStartCall && activeCall == null))) { val enabled = chatInfo !is ChatInfo.Local || chatInfo.noteFolder.ready - if (activeCallInChat) { - menuItems.add { - ItemAction( - stringResource(MR.strings.content_filter_menu_item), + barButtons.add { + IconButton( + { showContentFilterMenu.value = true }, + enabled = enabled + ) { + Icon( painterResource(MR.images.ic_photo_library), - onClick = { - showMenu.value = false - showContentFilterMenu.value = true - } + null, + tint = MaterialTheme.colors.primary ) } - } else { - barButtons.add { - IconButton( - { showContentFilterMenu.value = true }, - enabled = enabled - ) { - Icon( - painterResource(MR.images.ic_photo_library), - null, - tint = MaterialTheme.colors.primary - ) - } - } } } @@ -1245,19 +1239,12 @@ fun BoxScope.ChatInfoToolbar( } } } - // Call buttons moved to menu - if (chatInfo.contact.mergedPreferences.calls.enabled.forUser && chatInfo.contact.ready && chatInfo.contact.active && activeCall == null) { - menuItems.add { - ItemAction(stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current), painterResource(MR.images.ic_call_500), onClick = { - showMenu.value = false - startCall(CallMediaType.Audio) - }) - } - menuItems.add { - ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { - showMenu.value = false - startCall(CallMediaType.Video) - }) + // Call button always in toolbar; tap opens Audio/Video call submenu + if (canStartCall) { + barButtons.add(0) { + IconButton({ showCallMenu.value = true }) { + Icon(painterResource(MR.images.ic_call_500), null, tint = MaterialTheme.colors.primary) + } } } menuItems.add { @@ -1316,6 +1303,53 @@ fun BoxScope.ChatInfoToolbar( } } + // Android only: for direct/local chats where the filter bar button is NOT shown, filter options go in the three-dots menu separated by a divider + if (appPlatform.isAndroid && chatInfo !is ChatInfo.Group && showContentFilterButton && + !(chatInfo is ChatInfo.Direct && !canStartCall && activeCall == null)) { + menuItems.add { Divider() } + availableContent.value.forEach { filter -> + menuItems.add { + val isSelected = contentFilter.value == filter + ItemAction( + stringResource(filter.label), + painterResource(if (isSelected) filter.iconFilled else filter.icon), + color = if (isSelected) MaterialTheme.colors.primary else Color.Unspecified, + onClick = { + showMenu.value = false + if (contentFilter.value == filter) return@ItemAction + contentFilter.value = filter + showSearch.value = true + scope.launch { + val c = chatModel.getChat(chatInfo.id) + if (c != null) { + apiFindMessages(chatsCtx, c, filter.contentTag, "") + } + } + } + ) + } + } + if (showSearch.value) { + menuItems.add { + ItemAction( + stringResource(MR.strings.content_filter_all_messages), + painterResource(MR.images.ic_forum), + onClick = { + showMenu.value = false + contentFilter.value = null + showSearch.value = false + scope.launch { + val c = chatModel.getChat(chatInfo.id) + if (c != null) { + apiFindMessages(chatsCtx, c, null, "") + } + } + } + ) + } + } + } + if (menuItems.isNotEmpty()) { barButtons.add { IconButton({ showMenu.value = true }) { @@ -1425,6 +1459,38 @@ fun BoxScope.ChatInfoToolbar( contentFilterMenuItems.forEach { it() } } } + val callMenuWidth = remember { mutableStateOf(250.dp) } + val callMenuHeight = remember { mutableStateOf(0.dp) } + DefaultDropdownMenu( + showCallMenu, + modifier = Modifier.onSizeChanged { with(density) { + callMenuWidth.value = it.width.toDp().coerceAtLeast(250.dp) + if (oneHandUI.value && chatBottomBar.value && (appPlatform.isDesktop || (platform.androidApiLevel ?: 0) >= 30)) callMenuHeight.value = it.height.toDp() + } }, + offset = DpOffset(-callMenuWidth.value, if (oneHandUI.value && chatBottomBar.value) -callMenuHeight.value else AppBarHeight) + ) { + if (chatInfo is ChatInfo.Direct) { + val callMenuItems: List<@Composable () -> Unit> = buildList { + add { + ItemAction(stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current), painterResource(MR.images.ic_call_500), onClick = { + showCallMenu.value = false + startCall(CallMediaType.Audio) + }) + } + add { + ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { + showCallMenu.value = false + startCall(CallMediaType.Video) + }) + } + } + if (oneHandUI.value && chatBottomBar.value) { + callMenuItems.asReversed().forEach { it() } + } else { + callMenuItems.forEach { it() } + } + } + } } }