diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index bc97703ce..0d50a3e42 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -30,6 +30,7 @@ final class AppSettings: ObservableObject { case startChatFlowEnabled = "showStartChatFlow" case startChatUserSuggestionsEnabled case mediaUploadingFlowEnabled + case invitesFlowEnabled } private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier @@ -172,4 +173,9 @@ final class AppSettings: ObservableObject { @UserSetting(key: UserDefaultsKeys.mediaUploadingFlowEnabled.rawValue, defaultValue: false, persistIn: nil) var mediaUploadingFlowEnabled + + // MARK: Invites + + @UserSetting(key: UserDefaultsKeys.invitesFlowEnabled.rawValue, defaultValue: false, persistIn: store) + var invitesFlowEnabled } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 7300a0aaf..5158b5d46 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -481,6 +481,23 @@ class RoomProxyMock: RoomProxyProtocol { updateMembersCallsCount += 1 await updateMembersClosure?() } + //MARK: - inviter + + var inviterCallsCount = 0 + var inviterCalled: Bool { + return inviterCallsCount > 0 + } + var inviterReturnValue: RoomMemberProxyProtocol? + var inviterClosure: (() async -> RoomMemberProxyProtocol?)? + + func inviter() async -> RoomMemberProxyProtocol? { + inviterCallsCount += 1 + if let inviterClosure = inviterClosure { + return await inviterClosure() + } else { + return inviterReturnValue + } + } } class SessionVerificationControllerProxyMock: SessionVerificationControllerProxyProtocol { var callbacks: PassthroughSubject { diff --git a/ElementX/Sources/Other/AccessibilityIdentifiers.swift b/ElementX/Sources/Other/AccessibilityIdentifiers.swift index c8a71b424..dd749bae5 100644 --- a/ElementX/Sources/Other/AccessibilityIdentifiers.swift +++ b/ElementX/Sources/Other/AccessibilityIdentifiers.swift @@ -29,6 +29,7 @@ struct A11yIdentifiers { static let softLogoutScreen = SoftLogoutScreen() static let startChatScreen = StartChatScreen() static let roomMemberDetailsScreen = RoomMemberDetailsScreen() + static let invitesScreen = InvitesScreen() struct BugReportScreen { let report = "bug_report-report" @@ -114,4 +115,8 @@ struct A11yIdentifiers { let ignore = "room_member_details-ignore" let unignore = "room_member_details-unignore" } + + struct InvitesScreen { + let noInvites = "invites-no_invites" + } } diff --git a/ElementX/Sources/Other/SwiftUI/Views/PlaceholderAvatarImage.swift b/ElementX/Sources/Other/SwiftUI/Views/PlaceholderAvatarImage.swift index dd1649c3d..48ca0ab5f 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/PlaceholderAvatarImage.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/PlaceholderAvatarImage.swift @@ -23,16 +23,19 @@ struct PlaceholderAvatarImage: View { private let contentID: String? var body: some View { - ZStack { - bgColor - - // This text's frame doesn't look right when redacted - if redactionReasons != .placeholder { - Text(textForImage) - .padding(4) - .foregroundColor(.white) - .font(.system(size: 200).weight(.semibold)) - .minimumScaleFactor(0.001) + GeometryReader { geometry in + ZStack(alignment: .center) { + bgColor + + // This text's frame doesn't look right when redacted + if redactionReasons != .placeholder { + Text(textForImage) + .padding(geometry.size.width <= 30 ? 0 : 4) + .foregroundColor(.white) + .font(.system(size: 200).weight(.semibold)) + .minimumScaleFactor(0.001) + .frame(alignment: .center) + } } } .aspectRatio(1, contentMode: .fill) @@ -71,6 +74,10 @@ struct PlaceholderAvatarImage_Previews: PreviewProvider { PlaceholderAvatarImage(name: nil, contentID: "@userid3:matrix.org") .clipShape(Circle()) .frame(width: 150, height: 100) + + PlaceholderAvatarImage(name: nil, contentID: "@fooserid:matrix.org") + .clipShape(Circle()) + .frame(width: 30, height: 30) } } } diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index d23fdcb07..9058bab12 100644 --- a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -27,6 +27,7 @@ struct DeveloperOptionsScreenViewStateBindings { var startChatFlowEnabled: Bool var startChatUserSuggestionsEnabled: Bool var mediaUploadFlowEnabled: Bool + var invitesFlowEnabled: Bool } enum DeveloperOptionsScreenViewAction { @@ -34,4 +35,5 @@ enum DeveloperOptionsScreenViewAction { case changedStartChatFlowEnabled case changedStartChatUserSuggestionsEnabled case changedMediaUploadFlowEnabled + case changedInvitesFlowEnabled } diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift index b57ced5a3..794971dcb 100644 --- a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift @@ -25,7 +25,8 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve let bindings = DeveloperOptionsScreenViewStateBindings(shouldCollapseRoomStateEvents: ServiceLocator.shared.settings.shouldCollapseRoomStateEvents, startChatFlowEnabled: ServiceLocator.shared.settings.startChatFlowEnabled, startChatUserSuggestionsEnabled: ServiceLocator.shared.settings.startChatUserSuggestionsEnabled, - mediaUploadFlowEnabled: ServiceLocator.shared.settings.mediaUploadingFlowEnabled) + mediaUploadFlowEnabled: ServiceLocator.shared.settings.mediaUploadingFlowEnabled, + invitesFlowEnabled: ServiceLocator.shared.settings.invitesFlowEnabled) let state = DeveloperOptionsScreenViewState(bindings: bindings) super.init(initialViewState: state) @@ -45,6 +46,8 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve ServiceLocator.shared.settings.startChatUserSuggestionsEnabled = state.bindings.startChatUserSuggestionsEnabled case .changedMediaUploadFlowEnabled: ServiceLocator.shared.settings.mediaUploadingFlowEnabled = state.bindings.mediaUploadFlowEnabled + case .changedInvitesFlowEnabled: + ServiceLocator.shared.settings.invitesFlowEnabled = state.bindings.invitesFlowEnabled } } } diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index 1433201e3..7e2741bf5 100644 --- a/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -50,6 +50,13 @@ struct DeveloperOptionsScreen: View { .onChange(of: context.mediaUploadFlowEnabled) { _ in context.send(viewAction: .changedMediaUploadFlowEnabled) } + + Toggle(isOn: $context.invitesFlowEnabled) { + Text("Show Invites flow") + } + .onChange(of: context.invitesFlowEnabled) { _ in + context.send(viewAction: .changedInvitesFlowEnabled) + } } Section { diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift index 910393f8e..c4d5497ea 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift @@ -30,6 +30,7 @@ enum HomeScreenCoordinatorAction { case presentFeedbackScreen case presentSessionVerificationScreen case presentStartChatScreen + case presentInvitesScreen case signOut } @@ -63,6 +64,8 @@ final class HomeScreenCoordinator: CoordinatorProtocol { self.callback?(.signOut) case .presentStartChatScreen: self.callback?(.presentStartChatScreen) + case .presentInvitesScreen: + self.callback?(.presentInvitesScreen) } } } diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift index 392553b5d..0e5a5d083 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift @@ -24,6 +24,7 @@ enum HomeScreenViewModelAction { case presentSettingsScreen case presentFeedbackScreen case presentStartChatScreen + case presentInvitesScreen case signOut } @@ -40,6 +41,7 @@ enum HomeScreenViewAction { case verifySession case skipSessionVerification case updateVisibleItemRange(range: Range, isScrolling: Bool) + case selectInvites } enum HomeScreenRoomListMode: CustomStringConvertible { @@ -70,6 +72,8 @@ struct HomeScreenViewState: BindableState { /// The URL that will be shared when inviting friends to use the app. let invitePermalink: URL? + var hasPendingInvitations = false + var startChatFlowEnabled: Bool { ServiceLocator.shared.settings.startChatFlowEnabled } diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index ba48ffc68..9534ffe56 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -23,6 +23,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol private let userSession: UserSessionProtocol private let visibleRoomsSummaryProvider: RoomSummaryProviderProtocol? private let allRoomsSummaryProvider: RoomSummaryProviderProtocol? + private let invitesSummaryProvider: RoomSummaryProviderProtocol? private let attributedStringBuilder: AttributedStringBuilderProtocol private var visibleItemRangeObservationToken: AnyCancellable? @@ -37,6 +38,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol visibleRoomsSummaryProvider = userSession.clientProxy.visibleRoomsSummaryProvider allRoomsSummaryProvider = userSession.clientProxy.allRoomsSummaryProvider + invitesSummaryProvider = userSession.clientProxy.invitesSummaryProvider let invitePermalink = try? PermalinkBuilder.permalinkTo(userIdentifier: userSession.userID) super.init(initialViewState: HomeScreenViewState(userID: userSession.userID, invitePermalink: invitePermalink), @@ -60,7 +62,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol .weakAssign(to: \.state.userAvatarURL, on: self) .store(in: &cancellables) - guard let visibleRoomsSummaryProvider, let allRoomsSummaryProvider else { + guard let visibleRoomsSummaryProvider, let allRoomsSummaryProvider, let invitesSummaryProvider else { MXLog.error("Room summary provider unavailable") return } @@ -132,6 +134,12 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol } .store(in: &cancellables) + invitesSummaryProvider.countPublisher + .map { $0 > 0 } + .receive(on: DispatchQueue.main) + .weakAssign(to: \.state.hasPendingInvitations, on: self) + .store(in: &cancellables) + updateRooms() } @@ -158,6 +166,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol visibleItemRangePublisher.send((range, isScrolling)) case .startChat: callback?(.presentStartChatScreen) + case .selectInvites: + callback?(.presentInvitesScreen) } } diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift index 544ceef83..c969bd62d 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift @@ -40,6 +40,14 @@ struct HomeScreen: View { if context.viewState.showSessionVerificationBanner { sessionVerificationBanner } + + if context.viewState.hasPendingInvitations, ServiceLocator.shared.settings.invitesFlowEnabled { + HomeScreenInvitesButton(title: L10n.actionInvitesList, hasBadge: true) { + context.send(viewAction: .selectInvites) + } + .padding(.trailing, 16) + .frame(maxWidth: .infinity, alignment: .trailing) + } if context.viewState.roomListMode == .skeletons { LazyVStack(spacing: 0) { @@ -240,7 +248,7 @@ struct HomeScreen: View { struct HomeScreen_Previews: PreviewProvider { static var previews: some View { body(.loading) - body(.loaded) + body(.loaded(.mockRooms)) } static func body(_ state: MockRoomSummaryProviderState) -> some View { diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInvitesButton.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInvitesButton.swift new file mode 100644 index 000000000..79d0412bc --- /dev/null +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenInvitesButton.swift @@ -0,0 +1,61 @@ +// +// 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 HomeScreenInvitesButton: View { + @ScaledMetric private var badgeSize = 12.0 + + let title: String + let hasBadge: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + Text(title) + .foregroundColor(.element.primaryContent) + .font(.compound.bodyMD) + + if hasBadge { + badge + } + } + } + } + + // MARK: - Private + + private var badge: some View { + Circle() + .frame(width: badgeSize, height: badgeSize) + .foregroundColor(.element.brand) + } +} + +struct InvitesButton_Previews: PreviewProvider { + static var previews: some View { + HomeScreenInvitesButton(title: "Invites", hasBadge: true, action: { }) + .previewDisplayName("Badge on") + + HomeScreenInvitesButton(title: "Invites", hasBadge: false, action: { }) + .previewDisplayName("Badge off") + + HomeScreenInvitesButton(title: "Invites", hasBadge: true, action: { }) + .previewDisplayName("Badge on (AX1)") + .dynamicTypeSize(.accessibility1) + } +} diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift index 086ab7160..0cf7532e6 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift @@ -165,7 +165,7 @@ private extension View { struct HomeScreenRoomCell_Previews: PreviewProvider { static var previews: some View { - let summaryProvider = MockRoomSummaryProvider(state: .loaded) + let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockRooms)) let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe", roomSummaryProvider: summaryProvider), mediaProvider: MockMediaProvider()) diff --git a/ElementX/Sources/Screens/Invites/InvitesCoordinator.swift b/ElementX/Sources/Screens/Invites/InvitesCoordinator.swift new file mode 100644 index 000000000..115a2824d --- /dev/null +++ b/ElementX/Sources/Screens/Invites/InvitesCoordinator.swift @@ -0,0 +1,51 @@ +// +// 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 Combine +import SwiftUI + +struct InvitesCoordinatorParameters { + let userSession: UserSessionProtocol +} + +enum InvitesCoordinatorAction { } + +final class InvitesCoordinator: CoordinatorProtocol { + private let parameters: InvitesCoordinatorParameters + private var viewModel: InvitesViewModelProtocol + private let actionsSubject: PassthroughSubject = .init() + private var cancellables: Set = .init() + + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(parameters: InvitesCoordinatorParameters) { + self.parameters = parameters + viewModel = InvitesViewModel(userSession: parameters.userSession) + } + + func start() { + viewModel.actions.sink { [weak self] _ in + guard let self else { return } + } + .store(in: &cancellables) + } + + func toPresentable() -> AnyView { + AnyView(InvitesScreen(context: viewModel.context)) + } +} diff --git a/ElementX/Sources/Screens/Invites/InvitesModels.swift b/ElementX/Sources/Screens/Invites/InvitesModels.swift new file mode 100644 index 000000000..9ead3f8c1 --- /dev/null +++ b/ElementX/Sources/Screens/Invites/InvitesModels.swift @@ -0,0 +1,35 @@ +// +// 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. +// + +enum InvitesViewModelAction { } + +struct InvitesViewState: BindableState { + var invites: [InvitesRoomDetails]? +} + +struct InvitesRoomDetails { + let roomDetails: RoomSummaryDetails + var inviter: RoomMemberProxyProtocol? + + var isDirect: Bool { + roomDetails.isDirect + } +} + +enum InvitesViewAction { + case accept(InvitesRoomDetails) + case decline(InvitesRoomDetails) +} diff --git a/ElementX/Sources/Screens/Invites/InvitesViewModel.swift b/ElementX/Sources/Screens/Invites/InvitesViewModel.swift new file mode 100644 index 000000000..f4cec7d01 --- /dev/null +++ b/ElementX/Sources/Screens/Invites/InvitesViewModel.swift @@ -0,0 +1,99 @@ +// +// 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 Combine +import SwiftUI + +typealias InvitesViewModelType = StateStoreViewModel + +class InvitesViewModel: InvitesViewModelType, InvitesViewModelProtocol { + private var actionsSubject: PassthroughSubject = .init() + private let userSession: UserSessionProtocol + + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(userSession: UserSessionProtocol) { + self.userSession = userSession + super.init(initialViewState: InvitesViewState(), imageProvider: userSession.mediaProvider) + setupSubscriptions() + } + + // MARK: - Public + + override func process(viewAction: InvitesViewAction) { + switch viewAction { + case .accept: + break + case .decline: + break + } + } + + // MARK: - Private + + private var invitesSummaryProvider: RoomSummaryProviderProtocol? { + userSession.clientProxy.invitesSummaryProvider + } + + private func setupSubscriptions() { + guard let invitesSummaryProvider else { + MXLog.error("Room summary provider unavailable") + return + } + + invitesSummaryProvider.roomListPublisher + .sink { [weak self] roomSummaries in + guard let self else { return } + + let invites = roomSummaries.invites + self.state.invites = invites + + for invite in invites { + self.fetchInviter(for: invite.roomDetails.id) + } + } + .store(in: &cancellables) + } + + private func fetchInviter(for roomID: String) { + Task { + guard let room: RoomProxyProtocol = await self.userSession.clientProxy.roomForIdentifier(roomID) else { + return + } + + let inviter: RoomMemberProxyProtocol? = await room.inviter() + + guard let inviter, let inviteIndex = state.invites?.firstIndex(where: { $0.roomDetails.id == roomID }) else { + return + } + + state.invites?[inviteIndex].inviter = inviter + } + } +} + +private extension Array where Element == RoomSummary { + var invites: [InvitesRoomDetails] { + compactMap { summary in + guard case .filled(let details) = summary else { + return nil + } + return .init(roomDetails: details) + } + } +} diff --git a/ElementX/Sources/Screens/Invites/InvitesViewModelProtocol.swift b/ElementX/Sources/Screens/Invites/InvitesViewModelProtocol.swift new file mode 100644 index 000000000..674a4f752 --- /dev/null +++ b/ElementX/Sources/Screens/Invites/InvitesViewModelProtocol.swift @@ -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 Combine + +@MainActor +protocol InvitesViewModelProtocol { + var actions: AnyPublisher { get } + var context: InvitesViewModelType.Context { get } +} diff --git a/ElementX/Sources/Screens/Invites/View/InvitesScreen.swift b/ElementX/Sources/Screens/Invites/View/InvitesScreen.swift new file mode 100644 index 000000000..ee09c2198 --- /dev/null +++ b/ElementX/Sources/Screens/Invites/View/InvitesScreen.swift @@ -0,0 +1,86 @@ +// +// 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 InvitesScreen: View { + @ObservedObject var context: InvitesViewModel.Context + + var body: some View { + ScrollView { + if let rooms = context.viewState.invites, !rooms.isEmpty { + LazyVStack { + ForEach(rooms, id: \.roomDetails.id) { invite in + InvitesScreenCell(invite: invite, + imageProvider: context.imageProvider, + acceptAction: { context.send(viewAction: .accept(invite)) }, + declineAction: { context.send(viewAction: .decline(invite)) }) + } + } + } else { + noInvitesContent + } + } + .navigationTitle(L10n.actionInvitesList) + } + + // MARK: - Private + + private var noInvitesContent: some View { + Text(L10n.screenInvitesEmptyList) + .font(.element.body) + .foregroundColor(.element.tertiaryContent) + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .accessibilityIdentifier(A11yIdentifiers.invitesScreen.noInvites) + .padding(.top, 80) + } +} + +// MARK: - Previews + +struct InvitesScreen_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + InvitesScreen(context: InvitesViewModel.noInvites.context) + } + .previewDisplayName("No Invites") + + NavigationView { + InvitesScreen(context: InvitesViewModel.someInvite.context) + } + .previewDisplayName("Some Invite") + } +} + +private extension InvitesViewModel { + static let noInvites: InvitesViewModel = { + let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"), + mediaProvider: MockMediaProvider()) + let regularViewModel = InvitesViewModel(userSession: userSession) + return regularViewModel + }() + + static let someInvite: InvitesViewModel = { + let clientProxy = MockClientProxy(userID: "@userid:example.com") + clientProxy.invitesSummaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites)) + clientProxy.visibleRoomsSummaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites)) + let userSession = MockUserSession(clientProxy: clientProxy, + mediaProvider: MockMediaProvider()) + let regularViewModel = InvitesViewModel(userSession: userSession) + return regularViewModel + }() +} diff --git a/ElementX/Sources/Screens/Invites/View/InvitesScreenCell.swift b/ElementX/Sources/Screens/Invites/View/InvitesScreenCell.swift new file mode 100644 index 000000000..120badda2 --- /dev/null +++ b/ElementX/Sources/Screens/Invites/View/InvitesScreenCell.swift @@ -0,0 +1,177 @@ +// +// 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 + +@MainActor +struct InvitesScreenCell: View { + let invite: InvitesRoomDetails + let imageProvider: ImageProviderProtocol? + let acceptAction: () -> Void + let declineAction: () -> Void + + private let verticalInsets = 16.0 + + var body: some View { + HStack(alignment: .top, spacing: 16) { + LoadableAvatarImage(url: mainAvatarURL, + name: title, + contentID: invite.roomDetails.id, + avatarSize: .custom(52), + imageProvider: imageProvider) + .accessibilityHidden(true) + + mainContent + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, verticalInsets) + .overlay(alignment: .bottom) { + separator + } + } + .padding(.top, verticalInsets) + .padding(.horizontal, 12) + } + + // MARK: - Private + + private var mainContent: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.element.headline) + .foregroundColor(.element.primaryContent) + + if let subtitle { + Text(subtitle) + .font(.compound.bodyMD) + .foregroundColor(.compound.textPlaceholder) + } + + inviterView + + buttons + .padding(.top, 10) + } + } + + @ViewBuilder + private var inviterView: some View { + if let invitedText = attributedInviteText, let name = invite.inviter?.displayName { + HStack { + LoadableAvatarImage(url: invite.inviter?.avatarURL, + name: name, + contentID: name, + avatarSize: .custom(16), + imageProvider: imageProvider) + + Text(invitedText) + } + .padding(.top, 4) + } + } + + private var buttons: some View { + HStack(spacing: 12) { + Button(L10n.actionDecline, action: declineAction) + .buttonStyle(.elementCapsule) + + Button(L10n.actionAccept, action: acceptAction) + .buttonStyle(.elementCapsuleProminent) + } + } + + private var separator: some View { + Rectangle() + .fill(Color.element.quinaryContent) + .frame(height: 1 / UIScreen.main.scale) + } + + #warning("Return just `roomDetails.avatarURL` when this logic is implemented in the rust sdk") + private var mainAvatarURL: URL? { + invite.isDirect ? invite.inviter?.avatarURL : invite.roomDetails.avatarURL + } + + private var title: String { + invite.roomDetails.name + } + + private var subtitle: String? { + invite.isDirect ? invite.inviter?.userID : invite.roomDetails.canonicalAlias + } + + private var attributedInviteText: AttributedString? { + guard invite.roomDetails.isDirect == false, let inviterName = invite.inviter?.displayName else { + return nil + } + + let text = L10n.screenInvitesInvitedYou(inviterName) + var attributedString = AttributedString(text) + attributedString.font = .compound.bodyMD + attributedString.foregroundColor = .compound.textPlaceholder + if let range = attributedString.range(of: inviterName) { + attributedString[range].foregroundColor = .compound.textPrimary + } + return attributedString + } +} + +struct InvitesScreenCell_Previews: PreviewProvider { + static var previews: some View { + InvitesScreenCell(invite: .dm, imageProvider: MockMediaProvider(), acceptAction: { }, declineAction: { }) + .previewDisplayName("Direct room") + + InvitesScreenCell(invite: .room(alias: nil), imageProvider: MockMediaProvider(), acceptAction: { }, declineAction: { }) + .previewDisplayName("Default room") + + InvitesScreenCell(invite: .room(alias: "#footest:somewhere.org"), imageProvider: MockMediaProvider(), acceptAction: { }, declineAction: { }) + .previewDisplayName("Aliased room") + } +} + +@MainActor +private extension InvitesRoomDetails { + static var dm: InvitesRoomDetails { + let dmRoom = RoomSummaryDetails(id: "@someone:somewhere.com", + name: "Some Guy", + isDirect: true, + avatarURL: nil, + lastMessage: nil, + lastMessageFormattedTimestamp: nil, + unreadNotificationCount: 0, + canonicalAlias: "#footest:somewhere.org") + let inviter = RoomMemberProxyMock() + inviter.displayName = "Jack" + inviter.userID = "@jack:somewhere.com" + + return .init(roomDetails: dmRoom, inviter: inviter) + } + + static func room(alias: String?) -> InvitesRoomDetails { + let dmRoom = RoomSummaryDetails(id: "@someone:somewhere.com", + name: "Awesome Room", + isDirect: false, + avatarURL: nil, + lastMessage: nil, + lastMessageFormattedTimestamp: nil, + unreadNotificationCount: 0, + canonicalAlias: alias) + let inviter = RoomMemberProxyMock() + inviter.displayName = "Luca" + inviter.userID = "@jack:somewhere.com" + inviter.avatarURL = nil + + return .init(roomDetails: dmRoom, inviter: inviter) + } +} diff --git a/ElementX/Sources/Services/Client/ClientProxy.swift b/ElementX/Sources/Services/Client/ClientProxy.swift index 5c48446cd..dec06ac80 100644 --- a/ElementX/Sources/Services/Client/ClientProxy.swift +++ b/ElementX/Sources/Services/Client/ClientProxy.swift @@ -61,6 +61,10 @@ class ClientProxy: ClientProxyProtocol { var allRoomsViewProxy: SlidingSyncViewProxy? var allRoomsSummaryProvider: RoomSummaryProviderProtocol? + var invitesSlidingSyncView: SlidingSyncList? + var invitesViewProxy: SlidingSyncViewProxy? + var invitesSummaryProvider: RoomSummaryProviderProtocol? + private var loadCachedAvatarURLTask: Task? private let avatarURLSubject = CurrentValueSubject(nil) var avatarURLPublisher: AnyPublisher { @@ -303,6 +307,7 @@ class ClientProxy: ClientProxyProtocol { // cold cache state and count updates will be lost buildAndConfigureVisibleRoomsSlidingSyncView() buildAndConfigureAllRoomsSlidingSyncView() + buildAndConfigureInvitesSlidingSyncView() guard let visibleRoomsSlidingSyncView else { MXLog.error("Visible rooms sliding sync view unavailable") @@ -320,6 +325,7 @@ class ClientProxy: ClientProxyProtocol { // Don't forget to update the view proxies after building the slidingSync visibleRoomsViewProxy?.setSlidingSync(slidingSync: slidingSync) allRoomsViewProxy?.setSlidingSync(slidingSync: slidingSync) + invitesViewProxy?.setSlidingSync(slidingSync: slidingSync) slidingSync.setObserver(observer: WeakClientProxyWrapper(clientProxy: self)) @@ -401,9 +407,41 @@ class ClientProxy: ClientProxyProtocol { } } + private func buildAndConfigureInvitesSlidingSyncView() { + guard invitesSlidingSyncView == nil else { + fatalError("This shouldn't be called more than once") + } + + do { + let invitesView = try SlidingSyncListBuilder() + .noTimelineLimit() + .requiredState(requiredState: slidingSyncInvitesRequiredState) + .filters(filters: slidingSyncInviteFilters) + .name(name: "Invites") + .syncMode(mode: .growing) + .batchSize(batchSize: 100) + .build() + + let invitesViewProxy = SlidingSyncViewProxy(slidingSyncView: invitesView, name: "Invites") + + invitesSummaryProvider = RoomSummaryProvider(slidingSyncViewProxy: invitesViewProxy, + eventStringBuilder: RoomEventStringBuilder(stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID))) + + invitesSlidingSyncView = invitesView + self.invitesViewProxy = invitesViewProxy + } catch { + MXLog.error("Failed building the invites sliding sync view with error: \(error)") + } + } + private lazy var slidingSyncRequiredState = [RequiredState(key: "m.room.avatar", value: ""), RequiredState(key: "m.room.encryption", value: "")] + private lazy var slidingSyncInvitesRequiredState = [RequiredState(key: "m.room.avatar", value: ""), + RequiredState(key: "m.room.encryption", value: ""), + RequiredState(key: "m.room.member", value: "$ME"), + RequiredState(key: "m.room.canonical_alias", value: "")] + private lazy var slidingSyncFilters = SlidingSyncRequestListFilters(isDm: nil, spaces: [], isEncrypted: nil, @@ -415,6 +453,17 @@ class ClientProxy: ClientProxyProtocol { tags: [], notTags: []) + private lazy var slidingSyncInviteFilters = SlidingSyncRequestListFilters(isDm: nil, + spaces: [], + isEncrypted: nil, + isInvite: true, + isTombstoned: false, + roomTypes: [], + notRoomTypes: ["m.space"], + roomNameLike: nil, + tags: [], + notTags: []) + private func configureViewsPostInitialSync() { if let visibleRoomsSlidingSyncView { MXLog.info("Setting visible rooms view timeline limit to \(SlidingSyncConstants.lastMessageTimelineLimit)") @@ -430,6 +479,13 @@ class ClientProxy: ClientProxyProtocol { MXLog.error("All rooms sliding sync view unavailable") } + if let invitesSlidingSyncView { + MXLog.info("Registering invites view") + _ = slidingSync?.addList(list: invitesSlidingSyncView) + } else { + MXLog.error("Invites sliding sync view unavailable") + } + restartSync() } diff --git a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift index 621e35467..4cc3dfc1c 100644 --- a/ElementX/Sources/Services/Client/ClientProxyProtocol.swift +++ b/ElementX/Sources/Services/Client/ClientProxyProtocol.swift @@ -70,6 +70,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol { var allRoomsSummaryProvider: RoomSummaryProviderProtocol? { get } + var invitesSummaryProvider: RoomSummaryProviderProtocol? { get } + func startSync() func stopSync() diff --git a/ElementX/Sources/Services/Client/MockClientProxy.swift b/ElementX/Sources/Services/Client/MockClientProxy.swift index 29ee71ee6..887fac539 100644 --- a/ElementX/Sources/Services/Client/MockClientProxy.swift +++ b/ElementX/Sources/Services/Client/MockClientProxy.swift @@ -29,6 +29,8 @@ class MockClientProxy: ClientProxyProtocol { var visibleRoomsSummaryProvider: RoomSummaryProviderProtocol? = MockRoomSummaryProvider() var allRoomsSummaryProvider: RoomSummaryProviderProtocol? = MockRoomSummaryProvider() + + var invitesSummaryProvider: RoomSummaryProviderProtocol? = MockRoomSummaryProvider() var avatarURLPublisher: AnyPublisher { Empty().eraseToAnyPublisher() } @@ -51,6 +53,8 @@ class MockClientProxy: ClientProxyProtocol { .failure(.failedCreatingRoom) } + var roomInviter: RoomMemberProxyMock? + @MainActor func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol? { guard let room = visibleRoomsSummaryProvider?.roomListPublisher.value.first(where: { $0.id == identifier }) else { return nil @@ -58,9 +62,11 @@ class MockClientProxy: ClientProxyProtocol { switch room { case .empty: - return await RoomProxyMock(with: .init(displayName: "Empty room")) + return RoomProxyMock(with: .init(displayName: "Empty room")) case .filled(let details), .invalidated(let details): - return await RoomProxyMock(with: .init(displayName: details.name)) + let room = RoomProxyMock(with: .init(displayName: details.name)) + room.inviterReturnValue = roomInviter + return room } } diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 0d4d3a994..329e43418 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -338,6 +338,16 @@ class RoomProxy: RoomProxyProtocol { } } + func inviter() async -> RoomMemberProxyProtocol? { + let inviter = await Task.dispatch(on: .global()) { + self.room.inviter() + } + + return inviter.map { + RoomMemberProxy(member: $0, backgroundTaskService: self.backgroundTaskService) + } + } + // MARK: - Private /// Force the timeline to load member details so it can populate sender profiles whenever we add a timeline listener diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 2344369b9..5338c5392 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -88,6 +88,8 @@ protocol RoomProxyProtocol { func leaveRoom() async -> Result func updateMembers() async + + func inviter() async -> RoomMemberProxyProtocol? } extension RoomProxyProtocol { diff --git a/ElementX/Sources/Services/Room/RoomSummary/MockRoomSummaryProvider.swift b/ElementX/Sources/Services/Room/RoomSummary/MockRoomSummaryProvider.swift index 4414a911b..9296c2317 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/MockRoomSummaryProvider.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/MockRoomSummaryProvider.swift @@ -19,7 +19,7 @@ import Foundation enum MockRoomSummaryProviderState { case loading - case loaded + case loaded([RoomSummary]) } class MockRoomSummaryProvider: RoomSummaryProviderProtocol { @@ -37,38 +37,59 @@ class MockRoomSummaryProvider: RoomSummaryProviderProtocol { roomListPublisher = .init([]) statePublisher = .init(.notLoaded) countPublisher = .init(0) - case .loaded: - roomListPublisher = .init(Self.rooms) + case .loaded(let rooms): + roomListPublisher = .init(rooms) statePublisher = .init(.fullyLoaded) - countPublisher = .init(UInt(Self.rooms.count)) + countPublisher = .init(UInt(rooms.count)) } } func updateVisibleRange(_ range: Range, timelineLimit: UInt) { } - - // MARK: - Private - - static let rooms: [RoomSummary] = [ +} + +extension Array where Element == RoomSummary { + static let mockRooms: [Element] = [ .filled(details: RoomSummaryDetails(id: "1", name: "First room", isDirect: true, avatarURL: nil, lastMessage: AttributedString("Prosciutto beef ribs pancetta filet mignon kevin hamburger, chuck ham venison picanha. Beef ribs chislic turkey biltong tenderloin."), lastMessageFormattedTimestamp: "Now", - unreadNotificationCount: 4)), + unreadNotificationCount: 4, + canonicalAlias: nil)), .filled(details: RoomSummaryDetails(id: "2", name: "Second room", isDirect: true, avatarURL: URL.picturesDirectory, lastMessage: nil, lastMessageFormattedTimestamp: nil, - unreadNotificationCount: 1)), + unreadNotificationCount: 1, + canonicalAlias: nil)), .filled(details: RoomSummaryDetails(id: "3", name: "Third room", isDirect: true, avatarURL: nil, lastMessage: try? AttributedString(markdown: "**@mock:client.com**: T-bone beef ribs bacon"), lastMessageFormattedTimestamp: "Later", - unreadNotificationCount: 0)), + unreadNotificationCount: 0, + canonicalAlias: nil)), .empty ] + + static let mockInvites: [Element] = [ + .filled(details: RoomSummaryDetails(id: "someAwesomeRoomId1", name: "First room", + isDirect: false, + avatarURL: URL.picturesDirectory, + lastMessage: nil, + lastMessageFormattedTimestamp: nil, + unreadNotificationCount: 0, + canonicalAlias: "#footest:somewhere.org")), + .filled(details: RoomSummaryDetails(id: "someAwesomeRoomId2", + name: "Second room", + isDirect: true, + avatarURL: nil, + lastMessage: nil, + lastMessageFormattedTimestamp: nil, + unreadNotificationCount: 0, + canonicalAlias: nil)) + ] } diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryDetails.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryDetails.swift index 53a4831a5..9e94ca8c6 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryDetails.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryDetails.swift @@ -24,6 +24,7 @@ struct RoomSummaryDetails { let lastMessage: AttributedString? let lastMessageFormattedTimestamp: String? let unreadNotificationCount: UInt + let canonicalAlias: String? } extension RoomSummaryDetails: CustomStringConvertible { diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift index ce088c326..8af263704 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomSummaryProvider.swift @@ -111,7 +111,9 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol { } } - let avatarURL = room.fullRoom()?.avatarUrl().flatMap(URL.init(string:)) + let fullRoom = room.fullRoom() + let avatarURL = fullRoom?.avatarUrl().flatMap(URL.init(string:)) + let canonicalAlias = fullRoom?.canonicalAlias() let details = RoomSummaryDetails(id: room.roomId(), name: room.name() ?? room.roomId(), @@ -119,7 +121,8 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol { avatarURL: avatarURL, lastMessage: attributedLastMessage, lastMessageFormattedTimestamp: lastMessageFormattedTimestamp, - unreadNotificationCount: UInt(room.unreadNotifications().notificationCount())) + unreadNotificationCount: UInt(room.unreadNotifications().notificationCount()), + canonicalAlias: canonicalAlias) return invalidated ? .invalidated(details: details) : .filled(details: details) } diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift index 5c9e216df..600086f5e 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import Combine import SwiftUI enum UserSessionFlowCoordinatorAction { @@ -22,6 +23,7 @@ enum UserSessionFlowCoordinatorAction { class UserSessionFlowCoordinator: CoordinatorProtocol { private let stateMachine: UserSessionFlowCoordinatorStateMachine + private var cancellables: Set = .init() private let userSession: UserSessionProtocol private let navigationSplitCoordinator: NavigationSplitCoordinator @@ -64,7 +66,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { func handleAppRoute(_ appRoute: AppRoute) { switch stateMachine.state { - case .feedbackScreen, .sessionVerificationScreen, .settingsScreen, .startChatScreen: + case .feedbackScreen, .sessionVerificationScreen, .settingsScreen, .startChatScreen, .invitesScreen: navigationSplitCoordinator.setSheetCoordinator(nil) case .roomList, .initial: break @@ -115,6 +117,10 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { self.presentStartChat() case (.startChatScreen, .dismissedStartChatScreen, .roomList): break + case (.roomList, .showInvitesScreen, .invitesScreen): + self.presentInvitesList() + case (.invitesScreen, .closedInvitesScreen, .roomList): + break default: fatalError("Unknown transition: \(context)") } @@ -150,6 +156,8 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { self.stateMachine.processEvent(.showStartChatScreen) case .signOut: self.callback?(.signOut) + case .presentInvitesScreen: + self.stateMachine.processEvent(.showInvitesScreen) } } @@ -313,4 +321,19 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { self?.stateMachine.processEvent(.dismissedFeedbackScreen) } } + + // MARK: Invites list + + private func presentInvitesList() { + let parameters = InvitesCoordinatorParameters(userSession: userSession) + let coordinator = InvitesCoordinator(parameters: parameters) + + coordinator.actions + .sink { _ in } + .store(in: &cancellables) + + navigationSplitCoordinator.setDetailCoordinator(coordinator) { [weak self] in + self?.stateMachine.processEvent(.closedInvitesScreen) + } + } } diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift index 780834a0d..9fe2dbc57 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinatorStateMachine.swift @@ -37,6 +37,9 @@ class UserSessionFlowCoordinatorStateMachine { /// Showing the start chat screen case startChatScreen(selectedRoomId: String?) + + /// Showing invites list screen + case invitesScreen(selectedRoomId: String?) } /// Events that can be triggered on the AppCoordinator state machine @@ -69,6 +72,11 @@ class UserSessionFlowCoordinatorStateMachine { case showStartChatScreen /// Start chat has been dismissed case dismissedStartChatScreen + + /// Request presentation of the invites screen + case showInvitesScreen + /// The invites screen has been dismissed + case closedInvitesScreen } private let stateMachine: StateMachine @@ -112,6 +120,12 @@ class UserSessionFlowCoordinatorStateMachine { return .startChatScreen(selectedRoomId: selectedRoomId) case (.dismissedStartChatScreen, .startChatScreen(let selectedRoomId)): return .roomList(selectedRoomId: selectedRoomId) + + case (.showInvitesScreen, .roomList(let selectedRoomId)): + return .invitesScreen(selectedRoomId: selectedRoomId) + case (.closedInvitesScreen, .invitesScreen(let selectedRoomId)): + return .roomList(selectedRoomId: selectedRoomId) + default: return nil } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index d2008fd25..7907b1d5b 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -256,7 +256,7 @@ class MockScreen: Identifiable { case .userSessionScreen: let navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: SplashScreenCoordinator()) - let clientProxy = MockClientProxy(userID: "@mock:client.com", roomSummaryProvider: MockRoomSummaryProvider(state: .loaded)) + let clientProxy = MockClientProxy(userID: "@mock:client.com", roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms))) let coordinator = UserSessionFlowCoordinator(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()), navigationSplitCoordinator: navigationSplitCoordinator, @@ -359,6 +359,23 @@ class MockScreen: Identifiable { mediaProvider: MockMediaProvider())) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator + case .invites: + let navigationStackCoordinator = NavigationStackCoordinator() + let clientProxy = MockClientProxy(userID: "@mock:client.com") + clientProxy.roomInviter = RoomMemberProxyMock.mockCharlie + let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites)) + clientProxy.visibleRoomsSummaryProvider = summaryProvider + clientProxy.invitesSummaryProvider = summaryProvider + + let coordinator = InvitesCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()))) + navigationStackCoordinator.setRootCoordinator(coordinator) + return navigationStackCoordinator + case .invitesNoInvites: + let navigationStackCoordinator = NavigationStackCoordinator() + let clientProxy = MockClientProxy(userID: "@mock:client.com") + let coordinator = InvitesCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()))) + navigationStackCoordinator.setRootCoordinator(coordinator) + return navigationStackCoordinator } }() } diff --git a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift index ceaa68cde..c6a1eaa2f 100644 --- a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift +++ b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift @@ -51,6 +51,8 @@ enum UITestsScreenIdentifier: String { case startChat case startChatWithSearchResults case startChatSearchingNonexistentID + case invites + case invitesNoInvites } extension UITestsScreenIdentifier: CustomStringConvertible { diff --git a/Tools/Scripts/Templates/SimpleScreenExample/Tests/Unit/TemplateViewModelTests.swift b/Tools/Scripts/Templates/SimpleScreenExample/Tests/Unit/TemplateViewModelTests.swift index 950cf9a80..9c07c02fc 100644 --- a/Tools/Scripts/Templates/SimpleScreenExample/Tests/Unit/TemplateViewModelTests.swift +++ b/Tools/Scripts/Templates/SimpleScreenExample/Tests/Unit/TemplateViewModelTests.swift @@ -25,11 +25,13 @@ class TemplateScreenViewModelTests: XCTestCase { } var viewModel: TemplateViewModelProtocol! - var context: TemplateViewModelType.Context! - @MainActor override func setUpWithError() throws { + var context: TemplateViewModelType.Context { + viewModel.context + } + + override func setUpWithError() throws { viewModel = TemplateViewModel(promptType: .regular, initialCount: Constants.counterInitialValue) - context = viewModel.context } func testInitialState() { diff --git a/UITests/Sources/InvitesScreenUITests.swift b/UITests/Sources/InvitesScreenUITests.swift new file mode 100644 index 000000000..1dde28646 --- /dev/null +++ b/UITests/Sources/InvitesScreenUITests.swift @@ -0,0 +1,31 @@ +// +// 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 InvitesScreenUITests: XCTestCase { + func testMixedInvites() { + let app = Application.launch(.invites) + app.assertScreenshot(.invites) + } + + func testNoInvites() { + let app = Application.launch(.invitesNoInvites) + XCTAssertTrue(app.staticTexts[A11yIdentifiers.invitesScreen.noInvites].exists) + app.assertScreenshot(.invitesNoInvites) + } +} diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.invites.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.invites.png new file mode 100644 index 000000000..3512b9a32 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.invites.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b7e4017ecafaca01e672701182dd6695573b021bc684c285e76214f311b35f6 +size 98707 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.invitesNoInvites.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.invitesNoInvites.png new file mode 100644 index 000000000..30cea933a --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.invitesNoInvites.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f31e501d332a6a5000233961639d85327e8bf767e4d47a7c78433dc5bb8803b +size 59517 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.invites.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.invites.png new file mode 100644 index 000000000..e714e7877 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.invites.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:947fbd514af15087b850aae85371920b9f829fe92c0bbb4ca62ac97e95b6a92d +size 116523 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.invitesNoInvites.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.invitesNoInvites.png new file mode 100644 index 000000000..7e34e68b8 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.invitesNoInvites.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cdf43c35ecaafb6c0a167e3278303e52f9d497e95f6cecc396893250f83f2434 +size 58645 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.invites.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.invites.png new file mode 100644 index 000000000..9535c0cb7 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.invites.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5e4ce0956d2c9499c1d4f6e84041377263ed74a959196e4f60574c8a7f694855 +size 104489 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.invitesNoInvites.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.invitesNoInvites.png new file mode 100644 index 000000000..ac51657ce --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.invitesNoInvites.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:949b61999f872d1b84ebf6f11e8988ec5d034c9e915f719daac76753363f6ad7 +size 60966 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.invites.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.invites.png new file mode 100644 index 000000000..011abc022 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.invites.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd3942891d03d9e67861fefd90406d51bb3189782307fc0335a1fba1c4754c0b +size 121889 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.invitesNoInvites.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.invitesNoInvites.png new file mode 100644 index 000000000..a5577ccd9 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.invitesNoInvites.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:356aa560b74edad92cc3437da4f3557fbda4fb697a0a025e23c095589c13ece9 +size 60635 diff --git a/UnitTests/Sources/InvitesViewModelTests.swift b/UnitTests/Sources/InvitesViewModelTests.swift new file mode 100644 index 000000000..6afc7e01c --- /dev/null +++ b/UnitTests/Sources/InvitesViewModelTests.swift @@ -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. +// + +@testable import ElementX +import XCTest + +@MainActor +class InvitesViewModelTests: XCTestCase { + var viewModel: InvitesViewModelProtocol! + var clientProxy: MockClientProxy! + var userSession: MockUserSession! + + var context: InvitesViewModelType.Context { + viewModel.context + } + + override func setUpWithError() throws { + clientProxy = MockClientProxy(userID: "@a:b.com") + userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) + } + + func testEmptyState() throws { + setupViewModel() + let invites = try XCTUnwrap(context.viewState.invites) + XCTAssertTrue(invites.isEmpty) + } + + func testListState() throws { + let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites)) + clientProxy.invitesSummaryProvider = summaryProvider + clientProxy.visibleRoomsSummaryProvider = summaryProvider + setupViewModel() + let invites = try XCTUnwrap(context.viewState.invites) + XCTAssertEqual(invites.count, 2) + } + + // MARK: - Private + + private func setupViewModel() { + viewModel = InvitesViewModel(userSession: userSession) + } +} diff --git a/UnitTests/Sources/LoggingTests.swift b/UnitTests/Sources/LoggingTests.swift index f57b73143..5dce3c600 100644 --- a/UnitTests/Sources/LoggingTests.swift +++ b/UnitTests/Sources/LoggingTests.swift @@ -184,7 +184,8 @@ class LoggingTests: XCTestCase { avatarURL: nil, lastMessage: AttributedString(lastMessage), lastMessageFormattedTimestamp: "Now", - unreadNotificationCount: 0) + unreadNotificationCount: 0, + canonicalAlias: nil) // When logging that value XCTAssert(MXLogger.logFiles.isEmpty) diff --git a/changelog.d/605.feature b/changelog.d/605.feature new file mode 100644 index 000000000..8391af3d8 --- /dev/null +++ b/changelog.d/605.feature @@ -0,0 +1 @@ +Add invites list (UI only)