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
@@ -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 {