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
@@ -8,7 +8,7 @@
import Foundation
import OrderedCollections
enum RoomScreenViewModelAction {
enum RoomScreenViewModelAction: Equatable {
case focusEvent(eventID: String)
case displayPinnedEventsTimeline
case displayRoomDetails
@@ -23,7 +23,7 @@ enum RoomScreenViewAction {
case displayRoomDetails
case displayCall
case footerViewAction(RoomScreenFooterViewAction)
case acceptKnock(userID: String)
case acceptKnock(eventID: String)
case dismissKnockRequests
case viewKnockRequests
}
@@ -48,11 +48,18 @@ struct RoomScreenViewState: BindableState {
var canAcceptKnocks = false
var canDeclineKnocks = false
var canBan = false
// TODO: We still don't know how to get these, but these will be the non already seen knock requests of the room, for now we are using this as a mock for testing purposes
var unseenKnockRequests: [KnockRequestInfo] = [.init(displayName: "Alice", avatarURL: nil, userID: "@alice:matrix.org", reason: "Helloooo")]
var unseenKnockRequests: [KnockRequestInfo] = []
var handledEventIDs: Set<String> = []
var displayedKnockRequests: [KnockRequestInfo] {
unseenKnockRequests.filter { !handledEventIDs.contains($0.eventID) }
}
var shouldSeeKnockRequests: Bool {
isKnockingEnabled && isKnockableRoom && !unseenKnockRequests.isEmpty && (canAcceptKnocks || canDeclineKnocks || canBan)
isKnockingEnabled &&
isKnockableRoom &&
!displayedKnockRequests.isEmpty &&
(canAcceptKnocks || canDeclineKnocks || canBan)
}
var footerDetails: RoomScreenFooterViewDetails?
@@ -103,12 +103,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
case .resolvePinViolation(let userID):
Task { await resolveIdentityPinningViolation(userID) }
}
case .acceptKnock(userID: let userID):
// TODO: API to accept a knock required
break
case .acceptKnock(let eventID):
Task { await acceptKnock(eventID: eventID) }
case .dismissKnockRequests:
// TODO: API to mark knocks as seen required
break
Task { await markAllKnocksAsSeen() }
case .viewKnockRequests:
actionsSubject.send(.displayKnockRequests)
}
@@ -181,6 +179,23 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.shouldShowCallButton = ongoingCallRoomID != roomProxy.id
}
.store(in: &cancellables)
roomProxy.knockRequestsStatePublisher
// We only care about unseen requests
.map { knockRequestsState in
guard case let .loaded(requests) = knockRequestsState else {
return []
}
return requests
.filter { !$0.isSeen }
.map(KnockRequestInfo.init)
}
// If the requests have the same event ids we can discard the output
.removeDuplicates { Set($0.map(\.eventID)) == Set($1.map(\.eventID)) }
.throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true)
.weakAssign(to: \.state.unseenKnockRequests, on: self)
.store(in: &cancellables)
}
private func processIdentityStatusChanges(_ changes: [IdentityStatusChange]) async {
@@ -277,10 +292,49 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
}
}
private func acceptKnock(eventID: String) async {
guard case let .loaded(requests) = roomProxy.knockRequestsStatePublisher.value,
let request = requests.first(where: { $0.eventID == eventID }) else {
return
}
state.handledEventIDs.insert(eventID)
switch await request.accept() {
case .success:
break
case .failure:
userIndicatorController.submitIndicator(.init(id: Self.errorIndicatorIdentifier, type: .toast, title: L10n.errorUnknown))
state.handledEventIDs.remove(eventID)
}
}
private func markAllKnocksAsSeen() async {
guard case let .loaded(requests) = roomProxy.knockRequestsStatePublisher.value else {
return
}
state.handledEventIDs.formUnion(Set(requests.map(\.eventID)))
let failedIDs = await withTaskGroup(of: (String, Result<Void, KnockRequestProxyError>).self) { group in
for request in requests {
group.addTask {
await (request.eventID, request.markAsSeen())
}
}
var failedIDs = [String]()
for await result in group where result.1.isFailure {
failedIDs.append(result.0)
}
return failedIDs
}
state.handledEventIDs.subtract(failedIDs)
}
// MARK: Loading indicators
private static let loadingIndicatorIdentifier = "\(RoomScreenViewModel.self)-Loading"
private static let errorIndicatorIdentifier = "\(RoomScreenViewModel.self)-Error"
private func showLoadingIndicator() {
userIndicatorController.submitIndicator(.init(id: Self.loadingIndicatorIdentifier, type: .toast, title: L10n.commonLoading))
@@ -304,3 +358,13 @@ extension RoomScreenViewModel {
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}
}
private extension KnockRequestInfo {
init(from proxy: KnockRequestProxyProtocol) {
self.init(displayName: proxy.displayName,
avatarURL: proxy.avatarURL,
userID: proxy.userID,
reason: proxy.reason,
eventID: proxy.eventID)
}
}
@@ -8,11 +8,12 @@
import Compound
import SwiftUI
struct KnockRequestInfo {
struct KnockRequestInfo: Equatable {
let displayName: String?
let avatarURL: URL?
let userID: String
let reason: String?
let eventID: String
}
struct KnockRequestsBannerView: View {
@@ -102,10 +103,8 @@ private struct SingleKnockRequestBannerContent: View {
Button(L10n.screenRoomSingleKnockRequestViewButtonTitle, action: onViewAll)
.buttonStyle(.compound(.secondary, size: .medium))
if let onAccept {
Button(L10n.screenRoomSingleKnockRequestAcceptButtonTitle) {
onAccept(request.userID)
}
.buttonStyle(.compound(.primary, size: .medium))
Button(L10n.screenRoomSingleKnockRequestAcceptButtonTitle) { onAccept(request.eventID) }
.buttonStyle(.compound(.primary, size: .medium))
}
}
.padding(.top, request.reason == nil ? 0 : 2)
@@ -123,7 +122,6 @@ private struct MultipleKnockRequestsBannerContent: View {
requests
.prefix(3)
.map { .init(url: $0.avatarURL, name: $0.displayName, contentID: $0.userID) }
.reversed()
}
private var multipleKnockRequestsTitle: String {
@@ -138,7 +136,7 @@ private struct MultipleKnockRequestsBannerContent: View {
var body: some View {
VStack(spacing: 14) {
HStack(spacing: 10) {
StackedAvatarsView(overlap: 16, lineWidth: 2, shouldStackFromLast: true, avatars: avatars, avatarSize: .user(on: .knockingUsersBannerStack), mediaProvider: mediaProvider)
StackedAvatarsView(overlap: 16, lineWidth: 2, avatars: avatars, avatarSize: .user(on: .knockingUsersBannerStack), mediaProvider: mediaProvider)
HStack(alignment: .top, spacing: 0) {
Text(multipleKnockRequestsTitle)
.lineLimit(2)
@@ -173,18 +171,22 @@ private struct KnockRequestsBannerDismissButton: View {
}
struct KnockRequestsBannerView_Previews: PreviewProvider, TestablePreview {
static let singleRequest: [KnockRequestInfo] = [.init(displayName: "Alice", avatarURL: nil, userID: "@alice:matrix.org", reason: nil)]
static let singleRequest: [KnockRequestInfo] = [.init(displayName: "Alice", avatarURL: nil, userID: "@alice:matrix.org", reason: nil, eventID: "1")]
static let singleRequestWithReason: [KnockRequestInfo] = [.init(displayName: "Alice", avatarURL: nil, userID: "@alice:matrix.org", reason: "Hey, I’d like to join this room because of xyz topic and I’d like to participate in the room.")]
static let singleRequestWithReason: [KnockRequestInfo] = [.init(displayName: "Alice",
avatarURL: nil,
userID: "@alice:matrix.org",
reason: "Hey, I’d like to join this room because of xyz topic and I’d like to participate in the room.",
eventID: "1")]
static let singleRequestNoDisplayName: [KnockRequestInfo] = [.init(displayName: nil, avatarURL: nil, userID: "@alice:matrix.org", reason: nil)]
static let singleRequestNoDisplayName: [KnockRequestInfo] = [.init(displayName: nil, avatarURL: nil, userID: "@alice:matrix.org", reason: nil, eventID: "1")]
static let multipleRequests: [KnockRequestInfo] = [
.init(displayName: "Alice", avatarURL: nil, userID: "@alice:matrix.org", reason: nil),
.init(displayName: "Bob", avatarURL: nil, userID: "@bob:matrix.org", reason: nil),
.init(displayName: "Charlie", avatarURL: nil, userID: "@charlie:matrix.org", reason: nil),
.init(displayName: "Dan", avatarURL: nil, userID: "@dan:matrix.org", reason: nil),
.init(displayName: "Test", avatarURL: nil, userID: "@dan:matrix.org", reason: nil)
.init(displayName: "Alice", avatarURL: nil, userID: "@alice:matrix.org", reason: nil, eventID: "1"),
.init(displayName: "Bob", avatarURL: nil, userID: "@bob:matrix.org", reason: nil, eventID: "2"),
.init(displayName: "Charlie", avatarURL: nil, userID: "@charlie:matrix.org", reason: nil, eventID: "3"),
.init(displayName: "Dan", avatarURL: nil, userID: "@dan:matrix.org", reason: nil, eventID: "4"),
.init(displayName: "Test", avatarURL: nil, userID: "@dan:matrix.org", reason: nil, eventID: "5")
]
static var previews: some View {
@@ -137,7 +137,7 @@ struct RoomScreen: View {
private var knockRequestsBanner: some View {
Group {
if roomContext.viewState.shouldSeeKnockRequests {
KnockRequestsBannerView(requests: roomContext.viewState.unseenKnockRequests,
KnockRequestsBannerView(requests: roomContext.viewState.displayedKnockRequests,
onDismiss: dismissKnockRequestsBanner,
onAccept: roomContext.viewState.canAcceptKnocks ? acceptKnockRequest : nil,
onViewAll: onViewAllKnockRequests,
@@ -153,8 +153,8 @@ struct RoomScreen: View {
roomContext.send(viewAction: .dismissKnockRequests)
}
private func acceptKnockRequest(userID: String) {
roomContext.send(viewAction: .acceptKnock(userID: userID))
private func acceptKnockRequest(eventID: String) {
roomContext.send(viewAction: .acceptKnock(eventID: eventID))
}
private func onViewAllKnockRequests() {