Handle media previews and invite avatars through the account data (#4142)

* added account data media display policy handling to NSE

* pr suggestions

* update compound

* code improvement
This commit is contained in:
Mauro
2025-05-28 19:51:34 +02:00
committed by GitHub
parent 256292c5a9
commit aef91b5bc9
24 changed files with 626 additions and 61 deletions
@@ -30,11 +30,12 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
private var userSession: UserSessionProtocol? {
didSet {
userSessionObserver?.cancel()
if userSession != nil {
if let userSession {
configureElementCallService()
configureNotificationManager()
observeUserSessionChanges()
startSync()
performSettingsToAccountDataMigration(userSession: userSession)
}
}
}
@@ -399,17 +400,54 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
Tracing.deleteLogFiles()
MXLog.info("Migrating to v1.6.7, log files have been wiped")
}
}
// This could be removed once the adotpion of 25.06.x is widespread.
private func performSettingsToAccountDataMigration(userSession: UserSessionProtocol) {
guard let userDefaults = UserDefaults(suiteName: InfoPlistReader.main.appGroupIdentifier) else {
return
}
if oldVersion < Version(25, 4, 2) {
MXLog.info("Migrating to v25.04.2, checking if hideTimelineMedia flag can be migrated to timelineMediaVisibility")
// Migration for the old `hideTimelineMedia` flag.
if let userDefaults = UserDefaults(suiteName: InfoPlistReader.main.appGroupIdentifier),
let hideTimelineMedia = userDefaults.value(forKey: "hideTimelineMedia") as? Bool {
appSettings.timelineMediaVisibility = hideTimelineMedia ? .never : .always
let hideInviteAvatars = userDefaults.value(forKey: "hideInviteAvatars") as? Bool
let timelineMediaVisibility = userDefaults
.data(forKey: "timelineMediaVisibility")
.flatMap {
try? JSONDecoder().decode(TimelineMediaVisibility.self, from: $0)
}
let hideTimelineMedia = userDefaults.value(forKey: "hideTimelineMedia") as? Bool
guard hideInviteAvatars != nil || timelineMediaVisibility != nil || hideTimelineMedia != nil else {
// No migration needed, no local settings found.
return
}
Task {
switch await userSession.clientProxy.fetchMediaPreviewConfiguration() {
case let .success(config):
guard config == nil else {
// Found a server configuration, no need to migrate.
userDefaults.removeObject(forKey: "hideInviteAvatars")
userDefaults.removeObject(forKey: "timelineMediaVisibility")
userDefaults.removeObject(forKey: "hideTimelineMedia")
return
}
if let hideInviteAvatars, case .success = await userSession.clientProxy.setHideInviteAvatars(hideInviteAvatars) {
userDefaults.removeObject(forKey: "hideInviteAvatars")
}
if let timelineMediaVisibility, case .success = await userSession.clientProxy.setTimelineMediaVisibility(timelineMediaVisibility) {
userDefaults.removeObject(forKey: "timelineMediaVisibility")
} else if let hideTimelineMedia, case .success = await userSession.clientProxy.setTimelineMediaVisibility(hideTimelineMedia ? .never : .always) {
userDefaults.removeObject(forKey: "hideTimelineMedia")
}
case let .failure(error):
MXLog.error("Could not perform migration, failed to fetch media preview config: \(error)")
return
}
}
}
/// Clears the keychain, app support directory etc ready for a fresh use.
/// - Parameter includingSettings: Whether to additionally wipe the user's app settings too.
private func wipeUserData(includingSettings: Bool = false) {
@@ -17,8 +17,6 @@ protocol CommonSettingsProtocol {
var logLevel: LogLevel { get }
var traceLogPacks: Set<TraceLogPack> { get }
var enableOnlySignedDeviceIsolationMode: Bool { get }
var hideInviteAvatars: Bool { get }
var timelineMediaVisibility: TimelineMediaVisibility { get }
var hideQuietNotificationAlerts: Bool { get }
}
@@ -46,8 +44,6 @@ final class AppSettings {
case optimizeMediaUploads
case appAppearance
case sharePresence
case hideInviteAvatars
case timelineMediaVisibility
case isNewBloomEnabled
case elementCallBaseURLOverride
@@ -368,20 +364,8 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.enableOnlySignedDeviceIsolationMode, defaultValue: false, storageType: .userDefaults(store))
var enableOnlySignedDeviceIsolationMode
@UserPreference(key: UserDefaultsKeys.hideInviteAvatars, defaultValue: false, storageType: .userDefaults(store))
var hideInviteAvatars
@UserPreference(key: UserDefaultsKeys.timelineMediaVisibility, defaultValue: TimelineMediaVisibility.always, storageType: .userDefaults(store))
var timelineMediaVisibility
@UserPreference(key: UserDefaultsKeys.hideQuietNotificationAlerts, defaultValue: false, storageType: .userDefaults(store))
var hideQuietNotificationAlerts
}
extension AppSettings: CommonSettingsProtocol { }
enum TimelineMediaVisibility: Codable {
case always
case privateOnly
case never
}
@@ -226,7 +226,9 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
private func presentAdvancedSettings() {
let coordinator = AdvancedSettingsScreenCoordinator(parameters: .init(appSettings: parameters.appSettings,
analytics: parameters.analytics))
analytics: parameters.analytics,
clientProxy: parameters.userSession.clientProxy,
userIndicatorController: parameters.userIndicatorController))
navigationStackCoordinator.push(coordinator)
}
@@ -17,6 +17,9 @@ struct ClientProxyMockConfiguration {
var roomDirectorySearchProxy: RoomDirectorySearchProxyProtocol?
var recoveryState: SecureBackupRecoveryState = .enabled
var timelineMediaVisibility = TimelineMediaVisibility.always
var hideInviteAvatars = false
}
enum ClientProxyMockError: Error {
@@ -94,5 +97,8 @@ extension ClientProxyMock {
userIdentityForReturnValue = .success(UserIdentityProxyMock(configuration: .init()))
underlyingIsReportRoomSupported = true
underlyingTimelineMediaVisibilityPublisher = CurrentValueSubject<TimelineMediaVisibility, Never>(configuration.timelineMediaVisibility).asCurrentValuePublisher()
underlyingHideInviteAvatarsPublisher = CurrentValueSubject<Bool, Never>(configuration.hideInviteAvatars).asCurrentValuePublisher()
}
}
@@ -2271,6 +2271,16 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
set(value) { underlyingIgnoredUsersPublisher = value }
}
var underlyingIgnoredUsersPublisher: CurrentValuePublisher<[String]?, Never>!
var timelineMediaVisibilityPublisher: CurrentValuePublisher<TimelineMediaVisibility, Never> {
get { return underlyingTimelineMediaVisibilityPublisher }
set(value) { underlyingTimelineMediaVisibilityPublisher = value }
}
var underlyingTimelineMediaVisibilityPublisher: CurrentValuePublisher<TimelineMediaVisibility, Never>!
var hideInviteAvatarsPublisher: CurrentValuePublisher<Bool, Never> {
get { return underlyingHideInviteAvatarsPublisher }
set(value) { underlyingHideInviteAvatarsPublisher = value }
}
var underlyingHideInviteAvatarsPublisher: CurrentValuePublisher<Bool, Never>!
var pusherNotificationClientIdentifier: String?
var roomSummaryProvider: RoomSummaryProviderProtocol {
get { return underlyingRoomSummaryProvider }
@@ -4363,6 +4373,70 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
return clearCachesReturnValue
}
}
//MARK: - fetchMediaPreviewConfiguration
var fetchMediaPreviewConfigurationUnderlyingCallsCount = 0
var fetchMediaPreviewConfigurationCallsCount: Int {
get {
if Thread.isMainThread {
return fetchMediaPreviewConfigurationUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = fetchMediaPreviewConfigurationUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
fetchMediaPreviewConfigurationUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
fetchMediaPreviewConfigurationUnderlyingCallsCount = newValue
}
}
}
}
var fetchMediaPreviewConfigurationCalled: Bool {
return fetchMediaPreviewConfigurationCallsCount > 0
}
var fetchMediaPreviewConfigurationUnderlyingReturnValue: Result<MediaPreviewConfig?, ClientProxyError>!
var fetchMediaPreviewConfigurationReturnValue: Result<MediaPreviewConfig?, ClientProxyError>! {
get {
if Thread.isMainThread {
return fetchMediaPreviewConfigurationUnderlyingReturnValue
} else {
var returnValue: Result<MediaPreviewConfig?, ClientProxyError>? = nil
DispatchQueue.main.sync {
returnValue = fetchMediaPreviewConfigurationUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
fetchMediaPreviewConfigurationUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
fetchMediaPreviewConfigurationUnderlyingReturnValue = newValue
}
}
}
}
var fetchMediaPreviewConfigurationClosure: (() async -> Result<MediaPreviewConfig?, ClientProxyError>)?
func fetchMediaPreviewConfiguration() async -> Result<MediaPreviewConfig?, ClientProxyError> {
fetchMediaPreviewConfigurationCallsCount += 1
if let fetchMediaPreviewConfigurationClosure = fetchMediaPreviewConfigurationClosure {
return await fetchMediaPreviewConfigurationClosure()
} else {
return fetchMediaPreviewConfigurationReturnValue
}
}
//MARK: - ignoreUser
var ignoreUserUnderlyingCallsCount = 0
@@ -5103,6 +5177,146 @@ class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
return userIdentityForReturnValue
}
}
//MARK: - setTimelineMediaVisibility
var setTimelineMediaVisibilityUnderlyingCallsCount = 0
var setTimelineMediaVisibilityCallsCount: Int {
get {
if Thread.isMainThread {
return setTimelineMediaVisibilityUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = setTimelineMediaVisibilityUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
setTimelineMediaVisibilityUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
setTimelineMediaVisibilityUnderlyingCallsCount = newValue
}
}
}
}
var setTimelineMediaVisibilityCalled: Bool {
return setTimelineMediaVisibilityCallsCount > 0
}
var setTimelineMediaVisibilityReceivedValue: TimelineMediaVisibility?
var setTimelineMediaVisibilityReceivedInvocations: [TimelineMediaVisibility] = []
var setTimelineMediaVisibilityUnderlyingReturnValue: Result<Void, ClientProxyError>!
var setTimelineMediaVisibilityReturnValue: Result<Void, ClientProxyError>! {
get {
if Thread.isMainThread {
return setTimelineMediaVisibilityUnderlyingReturnValue
} else {
var returnValue: Result<Void, ClientProxyError>? = nil
DispatchQueue.main.sync {
returnValue = setTimelineMediaVisibilityUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
setTimelineMediaVisibilityUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
setTimelineMediaVisibilityUnderlyingReturnValue = newValue
}
}
}
}
var setTimelineMediaVisibilityClosure: ((TimelineMediaVisibility) async -> Result<Void, ClientProxyError>)?
func setTimelineMediaVisibility(_ value: TimelineMediaVisibility) async -> Result<Void, ClientProxyError> {
setTimelineMediaVisibilityCallsCount += 1
setTimelineMediaVisibilityReceivedValue = value
DispatchQueue.main.async {
self.setTimelineMediaVisibilityReceivedInvocations.append(value)
}
if let setTimelineMediaVisibilityClosure = setTimelineMediaVisibilityClosure {
return await setTimelineMediaVisibilityClosure(value)
} else {
return setTimelineMediaVisibilityReturnValue
}
}
//MARK: - setHideInviteAvatars
var setHideInviteAvatarsUnderlyingCallsCount = 0
var setHideInviteAvatarsCallsCount: Int {
get {
if Thread.isMainThread {
return setHideInviteAvatarsUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = setHideInviteAvatarsUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
setHideInviteAvatarsUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
setHideInviteAvatarsUnderlyingCallsCount = newValue
}
}
}
}
var setHideInviteAvatarsCalled: Bool {
return setHideInviteAvatarsCallsCount > 0
}
var setHideInviteAvatarsReceivedValue: Bool?
var setHideInviteAvatarsReceivedInvocations: [Bool] = []
var setHideInviteAvatarsUnderlyingReturnValue: Result<Void, ClientProxyError>!
var setHideInviteAvatarsReturnValue: Result<Void, ClientProxyError>! {
get {
if Thread.isMainThread {
return setHideInviteAvatarsUnderlyingReturnValue
} else {
var returnValue: Result<Void, ClientProxyError>? = nil
DispatchQueue.main.sync {
returnValue = setHideInviteAvatarsUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
setHideInviteAvatarsUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
setHideInviteAvatarsUnderlyingReturnValue = newValue
}
}
}
}
var setHideInviteAvatarsClosure: ((Bool) async -> Result<Void, ClientProxyError>)?
func setHideInviteAvatars(_ value: Bool) async -> Result<Void, ClientProxyError> {
setHideInviteAvatarsCallsCount += 1
setHideInviteAvatarsReceivedValue = value
DispatchQueue.main.async {
self.setHideInviteAvatarsReceivedInvocations.append(value)
}
if let setHideInviteAvatarsClosure = setHideInviteAvatarsClosure {
return await setHideInviteAvatarsClosure(value)
} else {
return setHideInviteAvatarsReturnValue
}
}
//MARK: - loadMediaContentForSource
var loadMediaContentForSourceThrowableError: Error?
@@ -1050,6 +1050,75 @@ open class ClientSDKMock: MatrixRustSDK.Client, @unchecked Sendable {
}
}
//MARK: - fetchMediaPreviewConfig
open var fetchMediaPreviewConfigThrowableError: Error?
var fetchMediaPreviewConfigUnderlyingCallsCount = 0
open var fetchMediaPreviewConfigCallsCount: Int {
get {
if Thread.isMainThread {
return fetchMediaPreviewConfigUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = fetchMediaPreviewConfigUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
fetchMediaPreviewConfigUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
fetchMediaPreviewConfigUnderlyingCallsCount = newValue
}
}
}
}
open var fetchMediaPreviewConfigCalled: Bool {
return fetchMediaPreviewConfigCallsCount > 0
}
var fetchMediaPreviewConfigUnderlyingReturnValue: MediaPreviewConfig?
open var fetchMediaPreviewConfigReturnValue: MediaPreviewConfig? {
get {
if Thread.isMainThread {
return fetchMediaPreviewConfigUnderlyingReturnValue
} else {
var returnValue: MediaPreviewConfig?? = nil
DispatchQueue.main.sync {
returnValue = fetchMediaPreviewConfigUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
fetchMediaPreviewConfigUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
fetchMediaPreviewConfigUnderlyingReturnValue = newValue
}
}
}
}
open var fetchMediaPreviewConfigClosure: (() async throws -> MediaPreviewConfig?)?
open override func fetchMediaPreviewConfig() async throws -> MediaPreviewConfig? {
if let error = fetchMediaPreviewConfigThrowableError {
throw error
}
fetchMediaPreviewConfigCallsCount += 1
if let fetchMediaPreviewConfigClosure = fetchMediaPreviewConfigClosure {
return try await fetchMediaPreviewConfigClosure()
} else {
return fetchMediaPreviewConfigReturnValue
}
}
//MARK: - getDmRoom
open var getDmRoomUserIdThrowableError: Error?
@@ -1156,13 +1225,13 @@ open class ClientSDKMock: MatrixRustSDK.Client, @unchecked Sendable {
return getInviteAvatarsDisplayPolicyCallsCount > 0
}
var getInviteAvatarsDisplayPolicyUnderlyingReturnValue: InviteAvatars!
open var getInviteAvatarsDisplayPolicyReturnValue: InviteAvatars! {
var getInviteAvatarsDisplayPolicyUnderlyingReturnValue: InviteAvatars?
open var getInviteAvatarsDisplayPolicyReturnValue: InviteAvatars? {
get {
if Thread.isMainThread {
return getInviteAvatarsDisplayPolicyUnderlyingReturnValue
} else {
var returnValue: InviteAvatars? = nil
var returnValue: InviteAvatars?? = nil
DispatchQueue.main.sync {
returnValue = getInviteAvatarsDisplayPolicyUnderlyingReturnValue
}
@@ -1180,9 +1249,9 @@ open class ClientSDKMock: MatrixRustSDK.Client, @unchecked Sendable {
}
}
}
open var getInviteAvatarsDisplayPolicyClosure: (() async throws -> InviteAvatars)?
open var getInviteAvatarsDisplayPolicyClosure: (() async throws -> InviteAvatars?)?
open override func getInviteAvatarsDisplayPolicy() async throws -> InviteAvatars {
open override func getInviteAvatarsDisplayPolicy() async throws -> InviteAvatars? {
if let error = getInviteAvatarsDisplayPolicyThrowableError {
throw error
}
@@ -1375,13 +1444,13 @@ open class ClientSDKMock: MatrixRustSDK.Client, @unchecked Sendable {
return getMediaPreviewDisplayPolicyCallsCount > 0
}
var getMediaPreviewDisplayPolicyUnderlyingReturnValue: MediaPreviews!
open var getMediaPreviewDisplayPolicyReturnValue: MediaPreviews! {
var getMediaPreviewDisplayPolicyUnderlyingReturnValue: MediaPreviews?
open var getMediaPreviewDisplayPolicyReturnValue: MediaPreviews? {
get {
if Thread.isMainThread {
return getMediaPreviewDisplayPolicyUnderlyingReturnValue
} else {
var returnValue: MediaPreviews? = nil
var returnValue: MediaPreviews?? = nil
DispatchQueue.main.sync {
returnValue = getMediaPreviewDisplayPolicyUnderlyingReturnValue
}
@@ -1399,9 +1468,9 @@ open class ClientSDKMock: MatrixRustSDK.Client, @unchecked Sendable {
}
}
}
open var getMediaPreviewDisplayPolicyClosure: (() async throws -> MediaPreviews)?
open var getMediaPreviewDisplayPolicyClosure: (() async throws -> MediaPreviews?)?
open override func getMediaPreviewDisplayPolicy() async throws -> MediaPreviews {
open override func getMediaPreviewDisplayPolicy() async throws -> MediaPreviews? {
if let error = getMediaPreviewDisplayPolicyThrowableError {
throw error
}
+4
View File
@@ -30,6 +30,10 @@ extension SDKListener: QrLoginProgressListener where T == QrLoginProgress {
// MARK: ClientProxy
extension SDKListener: MediaPreviewConfigListener where T == MediaPreviewConfig? {
func onChange(mediaPreviewConfig: MediaPreviewConfig?) { onUpdateClosure(mediaPreviewConfig) }
}
extension SDKListener: SyncServiceStateObserver where T == SyncServiceState {
func onUpdate(state: SyncServiceState) { onUpdateClosure(state) }
}
@@ -104,7 +104,9 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
}
.store(in: &cancellables)
appSettings.$hideInviteAvatars
userSession.clientProxy.hideInviteAvatarsPublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.hideInviteAvatars, on: self)
.store(in: &cancellables)
@@ -41,7 +41,9 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
super.init(initialViewState: JoinRoomScreenViewState(roomID: roomID), mediaProvider: mediaProvider)
appSettings.$hideInviteAvatars
clientProxy.hideInviteAvatarsPublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.hideInviteAvatars, on: self)
.store(in: &cancellables)
@@ -363,9 +363,8 @@ struct JoinRoomScreen_Previews: PreviewProvider, TestablePreview {
static func makeViewModel(mode: JoinRoomScreenMode, hideInviteAvatars: Bool = false) -> JoinRoomScreenViewModel {
let appSettings = AppSettings()
appSettings.knockingEnabled = true
appSettings.hideInviteAvatars = hideInviteAvatars
let clientProxy = ClientProxyMock(.init())
let clientProxy = ClientProxyMock(.init(hideInviteAvatars: hideInviteAvatars))
switch mode {
case .unknown:
@@ -158,7 +158,8 @@ struct SecurityAndPrivacyScreen: View {
Section {
ListRow(label: .plain(title: L10n.screenSecurityAndPrivacyRoomDirectoryVisibilityToggleTitle),
details: .isWaiting(context.desiredSettings.isVisibileInRoomDirectory == nil),
details:
context.desiredSettings.isVisibileInRoomDirectory == nil ? .isWaiting(true) : nil,
kind: context.desiredSettings.isVisibileInRoomDirectory == nil ? .label : .toggle(binding))
} footer: {
Text(L10n.screenSecurityAndPrivacyRoomDirectoryVisibilitySectionFooter(context.viewState.serverName))
@@ -11,13 +11,18 @@ import SwiftUI
struct AdvancedSettingsScreenCoordinatorParameters {
let appSettings: AppSettings
let analytics: AnalyticsService
let clientProxy: ClientProxyProtocol
let userIndicatorController: UserIndicatorControllerProtocol
}
final class AdvancedSettingsScreenCoordinator: CoordinatorProtocol {
private var viewModel: AdvancedSettingsScreenViewModelProtocol
init(parameters: AdvancedSettingsScreenCoordinatorParameters) {
viewModel = AdvancedSettingsScreenViewModel(advancedSettings: parameters.appSettings, analytics: parameters.analytics)
viewModel = AdvancedSettingsScreenViewModel(advancedSettings: parameters.appSettings,
analytics: parameters.analytics,
clientProxy: parameters.clientProxy,
userIndicatorController: parameters.userIndicatorController)
}
func toPresentable() -> AnyView {
@@ -8,6 +8,10 @@
import Foundation
struct AdvancedSettingsScreenViewState: BindableState {
var timelineMediaVisibility: TimelineMediaVisibility
var hideInviteAvatars: Bool
var isWaitingTimelineMediaVisibility = false
var isWaitingHideInviteAvatars = false
var bindings: AdvancedSettingsScreenViewStateBindings
}
@@ -28,14 +32,14 @@ struct AdvancedSettingsScreenViewStateBindings {
enum AdvancedSettingsScreenViewAction {
case optimizeMediaUploadsChanged
case updateTimelineMediaVisibility(TimelineMediaVisibility)
case updateHideInviteAvatars(Bool)
}
protocol AdvancedSettingsProtocol: AnyObject {
var viewSourceEnabled: Bool { get set }
var appAppearance: AppAppearance { get set }
var sharePresence: Bool { get set }
var timelineMediaVisibility: TimelineMediaVisibility { get set }
var hideInviteAvatars: Bool { get set }
var optimizeMediaUploads: Bool { get set }
}
@@ -12,12 +12,36 @@ typealias AdvancedSettingsScreenViewModelType = StateStoreViewModelV2<AdvancedSe
class AdvancedSettingsScreenViewModel: AdvancedSettingsScreenViewModelType, AdvancedSettingsScreenViewModelProtocol {
private let analytics: AnalyticsService
private let clientProxy: ClientProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
init(advancedSettings: AdvancedSettingsProtocol, analytics: AnalyticsService) {
private var timelineMediaVisibilityTask: Task<Void, Never>?
private var hideInviteAvatarsTask: Task<Void, Never>?
init(advancedSettings: AdvancedSettingsProtocol,
analytics: AnalyticsService,
clientProxy: ClientProxyProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.analytics = analytics
self.clientProxy = clientProxy
self.userIndicatorController = userIndicatorController
let state = AdvancedSettingsScreenViewState(bindings: .init(advancedSettings: advancedSettings))
let state = AdvancedSettingsScreenViewState(timelineMediaVisibility: clientProxy.timelineMediaVisibilityPublisher.value,
hideInviteAvatars: clientProxy.hideInviteAvatarsPublisher.value,
bindings: .init(advancedSettings: advancedSettings))
super.init(initialViewState: state)
clientProxy.timelineMediaVisibilityPublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.timelineMediaVisibility, on: self)
.store(in: &cancellables)
clientProxy.hideInviteAvatarsPublisher
.removeDuplicates()
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.hideInviteAvatars, on: self)
.store(in: &cancellables)
}
override func process(viewAction: AdvancedSettingsScreenViewAction) {
@@ -25,6 +49,52 @@ class AdvancedSettingsScreenViewModel: AdvancedSettingsScreenViewModelType, Adva
case .optimizeMediaUploadsChanged:
// Note: Using a view action here as sinking the AppSettings publisher tracks the initial value.
analytics.trackInteraction(name: state.bindings.optimizeMediaUploads ? .MobileSettingsOptimizeMediaUploadsEnabled : .MobileSettingsOptimizeMediaUploadsDisabled)
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) }
}
}
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))
}
}
}
@@ -42,21 +42,39 @@ struct AdvancedSettingsScreen: View {
.navigationBarTitleDisplayMode(.inline)
}
@ViewBuilder
private var moderationAndSafetySection: some View {
let binding = Binding(get: {
context.viewState.hideInviteAvatars
}, set: { newValue in
context.send(viewAction: .updateHideInviteAvatars(newValue))
})
Section {
ListRow(label: .plain(title: L10n.screenAdvancedSettingsHideInviteAvatarsToggleTitle),
kind: .toggle($context.hideInviteAvatars))
details: context.viewState.isWaitingHideInviteAvatars ? .isWaiting(true) : nil,
kind: .toggle(binding))
.disabled(context.viewState.isWaitingHideInviteAvatars)
} header: {
Text(L10n.screenAdvancedSettingsModerationAndSafetySectionTitle)
.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),
kind: .inlinePicker(selection: $context.timelineMediaVisibility,
details: .isWaiting(context.viewState.isWaitingTimelineMediaVisibility),
kind: .inlinePicker(selection: binding,
items: TimelineMediaVisibility.items))
.disabled(context.viewState.isWaitingTimelineMediaVisibility)
} header: {
Text(L10n.screenAdvancedSettingsShowMediaTimelineTitle)
.compoundListSectionHeader()
@@ -84,7 +102,9 @@ private extension AppAppearance {
struct AdvancedSettingsScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = AdvancedSettingsScreenViewModel(advancedSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics)
analytics: ServiceLocator.shared.analytics,
clientProxy: ClientProxyMock(.init()),
userIndicatorController: UserIndicatorControllerMock())
static var previews: some View {
NavigationStack {
AdvancedSettingsScreen(context: viewModel.context)
@@ -88,7 +88,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
timelineControllerFactory: timelineControllerFactory,
clientProxy: clientProxy)
let hideTimelineMedia = switch appSettings.timelineMediaVisibility {
let hideTimelineMedia = switch clientProxy.timelineMediaVisibilityPublisher.value {
case .always:
false
case .privateOnly:
@@ -525,7 +525,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
.weakAssign(to: \.state.isViewSourceEnabled, on: self)
.store(in: &cancellables)
appSettings.$timelineMediaVisibility
clientProxy.timelineMediaVisibilityPublisher
.removeDuplicates()
.flatMap { [weak self] timelineMediaVisibility -> AnyPublisher<Bool, Never> in
switch timelineMediaVisibility {
@@ -538,10 +538,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
return roomProxy.infoPublisher
.map { !$0.isPrivate }
.removeDuplicates()
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.hideTimelineMedia, on: self)
.store(in: &cancellables)
}
@@ -39,6 +39,9 @@ class ClientProxy: ClientProxyProtocol {
// periphery:ignore - required for instance retention in the rust codebase
private var sendQueueListenerTaskHandle: TaskHandle?
// periphery:ignore - required for instance retention in the rust codebase
private var mediaPreviewConfigListenerTaskHandle: TaskHandle?
private var delegateHandle: TaskHandle?
// These following summary providers both operate on the same allRooms() list but
@@ -132,6 +135,16 @@ class ClientProxy: ClientProxyProtocol {
verificationStateSubject.asCurrentValuePublisher()
}
private let timelineMediaVisibilitySubject = CurrentValueSubject<TimelineMediaVisibility, Never>(.always)
var timelineMediaVisibilityPublisher: CurrentValuePublisher<TimelineMediaVisibility, Never> {
timelineMediaVisibilitySubject.asCurrentValuePublisher()
}
private let hideInviteAvatarsSubject = CurrentValueSubject<Bool, Never>(false)
var hideInviteAvatarsPublisher: CurrentValuePublisher<Bool, Never> {
hideInviteAvatarsSubject.asCurrentValuePublisher()
}
var roomsToAwait: Set<String> = []
private let sendQueueStatusSubject = CurrentValueSubject<Bool, Never>(false)
@@ -230,6 +243,10 @@ class ClientProxy: ClientProxyProtocol {
MXLog.error("Failed setting media retention policy with error: \(error)")
}
}
Task {
mediaPreviewConfigListenerTaskHandle = await createMediaPreviewConfigObserver()
}
}
var userID: String {
@@ -724,6 +741,16 @@ class ClientProxy: ClientProxyProtocol {
return .failure(.sdkError(error))
}
}
func fetchMediaPreviewConfiguration() async -> Result<MediaPreviewConfig?, ClientProxyError> {
do {
let config = try await client.fetchMediaPreviewConfig()
return .success(config)
} catch {
MXLog.error("Failed fetching media preview config with error: \(error)")
return .failure(.sdkError(error))
}
}
// MARK: Ignored users
@@ -798,6 +825,28 @@ class ClientProxy: ClientProxyProtocol {
return users.elements
}
// MARK: Moderation & Safety
func setTimelineMediaVisibility(_ value: TimelineMediaVisibility) async -> Result<Void, ClientProxyError> {
do {
try await client.setMediaPreviewDisplayPolicy(policy: value.rustValue)
return .success(())
} catch {
MXLog.error("Failed to set timeline media visibility: \(error)")
return .failure(.sdkError(error))
}
}
func setHideInviteAvatars(_ value: Bool) async -> Result<Void, ClientProxyError> {
do {
try await client.setInviteAvatarsDisplayPolicy(policy: value ? .off : .on)
return .success(())
} catch {
MXLog.error("Failed to set hide invite avatars: \(error)")
return .failure(.sdkError(error))
}
}
// MARK: - Private
private func cacheAccountURL() async {
@@ -867,6 +916,26 @@ class ClientProxy: ClientProxyProtocol {
}
})
}
private func createMediaPreviewConfigObserver() async -> TaskHandle? {
do {
return try await client.subscribeToMediaPreviewConfig(listener: SDKListener { [weak self] config in
guard let self else { return }
if let config {
timelineMediaVisibilitySubject.send(config.mediaPreviewVisibility)
hideInviteAvatarsSubject.send(config.hideInviteAvatars)
} else {
// return default values
timelineMediaVisibilitySubject.send(.always)
hideInviteAvatarsSubject.send(false)
}
})
} catch {
MXLog.error("Failed creating media preview config observer: \(error)")
return nil
}
}
private func createRoomListServiceObserver(_ roomListService: RoomListService) -> TaskHandle {
roomListService.state(listener: SDKListener { [weak self] state in
@@ -1133,3 +1202,38 @@ private struct ClientProxyServices {
self.roomListService = roomListService
}
}
private extension MediaPreviewConfig {
var mediaPreviewVisibility: TimelineMediaVisibility {
switch mediaPreviews {
case .on:
.always
case .private:
.privateOnly
case .off:
.never
}
}
var hideInviteAvatars: Bool {
switch inviteAvatars {
case .off:
true
case .on:
false
}
}
}
private extension TimelineMediaVisibility {
var rustValue: MediaPreviews {
switch self {
case .always:
.on
case .never:
.off
case .privateOnly:
.private
}
}
}
@@ -63,6 +63,13 @@ enum SessionVerificationState {
case unverified
}
// The `Decodable` conformance is just for the purpose of migration
enum TimelineMediaVisibility: Decodable {
case always
case privateOnly
case never
}
// sourcery: AutoMockable
protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
var actionsPublisher: AnyPublisher<ClientProxyAction, Never> { get }
@@ -93,6 +100,10 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
/// We delay fetching this until after the first sync. Nil until then
var ignoredUsersPublisher: CurrentValuePublisher<[String]?, Never> { get }
var timelineMediaVisibilityPublisher: CurrentValuePublisher<TimelineMediaVisibility, Never> { get }
var hideInviteAvatarsPublisher: CurrentValuePublisher<Bool, Never> { get }
var pusherNotificationClientIdentifier: String? { get }
var roomSummaryProvider: RoomSummaryProviderProtocol { get }
@@ -185,6 +196,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
func isAliasAvailable(_ alias: String) async -> Result<Bool, ClientProxyError>
@discardableResult func clearCaches() async -> Result<Void, ClientProxyError>
func fetchMediaPreviewConfiguration() async -> Result<MediaPreviewConfig?, ClientProxyError>
// MARK: - Ignored users
@@ -210,4 +223,9 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
func resetIdentity() async -> Result<IdentityResetHandle?, ClientProxyError>
func userIdentity(for userID: String) async -> Result<UserIdentityProxyProtocol?, ClientProxyError>
// MARK: - Moderation & Safety
func setTimelineMediaVisibility(_ value: TimelineMediaVisibility) async -> Result<Void, ClientProxyError>
func setHideInviteAvatars(_ value: Bool) async -> Result<Void, ClientProxyError>
}