Permalink polish (#2771)

* Fix unbalanced padding.

* Animate the scroll to an event when it is already loaded.

* Perform the timeline clearance immediately before replacing the items.
Hides the empty timeline that was briefly shown.

* Make switching timeline more resilient.
This commit is contained in:
Doug
2024-05-02 17:51:38 +01:00
committed by GitHub
parent ec19b5a0c4
commit d067e844da
23 changed files with 152 additions and 98 deletions
@@ -115,6 +115,8 @@ enum RoomScreenViewAction {
case focusLive case focusLive
/// The timeline scrolled to reveal the focussed item. /// The timeline scrolled to reveal the focussed item.
case scrolledToFocussedItem case scrolledToFocussedItem
/// The table view has loaded the first items for a new timeline.
case hasSwitchedTimeline
} }
enum RoomScreenComposerAction { enum RoomScreenComposerAction {
@@ -227,10 +229,27 @@ struct TimelineViewState {
var isLive = true var isLive = true
var paginationState = PaginationState.default var paginationState = PaginationState.default
/// The ID of the focussed event navigated to via a permalink. /// The room is in the process of loading items from a new timeline (switching to/from a detached timeline).
var focussedEventID: String? { didSet { focussedEventNeedsDisplay = focussedEventID != nil } } var isSwitchingTimelines = false
/// Whether the timeline should scroll to `focussedEventID` once its item has been built and added to the timeline.
var focussedEventNeedsDisplay: Bool struct FocussedEvent: Equatable {
enum Appearance {
/// The event should be shown using an animated scroll.
case animated
/// The event should be shown immediately, without any animation.
case immediate
/// The event has already been shown.
case hasAppeared
}
/// The ID of the event.
let eventID: String
/// How the event should be shown, or whether it has already appeared.
var appearance: Appearance
}
/// A focussed event that was navigated to via a permalink.
var focussedEvent: FocussedEvent?
// These can be removed when we have full swiftUI and moved as @State values in the view // These can be removed when we have full swiftUI and moved as @State values in the view
var scrollToBottomPublisher = PassthroughSubject<Void, Never>() var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
@@ -88,8 +88,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
roomAvatarURL: roomProxy.avatarURL, roomAvatarURL: roomProxy.avatarURL,
timelineStyle: appSettings.timelineStyle, timelineStyle: appSettings.timelineStyle,
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom, isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
timelineViewState: TimelineViewState(focussedEventID: focussedEventID, timelineViewState: TimelineViewState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
focussedEventNeedsDisplay: focussedEventID != nil),
ownUserID: roomProxy.ownUserID, ownUserID: roomProxy.ownUserID,
hasOngoingCall: roomProxy.hasOngoingCall, hasOngoingCall: roomProxy.hasOngoingCall,
bindings: .init(reactionsCollapsed: [:])), bindings: .init(reactionsCollapsed: [:])),
@@ -119,7 +118,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
return self.roomScreenInteractionHandler.audioPlayerState(for: itemID) return self.roomScreenInteractionHandler.audioPlayerState(for: itemID)
} }
buildTimelineViews() buildTimelineViews(timelineItems: timelineController.timelineItems)
updateMembers(roomProxy.membersPublisher.value) updateMembers(roomProxy.membersPublisher.value)
@@ -192,9 +191,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
focusLive() focusLive()
case .scrolledToFocussedItem: case .scrolledToFocussedItem:
Task { // Use a Task to mutate view state after the current view update. Task { // Use a Task to mutate view state after the current view update.
state.timelineViewState.focussedEventNeedsDisplay = false state.timelineViewState.focussedEvent?.appearance = .hasAppeared
hideFocusLoadingIndicator() hideFocusLoadingIndicator()
} }
case .hasSwitchedTimeline:
Task { state.timelineViewState.isSwitchingTimelines = false }
} }
} }
@@ -230,7 +231,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
func focusOnEvent(eventID: String) async { func focusOnEvent(eventID: String) async {
if state.timelineViewState.hasLoadedItem(with: eventID) { if state.timelineViewState.hasLoadedItem(with: eventID) {
state.timelineViewState.focussedEventID = eventID state.timelineViewState.focussedEvent = .init(eventID: eventID, appearance: .animated)
return return
} }
@@ -239,7 +240,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
switch await timelineController.focusOnEvent(eventID, timelineSize: Constants.detachedTimelineSize) { switch await timelineController.focusOnEvent(eventID, timelineSize: Constants.detachedTimelineSize) {
case .success: case .success:
state.timelineViewState.focussedEventID = eventID state.timelineViewState.focussedEvent = .init(eventID: eventID, appearance: .immediate)
case .failure(let error): case .failure(let error):
MXLog.error("Failed to focus on event \(eventID)") MXLog.error("Failed to focus on event \(eventID)")
@@ -255,7 +256,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private func focusLive() { private func focusLive() {
timelineController.focusLive() timelineController.focusLive()
state.timelineViewState.focussedEventID = nil
} }
private func attach(_ attachment: ComposerAttachmentType) { private func attach(_ attachment: ComposerAttachmentType) {
@@ -336,8 +336,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
guard let self else { return } guard let self else { return }
switch callback { switch callback {
case .updatedTimelineItems: case .updatedTimelineItems(let updatedItems, let isSwitchingTimelines):
buildTimelineViews() buildTimelineViews(timelineItems: updatedItems, isSwitchingTimelines: isSwitchingTimelines)
case .paginationState(let paginationState): case .paginationState(let paginationState):
if state.timelineViewState.paginationState != paginationState { if state.timelineViewState.paginationState != paginationState {
state.timelineViewState.paginationState = paginationState state.timelineViewState.paginationState = paginationState
@@ -347,8 +347,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.timelineViewState.isLive = isLive state.timelineViewState.isLive = isLive
// Remove the event highlight *only* when transitioning from non-live to live. // Remove the event highlight *only* when transitioning from non-live to live.
if isLive, state.timelineViewState.focussedEventID != nil { if isLive, state.timelineViewState.focussedEvent != nil {
state.timelineViewState.focussedEventID = nil state.timelineViewState.focussedEvent = nil
} }
} }
} }
@@ -568,10 +568,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
// MARK: - Timeline Item Building // MARK: - Timeline Item Building
private func buildTimelineViews() { private func buildTimelineViews(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool = false) {
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>() var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
timelineController.timelineItems.filter { $0 is RedactedRoomTimelineItem }.forEach { timelineItem in timelineItems.filter { $0 is RedactedRoomTimelineItem }.forEach { timelineItem in
// Stops the audio player when a voice message is redacted. // Stops the audio player when a voice message is redacted.
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(timelineItem.id)) else { guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(timelineItem.id)) else {
return return
@@ -583,7 +583,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
} }
} }
let itemsGroupedByTimelineDisplayStyle = timelineController.timelineItems.chunked { current, next in let itemsGroupedByTimelineDisplayStyle = timelineItems.chunked { current, next in
canGroupItem(timelineItem: current, with: next) canGroupItem(timelineItem: current, with: next)
} }
@@ -614,6 +614,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
} }
} }
if isSwitchingTimelines {
state.timelineViewState.isSwitchingTimelines = true
}
state.timelineViewState.itemsDictionary = timelineItemsDictionary state.timelineViewState.itemsDictionary = timelineItemsDictionary
} }
@@ -96,7 +96,7 @@ struct RoomScreen: View {
.id(context.viewState.roomID) .id(context.viewState.roomID)
.environmentObject(context) .environmentObject(context)
.environment(\.timelineStyle, context.viewState.timelineStyle) .environment(\.timelineStyle, context.viewState.timelineStyle)
.environment(\.focussedEventID, context.viewState.timelineViewState.focussedEventID) .environment(\.focussedEventID, context.viewState.timelineViewState.focussedEvent?.eventID)
.overlay(alignment: .bottomTrailing) { .overlay(alignment: .bottomTrailing) {
scrollToBottomButton scrollToBottomButton
} }
@@ -28,6 +28,7 @@ private struct HighlightedTimelineItemModifier: ViewModifier {
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.padding(.top, isHighlighted ? 1 : 0)
.background { .background {
if isHighlighted { if isHighlighted {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -47,7 +48,9 @@ private struct HighlightedTimelineItemModifier: ViewModifier {
} }
} }
// swiftlint:disable line_length // MARK: - Previews
// swiftlint:disable line_length blanket_disable_command
struct HighlightedTimelineItemModifier_Previews: PreviewProvider, TestablePreview { struct HighlightedTimelineItemModifier_Previews: PreviewProvider, TestablePreview {
static var previews: some View { static var previews: some View {
ScrollView { ScrollView {
@@ -67,6 +70,7 @@ struct HighlightedTimelineItemModifier_Previews: PreviewProvider, TestablePrevie
.highlightedTimelineItem(true) .highlightedTimelineItem(true)
} }
} }
.previewDisplayName("Layout")
} }
struct Bubble: View { struct Bubble: View {
@@ -85,4 +89,25 @@ struct HighlightedTimelineItemModifier_Previews: PreviewProvider, TestablePrevie
} }
} }
// swiftlint:enable line_length /// A preview that allows quick testing of the highlight appearance across various timeline scenarios.
struct HighlightedTimelineItemTimeline_Previews: PreviewProvider {
static let focussedEventID = "RoomTimelineItemFixtures.default.5"
static let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(with: .init(name: "Preview room")),
focussedEventID: focussedEventID,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
notificationCenter: NotificationCenterMock())
static var previews: some View {
NavigationStack {
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
}
.previewDisplayName("Timeline")
}
}
@@ -104,15 +104,14 @@ class TimelineTableViewController: UIViewController {
} }
} }
/// The ID of the focussed event if navigating to an event permalink within the room. /// Whether the table view is about to load items from a new timeline or not.
var focussedEventID: String? var isSwitchingTimelines = false
/// Whether the timeline should scroll to `focussedEventID` when that item is added to the data source. /// The focussed event if navigating to an event permalink within the room.
/// This is necessary as the focussed event can be set before the timeline builder has built its item. var focussedEvent: TimelineViewState.FocussedEvent? {
var focussedEventNeedsDisplay = false {
didSet { didSet {
guard focussedEventNeedsDisplay, let focussedEventID else { return } guard let focussedEvent, focussedEvent.appearance != .hasAppeared else { return }
scrollToItem(eventID: focussedEventID, animated: false) scrollToItem(eventID: focussedEvent.eventID, animated: focussedEvent.appearance == .animated)
} }
} }
@@ -300,7 +299,7 @@ class TimelineTableViewController: UIViewController {
let newestItemIdentifier = snapshot.mainItemIdentifiers.first let newestItemIdentifier = snapshot.mainItemIdentifiers.first
let currentNewestItemIdentifier = currentSnapshot.mainItemIdentifiers.first let currentNewestItemIdentifier = currentSnapshot.mainItemIdentifiers.first
let newestItemIDChanged = snapshot.numberOfMainItems > 0 && currentSnapshot.numberOfMainItems > 0 && newestItemIdentifier != currentNewestItemIdentifier let newestItemIDChanged = snapshot.numberOfMainItems > 0 && currentSnapshot.numberOfMainItems > 0 && newestItemIdentifier != currentNewestItemIdentifier
let animated = isLive && newestItemIDChanged let animated = isLive && !isSwitchingTimelines && newestItemIDChanged
let layout: Layout? = if !isLive, newestItemIDChanged { let layout: Layout? = if !isLive, newestItemIDChanged {
snapshotLayout() snapshotLayout()
@@ -310,10 +309,16 @@ class TimelineTableViewController: UIViewController {
dataSource.apply(snapshot, animatingDifferences: animated) dataSource.apply(snapshot, animatingDifferences: animated)
if let focussedEventID, focussedEventNeedsDisplay { if let focussedEvent, focussedEvent.appearance != .hasAppeared {
scrollToItem(eventID: focussedEventID, animated: false) scrollToItem(eventID: focussedEvent.eventID, animated: focussedEvent.appearance == .animated)
} else if let layout { } else if let layout {
restoreLayout(layout) restoreLayout(layout)
} else if isSwitchingTimelines {
scrollToNewestItem(animated: false)
}
if isSwitchingTimelines {
coordinator.send(viewAction: .hasSwitchedTimeline)
} }
} }
@@ -335,7 +340,7 @@ class TimelineTableViewController: UIViewController {
/// Scrolls to the item with the corresponding event ID if loaded in the timeline. /// Scrolls to the item with the corresponding event ID if loaded in the timeline.
private func scrollToItem(eventID: String, animated: Bool) { private func scrollToItem(eventID: String, animated: Bool) {
if let kvPair = timelineItemsDictionary.first(where: { $0.value.identifier.eventID == focussedEventID }), if let kvPair = timelineItemsDictionary.first(where: { $0.value.identifier.eventID == eventID }),
let indexPath = dataSource?.indexPath(for: kvPair.key) { let indexPath = dataSource?.indexPath(for: kvPair.key) {
tableView.scrollToRow(at: indexPath, at: .middle, animated: animated) tableView.scrollToRow(at: indexPath, at: .middle, animated: animated)
coordinator.send(viewAction: .scrolledToFocussedItem) coordinator.send(viewAction: .scrolledToFocussedItem)
@@ -50,6 +50,10 @@ struct TimelineView: UIViewControllerRepresentable {
/// Updates the specified table view's properties from the current view state. /// Updates the specified table view's properties from the current view state.
func update(tableViewController: TimelineTableViewController, timelineStyle: TimelineStyle) { func update(tableViewController: TimelineTableViewController, timelineStyle: TimelineStyle) {
if tableViewController.isSwitchingTimelines != context.viewState.timelineViewState.isSwitchingTimelines {
// Must come before timelineItemsDictionary in order to disable animations.
tableViewController.isSwitchingTimelines = context.viewState.timelineViewState.isSwitchingTimelines
}
if tableViewController.timelineStyle != timelineStyle { if tableViewController.timelineStyle != timelineStyle {
tableViewController.timelineStyle = timelineStyle tableViewController.timelineStyle = timelineStyle
} }
@@ -62,11 +66,8 @@ struct TimelineView: UIViewControllerRepresentable {
if tableViewController.isLive != context.viewState.timelineViewState.isLive { if tableViewController.isLive != context.viewState.timelineViewState.isLive {
tableViewController.isLive = context.viewState.timelineViewState.isLive tableViewController.isLive = context.viewState.timelineViewState.isLive
} }
if tableViewController.focussedEventID != context.viewState.timelineViewState.focussedEventID { if tableViewController.focussedEvent != context.viewState.timelineViewState.focussedEvent {
tableViewController.focussedEventID = context.viewState.timelineViewState.focussedEventID tableViewController.focussedEvent = context.viewState.timelineViewState.focussedEvent
}
if tableViewController.focussedEventNeedsDisplay != context.viewState.timelineViewState.focussedEventNeedsDisplay {
tableViewController.focussedEventNeedsDisplay = context.viewState.timelineViewState.focussedEventNeedsDisplay
} }
if tableViewController.typingMembers.members != context.viewState.typingMembers { if tableViewController.typingMembers.members != context.viewState.typingMembers {
@@ -20,7 +20,8 @@ enum RoomTimelineItemFixtures {
/// The default timeline items used in Xcode previews etc. /// The default timeline items used in Xcode previews etc.
static var `default`: [RoomTimelineItemProtocol] = [ static var `default`: [RoomTimelineItemProtocol] = [
SeparatorRoomTimelineItem(id: .init(timelineID: "Yesterday"), text: "Yesterday"), SeparatorRoomTimelineItem(id: .init(timelineID: "Yesterday"), text: "Yesterday"),
TextRoomTimelineItem(id: .random, TextRoomTimelineItem(id: .init(timelineID: ".RoomTimelineItemFixtures.default.0",
eventID: "RoomTimelineItemFixtures.default.0"),
timestamp: "10:10 AM", timestamp: "10:10 AM",
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
@@ -29,7 +30,8 @@ enum RoomTimelineItemFixtures {
sender: .init(id: "", displayName: "Jacob"), sender: .init(id: "", displayName: "Jacob"),
content: .init(body: "That looks so good!"), content: .init(body: "That looks so good!"),
properties: RoomTimelineItemProperties(isEdited: true)), properties: RoomTimelineItemProperties(isEdited: true)),
TextRoomTimelineItem(id: .random, TextRoomTimelineItem(id: .init(timelineID: "RoomTimelineItemFixtures.default.1",
eventID: "RoomTimelineItemFixtures.default.1"),
timestamp: "10:11 AM", timestamp: "10:11 AM",
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
@@ -40,7 +42,8 @@ enum RoomTimelineItemFixtures {
properties: RoomTimelineItemProperties(reactions: [ properties: RoomTimelineItemProperties(reactions: [
AggregatedReaction(accountOwnerID: "me", key: "🙌", senders: [ReactionSender(id: "me", timestamp: Date())]) AggregatedReaction(accountOwnerID: "me", key: "🙌", senders: [ReactionSender(id: "me", timestamp: Date())])
])), ])),
TextRoomTimelineItem(id: .random, TextRoomTimelineItem(id: .init(timelineID: "RoomTimelineItemFixtures.default.2",
eventID: "RoomTimelineItemFixtures.default.2"),
timestamp: "10:11 AM", timestamp: "10:11 AM",
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
@@ -59,7 +62,8 @@ enum RoomTimelineItemFixtures {
]) ])
])), ])),
SeparatorRoomTimelineItem(id: .init(timelineID: "Today"), text: "Today"), SeparatorRoomTimelineItem(id: .init(timelineID: "Today"), text: "Today"),
TextRoomTimelineItem(id: .random, TextRoomTimelineItem(id: .init(timelineID: "RoomTimelineItemFixtures.default.3",
eventID: "RoomTimelineItemFixtures.default.3"),
timestamp: "5 PM", timestamp: "5 PM",
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
@@ -68,7 +72,8 @@ enum RoomTimelineItemFixtures {
sender: .init(id: "", displayName: "Helena"), sender: .init(id: "", displayName: "Helena"),
content: .init(body: "Wow, cool. Ok, lets go the usual place tomorrow?! Is that too soon? Here’s the menu, let me know what you want it’s on me!"), content: .init(body: "Wow, cool. Ok, lets go the usual place tomorrow?! Is that too soon? Here’s the menu, let me know what you want it’s on me!"),
properties: RoomTimelineItemProperties(orderedReadReceipts: [ReadReceipt(userID: "alice", formattedTimestamp: nil)])), properties: RoomTimelineItemProperties(orderedReadReceipts: [ReadReceipt(userID: "alice", formattedTimestamp: nil)])),
TextRoomTimelineItem(id: .random, TextRoomTimelineItem(id: .init(timelineID: "RoomTimelineItemFixtures.default.4",
eventID: "RoomTimelineItemFixtures.default.4"),
timestamp: "5 PM", timestamp: "5 PM",
isOutgoing: true, isOutgoing: true,
isEditable: true, isEditable: true,
@@ -76,7 +81,8 @@ enum RoomTimelineItemFixtures {
isThreaded: false, isThreaded: false,
sender: .init(id: "", displayName: "Bob"), sender: .init(id: "", displayName: "Bob"),
content: .init(body: "And John's speech was amazing!")), content: .init(body: "And John's speech was amazing!")),
TextRoomTimelineItem(id: .random, TextRoomTimelineItem(id: .init(timelineID: "RoomTimelineItemFixtures.default.5",
eventID: "RoomTimelineItemFixtures.default.5"),
timestamp: "5 PM", timestamp: "5 PM",
isOutgoing: true, isOutgoing: true,
isEditable: true, isEditable: true,
@@ -89,7 +95,8 @@ enum RoomTimelineItemFixtures {
ReadReceipt(userID: "bob", formattedTimestamp: nil), ReadReceipt(userID: "bob", formattedTimestamp: nil),
ReadReceipt(userID: "charlie", formattedTimestamp: nil), ReadReceipt(userID: "charlie", formattedTimestamp: nil),
ReadReceipt(userID: "dan", formattedTimestamp: nil)])), ReadReceipt(userID: "dan", formattedTimestamp: nil)])),
TextRoomTimelineItem(id: .random, TextRoomTimelineItem(id: .init(timelineID: "RoomTimelineItemFixtures.default.6",
eventID: "RoomTimelineItemFixtures.default.6"),
timestamp: "5 PM", timestamp: "5 PM",
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
@@ -165,7 +165,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
let incomingItem = incomingItems.removeFirst() let incomingItem = incomingItems.removeFirst()
timelineItems.append(incomingItem) timelineItems.append(incomingItem)
callbacks.send(.updatedTimelineItems) callbacks.send(.updatedTimelineItems(timelineItems: timelineItems, isSwitchingTimelines: false))
try client?.send(.success) try client?.send(.success)
} }
@@ -181,7 +181,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
let newItems = backPaginationResponses.removeFirst() let newItems = backPaginationResponses.removeFirst()
timelineItems.insert(contentsOf: newItems, at: 0) timelineItems.insert(contentsOf: newItems, at: 0)
callbacks.send(.updatedTimelineItems) callbacks.send(.updatedTimelineItems(timelineItems: timelineItems, isSwitchingTimelines: false))
try client?.send(.success) try client?.send(.success)
} }
@@ -33,7 +33,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
private var activeTimeline: TimelineProxyProtocol private var activeTimeline: TimelineProxyProtocol
private var activeTimelineProvider: RoomTimelineProviderProtocol { private var activeTimelineProvider: RoomTimelineProviderProtocol {
didSet { didSet {
configureActiveTimelineProvider(clearExistingItems: true) configureActiveTimelineProvider()
} }
} }
@@ -272,30 +272,25 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
/// The cancellable used to update the timeline items. /// The cancellable used to update the timeline items.
private var updateTimelineItemsCancellable: AnyCancellable? private var updateTimelineItemsCancellable: AnyCancellable?
/// The controller is switching the `activeTimelineProvider`.
private var isSwitchingTimelines = false
/// Configures the controller to listen to `activeTimeline` for events. /// Configures the controller to listen to `activeTimeline` for events.
/// - Parameter clearExistingItems: Whether or not to clear any existing items before loading the timeline's contents. /// - Parameter clearExistingItems: Whether or not to clear any existing items before loading the timeline's contents.
private func configureActiveTimelineProvider(clearExistingItems: Bool = false) { private func configureActiveTimelineProvider() {
updateTimelineItemsCancellable = activeTimelineProvider updateTimelineItemsCancellable = nil
.updatePublisher
.receive(on: serialDispatchQueue) isSwitchingTimelines = true
.sink { [weak self] items, paginationState in
self?.updateTimelineItems(itemProxies: items, paginationState: paginationState)
}
// Inform the world that the initial items are loading from the store // Inform the world that the initial items are loading from the store
callbacks.send(.paginationState(.init(backward: .paginating, forward: .paginating))) callbacks.send(.paginationState(.init(backward: .paginating, forward: .paginating)))
callbacks.send(.isLive(activeTimelineProvider.isLive)) callbacks.send(.isLive(activeTimelineProvider.isLive))
if clearExistingItems { updateTimelineItemsCancellable = activeTimelineProvider
// Transition through empty to prevent animations. .updatePublisher
timelineItems.removeAll() .receive(on: serialDispatchQueue)
callbacks.send(.updatedTimelineItems) .sink { [weak self] items, paginationState in
} self?.updateTimelineItems(itemProxies: items, paginationState: paginationState)
serialDispatchQueue.async { [activeTimelineProvider] in
self.updateTimelineItems(itemProxies: activeTimelineProvider.itemProxies, paginationState: activeTimelineProvider.paginationState)
self.callbacks.send(.paginationState(.init(backward: .idle, forward: .idle)))
} }
} }
@@ -309,6 +304,9 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
private func updateTimelineItems(itemProxies: [TimelineItemProxy], paginationState: PaginationState) { private func updateTimelineItems(itemProxies: [TimelineItemProxy], paginationState: PaginationState) {
var newTimelineItems = [RoomTimelineItemProtocol]() var newTimelineItems = [RoomTimelineItemProtocol]()
let isNewTimeline = isSwitchingTimelines
isSwitchingTimelines = false
let collapsibleChunks = itemProxies.groupBy { isItemCollapsible($0) } let collapsibleChunks = itemProxies.groupBy { isItemCollapsible($0) }
for (index, collapsibleChunk) in collapsibleChunks.enumerated() { for (index, collapsibleChunk) in collapsibleChunks.enumerated() {
@@ -362,7 +360,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
timelineItems = newTimelineItems timelineItems = newTimelineItems
} }
callbacks.send(.updatedTimelineItems) callbacks.send(.updatedTimelineItems(timelineItems: newTimelineItems, isSwitchingTimelines: isNewTimeline))
callbacks.send(.paginationState(paginationState)) callbacks.send(.paginationState(paginationState))
} }
@@ -19,7 +19,7 @@ import Foundation
import UIKit import UIKit
enum RoomTimelineControllerCallback { enum RoomTimelineControllerCallback {
case updatedTimelineItems case updatedTimelineItems(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool)
case paginationState(PaginationState) case paginationState(PaginationState)
case isLive(Bool) case isLive(Bool)
} }
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:94a132c8800c74bc3f9e9299323c06166a4a351fa9f54ba1a3fdc680086a277d
size 417744
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d79d725cd4976368ba4e3e0e374d4f48e2b2e7c60e28c90e8e1ca92fc4038a80
size 426581
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:94a132c8800c74bc3f9e9299323c06166a4a351fa9f54ba1a3fdc680086a277d
size 417744
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d79d725cd4976368ba4e3e0e374d4f48e2b2e7c60e28c90e8e1ca92fc4038a80
size 426581
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8a3a0c17c3a5069ac1e80799e4830bc7f8ae593e0d36b83b2b094fc9f90abfa0
size 342251
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1d8789df893dd7c2390ff8ecf6446b066cad2801dff8faac5b70bea6afffb040
size 343065
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8a3a0c17c3a5069ac1e80799e4830bc7f8ae593e0d36b83b2b094fc9f90abfa0
size 342251
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1d8789df893dd7c2390ff8ecf6446b066cad2801dff8faac5b70bea6afffb040
size 343065
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:85ccfdd71e9fa2e59b65b9731c8e0135e7e44349d7f3debdb5afcff0262576d0 oid sha256:3f8f4642b8e6371079ca3e81ea55820264951dd7cbab8997eeb0b9087113360b
size 233181 size 233502
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:79e3b444b2682f3697c1f27c898be7a3ed50d230c60d5c0fd33bfe9125056802 oid sha256:1143957bde8bb8ff04948e2c3bbb3be3d22b8e65bfc6d9eeb550a498537b509a
size 331437 size 331822
@@ -171,8 +171,7 @@ class RoomScreenViewModelTests: XCTestCase {
let viewModel = makeViewModel(timelineController: timelineController) let viewModel = makeViewModel(timelineController: timelineController)
XCTAssertEqual(timelineController.focusOnEventCallCount, 0) XCTAssertEqual(timelineController.focusOnEventCallCount, 0)
XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive) XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive)
XCTAssertNil(viewModel.context.viewState.timelineViewState.focussedEventID) XCTAssertNil(viewModel.context.viewState.timelineViewState.focussedEvent)
XCTAssertFalse(viewModel.context.viewState.timelineViewState.focussedEventNeedsDisplay)
// When focussing on an item that isn't loaded. // When focussing on an item that isn't loaded.
let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.timelineViewState.isLive } let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.timelineViewState.isLive }
@@ -182,8 +181,7 @@ class RoomScreenViewModelTests: XCTestCase {
// Then a new timeline should be loaded and the room focussed on that event. // Then a new timeline should be loaded and the room focussed on that event.
XCTAssertEqual(timelineController.focusOnEventCallCount, 1) XCTAssertEqual(timelineController.focusOnEventCallCount, 1)
XCTAssertFalse(viewModel.context.viewState.timelineViewState.isLive) XCTAssertFalse(viewModel.context.viewState.timelineViewState.isLive)
XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEventID, "t4") XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEvent, .init(eventID: "t4", appearance: .immediate))
XCTAssertTrue(viewModel.context.viewState.timelineViewState.focussedEventNeedsDisplay)
} }
func testFocusLoadedItem() async throws { func testFocusLoadedItem() async throws {
@@ -197,8 +195,7 @@ class RoomScreenViewModelTests: XCTestCase {
let viewModel = makeViewModel(timelineController: timelineController) let viewModel = makeViewModel(timelineController: timelineController)
XCTAssertEqual(timelineController.focusOnEventCallCount, 0) XCTAssertEqual(timelineController.focusOnEventCallCount, 0)
XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive) XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive)
XCTAssertNil(viewModel.context.viewState.timelineViewState.focussedEventID) XCTAssertNil(viewModel.context.viewState.timelineViewState.focussedEvent)
XCTAssertFalse(viewModel.context.viewState.timelineViewState.focussedEventNeedsDisplay)
// When focussing on a loaded item. // When focussing on a loaded item.
let deferred = deferFailure(viewModel.context.$viewState, timeout: 1) { !$0.timelineViewState.isLive } let deferred = deferFailure(viewModel.context.$viewState, timeout: 1) { !$0.timelineViewState.isLive }
@@ -208,8 +205,7 @@ class RoomScreenViewModelTests: XCTestCase {
// Then the timeline should remain live and the item should be focussed. // Then the timeline should remain live and the item should be focussed.
XCTAssertEqual(timelineController.focusOnEventCallCount, 0) XCTAssertEqual(timelineController.focusOnEventCallCount, 0)
XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive) XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive)
XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEventID, "t1") XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEvent, .init(eventID: "t1", appearance: .animated))
XCTAssertTrue(viewModel.context.viewState.timelineViewState.focussedEventNeedsDisplay)
} }
func testFocusLive() async throws { func testFocusLive() async throws {
@@ -228,8 +224,7 @@ class RoomScreenViewModelTests: XCTestCase {
XCTAssertEqual(timelineController.focusLiveCallCount, 0) XCTAssertEqual(timelineController.focusLiveCallCount, 0)
XCTAssertFalse(viewModel.context.viewState.timelineViewState.isLive) XCTAssertFalse(viewModel.context.viewState.timelineViewState.isLive)
XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEventID, "t4") XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEvent, .init(eventID: "t4", appearance: .immediate))
XCTAssertTrue(viewModel.context.viewState.timelineViewState.focussedEventNeedsDisplay)
// When switching back to a live timeline. // When switching back to a live timeline.
deferred = deferFulfillment(viewModel.context.$viewState) { $0.timelineViewState.isLive } deferred = deferFulfillment(viewModel.context.$viewState) { $0.timelineViewState.isLive }
@@ -239,16 +234,14 @@ class RoomScreenViewModelTests: XCTestCase {
// Then the timeline should switch back to being live and the event focus should be removed. // Then the timeline should switch back to being live and the event focus should be removed.
XCTAssertEqual(timelineController.focusLiveCallCount, 1) XCTAssertEqual(timelineController.focusLiveCallCount, 1)
XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive) XCTAssertTrue(viewModel.context.viewState.timelineViewState.isLive)
XCTAssertNil(viewModel.context.viewState.timelineViewState.focussedEventID) XCTAssertNil(viewModel.context.viewState.timelineViewState.focussedEvent)
XCTAssertFalse(viewModel.context.viewState.timelineViewState.focussedEventNeedsDisplay)
} }
func testInitialFocusViewState() async throws { func testInitialFocusViewState() async throws {
let timelineController = MockRoomTimelineController() let timelineController = MockRoomTimelineController()
let viewModel = makeViewModel(focussedEventID: "t10", timelineController: timelineController) let viewModel = makeViewModel(focussedEventID: "t10", timelineController: timelineController)
XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEventID, "t10") XCTAssertEqual(viewModel.context.viewState.timelineViewState.focussedEvent, .init(eventID: "t10", appearance: .immediate))
XCTAssertTrue(viewModel.context.viewState.timelineViewState.focussedEventNeedsDisplay)
} }
// MARK: - Sending // MARK: - Sending
@@ -370,7 +363,7 @@ class RoomScreenViewModelTests: XCTestCase {
// When a new message is received and marked as read. // When a new message is received and marked as read.
let newMessage = TextRoomTimelineItem(eventID: "t4") let newMessage = TextRoomTimelineItem(eventID: "t4")
timelineController.timelineItems.append(newMessage) timelineController.timelineItems.append(newMessage)
timelineController.callbacks.send(.updatedTimelineItems) timelineController.callbacks.send(.updatedTimelineItems(timelineItems: timelineController.timelineItems, isSwitchingTimelines: false))
try await Task.sleep(for: .milliseconds(100)) try await Task.sleep(for: .milliseconds(100))
viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(newMessage.id)) viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(newMessage.id))
+1
View File
@@ -0,0 +1 @@
Use an animation when scrolling to a nearby timeline item.
+1
View File
@@ -0,0 +1 @@
Add missing padding on the permalink highlight.