Room details screen (#348)

* Create screen module

* Display details on header tap

* Add strings

* Add room avatar size on details

* Update members method signature

* Use room proxy values

* Create room members screen

* Display room members on tap

* Update previews and mock room proxy

* Commit project file

* Various tweaks and cleanup following code review

* More small tweaks + UI and screenshot tests

Co-authored-by: Stefan Ceriu <stefanc@matrix.org>
This commit is contained in:
ismailgulek
2022-12-19 15:39:33 +03:00
committed by GitHub
parent 4b29318147
commit 1a47fd1a8d
43 changed files with 1192 additions and 33 deletions
@@ -26,6 +26,10 @@ extension ElementL10n {
public static let loginTabletDevice = ElementL10n.tr("Untranslated", "login_tablet_device")
/// Notification
public static let notification = ElementL10n.tr("Untranslated", "Notification")
/// About
public static let roomDetailsAboutSectionTitle = ElementL10n.tr("Untranslated", "room_details_about_section_title")
/// Info
public static let roomDetailsTitle = ElementL10n.tr("Untranslated", "room_details_title")
/// Editing
public static let roomTimelineEditing = ElementL10n.tr("Untranslated", "room_timeline_editing")
/// Failed creating the permalink
+6
View File
@@ -45,6 +45,7 @@ enum UserAvatarSizeOnScreen {
case timeline
case home
case settings
case roomDetails
var value: CGFloat {
switch self {
@@ -54,6 +55,8 @@ enum UserAvatarSizeOnScreen {
return 32
case .settings:
return 60
case .roomDetails:
return 32
}
}
}
@@ -61,6 +64,7 @@ enum UserAvatarSizeOnScreen {
enum RoomAvatarSizeOnScreen {
case timeline
case home
case details
var value: CGFloat {
switch self {
@@ -68,6 +72,8 @@ enum RoomAvatarSizeOnScreen {
return 32
case .home:
return 44
case .details:
return 100
}
}
}
@@ -0,0 +1,72 @@
//
// 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 RoomDetailsCoordinatorParameters {
let navigationStackCoordinator: NavigationStackCoordinator
let roomProxy: RoomProxyProtocol
let mediaProvider: MediaProviderProtocol
}
enum RoomDetailsCoordinatorAction {
case cancel
}
final class RoomDetailsCoordinator: CoordinatorProtocol {
private let parameters: RoomDetailsCoordinatorParameters
private var viewModel: RoomDetailsViewModelProtocol
private var navigationStackCoordinator: NavigationStackCoordinator { parameters.navigationStackCoordinator }
var callback: ((RoomDetailsCoordinatorAction) -> Void)?
init(parameters: RoomDetailsCoordinatorParameters) {
self.parameters = parameters
viewModel = RoomDetailsViewModel(roomProxy: parameters.roomProxy,
mediaProvider: parameters.mediaProvider)
}
// MARK: - Public
func start() {
viewModel.callback = { [weak self] action in
guard let self else { return }
MXLog.debug("RoomDetailsViewModel did complete with result: \(action).")
switch action {
case .peopleTapped:
self.showPeople()
case .cancel:
self.callback?(.cancel)
}
}
}
func toPresentable() -> AnyView {
AnyView(RoomDetailsScreen(context: viewModel.context))
}
private func showPeople() {
let params = RoomMembersCoordinatorParameters(roomProxy: parameters.roomProxy,
mediaProvider: parameters.mediaProvider)
let coordinator = RoomMembersCoordinator(parameters: params)
coordinator.callback = { [weak self] _ in
self?.navigationStackCoordinator.pop()
}
navigationStackCoordinator.push(coordinator)
}
}
@@ -0,0 +1,73 @@
//
// 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
import UIKit
// MARK: - Coordinator
// MARK: View model
enum RoomDetailsViewModelAction {
case peopleTapped
case cancel
}
// MARK: View
struct RoomDetailsViewState: BindableState {
let roomId: String
let isEncrypted: Bool
let isDirect: Bool
var roomTitle = ""
var roomTopic: String?
var roomAvatar: UIImage?
var members: [RoomDetailsMember]
var isLoadingMembers: Bool {
members.isEmpty
}
var bindings: RoomDetailsViewStateBindings
}
struct RoomDetailsViewStateBindings {
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<RoomDetailsErrorType>?
}
enum RoomDetailsErrorType: Hashable {
/// A specific error message shown in an alert.
case alert(String)
}
enum RoomDetailsViewAction {
case processTapPeople
}
struct RoomDetailsMember: Identifiable, Equatable {
let id: String
let name: String?
let avatarUrl: String?
// cached
var avatar: UIImage?
init(withProxy proxy: RoomMemberProxy) {
id = proxy.userId
name = proxy.displayName
avatarUrl = proxy.avatarUrl
}
}
@@ -0,0 +1,75 @@
//
// 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 RoomDetailsViewModelType = StateStoreViewModel<RoomDetailsViewState, RoomDetailsViewAction>
class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtocol {
// MARK: - Properties
// MARK: Private
private let roomProxy: RoomProxyProtocol
private let mediaProvider: MediaProviderProtocol
// MARK: Public
var callback: ((RoomDetailsViewModelAction) -> Void)?
// MARK: - Setup
init(roomProxy: RoomProxyProtocol,
mediaProvider: MediaProviderProtocol) {
self.roomProxy = roomProxy
self.mediaProvider = mediaProvider
super.init(initialViewState: .init(roomId: roomProxy.id,
isEncrypted: roomProxy.isEncrypted,
isDirect: roomProxy.isDirect,
roomTitle: roomProxy.displayName ?? roomProxy.name ?? "Unknown Room",
roomTopic: roomProxy.topic,
members: [],
bindings: .init()))
Task {
switch await roomProxy.members() {
case .success(let members):
state.members = members.map { RoomDetailsMember(withProxy: $0) }
case .failure(let error):
MXLog.debug("Failed to retrieve room members: \(error)")
state.bindings.alertInfo = AlertInfo(id: .alert(ElementL10n.unknownError))
}
}
if let avatarURL = roomProxy.avatarURL {
Task {
if case let .success(avatar) = await mediaProvider.loadImageFromURLString(avatarURL,
avatarSize: .room(on: .details)) {
state.roomAvatar = avatar
}
}
}
}
// MARK: - Public
override func process(viewAction: RoomDetailsViewAction) async {
switch viewAction {
case .processTapPeople:
callback?(.peopleTapped)
}
}
}
@@ -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 RoomDetailsViewModelProtocol {
var callback: ((RoomDetailsViewModelAction) -> Void)? { get set }
var context: RoomDetailsViewModelType.Context { get }
}
@@ -0,0 +1,171 @@
//
// 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 RoomDetailsScreen: View {
// MARK: Private
@Environment(\.colorScheme) private var colorScheme
@ScaledMetric private var avatarSize = AvatarSize.room(on: .details).value
@ScaledMetric private var menuIconSize = 30.0
private let listRowInsets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)
// MARK: Public
@ObservedObject var context: RoomDetailsViewModel.Context
// MARK: Views
var body: some View {
Form {
headerSection
if let topic = context.viewState.roomTopic {
topicSection(with: topic)
}
aboutSection
if context.viewState.isEncrypted {
securitySection
}
}
.alert(item: $context.alertInfo) { $0.alert }
.navigationTitle(ElementL10n.roomDetailsTitle)
}
private var headerSection: some View {
VStack(spacing: 16.0) {
roomAvatarImage
Text(context.viewState.roomTitle)
.foregroundColor(.element.primaryContent)
.font(.element.headline)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
.padding(.top, 18)
.padding(.bottom, 42)
}
private func topicSection(with topic: String) -> some View {
Section(ElementL10n.roomSettingsTopic) {
Text(topic)
.foregroundColor(.element.secondaryContent)
.font(.element.footnote)
}
}
private var aboutSection: some View {
Section(ElementL10n.roomDetailsAboutSectionTitle) {
Button {
context.send(viewAction: .processTapPeople)
} label: {
HStack {
Image(systemName: "person")
.foregroundColor(.element.systemGray)
.padding(4)
.background(Color.element.systemGray6)
.clipShape(Circle())
.frame(width: menuIconSize, height: menuIconSize)
Text(ElementL10n.bottomActionPeople)
.foregroundColor(.element.primaryContent)
.font(.body)
Spacer()
if context.viewState.isLoadingMembers {
ProgressView()
} else {
Text(String(context.viewState.members.count))
.foregroundColor(.element.secondaryContent)
.font(.element.body)
Image(systemName: "chevron.forward")
.foregroundColor(.element.secondaryContent)
}
}
}
.listRowInsets(listRowInsets)
.foregroundColor(.element.primaryContent)
.accessibilityIdentifier("peopleButton")
.disabled(context.viewState.isLoadingMembers)
}
}
private var securitySection: some View {
Section(ElementL10n.roomProfileSectionSecurity) {
HStack(alignment: .center) {
Image(systemName: "lock.shield")
.foregroundColor(.element.systemGray)
.padding(4)
.background(Color.element.systemGray6)
.clipShape(Circle())
.frame(width: menuIconSize, height: menuIconSize)
VStack(alignment: .leading) {
Text(ElementL10n.encryptionEnabled)
.foregroundColor(.element.primaryContent)
.font(.element.body)
Text(ElementL10n.encryptionEnabledTileDescription)
.foregroundColor(.element.secondaryContent)
.font(.element.footnote)
}
Spacer()
Image(systemName: "checkmark")
.foregroundColor(.element.secondaryContent)
}
}
}
@ViewBuilder private var roomAvatarImage: some View {
if let avatar = context.viewState.roomAvatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
.accessibilityIdentifier("roomAvatarImage")
} else {
PlaceholderAvatarImage(text: context.viewState.roomTitle,
contentId: context.viewState.roomId)
.clipShape(Circle())
.frame(width: avatarSize, height: avatarSize)
.accessibilityIdentifier("roomAvatarPlaceholderImage")
}
}
}
// MARK: - Previews
struct RoomDetails_Previews: PreviewProvider {
static var previews: some View {
Group {
let members: [RoomMemberProxy] = [
.mockAlice,
.mockBob,
.mockCharlie
]
let roomProxy = MockRoomProxy(displayName: "Room A",
topic: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
isDirect: false,
isEncrypted: true,
members: members)
let viewModel = RoomDetailsViewModel(roomProxy: roomProxy,
mediaProvider: MockMediaProvider())
RoomDetailsScreen(context: viewModel.context)
}
.tint(.element.accent)
}
}
@@ -0,0 +1,55 @@
//
// 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 RoomMembersCoordinatorParameters {
let roomProxy: RoomProxyProtocol
let mediaProvider: MediaProviderProtocol
}
enum RoomMembersCoordinatorAction {
case cancel
}
final class RoomMembersCoordinator: CoordinatorProtocol {
private let parameters: RoomMembersCoordinatorParameters
private var viewModel: RoomMembersViewModelProtocol
var callback: ((RoomMembersCoordinatorAction) -> Void)?
init(parameters: RoomMembersCoordinatorParameters) {
self.parameters = parameters
viewModel = RoomMembersViewModel(roomProxy: parameters.roomProxy,
mediaProvider: parameters.mediaProvider)
}
func start() {
viewModel.callback = { [weak self] action in
guard let self else { return }
MXLog.debug("RoomMembersViewModel did complete with result: \(action).")
switch action {
case .cancel:
self.callback?(.cancel)
}
}
}
func toPresentable() -> AnyView {
AnyView(RoomMembersScreen(context: viewModel.context))
}
}
@@ -0,0 +1,50 @@
//
// 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 RoomMembersViewModelAction {
case cancel
}
struct RoomMembersViewState: BindableState {
var members: [RoomDetailsMember]
var bindings: RoomMembersViewStateBindings
var visibleMembers: [RoomDetailsMember] {
if bindings.searchQuery.isEmpty {
return members
}
return members.lazy.filter { member in
member.id.localizedCaseInsensitiveContains(bindings.searchQuery) ||
member.name?.localizedCaseInsensitiveContains(bindings.searchQuery) ?? false
}
}
}
struct RoomMembersViewStateBindings {
var searchQuery = ""
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<RoomDetailsErrorType>?
}
enum RoomMembersViewAction {
case selectMember(id: String)
case loadMemberData(id: String)
}
@@ -0,0 +1,78 @@
//
// 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 RoomMembersViewModelType = StateStoreViewModel<RoomMembersViewState, RoomMembersViewAction>
class RoomMembersViewModel: RoomMembersViewModelType, RoomMembersViewModelProtocol {
private let roomProxy: RoomProxyProtocol
private let mediaProvider: MediaProviderProtocol
var callback: ((RoomMembersViewModelAction) -> Void)?
init(roomProxy: RoomProxyProtocol,
mediaProvider: MediaProviderProtocol) {
self.roomProxy = roomProxy
self.mediaProvider = mediaProvider
super.init(initialViewState: .init(members: [],
bindings: .init()))
Task {
switch await roomProxy.members() {
case .success(let members):
state.members = members.map { RoomDetailsMember(withProxy: $0) }
case .failure(let error):
MXLog.debug("Failed to retrieve room members: \(error)")
state.bindings.alertInfo = AlertInfo(id: .alert(ElementL10n.unknownError))
}
}
}
// MARK: - Public
override func process(viewAction: RoomMembersViewAction) async {
switch viewAction {
case .selectMember(let id):
MXLog.debug("Member selected: \(id)")
case .loadMemberData(let id):
await loadAvatar(forMember: id)
}
}
private func loadAvatar(forMember memberId: String) async {
guard let member = state.members.first(where: { $0.id == memberId }) else {
return
}
if member.avatar != nil {
// already loaded
return
}
guard let avatarUrl = member.avatarUrl else {
// user has no avatar
return
}
switch await mediaProvider.loadImageFromURLString(avatarUrl, avatarSize: .user(on: .roomDetails)) {
case .success(let image):
if let index = state.members.firstIndex(where: { $0.id == memberId }) {
state.members[index].avatar = image
}
case .failure(let error):
MXLog.debug("Failed to retrieve room member avatar: \(error)")
}
}
}
@@ -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 RoomMembersViewModelProtocol {
var callback: ((RoomMembersViewModelAction) -> Void)? { get set }
var context: RoomMembersViewModelType.Context { get }
}
@@ -0,0 +1,84 @@
//
// 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 RoomMembersMemberCell: View {
@ScaledMetric private var avatarSize = AvatarSize.user(on: .roomDetails).value
let member: RoomDetailsMember
let context: RoomMembersViewModel.Context
var body: some View {
Button {
context.send(viewAction: .selectMember(id: member.id))
} label: {
HStack {
if let avatar = member.avatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
.accessibilityHidden(true)
} else {
PlaceholderAvatarImage(text: member.name ?? "", contentId: member.id)
.clipShape(Circle())
.frame(width: avatarSize, height: avatarSize)
.accessibilityHidden(true)
}
Text(member.name ?? "")
.font(.element.callout.bold())
.foregroundColor(.element.primaryContent)
.lineLimit(1)
Spacer()
}
.accessibilityElement(children: .combine)
.task {
context.send(viewAction: .loadMemberData(id: member.id))
}
}
}
}
struct RoomMembersMemberCell_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
.tint(.element.accent)
body.preferredColorScheme(.dark)
.tint(.element.accent)
}
static var body: some View {
let members: [RoomMemberProxy] = [
.mockAlice,
.mockBob,
.mockCharlie
]
let roomProxy = MockRoomProxy(displayName: "Room A",
members: members)
let viewModel = RoomMembersViewModel(roomProxy: roomProxy,
mediaProvider: MockMediaProvider())
return VStack {
ForEach(members) { member in
RoomMembersMemberCell(member: .init(withProxy: member), context: viewModel.context)
}
}
}
}
@@ -0,0 +1,61 @@
//
// 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 RoomMembersScreen: View {
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var context: RoomMembersViewModel.Context
var body: some View {
Form {
Section {
ForEach(context.viewState.visibleMembers) { member in
RoomMembersMemberCell(member: member, context: context)
.id(member.id)
}
} footer: {
Text(ElementL10n.roomTitleMembers(context.viewState.members.count))
.foregroundColor(.element.secondaryContent)
.font(.element.footnote)
}
}
.searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always))
.alert(item: $context.alertInfo) { $0.alert }
.navigationTitle(ElementL10n.listMembers)
}
}
// MARK: - Previews
struct RoomMembers_Previews: PreviewProvider {
static var previews: some View {
Group {
let members: [RoomMemberProxy] = [
.mockAlice,
.mockBob,
.mockCharlie
]
let roomProxy = MockRoomProxy(displayName: "Room A",
members: members)
let viewModel = RoomMembersViewModel(roomProxy: roomProxy,
mediaProvider: MockMediaProvider())
RoomMembersScreen(context: viewModel.context)
}
.tint(.element.accent)
}
}
@@ -18,10 +18,9 @@ import SwiftUI
struct RoomScreenCoordinatorParameters {
let navigationStackCoordinator: NavigationStackCoordinator
let roomProxy: RoomProxyProtocol
let timelineController: RoomTimelineControllerProtocol
let mediaProvider: MediaProviderProtocol
let roomName: String?
let roomAvatarUrl: String?
let emojiProvider: EmojiProviderProtocol
}
@@ -43,8 +42,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
viewModel = RoomScreenViewModel(timelineController: parameters.timelineController,
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: parameters.mediaProvider,
roomName: parameters.roomName,
roomAvatarUrl: parameters.roomAvatarUrl)
roomName: parameters.roomProxy.displayName ?? parameters.roomProxy.name,
roomAvatarUrl: parameters.roomProxy.avatarURL)
}
// MARK: - Public
@@ -54,6 +53,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
MXLog.debug("RoomScreenViewModel did complete with result: \(result).")
switch result {
case .displayRoomDetails:
self.displayRoomDetails()
case .displayVideo(let videoURL):
self.displayVideo(for: videoURL)
case .displayFile(let fileURL, let title):
@@ -122,4 +123,21 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
navigationStackCoordinator.setSheetCoordinator(coordinator)
}
private func displayRoomDetails() {
guard let roomProxy = parameters?.roomProxy,
let mediaProvider = parameters?.mediaProvider else {
return
}
let params = RoomDetailsCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: roomProxy,
mediaProvider: mediaProvider)
let coordinator = RoomDetailsCoordinator(parameters: params)
coordinator.callback = { [weak self] _ in
self?.navigationStackCoordinator.pop()
}
navigationStackCoordinator.push(coordinator)
}
}
@@ -18,6 +18,7 @@ import Combine
import UIKit
enum RoomScreenViewModelAction {
case displayRoomDetails
case displayVideo(videoURL: URL)
case displayFile(fileURL: URL, title: String?)
case displayEmojiPicker(itemId: String)
@@ -30,6 +31,7 @@ enum RoomScreenComposerMode: Equatable {
}
enum RoomScreenViewAction {
case headerTapped
case displayEmojiPicker(itemId: String)
case emojiTapped(emoji: String, itemId: String)
case paginateBackwards
@@ -98,6 +98,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
// swiftlint:disable:next cyclomatic_complexity
override func process(viewAction: RoomScreenViewAction) async {
switch viewAction {
case .headerTapped:
callback?(.displayRoomDetails)
case .paginateBackwards:
await paginateBackwards()
case .itemAppeared(let id):
@@ -24,12 +24,16 @@ struct RoomHeaderView: View {
@ObservedObject var context: RoomScreenViewModel.Context
var body: some View {
HStack(spacing: 8) {
roomAvatar
.accessibilityHidden(true)
Text(context.viewState.roomTitle)
.font(.element.headline)
.accessibilityIdentifier("roomNameLabel")
Button {
context.send(viewAction: .headerTapped)
} label: {
HStack(spacing: 8) {
roomAvatar
.accessibilityHidden(true)
Text(context.viewState.roomTitle)
.font(.element.headline)
.accessibilityIdentifier("roomNameLabel")
}
}
}
@@ -22,16 +22,14 @@ struct MockRoomProxy: RoomProxyProtocol {
let id = UUID().uuidString
let name: String? = nil
let displayName: String?
let topic: String? = nil
let avatarURL: String? = nil
let isDirect = Bool.random()
let isSpace = Bool.random()
let isPublic = Bool.random()
let isEncrypted = Bool.random()
let isTombstoned = Bool.random()
var topic: String?
var avatarURL: String?
var isDirect = Bool.random()
var isSpace = Bool.random()
var isPublic = Bool.random()
var isEncrypted = Bool.random()
var isTombstoned = Bool.random()
var members: [RoomMemberProxy]?
let timelineProvider: RoomTimelineProviderProtocol = MockRoomTimelineProvider()
@@ -76,4 +74,11 @@ struct MockRoomProxy: RoomProxyProtocol {
func redact(_ eventID: String) async -> Result<Void, RoomProxyError> {
.failure(.failedRedactingEvent)
}
func members() async -> Result<[RoomMemberProxy], RoomProxyError> {
if let members {
return .success(members)
}
return .failure(.failedRetrievingMembers)
}
}
@@ -0,0 +1,93 @@
//
// 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
import MatrixRustSDK
struct RoomMemberProxy {
private let member: RoomMember
init(with member: RoomMember) {
self.member = member
}
var userId: String {
member.userId
}
var displayName: String? {
member.displayName
}
var avatarUrl: String? {
member.avatarUrl
}
var membership: MembershipState {
member.membership
}
var isNameAmbiguous: Bool {
member.isNameAmbiguous
}
var powerLevel: Int64 {
member.powerLevel
}
var normalizedPowerLevel: Int64 {
member.normalizedPowerLevel
}
}
extension RoomMemberProxy: Identifiable {
var id: String {
userId
}
}
// Mocks
extension RoomMemberProxy {
static var mockAlice: RoomMemberProxy {
RoomMemberProxy(with: .init(userId: "alice@matrix.org",
displayName: "Alice",
avatarUrl: nil,
membership: .join,
isNameAmbiguous: false,
powerLevel: 50,
normalizedPowerLevel: 50))
}
static var mockBob: RoomMemberProxy {
RoomMemberProxy(with: .init(userId: "bob@matrix.org",
displayName: "Bob",
avatarUrl: nil,
membership: .join,
isNameAmbiguous: false,
powerLevel: 50,
normalizedPowerLevel: 50))
}
static var mockCharlie: RoomMemberProxy {
RoomMemberProxy(with: .init(userId: "charlie@matrix.org",
displayName: "Charlie",
avatarUrl: nil,
membership: .join,
isNameAmbiguous: false,
powerLevel: 50,
normalizedPowerLevel: 50))
}
}
@@ -224,6 +224,17 @@ class RoomProxy: RoomProxyProtocol {
.value
}
func members() async -> Result<[RoomMemberProxy], RoomProxyError> {
await Task.dispatch(on: .global()) {
do {
let members = try self.room.members()
return .success(members.map { RoomMemberProxy(with: $0) })
} catch {
return .failure(.failedRetrievingMembers)
}
}
}
func update(avatarURL: String?, forUserId userId: String) {
memberAvatars[userId] = avatarURL
}
@@ -25,6 +25,7 @@ enum RoomProxyError: Error {
case failedSendingMessage
case failedRedactingEvent
case failedAddingTimelineListener
case failedRetrievingMembers
}
@MainActor
@@ -62,6 +63,8 @@ protocol RoomProxyProtocol {
func editMessage(_ newMessage: String, originalEventId: String) async -> Result<Void, RoomProxyError>
func redact(_ eventID: String) async -> Result<Void, RoomProxyError>
func members() async -> Result<[RoomMemberProxy], RoomProxyError>
}
extension RoomProxyProtocol {
@@ -23,7 +23,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
private let timelineProvider: RoomTimelineProviderProtocol
private let timelineItemFactory: RoomTimelineItemFactoryProtocol
private let mediaProvider: MediaProviderProtocol
private let roomProxy: RoomProxyProtocol
let roomProxy: RoomProxyProtocol
private var cancellables = Set<AnyCancellable>()
private var timelineItemsUpdateTask: Task<Void, Never>? {
@@ -162,10 +162,9 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
mediaProvider: userSession.mediaProvider)
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: detailNavigationStackCoordinator,
roomProxy: roomProxy,
timelineController: timelineController,
mediaProvider: userSession.mediaProvider,
roomName: roomProxy.displayName ?? roomProxy.name,
roomAvatarUrl: roomProxy.avatarURL,
emojiProvider: emojiProvider)
let coordinator = RoomScreenCoordinator(parameters: parameters)
@@ -37,6 +37,8 @@ enum UITestScreenIdentifier: String {
case roomSmallTimelineLargePagination
case sessionVerification
case userSessionScreen
case roomDetailsScreen
case roomMembersScreen
}
extension UITestScreenIdentifier: CustomStringConvertible {
@@ -146,10 +146,9 @@ class MockScreen: Identifiable {
case .roomPlainNoAvatar:
let navigationStackCoordinator = NavigationStackCoordinator()
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: MockRoomProxy(displayName: "Some room name", avatarURL: nil),
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
roomName: "Some room name",
roomAvatarUrl: nil,
emojiProvider: EmojiProvider())
let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator)
@@ -157,10 +156,9 @@ class MockScreen: Identifiable {
case .roomEncryptedWithAvatar:
let navigationStackCoordinator = NavigationStackCoordinator()
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: MockRoomProxy(displayName: "Some room name", avatarURL: "mock_url"),
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
roomName: "Some room name",
roomAvatarUrl: "mock_url",
emojiProvider: EmojiProvider())
let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator)
@@ -170,10 +168,9 @@ class MockScreen: Identifiable {
let timelineController = MockRoomTimelineController()
timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: MockRoomProxy(displayName: "New room", avatarURL: "mock_url"),
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
roomName: "New room",
roomAvatarUrl: "mock_url",
emojiProvider: EmojiProvider())
let coordinator = RoomScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator)
@@ -187,10 +184,9 @@ class MockScreen: Identifiable {
timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage]
timelineController.simulateIncomingItems()
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: MockRoomProxy(displayName: "Small timeline", avatarURL: "mock_url"),
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
roomName: "Small timeline",
roomAvatarUrl: "mock_url",
emojiProvider: EmojiProvider())
let coordinator = RoomScreenCoordinator(parameters: parameters)
@@ -203,10 +199,9 @@ class MockScreen: Identifiable {
timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk]
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: MockRoomProxy(displayName: "Small timeline, paginating", avatarURL: "mock_url"),
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
roomName: "Small timeline, paginating",
roomAvatarUrl: "mock_url",
emojiProvider: EmojiProvider())
let coordinator = RoomScreenCoordinator(parameters: parameters)
@@ -230,6 +225,22 @@ class MockScreen: Identifiable {
retainedState.append(coordinator)
return navigationSplitCoordinator
case .roomDetailsScreen:
let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = RoomDetailsCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: MockRoomProxy(displayName: "Room",
isEncrypted: true,
members: [.mockAlice, .mockBob, .mockCharlie]),
mediaProvider: MockMediaProvider()))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .roomMembersScreen:
let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = RoomMembersCoordinator(parameters: .init(roomProxy: MockRoomProxy(displayName: "Room",
members: [.mockAlice, .mockBob, .mockCharlie]),
mediaProvider: MockMediaProvider()))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
}
}()
}