mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Invites list (#787)
* Setup invites SS window * Add invites label in the home screen * Add empty invtes list * Setup navigation to invites list * Inject invitesSummaryProvider * Show invites * Add InviteCell * Refine InviteCell UI * Push invites * Amend SS configuration * Add inviter in RoomProxyProtocol * Add Invite model type * Improve InviteCell * Fix media provider injection * Refine InviteCell * Refine invite cell * Add invites feature flag * Try different SS config for invites * Regiester invites view in configureViewsPostInitialSync * Cleanup * Start tests * Cleanup code * Add changelog.d file * Add tests * Handle canonical alias * Add InvitesListScreen previews * Add localisations and improve UI tests * Add reference screenshots * Cleanup code * Fix UT build errors * Refactor InvitesList -> Invites * Apply pr comments * Remove reduntant @MainActor * Naming cleanup * Fix InvitesScreenCell.title * Add accept/decline InvitesViewAction * Revert Package.resolved * Add warning in InvitesScreenCell * Improve PlaceholderAvatarImage * Record snapshots again * Refactor Invite -> InvitesRoomDeatils * Rename identifier in PlaceholderAvatarImage
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<SessionVerificationControllerProxyCallback, Never> {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+4
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Int>, 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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<InvitesCoordinatorAction, Never> = .init()
|
||||
private var cancellables: Set<AnyCancellable> = .init()
|
||||
|
||||
var actions: AnyPublisher<InvitesCoordinatorAction, Never> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<InvitesViewState, InvitesViewAction>
|
||||
|
||||
class InvitesViewModel: InvitesViewModelType, InvitesViewModelProtocol {
|
||||
private var actionsSubject: PassthroughSubject<InvitesViewModelAction, Never> = .init()
|
||||
private let userSession: UserSessionProtocol
|
||||
|
||||
var actions: AnyPublisher<InvitesViewModelAction, Never> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<InvitesViewModelAction, Never> { get }
|
||||
var context: InvitesViewModelType.Context { get }
|
||||
}
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Void, Never>?
|
||||
private let avatarURLSubject = CurrentValueSubject<URL?, Never>(nil)
|
||||
var avatarURLPublisher: AnyPublisher<URL?, Never> {
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
|
||||
|
||||
var allRoomsSummaryProvider: RoomSummaryProviderProtocol? { get }
|
||||
|
||||
var invitesSummaryProvider: RoomSummaryProviderProtocol? { get }
|
||||
|
||||
func startSync()
|
||||
|
||||
func stopSync()
|
||||
|
||||
@@ -29,6 +29,8 @@ class MockClientProxy: ClientProxyProtocol {
|
||||
var visibleRoomsSummaryProvider: RoomSummaryProviderProtocol? = MockRoomSummaryProvider()
|
||||
|
||||
var allRoomsSummaryProvider: RoomSummaryProviderProtocol? = MockRoomSummaryProvider()
|
||||
|
||||
var invitesSummaryProvider: RoomSummaryProviderProtocol? = MockRoomSummaryProvider()
|
||||
|
||||
var avatarURLPublisher: AnyPublisher<URL?, Never> { 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -88,6 +88,8 @@ protocol RoomProxyProtocol {
|
||||
func leaveRoom() async -> Result<Void, RoomProxyError>
|
||||
|
||||
func updateMembers() async
|
||||
|
||||
func inviter() async -> RoomMemberProxyProtocol?
|
||||
}
|
||||
|
||||
extension RoomProxyProtocol {
|
||||
|
||||
@@ -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<Int>, 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))
|
||||
]
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ struct RoomSummaryDetails {
|
||||
let lastMessage: AttributedString?
|
||||
let lastMessageFormattedTimestamp: String?
|
||||
let unreadNotificationCount: UInt
|
||||
let canonicalAlias: String?
|
||||
}
|
||||
|
||||
extension RoomSummaryDetails: CustomStringConvertible {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<AnyCancellable> = .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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<State, Event>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ enum UITestsScreenIdentifier: String {
|
||||
case startChat
|
||||
case startChatWithSearchResults
|
||||
case startChatSearchingNonexistentID
|
||||
case invites
|
||||
case invitesNoInvites
|
||||
}
|
||||
|
||||
extension UITestsScreenIdentifier: CustomStringConvertible {
|
||||
|
||||
Reference in New Issue
Block a user