ui: call button in toolbar with audio/video submenu, filter in menu on mobile (#6700)

* 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 <noreply@anthropic.com>

* ui: call button before filter button on desktop toolbar

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Narasimha-sc
2026-03-28 07:26:16 +00:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Evgeny Poberezkin
parent 427ce1b3ff
commit b0e6a7bfc1
2 changed files with 152 additions and 48 deletions
+49 -11
View File
@@ -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:
@@ -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() }
}
}
}
}
}