android, desktop: content filter in chats (#6594)

* android, desktop: content filter in chats

* fix command

* fix

* show content filter menu in search

* show end call in app bar during active call with the current contact
This commit is contained in:
Evgeny
2026-01-23 17:27:15 +00:00
committed by GitHub
parent a87f0772c8
commit d30dde5026
12 changed files with 344 additions and 119 deletions

View File

@@ -533,7 +533,7 @@ struct ChatView: View {
if let call = chatModel.activeCall, call.contact.id == cInfo.id {
endCallButton(call)
} else {
contentFilterMenu()
contentFilterMenu(withLabel: false)
}
Menu {
if callsPrefEnabled && chatModel.activeCall == nil {
@@ -551,7 +551,7 @@ struct ChatView: View {
.disabled(!contact.ready || !contact.active)
}
if let call = chatModel.activeCall, call.contact.id == cInfo.id {
contentFilterMenu()
contentFilterMenu(withLabel: true)
}
searchButton()
ToggleNtfsButton(chat: chat)
@@ -562,7 +562,7 @@ struct ChatView: View {
}
case let .group(groupInfo, _):
HStack {
contentFilterMenu()
contentFilterMenu(withLabel: false)
Menu {
if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) {
@@ -588,7 +588,7 @@ struct ChatView: View {
}
case .local:
HStack {
contentFilterMenu()
contentFilterMenu(withLabel: false)
searchButton()
}
default:
@@ -1288,7 +1288,7 @@ struct ChatView: View {
}
}
private func contentFilterMenu() -> some View {
private func contentFilterMenu(withLabel: Bool) -> some View {
Menu {
ForEach(availableContent, id: \.self) { type in
Button {
@@ -1305,7 +1305,12 @@ struct ChatView: View {
}
}
} label: {
Image(systemName: "photo.on.rectangle.angled")
let icon = contentFilter == nil ? "photo.on.rectangle" : "photo.on.rectangle.fill"
if withLabel {
Label("Filter", systemImage: icon)
} else {
Image(systemName: icon)
}
}
}
@@ -1316,6 +1321,7 @@ struct ChatView: View {
}
private func setContentFilter(_ type: ContentFilter) {
if (contentFilter == type) { return }
contentFilter = type
showSearch = true
searchText = ""

View File

@@ -1,5 +1,6 @@
*.iml
.gradle
.kotlin
/local.properties
/.idea
!/.idea/codeStyles/*

View File

@@ -3735,7 +3735,7 @@ sealed class CC {
}
"/_get chat ${chatRef(type, id, scope)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
}
is ApiGetChatContentTypes -> "/_get content types ${chatRef(type, id, scope)})"
is ApiGetChatContentTypes -> "/_get content types ${chatRef(type, id, scope)}"
is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id, scope)} $itemId"
is ApiSendMessages -> {
val msgs = json.encodeToString(composedMessages)

View File

@@ -27,11 +27,12 @@ suspend fun apiLoadMessages(
chatType: ChatType,
apiId: Long,
pagination: ChatPagination,
contentTag: MsgContentTag? = null,
search: String = "",
openAroundItemId: Long? = null,
visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 }
) = coroutineScope {
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), chatsCtx.contentTag, pagination, search) ?: return@coroutineScope
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, chatsCtx.groupScopeInfo?.toChatScope(), contentTag ?: chatsCtx.contentTag, pagination, search) ?: return@coroutineScope
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
/** When [openAroundItemId] is provided, chatId can be different too */
if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last && openAroundItemId == null)

View File

@@ -45,6 +45,7 @@ import chat.simplex.common.views.newchat.ContactConnectionInfoView
import chat.simplex.common.views.newchat.alertProfileImageSize
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.datetime.*
@@ -144,6 +145,9 @@ fun ChatView(
val scope = rememberCoroutineScope()
val selectedChatItems = rememberSaveable { mutableStateOf(null as Set<Long>?) }
val showCommandsMenu = rememberSaveable { mutableStateOf(false) }
val contentFilter = rememberSaveable { mutableStateOf<ContentFilter?>(null) }
val availableContent = remember { mutableStateOf<List<ContentFilter>>(ContentFilter.initialList) }
if (appPlatform.isAndroid) {
DisposableEffect(Unit) {
onDispose {
@@ -170,7 +174,12 @@ fun ChatView(
}
showSearch.value = false
searchText.value = ""
contentFilter.value = null
availableContent.value = ContentFilter.initialList
selectedChatItems.value = null
if (chatsCtx.secondaryContextFilter == null) {
updateAvailableContent(chatRh, activeChat, availableContent)
}
if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.activeConn != null) {
withBGApi {
val r = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId)
@@ -229,11 +238,11 @@ fun ChatView(
val sameText = searchText.value == value
// showSearch can be false with empty text when it was closed manually after clicking on message from search to load .around it
// (required on Android to have this check to prevent call to search with old text)
val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && chatsCtx.secondaryContextFilter == null
val emptyAndClosedSearch = searchText.value.isEmpty() && !showSearch.value && chatsCtx.secondaryContextFilter == null && contentFilter.value == null
val c = chatModel.getChat(chatInfo.id)
if (sameText || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged
if ((sameText && contentFilter.value == null) || emptyAndClosedSearch || c == null || chatModel.chatId.value != chatInfo.id) return@onSearchValueChanged
withBGApi {
apiFindMessages(chatsCtx, c, value)
apiFindMessages(chatsCtx, c, contentFilter.value?.contentTag, value)
searchText.value = value
}
}
@@ -486,7 +495,7 @@ fun ChatView(
val c = chatModel.getChat(chatId)
if (chatModel.chatId.value != chatId) return@ChatLayout
if (c != null) {
apiLoadMessages(chatsCtx, c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, searchText.value, null, visibleItemIndexes)
apiLoadMessages(chatsCtx, c.remoteHostId, c.chatInfo.chatType, c.chatInfo.apiId, pagination, contentFilter.value?.contentTag, searchText.value, null, visibleItemIndexes)
}
},
deleteMessage = { itemId, mode ->
@@ -742,14 +751,23 @@ fun ChatView(
changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) },
onSearchValueChanged = onSearchValueChanged,
closeSearch = {
onSearchValueChanged("")
showSearch.value = false
searchText.value = ""
contentFilter.value = null
// Update available content types when search closes
if (chatsCtx.secondaryContextFilter == null) {
updateAvailableContent(chatRh, activeChat, availableContent)
}
},
onComposed,
developerTools = chatModel.controller.appPrefs.developerTools.get(),
showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(),
showSearch = showSearch,
showCommandsMenu = showCommandsMenu
showCommandsMenu = showCommandsMenu,
contentFilter = contentFilter,
availableContent = availableContent,
searchPlaceholder = contentFilter.value?.searchPlaceholder?.let { generalGetString(it) }
)
}
}
@@ -785,6 +803,23 @@ fun ChatView(
}
}
fun updateAvailableContent(chatRh: Long?, activeChat: State<Chat?>, availableContent: MutableState<List<ContentFilter>>) {
withBGApi {
Log.e(TAG, "updateAvailableContent")
val chatInfo = activeChat.value?.chatInfo
if (chatInfo == null) return@withBGApi
val types = chatModel.controller.apiGetChatContentTypes(chatRh, chatInfo.chatType, chatInfo.apiId, null)
if (activeChat.value?.chatInfo?.id != chatInfo.id) return@withBGApi
if (types == null) {
availableContent.value = ContentFilter.entries
} else {
val typeSet: Set<MsgContentTag> = types.union(ContentFilter.alwaysShow)
Log.e(TAG, "updateAvailableContent $typeSet")
availableContent.value = ContentFilter.entries.filter { it -> typeSet.contains(it.contentTag) }
}
}
}
private fun connectingText(chatInfo: ChatInfo): String? {
return when (chatInfo) {
is ChatInfo.Direct ->
@@ -879,7 +914,10 @@ fun ChatLayout(
developerTools: Boolean,
showViaProxy: Boolean,
showSearch: MutableState<Boolean>,
showCommandsMenu: MutableState<Boolean>
showCommandsMenu: MutableState<Boolean>,
contentFilter: MutableState<ContentFilter?>,
availableContent: State<List<ContentFilter>>,
searchPlaceholder: String?
) {
val chatInfo = remember { derivedStateOf { chat.value?.chatInfo } }
val scope = rememberCoroutineScope()
@@ -1063,7 +1101,7 @@ fun ChatLayout(
Box {
if (selectedChatItems.value == null) {
if (chatInfo != null) {
ChatInfoToolbar(chatsCtx, chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch)
ChatInfoToolbar(chatsCtx, chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch, contentFilter, availableContent, searchPlaceholder)
}
} else {
SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value)
@@ -1096,10 +1134,14 @@ fun BoxScope.ChatInfoToolbar(
openGroupLink: (GroupInfo) -> Unit,
changeNtfsState: (MsgFilter, currentValue: MutableState<MsgFilter>) -> Unit,
onSearchValueChanged: (String) -> Unit,
showSearch: MutableState<Boolean>
showSearch: MutableState<Boolean>,
contentFilter: MutableState<ContentFilter?>,
availableContent: State<List<ContentFilter>>,
searchPlaceholder: String?
) {
val scope = rememberCoroutineScope()
val showMenu = rememberSaveable { mutableStateOf(false) }
val showContentFilterMenu = rememberSaveable { mutableStateOf(false) }
val onBackClicked = {
if (!showSearch.value) {
@@ -1107,6 +1149,7 @@ fun BoxScope.ChatInfoToolbar(
} else {
onSearchValueChanged("")
showSearch.value = false
contentFilter.value = null
}
}
if (appPlatform.isAndroid && chatsCtx.secondaryContextFilter == null) {
@@ -1115,104 +1158,141 @@ fun BoxScope.ChatInfoToolbar(
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val menuItems = arrayListOf<@Composable () -> Unit>()
val activeCall by remember { chatModel.activeCall }
if (chatInfo is ChatInfo.Local) {
barButtons.add {
IconButton(
{
showMenu.value = false
showSearch.value = true
}, enabled = chatInfo.noteFolder.ready
) {
Icon(
painterResource(MR.images.ic_search),
stringResource(MR.strings.search_verb).capitalize(Locale.current),
tint = if (chatInfo.noteFolder.ready) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
)
}
}
} else {
menuItems.add {
ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = {
showMenu.value = false
showSearch.value = true
})
}
}
if (chatInfo is ChatInfo.Direct && chatInfo.contact.mergedPreferences.calls.enabled.forUser) {
if (activeCall == null) {
barButtons.add {
IconButton({
showMenu.value = false
startCall(CallMediaType.Audio)
}, enabled = chatInfo.contact.ready && chatInfo.contact.active
) {
Icon(
painterResource(MR.images.ic_call_500),
stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current),
tint = if (chatInfo.contact.ready && chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
)
}
}
} else if (activeCall?.contact?.id == chatInfo.id && appPlatform.isDesktop) {
barButtons.add {
val call = remember { chatModel.activeCall }.value
val connectedAt = call?.connectedAt
if (connectedAt != null) {
val time = remember { mutableStateOf(durationText(0)) }
LaunchedEffect(Unit) {
while (true) {
time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt())
delay(250)
}
}
val sp50 = with(LocalDensity.current) { 50.sp.toDp() }
Text(time.value, Modifier.widthIn(min = sp50))
}
}
barButtons.add {
IconButton({
showMenu.value = false
endCall()
}) {
Icon(
painterResource(MR.images.ic_call_end_filled),
null,
tint = MaterialTheme.colors.error
)
}
}
}
if (chatInfo.contact.ready && chatInfo.contact.active && activeCall == null) {
val showContentFilterButton = availableContent.value.isNotEmpty()
val activeCallInChat = chatInfo is ChatInfo.Direct && activeCall?.contact?.id == chatInfo.id
// Content filter button - shown in bar, or moved to menu during active call
if (showContentFilterButton) {
val enabled = chatInfo !is ChatInfo.Local || chatInfo.noteFolder.ready
if (activeCallInChat) {
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)
})
}
}
} else if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canAddMembers) {
if (!chatInfo.incognito) {
barButtons.add {
IconButton({
showMenu.value = false
addMembers(chatInfo.groupInfo)
}) {
Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary)
}
ItemAction(
stringResource(MR.strings.content_filter_menu_item),
painterResource(MR.images.ic_photo_library),
onClick = {
showMenu.value = false
showContentFilterMenu.value = true
}
)
}
} else {
barButtons.add {
IconButton({
showMenu.value = false
openGroupLink(chatInfo.groupInfo)
}) {
Icon(painterResource(MR.images.ic_add_link), stringResource(MR.strings.group_link), tint = MaterialTheme.colors.primary)
IconButton(
{ showContentFilterMenu.value = true },
enabled = enabled
) {
Icon(
painterResource(MR.images.ic_photo_library),
null,
tint = MaterialTheme.colors.primary
)
}
}
}
}
// Chat-type specific buttons
when (chatInfo) {
is ChatInfo.Local -> {
barButtons.add {
IconButton(
{
showMenu.value = false
showSearch.value = true
}, enabled = chatInfo.noteFolder.ready
) {
Icon(
painterResource(MR.images.ic_search),
stringResource(MR.strings.search_verb).capitalize(Locale.current),
tint = if (chatInfo.noteFolder.ready) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
)
}
}
}
is ChatInfo.Direct -> {
if (activeCall?.contact?.id == chatInfo.id) {
if (appPlatform.isDesktop) {
barButtons.add {
val call = remember { chatModel.activeCall }.value
val connectedAt = call?.connectedAt
if (connectedAt != null) {
val time = remember { mutableStateOf(durationText(0)) }
LaunchedEffect(Unit) {
while (true) {
time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt())
delay(250)
}
}
val sp50 = with(LocalDensity.current) { 50.sp.toDp() }
Text(time.value, Modifier.widthIn(min = sp50))
}
}
}
barButtons.add {
IconButton({
showMenu.value = false
endCall()
}) {
Icon(
painterResource(MR.images.ic_call_end_filled),
null,
tint = MaterialTheme.colors.error
)
}
}
}
// 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)
})
}
}
menuItems.add {
ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = {
showMenu.value = false
showSearch.value = true
})
}
}
is ChatInfo.Group -> {
// Add members / group link moved to menu
if (chatInfo.groupInfo.canAddMembers) {
if (!chatInfo.incognito) {
menuItems.add {
ItemAction(stringResource(MR.strings.icon_descr_add_members), painterResource(MR.images.ic_person_add_500), onClick = {
showMenu.value = false
addMembers(chatInfo.groupInfo)
})
}
} else {
menuItems.add {
ItemAction(stringResource(MR.strings.group_link), painterResource(MR.images.ic_add_link), onClick = {
showMenu.value = false
openGroupLink(chatInfo.groupInfo)
})
}
}
}
menuItems.add {
ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = {
showMenu.value = false
showSearch.value = true
})
}
}
else -> {}
}
val enableNtfs = chatInfo.chatSettings?.enableNtfs
if (((chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) || chatInfo is ChatInfo.Group) && enableNtfs != null) {
val ntfMode = remember { mutableStateOf(enableNtfs) }
@@ -1242,13 +1322,27 @@ fun BoxScope.ChatInfoToolbar(
}
val oneHandUI = remember { appPrefs.oneHandUI.state }
val chatBottomBar = remember { appPrefs.chatBottomBar.state }
val searchTrailingContent: @Composable (() -> Unit)? = if (showContentFilterButton) {{
IconButton({ showContentFilterMenu.value = true }) {
Icon(
painterResource(if (contentFilter.value == null) MR.images.ic_photo_library else MR.images.ic_photo_library_filled),
null,
Modifier.padding(4.dp),
tint = MaterialTheme.colors.primary
)
}
}} else null
DefaultAppBar(
navigationButton = { if (appPlatform.isAndroid || showSearch.value) { NavigationButtonBack(onBackClicked) } },
title = { ChatInfoToolbarTitle(chatInfo) },
onTitleClick = if (chatInfo is ChatInfo.Local) null else info,
showSearch = showSearch.value,
searchAlwaysVisible = contentFilter.value != null,
onTop = !oneHandUI.value || !chatBottomBar.value,
searchPlaceholder = searchPlaceholder,
onSearchValueChanged = onSearchValueChanged,
searchTrailingContent = searchTrailingContent,
buttons = { barButtons.forEach { it() } }
)
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd)) {
@@ -1269,6 +1363,65 @@ fun BoxScope.ChatInfoToolbar(
menuItems.forEach { it() }
}
}
val contentFilterWidth = remember { mutableStateOf(250.dp) }
val contentFilterHeight = remember { mutableStateOf(0.dp) }
DefaultDropdownMenu(
showContentFilterMenu,
modifier = Modifier.onSizeChanged { with(density) {
contentFilterWidth.value = it.width.toDp().coerceAtLeast(250.dp)
if (oneHandUI.value && chatBottomBar.value && (appPlatform.isDesktop || (platform.androidApiLevel ?: 0) >= 30)) contentFilterHeight.value = it.height.toDp()
} },
offset = DpOffset(-contentFilterWidth.value, if (oneHandUI.value && chatBottomBar.value) -contentFilterHeight.value else AppBarHeight)
) {
val contentFilterMenuItems: List<@Composable () -> Unit> = buildList {
availableContent.value.forEach { filter ->
val isSelected = contentFilter.value == filter
add {
ItemAction(
stringResource(filter.label),
painterResource(if (isSelected) filter.iconFilled else filter.icon),
color = if (isSelected) MaterialTheme.colors.primary else Color.Unspecified,
onClick = {
showContentFilterMenu.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) {
add {
ItemAction(
stringResource(MR.strings.content_filter_all_messages),
painterResource(MR.images.ic_forum),
onClick = {
showContentFilterMenu.value = false
contentFilter.value = null
showSearch.value = false
scope.launch {
val c = chatModel.getChat(chatInfo.id)
if (c != null) {
apiFindMessages(chatsCtx, c, null, "")
}
}
}
)
}
}
}
if (oneHandUI.value && chatBottomBar.value) {
contentFilterMenuItems.asReversed().forEach { it() }
} else {
contentFilterMenuItems.forEach { it() }
}
}
}
}
@@ -3425,7 +3578,10 @@ fun PreviewChatLayout() {
developerTools = false,
showViaProxy = false,
showSearch = remember { mutableStateOf(false) },
showCommandsMenu = remember { mutableStateOf(false) }
showCommandsMenu = remember { mutableStateOf(false) },
contentFilter = remember { mutableStateOf(null) },
availableContent = remember { mutableStateOf(ContentFilter.initialList) },
searchPlaceholder = null
)
}
}
@@ -3505,7 +3661,30 @@ fun PreviewGroupChatLayout() {
developerTools = false,
showViaProxy = false,
showSearch = remember { mutableStateOf(false) },
showCommandsMenu = remember { mutableStateOf(false) }
showCommandsMenu = remember { mutableStateOf(false) },
contentFilter = remember { mutableStateOf(null) },
availableContent = remember { mutableStateOf(ContentFilter.initialList) },
searchPlaceholder = null
)
}
}
enum class ContentFilter(
val contentTag: MsgContentTag,
val label: StringResource,
val searchPlaceholder: StringResource,
val icon: ImageResource,
val iconFilled: ImageResource
) {
Images(MsgContentTag.Image, MR.strings.content_filter_images, MR.strings.placeholder_search_images, MR.images.ic_image, MR.images.ic_image_filled),
Videos(MsgContentTag.Video, MR.strings.content_filter_videos, MR.strings.placeholder_search_videos, MR.images.ic_videocam, MR.images.ic_videocam_filled),
Voice(MsgContentTag.Voice, MR.strings.content_filter_voice_messages, MR.strings.placeholder_search_voice_messages, MR.images.ic_mic, MR.images.ic_mic_filled),
Files(MsgContentTag.File, MR.strings.content_filter_files, MR.strings.placeholder_search_files, MR.images.ic_draft, MR.images.ic_draft_filled),
Links(MsgContentTag.Link, MR.strings.content_filter_links, MR.strings.placeholder_search_links, MR.images.ic_link, MR.images.ic_link);
companion object {
val alwaysShow: Set<MsgContentTag> = setOf(MsgContentTag.Image, MsgContentTag.Link)
val initialList: List<ContentFilter> = listOf(ContentFilter.Images, ContentFilter.Files, ContentFilter.Links)
}
}

View File

@@ -228,6 +228,7 @@ suspend fun openChat(
} else {
ChatPagination.Initial(ChatPagination.INITIAL_COUNT)
},
contentTag = null,
"",
openAroundItemId
)
@@ -241,11 +242,12 @@ suspend fun openLoadedChat(chat: Chat) {
}
}
suspend fun apiFindMessages(chatsCtx: ChatModel.ChatsContext, ch: Chat, search: String) {
suspend fun apiFindMessages(chatsCtx: ChatModel.ChatsContext, ch: Chat, contentTag: MsgContentTag?, search: String) {
withContext(Dispatchers.Main) {
chatsCtx.chatItems.clearAndNotify()
}
apiLoadMessages(chatsCtx, ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = if (search.isNotEmpty()) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT), search = search)
val pagination = if (search.isNotEmpty() || contentTag != null) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT)
apiLoadMessages(chatsCtx, ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination, contentTag, search)
}
suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope {

View File

@@ -29,7 +29,9 @@ fun DefaultAppBar(
onTop: Boolean,
showSearch: Boolean = false,
searchAlwaysVisible: Boolean = false,
searchPlaceholder: String? = null,
onSearchValueChanged: (String) -> Unit = {},
searchTrailingContent: @Composable (() -> Unit)? = null,
buttons: @Composable RowScope.() -> Unit = {},
) {
// If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier
@@ -78,7 +80,8 @@ fun DefaultAppBar(
AppBar(
title = {
if (showSearch) {
SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = searchAlwaysVisible, reducedCloseButtonPadding = 12.dp, onValueChange = onSearchValueChanged)
val placeholder = searchPlaceholder ?: stringResource(MR.strings.search_verb)
SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = searchAlwaysVisible, placeholder = placeholder, trailingContent = searchTrailingContent, reducedCloseButtonPadding = 12.dp, onValueChange = onSearchValueChanged)
} else if (title != null) {
title()
} else if (titleText.value.isNotEmpty() && connection != null) {

View File

@@ -10,6 +10,7 @@ import androidx.compose.material.TextFieldDefaults.indicatorLine
import androidx.compose.material.TextFieldDefaults.textFieldWithLabelPadding
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -112,18 +113,26 @@ fun SearchTextField(
placeholder = {
Text(placeholder, style = textStyle.copy(color = MaterialTheme.colors.secondary), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
trailingIcon = if (searchText.value.text.isNotEmpty()) {{
IconButton({
if (alwaysVisible) {
keyboard?.hide()
focusManager.clearFocus()
trailingIcon = if (searchText.value.text.isNotEmpty() || trailingContent != null) {{
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.offset(x = 8.dp)
) {
if (searchText.value.text.isNotEmpty()) {
IconButton({
if (alwaysVisible) {
keyboard?.hide()
focusManager.clearFocus()
}
searchText.value = TextFieldValue("")
onValueChange("")
}) {
Icon(painterResource(MR.images.ic_close), stringResource(MR.strings.icon_descr_close_button), tint = MaterialTheme.colors.primary)
}
}
searchText.value = TextFieldValue("");
onValueChange("")
}, Modifier.offset(x = reducedCloseButtonPadding)) {
Icon(painterResource(MR.images.ic_close), stringResource(MR.strings.icon_descr_close_button), tint = MaterialTheme.colors.primary,)
trailingContent?.invoke()
}
}} else trailingContent,
}} else null,
singleLine = true,
enabled = enabled,
interactionSource = interactionSource,

View File

@@ -368,6 +368,18 @@
<string name="edit_verb">Edit</string>
<string name="info_menu">Info</string>
<string name="search_verb">Search</string>
<string name="placeholder_search_images">Search images</string>
<string name="placeholder_search_videos">Search videos</string>
<string name="placeholder_search_voice_messages">Search voice messages</string>
<string name="placeholder_search_files">Search files</string>
<string name="placeholder_search_links">Search links</string>
<string name="content_filter_images">Images</string>
<string name="content_filter_videos">Videos</string>
<string name="content_filter_voice_messages">Voice messages</string>
<string name="content_filter_files">Files</string>
<string name="content_filter_links">Links</string>
<string name="content_filter_all_messages">All messages</string>
<string name="content_filter_menu_item">Filter</string>
<string name="archive_verb">Archive</string>
<string name="archive_report">Archive report</string>
<string name="archive_reports">Archive reports</string>

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000">
<path
d="M182-124.5q-22.97 0-40.23-17.27Q124.5-159.03 124.5-182v-596q0-22.97 17.27-40.23Q159.03-835.5 182-835.5h596q22.97 0 40.23 17.27Q835.5-800.97 835.5-778v596q0 22.97-17.27 40.23Q800.97-124.5 778-124.5H182Zm58-154h481.5L577-471 446-301.5l-92-125-114 148Z" />
</svg>

After

Width:  |  Height:  |  Size: 389 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000">
<path
d="M345.5-376H731L606.5-544l-103 135-67.5-86.5L345.5-376Zm-88 176q-22.97 0-40.23-17.27Q200-234.53 200-257.5v-560q0-22.97 17.27-40.23Q234.53-875 257.5-875h560q22.97 0 40.23 17.27Q875-840.47 875-817.5v560q0 22.97-17.27 40.23Q840.47-200 817.5-200h-560Zm0-57.5h560v-560h-560v560ZM142.5-85q-22.97 0-40.23-17.27Q85-119.53 85-142.5V-760h57.5v617.5H760V-85H142.5Zm115-732.5v560-560Z" />
</svg>

After

Width:  |  Height:  |  Size: 511 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000">
<path
d="M345.5-376H731L606.5-544l-103 135-67.5-86.5L345.5-376Zm-88 176q-22.97 0-40.23-17.27Q200-234.53 200-257.5v-560q0-22.97 17.27-40.23Q234.53-875 257.5-875h560q22.97 0 40.23 17.27Q875-840.47 875-817.5v560q0 22.97-17.27 40.23Q840.47-200 817.5-200h-560Zm-115 115q-22.97 0-40.23-17.27Q85-119.53 85-142.5V-760h57.5v617.5H760V-85H142.5Z" />
</svg>

After

Width:  |  Height:  |  Size: 466 B