Add a ModerationAndSafetySettingsScreen. (#6278)

This commit is contained in:
Doug
2026-10-07 09:10:14 +01:00
committed by GitHub
parent 09085c8bb8
commit 51dd2efe23
13 changed files with 363 additions and 0 deletions
@@ -102,6 +102,7 @@ enum TestablePreviewsDictionary {
"MessageComposer_Previews" : MessageComposer_Previews.self,
"MessageForwardingScreen_Previews" : MessageForwardingScreen_Previews.self,
"MessageText_Previews" : MessageText_Previews.self,
"ModerationAndSafetySettingsScreen_Previews" : ModerationAndSafetySettingsScreen_Previews.self,
"MultiAccountAnnouncementScreen_Previews" : MultiAccountAnnouncementScreen_Previews.self,
"NoticeRoomTimelineView_Previews" : NoticeRoomTimelineView_Previews.self,
"NotificationPermissionsScreen_Previews" : NotificationPermissionsScreen_Previews.self,
@@ -0,0 +1,27 @@
//
// Copyright 2026 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
struct ModerationAndSafetySettingsScreenCoordinatorParameters {
let userSession: UserSessionProtocol
let userIndicatorController: UserIndicatorControllerProtocol
}
final class ModerationAndSafetySettingsScreenCoordinator: CoordinatorProtocol {
private var viewModel: ModerationAndSafetySettingsScreenViewModelProtocol
init(parameters: ModerationAndSafetySettingsScreenCoordinatorParameters) {
viewModel = ModerationAndSafetySettingsScreenViewModel(userSession: parameters.userSession,
userIndicatorController: parameters.userIndicatorController)
}
func toPresentable() -> AnyView {
AnyView(ModerationAndSafetySettingsScreen(context: viewModel.context))
}
}
@@ -0,0 +1,44 @@
//
// Copyright 2026 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
import UIKit
enum ModerationAndSafetySettingsScreenViewModelAction {
case blockedUsers
}
struct ModerationAndSafetySettingsScreenViewState: BindableState {
var hideInviteAvatars: Bool
var isWaitingHideInviteAvatars = false
var timelineMediaVisibility: TimelineMediaVisibility
var isWaitingTimelineMediaVisibility = false
var showBlockedUsers = false
var bindings: ModerationAndSafetySettingsScreenViewStateBindings
}
struct ModerationAndSafetySettingsScreenViewStateBindings {
private let userSettings: UserSettings
init(userSettings: UserSettings) {
self.userSettings = userSettings
}
var sharePresence: Bool {
get { userSettings.sharePresence }
set { userSettings.sharePresence = newValue }
}
}
enum ModerationAndSafetySettingsScreenViewAction {
case updateHideInviteAvatars(Bool)
case updateTimelineMediaVisibility(TimelineMediaVisibility)
case blockedUsers
}
@@ -0,0 +1,106 @@
//
// Copyright 2026 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 ModerationAndSafetySettingsScreenViewModelType = StateStoreViewModelV2<ModerationAndSafetySettingsScreenViewState, ModerationAndSafetySettingsScreenViewAction>
class ModerationAndSafetySettingsScreenViewModel: ModerationAndSafetySettingsScreenViewModelType, ModerationAndSafetySettingsScreenViewModelProtocol {
private let clientProxy: ClientProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private var timelineMediaVisibilityTask: Task<Void, Never>?
private var hideInviteAvatarsTask: Task<Void, Never>?
private var actionsSubject: PassthroughSubject<ModerationAndSafetySettingsScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<ModerationAndSafetySettingsScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(userSession: UserSessionProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
clientProxy = userSession.clientProxy
self.userIndicatorController = userIndicatorController
let state = ModerationAndSafetySettingsScreenViewState(hideInviteAvatars: clientProxy.hideInviteAvatarsPublisher.value,
timelineMediaVisibility: clientProxy.timelineMediaVisibilityPublisher.value,
bindings: .init(userSettings: userSession.userSettings))
super.init(initialViewState: state)
clientProxy.hideInviteAvatarsPublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.hideInviteAvatars, on: self)
.store(in: &cancellables)
clientProxy.timelineMediaVisibilityPublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.timelineMediaVisibility, on: self)
.store(in: &cancellables)
clientProxy.ignoredUsersPublisher
.receive(on: DispatchQueue.main)
.map { $0?.isEmpty == false }
.weakAssign(to: \.state.showBlockedUsers, on: self)
.store(in: &cancellables)
}
override func process(viewAction: ModerationAndSafetySettingsScreenViewAction) {
switch viewAction {
case let .updateHideInviteAvatars(value):
hideInviteAvatarsTask = Task { [weak self] in await self?.updateHideInviteAvatars(value) }
case let .updateTimelineMediaVisibility(value):
timelineMediaVisibilityTask = Task { [weak self] in await self?.updateTimelineMediaVisibility(value) }
case .blockedUsers:
actionsSubject.send(.blockedUsers)
}
}
private func updateTimelineMediaVisibility(_ value: TimelineMediaVisibility) async {
defer {
timelineMediaVisibilityTask = nil
state.isWaitingTimelineMediaVisibility = false
}
let previousState = state.timelineMediaVisibility
state.isWaitingTimelineMediaVisibility = true
state.timelineMediaVisibility = value
// If the other value is updating wait also for it to finish
await hideInviteAvatarsTask?.value
switch await clientProxy.setTimelineMediaVisibility(value) {
case .success:
break
case .failure:
state.timelineMediaVisibility = previousState
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
private func updateHideInviteAvatars(_ value: Bool) async {
defer {
hideInviteAvatarsTask = nil
state.isWaitingHideInviteAvatars = false
}
let previousState = state.hideInviteAvatars
state.isWaitingHideInviteAvatars = true
state.hideInviteAvatars = value
// If the other value is updating wait also for it to finish
await timelineMediaVisibilityTask?.value
switch await clientProxy.setHideInviteAvatars(value) {
case .success:
break
case .failure:
state.hideInviteAvatars = previousState
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
}
@@ -0,0 +1,13 @@
//
// Copyright 2026 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
protocol ModerationAndSafetySettingsScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<ModerationAndSafetySettingsScreenViewModelAction, Never> { get }
var context: ModerationAndSafetySettingsScreenViewModelType.Context { get }
}
@@ -0,0 +1,113 @@
//
// Copyright 2026 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 ModerationAndSafetySettingsScreen: View {
@Bindable var context: ModerationAndSafetySettingsScreenViewModel.Context
var body: some View {
Form {
sharePresenceSection
invitesSection
timelineMediaSection
blockedUsersSection
}
.compoundList()
.navigationTitle(L10n.commonModerationAndSafety)
.navigationBarTitleDisplayMode(.inline)
}
private var sharePresenceSection: some View {
Section {
ListRow(label: .plain(title: L10n.screenAdvancedSettingsSharePresence,
description: L10n.screenAdvancedSettingsSharePresenceDescription),
kind: .toggle($context.sharePresence))
} header: {
Text(L10n.screenModerationAndSafetySharePresenceHeading)
.compoundListSectionHeader()
}
}
@ViewBuilder
private var invitesSection: some View {
let binding = Binding(get: {
context.viewState.hideInviteAvatars
}, set: { newValue in
context.send(viewAction: .updateHideInviteAvatars(newValue))
})
Section {
ListRow(label: .plain(title: L10n.screenAdvancedSettingsHideInviteAvatarsToggleTitle),
details: context.viewState.isWaitingHideInviteAvatars ? .isWaiting(true) : nil,
kind: .toggle(binding))
.disabled(context.viewState.isWaitingHideInviteAvatars)
} header: {
Text(L10n.screenModerationAndSafetyInvitesHeading)
.compoundListSectionHeader()
}
}
@ViewBuilder
private var timelineMediaSection: some View {
let binding = Binding(get: {
context.viewState.timelineMediaVisibility
}, set: { newValue in
context.send(viewAction: .updateTimelineMediaVisibility(newValue))
})
Section {
ListRow(label: .plain(title: L10n.screenAdvancedSettingsShowMediaTimelineTitle),
details: .isWaiting(context.viewState.isWaitingTimelineMediaVisibility),
kind: .inlinePicker(selection: binding,
items: TimelineMediaVisibility.items))
.disabled(context.viewState.isWaitingTimelineMediaVisibility)
} header: {
Text(L10n.screenAdvancedSettingsShowMediaTimelineTitle)
.compoundListSectionHeader()
} footer: {
Text(L10n.screenAdvancedSettingsShowMediaTimelineSubtitle)
.compoundListSectionFooter()
}
}
@ViewBuilder
private var blockedUsersSection: some View {
if context.viewState.showBlockedUsers {
Section {
ListRow(label: .default(title: L10n.commonBlockedUsers,
icon: \.block),
kind: .navigationLink {
context.send(viewAction: .blockedUsers)
})
}
}
}
}
// MARK: - Previews
struct ModerationAndSafetySettingsScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = ModerationAndSafetySettingsScreenViewModel(userSession: UserSessionMock(.init()),
userIndicatorController: UserIndicatorControllerMock())
static var previews: some View {
ElementNavigationStack {
ModerationAndSafetySettingsScreen(context: viewModel.context)
}
.snapshotPreferences(expect: viewModel.context.observe(\.viewState.showBlockedUsers))
}
}
private extension TimelineMediaVisibility {
static var items: [(title: String, tag: TimelineMediaVisibility)] {
[(title: L10n.screenAdvancedSettingsShowMediaTimelineAlwaysHide, tag: .never),
(title: L10n.screenAdvancedSettingsShowMediaTimelinePrivateRooms, tag: .privateOnly),
(title: L10n.screenAdvancedSettingsShowMediaTimelineAlwaysShow, tag: .always)]
}
}