Epic: create and join rooms - Start Chat (#680)

* add start chat flow with UI

* add feature flag for start chat

* add changelog

* fix naming and tests

* fix empty display name in user cell

* Update ElementX/Sources/Application/AppSettings.swift

Co-authored-by: Alfonso Grillo <alfogrillo@element.io>

* add screenshots from UI test

* fix swiftFormat and add identifiers

* fix warnings

---------

Co-authored-by: Alfonso Grillo <alfogrillo@element.io>
This commit is contained in:
Flescio 2023-03-14 10:50:09 +01:00 committed by GitHub
parent 7eef86970a
commit 8a069ecfa5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 495 additions and 2 deletions

View File

@ -50,6 +50,7 @@ let allowList = ["stefanceriu",
"gileluard",
"phlniji",
"aringenbach",
"flescio",
"Velin92"]
let requiresSignOff = !allowList.contains(where: {

View File

@ -115,6 +115,7 @@
"all_chats" = "All Chats";
"start_chat" = "Start Chat";
"create_room" = "Create Room";
"create_a_room" = "Create a room";
"change_space" = "Change Space";
"explore_rooms" = "Explore Rooms";
"a11y_expand_space_children" = "Expand %@ children";
@ -1865,6 +1866,7 @@
"inviting_users_to_room" = "Inviting users…";
"invite_users_to_room_title" = "Invite Users";
"invite_friends" = "Invite friends";
"invite_friends_to_element" = "Invite friends to Element";
"invite_friends_text" = "Hey, talk to me on %@: %@";
"invite_friends_rich_title" = "🔐️ Join me on %@";
"invitation_sent_to_one_user" = "Invitation sent to %1$@";
@ -2355,3 +2357,4 @@
"emoji_picker_objects_category" = "Objects";
"emoji_picker_symbols_category" = "Symbols";
"emoji_picker_flags_category" = "Flags";
"search_for_someone" = "Search for someone";

View File

@ -27,6 +27,7 @@ final class AppSettings: ObservableObject {
case enableInAppNotifications
case pusherProfileTag
case shouldCollapseRoomStateEvents
case showStartChatFlow
}
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
@ -140,7 +141,7 @@ final class AppSettings: ObservableObject {
@UserSetting(key: UserDefaultsKeys.shouldCollapseRoomStateEvents.rawValue, defaultValue: true, persistIn: nil)
var shouldCollapseRoomStateEvents
// MARK: - Notifications
@UserSetting(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: true, persistIn: store)
@ -153,4 +154,11 @@ final class AppSettings: ObservableObject {
// MARK: - Other
let permalinkBaseURL = URL(staticString: "https://matrix.to")
// MARK: - Feature Flags
// MARK: Start Chat
@UserSetting(key: UserDefaultsKeys.showStartChatFlow.rawValue, defaultValue: false, persistIn: store)
var startChatFlowFeatureFlag
}

View File

@ -797,6 +797,8 @@ public enum ElementL10n {
public static var copiedToClipboard: String { return ElementL10n.tr("Localizable", "copied_to_clipboard") }
/// Create
public static var create: String { return ElementL10n.tr("Localizable", "create") }
/// Create a room
public static var createARoom: String { return ElementL10n.tr("Localizable", "create_a_room") }
/// Create New Room
public static var createNewRoom: String { return ElementL10n.tr("Localizable", "create_new_room") }
/// Create New Space
@ -1858,6 +1860,8 @@ public enum ElementL10n {
public static func inviteFriendsText(_ p1: Any, _ p2: Any) -> String {
return ElementL10n.tr("Localizable", "invite_friends_text", String(describing: p1), String(describing: p2))
}
/// Invite friends to Element
public static var inviteFriendsToElement: String { return ElementL10n.tr("Localizable", "invite_friends_to_element") }
/// Just to this room
public static var inviteJustToThisRoom: String { return ElementL10n.tr("Localizable", "invite_just_to_this_room") }
/// They wont be a part of %@
@ -4177,6 +4181,8 @@ public enum ElementL10n {
public static var search: String { return ElementL10n.tr("Localizable", "search") }
/// Filter banned users
public static var searchBannedUserHint: String { return ElementL10n.tr("Localizable", "search_banned_user_hint") }
/// Search for someone
public static var searchForSomeone: String { return ElementL10n.tr("Localizable", "search_for_someone") }
/// Search
public static var searchHint: String { return ElementL10n.tr("Localizable", "search_hint") }
/// Search Name

View File

@ -26,6 +26,7 @@ struct A11yIdentifiers {
static let roomDetailsScreen = RoomDetailsScreen()
static let sessionVerificationScreen = SessionVerificationScreen()
static let softLogoutScreen = SoftLogoutScreen()
static let startChatScreen = StartChatScreen()
struct BugReportScreen {
let report = "bug_report-report"
@ -92,4 +93,9 @@ struct A11yIdentifiers {
let clearDataMessage = "soft_logout-clear_data_message"
let clearData = "soft_logout-clear_data"
}
struct StartChatScreen {
let closeStartChat = "start_chat-close"
let inviteFriends = "start_chat-invite_friends"
}
}

View File

@ -24,8 +24,10 @@ struct DeveloperOptionsScreenViewState: BindableState {
struct DeveloperOptionsScreenViewStateBindings {
var shouldCollapseRoomStateEvents: Bool
var showStartChatFlow: Bool
}
enum DeveloperOptionsScreenViewAction {
case changedShouldCollapseRoomStateEvents
case changedShowStartChatFlow
}

View File

@ -22,7 +22,10 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
var callback: ((DeveloperOptionsScreenViewModelAction) -> Void)?
init() {
super.init(initialViewState: DeveloperOptionsScreenViewState(bindings: DeveloperOptionsScreenViewStateBindings(shouldCollapseRoomStateEvents: ServiceLocator.shared.settings.shouldCollapseRoomStateEvents)))
let bindings = DeveloperOptionsScreenViewStateBindings(shouldCollapseRoomStateEvents: ServiceLocator.shared.settings.shouldCollapseRoomStateEvents, showStartChatFlow: ServiceLocator.shared.settings.startChatFlowFeatureFlag)
let state = DeveloperOptionsScreenViewState(bindings: bindings)
super.init(initialViewState: state)
ServiceLocator.shared.settings.$shouldCollapseRoomStateEvents
.weakAssign(to: \.state.bindings.shouldCollapseRoomStateEvents, on: self)
@ -33,6 +36,8 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
switch viewAction {
case .changedShouldCollapseRoomStateEvents:
ServiceLocator.shared.settings.shouldCollapseRoomStateEvents = state.bindings.shouldCollapseRoomStateEvents
case .changedShowStartChatFlow:
ServiceLocator.shared.settings.startChatFlowFeatureFlag = state.bindings.showStartChatFlow
}
}
}

View File

@ -29,6 +29,12 @@ struct DeveloperOptionsScreenScreen: View {
.onChange(of: context.shouldCollapseRoomStateEvents) { _ in
context.send(viewAction: .changedShouldCollapseRoomStateEvents)
}
Toggle(isOn: $context.showStartChatFlow) {
Text("Show Start Chat flow")
}
.onChange(of: context.showStartChatFlow) { _ in
context.send(viewAction: .changedShowStartChatFlow)
}
}
Section {

View File

@ -29,6 +29,7 @@ enum HomeScreenCoordinatorAction {
case presentSettingsScreen
case presentFeedbackScreen
case presentSessionVerificationScreen
case presentStartChatScreen
case signOut
}
@ -62,6 +63,8 @@ final class HomeScreenCoordinator: CoordinatorProtocol {
self.callback?(.presentSessionVerificationScreen)
case .signOut:
self.callback?(.signOut)
case .presentStartChatScreen:
self.callback?(.presentStartChatScreen)
}
}
}

View File

@ -24,6 +24,7 @@ enum HomeScreenViewModelAction {
case presentSettingsScreen
case presentInviteFriendsScreen
case presentFeedbackScreen
case presentStartChatScreen
case signOut
}
@ -37,6 +38,7 @@ enum HomeScreenViewUserMenuAction {
enum HomeScreenViewAction {
case selectRoom(roomIdentifier: String)
case userMenu(action: HomeScreenViewUserMenuAction)
case startChat
case verifySession
case skipSessionVerification
case updateVisibleItemRange(range: Range<Int>, isScrolling: Bool)
@ -67,6 +69,10 @@ struct HomeScreenViewState: BindableState {
var roomListMode: HomeScreenRoomListMode = .skeletons
var showStartChatFlowEnabled: Bool {
ServiceLocator.shared.settings.startChatFlowFeatureFlag
}
var visibleRooms: [HomeScreenRoom] {
if roomListMode == .skeletons {
return placeholderRooms

View File

@ -154,6 +154,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
state.showSessionVerificationBanner = false
case .updateVisibleItemRange(let range, let isScrolling):
visibleItemRangePublisher.send((range, isScrolling))
case .startChat:
callback?(.presentStartChatScreen)
}
}

View File

@ -102,6 +102,12 @@ struct HomeScreen: View {
ToolbarItem(placement: .navigationBarLeading) {
userMenuButton
}
if context.viewState.showStartChatFlowEnabled {
ToolbarItemGroup(placement: .bottomBar) {
Spacer()
newRoomButton
}
}
}
.background(Color.element.background.ignoresSafeArea())
}
@ -148,6 +154,12 @@ struct HomeScreen: View {
.accessibilityLabel(ElementL10n.a11yAllChatsUserAvatarMenu)
}
private var newRoomButton: some View {
Button(action: startChat) {
Image(systemName: "square.and.pencil")
}
}
private var sessionVerificationBanner: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
@ -191,6 +203,10 @@ struct HomeScreen: View {
context.send(viewAction: .userMenu(action: .inviteFriends))
}
private func startChat() {
context.send(viewAction: .startChat)
}
private func feedback() {
context.send(viewAction: .userMenu(action: .feedback))
}

View File

@ -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 StartChatCoordinatorParameters {
let userSession: UserSessionProtocol
}
enum StartChatCoordinatorAction {
case close
}
final class StartChatCoordinator: CoordinatorProtocol {
private let parameters: StartChatCoordinatorParameters
private var viewModel: StartChatViewModelProtocol
var callback: ((StartChatCoordinatorAction) -> Void)?
init(parameters: StartChatCoordinatorParameters) {
self.parameters = parameters
viewModel = StartChatViewModel(userSession: parameters.userSession)
}
func start() {
viewModel.callback = { [weak self] action in
guard let self else { return }
switch action {
case .close:
self.callback?(.close)
case .createRoom:
// TODO: start create room flow
break
}
}
}
func toPresentable() -> AnyView {
AnyView(StartChatScreen(context: viewModel.context))
}
}

View File

@ -0,0 +1,39 @@
//
// 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 StartChatViewModelAction {
case close
case createRoom
}
struct StartChatViewState: BindableState {
var bindings = StartChatScreenViewStateBindings()
// TODO: bind with real service, and mock data only in preview
var suggestedUsers: [RoomMemberProxy] = [.mockAlice, .mockBob, .mockCharlie]
}
struct StartChatScreenViewStateBindings {
var searchQuery = ""
}
enum StartChatViewAction {
case close
case createRoom
case inviteFriends
}

View File

@ -0,0 +1,44 @@
//
// 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 StartChatViewModelType = StateStoreViewModel<StartChatViewState, StartChatViewAction>
class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol {
private let userSession: UserSessionProtocol
var callback: ((StartChatViewModelAction) -> Void)?
init(userSession: UserSessionProtocol) {
self.userSession = userSession
super.init(initialViewState: StartChatViewState(), imageProvider: userSession.mediaProvider)
}
// MARK: - Public
override func process(viewAction: StartChatViewAction) async {
switch viewAction {
case .close:
callback?(.close)
case .createRoom:
callback?(.createRoom)
case .inviteFriends:
// TODO: start invite people flow
break
}
}
}

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 StartChatViewModelProtocol {
var callback: ((StartChatViewModelAction) -> Void)? { get set }
var context: StartChatViewModelType.Context { get }
}

View File

@ -0,0 +1,102 @@
//
// 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 StartChatScreen: View {
@ObservedObject var context: StartChatViewModel.Context
var body: some View {
Form {
createRoomSection
inviteFriendsSection
suggestionsSection
}
.scrollContentBackground(.hidden)
.background(Color.element.formBackground.ignoresSafeArea())
.navigationTitle(ElementL10n.startChat)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
closeButton
}
}
.searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: ElementL10n.searchForSomeone)
}
private var createRoomSection: some View {
Section {
Button(action: createRoom) {
Label(ElementL10n.createARoom, systemImage: "person.3")
}
.buttonStyle(FormButtonStyle(accessory: .navigationLink))
}
.formSectionStyle()
}
private var inviteFriendsSection: some View {
Section {
Button(action: inviteFriends) {
Label(ElementL10n.inviteFriendsToElement, systemImage: "square.and.arrow.up")
}
.buttonStyle(FormButtonStyle())
.accessibilityIdentifier(A11yIdentifiers.startChatScreen.inviteFriends)
}
.formSectionStyle()
}
private var suggestionsSection: some View {
Section {
ForEach(context.viewState.suggestedUsers, id: \.userId) { user in
StartChatSuggestedUserCell(user: user, imageProvider: context.imageProvider)
}
} header: {
Text(ElementL10n.directRoomUserListSuggestionsTitle)
}
.formSectionStyle()
}
private var closeButton: some View {
Button(ElementL10n.actionCancel, action: close)
.accessibilityIdentifier(A11yIdentifiers.startChatScreen.closeStartChat)
}
private func createRoom() {
context.send(viewAction: .createRoom)
}
private func inviteFriends() {
context.send(viewAction: .inviteFriends)
}
private func close() {
context.send(viewAction: .close)
}
}
// MARK: - Previews
struct StartChat_Previews: PreviewProvider {
static var previews: some View {
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"),
mediaProvider: MockMediaProvider())
let regularViewModel = StartChatViewModel(userSession: userSession)
NavigationView {
StartChatScreen(context: regularViewModel.context)
.tint(.element.accent)
}
}
}

View File

@ -0,0 +1,47 @@
//
// 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 StartChatSuggestedUserCell: View {
let user: RoomMemberProxy
let imageProvider: ImageProviderProtocol?
var body: some View {
HStack(spacing: 13) {
LoadableAvatarImage(url: user.avatarURL,
name: user.displayName,
contentID: user.userId,
avatarSize: .user(on: .home),
imageProvider: imageProvider)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
// covers both nil and empty state
let displayName = user.displayName ?? ""
Text(displayName.isEmpty ? user.userId : displayName)
.font(.element.title3)
.foregroundColor(.element.primaryContent)
if !displayName.isEmpty {
Text(user.userId)
.font(.element.subheadline)
.foregroundColor(.element.tertiaryContent)
}
}
.accessibilityElement(children: .combine)
}
}
}

View File

@ -102,6 +102,10 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
case (.feedbackScreen, .dismissedFeedbackScreen, .roomList):
break
case (.roomList, .showStartChatScreen, .startChatScreen):
self.presentStartChat()
case (.startChatScreen, .dismissedStartChatScreen, .roomList):
break
default:
fatalError("Unknown transition: \(context)")
}
@ -133,6 +137,8 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
self.stateMachine.processEvent(.feedbackScreen)
case .presentSessionVerificationScreen:
self.stateMachine.processEvent(.showSessionVerificationScreen)
case .presentStartChatScreen:
self.stateMachine.processEvent(.showStartChatScreen)
case .signOut:
self.callback?(.signOut)
}
@ -235,6 +241,30 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
self?.stateMachine.processEvent(.dismissedSessionVerificationScreen)
}
}
// MARK: Start Chat
private func presentStartChat() {
let startChatNavigationStackCoordinator = NavigationStackCoordinator()
let userIndicatorController = UserIndicatorController(rootCoordinator: startChatNavigationStackCoordinator)
let parameters = StartChatCoordinatorParameters(userSession: userSession)
let coordinator = StartChatCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
guard let self else { return }
switch action {
case .close:
self.navigationSplitCoordinator.setSheetCoordinator(nil)
}
}
startChatNavigationStackCoordinator.setRootCoordinator(coordinator)
navigationSplitCoordinator.setSheetCoordinator(userIndicatorController) { [weak self] in
self?.stateMachine.processEvent(.dismissedStartChatScreen)
}
}
// MARK: Bug reporting

View File

@ -34,6 +34,9 @@ class UserSessionFlowCoordinatorStateMachine {
/// Showing the settings screen
case settingsScreen(selectedRoomId: String?)
/// Showing the start chat screen
case startChatScreen(selectedRoomId: String?)
}
/// Events that can be triggered on the AppCoordinator state machine
@ -61,6 +64,11 @@ class UserSessionFlowCoordinatorStateMachine {
case showSessionVerificationScreen
/// Session verification has finished
case dismissedSessionVerificationScreen
/// Request the start of the start chat flow
case showStartChatScreen
/// Start chat has been dismissed
case dismissedStartChatScreen
}
private let stateMachine: StateMachine<State, Event>
@ -74,6 +82,7 @@ class UserSessionFlowCoordinatorStateMachine {
configure()
}
// swiftlint:disable:next cyclomatic_complexity
private func configure() {
stateMachine.addRoutes(event: .start, transitions: [.initial => .roomList(selectedRoomId: nil)])
@ -99,6 +108,10 @@ class UserSessionFlowCoordinatorStateMachine {
case (.dismissedSessionVerificationScreen, .sessionVerificationScreen(let selectedRoomId)):
return .roomList(selectedRoomId: selectedRoomId)
case (.showStartChatScreen, .roomList(let selectedRoomId)):
return .startChatScreen(selectedRoomId: selectedRoomId)
case (.dismissedStartChatScreen, .startChatScreen(let selectedRoomId)):
return .roomList(selectedRoomId: selectedRoomId)
default:
return nil
}

View File

@ -304,6 +304,11 @@ class MockScreen: Identifiable {
let coordinator = ReportContentCoordinator(parameters: .init(itemID: "test", roomProxy: MockRoomProxy(displayName: "test")))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .startChat:
let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider())))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
}
}()
}

View File

@ -44,6 +44,7 @@ enum UITestsScreenIdentifier: String {
case roomDetailsScreenWithRoomAvatar
case roomMemberDetailsScreen
case reportContent
case startChat
}
extension UITestsScreenIdentifier: CustomStringConvertible {

View File

@ -0,0 +1,25 @@
//
// 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 StartChatScreenUITests: XCTestCase {
func testStartChatScreen() {
let app = Application.launch(.startChat)
app.assertScreenshot(.startChat)
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,32 @@
//
// 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 StartChatScreenViewModelTests: XCTestCase {
var viewModel: StartChatViewModelProtocol!
var context: StartChatViewModelType.Context!
@MainActor override func setUpWithError() throws {
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: ""),
mediaProvider: MockMediaProvider())
viewModel = StartChatViewModel(userSession: userSession)
context = viewModel.context
}
}

View File

@ -0,0 +1 @@
Add the entry point for the Start a new Chat flow, with button on home Screen and first page