DM Details View (#744)

* Basic implementation completed but it requires some bug fixing and polishing (+4 squashed commits)
Squashed commits:
[43f0064e] now the content of the room details reflects the expected dm details content when the conditions are met
[fe6f2b5e] replaced existing headers with a composable and reusable HeaderView
[3c00120a] now the members are fetched every time the room is opened
[421d539b] refactored the members fetching to be immediate in case the room could be a direct chat

* polished the code and fixed some bugs that prevented the updated state of the members to be displayed in the room details view (+1 squashed commit)
Squashed commits:
[6bf98473] fix for the button not being destructive in room details screen

* removed copy button and updated copy

* moved a file and renamed header view

* addressed all PR comments

* sdk bump

* UI tests

* unit tests

* changelog

* updated mocks

* removed a duplicated signature in a mock
This commit is contained in:
Mauro
2023-04-03 13:18:19 +02:00
committed by GitHub
parent 220138d492
commit 0f858d977f
35 changed files with 1241 additions and 241 deletions
+23 -1
View File
@@ -88,7 +88,7 @@ public enum L10n {
public static var actionSearch: String { return L10n.tr("Localizable", "action_search") }
/// Send
public static var actionSend: String { return L10n.tr("Localizable", "action_send") }
/// Share link
/// Share
public static var actionShareLink: String { return L10n.tr("Localizable", "action_share_link") }
/// Skip
public static var actionSkip: String { return L10n.tr("Localizable", "action_skip") }
@@ -108,6 +108,8 @@ public enum L10n {
public static var commonAudio: String { return L10n.tr("Localizable", "common_audio") }
/// Bubbles
public static var commonBubbles: String { return L10n.tr("Localizable", "common_bubbles") }
/// Creating room…
public static var commonCreatingRoom: String { return L10n.tr("Localizable", "common_creating_room") }
/// Decryption error
public static var commonDecryptionError: String { return L10n.tr("Localizable", "common_decryption_error") }
/// Developer options
@@ -230,6 +232,8 @@ public enum L10n {
public static var errorFailedCreatingThePermalink: String { return L10n.tr("Localizable", "error_failed_creating_the_permalink") }
/// Failed loading messages
public static var errorFailedLoadingMessages: String { return L10n.tr("Localizable", "error_failed_loading_messages") }
/// No compatible app was found to handle this action.
public static var errorNoCompatibleAppFound: String { return L10n.tr("Localizable", "error_no_compatible_app_found") }
/// Some messages have not been sent
public static var errorSomeMessagesHaveNotBeenSent: String { return L10n.tr("Localizable", "error_some_messages_have_not_been_sent") }
/// Sorry, an error occurred
@@ -346,6 +350,18 @@ public enum L10n {
public static var screenCreateRoomActionInvitePeople: String { return L10n.tr("Localizable", "screen_create_room_action_invite_people") }
/// Add people
public static var screenCreateRoomAddPeopleTitle: String { return L10n.tr("Localizable", "screen_create_room_add_people_title") }
/// Block
public static var screenDmDetailsBlockAlertAction: String { return L10n.tr("Localizable", "screen_dm_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 var screenDmDetailsBlockAlertDescription: String { return L10n.tr("Localizable", "screen_dm_details_block_alert_description") }
/// Block user
public static var screenDmDetailsBlockUser: String { return L10n.tr("Localizable", "screen_dm_details_block_user") }
/// Unblock
public static var screenDmDetailsUnblockAlertAction: String { return L10n.tr("Localizable", "screen_dm_details_unblock_alert_action") }
/// On unblocking the user, you will be able to see all messages by them again.
public static var screenDmDetailsUnblockAlertDescription: String { return L10n.tr("Localizable", "screen_dm_details_unblock_alert_description") }
/// Unblock user
public static var screenDmDetailsUnblockUser: String { return L10n.tr("Localizable", "screen_dm_details_unblock_user") }
/// This account has been deactivated.
public static var screenLoginErrorDeactivatedAccount: String { return L10n.tr("Localizable", "screen_login_error_deactivated_account") }
/// Incorrect username and/or password
@@ -388,6 +404,8 @@ public enum L10n {
public static var screenRoomDetailsPeopleTitle: String { return L10n.tr("Localizable", "screen_room_details_people_title") }
/// Security
public static var screenRoomDetailsSecurityTitle: String { return L10n.tr("Localizable", "screen_room_details_security_title") }
/// Share room
public static var screenRoomDetailsShareRoomTitle: String { return L10n.tr("Localizable", "screen_room_details_share_room_title") }
/// Topic
public static var screenRoomDetailsTopicTitle: String { return L10n.tr("Localizable", "screen_room_details_topic_title") }
/// Block
@@ -402,6 +420,10 @@ public enum L10n {
public static var screenRoomMemberDetailsUnblockAlertDescription: String { return L10n.tr("Localizable", "screen_room_member_details_unblock_alert_description") }
/// Unblock user
public static var screenRoomMemberDetailsUnblockUser: String { return L10n.tr("Localizable", "screen_room_member_details_unblock_user") }
/// Plural format key: "%#@COUNT@"
public static func screenRoomMemberListHeaderTitle(_ p1: Int) -> String {
return L10n.tr("Localizable", "screen_room_member_list_header_title", p1)
}
/// Create a new conversation or room
public static var screenRoomlistA11yCreateMessage: String { return L10n.tr("Localizable", "screen_roomlist_a11y_create_message") }
/// All Chats
@@ -166,6 +166,11 @@ class RoomProxyMock: RoomProxyProtocol {
var displayName: String?
var topic: String?
var avatarURL: URL?
var membersPublisher: AnyPublisher<[RoomMemberProxyProtocol], Never> {
get { return underlyingMembersPublisher }
set(value) { underlyingMembersPublisher = value }
}
var underlyingMembersPublisher: AnyPublisher<[RoomMemberProxyProtocol], Never>!
//MARK: - loadAvatarURLForUserId
@@ -410,23 +415,6 @@ class RoomProxyMock: RoomProxyProtocol {
return reportContentReasonReturnValue
}
}
//MARK: - members
var membersCallsCount = 0
var membersCalled: Bool {
return membersCallsCount > 0
}
var membersReturnValue: Result<[RoomMemberProxyProtocol], RoomProxyError>!
var membersClosure: (() async -> Result<[RoomMemberProxyProtocol], RoomProxyError>)?
func members() async -> Result<[RoomMemberProxyProtocol], RoomProxyError> {
membersCallsCount += 1
if let membersClosure = membersClosure {
return await membersClosure()
} else {
return membersReturnValue
}
}
//MARK: - ignoreUser
var ignoreUserCallsCount = 0
@@ -481,6 +469,18 @@ class RoomProxyMock: RoomProxyProtocol {
return leaveRoomReturnValue
}
}
//MARK: - updateMembers
var updateMembersCallsCount = 0
var updateMembersCalled: Bool {
return updateMembersCallsCount > 0
}
var updateMembersClosure: (() async -> Void)?
func updateMembers() async {
updateMembersCallsCount += 1
await updateMembersClosure?()
}
}
class SessionVerificationControllerProxyMock: SessionVerificationControllerProxyProtocol {
var callbacks: PassthroughSubject<SessionVerificationControllerProxyCallback, Never> {
+7 -5
View File
@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import Foundation
struct RoomProxyMockConfiguration {
@@ -51,11 +52,12 @@ extension RoomProxyMock {
alternativeAliases = configuration.alternativeAliases
hasUnreadNotifications = configuration.hasUnreadNotifications
membersClosure = {
if let members = configuration.members {
return .success(members)
}
return .failure(.failedRetrievingMembers)
if let members = configuration.members {
membersPublisher = Just(members).eraseToAnyPublisher()
} else {
membersPublisher = Just([]).eraseToAnyPublisher()
}
updateMembersClosure = { }
}
}
@@ -79,6 +79,7 @@ struct A11yIdentifiers {
struct RoomDetailsScreen {
let avatar = "room_details-avatar"
let dmAvatar = "room_details-dm_avatar"
let people = "room_details-people"
}
@@ -0,0 +1,72 @@
//
// 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 SwiftUI
struct AvatarHeaderView<Footer: View>: View {
let avatarUrl: URL?
let name: String?
let id: String
let avatarSize: AvatarSize
let imageProvider: ImageProviderProtocol?
let subtitle: String?
@ViewBuilder var footer: () -> Footer
var body: some View {
VStack(spacing: 8.0) {
LoadableAvatarImage(url: avatarUrl,
name: name,
contentID: id,
avatarSize: avatarSize,
imageProvider: imageProvider)
Text(name ?? id)
.foregroundColor(.element.primaryContent)
.font(.element.title1Bold)
.multilineTextAlignment(.center)
if let subtitle {
Text(subtitle)
.foregroundColor(.element.secondaryContent)
.font(.element.body)
.multilineTextAlignment(.center)
}
footer()
}
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
}
}
struct HeaderView_Previews: PreviewProvider {
static var previews: some View {
AvatarHeaderView(avatarUrl: URL.picturesDirectory,
name: "Test",
id: "test",
avatarSize: .room(on: .details),
imageProvider: MockMediaProvider(),
subtitle: "test") {
HStack(spacing: 32) {
ShareLink(item: "test") {
Image(systemName: "square.and.arrow.up")
}
.buttonStyle(FormActionButtonStyle(title: "Test"))
}
.padding(.top, 32)
}
}
}
@@ -38,19 +38,65 @@ struct RoomDetailsViewState: BindableState {
var topic: String?
var avatarURL: URL?
let permalink: URL?
var members: [RoomDetailsMember]
var members: [RoomMemberDetails] = []
var isProcessingIgnoreRequest = false
var isLoadingMembers: Bool {
members.isEmpty
}
var bindings: RoomDetailsViewStateBindings
var dmRecipient: RoomMemberDetails?
private var isDMRoom: Bool {
isEncrypted && isDirect && members.count == 2
}
}
struct RoomDetailsViewStateBindings {
struct IgnoreUserAlertItem: AlertItem, Equatable {
enum Action {
case ignore
case unignore
}
let action: Action
let cancelTitle = L10n.actionCancel
var title: String {
switch action {
case .ignore: return L10n.screenDmDetailsBlockUser
case .unignore: return L10n.screenDmDetailsUnblockUser
}
}
var confirmationTitle: String {
switch action {
case .ignore: return L10n.screenDmDetailsBlockAlertAction
case .unignore: return L10n.screenDmDetailsUnblockAlertAction
}
}
var description: String {
switch action {
case .ignore: return L10n.screenDmDetailsBlockAlertDescription
case .unignore: return L10n.screenDmDetailsUnblockAlertDescription
}
}
var viewAction: RoomDetailsViewAction {
switch action {
case .ignore: return .ignoreConfirmed
case .unignore: return .unignoreConfirmed
}
}
}
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<RoomDetailsErrorType>?
var leaveRoomAlertItem: LeaveRoomAlertItem?
var ignoreUserRoomAlertItem: IgnoreUserAlertItem?
}
struct LeaveRoomAlertItem: AlertItem {
@@ -77,21 +123,11 @@ struct LeaveRoomAlertItem: AlertItem {
enum RoomDetailsViewAction {
case processTapPeople
case processTapLeave
case processTapIgnore
case processTapUnignore
case confirmLeave
case copyRoomLink
}
struct RoomDetailsMember: Identifiable, Equatable {
let id: String
let name: String?
let avatarURL: URL?
@MainActor
init(withProxy proxy: RoomMemberProxyProtocol) {
id = proxy.userID
name = proxy.displayName
avatarURL = proxy.avatarURL
}
case ignoreConfirmed
case unignoreConfirmed
}
enum RoomDetailsErrorType: Hashable {
@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import SwiftUI
typealias RoomDetailsViewModelType = StateStoreViewModel<RoomDetailsViewState, RoomDetailsViewAction>
@@ -22,12 +23,27 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc
private let roomProxy: RoomProxyProtocol
private var members: [RoomMemberProxyProtocol] = [] {
didSet {
state.members = members.map { RoomDetailsMember(withProxy: $0) }
state.members = members.map { RoomMemberDetails(withProxy: $0) }
if roomProxy.isDirect, roomProxy.isEncrypted, members.count == 2 {
dmRecipient = members.first(where: { !$0.isAccountOwner })
} else {
dmRecipient = nil
}
}
}
private var dmRecipient: RoomMemberProxyProtocol? {
didSet {
if let dmRecipient {
state.dmRecipient = RoomMemberDetails(withProxy: dmRecipient)
} else {
state.dmRecipient = nil
}
}
}
var callback: ((RoomDetailsViewModelAction) -> Void)?
init(roomProxy: RoomProxyProtocol,
mediaProvider: MediaProviderProtocol) {
self.roomProxy = roomProxy
@@ -39,18 +55,16 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc
topic: roomProxy.topic,
avatarURL: roomProxy.avatarURL,
permalink: roomProxy.permalink,
members: [],
bindings: .init()),
imageProvider: mediaProvider)
roomProxy.membersPublisher.sink { [weak self] members in
self?.members = members
}
.store(in: &cancellables)
Task {
switch await roomProxy.members() {
case .success(let members):
self.members = members
case .failure(let error):
MXLog.error("Failed retrieving room members: \(error)")
state.bindings.alertInfo = AlertInfo(id: .alert(L10n.errorUnknown))
}
await roomProxy.updateMembers()
}
}
@@ -60,8 +74,6 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc
switch viewAction {
case .processTapPeople:
callback?(.requestMemberDetailsPresentation(members))
case .copyRoomLink:
copyRoomLink()
case .processTapLeave:
guard members.count > 1 else {
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(state: .empty)
@@ -70,21 +82,20 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(state: roomProxy.isPublic ? .public : .private)
case .confirmLeave:
await leaveRoom()
case .processTapIgnore:
state.bindings.ignoreUserRoomAlertItem = .init(action: .ignore)
case .processTapUnignore:
state.bindings.ignoreUserRoomAlertItem = .init(action: .unignore)
case .ignoreConfirmed:
await ignore()
case .unignoreConfirmed:
await unignore()
}
}
// MARK: - Private
private static let leaveRoomLoadingID = "LeaveRoomLoading"
private func copyRoomLink() {
if let roomLink = state.permalink {
UIPasteboard.general.url = roomLink
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: L10n.commonLinkCopiedToClipboard))
} else {
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
}
}
private func leaveRoom() async {
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLoading, persistent: true))
@@ -97,4 +108,28 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc
callback?(.leftRoom)
}
}
private func ignore() async {
state.isProcessingIgnoreRequest = true
let result = await dmRecipient?.ignoreUser()
state.isProcessingIgnoreRequest = false
switch result {
case .success:
state.dmRecipient?.isIgnored = true
case .failure, .none:
state.bindings.alertInfo = .init(id: .unknown)
}
}
private func unignore() async {
state.isProcessingIgnoreRequest = true
let result = await dmRecipient?.unignoreUser()
state.isProcessingIgnoreRequest = false
switch result {
case .success:
state.dmRecipient?.isIgnored = false
case .failure, .none:
state.bindings.alertInfo = .init(id: .unknown)
}
}
}
@@ -21,17 +21,27 @@ struct RoomDetailsScreen: View {
var body: some View {
Form {
headerSection
if let recipient = context.viewState.dmRecipient {
dmHeaderSection(recipient: recipient)
} else {
normalRoomHeaderSection
}
topicSection
aboutSection
if context.viewState.dmRecipient == nil {
aboutSection
}
if context.viewState.isEncrypted {
securitySection
}
leaveRoomSection
if let recipient = context.viewState.dmRecipient {
ignoreUserSection(user: recipient)
} else {
leaveRoomSection
}
}
.scrollContentBackground(.hidden)
.background(Color.element.formBackground.ignoresSafeArea())
@@ -39,48 +49,53 @@ struct RoomDetailsScreen: View {
.alert(item: $context.leaveRoomAlertItem,
actions: leaveRoomAlertActions,
message: leaveRoomAlertMessage)
.alert(item: $context.ignoreUserRoomAlertItem,
actions: blockUserAlertActions,
message: blockUserAlertMessage)
}
// MARK: - Private
private var headerSection: some View {
VStack(spacing: 8.0) {
LoadableAvatarImage(url: context.viewState.avatarURL,
name: context.viewState.title,
contentID: context.viewState.roomId,
avatarSize: .room(on: .details),
imageProvider: context.imageProvider)
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.avatar)
Text(context.viewState.title)
.foregroundColor(.element.primaryContent)
.font(.element.title1Bold)
.multilineTextAlignment(.center)
if let canonicalAlias = context.viewState.canonicalAlias {
Text(canonicalAlias)
.foregroundColor(.element.secondaryContent)
.font(.element.body)
.multilineTextAlignment(.center)
}
@ViewBuilder
private var normalRoomHeaderSection: some View {
AvatarHeaderView(avatarUrl: context.viewState.avatarURL,
name: context.viewState.title,
id: context.viewState.roomId,
avatarSize: .room(on: .details),
imageProvider: context.imageProvider,
subtitle: context.viewState.canonicalAlias) {
if let permalink = context.viewState.permalink {
HStack(spacing: 32) {
Button { context.send(viewAction: .copyRoomLink) } label: {
Image(systemName: "link")
}
.buttonStyle(FormActionButtonStyle(title: L10n.actionCopyLink))
ShareLink(item: permalink) {
Image(systemName: "square.and.arrow.up")
}
.buttonStyle(FormActionButtonStyle(title: L10n.actionInvite))
.buttonStyle(FormActionButtonStyle(title: L10n.actionShareLink))
}
.padding(.top, 32)
}
}
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.avatar)
}
@ViewBuilder
private func dmHeaderSection(recipient: RoomMemberDetails) -> some View {
AvatarHeaderView(avatarUrl: recipient.avatarURL,
name: recipient.name,
id: recipient.id,
avatarSize: .user(on: .memberDetails),
imageProvider: context.imageProvider,
subtitle: recipient.id) {
if let permalink = recipient.permalink {
HStack(spacing: 32) {
ShareLink(item: permalink) {
Image(systemName: "square.and.arrow.up")
}
.buttonStyle(FormActionButtonStyle(title: L10n.actionShareLink))
}
.padding(.top, 32)
}
}
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.dmAvatar)
}
@ViewBuilder
@@ -158,6 +173,21 @@ struct RoomDetailsScreen: View {
.formSectionStyle()
}
@ViewBuilder
private func ignoreUserSection(user: RoomMemberDetails) -> some View {
Section {
Button(role: user.isIgnored ? nil : .destructive) {
context.send(viewAction: user.isIgnored ? .processTapUnignore : .processTapIgnore)
} label: {
Label(user.isIgnored ? L10n.screenDmDetailsUnblockUser : L10n.screenDmDetailsBlockUser,
systemImage: "slash.circle")
}
.buttonStyle(FormButtonStyle(accessory: context.viewState.isProcessingIgnoreRequest ? .progressView : nil))
.disabled(context.viewState.isProcessingIgnoreRequest)
}
.formSectionStyle()
}
@ViewBuilder
private func leaveRoomAlertActions(_ item: LeaveRoomAlertItem) -> some View {
Button(item.cancelTitle, role: .cancel) { }
@@ -169,12 +199,25 @@ struct RoomDetailsScreen: View {
private func leaveRoomAlertMessage(_ item: LeaveRoomAlertItem) -> some View {
Text(item.subtitle)
}
@ViewBuilder
private func blockUserAlertActions(_ item: RoomDetailsViewStateBindings.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: RoomDetailsViewStateBindings.IgnoreUserAlertItem) -> some View {
Text(item.description)
}
}
// MARK: - Previews
struct RoomDetails_Previews: PreviewProvider {
static let viewModel = {
static let genericRoomViewModel = {
let members: [RoomMemberProxyMock] = [
.mockAlice,
.mockBob,
@@ -190,8 +233,28 @@ struct RoomDetails_Previews: PreviewProvider {
return RoomDetailsViewModel(roomProxy: roomProxy,
mediaProvider: MockMediaProvider())
}()
static let dmRoomViewModel = {
let members: [RoomMemberProxyMock] = [
.mockMe,
.mockDan
]
let roomProxy = RoomProxyMock(with: .init(displayName: "DM Room",
topic: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
isDirect: true,
isEncrypted: true,
canonicalAlias: "#alias:domain.com",
members: members))
return RoomDetailsViewModel(roomProxy: roomProxy,
mediaProvider: MockMediaProvider())
}()
static var previews: some View {
RoomDetailsScreen(context: viewModel.context)
RoomDetailsScreen(context: genericRoomViewModel.context)
.previewDisplayName("Generic Room")
RoomDetailsScreen(context: dmRoomViewModel.context)
.previewDisplayName("DM Room")
}
}
@@ -19,64 +19,58 @@ 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 details: RoomMemberDetails
var isProcessingIgnoreRequest = false
var bindings: RoomMemberDetailsViewStateBindings
}
struct RoomMemberDetailsViewStateBindings {
struct IgnoreUserAlertItem: AlertItem, Equatable {
enum Action {
case ignore
case unignore
}
let action: Action
let cancelTitle = L10n.actionCancel
var title: String {
switch action {
case .ignore: return L10n.screenRoomMemberDetailsBlockUser
case .unignore: return L10n.screenRoomMemberDetailsUnblockUser
}
}
var confirmationTitle: String {
switch action {
case .ignore: return L10n.screenRoomMemberDetailsBlockAlertAction
case .unignore: return L10n.screenRoomMemberDetailsUnblockAlertAction
}
}
var description: String {
switch action {
case .ignore: return L10n.screenRoomMemberDetailsBlockAlertDescription
case .unignore: return L10n.screenRoomMemberDetailsUnblockAlertDescription
}
}
var viewAction: RoomMemberDetailsViewAction {
switch action {
case .ignore: return .ignoreConfirmed
case .unignore: return .unignoreConfirmed
}
}
}
var ignoreUserAlert: IgnoreUserAlertItem?
var errorAlert: ErrorAlertItem?
}
struct IgnoreUserAlertItem: AlertItem, Equatable {
enum Action {
case ignore
case unignore
}
let action: Action
let cancelTitle = L10n.actionCancel
var title: String {
switch action {
case .ignore: return L10n.screenRoomMemberDetailsBlockUser
case .unignore: return L10n.screenRoomMemberDetailsUnblockUser
}
}
var confirmationTitle: String {
switch action {
case .ignore: return L10n.screenRoomMemberDetailsBlockAlertAction
case .unignore: return L10n.screenRoomMemberDetailsUnblockAlertAction
}
}
var description: String {
switch action {
case .ignore: return L10n.screenRoomMemberDetailsBlockAlertDescription
case .unignore: return L10n.screenRoomMemberDetailsUnblockAlertDescription
}
}
var viewAction: RoomMemberDetailsViewAction {
switch action {
case .ignore: return .ignoreConfirmed
case .unignore: return .unignoreConfirmed
}
}
}
enum RoomMemberDetailsViewAction {
case showUnignoreAlert
case showIgnoreAlert
case ignoreConfirmed
case unignoreConfirmed
case copyUserLink
}
@@ -25,12 +25,7 @@ class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDeta
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,
let initialViewState = RoomMemberDetailsViewState(details: RoomMemberDetails(withProxy: roomMemberProxy),
bindings: .init())
super.init(initialViewState: initialViewState, imageProvider: mediaProvider)
}
@@ -43,8 +38,6 @@ class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDeta
state.bindings.ignoreUserAlert = .init(action: .unignore)
case .showIgnoreAlert:
state.bindings.ignoreUserAlert = .init(action: .ignore)
case .copyUserLink:
copyUserLink()
case .ignoreConfirmed:
await ignoreUser()
case .unignoreConfirmed:
@@ -54,22 +47,13 @@ class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDeta
// MARK: - Private
private func copyUserLink() {
if let userLink = state.permalink {
UIPasteboard.general.url = userLink
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: L10n.commonLinkCopiedToClipboard))
} else {
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
}
}
private func ignoreUser() async {
state.isProcessingIgnoreRequest = true
let result = await roomMemberProxy.ignoreUser()
state.isProcessingIgnoreRequest = false
switch result {
case .success:
state.isIgnored = true
state.details.isIgnored = true
case .failure:
state.bindings.errorAlert = .init()
}
@@ -81,7 +65,7 @@ class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDeta
state.isProcessingIgnoreRequest = false
switch result {
case .success:
state.isIgnored = false
state.details.isIgnored = false
case .failure:
state.bindings.errorAlert = .init()
}
@@ -23,7 +23,7 @@ struct RoomMemberDetailsScreen: View {
Form {
headerSection
if !context.viewState.isAccountOwner {
if !context.viewState.details.isAccountOwner {
blockUserSection
}
}
@@ -35,31 +35,16 @@ struct RoomMemberDetailsScreen: View {
// MARK: - Private
@ViewBuilder
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 {
AvatarHeaderView(avatarUrl: context.viewState.details.avatarURL,
name: context.viewState.details.name,
id: context.viewState.details.id,
avatarSize: .user(on: .memberDetails),
imageProvider: context.imageProvider,
subtitle: context.viewState.details.id) {
if let permalink = context.viewState.details.permalink {
HStack(spacing: 32) {
Button { context.send(viewAction: .copyUserLink) } label: {
Image(systemName: "link")
}
.buttonStyle(FormActionButtonStyle(title: L10n.actionCopyLink))
ShareLink(item: permalink) {
Image(systemName: "square.and.arrow.up")
}
@@ -68,13 +53,11 @@ struct RoomMemberDetailsScreen: View {
.padding(.top, 32)
}
}
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
}
private var blockUserSection: some View {
Section {
Button {
Button(role: context.viewState.details.isIgnored ? nil : .destructive) {
context.send(viewAction: blockUserButtonAction)
} label: {
Label(blockUserButtonTitle, systemImage: "slash.circle")
@@ -87,19 +70,19 @@ struct RoomMemberDetailsScreen: View {
}
private var blockUserButtonAction: RoomMemberDetailsViewAction {
context.viewState.isIgnored ? .showUnignoreAlert : .showIgnoreAlert
context.viewState.details.isIgnored ? .showUnignoreAlert : .showIgnoreAlert
}
private var blockUserButtonTitle: String {
context.viewState.isIgnored ? L10n.screenRoomMemberDetailsUnblockUser : L10n.screenRoomMemberDetailsBlockUser
context.viewState.details.isIgnored ? L10n.screenRoomMemberDetailsUnblockUser : L10n.screenRoomMemberDetailsBlockUser
}
private var blockUserButtonAccessibilityIdentifier: String {
context.viewState.isIgnored ? A11yIdentifiers.roomMemberDetailsScreen.unignore : A11yIdentifiers.roomMemberDetailsScreen.ignore
context.viewState.details.isIgnored ? A11yIdentifiers.roomMemberDetailsScreen.unignore : A11yIdentifiers.roomMemberDetailsScreen.ignore
}
@ViewBuilder
private func blockUserAlertActions(_ item: IgnoreUserAlertItem) -> some View {
private func blockUserAlertActions(_ item: RoomMemberDetailsViewStateBindings.IgnoreUserAlertItem) -> some View {
Button(item.cancelTitle, role: .cancel) { }
Button(item.confirmationTitle,
role: item.action == .ignore ? .destructive : nil) {
@@ -107,7 +90,7 @@ struct RoomMemberDetailsScreen: View {
}
}
private func blockUserAlertMessage(_ item: IgnoreUserAlertItem) -> some View {
private func blockUserAlertMessage(_ item: RoomMemberDetailsViewStateBindings.IgnoreUserAlertItem) -> some View {
Text(item.description)
}
}
@@ -21,11 +21,11 @@ enum RoomMembersListViewModelAction {
}
struct RoomMembersListViewState: BindableState {
var members: [RoomDetailsMember]
var members: [RoomMemberDetails]
var bindings: RoomMembersListViewStateBindings
var visibleMembers: [RoomDetailsMember] {
var visibleMembers: [RoomMemberDetails] {
if bindings.searchQuery.isEmpty {
return members
}
@@ -28,7 +28,7 @@ class RoomMembersListViewModel: RoomMembersListViewModelType, RoomMembersListVie
members: [RoomMemberProxyProtocol]) {
self.mediaProvider = mediaProvider
self.members = members
super.init(initialViewState: .init(members: members.map { RoomDetailsMember(withProxy: $0) },
super.init(initialViewState: .init(members: members.map { RoomMemberDetails(withProxy: $0) },
bindings: .init()),
imageProvider: mediaProvider)
}
@@ -19,7 +19,7 @@ import SwiftUI
struct RoomMembersListMemberCell: View {
@ScaledMetric private var avatarSize = AvatarSize.user(on: .roomDetails).value
let member: RoomDetailsMember
let member: RoomMemberDetails
let context: RoomMembersListViewModel.Context
var body: some View {
+12 -8
View File
@@ -34,6 +34,11 @@ class RoomProxy: RoomProxyProtocol {
private(set) var displayName: String?
private var timelineObservationToken: TaskHandle?
private let membersSubject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([])
var membersPublisher: AnyPublisher<[RoomMemberProxyProtocol], Never> {
membersSubject.eraseToAnyPublisher()
}
init(slidingSyncRoom: SlidingSyncRoomProtocol,
room: RoomProtocol,
@@ -140,6 +145,7 @@ class RoomProxy: RoomProxyProtocol {
timelineObservationToken = result.taskHandle
Task {
await fetchMembers()
await updateMembers()
}
return .success(result.items)
} else {
@@ -274,18 +280,16 @@ class RoomProxy: RoomProxyProtocol {
}
}
}
func members() async -> Result<[RoomMemberProxyProtocol], RoomProxyError> {
func updateMembers() async {
do {
let members = try await Task.dispatch(on: .global()) {
let members = try self.room.members()
return members
let roomMembers = try await Task.dispatch(on: .global()) {
try self.room.members()
}
let proxiedMembers = buildRoomMemberProxies(members: members)
return .success(proxiedMembers)
membersSubject.value = buildRoomMemberProxies(members: roomMembers)
} catch {
return .failure(.failedRetrievingMembers)
return
}
}
@@ -54,7 +54,9 @@ protocol RoomProxyProtocol {
var topic: String? { get }
var avatarURL: URL? { get }
var membersPublisher: AnyPublisher<[RoomMemberProxyProtocol], Never> { get }
func loadAvatarURLForUserId(_ userId: String) async -> Result<URL?, RoomProxyError>
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError>
@@ -79,13 +81,13 @@ protocol RoomProxyProtocol {
func reportContent(_ eventID: String, reason: String?) async -> Result<Void, RoomProxyError>
func members() async -> Result<[RoomMemberProxyProtocol], RoomProxyError>
func ignoreUser(_ userID: String) async -> Result<Void, RoomProxyError>
func retryDecryption(for sessionID: String) async
func leaveRoom() async -> Result<Void, RoomProxyError>
func updateMembers() async
}
extension RoomProxyProtocol {
@@ -0,0 +1,36 @@
//
// 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
struct RoomMemberDetails: Identifiable, Equatable {
let id: String
let name: String?
let avatarURL: URL?
let permalink: URL?
let isAccountOwner: Bool
var isIgnored: Bool
@MainActor
init(withProxy proxy: RoomMemberProxyProtocol) {
id = proxy.userID
name = proxy.displayName
avatarURL = proxy.avatarURL
permalink = proxy.permalink
isAccountOwner = proxy.isAccountOwner
isIgnored = proxy.isIgnored
}
}
@@ -337,6 +337,20 @@ class MockScreen: Identifiable {
let coordinator = RoomMemberDetailsCoordinator(parameters: .init(roomMemberProxy: RoomMemberProxyMock.mockIgnored, mediaProvider: MockMediaProvider()))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .roomDetailsScreenDmDetails:
let navigationStackCoordinator = NavigationStackCoordinator()
let members: [RoomMemberProxyMock] = [.mockMe, .mockDan]
let roomProxy = RoomProxyMock(with: .init(id: "MockRoomIdentifier",
displayName: "Room",
topic: "test",
isDirect: true,
isEncrypted: true,
members: members))
let coordinator = RoomDetailsCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: roomProxy,
mediaProvider: MockMediaProvider()))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
}
}()
}
@@ -42,6 +42,7 @@ enum UITestsScreenIdentifier: String {
case userSessionScreen
case roomDetailsScreen
case roomDetailsScreenWithRoomAvatar
case roomDetailsScreenDmDetails
case roomMembersListScreen
case roomMemberDetailsAccountOwner
case roomMemberDetails