mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
Space filters (#4977)
* Add support for SpaceFilters on the SpaceServiceProxy level and a screen that renders them * Add aliases to a couple of space room mocks * Update translations and related snapshots * Add a feature flag for space filters * Add a new room summary provider filter that takes a list of room identifiers next to the normal state filters This will take the SpaceFilter's descendants and combine it with the normal filters to reduce the rooms shown * Add a new space filter button to the home screen and link it to the SpaceFiltersScreen and the actual RoomSummaryProvider room list filters * Bump the RustSDK to v26.01.20-2 for SpaceFilter support. * Add a cancellation button to the space filters screen * Make the filter presentation mode liquidy, move the sheet to within the home screen. * Address PR comments
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ChatsSpaceFiltersScreenViewModelAction {
|
||||
case confirm(SpaceServiceFilter)
|
||||
case cancel
|
||||
}
|
||||
|
||||
struct ChatsSpaceFiltersScreenViewState: BindableState {
|
||||
var filters = [SpaceServiceFilter]()
|
||||
var bindings: ChatsSpaceFiltersScreenViewStateBindings
|
||||
}
|
||||
|
||||
struct ChatsSpaceFiltersScreenViewStateBindings { }
|
||||
|
||||
enum ChatsSpaceFiltersScreenViewAction: CustomStringConvertible {
|
||||
case confirm(SpaceServiceFilter)
|
||||
case cancel
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .confirm: "Confirm"
|
||||
case .cancel: "Cancel"
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias ChatsSpaceFiltersScreenViewModelType = StateStoreViewModelV2<ChatsSpaceFiltersScreenViewState, ChatsSpaceFiltersScreenViewAction>
|
||||
|
||||
class ChatsSpaceFiltersScreenViewModel: ChatsSpaceFiltersScreenViewModelType, ChatsSpaceFiltersScreenViewModelProtocol, Identifiable {
|
||||
private let spaceService: SpaceServiceProxyProtocol
|
||||
|
||||
private let actionsSubject: PassthroughSubject<ChatsSpaceFiltersScreenViewModelAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<ChatsSpaceFiltersScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
|
||||
init(spaceService: SpaceServiceProxyProtocol,
|
||||
mediaProvider: MediaProviderProtocol) {
|
||||
self.spaceService = spaceService
|
||||
|
||||
super.init(initialViewState: ChatsSpaceFiltersScreenViewState(bindings: .init()),
|
||||
mediaProvider: mediaProvider)
|
||||
|
||||
state.filters = spaceService.spaceFilterPublisher.value
|
||||
|
||||
spaceService.spaceFilterPublisher.sink { [weak self] filters in
|
||||
self?.state.filters = filters
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: ChatsSpaceFiltersScreenViewAction) {
|
||||
MXLog.info("View model: received view action: \(viewAction)")
|
||||
|
||||
switch viewAction {
|
||||
case .confirm(let filter):
|
||||
actionsSubject.send(.confirm(filter))
|
||||
case .cancel:
|
||||
actionsSubject.send(.cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
protocol ChatsSpaceFiltersScreenViewModelProtocol {
|
||||
var actionsPublisher: AnyPublisher<ChatsSpaceFiltersScreenViewModelAction, Never> { get }
|
||||
var context: ChatsSpaceFiltersScreenViewModelType.Context { get }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct ChatsSpaceFilterCell: View {
|
||||
@Environment(\.dynamicTypeSize) var dynamicTypeSize
|
||||
|
||||
let filter: SpaceServiceFilter
|
||||
let mediaProvider: MediaProviderProtocol!
|
||||
|
||||
private let verticalInsets = 12.0
|
||||
private let horizontalInsets = 16.0
|
||||
|
||||
let action: (SpaceServiceFilter) -> Void
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
action(filter)
|
||||
} label: {
|
||||
HStack(spacing: 12.0) {
|
||||
HStack(spacing: 8.0) {
|
||||
if filter.level > 0 {
|
||||
Spacer(minLength: 16 * CGFloat(filter.level))
|
||||
}
|
||||
|
||||
HStack(spacing: 12.0) {
|
||||
avatar
|
||||
|
||||
content
|
||||
.padding(.vertical, verticalInsets)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(Color.compound.borderDisabled)
|
||||
.frame(height: 1 / UIScreen.main.scale)
|
||||
.padding(.trailing, -horizontalInsets)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, horizontalInsets)
|
||||
}
|
||||
|
||||
@ViewBuilder @MainActor
|
||||
private var avatar: some View {
|
||||
if dynamicTypeSize < .accessibility3 {
|
||||
RoomAvatarImage(avatar: filter.room.avatar,
|
||||
avatarSize: .room(on: .spaceFilters),
|
||||
mediaProvider: mediaProvider)
|
||||
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
HStack(spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(filter.room.name)
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
ZStack {
|
||||
// Hidden text to maintain consistent height.
|
||||
Text("")
|
||||
.hidden()
|
||||
|
||||
if let alias = filter.room.canonicalAlias {
|
||||
Text(alias)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatsSpaceFilterCell_Previews: PreviewProvider, TestablePreview {
|
||||
static let mediaProvider = MediaProviderMock(configuration: .init())
|
||||
|
||||
static let spaces = [SpaceServiceRoomProtocol].mockJoinedSpaces2
|
||||
|
||||
static var previews: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(spaces, id: \.id) { space in
|
||||
ChatsSpaceFilterCell(filter: .init(room: space, level: 0, descendants: .init()),
|
||||
mediaProvider: mediaProvider) { _ in }
|
||||
ChatsSpaceFilterCell(filter: .init(room: space, level: 1, descendants: .init()),
|
||||
mediaProvider: mediaProvider) { _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct ChatsSpaceFiltersScreen: View {
|
||||
@Bindable var context: ChatsSpaceFiltersScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(context.viewState.filters) { filter in
|
||||
ChatsSpaceFilterCell(filter: filter,
|
||||
mediaProvider: context.mediaProvider) { filter in
|
||||
context.send(viewAction: .confirm(filter))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbar { toolbar }
|
||||
.navigationTitle(L10n.screenRoomlistYourSpaces)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
ToolbarButton(role: .cancel) {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct ChatsSpaceFiltersScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = makeViewModel()
|
||||
|
||||
static var previews: some View {
|
||||
ChatsSpaceFiltersScreen(context: viewModel.context)
|
||||
}
|
||||
|
||||
static func makeViewModel() -> ChatsSpaceFiltersScreenViewModel {
|
||||
ChatsSpaceFiltersScreenViewModel(spaceService: SpaceServiceProxyMock(.populated),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()))
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ enum HomeScreenViewAction {
|
||||
case dismissNewSoundBanner
|
||||
case updateVisibleItemRange(Range<Int>)
|
||||
case globalSearch
|
||||
case spaceFilters
|
||||
case markRoomAsUnread(roomIdentifier: String)
|
||||
case markRoomAsRead(roomIdentifier: String)
|
||||
case markRoomAsFavourite(roomIdentifier: String, isFavourite: Bool)
|
||||
@@ -109,6 +110,10 @@ struct HomeScreenViewState: BindableState {
|
||||
|
||||
var reportRoomEnabled = false
|
||||
|
||||
var spaceFiltersEnabled = false
|
||||
|
||||
var selectedSpaceFilter: SpaceServiceFilter?
|
||||
|
||||
var visibleRooms: [HomeScreenRoom] {
|
||||
if roomListMode == .skeletons {
|
||||
return placeholderRooms
|
||||
@@ -150,6 +155,8 @@ struct HomeScreenViewStateBindings {
|
||||
|
||||
var alertInfo: AlertInfo<UUID>?
|
||||
var leaveRoomAlertItem: LeaveRoomAlertItem?
|
||||
|
||||
var spaceFiltersViewModel: ChatsSpaceFiltersScreenViewModel?
|
||||
}
|
||||
|
||||
struct HomeScreenRoom: Identifiable, Equatable {
|
||||
|
||||
@@ -15,6 +15,7 @@ typealias HomeScreenViewModelType = StateStoreViewModel<HomeScreenViewState, Hom
|
||||
|
||||
class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol {
|
||||
private let userSession: UserSessionProtocol
|
||||
private let spaceFilterSubject: CurrentValueSubject<SpaceServiceFilter?, Never>
|
||||
private let analyticsService: AnalyticsService
|
||||
private let appSettings: AppSettings
|
||||
private let notificationManager: NotificationManagerProtocol
|
||||
@@ -39,9 +40,12 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
self.notificationManager = notificationManager
|
||||
self.userIndicatorController = userIndicatorController
|
||||
|
||||
spaceFilterSubject = CurrentValueSubject<SpaceServiceFilter?, Never>(nil)
|
||||
|
||||
roomSummaryProvider = userSession.clientProxy.roomSummaryProvider
|
||||
|
||||
super.init(initialViewState: .init(userID: userSession.clientProxy.userID,
|
||||
spaceFiltersEnabled: appSettings.spaceFiltersEnabled,
|
||||
bindings: .init(filtersState: .init(appSettings: appSettings))),
|
||||
mediaProvider: userSession.mediaProvider)
|
||||
|
||||
@@ -112,12 +116,22 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
appSettings.$spaceFiltersEnabled
|
||||
.receive(on: DispatchQueue.main)
|
||||
.weakAssign(to: \.state.spaceFiltersEnabled, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
userSession.clientProxy.hideInviteAvatarsPublisher
|
||||
.removeDuplicates()
|
||||
.receive(on: DispatchQueue.main)
|
||||
.weakAssign(to: \.state.hideInviteAvatars, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
spaceFilterSubject
|
||||
.receive(on: DispatchQueue.main)
|
||||
.weakAssign(to: \.state.selectedSpaceFilter, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
Task {
|
||||
state.reportRoomEnabled = await userSession.clientProxy.isReportRoomSupported
|
||||
}
|
||||
@@ -126,9 +140,9 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
let searchQuery = context.$viewState.map(\.bindings.searchQuery)
|
||||
let activeFilters = context.$viewState.map(\.bindings.filtersState.activeFilters)
|
||||
isSearchFieldFocused
|
||||
.combineLatest(searchQuery, activeFilters)
|
||||
.combineLatest(searchQuery, activeFilters, spaceFilterSubject)
|
||||
.removeDuplicates { $0 == $1 }
|
||||
.sink { [weak self] isSearchFieldFocused, _, _ in
|
||||
.sink { [weak self] isSearchFieldFocused, _, _, _ in
|
||||
guard let self else { return }
|
||||
// isSearchFieldFocused` is sometimes turning to true after cancelling the search. So to be extra sure we are updating the values correctly we read them directly in the next run loop, and we add a small delay if the value has changed
|
||||
let delay = isSearchFieldFocused == self.context.viewState.bindings.isSearchFieldFocused ? 0.0 : 0.05
|
||||
@@ -175,6 +189,26 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
actionsSubject.send(.presentStartChatScreen)
|
||||
case .globalSearch:
|
||||
actionsSubject.send(.presentGlobalSearch)
|
||||
case .spaceFilters:
|
||||
if spaceFilterSubject.value != nil {
|
||||
spaceFilterSubject.send(nil)
|
||||
} else {
|
||||
state.bindings.spaceFiltersViewModel = ChatsSpaceFiltersScreenViewModel(spaceService: userSession.clientProxy.spaceService,
|
||||
mediaProvider: userSession.mediaProvider)
|
||||
|
||||
state.bindings.spaceFiltersViewModel?.actionsPublisher.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .confirm(let spaceServiceFilter):
|
||||
spaceFilterSubject.send(spaceServiceFilter)
|
||||
state.bindings.spaceFiltersViewModel = nil
|
||||
case .cancel:
|
||||
state.bindings.spaceFiltersViewModel = nil
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
case .markRoomAsUnread(let roomIdentifier):
|
||||
Task {
|
||||
guard case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(roomIdentifier) else {
|
||||
@@ -242,7 +276,12 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
if state.bindings.isSearchFieldFocused {
|
||||
roomSummaryProvider?.setFilter(.search(query: state.bindings.searchQuery))
|
||||
} else {
|
||||
roomSummaryProvider?.setFilter(.all(filters: state.bindings.filtersState.activeFilters.set))
|
||||
if let spaceFilter = spaceFilterSubject.value {
|
||||
roomSummaryProvider?.setFilter(.rooms(roomsIDs: spaceFilter.descendants,
|
||||
filters: state.bindings.filtersState.activeFilters.set))
|
||||
} else {
|
||||
roomSummaryProvider?.setFilter(.all(filters: state.bindings.filtersState.activeFilters.set))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,21 +16,39 @@ struct HomeScreen: View {
|
||||
|
||||
@State private var scrollViewAdapter = ScrollViewAdapter()
|
||||
|
||||
@Namespace private var navigationTransitionNamespace
|
||||
private enum NavigationTransitionSourceID {
|
||||
case spaceFilters
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HomeScreenContent(context: context, scrollViewAdapter: scrollViewAdapter)
|
||||
.alert(item: $context.alertInfo)
|
||||
.alert(item: $context.leaveRoomAlertItem,
|
||||
actions: leaveRoomAlertActions,
|
||||
message: leaveRoomAlertMessage)
|
||||
.navigationTitle(L10n.screenRoomlistMainSpaceTitle)
|
||||
.navigationTitle(title)
|
||||
.toolbar { toolbar }
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
.track(screen: .Home)
|
||||
.toolbarBloom(hasSearchBar: true)
|
||||
.sentryTrace("\(Self.self)")
|
||||
.sheet(item: $context.spaceFiltersViewModel) { vm in
|
||||
ChatsSpaceFiltersScreen(context: vm.context)
|
||||
.navigationTransition(.zoom(sourceID: NavigationTransitionSourceID.spaceFilters,
|
||||
in: navigationTransitionNamespace))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var title: String {
|
||||
if let selectedSpace = context.viewState.selectedSpaceFilter {
|
||||
selectedSpace.room.name
|
||||
} else {
|
||||
L10n.screenRoomlistMainSpaceTitle
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
@@ -47,6 +65,20 @@ struct HomeScreen: View {
|
||||
.buttonStyle(.compound(.super, size: .toolbarIcon))
|
||||
}
|
||||
}
|
||||
|
||||
if context.viewState.spaceFiltersEnabled {
|
||||
if #available(iOS 26, *) {
|
||||
ToolbarSpacer(.fixed, placement: .primaryAction)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
SpaceFiltersButton(selected: context.viewState.selectedSpaceFilter != nil) {
|
||||
context.send(viewAction: .spaceFilters)
|
||||
}
|
||||
.matchedTransitionSource(id: NavigationTransitionSourceID.spaceFilters,
|
||||
in: navigationTransitionNamespace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var settingsButton: some View {
|
||||
@@ -93,6 +125,42 @@ struct HomeScreen: View {
|
||||
private func leaveRoomAlertMessage(_ item: LeaveRoomAlertItem) -> some View {
|
||||
Text(item.subtitle)
|
||||
}
|
||||
|
||||
private struct SpaceFiltersButton: View {
|
||||
var selected = false
|
||||
var action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 26, *) {
|
||||
if selected {
|
||||
content
|
||||
.backportButtonStyleGlassProminent()
|
||||
.tint(.compound.bgAccentRest)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
} else {
|
||||
if selected {
|
||||
content
|
||||
.buttonStyle(.compound(.primary, size: .toolbarIcon))
|
||||
} else {
|
||||
content
|
||||
.buttonStyle(.compound(.tertiary, size: .toolbarIcon))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
Button {
|
||||
action()
|
||||
} label: {
|
||||
CompoundIcon(\.filter)
|
||||
}
|
||||
.accessibilityLabel(L10n.screenRoomlistYourSpaces)
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
.accessibilityIdentifier(A11yIdentifiers.homeScreen.spaceFilters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
+1
@@ -70,6 +70,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
|
||||
|
||||
var spaceSettingsEnabled: Bool { get set }
|
||||
var createSpaceEnabled: Bool { get set }
|
||||
var spaceFiltersEnabled: Bool { get set }
|
||||
}
|
||||
|
||||
extension AppSettings: DeveloperOptionsProtocol { }
|
||||
|
||||
+4
@@ -52,6 +52,10 @@ struct DeveloperOptionsScreen: View {
|
||||
}
|
||||
|
||||
Section("Spaces") {
|
||||
Toggle(isOn: $context.spaceFiltersEnabled) {
|
||||
Text("Space filters")
|
||||
}
|
||||
|
||||
Toggle(isOn: $context.spaceSettingsEnabled) {
|
||||
Text("Space settings")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user