Implement Knock Logic (#3573)

* WIP RequestToJoin struct

* implemented the logic to display the cells

* knock request banner accept flow

* mark all knocks as seen implemented

* details logic

* implemented accept, decline and ban in the list

* added a loader and modified the stacked view

of the banner

* pr suggestions

* updated naming and loading strings

* added the initial loading state

improved code and the tests

* updated a string that has changed

* code improvement

* tests for the room screen view model

* room details tests

and improved the knock requests tests for the room screen

* knock requests list tests

* added error state alerts with retry

* struct has been renamed on the sdk

so I renamed it also on the app side

* update SDK
This commit is contained in:
Mauro
2024-12-16 15:32:45 +01:00
committed by GitHub
parent 016cdc687a
commit 45a630dd85
54 changed files with 1700 additions and 150 deletions
@@ -13,6 +13,7 @@ import SwiftUI
struct KnockRequestsListScreenCoordinatorParameters {
let roomProxy: JoinedRoomProxyProtocol
let mediaProvider: MediaProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
}
enum KnockRequestsListScreenCoordinatorAction { }
@@ -29,7 +30,8 @@ final class KnockRequestsListScreenCoordinator: CoordinatorProtocol {
init(parameters: KnockRequestsListScreenCoordinatorParameters) {
viewModel = KnockRequestsListScreenViewModel(roomProxy: parameters.roomProxy,
mediaProvider: parameters.mediaProvider)
mediaProvider: parameters.mediaProvider,
userIndicatorController: parameters.userIndicatorController)
}
func start() { }
@@ -10,18 +10,43 @@ import Foundation
enum KnockRequestsListScreenViewModelAction { }
struct KnockRequestsListScreenViewState: BindableState {
// TODO: Not sure yet how we will fetch this, this is just for testing purposes
var requests: [KnockRequestCellInfo] = [.init(id: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "Now", reason: "Hello")]
var requestsState: KnockRequestsListState = .loading
var displayedRequests: [KnockRequestCellInfo] {
guard case let .loaded(requests) = requestsState else {
return []
}
return requests.filter { !handledEventIDs.contains($0.id) }
}
var isLoading: Bool {
switch requestsState {
case .loading:
true
default:
false
}
}
// If you are in this view one of these must have been true so by default we assume all of them to be true
var canAccept = true
var canDecline = true
var canBan = true
var isKnockableRoom = true
var handledEventIDs: Set<String> = []
// If all the permissions are denied or the join rule changes while we are in the view
// we want to stop displaying any request
var shouldDisplayRequests: Bool {
!requests.isEmpty && isKnockableRoom && (canAccept || canDecline || canBan)
!displayedRequests.isEmpty && isKnockableRoom && (canAccept || canDecline || canBan)
}
var shouldDisplayAcceptAllButton: Bool {
!isLoading && shouldDisplayRequests && displayedRequests.count > 1
}
var shouldDisplayEmptyView: Bool {
!isLoading && !shouldDisplayRequests
}
var bindings = KnockRequestsListStateBindings()
@@ -35,11 +60,39 @@ enum KnockRequestsListAlertType {
case acceptAllRequests
case declineRequest
case declineAndBan
case acceptAllFailed
case acceptFailed
case declineFailed
}
enum KnockRequestsListScreenViewAction {
case acceptAllRequests
case acceptRequest(userID: String)
case declineRequest(userID: String)
case ban(userID: String)
case acceptRequest(eventID: String)
case declineRequest(eventID: String)
case ban(eventID: String)
}
enum KnockRequestsListState: Equatable {
case loading
case loaded([KnockRequestCellInfo])
init(from state: KnockRequestsState) {
switch state {
case .loading:
self = .loading
case .loaded(let requests):
self = .loaded(requests.map(KnockRequestCellInfo.init))
}
}
}
private extension KnockRequestCellInfo {
init(from proxy: KnockRequestProxyProtocol) {
self.init(eventID: proxy.eventID,
userID: proxy.userID,
displayName: proxy.displayName,
avatarURL: proxy.avatarURL,
timestamp: proxy.formattedTimestamp,
reason: proxy.reason)
}
}
@@ -12,14 +12,18 @@ typealias KnockRequestsListScreenViewModelType = StateStoreViewModel<KnockReques
class KnockRequestsListScreenViewModel: KnockRequestsListScreenViewModelType, KnockRequestsListScreenViewModelProtocol {
private let roomProxy: JoinedRoomProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let actionsSubject: PassthroughSubject<KnockRequestsListScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<KnockRequestsListScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(roomProxy: JoinedRoomProxyProtocol, mediaProvider: MediaProviderProtocol) {
init(roomProxy: JoinedRoomProxyProtocol,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.roomProxy = roomProxy
self.userIndicatorController = userIndicatorController
super.init(initialViewState: KnockRequestsListScreenViewState(), mediaProvider: mediaProvider)
updateRoomInfo(roomInfo: roomProxy.infoPublisher.value)
@@ -39,35 +43,147 @@ class KnockRequestsListScreenViewModel: KnockRequestsListScreenViewModelType, Kn
title: L10n.screenKnockRequestsListAcceptAllAlertTitle,
message: L10n.screenKnockRequestsListAcceptAllAlertDescription,
primaryButton: .init(title: L10n.screenKnockRequestsListAcceptAllAlertConfirmButtonTitle,
// TODO: Implement action
action: nil),
action: acceptAll),
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
case .acceptRequest(let userID):
// TODO: Implement
break
case .declineRequest(let userID):
case .acceptRequest(let eventID):
guard let request = getRequest(eventID: eventID) else {
return
}
accept(request: request)
case .declineRequest(let eventID):
guard let request = getRequest(eventID: eventID) else {
return
}
state.bindings.alertInfo = .init(id: .declineRequest,
title: L10n.screenKnockRequestsListDeclineAlertTitle,
message: L10n.screenKnockRequestsListDeclineAlertDescription(userID),
message: L10n.screenKnockRequestsListDeclineAlertDescription(request.userID),
primaryButton: .init(title: L10n.screenKnockRequestsListDeclineAlertConfirmButtonTitle,
role: .destructive,
// TODO: Implement action
action: nil),
role: .destructive) { [weak self] in self?.decline(request: request) },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
case .ban(let userID):
case .ban(let eventID):
guard let request = getRequest(eventID: eventID) else {
return
}
state.bindings.alertInfo = .init(id: .declineAndBan,
title: L10n.screenKnockRequestsListBanAlertTitle,
message: L10n.screenKnockRequestsListBanAlertDescription(userID),
// TODO: Implement action
primaryButton: .init(title: L10n.screenKnockRequestsListBanAlertConfirmButtonTitle,
role: .destructive,
action: nil),
role: .destructive) { [weak self] in self?.declineAndBan(request: request) },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
}
// MARK: - Private
private func getRequest(eventID: String) -> KnockRequestProxyProtocol? {
guard case let .loaded(requests) = roomProxy.knockRequestsStatePublisher.value,
let request = requests.first(where: { $0.eventID == eventID }) else {
return nil
}
return request
}
private func accept(request: KnockRequestProxyProtocol) {
showLoadingIndicator(title: L10n.screenKnockRequestsListAcceptLoadingTitle)
let eventID = request.eventID
state.handledEventIDs.insert(eventID)
Task {
switch await request.accept() {
case .success:
hideLoadingIndicator()
case .failure:
hideLoadingIndicator()
state.handledEventIDs.remove(eventID)
state.bindings.alertInfo = .init(id: .acceptFailed,
title: L10n.screenKnockRequestsListAcceptFailedAlertTitle,
message: L10n.screenKnockRequestsListAcceptFailedAlertDescription,
primaryButton: .init(title: L10n.actionYesTryAgain) { [weak self] in self?.accept(request: request) },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
}
}
private func decline(request: KnockRequestProxyProtocol) {
showLoadingIndicator(title: L10n.screenKnockRequestsListDeclineLoadingTitle)
let eventID = request.eventID
state.handledEventIDs.insert(eventID)
Task {
switch await request.decline() {
case .success:
hideLoadingIndicator()
case .failure:
hideLoadingIndicator()
state.handledEventIDs.remove(eventID)
state.bindings.alertInfo = .init(id: .declineFailed,
title: L10n.screenKnockRequestsListDeclineFailedAlertTitle,
message: L10n.screenKnockRequestsListDeclineFailedAlertDescription,
primaryButton: .init(title: L10n.actionYesTryAgain) { [weak self] in self?.decline(request: request) },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
}
}
private func declineAndBan(request: KnockRequestProxyProtocol) {
showLoadingIndicator(title: L10n.screenKnockRequestsListBanLoadingTitle)
let eventID = request.eventID
state.handledEventIDs.insert(eventID)
Task {
switch await request.ban() {
case .success:
hideLoadingIndicator()
case .failure:
hideLoadingIndicator()
state.handledEventIDs.remove(eventID)
state.bindings.alertInfo = .init(id: .declineFailed,
title: L10n.screenKnockRequestsListDeclineFailedAlertTitle,
message: L10n.screenKnockRequestsListDeclineFailedAlertDescription,
primaryButton: .init(title: L10n.actionYesTryAgain) { [weak self] in self?.declineAndBan(request: request) },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
}
}
private func acceptAll() {
guard case let .loaded(requests) = roomProxy.knockRequestsStatePublisher.value else {
return
}
showLoadingIndicator(title: L10n.screenKnockRequestsListAcceptAllLoadingTitle)
state.handledEventIDs.formUnion(Set(requests.map(\.eventID)))
Task {
let failedIDs = await withTaskGroup(of: (String, Result<Void, KnockRequestProxyError>).self) { group in
for request in requests {
group.addTask {
await (request.eventID, request.accept())
}
}
var failedIDs = [String]()
for await result in group where result.1.isFailure {
failedIDs.append(result.0)
}
return failedIDs
}
hideLoadingIndicator()
if !failedIDs.isEmpty {
state.handledEventIDs.subtract(failedIDs)
state.bindings.alertInfo = .init(id: .acceptAllFailed,
title: L10n.screenKnockRequestsListAcceptAllFailedAlertTitle,
message: L10n.screenKnockRequestsListAcceptAllFailedAlertDescription,
primaryButton: .init(title: L10n.actionYesTryAgain) { [weak self] in self?.acceptAll() },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
}
}
private func setupSubscriptions() {
roomProxy.infoPublisher
.receive(on: DispatchQueue.main)
@@ -76,6 +192,26 @@ class KnockRequestsListScreenViewModel: KnockRequestsListScreenViewModelType, Kn
Task { await self?.updatePermissions() }
}
.store(in: &cancellables)
roomProxy.knockRequestsStatePublisher
.map(KnockRequestsListState.init)
.removeDuplicates()
.throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true)
.weakAssign(to: \.state.requestsState, on: self)
.store(in: &cancellables)
context.$viewState
.map(\.isLoading)
.removeDuplicates()
.sink { [weak self] isLoading in
guard let self else { return }
if isLoading {
showInitialLoadingIndicator()
} else {
hideLoadingIndicator()
}
}
.store(in: &cancellables)
}
private func updateRoomInfo(roomInfo: RoomInfoProxy) {
@@ -93,15 +229,29 @@ class KnockRequestsListScreenViewModel: KnockRequestsListScreenViewModelType, Kn
state.canBan = await (try? roomProxy.canUserBan(userID: roomProxy.ownUserID).get()) == true
}
// For testing purposes
private init(initialViewState: KnockRequestsListScreenViewState) {
roomProxy = JoinedRoomProxyMock(.init())
super.init(initialViewState: initialViewState)
}
}
extension KnockRequestsListScreenViewModel {
static func mockWithInitialState(_ initialViewState: KnockRequestsListScreenViewState) -> KnockRequestsListScreenViewModel {
.init(initialViewState: initialViewState)
private static let loadingIndicatorIdentifier = "\(KnockRequestsListScreenViewModel.self)-Loading"
private func showInitialLoadingIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal(progress: .indeterminate,
interactiveDismissDisabled: false,
allowsInteraction: true),
title: L10n.screenKnockRequestsListInitialLoadingTitle,
persistent: true),
delay: .milliseconds(100))
}
private func showLoadingIndicator(title: String) {
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal(progress: .indeterminate,
interactiveDismissDisabled: false,
allowsInteraction: false),
title: title,
persistent: true),
delay: .milliseconds(200))
}
private func hideLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
}
@@ -15,9 +15,9 @@
import Compound
import SwiftUI
struct KnockRequestCellInfo: Identifiable {
/// user identifier of the usee that sent the request
let id: String
struct KnockRequestCellInfo: Equatable {
let eventID: String
let userID: String
let displayName: String?
let avatarURL: URL?
let timestamp: String?
@@ -35,7 +35,7 @@ struct KnockRequestCell: View {
HStack(alignment: .top, spacing: 16) {
LoadableAvatarImage(url: cellInfo.avatarURL,
name: cellInfo.displayName,
contentID: cellInfo.id,
contentID: cellInfo.userID,
avatarSize: .user(on: .knockingUserList),
mediaProvider: mediaProvider)
VStack(alignment: .leading, spacing: 12) {
@@ -60,7 +60,7 @@ struct KnockRequestCell: View {
private var header: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 0) {
Text(cellInfo.displayName ?? cellInfo.id)
Text(cellInfo.displayName ?? cellInfo.userID)
.font(.compound.bodyLGSemibold)
.foregroundStyle(.compound.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -71,7 +71,7 @@ struct KnockRequestCell: View {
}
}
if cellInfo.displayName != nil {
Text(cellInfo.id)
Text(cellInfo.userID)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
}
@@ -85,14 +85,14 @@ struct KnockRequestCell: View {
HStack(spacing: 16) {
if let onDecline {
Button(L10n.actionDecline) {
onDecline(cellInfo.id)
onDecline(cellInfo.eventID)
}
.buttonStyle(.compound(.secondary, size: .medium))
}
if let onAccept {
Button(L10n.actionAccept) {
onAccept(cellInfo.id)
onAccept(cellInfo.eventID)
}
.buttonStyle(.compound(.primary, size: .medium))
}
@@ -101,7 +101,7 @@ struct KnockRequestCell: View {
if let onDeclineAndBan {
Button(role: .destructive) {
onDeclineAndBan(cellInfo.id)
onDeclineAndBan(cellInfo.eventID)
} label: {
Text(L10n.screenKnockRequestsListDeclineAndBanActionTitle)
.padding(.top, 8)
@@ -166,15 +166,19 @@ private struct DisclosableText: View {
}
}
extension KnockRequestCellInfo: Identifiable {
var id: String { eventID }
}
struct KnockRequestCell_Previews: PreviewProvider, TestablePreview {
// swiftlint:disable:next line_length
static let aliceWithLongReason = KnockRequestCellInfo(id: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "20 Nov 2024", reason: "Hello would like to join this room, also this is a very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very long reason")
static let aliceWithLongReason = KnockRequestCellInfo(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "20 Nov 2024", reason: "Hello would like to join this room, also this is a very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very long reason")
static let aliceWithShortReason = KnockRequestCellInfo(id: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "20 Nov 2024", reason: "Hello, I am Alice and would like to join this room, please")
static let aliceWithShortReason = KnockRequestCellInfo(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "20 Nov 2024", reason: "Hello, I am Alice and would like to join this room, please")
static let aliceWithNoReason = KnockRequestCellInfo(id: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "20 Nov 2024", reason: nil)
static let aliceWithNoReason = KnockRequestCellInfo(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "20 Nov 2024", reason: nil)
static let aliceWithNoName = KnockRequestCellInfo(id: "@alice:matrix.org", displayName: nil, avatarURL: nil, timestamp: "20 Nov 2024", reason: nil)
static let aliceWithNoName = KnockRequestCellInfo(eventID: "1", userID: "@alice:matrix.org", displayName: nil, avatarURL: nil, timestamp: "20 Nov 2024", reason: nil)
static var previews: some View {
KnockRequestCell(cellInfo: aliceWithLongReason) { _ in } onDecline: { _ in } onDeclineAndBan: { _ in }
@@ -17,12 +17,12 @@ struct KnockRequestsListScreen: View {
.navigationTitle(L10n.screenKnockRequestsListTitle)
.background(.compound.bgCanvasDefault)
.overlay {
if !context.viewState.shouldDisplayRequests {
if context.viewState.shouldDisplayEmptyView {
KnockRequestsListEmptyStateView()
}
}
.safeAreaInset(edge: .bottom) {
if context.viewState.shouldDisplayRequests {
if context.viewState.shouldDisplayAcceptAllButton {
acceptAllButton
}
}
@@ -31,10 +31,18 @@ struct KnockRequestsListScreen: View {
@ViewBuilder
private var mainContent: some View {
if context.viewState.isLoading {
EmptyView()
} else {
list
}
}
private var list: some View {
ScrollView {
LazyVStack(spacing: 0) {
if context.viewState.shouldDisplayRequests {
ForEach(context.viewState.requests) { requestInfo in
ForEach(context.viewState.displayedRequests) { requestInfo in
ListRow(kind: .custom {
KnockRequestCell(cellInfo: requestInfo,
mediaProvider: context.mediaProvider,
@@ -60,37 +68,66 @@ struct KnockRequestsListScreen: View {
.background(.compound.bgCanvasDefault)
}
private func onAccept(userID: String) {
context.send(viewAction: .acceptRequest(userID: userID))
private func onAccept(eventID: String) {
context.send(viewAction: .acceptRequest(eventID: eventID))
}
private func onDecline(userID: String) {
context.send(viewAction: .declineRequest(userID: userID))
private func onDecline(eventID: String) {
context.send(viewAction: .declineRequest(eventID: eventID))
}
private func onDeclineAndBan(userID: String) {
context.send(viewAction: .ban(userID: userID))
private func onDeclineAndBan(eventID: String) {
context.send(viewAction: .ban(eventID: eventID))
}
}
// MARK: - Previews
struct KnockRequestsListScreen_Previews: PreviewProvider, TestablePreview {
static let emptyViewModel = KnockRequestsListScreenViewModel.mockWithInitialState(.init(requests: []))
static let loadingViewModel = KnockRequestsListScreenViewModel.mockWithRequestsState(.loading)
static let emptyViewModel = KnockRequestsListScreenViewModel.mockWithRequestsState(.loaded([]))
static let singleRequestViewModel = KnockRequestsListScreenViewModel.mockWithRequestsState(.loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "Now", reason: "Hello"))]))
static let viewModel = KnockRequestsListScreenViewModel.mockWithRequestsState(.loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "Now", reason: "Hello")),
// swiftlint:disable:next line_length
KnockRequestProxyMock(.init(eventID: "2", userID: "@bob:matrix.org", displayName: "Bob", avatarURL: nil, timestamp: "Now", reason: "Hello this one is a very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very long reason")),
KnockRequestProxyMock(.init(eventID: "3", userID: "@charlie:matrix.org", displayName: "Charlie", avatarURL: nil, timestamp: "Now", reason: nil)),
KnockRequestProxyMock(.init(eventID: "4", userID: "@dan:matrix.org", displayName: "Dan", avatarURL: nil, timestamp: "Now", reason: "Hello! It's a me! Dan!"))]))
static let viewModel = KnockRequestsListScreenViewModel.mockWithInitialState(.init(requests: [.init(id: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "Now", reason: "Hello"),
// swiftlint:disable:next line_length
.init(id: "@bob:matrix.org", displayName: "Bob", avatarURL: nil, timestamp: "Now", reason: "Hello this one is a very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very long reason"),
.init(id: "@charlie:matrix.org", displayName: "Charlie", avatarURL: nil, timestamp: "Now", reason: nil),
.init(id: "@dan:matrix.org", displayName: "Dan", avatarURL: nil, timestamp: "Now", reason: "Hello! It's a me! Dan!")]))
static var previews: some View {
NavigationStack {
KnockRequestsListScreen(context: viewModel.context)
}
.snapshotPreferences(delay: 0.2)
NavigationStack {
KnockRequestsListScreen(context: singleRequestViewModel.context)
}
.previewDisplayName("Single Request")
.snapshotPreferences(delay: 0.2)
NavigationStack {
KnockRequestsListScreen(context: emptyViewModel.context)
}
.previewDisplayName("Empty state")
.snapshotPreferences(delay: 0.2)
NavigationStack {
KnockRequestsListScreen(context: loadingViewModel.context)
}
.previewDisplayName("Loading state")
}
}
extension KnockRequestsListScreenViewModel {
static func mockWithRequestsState(_ requestsState: KnockRequestsState) -> KnockRequestsListScreenViewModel {
.init(roomProxy: JoinedRoomProxyMock(.init(members: [.mockAdmin],
knockRequestsState: requestsState,
ownUserID: RoomMemberProxyMock.mockAdmin.userID,
joinRule: .knock)),
mediaProvider: MediaProviderMock(),
userIndicatorController: UserIndicatorControllerMock())
}
}