Be more lenient with the power levels as they can still be missing at the time the various screens are created e.g. after accepting an invite.

The correct solution is to subscribe to update and update the UI accordingly when receiving them.
This commit is contained in:
Stefan Ceriu
2025-06-26 16:34:34 +03:00
committed by Stefan Ceriu
parent 37cbf733d4
commit 9c28a69ccf
10 changed files with 108 additions and 103 deletions
@@ -26,7 +26,7 @@ class KnockRequestsListScreenViewModel: KnockRequestsListScreenViewModelType, Kn
self.userIndicatorController = userIndicatorController self.userIndicatorController = userIndicatorController
super.init(initialViewState: KnockRequestsListScreenViewState(), mediaProvider: mediaProvider) super.init(initialViewState: KnockRequestsListScreenViewState(), mediaProvider: mediaProvider)
updateRoomInfo(roomInfo: roomProxy.infoPublisher.value) updateRoomInfo(roomProxy.infoPublisher.value)
setupSubscriptions() setupSubscriptions()
} }
@@ -185,7 +185,7 @@ class KnockRequestsListScreenViewModel: KnockRequestsListScreenViewModelType, Kn
roomProxy.infoPublisher roomProxy.infoPublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] roomInfo in .sink { [weak self] roomInfo in
self?.updateRoomInfo(roomInfo: roomInfo) self?.updateRoomInfo(roomInfo)
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -210,7 +210,7 @@ class KnockRequestsListScreenViewModel: KnockRequestsListScreenViewModelType, Kn
.store(in: &cancellables) .store(in: &cancellables)
} }
private func updateRoomInfo(roomInfo: RoomInfoProxyProtocol) { private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) {
switch roomInfo.joinRule { switch roomInfo.joinRule {
case .knock, .knockRestricted: case .knock, .knockRestricted:
state.isKnockableRoom = true state.isKnockableRoom = true
@@ -218,10 +218,11 @@ class KnockRequestsListScreenViewModel: KnockRequestsListScreenViewModelType, Kn
state.isKnockableRoom = false state.isKnockableRoom = false
} }
guard let powerLevels = roomProxy.infoPublisher.value.powerLevels else { fatalError("Missing room power levels") } if let powerLevels = roomProxy.infoPublisher.value.powerLevels {
state.canAccept = powerLevels.canOwnUserInvite() state.canAccept = powerLevels.canOwnUserInvite()
state.canDecline = powerLevels.canOwnUserKick() state.canDecline = powerLevels.canOwnUserKick()
state.canBan = powerLevels.canOwnUserBan() state.canBan = powerLevels.canOwnUserBan()
}
} }
private static let loadingIndicatorIdentifier = "\(KnockRequestsListScreenViewModel.self)-Loading" private static let loadingIndicatorIdentifier = "\(KnockRequestsListScreenViewModel.self)-Loading"
@@ -39,6 +39,13 @@ class RoomDetailsEditScreenViewModel: RoomDetailsEditScreenViewModelType, RoomDe
avatarURL: roomAvatar, avatarURL: roomAvatar,
bindings: .init(name: roomName ?? "", topic: roomTopic ?? "")), mediaProvider: mediaProvider) bindings: .init(name: roomName ?? "", topic: roomTopic ?? "")), mediaProvider: mediaProvider)
roomProxy.infoPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] roomInfo in
self?.updateRoomInfo(roomInfo: roomInfo)
}
.store(in: &cancellables)
updateRoomInfo(roomInfo: roomProxy.infoPublisher.value) updateRoomInfo(roomInfo: roomProxy.infoPublisher.value)
} }
@@ -87,10 +94,11 @@ class RoomDetailsEditScreenViewModel: RoomDetailsEditScreenViewModelType, RoomDe
// MARK: - Private // MARK: - Private
private func updateRoomInfo(roomInfo: RoomInfoProxyProtocol) { private func updateRoomInfo(roomInfo: RoomInfoProxyProtocol) {
guard let powerLevels = roomInfo.powerLevels else { fatalError("Missing room power levels") } if let powerLevels = roomInfo.powerLevels {
state.canEditAvatar = powerLevels.canOwnUser(sendStateEvent: .roomAvatar) state.canEditAvatar = powerLevels.canOwnUser(sendStateEvent: .roomAvatar)
state.canEditName = powerLevels.canOwnUser(sendStateEvent: .roomName) state.canEditName = powerLevels.canOwnUser(sendStateEvent: .roomName)
state.canEditTopic = powerLevels.canOwnUser(sendStateEvent: .roomTopic) state.canEditTopic = powerLevels.canOwnUser(sendStateEvent: .roomTopic)
}
} }
private func saveRoomDetails() { private func saveRoomDetails() {
@@ -179,7 +179,7 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
private func setupRoomSubscription() { private func setupRoomSubscription() {
roomProxy.infoPublisher roomProxy.infoPublisher
.throttle(for: .milliseconds(200), scheduler: DispatchQueue.main, latest: true) .receive(on: DispatchQueue.main)
.sink { [weak self] roomInfo in .sink { [weak self] roomInfo in
self?.updateRoomInfo(roomInfo) self?.updateRoomInfo(roomInfo)
} }
@@ -233,8 +233,6 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
state.canBanUsers = powerLevels.canOwnUserBan() state.canBanUsers = powerLevels.canOwnUserBan()
state.canJoinCall = powerLevels.canOwnUserJoinCall() state.canJoinCall = powerLevels.canOwnUserJoinCall()
state.canEditRolesOrPermissions = powerLevels.suggestedRole(forUser: roomProxy.ownUserID) == .administrator state.canEditRolesOrPermissions = powerLevels.suggestedRole(forUser: roomProxy.ownUserID) == .administrator
} else {
fatalError("Missing room power levels")
} }
} }
@@ -103,10 +103,11 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
bannedMembers: roomMembersDetails.bannedMembers, bannedMembers: roomMembersDetails.bannedMembers,
bindings: state.bindings) bindings: state.bindings)
guard let powerLevels = roomProxy.infoPublisher.value.powerLevels else { fatalError("Missing room power levels") } if let powerLevels = roomProxy.infoPublisher.value.powerLevels {
self.state.canInviteUsers = powerLevels.canOwnUserInvite() self.state.canInviteUsers = powerLevels.canOwnUserInvite()
self.state.canKickUsers = powerLevels.canOwnUserKick() self.state.canKickUsers = powerLevels.canOwnUserKick()
self.state.canBanUsers = powerLevels.canOwnUserBan() self.state.canBanUsers = powerLevels.canOwnUserBan()
}
} }
} }
@@ -114,8 +114,9 @@ class RoomRolesAndPermissionsScreenViewModel: RoomRolesAndPermissionsScreenViewM
// MARK: - Permissions // MARK: - Permissions
private func updateRoomInfo(roomInfo: RoomInfoProxyProtocol) { private func updateRoomInfo(roomInfo: RoomInfoProxyProtocol) {
guard let powerLevels = roomInfo.powerLevels else { fatalError("Missing room power levels") } if let powerLevels = roomInfo.powerLevels {
state.permissions = .init(powerLevels: powerLevels.values) state.permissions = .init(powerLevels: powerLevels.values)
}
} }
private func editPermissions(group: RoomRolesAndPermissionsScreenPermissionsGroup) { private func editPermissions(group: RoomRolesAndPermissionsScreenPermissionsGroup) {
@@ -19,10 +19,13 @@ private enum SuggestionTriggerRegex {
final class CompletionSuggestionService: CompletionSuggestionServiceProtocol { final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
private let roomProxy: JoinedRoomProxyProtocol private let roomProxy: JoinedRoomProxyProtocol
private var canMentionAllUsers = false private var canMentionAllUsers = false
private(set) var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> = Empty().eraseToAnyPublisher() private(set) var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> = Empty().eraseToAnyPublisher()
private let suggestionTriggerSubject = CurrentValueSubject<SuggestionTrigger?, Never>(nil) private let suggestionTriggerSubject = CurrentValueSubject<SuggestionTrigger?, Never>(nil)
private var cancellables = Set<AnyCancellable>()
init(roomProxy: JoinedRoomProxyProtocol, init(roomProxy: JoinedRoomProxyProtocol,
roomListPublisher: AnyPublisher<[RoomSummary], Never>) { roomListPublisher: AnyPublisher<[RoomSummary], Never>) {
self.roomProxy = roomProxy self.roomProxy = roomProxy
@@ -47,8 +50,14 @@ final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
self?.suggestionTriggerSubject.value != nil ? .milliseconds(500) : .milliseconds(0) self?.suggestionTriggerSubject.value != nil ? .milliseconds(500) : .milliseconds(0)
} }
guard let powerLevels = roomProxy.infoPublisher.value.powerLevels else { fatalError("Missing room power levels") } roomProxy.infoPublisher
canMentionAllUsers = powerLevels.canOwnUserTriggerRoomNotification() .receive(on: DispatchQueue.main)
.sink { [weak self] roomInfo in
self?.updateRoomInfo(roomInfo)
}
.store(in: &cancellables)
updateRoomInfo(roomProxy.infoPublisher.value)
} }
func processTextMessage(_ textMessage: String, selectedRange: NSRange) { func processTextMessage(_ textMessage: String, selectedRange: NSRange) {
@@ -61,6 +70,12 @@ final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
// MARK: - Private // MARK: - Private
private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) {
if let powerLevels = roomProxy.infoPublisher.value.powerLevels {
canMentionAllUsers = powerLevels.canOwnUserTriggerRoomNotification()
}
}
private func membersSuggestions(suggestionTrigger: SuggestionTrigger, private func membersSuggestions(suggestionTrigger: SuggestionTrigger,
members: [RoomMemberProxyProtocol], members: [RoomMemberProxyProtocol],
ownUserID: String) -> [SuggestionItem] { ownUserID: String) -> [SuggestionItem] {
@@ -77,12 +77,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
super.init(initialViewState: appHooks.roomScreenHook.update(viewState), super.init(initialViewState: appHooks.roomScreenHook.update(viewState),
mediaProvider: mediaProvider) mediaProvider: mediaProvider)
updateRoomInfo(roomProxy.infoPublisher.value)
setupSubscriptions(ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher)
Task { Task {
await handleRoomInfoUpdate(roomProxy.infoPublisher.value)
await updateVerificationBadge() await updateVerificationBadge()
} }
setupSubscriptions(ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher)
} }
override func process(viewAction: RoomScreenViewAction) { override func process(viewAction: RoomScreenViewAction) {
@@ -157,31 +157,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
appSettings.$knockingEnabled appSettings.$knockingEnabled
.weakAssign(to: \.state.isKnockingEnabled, on: self) .weakAssign(to: \.state.isKnockingEnabled, on: self)
.store(in: &cancellables) .store(in: &cancellables)
let roomInfoSubscription = roomProxy roomProxy.infoPublisher
.infoPublisher .receive(on: DispatchQueue.main)
roomInfoSubscription
.throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] roomInfo in .sink { [weak self] roomInfo in
guard let self else { return } self?.updateRoomInfo(roomInfo)
state.roomTitle = roomInfo.displayName ?? roomProxy.id
state.roomAvatar = roomInfo.avatar
state.hasOngoingCall = roomInfo.hasRoomCall
} }
.store(in: &cancellables) .store(in: &cancellables)
Task { [weak self] in
for await roomInfo in roomInfoSubscription.receive(on: DispatchQueue.main).values {
guard !Task.isCancelled else {
return
}
await self?.handleRoomInfoUpdate(roomInfo)
}
}
.store(in: &cancellables)
let identityStatusChangesPublisher = roomProxy.identityStatusChangesPublisher.receive(on: DispatchQueue.main) let identityStatusChangesPublisher = roomProxy.identityStatusChangesPublisher.receive(on: DispatchQueue.main)
Task { [weak self] in Task { [weak self] in
@@ -332,7 +315,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
} }
} }
private func handleRoomInfoUpdate(_ roomInfo: RoomInfoProxyProtocol) async { private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) {
state.roomTitle = roomInfo.displayName ?? roomProxy.id
state.roomAvatar = roomInfo.avatar
state.hasOngoingCall = roomInfo.hasRoomCall
state.hasSuccessor = roomInfo.successor != nil state.hasSuccessor = roomInfo.successor != nil
let pinnedEventIDs = roomInfo.pinnedEventIDs let pinnedEventIDs = roomInfo.pinnedEventIDs
@@ -348,12 +334,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.isKnockableRoom = false state.isKnockableRoom = false
} }
guard let powerLevels = roomInfo.powerLevels else { fatalError("Missing room power levels") } if let powerLevels = roomInfo.powerLevels {
state.canSendMessage = powerLevels.canOwnUser(sendMessage: .roomMessage) state.canSendMessage = powerLevels.canOwnUser(sendMessage: .roomMessage)
state.canJoinCall = powerLevels.canOwnUserJoinCall() state.canJoinCall = powerLevels.canOwnUserJoinCall()
state.canAcceptKnocks = powerLevels.canOwnUserInvite() state.canAcceptKnocks = powerLevels.canOwnUserInvite()
state.canDeclineKnocks = powerLevels.canOwnUserKick() state.canDeclineKnocks = powerLevels.canOwnUserKick()
state.canBan = powerLevels.canOwnUserBan() state.canBan = powerLevels.canOwnUserBan()
}
} }
private func setupPinnedEventsTimelineItemProviderIfNeeded() { private func setupPinnedEventsTimelineItemProviderIfNeeded() {
@@ -23,18 +23,14 @@ class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTi
super.init(initialViewState: ThreadTimelineScreenViewState()) super.init(initialViewState: ThreadTimelineScreenViewState())
Task { [weak self] in roomProxy.infoPublisher
for await roomInfo in roomProxy.infoPublisher.receive(on: DispatchQueue.main).values { .receive(on: DispatchQueue.main)
guard !Task.isCancelled else { .sink { [weak self] roomInfo in
return self?.updateRoomInfo(roomInfo)
}
self?.handleRoomInfoUpdate(roomInfo)
} }
} .store(in: &cancellables)
.store(in: &cancellables)
handleRoomInfoUpdate(roomProxy.infoPublisher.value) updateRoomInfo(roomProxy.infoPublisher.value)
} }
// MARK: - Public // MARK: - Public
@@ -62,8 +58,9 @@ class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTi
// MARK: - Private // MARK: - Private
private func handleRoomInfoUpdate(_ roomInfo: RoomInfoProxyProtocol) { private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) {
guard let powerLevels = roomInfo.powerLevels else { fatalError("Missing room power levels") } if let powerLevels = roomInfo.powerLevels {
state.canSendMessage = powerLevels.canOwnUser(sendMessage: .roomMessage) state.canSendMessage = powerLevels.canOwnUser(sendMessage: .roomMessage)
}
} }
} }
@@ -119,8 +119,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
setupSubscriptions() setupSubscriptions()
setupDirectRoomSubscriptionsIfNeeded() setupDirectRoomSubscriptionsIfNeeded()
updateRoomInfo(roomInfo: roomProxy.infoPublisher.value)
state.audioPlayerStateProvider = { [weak self] itemID -> AudioPlayerState? in state.audioPlayerStateProvider = { [weak self] itemID -> AudioPlayerState? in
guard let self else { guard let self else {
return nil return nil
@@ -144,6 +142,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
state.timelineState.paginationState = timelineController.paginationState state.timelineState.paginationState = timelineController.paginationState
buildTimelineViews(timelineItems: timelineController.timelineItems) buildTimelineViews(timelineItems: timelineController.timelineItems)
updateRoomInfo(roomProxy.infoPublisher.value)
updateMembers(roomProxy.membersPublisher.value) updateMembers(roomProxy.membersPublisher.value)
// Note: beware if we get to e.g. restore a reply / edit, // Note: beware if we get to e.g. restore a reply / edit,
@@ -402,16 +401,17 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
} }
} }
private func updateRoomInfo(roomInfo: RoomInfoProxyProtocol) { private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) {
state.pinnedEventIDs = roomInfo.pinnedEventIDs state.pinnedEventIDs = roomInfo.pinnedEventIDs
guard let powerLevels = roomInfo.powerLevels else { fatalError("Missing room power levels") } if let powerLevels = roomInfo.powerLevels {
state.canCurrentUserSendMessage = powerLevels.canOwnUser(sendMessage: .roomMessage) state.canCurrentUserSendMessage = powerLevels.canOwnUser(sendMessage: .roomMessage)
state.canCurrentUserRedactOthers = powerLevels.canOwnUserRedactOther() state.canCurrentUserRedactOthers = powerLevels.canOwnUserRedactOther()
state.canCurrentUserRedactSelf = powerLevels.canOwnUserRedactOwn() state.canCurrentUserRedactSelf = powerLevels.canOwnUserRedactOwn()
state.canCurrentUserPin = powerLevels.canOwnUserPinOrUnpin() state.canCurrentUserPin = powerLevels.canOwnUserPinOrUnpin()
state.canCurrentUserKick = powerLevels.canOwnUserKick() state.canCurrentUserKick = powerLevels.canOwnUserKick()
state.canCurrentUserBan = powerLevels.canOwnUserBan() state.canCurrentUserBan = powerLevels.canOwnUserBan()
}
} }
private func setupSubscriptions() { private func setupSubscriptions() {
@@ -440,17 +440,12 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
} }
.store(in: &cancellables) .store(in: &cancellables)
let roomInfoSubscription = roomProxy.infoPublisher roomProxy.infoPublisher
Task { [weak self] in .receive(on: DispatchQueue.main)
for await roomInfo in roomInfoSubscription.receive(on: DispatchQueue.main).values { .sink { [weak self] roomInfo in
guard !Task.isCancelled else { self?.updateRoomInfo(roomInfo)
return
}
self?.updateRoomInfo(roomInfo: roomInfo)
} }
} .store(in: &cancellables)
.store(in: &cancellables)
setupAppSettingsSubscriptions() setupAppSettingsSubscriptions()
@@ -165,15 +165,19 @@ class RoomScreenViewModelTests: XCTestCase {
func testRoomInfoUpdate() async throws { func testRoomInfoUpdate() async throws {
var configuration = JoinedRoomProxyMockConfiguration(id: "TestID", name: "StartingName", avatarURL: nil, hasOngoingCall: false) var configuration = JoinedRoomProxyMockConfiguration(id: "TestID", name: "StartingName", avatarURL: nil, hasOngoingCall: false)
let infoSubject = CurrentValueSubject<RoomInfoProxyProtocol, Never>(RoomInfoProxyMock(configuration))
let roomProxyMock = JoinedRoomProxyMock(configuration) let roomProxyMock = JoinedRoomProxyMock(configuration)
let powerLevelsMock = RoomPowerLevelsProxyMock(configuration: .init()) let powerLevelsMock = RoomPowerLevelsProxyMock(configuration: .init())
powerLevelsMock.canUserJoinCallUserIDReturnValue = .success(false)
powerLevelsMock.canOwnUserJoinCallReturnValue = false
roomProxyMock.powerLevelsReturnValue = .success(powerLevelsMock) roomProxyMock.powerLevelsReturnValue = .success(powerLevelsMock)
// setup the room proxy actions publisher let roomInfoProxyMock = RoomInfoProxyMock(configuration)
powerLevelsMock.canUserJoinCallUserIDReturnValue = .success(false) roomInfoProxyMock.powerLevels = powerLevelsMock
let infoSubject = CurrentValueSubject<RoomInfoProxyProtocol, Never>(roomInfoProxyMock)
roomProxyMock.underlyingInfoPublisher = infoSubject.asCurrentValuePublisher() roomProxyMock.underlyingInfoPublisher = infoSubject.asCurrentValuePublisher()
let viewModel = RoomScreenViewModel(clientProxy: ClientProxyMock(), let viewModel = RoomScreenViewModel(clientProxy: ClientProxyMock(),
roomProxy: roomProxyMock, roomProxy: roomProxyMock,
initialSelectedPinnedEventID: nil, initialSelectedPinnedEventID: nil,
@@ -186,27 +190,25 @@ class RoomScreenViewModelTests: XCTestCase {
userIndicatorController: ServiceLocator.shared.userIndicatorController) userIndicatorController: ServiceLocator.shared.userIndicatorController)
self.viewModel = viewModel self.viewModel = viewModel
var deferred = deferFulfillment(viewModel.context.$viewState) { viewState in XCTAssertEqual(viewModel.state.roomTitle, "StartingName")
viewState.roomTitle == "StartingName" && XCTAssertEqual(viewModel.state.roomAvatar, .room(id: "TestID", name: "StartingName", avatarURL: nil))
viewState.roomAvatar == .room(id: "TestID", name: "StartingName", avatarURL: nil) && XCTAssertFalse(viewModel.state.canJoinCall)
!viewState.canJoinCall && XCTAssertFalse(viewModel.state.hasOngoingCall)
!viewState.hasOngoingCall
} let deferred = deferFulfillment(viewModel.context.$viewState) { viewState in
try await deferred.fulfill()
configuration.name = "NewName"
configuration.avatarURL = .mockMXCAvatar
configuration.hasOngoingCall = true
powerLevelsMock.canUserJoinCallUserIDReturnValue = .success(true)
deferred = deferFulfillment(viewModel.context.$viewState) { viewState in
viewState.roomTitle == "NewName" && viewState.roomTitle == "NewName" &&
viewState.roomAvatar == .room(id: "TestID", name: "NewName", avatarURL: .mockMXCAvatar) && viewState.roomAvatar == .room(id: "TestID", name: "NewName", avatarURL: .mockMXCAvatar) &&
viewState.canJoinCall && viewState.canJoinCall &&
viewState.hasOngoingCall viewState.hasOngoingCall
} }
configuration.name = "NewName"
configuration.avatarURL = .mockMXCAvatar
configuration.hasOngoingCall = true
powerLevelsMock.canUserJoinCallUserIDReturnValue = .success(true)
infoSubject.send(RoomInfoProxyMock(configuration)) infoSubject.send(RoomInfoProxyMock(configuration))
try await deferred.fulfill() try await deferred.fulfill()
} }