diff --git a/Multiplatform/Shared/CombineExt/ReplaySubject.swift b/Multiplatform/Shared/CombineExt/ReplaySubject.swift new file mode 100644 index 000000000..b639da4fd --- /dev/null +++ b/Multiplatform/Shared/CombineExt/ReplaySubject.swift @@ -0,0 +1,121 @@ +// +// ReplaySubject.swift +// CombineExt +// +// Created by Jasdev Singh on 13/04/2020. +// Copyright © 2020 Combine Community. All rights reserved. +// + +#if canImport(Combine) +import Combine + +/// A `ReplaySubject` is a subject that can buffer one or more values. It stores value events, up to its `bufferSize` in a +/// first-in-first-out manner and then replays it to +/// future subscribers and also forwards completion events. +/// +/// The implementation borrows heavily from [Entwine’s](https://github.com/tcldr/Entwine/blob/b839c9fcc7466878d6a823677ce608da998b95b9/Sources/Entwine/Operators/ReplaySubject.swift). +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public final class ReplaySubject: Subject { + public typealias Output = Output + public typealias Failure = Failure + + private let bufferSize: Int + private var buffer = [Output]() + + // Keeping track of all live subscriptions, so `send` events can be forwarded to them. + private var subscriptions = [Subscription>]() + + private var completion: Subscribers.Completion? + private var isActive: Bool { completion == nil } + + /// Create a `ReplaySubject`, buffering up to `bufferSize` values and replaying them to new subscribers + /// - Parameter bufferSize: The maximum number of value events to buffer and replay to all future subscribers. + public init(bufferSize: Int) { + self.bufferSize = bufferSize + } + + public func send(_ value: Output) { + guard isActive else { return } + + buffer.append(value) + + if buffer.count > bufferSize { + buffer.removeFirst() + } + + subscriptions.forEach { $0.forwardValueToBuffer(value) } + } + + public func send(completion: Subscribers.Completion) { + guard isActive else { return } + + self.completion = completion + + subscriptions.forEach { $0.forwardCompletionToBuffer(completion) } + } + + public func send(subscription: Combine.Subscription) { + subscription.request(.unlimited) + } + + public func receive(subscriber: Subscriber) where Failure == Subscriber.Failure, Output == Subscriber.Input { + let subscriberIdentifier = subscriber.combineIdentifier + + let subscription = Subscription(downstream: AnySubscriber(subscriber)) { [weak self] in + guard let self = self, + let subscriptionIndex = self.subscriptions + .firstIndex(where: { $0.innerSubscriberIdentifier == subscriberIdentifier }) else { return } + + self.subscriptions.remove(at: subscriptionIndex) + } + + subscriptions.append(subscription) + + subscriber.receive(subscription: subscription) + subscription.replay(buffer, completion: completion) + } +} + +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +extension ReplaySubject { + final class Subscription: Combine.Subscription where Output == Downstream.Input, Failure == Downstream.Failure { + private var demandBuffer: DemandBuffer? + private var cancellationHandler: (() -> Void)? + + fileprivate let innerSubscriberIdentifier: CombineIdentifier + + init(downstream: Downstream, cancellationHandler: (() -> Void)?) { + self.demandBuffer = DemandBuffer(subscriber: downstream) + self.innerSubscriberIdentifier = downstream.combineIdentifier + self.cancellationHandler = cancellationHandler + } + + func replay(_ buffer: [Output], completion: Subscribers.Completion?) { + buffer.forEach(forwardValueToBuffer) + + if let completion = completion { + forwardCompletionToBuffer(completion) + } + } + + func forwardValueToBuffer(_ value: Output) { + _ = demandBuffer?.buffer(value: value) + } + + func forwardCompletionToBuffer(_ completion: Subscribers.Completion) { + demandBuffer?.complete(completion: completion) + } + + func request(_ demand: Subscribers.Demand) { + _ = demandBuffer?.demand(demand) + } + + func cancel() { + cancellationHandler?() + cancellationHandler = nil + + demandBuffer = nil + } + } +} +#endif diff --git a/Multiplatform/Shared/CombineExt/ShareReplay.swift b/Multiplatform/Shared/CombineExt/ShareReplay.swift new file mode 100644 index 000000000..d5c2b24a6 --- /dev/null +++ b/Multiplatform/Shared/CombineExt/ShareReplay.swift @@ -0,0 +1,24 @@ +// +// ShareReplay.swift +// CombineExt +// +// Created by Jasdev Singh on 13/04/2020. +// Copyright © 2020 Combine Community. All rights reserved. +// + +#if canImport(Combine) +import Combine + +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension Publisher { + /// A variation on [share()](https://developer.apple.com/documentation/combine/publisher/3204754-share) + /// that allows for buffering and replaying a `replay` amount of value events to future subscribers. + /// + /// - Parameter count: The number of value events to buffer in a first-in-first-out manner. + /// - Returns: A publisher that replays the specified number of value events to future subscribers. + func share(replay count: Int) -> Publishers.Autoconnect>> { + multicast { ReplaySubject(bufferSize: count) } + .autoconnect() + } +} +#endif diff --git a/Multiplatform/Shared/Sidebar/SidebarModel.swift b/Multiplatform/Shared/Sidebar/SidebarModel.swift index 1de2fa8c7..3854f3785 100644 --- a/Multiplatform/Shared/Sidebar/SidebarModel.swift +++ b/Multiplatform/Shared/Sidebar/SidebarModel.swift @@ -75,6 +75,7 @@ private extension SidebarModel { .removeDuplicates(by: { previousFeeds, currentFeeds in return previousFeeds.elementsEqual(currentFeeds, by: { $0.feedID == $1.feedID }) }) + .share(replay: 1) .eraseToAnyPublisher() } @@ -107,7 +108,7 @@ private extension SidebarModel { .compactMap { [weak self] _, readFilter, selectedFeeds in self?.rebuildSidebarItems(isReadFiltered: readFilter, selectedFeeds: selectedFeeds) } - .share() + .share(replay: 1) .eraseToAnyPublisher() } diff --git a/Multiplatform/Shared/Timeline/TimelineModel.swift b/Multiplatform/Shared/Timeline/TimelineModel.swift index 8fe6304b0..fbdd80890 100644 --- a/Multiplatform/Shared/Timeline/TimelineModel.swift +++ b/Multiplatform/Shared/Timeline/TimelineModel.swift @@ -26,8 +26,8 @@ class TimelineModel: ObservableObject, UndoableCommandRunner { weak var delegate: TimelineModelDelegate? @Published var nameForDisplay = "" - @Published var selectedArticleIDs = Set() // Don't use directly. Use selectedArticles - @Published var selectedArticleID: String? = nil // Don't use directly. Use selectedArticles + @Published var selectedTimelineItemIDs = Set() // Don't use directly. Use selectedTimelineItemsPublisher + @Published var selectedTimelineItemID: String? = nil // Don't use directly. Use selectedTimelineItemsPublisher @Published var isReadFiltered: Bool? = nil var timelineItemsPublisher: AnyPublisher<[TimelineItem], Never>? @@ -40,15 +40,16 @@ class TimelineModel: ObservableObject, UndoableCommandRunner { private var cancellables = Set() - private var sortDirectionSubject = PassthroughSubject() - private var groupByFeedSubject = PassthroughSubject() + private var sortDirectionSubject = ReplaySubject(bufferSize: 1) + private var groupByFeedSubject = ReplaySubject(bufferSize: 1) init(delegate: TimelineModelDelegate) { self.delegate = delegate -// subscribeToArticleStatusChanges() subscribeToUserDefaultsChanges() + subscribeToReadFilterChanges() subscribeToArticleFetchChanges() subscribeToSelectedArticleSelectionChanges() +// subscribeToArticleStatusChanges() // subscribeToAccountDidDownloadArticles() } @@ -78,6 +79,31 @@ class TimelineModel: ObservableObject, UndoableCommandRunner { // }.store(in: &cancellables) // } + func subscribeToReadFilterChanges() { + guard let selectedFeedsPublisher = delegate?.selectedFeedsPublisher else { return } + + selectedFeedsPublisher.sink { [weak self] feeds in + guard let self = self else { return } + + guard feeds.count == 1, let timelineFeed = feeds.first else { + self.isReadFiltered = nil + return + } + + guard timelineFeed.defaultReadFilterType != .alwaysRead else { + self.isReadFiltered = nil + return + } + + if let feedID = timelineFeed.feedID, let readFilterEnabled = self.readFilterEnabledTable[feedID] { + self.isReadFiltered = readFilterEnabled + } else { + self.isReadFiltered = timelineFeed.defaultReadFilterType == .read + } + } + .store(in: &cancellables) + } + func subscribeToUserDefaultsChanges() { let kickStartNote = Notification(name: Notification.Name("Kick Start")) NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) @@ -97,11 +123,12 @@ class TimelineModel: ObservableObject, UndoableCommandRunner { .map { [weak self] feeds -> Set
in return self?.fetchArticles(feeds: feeds) ?? Set
() } - .combineLatest($isReadFiltered, sortDirectionPublisher, groupByPublisher) - .compactMap { [weak self] articles, filtered, sortDirection, groupBy -> [TimelineItem] in + .combineLatest(sortDirectionPublisher, groupByPublisher) + .compactMap { [weak self] articles, sortDirection, groupBy -> [TimelineItem] in let sortedArticles = Array(articles).sortedByDate(sortDirection ? .orderedDescending : .orderedAscending, groupByFeed: groupBy) return self?.buildTimelineItems(articles: sortedArticles) ?? [TimelineItem]() } + .share(replay: 1) .eraseToAnyPublisher() } @@ -218,24 +245,6 @@ private extension TimelineModel { } // MARK: Timeline Management - -// func resetReadFilter() { -// guard feeds.count == 1, let timelineFeed = feeds.first else { -// isReadFiltered = nil -// return -// } -// -// guard timelineFeed.defaultReadFilterType != .alwaysRead else { -// isReadFiltered = nil -// return -// } -// -// if let feedID = timelineFeed.feedID, let readFilterEnabled = readFilterEnabledTable[feedID] { -// isReadFiltered = readFilterEnabled -// } else { -// isReadFiltered = timelineFeed.defaultReadFilterType == .read -// } -// } func sortParametersDidChange() { // performBlockAndRestoreSelection { diff --git a/Multiplatform/Shared/Timeline/TimelineView.swift b/Multiplatform/Shared/Timeline/TimelineView.swift index dd24a2141..f7da66f45 100644 --- a/Multiplatform/Shared/Timeline/TimelineView.swift +++ b/Multiplatform/Shared/Timeline/TimelineView.swift @@ -38,8 +38,8 @@ struct TimelineView: View { .help(timelineModel.isReadFiltered ?? false ? "Show Read Articles" : "Filter Read Articles") } ScrollViewReader { scrollViewProxy in - List(timelineItems, selection: $timelineModel.selectedArticleIDs) { timelineItem in - let selected = timelineModel.selectedArticleIDs.contains(timelineItem.article.articleID) + List(timelineItems, selection: $timelineModel.selectedTimelineItemIDs) { timelineItem in + let selected = timelineModel.selectedTimelineItemIDs.contains(timelineItem.article.articleID) TimelineItemView(selected: selected, width: geometryReaderProxy.size.width, timelineItem: timelineItem) .background(TimelineItemFramePreferenceView(timelineItem: timelineItem)) } @@ -48,7 +48,7 @@ struct TimelineView: View { timelineItemFrames[pref.articleID] = pref.frame } } - .onChange(of: timelineModel.selectedArticleIDs) { selectedArticleIDs in + .onChange(of: timelineModel.selectedTimelineItemIDs) { selectedArticleIDs in let proxyFrame = geometryReaderProxy.frame(in: .global) for articleID in selectedArticleIDs { if let itemFrame = timelineItemFrames[articleID] { @@ -70,14 +70,14 @@ struct TimelineView: View { .navigationTitle(Text(verbatim: timelineModel.nameForDisplay)) #else ScrollViewReader { scrollViewProxy in - List(timelineModel.timelineItems) { timelineItem in + List(timelineItems) { timelineItem in ZStack { - let selected = timelineModel.selectedArticleID == timelineItem.article.articleID + let selected = timelineModel.selectedTimelineItemID == timelineItem.article.articleID TimelineItemView(selected: selected, width: geometryReaderProxy.size.width, timelineItem: timelineItem) .background(TimelineItemFramePreferenceView(timelineItem: timelineItem)) NavigationLink(destination: ArticleContainerView(), tag: timelineItem.article.articleID, - selection: $timelineModel.selectedArticleID) { + selection: $timelineModel.selectedTimelineItemID) { EmptyView() }.buttonStyle(PlainButtonStyle()) } @@ -87,7 +87,7 @@ struct TimelineView: View { timelineItemFrames[pref.articleID] = pref.frame } } - .onChange(of: timelineModel.selectedArticleID) { selectedArticleID in + .onChange(of: timelineModel.selectedTimelineItemID) { selectedArticleID in let proxyFrame = geometryReaderProxy.frame(in: .global) if let articleID = selectedArticleID, let itemFrame = timelineItemFrames[articleID] { if itemFrame.minY < proxyFrame.minY + 3 || itemFrame.maxY > proxyFrame.maxY - 3 { @@ -98,6 +98,12 @@ struct TimelineView: View { } } } + .onReceive(timelineModel.timelineItemsPublisher!) { items in +// Animations crash on iPadOS right now +// withAnimation { + timelineItems = items +// } + } .navigationBarTitle(Text(verbatim: timelineModel.nameForDisplay), displayMode: .inline) #endif } diff --git a/Multiplatform/iOS/Article/ArticleViewController.swift b/Multiplatform/iOS/Article/ArticleViewController.swift index 3ee18b089..c786b183a 100644 --- a/Multiplatform/iOS/Article/ArticleViewController.swift +++ b/Multiplatform/iOS/Article/ArticleViewController.swift @@ -61,9 +61,9 @@ class ArticleViewController: UIViewController { view.bottomAnchor.constraint(equalTo: pageViewController.view.bottomAnchor) ]) - selectedArticlesCancellable = sceneModel?.timelineModel.$selectedArticles.sink { [weak self] articles in - self?.articles = articles - } +// selectedArticlesCancellable = sceneModel?.timelineModel.$selectedArticles.sink { [weak self] articles in +// self?.articles = articles +// } let controller = createWebViewController(currentArticle, updateView: true) self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil) diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 080b3b84b..4deafb144 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -340,6 +340,10 @@ 51A8001324CA0FC700F41F1D /* Sink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8001124CA0FC700F41F1D /* Sink.swift */; }; 51A8001524CA0FEC00F41F1D /* DemandBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8001424CA0FEC00F41F1D /* DemandBuffer.swift */; }; 51A8001624CA0FEC00F41F1D /* DemandBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8001424CA0FEC00F41F1D /* DemandBuffer.swift */; }; + 51A8002D24CC451500F41F1D /* ShareReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8002C24CC451500F41F1D /* ShareReplay.swift */; }; + 51A8002E24CC451600F41F1D /* ShareReplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8002C24CC451500F41F1D /* ShareReplay.swift */; }; + 51A8005124CC453C00F41F1D /* ReplaySubject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8005024CC453C00F41F1D /* ReplaySubject.swift */; }; + 51A8005224CC453C00F41F1D /* ReplaySubject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8005024CC453C00F41F1D /* ReplaySubject.swift */; }; 51A8FFED24CA0CF400F41F1D /* WIthLatestFrom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8FFEC24CA0CF400F41F1D /* WIthLatestFrom.swift */; }; 51A8FFEE24CA0CF400F41F1D /* WIthLatestFrom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8FFEC24CA0CF400F41F1D /* WIthLatestFrom.swift */; }; 51A9A5E12380C4FE0033AADF /* AppDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C45255226507D200C03939 /* AppDefaults.swift */; }; @@ -2036,6 +2040,8 @@ 51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddWebFeedDefaultContainer.swift; sourceTree = ""; }; 51A8001124CA0FC700F41F1D /* Sink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sink.swift; sourceTree = ""; }; 51A8001424CA0FEC00F41F1D /* DemandBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemandBuffer.swift; sourceTree = ""; }; + 51A8002C24CC451500F41F1D /* ShareReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareReplay.swift; sourceTree = ""; }; + 51A8005024CC453C00F41F1D /* ReplaySubject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplaySubject.swift; sourceTree = ""; }; 51A8FFEC24CA0CF400F41F1D /* WIthLatestFrom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WIthLatestFrom.swift; sourceTree = ""; }; 51A9A5E32380C8870033AADF /* ShareFolderPickerAccountCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ShareFolderPickerAccountCell.xib; sourceTree = ""; }; 51A9A5E52380C8B20033AADF /* ShareFolderPickerFolderCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ShareFolderPickerFolderCell.xib; sourceTree = ""; }; @@ -3044,6 +3050,8 @@ 51A8001424CA0FEC00F41F1D /* DemandBuffer.swift */, 51A8001124CA0FC700F41F1D /* Sink.swift */, 51A8FFEC24CA0CF400F41F1D /* WIthLatestFrom.swift */, + 51A8002C24CC451500F41F1D /* ShareReplay.swift */, + 51A8005024CC453C00F41F1D /* ReplaySubject.swift */, ); path = CombineExt; sourceTree = ""; @@ -4331,46 +4339,46 @@ TargetAttributes = { 51314636235A7BBE00387FDC = { CreatedOnToolsVersion = 11.2; - DevelopmentTeam = FQLBNX3GP7; + DevelopmentTeam = SHJK2V3AJG; LastSwiftMigration = 1120; ProvisioningStyle = Automatic; }; 513C5CE5232571C2003D4054 = { CreatedOnToolsVersion = 11.0; - DevelopmentTeam = FQLBNX3GP7; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; }; 518B2ED12351B3DD00400001 = { CreatedOnToolsVersion = 11.2; - DevelopmentTeam = FQLBNX3GP7; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; TestTargetID = 840D617B2029031C009BC708; }; 51C0513C24A77DF800194D5E = { CreatedOnToolsVersion = 12.0; - DevelopmentTeam = FQLBNX3GP7; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; }; 51C0514324A77DF800194D5E = { CreatedOnToolsVersion = 12.0; - DevelopmentTeam = FQLBNX3GP7; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; }; 6581C73220CED60000F4AD34 = { - DevelopmentTeam = FQLBNX3GP7; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; }; 65ED3FA2235DEF6C0081F399 = { - DevelopmentTeam = FQLBNX3GP7; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; }; 65ED4090235DEF770081F399 = { - DevelopmentTeam = FQLBNX3GP7; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; }; 840D617B2029031C009BC708 = { CreatedOnToolsVersion = 9.3; - DevelopmentTeam = FQLBNX3GP7; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.BackgroundModes = { @@ -4380,7 +4388,7 @@ }; 849C645F1ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = FQLBNX3GP7; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.HardenedRuntime = { @@ -4390,7 +4398,7 @@ }; 849C64701ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = FQLBNX3GP7; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; TestTargetID = 849C645F1ED37A5D003D8FC0; }; @@ -5241,6 +5249,7 @@ 65082A5224C72B88009FA994 /* SettingsCredentialsAccountModel.swift in Sources */, 172199C924AB228900A31D04 /* SettingsView.swift in Sources */, 51B8BCC224C25C3E00360B00 /* SidebarContextMenu.swift in Sources */, + 51A8005124CC453C00F41F1D /* ReplaySubject.swift in Sources */, 17D232A824AFF10A0005F075 /* AddWebFeedModel.swift in Sources */, 51B80EB824BD1F8B00C6C32D /* ActivityViewController.swift in Sources */, 51E4994224A8713C00B667CB /* ArticleStatusSyncTimer.swift in Sources */, @@ -5308,6 +5317,7 @@ 51919FEE24AB85E400541E64 /* TimelineContainerView.swift in Sources */, 653A4E7924BCA5BB00EF2D7F /* SettingsCloudKitAccountView.swift in Sources */, 51E4995724A8734D00B667CB /* ExtensionPoint.swift in Sources */, + 51A8002D24CC451500F41F1D /* ShareReplay.swift in Sources */, 51B8BCE624C25F7C00360B00 /* TimelineContextMenu.swift in Sources */, 1776E88E24AC5F8A00E78166 /* AppDefaults.swift in Sources */, 51E4991124A808DE00B667CB /* SmallIconProvider.swift in Sources */, @@ -5361,6 +5371,7 @@ 51E498CB24A8085D00B667CB /* TodayFeedDelegate.swift in Sources */, 51B80F1F24BE531200C6C32D /* SharingServiceView.swift in Sources */, 17D232A924AFF10A0005F075 /* AddWebFeedModel.swift in Sources */, + 51A8005224CC453C00F41F1D /* ReplaySubject.swift in Sources */, 51E4993324A867E700B667CB /* AppNotifications.swift in Sources */, 51B80F4224BE588200C6C32D /* SharingServicePickerDelegate.swift in Sources */, 51E4990624A808C300B667CB /* ImageDownloader.swift in Sources */, @@ -5418,6 +5429,7 @@ 514E6BDB24ACEA0400AC6F6E /* TimelineItemView.swift in Sources */, 51B8BCE724C25F7C00360B00 /* TimelineContextMenu.swift in Sources */, 51E4996E24A8764C00B667CB /* ActivityManager.swift in Sources */, + 51A8002E24CC451600F41F1D /* ShareReplay.swift in Sources */, 1769E33024BD6271000E1E8E /* EditAccountCredentialsView.swift in Sources */, 51E4995A24A873F900B667CB /* ErrorHandler.swift in Sources */, 5194737124BBCAF4001A2939 /* TimelineSortOrderView.swift in Sources */,