From d30dde5026ff896cd3887ac9ed0299ba7e05aedb Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 23 Jan 2026 17:27:15 +0000 Subject: [PATCH] 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 --- apps/ios/Shared/Views/Chat/ChatView.swift | 18 +- apps/multiplatform/.gitignore | 1 + .../chat/simplex/common/model/SimpleXAPI.kt | 2 +- .../common/views/chat/ChatItemsLoader.kt | 3 +- .../simplex/common/views/chat/ChatView.kt | 375 +++++++++++++----- .../views/chatlist/ChatListNavLinkView.kt | 6 +- .../common/views/helpers/DefaultTopAppBar.kt | 5 +- .../common/views/helpers/SearchTextField.kt | 29 +- .../commonMain/resources/MR/base/strings.xml | 12 + .../resources/MR/images/ic_image_filled.svg | 4 + .../resources/MR/images/ic_photo_library.svg | 4 + .../MR/images/ic_photo_library_filled.svg | 4 + 12 files changed, 344 insertions(+), 119 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 5b2117e9d3..dc1228fce8 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -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 = "" diff --git a/apps/multiplatform/.gitignore b/apps/multiplatform/.gitignore index f30061200c..5d39eb29f2 100644 --- a/apps/multiplatform/.gitignore +++ b/apps/multiplatform/.gitignore @@ -1,5 +1,6 @@ *.iml .gradle +.kotlin /local.properties /.idea !/.idea/codeStyles/* diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index b2817291ce..31edeec55a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -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) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index ed40150cb1..107d427556 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -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) 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 26daee363f..7322e3b17d 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 @@ -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?) } val showCommandsMenu = rememberSaveable { mutableStateOf(false) } + val contentFilter = rememberSaveable { mutableStateOf(null) } + val availableContent = remember { mutableStateOf>(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, availableContent: MutableState>) { + 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 = 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, - showCommandsMenu: MutableState + showCommandsMenu: MutableState, + contentFilter: MutableState, + availableContent: State>, + 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) -> Unit, onSearchValueChanged: (String) -> Unit, - showSearch: MutableState + showSearch: MutableState, + contentFilter: MutableState, + availableContent: State>, + 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 = setOf(MsgContentTag.Image, MsgContentTag.Link) + + val initialList: List = listOf(ContentFilter.Images, ContentFilter.Files, ContentFilter.Links) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 77ab62dbf1..014a180712 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -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 { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt index 1c5f86c8b5..81fac40a40 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultTopAppBar.kt @@ -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) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt index 7124f34ac0..a122ddd885 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/SearchTextField.kt @@ -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, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 371f0e076f..4d71073dac 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -368,6 +368,18 @@ Edit Info Search + Search images + Search videos + Search voice messages + Search files + Search links + Images + Videos + Voice messages + Files + Links + All messages + Filter Archive Archive report Archive reports diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg new file mode 100644 index 0000000000..045484d0a1 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_image_filled.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg new file mode 100644 index 0000000000..091b0d4692 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg new file mode 100644 index 0000000000..72692b2e17 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_photo_library_filled.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file