RoomMemberDetailsScreen (#727)

* generated files

* Revert "generated files"

This reverts commit f62c1dbcd9e505083ad4ff17532e2c054e253187.

* 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 d344a20d2e
commit 4586fbae39
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,34 +17,25 @@
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

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5afc94645db66763519ca354f302e3e3ca5e380c9a7a9b6d634b4fc9ed98c31c
size 73131

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1d6d23f2c8fb88c9baeab1181bbc1a59dc07fc36da4d9f3192f4e3d943e60a70
size 150567
oid sha256:abfabc7f8c5f26e407e1f6263678bff477afb1e44dff8f5d612e76cd367e41da
size 152690

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:65b9fb1f561d907511a365a893840e638fdab7905a16a3a67e8136ea86a7ae6d
size 77514

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7533db27ed76004d975897520ea841421fb54aa77c9966330e41771ef7c18dba
size 91562
oid sha256:90e6f07709c9cad3b3c2b085eaff8f546c1ff5b67d6f010c075c2a3b7ec78570
size 91291

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7d267a4f41ee25c78e7beba699d89f67fa03da101a7b2cf30a973731cc38267f
size 116394
oid sha256:02de3604c91bc4566ebb8a4805906f6567b6fb96c9e7d773a1e4351c57c6950e
size 116185

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5afc94645db66763519ca354f302e3e3ca5e380c9a7a9b6d634b4fc9ed98c31c
size 73131

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d52f746ba47e31fb6aaa72dc7def6cd3ce9bde9a32eb2bbe7ad0da16d2249745
size 109874
oid sha256:f53cb9fcc716b9d58c78a6fd31f6181c9ccee3561d53c0850fa8b0c586637241
size 109441

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f7d2db5f50221fcea17f3f0453b5098cae0dbdf16a1d531d34ad5656917481c4
size 146955
oid sha256:488bbb53e818a65a3b7e9aeeb362477c5550acca91296f5f994534a8271e7f7b
size 148772

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:65b9fb1f561d907511a365a893840e638fdab7905a16a3a67e8136ea86a7ae6d
size 77514

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.