mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-2
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+18
-13
@@ -66,7 +66,7 @@ class TimelineTableViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applySnapshot()
|
applySnapshot()
|
||||||
|
|
||||||
if timelineItemsDictionary.isEmpty {
|
if timelineItemsDictionary.isEmpty {
|
||||||
paginatePublisher.send()
|
paginatePublisher.send()
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
+2
-2
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-18
@@ -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,31 +272,26 @@ 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 = nil
|
||||||
|
|
||||||
|
isSwitchingTimelines = true
|
||||||
|
|
||||||
|
// Inform the world that the initial items are loading from the store
|
||||||
|
callbacks.send(.paginationState(.init(backward: .paginating, forward: .paginating)))
|
||||||
|
callbacks.send(.isLive(activeTimelineProvider.isLive))
|
||||||
|
|
||||||
updateTimelineItemsCancellable = activeTimelineProvider
|
updateTimelineItemsCancellable = activeTimelineProvider
|
||||||
.updatePublisher
|
.updatePublisher
|
||||||
.receive(on: serialDispatchQueue)
|
.receive(on: serialDispatchQueue)
|
||||||
.sink { [weak self] items, paginationState in
|
.sink { [weak self] items, paginationState in
|
||||||
self?.updateTimelineItems(itemProxies: items, paginationState: paginationState)
|
self?.updateTimelineItems(itemProxies: items, paginationState: paginationState)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inform the world that the initial items are loading from the store
|
|
||||||
callbacks.send(.paginationState(.init(backward: .paginating, forward: .paginating)))
|
|
||||||
callbacks.send(.isLive(activeTimelineProvider.isLive))
|
|
||||||
|
|
||||||
if clearExistingItems {
|
|
||||||
// Transition through empty to prevent animations.
|
|
||||||
timelineItems.removeAll()
|
|
||||||
callbacks.send(.updatedTimelineItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
serialDispatchQueue.async { [activeTimelineProvider] in
|
|
||||||
self.updateTimelineItems(itemProxies: activeTimelineProvider.itemProxies, paginationState: activeTimelineProvider.paginationState)
|
|
||||||
self.callbacks.send(.paginationState(.init(backward: .idle, forward: .idle)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func contentSizeCategoryDidChange() {
|
@objc private func contentSizeCategoryDidChange() {
|
||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:94a132c8800c74bc3f9e9299323c06166a4a351fa9f54ba1a3fdc680086a277d
|
|
||||||
size 417744
|
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d79d725cd4976368ba4e3e0e374d4f48e2b2e7c60e28c90e8e1ca92fc4038a80
|
||||||
|
size 426581
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:94a132c8800c74bc3f9e9299323c06166a4a351fa9f54ba1a3fdc680086a277d
|
|
||||||
size 417744
|
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:d79d725cd4976368ba4e3e0e374d4f48e2b2e7c60e28c90e8e1ca92fc4038a80
|
||||||
|
size 426581
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:8a3a0c17c3a5069ac1e80799e4830bc7f8ae593e0d36b83b2b094fc9f90abfa0
|
|
||||||
size 342251
|
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1d8789df893dd7c2390ff8ecf6446b066cad2801dff8faac5b70bea6afffb040
|
||||||
|
size 343065
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:8a3a0c17c3a5069ac1e80799e4830bc7f8ae593e0d36b83b2b094fc9f90abfa0
|
|
||||||
size 342251
|
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1d8789df893dd7c2390ff8ecf6446b066cad2801dff8faac5b70bea6afffb040
|
||||||
|
size 343065
|
||||||
+2
-2
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -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))
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Use an animation when scrolling to a nearby timeline item.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Add missing padding on the permalink highlight.
|
||||||
Reference in New Issue
Block a user