mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user