diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index fb731de91..e59c9275f 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -8593,6 +8593,76 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable { } //MARK: - canUser + var canUserUserIDSendMessageUnderlyingCallsCount = 0 + var canUserUserIDSendMessageCallsCount: Int { + get { + if Thread.isMainThread { + return canUserUserIDSendMessageUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = canUserUserIDSendMessageUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + canUserUserIDSendMessageUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + canUserUserIDSendMessageUnderlyingCallsCount = newValue + } + } + } + } + var canUserUserIDSendMessageCalled: Bool { + return canUserUserIDSendMessageCallsCount > 0 + } + var canUserUserIDSendMessageReceivedArguments: (userID: String, messageType: MessageLikeEventType)? + var canUserUserIDSendMessageReceivedInvocations: [(userID: String, messageType: MessageLikeEventType)] = [] + + var canUserUserIDSendMessageUnderlyingReturnValue: Result! + var canUserUserIDSendMessageReturnValue: Result! { + get { + if Thread.isMainThread { + return canUserUserIDSendMessageUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = canUserUserIDSendMessageUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + canUserUserIDSendMessageUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + canUserUserIDSendMessageUnderlyingReturnValue = newValue + } + } + } + } + var canUserUserIDSendMessageClosure: ((String, MessageLikeEventType) async -> Result)? + + func canUser(userID: String, sendMessage messageType: MessageLikeEventType) async -> Result { + canUserUserIDSendMessageCallsCount += 1 + canUserUserIDSendMessageReceivedArguments = (userID: userID, messageType: messageType) + DispatchQueue.main.async { + self.canUserUserIDSendMessageReceivedInvocations.append((userID: userID, messageType: messageType)) + } + if let canUserUserIDSendMessageClosure = canUserUserIDSendMessageClosure { + return await canUserUserIDSendMessageClosure(userID, messageType) + } else { + return canUserUserIDSendMessageReturnValue + } + } + //MARK: - canUser + var canUserUserIDSendStateEventUnderlyingCallsCount = 0 var canUserUserIDSendStateEventCallsCount: Int { get { diff --git a/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift b/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift index dfba5ebf2..aa0e10ae7 100644 --- a/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift +++ b/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift @@ -36,6 +36,7 @@ struct JoinedRoomProxyMockConfiguration { var ownUserID = RoomMemberProxyMock.mockMe.userID var inviter: RoomMemberProxyProtocol? + var canUserSendMessage = true var canUserInvite = true var canUserTriggerRoomNotification = false var canUserJoinCall = true @@ -91,6 +92,7 @@ extension JoinedRoomProxyMock { return .success(member.role) } updatePowerLevelsForUsersReturnValue = .success(()) + canUserUserIDSendMessageReturnValue = .success(configuration.canUserSendMessage) canUserUserIDSendStateEventClosure = { [weak self] userID, _ in .success(self?.membersPublisher.value.first { $0.userID == userID }?.role ?? .user != .user) } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 83fe22475..0c9559ad7 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -40,6 +40,7 @@ struct RoomScreenViewState: BindableState { !pinnedEventsBannerState.isEmpty && lastScrollDirection != .top } + var canSendMessage = true var canJoinCall = false var hasOngoingCall: Bool var shouldShowCallButton = true diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 59db0e1e3..a68abe709 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -340,6 +340,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } let ownUserID = roomProxy.ownUserID + state.canSendMessage = await (try? roomProxy.canUser(userID: ownUserID, sendMessage: .roomMessage).get()) == true state.canJoinCall = await (try? roomProxy.canUserJoinCall(userID: ownUserID).get()) == true state.canAcceptKnocks = await (try? roomProxy.canUserInvite(userID: ownUserID).get()) == true state.canDeclineKnocks = await (try? roomProxy.canUserKick(userID: ownUserID).get()) == true diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 116b53280..ce7e431a1 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -42,7 +42,7 @@ struct RoomScreen: View { roomContext.send(viewAction: .footerViewAction(action)) } - composerToolbar + composer .padding(.bottom, composerToolbarContext.composerFormattingEnabled ? 8 : 12) .background { if composerToolbarContext.composerFormattingEnabled { @@ -188,6 +188,19 @@ struct RoomScreen: View { timelineContext.isScrolledToBottom && timelineContext.viewState.timelineState.isLive } + @ViewBuilder + private var composer: some View { + if roomContext.viewState.canSendMessage { + composerToolbar + } else { + Text(L10n.screenRoomTimelineNoPermissionToPost) + .font(.compound.bodyLG) + .foregroundStyle(.compound.textDisabled) + .multilineTextAlignment(.center) + .padding(.vertical, 10) // Matches the MessageComposerStyleModifier + } + } + @ViewBuilder private var loadingIndicator: some View { if timelineContext.viewState.showLoading { @@ -257,28 +270,50 @@ struct RoomScreen: View { // MARK: - Previews struct RoomScreen_Previews: PreviewProvider, TestablePreview { - static let roomProxyMock = JoinedRoomProxyMock(.init(id: "stable_id", - name: "Preview room", - hasOngoingCall: true)) - static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock) - static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock, - timelineController: MockTimelineController(), - mediaProvider: MediaProviderMock(configuration: .init()), - mediaPlayerProvider: MediaPlayerProviderMock(), - voiceMessageMediaManager: VoiceMessageMediaManagerMock(), - userIndicatorController: ServiceLocator.shared.userIndicatorController, - appMediator: AppMediatorMock.default, - appSettings: ServiceLocator.shared.settings, - analyticsService: ServiceLocator.shared.analytics, - emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), - timelineControllerFactory: TimelineControllerFactoryMock(.init()), - clientProxy: ClientProxyMock(.init())) + static let viewModels = makeViewModels() + static let readOnlyViewModels = makeViewModels(canSendMessage: false) static var previews: some View { NavigationStack { - RoomScreen(roomViewModel: roomViewModel, - timelineViewModel: timelineViewModel, + RoomScreen(roomViewModel: viewModels.room, + timelineViewModel: viewModels.timeline, composerToolbar: ComposerToolbar.mock()) } + .previewDisplayName("Normal") + + NavigationStack { + RoomScreen(roomViewModel: readOnlyViewModels.room, + timelineViewModel: readOnlyViewModels.timeline, + composerToolbar: ComposerToolbar.mock()) + } + .previewDisplayName("Read-only") + .snapshotPreferences(expect: readOnlyViewModels.room.context.$viewState.map { !$0.canSendMessage }) + } + + static func makeViewModels(canSendMessage: Bool = true) -> ViewModels { + let roomProxyMock = JoinedRoomProxyMock(.init(id: "stable_id", + name: "Preview room", + hasOngoingCall: true, + canUserSendMessage: canSendMessage)) + let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock) + let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock, + timelineController: MockTimelineController(), + mediaProvider: MediaProviderMock(configuration: .init()), + mediaPlayerProvider: MediaPlayerProviderMock(), + voiceMessageMediaManager: VoiceMessageMediaManagerMock(), + userIndicatorController: ServiceLocator.shared.userIndicatorController, + appMediator: AppMediatorMock.default, + appSettings: ServiceLocator.shared.settings, + analyticsService: ServiceLocator.shared.analytics, + emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings), + timelineControllerFactory: TimelineControllerFactoryMock(.init()), + clientProxy: ClientProxyMock(.init())) + + return .init(room: roomViewModel, timeline: timelineViewModel) + } + + struct ViewModels { + let room: RoomScreenViewModelProtocol + let timeline: TimelineViewModelProtocol } } diff --git a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift index 80f27bf07..1c717dab8 100644 --- a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift +++ b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift @@ -559,6 +559,15 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { } } + func canUser(userID: String, sendMessage messageType: MessageLikeEventType) async -> Result { + do { + return try await .success(room.canUserSendMessage(userId: userID, message: messageType)) + } catch { + MXLog.error("Failed checking if the user can send message with error: \(error)") + return .failure(.sdkError(error)) + } + } + func canUser(userID: String, sendStateEvent event: StateEventType) async -> Result { do { return try await .success(room.canUserSendState(userId: userID, stateEvent: event)) diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index cd82fbefc..5d4f31f08 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -150,6 +150,7 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol { func resetPowerLevels() async -> Result func suggestedRole(for userID: String) async -> Result func updatePowerLevelsForUsers(_ updates: [(userID: String, powerLevel: Int64)]) async -> Result + func canUser(userID: String, sendMessage messageType: MessageLikeEventType) async -> Result func canUser(userID: String, sendStateEvent event: StateEventType) async -> Result func canUserInvite(userID: String) async -> Result func canUserRedactOther(userID: String) async -> Result diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Normal-iPad-en-GB.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.iPad-en-GB-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Normal-iPad-en-GB.png diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Normal-iPad-pseudo.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.iPad-pseudo-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Normal-iPad-pseudo.png diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.iPhone-16-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Normal-iPhone-16-en-GB.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.iPhone-16-en-GB-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Normal-iPhone-16-en-GB.png diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.iPhone-16-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Normal-iPhone-16-pseudo.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.iPhone-16-pseudo-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Normal-iPhone-16-pseudo.png diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Read-only-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Read-only-iPad-en-GB.png new file mode 100644 index 000000000..a221ce13c --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Read-only-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30073fdf3797dd70a1a9a39301e355e46d78ce4ed064215e2222ea1e3ac4eff8 +size 296108 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Read-only-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Read-only-iPad-pseudo.png new file mode 100644 index 000000000..90610a2bd --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Read-only-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c8f9d9ad5dfe6a949e392eb6a2014a26fa1c99dcd1609eebacf9fa3129f1ced +size 306552 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Read-only-iPhone-16-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Read-only-iPhone-16-en-GB.png new file mode 100644 index 000000000..0548763c0 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Read-only-iPhone-16-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bcc07c762ebeab3cb9de5c0e516fb47704a7309e28c6bbbd335e07692e55ccfa +size 182334 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Read-only-iPhone-16-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Read-only-iPhone-16-pseudo.png new file mode 100644 index 000000000..0cf332c45 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Read-only-iPhone-16-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc29ca8433a626ddd150d7852483179592bfe060f6290e6b94820b4b59544eb0 +size 184921