RoomMemberDetailsScreen (#727)

* generated files

* Revert "generated files"

This reverts commit f62c1dbcd9.

* renaming files to RoomMembersList

* completed the renaming of the list files

* added generated files

* basic setup of the view and the mock

* added a new mock with a avatar

* share/copy link

* copyUserLink implemented

* removed unimplemented tests

* block user UI

* navigation to room member details added

* implemented but we require a sync from the Rust side

* adjusted some UI test screens

* alert for unblocking

* completed

* some tests

* changelog

* some unit tests

* improved the tests

* removed unused comment

* Update ElementX/Sources/Services/Room/RoomProxy.swift

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>

* optional displayName

* removing toggle

* removed cancel title

* Update UnitTests/Sources/RoomMemberDetailsViewModelTests.swift

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>

* removing Group

* pr suggestion

* better naming

* removed capitalizingFirstLetter

* Update ElementX/Sources/Other/Extensions/Alert.swift

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>

* trailing closure

* removed useless catch clause

* naming conformed to ignore

---------

Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>
This commit is contained in:
Mauro 2023-03-24 20:27:47 +01:00 committed by GitHub
parent 9cd38d1a4c
commit 8daa23b27a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 835 additions and 94 deletions

View File

@ -163,7 +163,7 @@
{
"identity" : "swift-snapshot-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing.git",
"state" : {
"revision" : "cef5b3f6f11781dd4591bdd1dd0a3d22bd609334",
"version" : "1.11.0"

View File

@ -5,6 +5,8 @@
"ios_no" = "No";
"action_confirm" = "Confirm";
"action_match" = "Match";
"action_copy_link" = "Copy Link";
"action_share_link" = "Share Link";
"message" = "Message";
@ -72,12 +74,19 @@
// Room Details
"room_details_title" = "Info";
"room_details_about_section_title" = "About";
"room_details_copy_link" = "Copy Link";
"room_details_leave_room_alert_subtitle" = "Are you sure that you want to leave the room?";
"room_details_leave_private_room_alert_subtitle" = "Are you sure that you want to leave this room? This room is not public and you will not be able to rejoin without an invite.";
"room_details_leave_empty_room_alert_subtitle" = "Are you sure that you want to leave this room? You are the only person here. If you leave, no one will be able to join in the future, including you.";
"room_details_room_left_toast" = "Room left";
// Room Member Details
"room_member_details_block_user" = "Block user";
"room_member_details_unblock_user" = "Unblock user";
"room_member_details_block_alert_action" = "Block";
"room_member_details_unblock_alert_action" = "Unblock";
"room_member_details_block_alert_description" = "Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime.";
"room_member_details_unblock_alert_description" = "On unblocking the user, you will be able to see all messages by them again.";
// Onboarding
"ftue_auth_carousel_welcome_title" = "Be in your Element";
"ftue_auth_carousel_welcome_body" = "Welcome to the %@ Beta. Supercharged, for speed and simplicity.";

View File

@ -14,8 +14,12 @@ extension ElementL10n {
public static let a11yAllChatsUserAvatarMenu = ElementL10n.tr("Untranslated", "a11y_all_chats_user_avatar_menu")
/// Confirm
public static let actionConfirm = ElementL10n.tr("Untranslated", "action_confirm")
/// Copy Link
public static let actionCopyLink = ElementL10n.tr("Untranslated", "action_copy_link")
/// Match
public static let actionMatch = ElementL10n.tr("Untranslated", "action_match")
/// Share Link
public static let actionShareLink = ElementL10n.tr("Untranslated", "action_share_link")
/// Attach Screenshot
public static let bugReportScreenAttachScreenshot = ElementL10n.tr("Untranslated", "bug_report_screen_attach_screenshot")
/// Please describe the bug. What did you do? What did you expect to happen? What actually happened. Please go into as much detail as you can.
@ -104,8 +108,6 @@ extension ElementL10n {
public static let retrievingDirectRoomError = ElementL10n.tr("Untranslated", "retrieving_direct_room_error")
/// About
public static let roomDetailsAboutSectionTitle = ElementL10n.tr("Untranslated", "room_details_about_section_title")
/// Copy Link
public static let roomDetailsCopyLink = ElementL10n.tr("Untranslated", "room_details_copy_link")
/// Are you sure that you want to leave this room? You are the only person here. If you leave, no one will be able to join in the future, including you.
public static let roomDetailsLeaveEmptyRoomAlertSubtitle = ElementL10n.tr("Untranslated", "room_details_leave_empty_room_alert_subtitle")
/// Are you sure that you want to leave this room? This room is not public and you will not be able to rejoin without an invite.
@ -116,6 +118,18 @@ extension ElementL10n {
public static let roomDetailsRoomLeftToast = ElementL10n.tr("Untranslated", "room_details_room_left_toast")
/// Info
public static let roomDetailsTitle = ElementL10n.tr("Untranslated", "room_details_title")
/// Block
public static let roomMemberDetailsBlockAlertAction = ElementL10n.tr("Untranslated", "room_member_details_block_alert_action")
/// Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime.
public static let roomMemberDetailsBlockAlertDescription = ElementL10n.tr("Untranslated", "room_member_details_block_alert_description")
/// Block user
public static let roomMemberDetailsBlockUser = ElementL10n.tr("Untranslated", "room_member_details_block_user")
/// Unblock
public static let roomMemberDetailsUnblockAlertAction = ElementL10n.tr("Untranslated", "room_member_details_unblock_alert_action")
/// On unblocking the user, you will be able to see all messages by them again.
public static let roomMemberDetailsUnblockAlertDescription = ElementL10n.tr("Untranslated", "room_member_details_unblock_alert_description")
/// Unblock user
public static let roomMemberDetailsUnblockUser = ElementL10n.tr("Untranslated", "room_member_details_unblock_user")
/// Failed loading messages
public static let roomTimelineBackpaginationFailure = ElementL10n.tr("Untranslated", "room_timeline_backpagination_failure")
/// Retry decryption

View File

@ -78,7 +78,51 @@ class RoomMemberProxyMock: RoomMemberProxyProtocol {
set(value) { underlyingNormalizedPowerLevel = value }
}
var underlyingNormalizedPowerLevel: Int!
var isAccountOwner: Bool {
get { return underlyingIsAccountOwner }
set(value) { underlyingIsAccountOwner = value }
}
var underlyingIsAccountOwner: Bool!
var isIgnored: Bool {
get { return underlyingIsIgnored }
set(value) { underlyingIsIgnored = value }
}
var underlyingIsIgnored: Bool!
//MARK: - ignoreUser
var ignoreUserCallsCount = 0
var ignoreUserCalled: Bool {
return ignoreUserCallsCount > 0
}
var ignoreUserReturnValue: Result<Void, RoomMemberProxyError>!
var ignoreUserClosure: (() async -> Result<Void, RoomMemberProxyError>)?
func ignoreUser() async -> Result<Void, RoomMemberProxyError> {
ignoreUserCallsCount += 1
if let ignoreUserClosure = ignoreUserClosure {
return await ignoreUserClosure()
} else {
return ignoreUserReturnValue
}
}
//MARK: - unignoreUser
var unignoreUserCallsCount = 0
var unignoreUserCalled: Bool {
return unignoreUserCallsCount > 0
}
var unignoreUserReturnValue: Result<Void, RoomMemberProxyError>!
var unignoreUserClosure: (() async -> Result<Void, RoomMemberProxyError>)?
func unignoreUser() async -> Result<Void, RoomMemberProxyError> {
unignoreUserCallsCount += 1
if let unignoreUserClosure = unignoreUserClosure {
return await unignoreUserClosure()
} else {
return unignoreUserReturnValue
}
}
}
class RoomProxyMock: RoomProxyProtocol {
var id: String {

View File

@ -20,11 +20,13 @@ import MatrixRustSDK
struct RoomMemberProxyMockConfiguration {
var userID: String
var displayName: String
var avatarURL: String?
var avatarURL: URL?
var membership: MembershipState
var isNameAmbiguous: Bool
var powerLevel: Int
var normalizedPowerLevel: Int
var isAccountOwner: Bool
var isIgnored: Bool
}
extension RoomMemberProxyMock {
@ -32,43 +34,85 @@ extension RoomMemberProxyMock {
self.init()
userID = configuration.userID
displayName = configuration.displayName
if let avatarURL = configuration.avatarURL {
self.avatarURL = URL(string: avatarURL)
}
avatarURL = configuration.avatarURL
membership = configuration.membership
isNameAmbiguous = configuration.isNameAmbiguous
powerLevel = configuration.powerLevel
normalizedPowerLevel = configuration.normalizedPowerLevel
isAccountOwner = configuration.isAccountOwner
isIgnored = configuration.isIgnored
}
// Mocks
static var mockAlice: RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "alice@matrix.org",
RoomMemberProxyMock(with: .init(userID: "@alice:matrix.org",
displayName: "Alice",
avatarURL: nil,
membership: .join,
isNameAmbiguous: false,
powerLevel: 50,
normalizedPowerLevel: 50))
normalizedPowerLevel: 50,
isAccountOwner: false,
isIgnored: false))
}
static var mockBob: RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "bob@matrix.org",
RoomMemberProxyMock(with: .init(userID: "@bob:matrix.org",
displayName: "Bob",
avatarURL: nil,
membership: .join,
isNameAmbiguous: false,
powerLevel: 50,
normalizedPowerLevel: 50))
normalizedPowerLevel: 50,
isAccountOwner: false,
isIgnored: false))
}
static var mockCharlie: RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "charlie@matrix.org",
RoomMemberProxyMock(with: .init(userID: "@charlie:matrix.org",
displayName: "Charlie",
avatarURL: nil,
membership: .join,
isNameAmbiguous: false,
powerLevel: 50,
normalizedPowerLevel: 50))
normalizedPowerLevel: 50,
isAccountOwner: false,
isIgnored: false))
}
static var mockDan: RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "@dan:matrix.org",
displayName: "Dan",
avatarURL: URL.picturesDirectory,
membership: .join,
isNameAmbiguous: false,
powerLevel: 50,
normalizedPowerLevel: 50,
isAccountOwner: false,
isIgnored: false))
}
static var mockMe: RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "@me:matrix.org",
displayName: "Me",
avatarURL: URL.picturesDirectory,
membership: .join,
isNameAmbiguous: false,
powerLevel: 50,
normalizedPowerLevel: 50,
isAccountOwner: true,
isIgnored: false))
}
static var mockIgnored: RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "@ignored:matrix.org",
displayName: "Ignored",
avatarURL: nil,
membership: .join,
isNameAmbiguous: false,
powerLevel: 50,
normalizedPowerLevel: 50,
isAccountOwner: false,
isIgnored: true))
}
}

View File

@ -47,6 +47,7 @@ enum UserAvatarSizeOnScreen {
case settings
case roomDetails
case startChat
case memberDetails
var value: CGFloat {
switch self {
@ -60,6 +61,8 @@ enum UserAvatarSizeOnScreen {
return 44
case .startChat:
return 36
case .memberDetails:
return 70
}
}
}

View File

@ -21,7 +21,7 @@ protocol AlertItem {
}
extension View {
func alert<Item, Actions, Message>(item: Binding<Item?>, actions: (Item) -> Actions, message: (Item) -> Message) -> some View where Item: AlertItem, Actions: View, Message: View {
func alert<Item, Actions, Message>(item: Binding<Item?>, @ViewBuilder actions: (Item) -> Actions, @ViewBuilder message: (Item) -> Message) -> some View where Item: AlertItem, Actions: View, Message: View {
let binding = Binding<Bool>(get: {
item.wrappedValue != nil
}, set: { newValue in
@ -32,7 +32,7 @@ extension View {
return alert(item.wrappedValue?.title ?? "", isPresented: binding, presenting: item.wrappedValue, actions: actions, message: message)
}
func alert<Item, Actions>(item: Binding<Item?>, actions: (Item) -> Actions) -> some View where Item: AlertItem, Actions: View {
func alert<Item, Actions>(item: Binding<Item?>, @ViewBuilder actions: (Item) -> Actions) -> some View where Item: AlertItem, Actions: View {
let binding = Binding<Bool>(get: {
item.wrappedValue != nil
}, set: { newValue in
@ -43,3 +43,29 @@ extension View {
return alert(item.wrappedValue?.title ?? "", isPresented: binding, presenting: item.wrappedValue, actions: actions)
}
}
// Only for Alerts that display a simple error message with a message and one or two buttons
struct ErrorAlertItem: AlertItem {
struct Action {
var title: String
var action: () -> Void
}
var title = ElementL10n.dialogTitleError
var message = ElementL10n.unknownError
var cancelAction = Action(title: ElementL10n.ok, action: { })
var primaryAction: Action?
}
extension View {
func errorAlert(item: Binding<ErrorAlertItem?>) -> some View {
alert(item: item) { item in
Button(item.cancelAction.title) { item.cancelAction.action() }
if let primaryAction = item.primaryAction {
Button(primaryAction.title) { primaryAction.action() }
}
} message: { item in
Text(item.message)
}
}
}

View File

@ -0,0 +1,37 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
extension Sequence {
func asyncMap<T>(_ transform: @escaping (Element) async -> T) async -> [T] {
await withTaskGroup(of: T.self) { group in
var transformedElements = [T]()
for element in self {
group.addTask {
await transform(element)
}
}
for await transformedElement in group {
transformedElements.append(transformedElement)
}
return transformedElements
}
}
}

View File

@ -49,7 +49,7 @@ final class RoomDetailsCoordinator: CoordinatorProtocol {
switch action {
case .requestMemberDetailsPresentation(let members):
self.presentRoomMemberDetails(members)
self.presentRoomMembersList(members)
case .cancel:
self.callback?(.cancel)
case .leftRoom:
@ -62,13 +62,11 @@ final class RoomDetailsCoordinator: CoordinatorProtocol {
AnyView(RoomDetailsScreen(context: viewModel.context))
}
private func presentRoomMemberDetails(_ members: [RoomMemberProxyProtocol]) {
let params = RoomMemberDetailsCoordinatorParameters(mediaProvider: parameters.mediaProvider,
members: members)
let coordinator = RoomMemberDetailsCoordinator(parameters: params)
coordinator.callback = { [weak self] _ in
self?.navigationStackCoordinator.pop()
}
private func presentRoomMembersList(_ members: [RoomMemberProxyProtocol]) {
let params = RoomMembersListCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
mediaProvider: parameters.mediaProvider,
members: members)
let coordinator = RoomMembersListCoordinator(parameters: params)
navigationStackCoordinator.push(coordinator)
}

View File

@ -86,6 +86,7 @@ struct RoomDetailsMember: Identifiable, Equatable {
let name: String?
let avatarURL: URL?
@MainActor
init(withProxy proxy: RoomMemberProxyProtocol) {
id = proxy.userID
name = proxy.displayName

View File

@ -69,7 +69,7 @@ struct RoomDetailsScreen: View {
Button { context.send(viewAction: .copyRoomLink) } label: {
Image(systemName: "link")
}
.buttonStyle(FormActionButtonStyle(title: ElementL10n.roomDetailsCopyLink))
.buttonStyle(FormActionButtonStyle(title: ElementL10n.actionCopyLink))
ShareLink(item: permalink) {
Image(systemName: "square.and.arrow.up")

View File

@ -17,35 +17,26 @@
import SwiftUI
struct RoomMemberDetailsCoordinatorParameters {
let roomMemberProxy: RoomMemberProxyProtocol
let mediaProvider: MediaProviderProtocol
let members: [RoomMemberProxyProtocol]
}
enum RoomMemberDetailsCoordinatorAction {
case cancel
}
enum RoomMemberDetailsCoordinatorAction { }
final class RoomMemberDetailsCoordinator: CoordinatorProtocol {
private let parameters: RoomMemberDetailsCoordinatorParameters
private var viewModel: RoomMemberDetailsViewModelProtocol
var callback: ((RoomMemberDetailsCoordinatorAction) -> Void)?
init(parameters: RoomMemberDetailsCoordinatorParameters) {
viewModel = RoomMemberDetailsViewModel(mediaProvider: parameters.mediaProvider,
members: parameters.members)
self.parameters = parameters
viewModel = RoomMemberDetailsViewModel(roomMemberProxy: parameters.roomMemberProxy, mediaProvider: parameters.mediaProvider)
}
func start() {
viewModel.callback = { [weak self] action in
guard let self else { return }
switch action {
case .cancel:
self.callback?(.cancel)
}
}
}
func start() { }
func toPresentable() -> AnyView {
AnyView(RoomMemberDetailsScreen(context: viewModel.context))
}

View File

@ -0,0 +1,81 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
enum RoomMemberDetailsViewModelAction { }
struct RoomMemberDetailsViewState: BindableState {
let userID: String
let name: String?
let avatarURL: URL?
let isAccountOwner: Bool
let permalink: URL?
var isIgnored: Bool
var bindings: RoomMemberDetailsViewStateBindings
}
struct RoomMemberDetailsViewStateBindings {
var ignoreUserAlert: IgnoreUserAlertItem?
var errorAlert: ErrorAlertItem?
}
struct IgnoreUserAlertItem: AlertItem {
enum Action {
case ignore
case unignore
}
let action: Action
let cancelTitle = ElementL10n.actionCancel
var title: String {
switch action {
case .ignore: return ElementL10n.roomMemberDetailsBlockUser
case .unignore: return ElementL10n.roomMemberDetailsUnblockUser
}
}
var confirmationTitle: String {
switch action {
case .ignore: return ElementL10n.roomMemberDetailsBlockAlertAction
case .unignore: return ElementL10n.roomMemberDetailsUnblockAlertAction
}
}
var description: String {
switch action {
case .ignore: return ElementL10n.roomMemberDetailsBlockAlertDescription
case .unignore: return ElementL10n.roomMemberDetailsUnblockAlertDescription
}
}
var viewAction: RoomMemberDetailsViewAction {
switch action {
case .ignore: return .ignoreConfirmed
case .unignore: return .unignoreConfirmed
}
}
}
enum RoomMemberDetailsViewAction {
case showUnblockAlert
case showBlockAlert
case ignoreConfirmed
case unignoreConfirmed
case copyUserLink
}

View File

@ -0,0 +1,83 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
typealias RoomMemberDetailsViewModelType = StateStoreViewModel<RoomMemberDetailsViewState, RoomMemberDetailsViewAction>
class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDetailsViewModelProtocol {
let roomMemberProxy: RoomMemberProxyProtocol
var callback: ((RoomMemberDetailsViewModelAction) -> Void)?
init(roomMemberProxy: RoomMemberProxyProtocol, mediaProvider: MediaProviderProtocol) {
self.roomMemberProxy = roomMemberProxy
let initialViewState = RoomMemberDetailsViewState(userID: roomMemberProxy.userID,
name: roomMemberProxy.displayName,
avatarURL: roomMemberProxy.avatarURL,
isAccountOwner: roomMemberProxy.isAccountOwner,
permalink: roomMemberProxy.permalink,
isIgnored: roomMemberProxy.isIgnored,
bindings: .init())
super.init(initialViewState: initialViewState, imageProvider: mediaProvider)
}
// MARK: - Public
override func process(viewAction: RoomMemberDetailsViewAction) async {
switch viewAction {
case .showUnblockAlert:
state.bindings.ignoreUserAlert = .init(action: .unignore)
case .showBlockAlert:
state.bindings.ignoreUserAlert = .init(action: .ignore)
case .copyUserLink:
copyUserLink()
case .ignoreConfirmed:
await ignoreUser()
case .unignoreConfirmed:
await unignoreUser()
}
}
// MARK: - Private
private func copyUserLink() {
if let userLink = state.permalink {
UIPasteboard.general.url = userLink
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: ElementL10n.linkCopiedToClipboard))
} else {
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: ElementL10n.unknownError))
}
}
private func ignoreUser() async {
switch await roomMemberProxy.ignoreUser() {
case .success:
state.isIgnored = true
case .failure:
state.bindings.errorAlert = .init()
}
}
private func unignoreUser() async {
switch await roomMemberProxy.unignoreUser() {
case .success:
state.isIgnored = false
case .failure:
state.bindings.errorAlert = .init()
}
}
}

View File

@ -0,0 +1,137 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct RoomMemberDetailsScreen: View {
@ObservedObject var context: RoomMemberDetailsViewModel.Context
var body: some View {
Form {
headerSection
// TODO: Uncomment when the feature is ready
// if !context.viewState.isAccountOwner {
// blockUserSection
// }
}
.scrollContentBackground(.hidden)
.background(Color.element.formBackground.ignoresSafeArea())
.alert(item: $context.ignoreUserAlert, actions: blockUserAlertActions, message: blockUserAlertMessage)
.errorAlert(item: $context.errorAlert)
}
// MARK: - Private
private var headerSection: some View {
VStack(spacing: 8.0) {
LoadableAvatarImage(url: context.viewState.avatarURL,
name: context.viewState.name,
contentID: context.viewState.userID,
avatarSize: .user(on: .memberDetails),
imageProvider: context.imageProvider)
if let name = context.viewState.name {
Text(name)
.foregroundColor(.element.primaryContent)
.font(.element.title1Bold)
.multilineTextAlignment(.center)
}
Text(context.viewState.userID)
.foregroundColor(.element.secondaryContent)
.font(.element.body)
.multilineTextAlignment(.center)
if let permalink = context.viewState.permalink {
HStack(spacing: 32) {
Button { context.send(viewAction: .copyUserLink) } label: {
Image(systemName: "link")
}
.buttonStyle(FormActionButtonStyle(title: ElementL10n.actionCopyLink))
ShareLink(item: permalink) {
Image(systemName: "square.and.arrow.up")
}
.buttonStyle(FormActionButtonStyle(title: ElementL10n.actionShareLink))
}
.padding(.top, 32)
}
}
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
}
private var blockUserSection: some View {
Section {
if context.viewState.isIgnored {
Button {
context.send(viewAction: .showUnblockAlert)
} label: {
Label(ElementL10n.roomMemberDetailsUnblockUser, systemImage: "slash.circle")
}
.buttonStyle(FormButtonStyle(accessory: nil))
} else {
Button(role: .destructive) {
context.send(viewAction: .showBlockAlert)
} label: {
Label(ElementL10n.roomMemberDetailsBlockUser, systemImage: "slash.circle")
}
.buttonStyle(FormButtonStyle(accessory: nil))
}
}
.formSectionStyle()
}
@ViewBuilder
private func blockUserAlertActions(_ item: IgnoreUserAlertItem) -> some View {
Button(item.cancelTitle, role: .cancel) { }
Button(item.confirmationTitle,
role: item.action == .ignore ? .destructive : nil) {
context.send(viewAction: item.viewAction)
}
}
private func blockUserAlertMessage(_ item: IgnoreUserAlertItem) -> some View {
Text(item.description)
}
}
// MARK: - Previews
struct RoomMemberDetails_Previews: PreviewProvider {
static let otherUserViewModel = {
let member = RoomMemberProxyMock.mockDan
return RoomMemberDetailsViewModel(roomMemberProxy: member, mediaProvider: MockMediaProvider())
}()
static let accountOwnerViewModel = {
let member = RoomMemberProxyMock.mockMe
return RoomMemberDetailsViewModel(roomMemberProxy: member, mediaProvider: MockMediaProvider())
}()
static let ignoredUserViewModel = {
let member = RoomMemberProxyMock.mockIgnored
return RoomMemberDetailsViewModel(roomMemberProxy: member, mediaProvider: MockMediaProvider())
}()
static var previews: some View {
RoomMemberDetailsScreen(context: otherUserViewModel.context)
.previewDisplayName("Other User")
RoomMemberDetailsScreen(context: accountOwnerViewModel.context)
.previewDisplayName("Account Owner")
RoomMemberDetailsScreen(context: ignoredUserViewModel.context)
.previewDisplayName("Ignored User")
}
}

View File

@ -0,0 +1,64 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct RoomMembersListCoordinatorParameters {
let navigationStackCoordinator: NavigationStackCoordinator
let mediaProvider: MediaProviderProtocol
let members: [RoomMemberProxyProtocol]
}
enum RoomMembersListCoordinatorAction { }
final class RoomMembersListCoordinator: CoordinatorProtocol {
private let parameters: RoomMembersListCoordinatorParameters
private var viewModel: RoomMembersListViewModelProtocol
private var navigationStackCoordinator: NavigationStackCoordinator { parameters.navigationStackCoordinator }
var callback: ((RoomMembersListCoordinatorAction) -> Void)?
init(parameters: RoomMembersListCoordinatorParameters) {
self.parameters = parameters
viewModel = RoomMembersListViewModel(mediaProvider: parameters.mediaProvider,
members: parameters.members)
}
func start() {
viewModel.callback = { [weak self] action in
guard let self else { return }
switch action {
case let .selectMember(member):
self.selectMember(member)
}
}
}
func toPresentable() -> AnyView {
AnyView(RoomMembersListScreen(context: viewModel.context))
}
// MARK: - Private
private func selectMember(_ member: RoomMemberProxyProtocol) {
let parameters = RoomMemberDetailsCoordinatorParameters(roomMemberProxy: member, mediaProvider: parameters.mediaProvider)
let coordinator = RoomMemberDetailsCoordinator(parameters: parameters)
navigationStackCoordinator.push(coordinator)
}
}

View File

@ -16,14 +16,14 @@
import Foundation
enum RoomMemberDetailsViewModelAction {
case cancel
enum RoomMembersListViewModelAction {
case selectMember(_ member: RoomMemberProxyProtocol)
}
struct RoomMemberDetailsViewState: BindableState {
struct RoomMembersListViewState: BindableState {
var members: [RoomDetailsMember]
var bindings: RoomMemberDetailsViewStateBindings
var bindings: RoomMembersListViewStateBindings
var visibleMembers: [RoomDetailsMember] {
if bindings.searchQuery.isEmpty {
@ -37,13 +37,13 @@ struct RoomMemberDetailsViewState: BindableState {
}
}
struct RoomMemberDetailsViewStateBindings {
struct RoomMembersListViewStateBindings {
var searchQuery = ""
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<RoomDetailsErrorType>?
}
enum RoomMemberDetailsViewAction {
enum RoomMembersListViewAction {
case selectMember(id: String)
}

View File

@ -16,16 +16,18 @@
import SwiftUI
typealias RoomMemberDetailsViewModelType = StateStoreViewModel<RoomMemberDetailsViewState, RoomMemberDetailsViewAction>
typealias RoomMembersListViewModelType = StateStoreViewModel<RoomMembersListViewState, RoomMembersListViewAction>
class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDetailsViewModelProtocol {
class RoomMembersListViewModel: RoomMembersListViewModelType, RoomMembersListViewModelProtocol {
private let mediaProvider: MediaProviderProtocol
private let members: [RoomMemberProxyProtocol]
var callback: ((RoomMemberDetailsViewModelAction) -> Void)?
var callback: ((RoomMembersListViewModelAction) -> Void)?
init(mediaProvider: MediaProviderProtocol,
members: [RoomMemberProxyProtocol]) {
self.mediaProvider = mediaProvider
self.members = members
super.init(initialViewState: .init(members: members.map { RoomDetailsMember(withProxy: $0) },
bindings: .init()),
imageProvider: mediaProvider)
@ -33,10 +35,14 @@ class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDeta
// MARK: - Public
override func process(viewAction: RoomMemberDetailsViewAction) async {
override func process(viewAction: RoomMembersListViewAction) async {
switch viewAction {
case .selectMember(let id):
MXLog.debug("Member selected: \(id)")
guard let member = members.first(where: { $0.userID == id }) else {
MXLog.error("Selected member \(id) not found")
return
}
callback?(.selectMember(member))
}
}
}

View File

@ -0,0 +1,23 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
@MainActor
protocol RoomMembersListViewModelProtocol {
var callback: ((RoomMembersListViewModelAction) -> Void)? { get set }
var context: RoomMembersListViewModelType.Context { get }
}

View File

@ -16,11 +16,11 @@
import SwiftUI
struct RoomMemberDetailsMemberCell: View {
struct RoomMembersListMemberCell: View {
@ScaledMetric private var avatarSize = AvatarSize.user(on: .roomDetails).value
let member: RoomDetailsMember
let context: RoomMemberDetailsViewModel.Context
let context: RoomMembersListViewModel.Context
var body: some View {
Button {
@ -46,19 +46,19 @@ struct RoomMemberDetailsMemberCell: View {
}
}
struct RoomMemberDetailsMemberCell_Previews: PreviewProvider {
struct RoomMembersListMemberCell_Previews: PreviewProvider {
static var previews: some View {
let members: [RoomMemberProxyMock] = [
.mockAlice,
.mockBob,
.mockCharlie
]
let viewModel = RoomMemberDetailsViewModel(mediaProvider: MockMediaProvider(),
members: members)
let viewModel = RoomMembersListViewModel(mediaProvider: MockMediaProvider(),
members: members)
return VStack {
ForEach(members, id: \.userID) { member in
RoomMemberDetailsMemberCell(member: .init(withProxy: member), context: viewModel.context)
RoomMembersListMemberCell(member: .init(withProxy: member), context: viewModel.context)
}
}
}

View File

@ -16,17 +16,17 @@
import SwiftUI
struct RoomMemberDetailsScreen: View {
struct RoomMembersListScreen: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var context: RoomMemberDetailsViewModel.Context
@ObservedObject var context: RoomMembersListViewModel.Context
var body: some View {
ScrollView {
LazyVStack(alignment: .leading) {
Section {
ForEach(context.viewState.visibleMembers) { member in
RoomMemberDetailsMemberCell(member: member, context: context)
RoomMembersListMemberCell(member: member, context: context)
.id(member.id)
}
} header: {
@ -48,20 +48,20 @@ struct RoomMemberDetailsScreen: View {
// MARK: - Previews
struct RoomMemberDetails_Previews: PreviewProvider {
struct RoomMembersList_Previews: PreviewProvider {
static let viewModel = {
let members: [RoomMemberProxyMock] = [
.mockAlice,
.mockBob,
.mockCharlie
]
return RoomMemberDetailsViewModel(mediaProvider: MockMediaProvider(),
members: members)
return RoomMembersListViewModel(mediaProvider: MockMediaProvider(),
members: members)
}()
static var previews: some View {
NavigationStack {
RoomMemberDetailsScreen(context: viewModel.context)
RoomMembersListScreen(context: viewModel.context)
}
}
}

View File

@ -18,9 +18,16 @@ import Foundation
import MatrixRustSDK
final class RoomMemberProxy: RoomMemberProxyProtocol {
private let backgroundTaskService: BackgroundTaskServiceProtocol
private let member: RoomMemberProtocol
init(member: RoomMemberProtocol) {
private let backgroundAccountDataTaskName = "SendAccountDataEvent"
private var sendAccountDataEventBackgroundTask: BackgroundTaskProtocol?
private let userInitiatedDispatchQueue = DispatchQueue(label: "io.element.elementx.roommemberproxy.userinitiated", qos: .userInitiated)
init(member: RoomMemberProtocol, backgroundTaskService: BackgroundTaskServiceProtocol) {
self.backgroundTaskService = backgroundTaskService
self.member = member
}
@ -51,4 +58,44 @@ final class RoomMemberProxy: RoomMemberProxyProtocol {
var normalizedPowerLevel: Int {
Int(member.normalizedPowerLevel())
}
var isAccountOwner: Bool {
member.isAccountUser()
}
var isIgnored: Bool {
member.isIgnored()
}
func ignoreUser() async -> Result<Void, RoomMemberProxyError> {
sendAccountDataEventBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundAccountDataTaskName, isReusable: true)
defer {
sendAccountDataEventBackgroundTask?.stop()
}
return await Task.dispatch(on: userInitiatedDispatchQueue) {
do {
try self.member.ignore()
return .success(())
} catch {
return .failure(.ignoreUserFailed)
}
}
}
func unignoreUser() async -> Result<Void, RoomMemberProxyError> {
sendAccountDataEventBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundAccountDataTaskName, isReusable: true)
defer {
sendAccountDataEventBackgroundTask?.stop()
}
return await Task.dispatch(on: userInitiatedDispatchQueue) {
do {
try self.member.unignore()
return .success(())
} catch {
return .failure(.unignoreUserFailed)
}
}
}
}

View File

@ -17,6 +17,12 @@
import Foundation
import MatrixRustSDK
enum RoomMemberProxyError: Error {
case ignoreUserFailed
case unignoreUserFailed
}
@MainActor
// sourcery: AutoMockable
protocol RoomMemberProxyProtocol {
var userID: String { get }
@ -26,4 +32,15 @@ protocol RoomMemberProxyProtocol {
var isNameAmbiguous: Bool { get }
var powerLevel: Int { get }
var normalizedPowerLevel: Int { get }
var isAccountOwner: Bool { get }
var isIgnored: Bool { get }
func ignoreUser() async -> Result<Void, RoomMemberProxyError>
func unignoreUser() async -> Result<Void, RoomMemberProxyError>
}
extension RoomMemberProxyProtocol {
var permalink: URL? {
try? PermalinkBuilder.permalinkTo(userIdentifier: userID)
}
}

View File

@ -272,13 +272,16 @@ class RoomProxy: RoomProxyProtocol {
}
func members() async -> Result<[RoomMemberProxyProtocol], RoomProxyError> {
await Task.dispatch(on: .global()) {
do {
do {
let members = try await Task.dispatch(on: .global()) {
let members = try self.room.members()
return .success(members.map { RoomMemberProxy(member: $0) })
} catch {
return .failure(.failedRetrievingMembers)
return members
}
let proxiedMembers = await members.asyncMap { RoomMemberProxy(member: $0, backgroundTaskService: self.backgroundTaskService) }
return .success(proxiedMembers)
} catch {
return .failure(.failedRetrievingMembers)
}
}

View File

@ -295,11 +295,12 @@ class MockScreen: Identifiable {
mediaProvider: MockMediaProvider()))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .roomMemberDetailsScreen:
case .roomMembersListScreen:
let navigationStackCoordinator = NavigationStackCoordinator()
let members: [RoomMemberProxyMock] = [.mockAlice, .mockBob, .mockCharlie]
let coordinator = RoomMemberDetailsCoordinator(parameters: .init(mediaProvider: MockMediaProvider(),
members: members))
let coordinator = RoomMembersListCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator,
mediaProvider: MockMediaProvider(),
members: members))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .reportContent:
@ -312,6 +313,11 @@ class MockScreen: Identifiable {
let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider())))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .roomMemberDetailsAccountOwner:
let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = RoomMemberDetailsCoordinator(parameters: .init(roomMemberProxy: RoomMemberProxyMock.mockMe, mediaProvider: MockMediaProvider()))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
}
}()
}

View File

@ -42,7 +42,8 @@ enum UITestsScreenIdentifier: String {
case userSessionScreen
case roomDetailsScreen
case roomDetailsScreenWithRoomAvatar
case roomMemberDetailsScreen
case roomMembersListScreen
case roomMemberDetailsAccountOwner
case reportContent
case startChat
}

View File

@ -18,9 +18,8 @@ import ElementX
import XCTest
class RoomMemberDetailsScreenUITests: XCTestCase {
func testInitialStateComponents() {
let app = Application.launch(.roomMemberDetailsScreen)
app.assertScreenshot(.roomMemberDetailsScreen)
func testInitialStateComponentsForAccountOwner() {
let app = Application.launch(.roomMemberDetailsAccountOwner)
app.assertScreenshot(.roomMemberDetailsAccountOwner)
}
}

View File

@ -0,0 +1,26 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import ElementX
import XCTest
class RoomMembersListScreenUITests: XCTestCase {
func testInitialStateComponents() {
let app = Application.launch(.roomMembersListScreen)
app.assertScreenshot(.roomMembersListScreen)
}
}

View File

@ -19,4 +19,50 @@ import XCTest
@testable import ElementX
@MainActor
class RoomMemberDetailsScreenViewModelTests: XCTestCase { }
class RoomMemberDetailsViewModelTests: XCTestCase {
var viewModel: RoomMemberDetailsViewModelProtocol!
var roomMemberProxyMock: RoomMemberProxyMock!
var context: RoomMemberDetailsViewModelType.Context { viewModel.context }
func testInitialState() async {
roomMemberProxyMock = RoomMemberProxyMock.mockAlice
viewModel = RoomMemberDetailsViewModel(roomMemberProxy: roomMemberProxyMock, mediaProvider: MockMediaProvider())
XCTAssertEqual(context.viewState.name, "Alice")
XCTAssertFalse(context.viewState.isAccountOwner)
XCTAssertFalse(context.viewState.isIgnored)
XCTAssertEqual(context.viewState.userID, "@alice:matrix.org")
XCTAssertEqual(context.viewState.permalink, URL(string: "https://matrix.to/#/@alice:matrix.org"))
XCTAssertEqual(context.viewState.avatarURL, nil)
XCTAssertNil(context.ignoreUserAlert)
XCTAssertNil(context.errorAlert)
}
func testInitialStateAccountOwner() async {
roomMemberProxyMock = RoomMemberProxyMock.mockMe
viewModel = RoomMemberDetailsViewModel(roomMemberProxy: roomMemberProxyMock, mediaProvider: MockMediaProvider())
XCTAssertEqual(context.viewState.name, "Me")
XCTAssertTrue(context.viewState.isAccountOwner)
XCTAssertFalse(context.viewState.isIgnored)
XCTAssertEqual(context.viewState.userID, "@me:matrix.org")
XCTAssertEqual(context.viewState.permalink, URL(string: "https://matrix.to/#/@me:matrix.org"))
XCTAssertEqual(context.viewState.avatarURL, URL.picturesDirectory)
XCTAssertNil(context.ignoreUserAlert)
XCTAssertNil(context.errorAlert)
}
func testInitialStateIgnoredUser() async {
roomMemberProxyMock = RoomMemberProxyMock.mockIgnored
viewModel = RoomMemberDetailsViewModel(roomMemberProxy: roomMemberProxyMock, mediaProvider: MockMediaProvider())
XCTAssertEqual(context.viewState.name, "Ignored")
XCTAssertFalse(context.viewState.isAccountOwner)
XCTAssertTrue(context.viewState.isIgnored)
XCTAssertEqual(context.viewState.userID, "@ignored:matrix.org")
XCTAssertEqual(context.viewState.permalink, URL(string: "https://matrix.to/#/@ignored:matrix.org"))
XCTAssertEqual(context.viewState.avatarURL, nil)
XCTAssertNil(context.ignoreUserAlert)
XCTAssertNil(context.errorAlert)
}
}

View File

@ -0,0 +1,22 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
@testable import ElementX
@MainActor
class RoomMembersListScreenViewModelTests: XCTestCase { }

1
changelog.d/723.feature Normal file
View File

@ -0,0 +1 @@
Added the Room Member Details Screen.