mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Add the empty state to SpaceScreen. (#4985)
* Rename PaginationState.timelineEndReached to PaginationState.endReached. * Rename PaginationState to TimelinePaginationState. Also renames PaginationStatus to PaginationState so that a TimelinePaginationState consists of the forward and backward pagination states. * Add the empty state to SpaceScreen. Only has 1 of the 2 buttons for now.
This commit is contained in:
@@ -1155,6 +1155,7 @@
|
|||||||
C8E1E4E06B7C7A3A8246FC9B /* MediaEventsTimelineScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8512B82404B1751D0BCC82D2 /* MediaEventsTimelineScreenCoordinator.swift */; };
|
C8E1E4E06B7C7A3A8246FC9B /* MediaEventsTimelineScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8512B82404B1751D0BCC82D2 /* MediaEventsTimelineScreenCoordinator.swift */; };
|
||||||
C900127318820AD04D6C90B8 /* LabsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E43D8784B0054C048060FEB /* LabsScreenModels.swift */; };
|
C900127318820AD04D6C90B8 /* LabsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E43D8784B0054C048060FEB /* LabsScreenModels.swift */; };
|
||||||
C915347779B3C7FDD073A87A /* AVMetadataMachineReadableCodeObjectExtensionsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93E1FF0DFBB3768F79FDBF6D /* AVMetadataMachineReadableCodeObjectExtensionsTest.swift */; };
|
C915347779B3C7FDD073A87A /* AVMetadataMachineReadableCodeObjectExtensionsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93E1FF0DFBB3768F79FDBF6D /* AVMetadataMachineReadableCodeObjectExtensionsTest.swift */; };
|
||||||
|
C960BACE42A9D8C535E8CB34 /* Pagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1912062B53CE95E6F700DA60 /* Pagination.swift */; };
|
||||||
C969A62F3D9F14318481A33B /* KnockedRoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 858DA81F2ACF484B7CAD6AE4 /* KnockedRoomProxy.swift */; };
|
C969A62F3D9F14318481A33B /* KnockedRoomProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 858DA81F2ACF484B7CAD6AE4 /* KnockedRoomProxy.swift */; };
|
||||||
C97325EFDCCEE457432A9E82 /* MessageText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E0B4A34E69BD2132BEC521 /* MessageText.swift */; };
|
C97325EFDCCEE457432A9E82 /* MessageText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E0B4A34E69BD2132BEC521 /* MessageText.swift */; };
|
||||||
C9A631FD968249B4BA0B7B3C /* ReactionsSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EE0FABA8ED6D6C1D6CE71D /* ReactionsSummaryView.swift */; };
|
C9A631FD968249B4BA0B7B3C /* ReactionsSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EE0FABA8ED6D6C1D6CE71D /* ReactionsSummaryView.swift */; };
|
||||||
@@ -1692,6 +1693,7 @@
|
|||||||
18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceScreenViewModelTests.swift; sourceTree = "<group>"; };
|
18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceScreenViewModelTests.swift; sourceTree = "<group>"; };
|
||||||
18F2958E6D247AE2516BEEE8 /* ClientProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientProxy.swift; sourceTree = "<group>"; };
|
18F2958E6D247AE2516BEEE8 /* ClientProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientProxy.swift; sourceTree = "<group>"; };
|
||||||
190EC7285D3CFEF0D3011BCF /* GeoURI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoURI.swift; sourceTree = "<group>"; };
|
190EC7285D3CFEF0D3011BCF /* GeoURI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoURI.swift; sourceTree = "<group>"; };
|
||||||
|
1912062B53CE95E6F700DA60 /* Pagination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pagination.swift; sourceTree = "<group>"; };
|
||||||
196004E7695FBA292A7944AF /* ScreenTrackerViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTrackerViewModifier.swift; sourceTree = "<group>"; };
|
196004E7695FBA292A7944AF /* ScreenTrackerViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenTrackerViewModifier.swift; sourceTree = "<group>"; };
|
||||||
19DD166C3625EE426203FA29 /* AppLockSetupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupTests.swift; sourceTree = "<group>"; };
|
19DD166C3625EE426203FA29 /* AppLockSetupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockSetupTests.swift; sourceTree = "<group>"; };
|
||||||
1A02406480C351B8C6E0682C /* MediaLoaderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLoaderProtocol.swift; sourceTree = "<group>"; };
|
1A02406480C351B8C6E0682C /* MediaLoaderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLoaderProtocol.swift; sourceTree = "<group>"; };
|
||||||
@@ -6014,6 +6016,7 @@
|
|||||||
7B25F959A434BB9923A3223F /* ExpiringTaskRunner.swift */,
|
7B25F959A434BB9923A3223F /* ExpiringTaskRunner.swift */,
|
||||||
6A580295A56B55A856CC4084 /* InfoPlistReader.swift */,
|
6A580295A56B55A856CC4084 /* InfoPlistReader.swift */,
|
||||||
6AD1A853D605C2146B0DC028 /* MatrixEntityRegex.swift */,
|
6AD1A853D605C2146B0DC028 /* MatrixEntityRegex.swift */,
|
||||||
|
1912062B53CE95E6F700DA60 /* Pagination.swift */,
|
||||||
53482ECA4B6633961EC224F5 /* ScrollViewAdapter.swift */,
|
53482ECA4B6633961EC224F5 /* ScrollViewAdapter.swift */,
|
||||||
FE5CD2993048222B64C45006 /* SDKListener.swift */,
|
FE5CD2993048222B64C45006 /* SDKListener.swift */,
|
||||||
4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */,
|
4481799F455B3DA243BDA2AC /* ShareToMapsAppActivity.swift */,
|
||||||
@@ -8338,6 +8341,7 @@
|
|||||||
6F86349BDEAF4495EAE38931 /* PHGPostHogMock.swift in Sources */,
|
6F86349BDEAF4495EAE38931 /* PHGPostHogMock.swift in Sources */,
|
||||||
F7D709D7ECABE46641BB8B6B /* PHGPostHogProtocol.swift in Sources */,
|
F7D709D7ECABE46641BB8B6B /* PHGPostHogProtocol.swift in Sources */,
|
||||||
847DE3A7EB9FCA2C429C6E85 /* PINTextField.swift in Sources */,
|
847DE3A7EB9FCA2C429C6E85 /* PINTextField.swift in Sources */,
|
||||||
|
C960BACE42A9D8C535E8CB34 /* Pagination.swift in Sources */,
|
||||||
7501442D52A65F73DF79FFD4 /* PaginationIndicatorRoomTimelineItem.swift in Sources */,
|
7501442D52A65F73DF79FFD4 /* PaginationIndicatorRoomTimelineItem.swift in Sources */,
|
||||||
BC7CA1379D7C24F47B1B8B7E /* PaginationIndicatorRoomTimelineView.swift in Sources */,
|
BC7CA1379D7C24F47B1B8B7E /* PaginationIndicatorRoomTimelineView.swift in Sources */,
|
||||||
EA2FECCD9E00D9784AC6017D /* PhishingDetector.swift in Sources */,
|
EA2FECCD9E00D9784AC6017D /* PhishingDetector.swift in Sources */,
|
||||||
|
|||||||
@@ -723,6 +723,7 @@
|
|||||||
"screen_security_and_privacy_room_visibility_section_footer" = "Addresses are a way to find and access rooms and spaces. This also ensures you can easily share them with others.";
|
"screen_security_and_privacy_room_visibility_section_footer" = "Addresses are a way to find and access rooms and spaces. This also ensures you can easily share them with others.";
|
||||||
"screen_security_and_privacy_room_visibility_section_header" = "Visibility";
|
"screen_security_and_privacy_room_visibility_section_header" = "Visibility";
|
||||||
"screen_security_and_privacy_title" = "Security & privacy";
|
"screen_security_and_privacy_title" = "Security & privacy";
|
||||||
|
"screen_space_empty_state_title" = "Add your first room";
|
||||||
"screen_space_menu_action_members" = "View members";
|
"screen_space_menu_action_members" = "View members";
|
||||||
"screen_space_remove_rooms_confirmation_content" = "Removing a room will not affect the room access. To change the access go to Room info > Privacy & security.";
|
"screen_space_remove_rooms_confirmation_content" = "Removing a room will not affect the room access. To change the access go to Room info > Privacy & security.";
|
||||||
"screen_space_remove_rooms_confirmation_title_ios" = "Remove rooms from %1$@?";
|
"screen_space_remove_rooms_confirmation_title_ios" = "Remove rooms from %1$@?";
|
||||||
|
|||||||
@@ -723,6 +723,7 @@
|
|||||||
"screen_security_and_privacy_room_visibility_section_footer" = "Addresses are a way to find and access rooms and spaces. This also ensures you can easily share them with others.";
|
"screen_security_and_privacy_room_visibility_section_footer" = "Addresses are a way to find and access rooms and spaces. This also ensures you can easily share them with others.";
|
||||||
"screen_security_and_privacy_room_visibility_section_header" = "Visibility";
|
"screen_security_and_privacy_room_visibility_section_header" = "Visibility";
|
||||||
"screen_security_and_privacy_title" = "Security & privacy";
|
"screen_security_and_privacy_title" = "Security & privacy";
|
||||||
|
"screen_space_empty_state_title" = "Add your first room";
|
||||||
"screen_space_menu_action_members" = "View members";
|
"screen_space_menu_action_members" = "View members";
|
||||||
"screen_space_remove_rooms_confirmation_content" = "Removing a room will not affect the room access. To change the access go to Room info > Privacy & security.";
|
"screen_space_remove_rooms_confirmation_content" = "Removing a room will not affect the room access. To change the access go to Room info > Privacy & security.";
|
||||||
"screen_space_remove_rooms_confirmation_title_ios" = "Remove rooms from %1$@?";
|
"screen_space_remove_rooms_confirmation_title_ios" = "Remove rooms from %1$@?";
|
||||||
|
|||||||
@@ -3159,6 +3159,8 @@ internal enum L10n {
|
|||||||
internal static var screenSpaceAnnouncementSubtitle: String { return L10n.tr("Localizable", "screen_space_announcement_subtitle") }
|
internal static var screenSpaceAnnouncementSubtitle: String { return L10n.tr("Localizable", "screen_space_announcement_subtitle") }
|
||||||
/// Introducing Spaces
|
/// Introducing Spaces
|
||||||
internal static var screenSpaceAnnouncementTitle: String { return L10n.tr("Localizable", "screen_space_announcement_title") }
|
internal static var screenSpaceAnnouncementTitle: String { return L10n.tr("Localizable", "screen_space_announcement_title") }
|
||||||
|
/// Add your first room
|
||||||
|
internal static var screenSpaceEmptyStateTitle: String { return L10n.tr("Localizable", "screen_space_empty_state_title") }
|
||||||
/// Spaces you have created or joined.
|
/// Spaces you have created or joined.
|
||||||
internal static var screenSpaceListDescription: String { return L10n.tr("Localizable", "screen_space_list_description") }
|
internal static var screenSpaceListDescription: String { return L10n.tr("Localizable", "screen_space_list_description") }
|
||||||
/// %1$@ • %2$@
|
/// %1$@ • %2$@
|
||||||
|
|||||||
@@ -17622,17 +17622,17 @@ class TimelineControllerFactoryMock: TimelineControllerFactoryProtocol, @uncheck
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
class TimelineItemProviderMock: TimelineItemProviderProtocol, @unchecked Sendable {
|
class TimelineItemProviderMock: TimelineItemProviderProtocol, @unchecked Sendable {
|
||||||
var updatePublisher: AnyPublisher<([TimelineItemProxy], PaginationState), Never> {
|
var updatePublisher: AnyPublisher<([TimelineItemProxy], TimelinePaginationState), Never> {
|
||||||
get { return underlyingUpdatePublisher }
|
get { return underlyingUpdatePublisher }
|
||||||
set(value) { underlyingUpdatePublisher = value }
|
set(value) { underlyingUpdatePublisher = value }
|
||||||
}
|
}
|
||||||
var underlyingUpdatePublisher: AnyPublisher<([TimelineItemProxy], PaginationState), Never>!
|
var underlyingUpdatePublisher: AnyPublisher<([TimelineItemProxy], TimelinePaginationState), Never>!
|
||||||
var itemProxies: [TimelineItemProxy] = []
|
var itemProxies: [TimelineItemProxy] = []
|
||||||
var paginationState: PaginationState {
|
var paginationState: TimelinePaginationState {
|
||||||
get { return underlyingPaginationState }
|
get { return underlyingPaginationState }
|
||||||
set(value) { underlyingPaginationState = value }
|
set(value) { underlyingPaginationState = value }
|
||||||
}
|
}
|
||||||
var underlyingPaginationState: PaginationState!
|
var underlyingPaginationState: TimelinePaginationState!
|
||||||
var kind: TimelineKind {
|
var kind: TimelineKind {
|
||||||
get { return underlyingKind }
|
get { return underlyingKind }
|
||||||
set(value) { underlyingKind = value }
|
set(value) { underlyingKind = value }
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import MatrixRustSDKMocks
|
|||||||
class AutoUpdatingTimelineItemProviderMock: TimelineItemProvider {
|
class AutoUpdatingTimelineItemProviderMock: TimelineItemProvider {
|
||||||
static var timelineListener: TimelineListener?
|
static var timelineListener: TimelineListener?
|
||||||
|
|
||||||
private let innerPaginationStatePublisher: PassthroughSubject<PaginationState, Never>
|
private let innerPaginationStatePublisher: PassthroughSubject<TimelinePaginationState, Never>
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
innerPaginationStatePublisher = .init()
|
innerPaginationStatePublisher = .init()
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ extension TimelineProxyMock {
|
|||||||
underlyingTimelineItemProvider = AutoUpdatingTimelineItemProviderMock()
|
underlyingTimelineItemProvider = AutoUpdatingTimelineItemProviderMock()
|
||||||
} else {
|
} else {
|
||||||
let timelineItemProvider = TimelineItemProviderMock()
|
let timelineItemProvider = TimelineItemProviderMock()
|
||||||
timelineItemProvider.paginationState = .init(backward: configuration.timelineStartReached ? .timelineEndReached : .idle, forward: .timelineEndReached)
|
timelineItemProvider.paginationState = .init(backward: configuration.timelineStartReached ? .endReached : .idle, forward: .endReached)
|
||||||
timelineItemProvider.underlyingMembershipChangePublisher = PassthroughSubject().eraseToAnyPublisher()
|
timelineItemProvider.underlyingMembershipChangePublisher = PassthroughSubject().eraseToAnyPublisher()
|
||||||
underlyingTimelineItemProvider = timelineItemProvider
|
underlyingTimelineItemProvider = timelineItemProvider
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// Copyright 2026 Element Creations Ltd.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
// Please see LICENSE files in the repository root for full details.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum PaginationDirection: String {
|
||||||
|
case backwards, forwards
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PaginationState {
|
||||||
|
case idle
|
||||||
|
case endReached
|
||||||
|
case paginating
|
||||||
|
}
|
||||||
@@ -33,12 +33,12 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
|
|||||||
private var backwardPadding: Int
|
private var backwardPadding: Int
|
||||||
private var forwardPadding: Int
|
private var forwardPadding: Int
|
||||||
|
|
||||||
var paginationState: PaginationState
|
var paginationState: TimelinePaginationState
|
||||||
|
|
||||||
init(itemViewStates: [RoomTimelineItemViewState],
|
init(itemViewStates: [RoomTimelineItemViewState],
|
||||||
initialItem: EventBasedMessageTimelineItemProtocol,
|
initialItem: EventBasedMessageTimelineItemProtocol,
|
||||||
initialPadding: Int = 100,
|
initialPadding: Int = 100,
|
||||||
paginationState: PaginationState) {
|
paginationState: TimelinePaginationState) {
|
||||||
previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.Media.init)
|
previewItems = itemViewStates.compactMap(TimelineMediaPreviewItem.Media.init)
|
||||||
self.initialItem = initialItem
|
self.initialItem = initialItem
|
||||||
|
|
||||||
@@ -125,9 +125,9 @@ class TimelineMediaPreviewDataSource: NSObject, QLPreviewControllerDataSource {
|
|||||||
let arrayIndex = index - backwardPadding
|
let arrayIndex = index - backwardPadding
|
||||||
|
|
||||||
if index < firstPreviewItemIndex {
|
if index < firstPreviewItemIndex {
|
||||||
return paginationState.backward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineStart : .paginatingBackwards
|
return paginationState.backward == .endReached ? TimelineMediaPreviewItem.Loading.timelineStart : .paginatingBackwards
|
||||||
} else if index > lastPreviewItemIndex {
|
} else if index > lastPreviewItemIndex {
|
||||||
return paginationState.forward == .timelineEndReached ? TimelineMediaPreviewItem.Loading.timelineEnd : .paginatingForwards
|
return paginationState.forward == .endReached ? TimelineMediaPreviewItem.Loading.timelineEnd : .paginatingForwards
|
||||||
} else {
|
} else {
|
||||||
return previewItems[arrayIndex]
|
return previewItems[arrayIndex]
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -123,7 +123,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
|
|||||||
updateWithTimelineViewState(activeTimelineViewModel.context.viewState)
|
updateWithTimelineViewState(activeTimelineViewModel.context.viewState)
|
||||||
case .oldestItemDidAppear:
|
case .oldestItemDidAppear:
|
||||||
isOldestItemVisible = true
|
isOldestItemVisible = true
|
||||||
backPaginateIfNecessary(paginationStatus: activeTimelineViewModel.context.viewState.timelineState.paginationState.backward)
|
backPaginateIfNecessary(backPaginationState: activeTimelineViewModel.context.viewState.timelineState.paginationState.backward)
|
||||||
case .oldestItemDidDisappear:
|
case .oldestItemDidDisappear:
|
||||||
isOldestItemVisible = false
|
isOldestItemVisible = false
|
||||||
case .tappedItem(let item):
|
case .tappedItem(let item):
|
||||||
@@ -205,12 +205,12 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
|
|||||||
state.groups = newGroups
|
state.groups = newGroups
|
||||||
|
|
||||||
state.isBackPaginating = timelineViewState.timelineState.paginationState.backward == .paginating
|
state.isBackPaginating = timelineViewState.timelineState.paginationState.backward == .paginating
|
||||||
state.shouldShowEmptyState = newGroups.isEmpty && timelineViewState.timelineState.paginationState.backward == .timelineEndReached
|
state.shouldShowEmptyState = newGroups.isEmpty && timelineViewState.timelineState.paginationState.backward == .endReached
|
||||||
backPaginateIfNecessary(paginationStatus: timelineViewState.timelineState.paginationState.backward)
|
backPaginateIfNecessary(backPaginationState: timelineViewState.timelineState.paginationState.backward)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func backPaginateIfNecessary(paginationStatus: PaginationStatus) {
|
private func backPaginateIfNecessary(backPaginationState: PaginationState) {
|
||||||
if paginationStatus == .idle, isOldestItemVisible {
|
if backPaginationState == .idle, isOldestItemVisible {
|
||||||
activeTimelineViewModel.context.send(viewAction: .paginateBackwards)
|
activeTimelineViewModel.context.send(viewAction: .paginateBackwards)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -75,7 +75,7 @@ class RoomPollsHistoryScreenViewModel: RoomPollsHistoryScreenViewModelType, Room
|
|||||||
case .updatedTimelineItems:
|
case .updatedTimelineItems:
|
||||||
self.updatePollsList(filter: state.bindings.filter)
|
self.updatePollsList(filter: state.bindings.filter)
|
||||||
case .paginationState(let paginationState):
|
case .paginationState(let paginationState):
|
||||||
let canBackPaginate = paginationState.backward != .timelineEndReached
|
let canBackPaginate = paginationState.backward != .endReached
|
||||||
if self.state.canBackPaginate != canBackPaginate {
|
if self.state.canBackPaginate != canBackPaginate {
|
||||||
self.state.canBackPaginate = canBackPaginate
|
self.state.canBackPaginate = canBackPaginate
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ struct SpaceScreenViewState: BindableState {
|
|||||||
var permalink: URL?
|
var permalink: URL?
|
||||||
var roomProxy: JoinedRoomProxyProtocol?
|
var roomProxy: JoinedRoomProxyProtocol?
|
||||||
|
|
||||||
var isPaginating = false
|
var paginationState: PaginationState = .idle
|
||||||
var rooms: [SpaceServiceRoomProtocol]
|
var rooms: [SpaceServiceRoomProtocol]
|
||||||
var selectedSpaceRoomID: String?
|
var selectedSpaceRoomID: String?
|
||||||
var joiningRoomIDs: Set<String> = []
|
var joiningRoomIDs: Set<String> = []
|
||||||
@@ -40,6 +40,10 @@ struct SpaceScreenViewState: BindableState {
|
|||||||
|
|
||||||
var bindings = SpaceScreenViewStateBindings()
|
var bindings = SpaceScreenViewStateBindings()
|
||||||
|
|
||||||
|
var shouldShowEmptyState: Bool {
|
||||||
|
rooms.isEmpty && paginationState == .endReached && canEditChildren
|
||||||
|
}
|
||||||
|
|
||||||
var visibleRooms: [SpaceServiceRoomProtocol] {
|
var visibleRooms: [SpaceServiceRoomProtocol] {
|
||||||
if editMode == .inactive {
|
if editMode == .inactive {
|
||||||
rooms
|
rooms
|
||||||
|
|||||||
@@ -57,13 +57,16 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
|
|||||||
spaceRoomListProxy.paginationStatePublisher
|
spaceRoomListProxy.paginationStatePublisher
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] paginationState in
|
.sink { [weak self] paginationState in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
switch paginationState {
|
switch paginationState {
|
||||||
case .idle(let endReached):
|
case .idle(endReached: false):
|
||||||
self?.state.isPaginating = false
|
state.paginationState = .idle
|
||||||
guard !endReached else { return }
|
|
||||||
Task { await spaceRoomListProxy.paginate() }
|
Task { await spaceRoomListProxy.paginate() }
|
||||||
|
case .idle(endReached: true):
|
||||||
|
state.paginationState = .endReached
|
||||||
case .loading:
|
case .loading:
|
||||||
self?.state.isPaginating = true
|
state.paginationState = .paginating
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ struct SpaceScreen: View {
|
|||||||
mediaProvider: context.mediaProvider)
|
mediaProvider: context.mediaProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
rooms
|
if context.viewState.shouldShowEmptyState {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
rooms
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.environment(\.editMode, .constant(context.viewState.editMode))
|
.environment(\.editMode, .constant(context.viewState.editMode))
|
||||||
@@ -53,12 +57,32 @@ struct SpaceScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if context.viewState.isPaginating {
|
if context.viewState.paginationState == .paginating {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var emptyState: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
TitleAndIcon(title: L10n.screenSpaceEmptyStateTitle,
|
||||||
|
icon: \.room,
|
||||||
|
iconStyle: .defaultSolid)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Button { context.send(viewAction: .addExistingRooms) } label: {
|
||||||
|
Label(L10n.actionAddExistingRooms, icon: \.plus)
|
||||||
|
}
|
||||||
|
.buttonStyle(.compound(.primary))
|
||||||
|
|
||||||
|
// @Velin92 Create Room button goes here.
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
.padding(.top, 40)
|
||||||
|
}
|
||||||
|
|
||||||
@ToolbarContentBuilder
|
@ToolbarContentBuilder
|
||||||
var toolbar: some ToolbarContent {
|
var toolbar: some ToolbarContent {
|
||||||
if isEditModeActive {
|
if isEditModeActive {
|
||||||
@@ -150,6 +174,7 @@ struct SpaceScreen: View {
|
|||||||
struct SpaceScreen_Previews: PreviewProvider, TestablePreview {
|
struct SpaceScreen_Previews: PreviewProvider, TestablePreview {
|
||||||
static let viewModel = makeViewModel()
|
static let viewModel = makeViewModel()
|
||||||
static let managingViewModel = makeViewModel(isManagingRooms: true)
|
static let managingViewModel = makeViewModel(isManagingRooms: true)
|
||||||
|
static let newSpaceViewModel = makeViewModel(isNewSpace: true)
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -160,9 +185,19 @@ struct SpaceScreen_Previews: PreviewProvider, TestablePreview {
|
|||||||
SpaceScreen(context: managingViewModel.context)
|
SpaceScreen(context: managingViewModel.context)
|
||||||
}
|
}
|
||||||
.previewDisplayName("Managing")
|
.previewDisplayName("Managing")
|
||||||
|
|
||||||
|
NavigationStack {
|
||||||
|
SpaceScreen(context: newSpaceViewModel.context)
|
||||||
|
}
|
||||||
|
.previewDisplayName("New Space")
|
||||||
|
.snapshotPreferences(expect: newSpaceViewModel.context.observe(\.viewState.shouldShowEmptyState))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func makeViewModel(isManagingRooms: Bool = false) -> SpaceScreenViewModel {
|
static func makeViewModel(isManagingRooms: Bool = false, isNewSpace: Bool = false) -> SpaceScreenViewModel {
|
||||||
|
let appSettings = AppSettings()
|
||||||
|
appSettings.spaceSettingsEnabled = true
|
||||||
|
appSettings.createSpaceEnabled = true
|
||||||
|
|
||||||
let spaceServiceRoom = SpaceServiceRoomMock(.init(id: "!eng-space:matrix.org",
|
let spaceServiceRoom = SpaceServiceRoomMock(.init(id: "!eng-space:matrix.org",
|
||||||
name: "Engineering Team",
|
name: "Engineering Team",
|
||||||
isSpace: true,
|
isSpace: true,
|
||||||
@@ -173,11 +208,11 @@ struct SpaceScreen_Previews: PreviewProvider, TestablePreview {
|
|||||||
canonicalAlias: "#engineering-team:element.io",
|
canonicalAlias: "#engineering-team:element.io",
|
||||||
joinRule: .knockRestricted(rules: [.roomMembership(roomId: "")])))
|
joinRule: .knockRestricted(rules: [.roomMembership(roomId: "")])))
|
||||||
let spaceRoomListProxy = SpaceRoomListProxyMock(.init(spaceServiceRoom: spaceServiceRoom,
|
let spaceRoomListProxy = SpaceRoomListProxyMock(.init(spaceServiceRoom: spaceServiceRoom,
|
||||||
initialSpaceRooms: .mockSpaceList))
|
initialSpaceRooms: isNewSpace ? [] : .mockSpaceList))
|
||||||
|
|
||||||
let clientProxy = ClientProxyMock(.init())
|
let clientProxy = ClientProxyMock(.init())
|
||||||
clientProxy.roomForIdentifierClosure = { _ in
|
clientProxy.roomForIdentifierClosure = { _ in
|
||||||
.joined(JoinedRoomProxyMock(.init()))
|
.joined(JoinedRoomProxyMock(.init(members: .allMembersAsAdmin)))
|
||||||
}
|
}
|
||||||
let userSession = UserSessionMock(.init(clientProxy: clientProxy))
|
let userSession = UserSessionMock(.init(clientProxy: clientProxy))
|
||||||
|
|
||||||
@@ -185,7 +220,7 @@ struct SpaceScreen_Previews: PreviewProvider, TestablePreview {
|
|||||||
spaceServiceProxy: SpaceServiceProxyMock(.init()),
|
spaceServiceProxy: SpaceServiceProxyMock(.init()),
|
||||||
selectedSpaceRoomPublisher: .init(nil),
|
selectedSpaceRoomPublisher: .init(nil),
|
||||||
userSession: userSession,
|
userSession: userSession,
|
||||||
appSettings: AppSettings(),
|
appSettings: appSettings,
|
||||||
userIndicatorController: UserIndicatorControllerMock())
|
userIndicatorController: UserIndicatorControllerMock())
|
||||||
|
|
||||||
if isManagingRooms {
|
if isManagingRooms {
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ struct RoomMemberState {
|
|||||||
/// Is also nice to have this as a wrapper for any state that is directly connected to the timeline.
|
/// Is also nice to have this as a wrapper for any state that is directly connected to the timeline.
|
||||||
struct TimelineState {
|
struct TimelineState {
|
||||||
var isLive = true
|
var isLive = true
|
||||||
var paginationState = PaginationState.initial
|
var paginationState = TimelinePaginationState.initial
|
||||||
|
|
||||||
/// The room is in the process of loading items from a new timeline (switching to/from a detached timeline).
|
/// The room is in the process of loading items from a new timeline (switching to/from a detached timeline).
|
||||||
var isSwitchingTimelines = false
|
var isSwitchingTimelines = false
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class TimelineTableViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The state of pagination (in both directions) of the current timeline.
|
/// The state of pagination (in both directions) of the current timeline.
|
||||||
var paginationState: PaginationState = .initial {
|
var paginationState: TimelinePaginationState = .initial {
|
||||||
didSet {
|
didSet {
|
||||||
// Paginate again if the threshold hasn't been satisfied.
|
// Paginate again if the threshold hasn't been satisfied.
|
||||||
paginatePublisher.send(())
|
paginatePublisher.send(())
|
||||||
|
|||||||
@@ -614,7 +614,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if state.timelineState.paginationState.forward == .timelineEndReached {
|
if state.timelineState.paginationState.forward == .endReached {
|
||||||
focusLive()
|
focusLive()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class MockTimelineController: TimelineControllerProtocol {
|
|||||||
|
|
||||||
let callbacks = PassthroughSubject<TimelineControllerCallback, Never>()
|
let callbacks = PassthroughSubject<TimelineControllerCallback, Never>()
|
||||||
|
|
||||||
var paginationState: PaginationState = .initial {
|
var paginationState: TimelinePaginationState = .initial {
|
||||||
didSet {
|
didSet {
|
||||||
callbacks.send(.paginationState(paginationState))
|
callbacks.send(.paginationState(paginationState))
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ class MockTimelineController: TimelineControllerProtocol {
|
|||||||
|
|
||||||
static var emptyMediaGallery: MockTimelineController {
|
static var emptyMediaGallery: MockTimelineController {
|
||||||
let mock = MockTimelineController(timelineKind: .media(.mediaFilesScreen))
|
let mock = MockTimelineController(timelineKind: .media(.mediaFilesScreen))
|
||||||
mock.paginationState = PaginationState(backward: .timelineEndReached, forward: .timelineEndReached)
|
mock.paginationState = TimelinePaginationState(backward: .endReached, forward: .endReached)
|
||||||
return mock
|
return mock
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ class MockTimelineController: TimelineControllerProtocol {
|
|||||||
func paginateBackwards(requestSize: UInt16) async -> Result<Void, TimelineControllerError> {
|
func paginateBackwards(requestSize: UInt16) async -> Result<Void, TimelineControllerError> {
|
||||||
paginateBackwardsCallCount += 1
|
paginateBackwardsCallCount += 1
|
||||||
|
|
||||||
paginationState = PaginationState(backward: .paginating, forward: .timelineEndReached)
|
paginationState = TimelinePaginationState(backward: .paginating, forward: .endReached)
|
||||||
|
|
||||||
if client == nil {
|
if client == nil {
|
||||||
try? await simulateBackPagination()
|
try? await simulateBackPagination()
|
||||||
@@ -331,8 +331,8 @@ class MockTimelineController: TimelineControllerProtocol {
|
|||||||
/// Prepends the next chunk of items to the `timelineItems` array.
|
/// Prepends the next chunk of items to the `timelineItems` array.
|
||||||
private func simulateBackPagination() async throws {
|
private func simulateBackPagination() async throws {
|
||||||
defer {
|
defer {
|
||||||
paginationState = PaginationState(backward: backPaginationResponses.isEmpty ? .timelineEndReached : .idle,
|
paginationState = TimelinePaginationState(backward: backPaginationResponses.isEmpty ? .endReached : .idle,
|
||||||
forward: .timelineEndReached)
|
forward: .endReached)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard !backPaginationResponses.isEmpty else { return }
|
guard !backPaginationResponses.isEmpty else { return }
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class TimelineController: TimelineControllerProtocol {
|
|||||||
|
|
||||||
private(set) var timelineItems = [RoomTimelineItemProtocol]()
|
private(set) var timelineItems = [RoomTimelineItemProtocol]()
|
||||||
|
|
||||||
private(set) var paginationState: PaginationState = .initial {
|
private(set) var paginationState: TimelinePaginationState = .initial {
|
||||||
didSet {
|
didSet {
|
||||||
callbacks.send(.paginationState(paginationState))
|
callbacks.send(.paginationState(paginationState))
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ class TimelineController: TimelineControllerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
paginationState = PaginationState(backward: .paginating, forward: .paginating)
|
paginationState = TimelinePaginationState(backward: .paginating, forward: .paginating)
|
||||||
|
|
||||||
switch await focusOnEvent(initialFocussedEventID, timelineSize: 100) {
|
switch await focusOnEvent(initialFocussedEventID, timelineSize: 100) {
|
||||||
case .success:
|
case .success:
|
||||||
@@ -396,7 +396,7 @@ class TimelineController: TimelineControllerProtocol {
|
|||||||
isSwitchingTimelines = true
|
isSwitchingTimelines = true
|
||||||
|
|
||||||
// Inform the world that the initial items are loading from the store
|
// Inform the world that the initial items are loading from the store
|
||||||
paginationState = PaginationState(backward: .paginating, forward: .paginating)
|
paginationState = TimelinePaginationState(backward: .paginating, forward: .paginating)
|
||||||
callbacks.send(.isLive(activeTimelineItemProvider.kind == .live))
|
callbacks.send(.isLive(activeTimelineItemProvider.kind == .live))
|
||||||
|
|
||||||
updateTimelineItemsCancellable = Task { [weak self, activeTimelineItemProvider] in
|
updateTimelineItemsCancellable = Task { [weak self, activeTimelineItemProvider] in
|
||||||
@@ -411,7 +411,7 @@ class TimelineController: TimelineControllerProtocol {
|
|||||||
}.asCancellable()
|
}.asCancellable()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateTimelineItems(itemProxies: [TimelineItemProxy], paginationState: PaginationState) async {
|
private func updateTimelineItems(itemProxies: [TimelineItemProxy], paginationState: TimelinePaginationState) async {
|
||||||
let isNewTimeline = isSwitchingTimelines
|
let isNewTimeline = isSwitchingTimelines
|
||||||
isSwitchingTimelines = false
|
isSwitchingTimelines = false
|
||||||
|
|
||||||
@@ -462,14 +462,14 @@ class TimelineController: TimelineControllerProtocol {
|
|||||||
switch paginationState.backward {
|
switch paginationState.backward {
|
||||||
case .paginating:
|
case .paginating:
|
||||||
newTimelineItems.insert(PaginationIndicatorRoomTimelineItem(position: .start), at: 0)
|
newTimelineItems.insert(PaginationIndicatorRoomTimelineItem(position: .start), at: 0)
|
||||||
case .idle, .timelineEndReached:
|
case .idle, .endReached:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
switch paginationState.forward {
|
switch paginationState.forward {
|
||||||
case .paginating:
|
case .paginating:
|
||||||
newTimelineItems.insert(PaginationIndicatorRoomTimelineItem(position: .end), at: newTimelineItems.count)
|
newTimelineItems.insert(PaginationIndicatorRoomTimelineItem(position: .end), at: newTimelineItems.count)
|
||||||
case .idle, .timelineEndReached:
|
case .idle, .endReached:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@ import SwiftUI
|
|||||||
|
|
||||||
enum TimelineControllerCallback {
|
enum TimelineControllerCallback {
|
||||||
case updatedTimelineItems(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool)
|
case updatedTimelineItems(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool)
|
||||||
case paginationState(PaginationState)
|
case paginationState(TimelinePaginationState)
|
||||||
case isLive(Bool)
|
case isLive(Bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ protocol TimelineControllerProtocol {
|
|||||||
var timelineItems: [RoomTimelineItemProtocol] { get }
|
var timelineItems: [RoomTimelineItemProtocol] { get }
|
||||||
|
|
||||||
/// The current pagination state, use only for setting up the intial state
|
/// The current pagination state, use only for setting up the intial state
|
||||||
var paginationState: PaginationState { get }
|
var paginationState: TimelinePaginationState { get }
|
||||||
|
|
||||||
var callbacks: PassthroughSubject<TimelineControllerCallback, Never> { get }
|
var callbacks: PassthroughSubject<TimelineControllerCallback, Never> { get }
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ class TimelineItemProvider: TimelineItemProviderProtocol {
|
|||||||
|
|
||||||
private var roomTimelineObservationToken: TaskHandle?
|
private var roomTimelineObservationToken: TaskHandle?
|
||||||
|
|
||||||
private let paginationStateSubject = CurrentValueSubject<PaginationState, Never>(.initial)
|
private let paginationStateSubject = CurrentValueSubject<TimelinePaginationState, Never>(.initial)
|
||||||
var paginationState: PaginationState {
|
var paginationState: TimelinePaginationState {
|
||||||
paginationStateSubject.value
|
paginationStateSubject.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ class TimelineItemProvider: TimelineItemProviderProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var updatePublisher: AnyPublisher<([TimelineItemProxy], PaginationState), Never> {
|
var updatePublisher: AnyPublisher<([TimelineItemProxy], TimelinePaginationState), Never> {
|
||||||
itemProxiesSubject
|
itemProxiesSubject
|
||||||
.combineLatest(paginationStateSubject)
|
.combineLatest(paginationStateSubject)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
@@ -46,7 +46,7 @@ class TimelineItemProvider: TimelineItemProviderProtocol {
|
|||||||
roomTimelineObservationToken?.cancel()
|
roomTimelineObservationToken?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
init(timeline: Timeline, kind: TimelineKind, paginationStatePublisher: AnyPublisher<PaginationState, Never>) {
|
init(timeline: Timeline, kind: TimelineKind, paginationStatePublisher: AnyPublisher<TimelinePaginationState, Never>) {
|
||||||
serialDispatchQueue = DispatchQueue(label: "io.element.elementx.timeline_item_provider", qos: .utility)
|
serialDispatchQueue = DispatchQueue(label: "io.element.elementx.timeline_item_provider", qos: .utility)
|
||||||
itemProxiesSubject = CurrentValueSubject<[TimelineItemProxy], Never>([])
|
itemProxiesSubject = CurrentValueSubject<[TimelineItemProxy], Never>([])
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
|
|||||||
@@ -10,22 +10,12 @@ import Combine
|
|||||||
import Foundation
|
import Foundation
|
||||||
import MatrixRustSDK
|
import MatrixRustSDK
|
||||||
|
|
||||||
enum PaginationDirection: String {
|
struct TimelinePaginationState: Equatable {
|
||||||
case backwards, forwards
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PaginationStatus {
|
|
||||||
case idle
|
|
||||||
case timelineEndReached
|
|
||||||
case paginating
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PaginationState: Equatable {
|
|
||||||
/// An initial state that is used to prevent pagination whilst loading the timeline.
|
/// An initial state that is used to prevent pagination whilst loading the timeline.
|
||||||
/// Once the initial items are loaded the TimelineProxy will publish the correct value.
|
/// Once the initial items are loaded the TimelineProxy will publish the correct value.
|
||||||
static var initial = PaginationState(backward: .timelineEndReached, forward: .timelineEndReached)
|
static var initial = TimelinePaginationState(backward: .endReached, forward: .endReached)
|
||||||
let backward: PaginationStatus
|
let backward: PaginationState
|
||||||
let forward: PaginationStatus
|
let forward: PaginationState
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Entities implementing this protocol are responsible for processings diffs coming from the rust timeline
|
/// Entities implementing this protocol are responsible for processings diffs coming from the rust timeline
|
||||||
@@ -34,11 +24,11 @@ struct PaginationState: Equatable {
|
|||||||
@MainActor
|
@MainActor
|
||||||
protocol TimelineItemProviderProtocol {
|
protocol TimelineItemProviderProtocol {
|
||||||
/// A publisher that signals when ``itemProxies`` or ``paginationState`` are changed.
|
/// A publisher that signals when ``itemProxies`` or ``paginationState`` are changed.
|
||||||
var updatePublisher: AnyPublisher<([TimelineItemProxy], PaginationState), Never> { get }
|
var updatePublisher: AnyPublisher<([TimelineItemProxy], TimelinePaginationState), Never> { get }
|
||||||
/// The current set of items in the timeline.
|
/// The current set of items in the timeline.
|
||||||
var itemProxies: [TimelineItemProxy] { get }
|
var itemProxies: [TimelineItemProxy] { get }
|
||||||
/// Whether the timeline is back/forward paginating or not (or has reached the start/end of the room).
|
/// Whether the timeline is back/forward paginating or not (or has reached the start/end of the room).
|
||||||
var paginationState: PaginationState { get }
|
var paginationState: TimelinePaginationState { get }
|
||||||
/// The kind of the timeline
|
/// The kind of the timeline
|
||||||
var kind: TimelineKind { get }
|
var kind: TimelineKind { get }
|
||||||
/// A publisher that signals when changes to the room's membership have occurred through `/sync`.
|
/// A publisher that signals when changes to the room's membership have occurred through `/sync`.
|
||||||
|
|||||||
@@ -13,15 +13,15 @@ import MatrixRustSDK
|
|||||||
final class TimelineProxy: TimelineProxyProtocol {
|
final class TimelineProxy: TimelineProxyProtocol {
|
||||||
private let timeline: Timeline
|
private let timeline: Timeline
|
||||||
|
|
||||||
private var backPaginationStatusObservationToken: TaskHandle?
|
private var backPaginationStateObservationToken: TaskHandle?
|
||||||
|
|
||||||
// The default values shouldn't matter here as they will be updated when calling subscribeToPagination
|
// The default values shouldn't matter here as they will be updated when calling subscribeToPagination
|
||||||
// but empirically we randomly see the timeline start virtual item when we shouldn't.
|
// but empirically we randomly see the timeline start virtual item when we shouldn't.
|
||||||
// We believe there's a race condition between the default values the status publisher
|
// We believe there's a race condition between the default values the status publisher
|
||||||
// so we're going to default the backwards one to .idle. Worst case it's going to do
|
// so we're going to default the backwards one to .idle. Worst case it's going to do
|
||||||
// one extra back pagination.
|
// one extra back pagination.
|
||||||
private let backPaginationStatusSubject = CurrentValueSubject<PaginationStatus, Never>(.idle)
|
private let backPaginationStateSubject = CurrentValueSubject<PaginationState, Never>(.idle)
|
||||||
private let forwardPaginationStatusSubject = CurrentValueSubject<PaginationStatus, Never>(.timelineEndReached)
|
private let forwardPaginationStateSubject = CurrentValueSubject<PaginationState, Never>(.endReached)
|
||||||
|
|
||||||
private let kind: TimelineKind
|
private let kind: TimelineKind
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
backPaginationStatusObservationToken?.cancel()
|
backPaginationStateObservationToken?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
init(timeline: Timeline, kind: TimelineKind) {
|
init(timeline: Timeline, kind: TimelineKind) {
|
||||||
@@ -45,9 +45,9 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let paginationStatePublisher = backPaginationStatusSubject
|
let paginationStatePublisher = backPaginationStateSubject
|
||||||
.combineLatest(forwardPaginationStatusSubject)
|
.combineLatest(forwardPaginationStateSubject)
|
||||||
.map { PaginationState(backward: $0.0, forward: $0.1) }
|
.map { TimelinePaginationState(backward: $0.0, forward: $0.1) }
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
|
||||||
await subscribeToPagination()
|
await subscribeToPagination()
|
||||||
@@ -131,8 +131,8 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
/// Rust subscription isn't allowed on focussed/detached timelines.
|
/// Rust subscription isn't allowed on focussed/detached timelines.
|
||||||
private func focussedPaginate(_ direction: PaginationDirection, requestSize: UInt16) async -> Result<Void, TimelineProxyError> {
|
private func focussedPaginate(_ direction: PaginationDirection, requestSize: UInt16) async -> Result<Void, TimelineProxyError> {
|
||||||
let subject = switch direction {
|
let subject = switch direction {
|
||||||
case .backwards: backPaginationStatusSubject
|
case .backwards: backPaginationStateSubject
|
||||||
case .forwards: forwardPaginationStatusSubject
|
case .forwards: forwardPaginationStateSubject
|
||||||
}
|
}
|
||||||
|
|
||||||
// This extra check is necessary as detached timelines don't support subscribing to pagination status.
|
// This extra check is necessary as detached timelines don't support subscribing to pagination status.
|
||||||
@@ -150,7 +150,7 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
}
|
}
|
||||||
MXLog.info("Finished paginating \(direction.rawValue)")
|
MXLog.info("Finished paginating \(direction.rawValue)")
|
||||||
|
|
||||||
subject.send(timelineEndReached ? .timelineEndReached : .idle)
|
subject.send(timelineEndReached ? .endReached : .idle)
|
||||||
return .success(())
|
return .success(())
|
||||||
} catch {
|
} catch {
|
||||||
MXLog.error("Failed paginating \(direction.rawValue) with error: \(error)")
|
MXLog.error("Failed paginating \(direction.rawValue) with error: \(error)")
|
||||||
@@ -592,28 +592,28 @@ final class TimelineProxy: TimelineProxyProtocol {
|
|||||||
|
|
||||||
switch status {
|
switch status {
|
||||||
case .idle(let hitStartOfTimeline):
|
case .idle(let hitStartOfTimeline):
|
||||||
backPaginationStatusSubject.send(hitStartOfTimeline ? .timelineEndReached : .idle)
|
backPaginationStateSubject.send(hitStartOfTimeline ? .endReached : .idle)
|
||||||
case .paginating:
|
case .paginating:
|
||||||
backPaginationStatusSubject.send(.paginating)
|
backPaginationStateSubject.send(.paginating)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
backPaginationStatusObservationToken = try await timeline.subscribeToBackPaginationStatus(listener: backPaginationListener)
|
backPaginationStateObservationToken = try await timeline.subscribeToBackPaginationStatus(listener: backPaginationListener)
|
||||||
} catch {
|
} catch {
|
||||||
MXLog.error("Failed to subscribe to back pagination status with error: \(error)")
|
MXLog.error("Failed to subscribe to back pagination status with error: \(error)")
|
||||||
}
|
}
|
||||||
forwardPaginationStatusSubject.send(.timelineEndReached)
|
forwardPaginationStateSubject.send(.endReached)
|
||||||
case .detached, .thread:
|
case .detached, .thread:
|
||||||
// Detached timelines don't support observation, set the initial state ourself.
|
// Detached timelines don't support observation, set the initial state ourself.
|
||||||
backPaginationStatusSubject.send(.idle)
|
backPaginationStateSubject.send(.idle)
|
||||||
forwardPaginationStatusSubject.send(.idle)
|
forwardPaginationStateSubject.send(.idle)
|
||||||
case .media(let presentation):
|
case .media(let presentation):
|
||||||
backPaginationStatusSubject.send(presentation == .pinnedEventsScreen ? .timelineEndReached : .idle)
|
backPaginationStateSubject.send(presentation == .pinnedEventsScreen ? .endReached : .idle)
|
||||||
forwardPaginationStatusSubject.send(presentation == .roomScreenDetached ? .idle : .timelineEndReached)
|
forwardPaginationStateSubject.send(presentation == .roomScreenDetached ? .idle : .endReached)
|
||||||
case .pinned:
|
case .pinned:
|
||||||
backPaginationStatusSubject.send(.timelineEndReached)
|
backPaginationStateSubject.send(.endReached)
|
||||||
forwardPaginationStatusSubject.send(.timelineEndReached)
|
forwardPaginationStateSubject.send(.endReached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:65f117e0aa251da2d4bc8d2d1f98d36a653dd3049613d0a8ff69486debaca72f
|
||||||
|
size 131043
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:989cff06585632050964aff0d5cb8a9da15e8be009acbc584f0fde3477c3cee1
|
||||||
|
size 137412
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f1c645564a1248975e005dc8208ee9b8fc5e7d85f8c6c03167fcb0ef5b1681b0
|
||||||
|
size 86553
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9b6753951ad86e77d21fadc03ddd6faedc24dd59256225901d83d5f253e23f65
|
||||||
|
size 96839
|
||||||
@@ -73,7 +73,7 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
// setup the loaded pinned events injection in the timeline
|
// setup the loaded pinned events injection in the timeline
|
||||||
let pinnedTimelineMock = TimelineProxyMock()
|
let pinnedTimelineMock = TimelineProxyMock()
|
||||||
let pinnedTimelineItemProviderMock = TimelineItemProviderMock()
|
let pinnedTimelineItemProviderMock = TimelineItemProviderMock()
|
||||||
let providerUpdateSubject = PassthroughSubject<([TimelineItemProxy], PaginationState), Never>()
|
let providerUpdateSubject = PassthroughSubject<([TimelineItemProxy], TimelinePaginationState), Never>()
|
||||||
pinnedTimelineItemProviderMock.underlyingUpdatePublisher = providerUpdateSubject.eraseToAnyPublisher()
|
pinnedTimelineItemProviderMock.underlyingUpdatePublisher = providerUpdateSubject.eraseToAnyPublisher()
|
||||||
pinnedTimelineMock.timelineItemProvider = pinnedTimelineItemProviderMock
|
pinnedTimelineMock.timelineItemProvider = pinnedTimelineItemProviderMock
|
||||||
pinnedTimelineItemProviderMock.itemProxies = [.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test1")), uniqueID: .init("1"))),
|
pinnedTimelineItemProviderMock.itemProxies = [.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test1")), uniqueID: .init("1"))),
|
||||||
@@ -116,7 +116,7 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
let pinnedTimelineMock = TimelineProxyMock()
|
let pinnedTimelineMock = TimelineProxyMock()
|
||||||
let pinnedTimelineItemProviderMock = TimelineItemProviderMock()
|
let pinnedTimelineItemProviderMock = TimelineItemProviderMock()
|
||||||
pinnedTimelineMock.timelineItemProvider = pinnedTimelineItemProviderMock
|
pinnedTimelineMock.timelineItemProvider = pinnedTimelineItemProviderMock
|
||||||
pinnedTimelineItemProviderMock.underlyingUpdatePublisher = Empty<([TimelineItemProxy], PaginationState), Never>().eraseToAnyPublisher()
|
pinnedTimelineItemProviderMock.underlyingUpdatePublisher = Empty<([TimelineItemProxy], TimelinePaginationState), Never>().eraseToAnyPublisher()
|
||||||
pinnedTimelineItemProviderMock.itemProxies = [.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test1")), uniqueID: .init("1"))),
|
pinnedTimelineItemProviderMock.itemProxies = [.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test1")), uniqueID: .init("1"))),
|
||||||
.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test2")), uniqueID: .init("2"))),
|
.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test2")), uniqueID: .init("2"))),
|
||||||
.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test3")), uniqueID: .init("3")))]
|
.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test3")), uniqueID: .init("3")))]
|
||||||
@@ -175,7 +175,7 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
let pinnedTimelineMock = TimelineProxyMock()
|
let pinnedTimelineMock = TimelineProxyMock()
|
||||||
let pinnedTimelineItemProviderMock = TimelineItemProviderMock()
|
let pinnedTimelineItemProviderMock = TimelineItemProviderMock()
|
||||||
pinnedTimelineMock.timelineItemProvider = pinnedTimelineItemProviderMock
|
pinnedTimelineMock.timelineItemProvider = pinnedTimelineItemProviderMock
|
||||||
pinnedTimelineItemProviderMock.underlyingUpdatePublisher = Empty<([TimelineItemProxy], PaginationState), Never>().eraseToAnyPublisher()
|
pinnedTimelineItemProviderMock.underlyingUpdatePublisher = Empty<([TimelineItemProxy], TimelinePaginationState), Never>().eraseToAnyPublisher()
|
||||||
pinnedTimelineItemProviderMock.itemProxies = [.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test1")), uniqueID: .init("1"))),
|
pinnedTimelineItemProviderMock.itemProxies = [.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test1")), uniqueID: .init("1"))),
|
||||||
.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test2")), uniqueID: .init("2"))),
|
.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test2")), uniqueID: .init("2"))),
|
||||||
.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test3")), uniqueID: .init("3")))]
|
.event(.init(item: EventTimelineItem(configuration: .init(eventID: "test3")), uniqueID: .init("3")))]
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class SpaceScreenViewModelTests: XCTestCase {
|
|||||||
func testInitialState() {
|
func testInitialState() {
|
||||||
setupViewModel()
|
setupViewModel()
|
||||||
|
|
||||||
XCTAssertFalse(context.viewState.isPaginating)
|
XCTAssertEqual(context.viewState.paginationState, .idle)
|
||||||
XCTAssertTrue(context.viewState.rooms.isEmpty)
|
XCTAssertTrue(context.viewState.rooms.isEmpty)
|
||||||
XCTAssertFalse(spaceRoomListProxy.paginateCalled)
|
XCTAssertFalse(spaceRoomListProxy.paginateCalled)
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ class SpaceScreenViewModelTests: XCTestCase {
|
|||||||
let response = mockSpaceRooms.prefix(3)
|
let response = mockSpaceRooms.prefix(3)
|
||||||
setupViewModel(paginationResponses: [Array(response)])
|
setupViewModel(paginationResponses: [Array(response)])
|
||||||
|
|
||||||
XCTAssertFalse(context.viewState.isPaginating)
|
XCTAssertEqual(context.viewState.paginationState, .idle)
|
||||||
XCTAssertTrue(context.viewState.rooms.isEmpty)
|
XCTAssertTrue(context.viewState.rooms.isEmpty)
|
||||||
XCTAssertFalse(spaceRoomListProxy.paginateCalled)
|
XCTAssertFalse(spaceRoomListProxy.paginateCalled)
|
||||||
XCTAssertFalse(response.isEmpty, "There should be some test rooms.")
|
XCTAssertFalse(response.isEmpty, "There should be some test rooms.")
|
||||||
@@ -52,7 +52,7 @@ class SpaceScreenViewModelTests: XCTestCase {
|
|||||||
try await deferred.fulfill()
|
try await deferred.fulfill()
|
||||||
|
|
||||||
// Then the screen should show a paginating indicator.
|
// Then the screen should show a paginating indicator.
|
||||||
XCTAssertTrue(context.viewState.isPaginating)
|
XCTAssertEqual(context.viewState.paginationState, .paginating)
|
||||||
XCTAssertEqual(spaceRoomListProxy.paginateCallsCount, 1)
|
XCTAssertEqual(spaceRoomListProxy.paginateCallsCount, 1)
|
||||||
|
|
||||||
// When waiting for the pagination to finish.
|
// When waiting for the pagination to finish.
|
||||||
@@ -60,7 +60,7 @@ class SpaceScreenViewModelTests: XCTestCase {
|
|||||||
try await deferred.fulfill()
|
try await deferred.fulfill()
|
||||||
|
|
||||||
// Then no more pagination requests should be made the the space rooms should be populated.
|
// Then no more pagination requests should be made the the space rooms should be populated.
|
||||||
XCTAssertFalse(context.viewState.isPaginating)
|
XCTAssertEqual(context.viewState.paginationState, .endReached)
|
||||||
XCTAssertEqual(spaceRoomListProxy.paginateCallsCount, 1)
|
XCTAssertEqual(spaceRoomListProxy.paginateCallsCount, 1)
|
||||||
XCTAssertEqual(context.viewState.rooms.map(\.id), response.map(\.id))
|
XCTAssertEqual(context.viewState.rooms.map(\.id), response.map(\.id))
|
||||||
}
|
}
|
||||||
@@ -71,14 +71,14 @@ class SpaceScreenViewModelTests: XCTestCase {
|
|||||||
let response2 = mockSpaceRooms.suffix(mockSpaceRooms.count - 3)
|
let response2 = mockSpaceRooms.suffix(mockSpaceRooms.count - 3)
|
||||||
setupViewModel(paginationResponses: [Array(response1), Array(response2)])
|
setupViewModel(paginationResponses: [Array(response1), Array(response2)])
|
||||||
|
|
||||||
XCTAssertFalse(context.viewState.isPaginating)
|
XCTAssertEqual(context.viewState.paginationState, .idle)
|
||||||
XCTAssertTrue(context.viewState.rooms.isEmpty)
|
XCTAssertTrue(context.viewState.rooms.isEmpty)
|
||||||
XCTAssertFalse(spaceRoomListProxy.paginateCalled)
|
XCTAssertFalse(spaceRoomListProxy.paginateCalled)
|
||||||
XCTAssertFalse(response1.isEmpty, "There should be some test rooms.")
|
XCTAssertFalse(response1.isEmpty, "There should be some test rooms.")
|
||||||
XCTAssertFalse(response2.isEmpty, "There should be more test rooms.")
|
XCTAssertFalse(response2.isEmpty, "There should be more test rooms.")
|
||||||
|
|
||||||
// When the pagination is triggered.
|
// When the pagination is triggered.
|
||||||
let deferredIsPaginating = deferFulfillment(context.observe(\.viewState.isPaginating), transitionValues: [true, false, true, false])
|
let deferredIsPaginating = deferFulfillment(context.observe(\.viewState.paginationState), transitionValues: [.paginating, .idle, .paginating, .endReached])
|
||||||
let deferredState = deferFulfillment(spaceRoomListProxy.paginationStatePublisher, keyPath: \.self, transitionValues: [.loading,
|
let deferredState = deferFulfillment(spaceRoomListProxy.paginationStatePublisher, keyPath: \.self, transitionValues: [.loading,
|
||||||
.idle(endReached: false),
|
.idle(endReached: false),
|
||||||
.loading,
|
.loading,
|
||||||
@@ -89,7 +89,7 @@ class SpaceScreenViewModelTests: XCTestCase {
|
|||||||
try await deferredIsPaginating.fulfill()
|
try await deferredIsPaginating.fulfill()
|
||||||
try await deferredState.fulfill()
|
try await deferredState.fulfill()
|
||||||
|
|
||||||
XCTAssertFalse(context.viewState.isPaginating)
|
XCTAssertEqual(context.viewState.paginationState, .endReached)
|
||||||
XCTAssertEqual(spaceRoomListProxy.paginateCallsCount, 2)
|
XCTAssertEqual(spaceRoomListProxy.paginateCallsCount, 2)
|
||||||
XCTAssertEqual(context.viewState.rooms.map(\.id), mockSpaceRooms.map(\.id))
|
XCTAssertEqual(context.viewState.rooms.map(\.id), mockSpaceRooms.map(\.id))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class TimelineMediaPreviewViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(timelineController.paginateBackwardsCallCount, 0)
|
XCTAssertEqual(timelineController.paginateBackwardsCallCount, 0)
|
||||||
|
|
||||||
// When swiping to a "loading more" item and there are more media items to load.
|
// When swiping to a "loading more" item and there are more media items to load.
|
||||||
timelineController.paginationState = .init(backward: .idle, forward: .timelineEndReached)
|
timelineController.paginationState = .init(backward: .idle, forward: .endReached)
|
||||||
timelineController.backPaginationResponses.append(RoomTimelineItemFixtures.mediaChunk)
|
timelineController.backPaginationResponses.append(RoomTimelineItemFixtures.mediaChunk)
|
||||||
let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded }
|
let failure = deferFailure(viewModel.state.previewControllerDriver, timeout: 1) { $0.isItemLoaded }
|
||||||
context.send(viewAction: .updateCurrentItem(.loading(.paginatingBackwards)))
|
context.send(viewAction: .updateCurrentItem(.loading(.paginatingBackwards)))
|
||||||
|
|||||||
Reference in New Issue
Block a user