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
29 changed files with 495 additions and 2 deletions
@@ -24,8 +24,10 @@ struct DeveloperOptionsScreenViewState: BindableState {
struct DeveloperOptionsScreenViewStateBindings {
var shouldCollapseRoomStateEvents: Bool
var showStartChatFlow: Bool
}
enum DeveloperOptionsScreenViewAction {
case changedShouldCollapseRoomStateEvents
case changedShowStartChatFlow
}
@@ -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
}
}
}
@@ -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 {
@@ -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)
}
}
}
@@ -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
@@ -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)
}
}
@@ -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))
}
@@ -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))
}
}
@@ -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
}
@@ -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
}
}
}
@@ -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 }
}
@@ -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)
}
}
}
@@ -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)
}
}
}