From a87f0772c86771604ebc7355221bd10a684d3421 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 23 Jan 2026 12:50:44 +0000 Subject: [PATCH] ios: content filter in chats (#6590) * ios: content filter in chats * allowed content types * icons --- apps/ios/Shared/Model/SimpleXAPI.swift | 11 +- .../Shared/Views/Chat/ChatItemsLoader.swift | 3 +- apps/ios/Shared/Views/Chat/ChatView.swift | 182 +++++++++++++++--- 3 files changed, 161 insertions(+), 35 deletions(-) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 52a0c343ff..46ee753438 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -444,17 +444,17 @@ func apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTa throw r.unexpected } -func apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope?) async throws -> [MsgContentTag] { +func apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope? = nil) async throws -> [MsgContentTag] { let r: ChatResponse0 = try await chatSendCmd(.apiGetChatContentTypes(chatId: chatId, scope: scope)) if case let .chatContentTypes(types) = r { return types } throw r.unexpected } -func loadChat(chat: Chat, im: ItemsModel, search: String = "", clearItems: Bool = true) async { - await loadChat(chatId: chat.chatInfo.id, im: im, search: search, clearItems: clearItems) +func loadChat(chat: Chat, im: ItemsModel, contentTag: MsgContentTag? = nil, search: String = "", clearItems: Bool = true) async { + await loadChat(chatId: chat.chatInfo.id, im: im, contentTag: contentTag, search: search, clearItems: clearItems) } -func loadChat(chatId: ChatId, im: ItemsModel, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async { +func loadChat(chatId: ChatId, im: ItemsModel, contentTag: MsgContentTag? = nil, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async { await MainActor.run { if clearItems { im.reversedChatItems = [] @@ -468,10 +468,11 @@ func loadChat(chatId: ChatId, im: ItemsModel, search: String = "", openAroundIte openAroundItemId != nil ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) : ( - search == "" + contentTag == nil && search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage) ) ), + contentTag, search, openAroundItemId, { 0...0 } diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index 93ecf870eb..9987fb4697 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -15,6 +15,7 @@ func apiLoadMessages( _ chatId: ChatId, _ im: ItemsModel, _ pagination: ChatPagination, + _ contentTag: MsgContentTag? = nil, _ search: String = "", _ openAroundItemId: ChatItem.ID? = nil, _ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange = { 0 ... 0 } @@ -22,7 +23,7 @@ func apiLoadMessages( let chat: Chat let navInfo: NavigationInfo do { - (chat, navInfo) = try await apiGetChat(chatId: chatId, scope: im.groupScopeInfo?.toChatScope(), contentTag: im.contentTag, pagination: pagination, search: search) + (chat, navInfo) = try await apiGetChat(chatId: chatId, scope: im.groupScopeInfo?.toChatScope(), contentTag: contentTag ?? im.contentTag, pagination: pagination, search: search) } catch let error { logger.error("apiLoadMessages error: \(responseError(error))") return diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 709758655f..5b2117e9d3 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -44,6 +44,8 @@ struct ChatView: View { @State private var showSearch = false @State private var searchText: String = "" @FocusState private var searchFocussed + @State private var contentFilter: ContentFilter? = nil + @State private var availableContent: [ContentFilter] = [.images, .files, .links] // opening GroupMemberInfoView on member icon @State private var selectedMember: GMember? = nil // opening GroupLinkView on link button (incognito) @@ -528,16 +530,19 @@ struct ChatView: View { case let .direct(contact): HStack { let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser - if callsPrefEnabled { - if chatModel.activeCall == nil { - callButton(contact, .audio, imageName: "phone") - .disabled(!contact.ready || !contact.active) - } else if let call = chatModel.activeCall, call.contact.id == cInfo.id { - endCallButton(call) - } + if let call = chatModel.activeCall, call.contact.id == cInfo.id { + endCallButton(call) + } else { + contentFilterMenu() } Menu { if callsPrefEnabled && chatModel.activeCall == nil { + Button { + CallController.shared.startCall(contact, .audio) + } label: { + Label("Audio call", systemImage: "phone") + } + .disabled(!contact.ready || !contact.active) Button { CallController.shared.startCall(contact, .video) } label: { @@ -545,6 +550,9 @@ struct ChatView: View { } .disabled(!contact.ready || !contact.active) } + if let call = chatModel.activeCall, call.contact.id == cInfo.id { + contentFilterMenu() + } searchButton() ToggleNtfsButton(chat: chat) .disabled(!contact.ready || !contact.active) @@ -554,23 +562,24 @@ struct ChatView: View { } case let .group(groupInfo, _): HStack { - if groupInfo.canAddMembers { - if (chat.chatInfo.incognito) { - groupLinkButton() - .appSheet(isPresented: $showGroupLinkSheet) { - GroupLinkView( - groupId: groupInfo.groupId, - groupLink: $groupLink, - groupLinkMemberRole: $groupLinkMemberRole, - showTitle: true, - creatingGroup: false - ) - } - } else { - addMembersButton() - } - } + contentFilterMenu() Menu { + if groupInfo.canAddMembers { + if (chat.chatInfo.incognito) { + groupLinkButton() + .appSheet(isPresented: $showGroupLinkSheet) { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false + ) + } + } else { + addMembersButton() + } + } searchButton() ToggleNtfsButton(chat: chat) } label: { @@ -578,7 +587,10 @@ struct ChatView: View { } } case .local: - searchButton() + HStack { + contentFilterMenu() + searchButton() + } default: EmptyView() } @@ -685,6 +697,7 @@ struct ChatView: View { } } } + updateAvailableContent() } if chatModel.draftChatId == cInfo.id && !composeState.forwarding, let draft = chatModel.draft { @@ -698,6 +711,22 @@ struct ChatView: View { floatingButtonModel.updateOnListChange(scrollView.listState) } + private func updateAvailableContent() { + Task { + let content: [ContentFilter] + do { + let contentTags = Set(try await apiGetChatContentTypes(chatId: chat.chatInfo.id)).union(ContentFilter.alwaysShow) + content = ContentFilter.allCases.filter { contentTags.contains($0.contentTag) } + } catch let error { + logger.error("apiGetChatContentTypes error: \(responseError(error))") + content = ContentFilter.allCases + } + await MainActor.run { + availableContent = content + } + } + } + private func scrollToItem(_ itemId: ChatItem.ID) { Task { do { @@ -732,10 +761,14 @@ struct ChatView: View { } private func searchToolbar() -> some View { - HStack(spacing: 12) { + let placeholder: LocalizedStringKey = contentFilter?.searchPlaceholder ?? "Search" + return HStack(spacing: 12) { HStack(spacing: 4) { Image(systemName: "magnifyingglass") - TextField("Search", text: $searchText) + if let contentFilter { + Image(systemName: contentFilter.icon) + } + TextField(placeholder, text: $searchText) .focused($searchFocussed) .foregroundColor(theme.colors.onBackground) .frame(maxWidth: .infinity) @@ -1052,7 +1085,7 @@ struct ChatView: View { private func searchTextChanged(_ s: String) { Task { - await loadChat(chat: chat, im: im, search: s) + await loadChat(chat: chat, im: im, contentTag: contentFilter?.contentTag, search: s) mergedItems.boxedValue = MergedItems.create(im, revealedItems) await MainActor.run { scrollView.updateItems(mergedItems.boxedValue.items) @@ -1255,16 +1288,46 @@ struct ChatView: View { } } + private func contentFilterMenu() -> some View { + 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: "photo.on.rectangle.angled") + } + } + private func focusSearch() { showSearch = true searchFocussed = true searchText = "" } + private func setContentFilter(_ type: ContentFilter) { + contentFilter = type + showSearch = true + searchText = "" + searchTextChanged("") + } + private func closeSearch() { showSearch = false searchText = "" searchFocussed = false + contentFilter = nil + updateAvailableContent() } private func closeKeyboardAndRun(_ action: @escaping () -> Void) { @@ -1285,7 +1348,7 @@ struct ChatView: View { Task { await chatModel.loadGroupMembers(gInfo) { showAddMembersSheet = true } } } } label: { - Image(systemName: "person.crop.circle.badge.plus") + Label("Invite member", systemImage: "person.crop.circle.badge.plus") } } @@ -1305,7 +1368,7 @@ struct ChatView: View { } } } label: { - Image(systemName: "link.badge.plus") + Label("Group link", systemImage: "link.badge.plus") } } @@ -1473,6 +1536,7 @@ struct ChatView: View { chat.chatInfo.id, im, pagination, + contentFilter?.contentTag, searchText, nil, { visibleItemIndexesNonReversed(im, scrollView.listState, mergedItems.boxedValue) } @@ -2957,6 +3021,66 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) { } } +enum ContentFilter: CaseIterable { + case images + case videos + case voice + case files + case links + + static let alwaysShow: Set = [.image, .link] + + var contentTag: MsgContentTag { + switch self { + case .images: .image + case .videos: .video + case .voice: .voice + case .files: .file + case .links: .link + } + } + + var label: LocalizedStringKey { + switch self { + case .images: "Images" + case .videos: "Videos" + case .voice: "Voice messages" + case .files: "Files" + case .links: "Links" + } + } + + var searchPlaceholder: LocalizedStringKey { + switch self { + case .images: "Search images" + case .videos: "Search videos" + case .voice: "Search voice messages" + case .files: "Search files" + case .links: "Search links" + } + } + + var icon: String { + switch self { + case .images: "photo" + case .videos: "video" + case .voice: "mic" + case .files: "doc" + case .links: "link" + } + } + + var iconFilled: String { + switch self { + case .images: "photo.fill" + case .videos: "video.fill" + case .voice: "mic.fill" + case .files: "doc.fill" + case .links: "link.circle.fill" + } + } +} + struct ChatView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel()