From 75b9264d44f0125532b618a4ad78ffa3678a2902 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Thu, 23 Jul 2020 16:27:54 -0500 Subject: [PATCH] Rewrite Sidebar select next unread in Combine --- .../Shared/CombineExt/DemandBuffer.swift | 151 +++++++++++ Multiplatform/Shared/CombineExt/Sink.swift | 101 ++++++++ .../Shared/CombineExt/WIthLatestFrom.swift | 238 ++++++++++++++++++ Multiplatform/Shared/SceneModel.swift | 2 +- .../Shared/Sidebar/SidebarModel.swift | 105 ++++---- NetNewsWire.xcodeproj/project.pbxproj | 48 +++- 6 files changed, 587 insertions(+), 58 deletions(-) create mode 100644 Multiplatform/Shared/CombineExt/DemandBuffer.swift create mode 100644 Multiplatform/Shared/CombineExt/Sink.swift create mode 100644 Multiplatform/Shared/CombineExt/WIthLatestFrom.swift diff --git a/Multiplatform/Shared/CombineExt/DemandBuffer.swift b/Multiplatform/Shared/CombineExt/DemandBuffer.swift new file mode 100644 index 000000000..7b02ac377 --- /dev/null +++ b/Multiplatform/Shared/CombineExt/DemandBuffer.swift @@ -0,0 +1,151 @@ +// +// DemandBuffer.swift +// CombineExt +// +// Created by Shai Mishali on 21/02/2020. +// Copyright © 2020 Combine Community. All rights reserved. +// + +#if canImport(Combine) +import Combine +import class Foundation.NSRecursiveLock + +/// A buffer responsible for managing the demand of a downstream +/// subscriber for an upstream publisher +/// +/// It buffers values and completion events and forwards them dynamically +/// according to the demand requested by the downstream +/// +/// In a sense, the subscription only relays the requests for demand, as well +/// the events emitted by the upstream — to this buffer, which manages +/// the entire behavior and backpressure contract +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +class DemandBuffer { + private let lock = NSRecursiveLock() + private var buffer = [S.Input]() + private let subscriber: S + private var completion: Subscribers.Completion? + private var demandState = Demand() + + /// Initialize a new demand buffer for a provided downstream subscriber + /// + /// - parameter subscriber: The downstream subscriber demanding events + init(subscriber: S) { + self.subscriber = subscriber + } + + /// Buffer an upstream value to later be forwarded to + /// the downstream subscriber, once it demands it + /// + /// - parameter value: Upstream value to buffer + /// + /// - returns: The demand fulfilled by the bufferr + func buffer(value: S.Input) -> Subscribers.Demand { + precondition(self.completion == nil, + "How could a completed publisher sent values?! Beats me 🤷‍♂️") + + switch demandState.requested { + case .unlimited: + return subscriber.receive(value) + default: + buffer.append(value) + return flush() + } + } + + /// Complete the demand buffer with an upstream completion event + /// + /// This method will deplete the buffer immediately, + /// based on the currently accumulated demand, and relay the + /// completion event down as soon as demand is fulfilled + /// + /// - parameter completion: Completion event + func complete(completion: Subscribers.Completion) { + precondition(self.completion == nil, + "Completion have already occured, which is quite awkward 🥺") + + self.completion = completion + _ = flush() + } + + /// Signal to the buffer that the downstream requested new demand + /// + /// - note: The buffer will attempt to flush as many events rqeuested + /// by the downstream at this point + func demand(_ demand: Subscribers.Demand) -> Subscribers.Demand { + flush(adding: demand) + } + + /// Flush buffered events to the downstream based on the current + /// state of the downstream's demand + /// + /// - parameter newDemand: The new demand to add. If `nil`, the flush isn't the + /// result of an explicit demand change + /// + /// - note: After fulfilling the downstream's request, if completion + /// has already occured, the buffer will be cleared and the + /// completion event will be sent to the downstream subscriber + private func flush(adding newDemand: Subscribers.Demand? = nil) -> Subscribers.Demand { + lock.lock() + defer { lock.unlock() } + + if let newDemand = newDemand { + demandState.requested += newDemand + } + + // If buffer isn't ready for flushing, return immediately + guard demandState.requested > 0 || newDemand == Subscribers.Demand.none else { return .none } + + while !buffer.isEmpty && demandState.processed < demandState.requested { + demandState.requested += subscriber.receive(buffer.remove(at: 0)) + demandState.processed += 1 + } + + if let completion = completion { + // Completion event was already sent + buffer = [] + demandState = .init() + self.completion = nil + subscriber.receive(completion: completion) + return .none + } + + let sentDemand = demandState.requested - demandState.sent + demandState.sent += sentDemand + return sentDemand + } +} + +// MARK: - Private Helpers +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +private extension DemandBuffer { + /// A model that tracks the downstream's + /// accumulated demand state + struct Demand { + var processed: Subscribers.Demand = .none + var requested: Subscribers.Demand = .none + var sent: Subscribers.Demand = .none + } +} + +// MARK: - Internally-scoped helpers +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +extension Subscription { + /// Reqeust demand if it's not empty + /// + /// - parameter demand: Requested demand + func requestIfNeeded(_ demand: Subscribers.Demand) { + guard demand > .none else { return } + request(demand) + } +} + +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +extension Optional where Wrapped == Subscription { + /// Cancel the Optional subscription and nullify it + mutating func kill() { + self?.cancel() + self = nil + } +} +#endif diff --git a/Multiplatform/Shared/CombineExt/Sink.swift b/Multiplatform/Shared/CombineExt/Sink.swift new file mode 100644 index 000000000..c60008ac8 --- /dev/null +++ b/Multiplatform/Shared/CombineExt/Sink.swift @@ -0,0 +1,101 @@ +// +// Sink.swift +// CombineExt +// +// Created by Shai Mishali on 14/03/2020. +// Copyright © 2020 Combine Community. All rights reserved. +// + +#if canImport(Combine) +import Combine + +/// A generic sink using an underlying demand buffer to balance +/// the demand of a downstream subscriber for the events of an +/// upstream publisher +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +class Sink: Subscriber { + typealias TransformFailure = (Upstream.Failure) -> Downstream.Failure? + typealias TransformOutput = (Upstream.Output) -> Downstream.Input? + + private(set) var buffer: DemandBuffer + private var upstreamSubscription: Subscription? + private let transformOutput: TransformOutput? + private let transformFailure: TransformFailure? + + /// Initialize a new sink subscribing to the upstream publisher and + /// fulfilling the demand of the downstream subscriber using a backpresurre + /// demand-maintaining buffer. + /// + /// - parameter upstream: The upstream publisher + /// - parameter downstream: The downstream subscriber + /// - parameter transformOutput: Transform the upstream publisher's output type to the downstream's input type + /// - parameter transformFailure: Transform the upstream failure type to the downstream's failure type + /// + /// - note: You **must** provide the two transformation functions above if you're using + /// the default `Sink` implementation. Otherwise, you must subclass `Sink` with your own + /// publisher's sink and manage the buffer accordingly. + init(upstream: Upstream, + downstream: Downstream, + transformOutput: TransformOutput? = nil, + transformFailure: TransformFailure? = nil) { + self.buffer = DemandBuffer(subscriber: downstream) + self.transformOutput = transformOutput + self.transformFailure = transformFailure + upstream.subscribe(self) + } + + func demand(_ demand: Subscribers.Demand) { + let newDemand = buffer.demand(demand) + upstreamSubscription?.requestIfNeeded(newDemand) + } + + func receive(subscription: Subscription) { + upstreamSubscription = subscription + } + + func receive(_ input: Upstream.Output) -> Subscribers.Demand { + guard let transform = transformOutput else { + fatalError(""" + ❌ Missing output transformation + ========================= + + You must either: + - Provide a transformation function from the upstream's output to the downstream's input; or + - Subclass `Sink` with your own publisher's Sink and manage the buffer yourself + """) + } + + guard let input = transform(input) else { return .none } + return buffer.buffer(value: input) + } + + func receive(completion: Subscribers.Completion) { + switch completion { + case .finished: + buffer.complete(completion: .finished) + case .failure(let error): + guard let transform = transformFailure else { + fatalError(""" + ❌ Missing failure transformation + ========================= + + You must either: + - Provide a transformation function from the upstream's failure to the downstream's failuer; or + - Subclass `Sink` with your own publisher's Sink and manage the buffer yourself + """) + } + + guard let error = transform(error) else { return } + buffer.complete(completion: .failure(error)) + } + + cancelUpstream() + } + + func cancelUpstream() { + upstreamSubscription.kill() + } + + deinit { cancelUpstream() } +} +#endif diff --git a/Multiplatform/Shared/CombineExt/WIthLatestFrom.swift b/Multiplatform/Shared/CombineExt/WIthLatestFrom.swift new file mode 100644 index 000000000..cda9b1c84 --- /dev/null +++ b/Multiplatform/Shared/CombineExt/WIthLatestFrom.swift @@ -0,0 +1,238 @@ +// +// WithLatestFrom.swift +// CombineExt +// +// Created by Shai Mishali on 29/08/2019. +// Copyright © 2020 Combine Community. All rights reserved. +// + +#if canImport(Combine) +import Combine + +// MARK: - Operator methods +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension Publisher { + /// Merges two publishers into a single publisher by combining each value + /// from self with the latest value from the second publisher, if any. + /// + /// - parameter other: A second publisher source. + /// - parameter resultSelector: Function to invoke for each value from the self combined + /// with the latest value from the second source, if any. + /// + /// - returns: A publisher containing the result of combining each value of the self + /// with the latest value from the second publisher, if any, using the + /// specified result selector function. + func withLatestFrom(_ other: Other, + resultSelector: @escaping (Output, Other.Output) -> Result) + -> Publishers.WithLatestFrom { + return .init(upstream: self, second: other, resultSelector: resultSelector) + } + + /// Merges three publishers into a single publisher by combining each value + /// from self with the latest value from the second and third publisher, if any. + /// + /// - parameter other: A second publisher source. + /// - parameter other1: A third publisher source. + /// - parameter resultSelector: Function to invoke for each value from the self combined + /// with the latest value from the second and third source, if any. + /// + /// - returns: A publisher containing the result of combining each value of the self + /// with the latest value from the second and third publisher, if any, using the + /// specified result selector function. + func withLatestFrom(_ other: Other, + _ other1: Other1, + resultSelector: @escaping (Output, (Other.Output, Other1.Output)) -> Result) + -> Publishers.WithLatestFrom, Result> + where Other.Failure == Failure, Other1.Failure == Failure { + let combined = other.combineLatest(other1) + .eraseToAnyPublisher() + return .init(upstream: self, second: combined, resultSelector: resultSelector) + } + + /// Merges four publishers into a single publisher by combining each value + /// from self with the latest value from the second, third and fourth publisher, if any. + /// + /// - parameter other: A second publisher source. + /// - parameter other1: A third publisher source. + /// - parameter other2: A fourth publisher source. + /// - parameter resultSelector: Function to invoke for each value from the self combined + /// with the latest value from the second, third and fourth source, if any. + /// + /// - returns: A publisher containing the result of combining each value of the self + /// with the latest value from the second, third and fourth publisher, if any, using the + /// specified result selector function. + func withLatestFrom(_ other: Other, + _ other1: Other1, + _ other2: Other2, + resultSelector: @escaping (Output, (Other.Output, Other1.Output, Other2.Output)) -> Result) + -> Publishers.WithLatestFrom, Result> + where Other.Failure == Failure, Other1.Failure == Failure, Other2.Failure == Failure { + let combined = other.combineLatest(other1, other2) + .eraseToAnyPublisher() + return .init(upstream: self, second: combined, resultSelector: resultSelector) + } + + /// Upon an emission from self, emit the latest value from the + /// second publisher, if any exists. + /// + /// - parameter other: A second publisher source. + /// + /// - returns: A publisher containing the latest value from the second publisher, if any. + func withLatestFrom(_ other: Other) + -> Publishers.WithLatestFrom { + return .init(upstream: self, second: other) { $1 } + } + + /// Upon an emission from self, emit the latest value from the + /// second and third publisher, if any exists. + /// + /// - parameter other: A second publisher source. + /// - parameter other1: A third publisher source. + /// + /// - returns: A publisher containing the latest value from the second and third publisher, if any. + func withLatestFrom(_ other: Other, + _ other1: Other1) + -> Publishers.WithLatestFrom, (Other.Output, Other1.Output)> + where Other.Failure == Failure, Other1.Failure == Failure { + withLatestFrom(other, other1) { $1 } + } + + /// Upon an emission from self, emit the latest value from the + /// second, third and forth publisher, if any exists. + /// + /// - parameter other: A second publisher source. + /// - parameter other1: A third publisher source. + /// - parameter other2: A forth publisher source. + /// + /// - returns: A publisher containing the latest value from the second, third and forth publisher, if any. + func withLatestFrom(_ other: Other, + _ other1: Other1, + _ other2: Other2) + -> Publishers.WithLatestFrom, (Other.Output, Other1.Output, Other2.Output)> + where Other.Failure == Failure, Other1.Failure == Failure, Other2.Failure == Failure { + withLatestFrom(other, other1, other2) { $1 } + } +} + +// MARK: - Publisher +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +public extension Publishers { + struct WithLatestFrom: Publisher where Upstream.Failure == Other.Failure { + public typealias Failure = Upstream.Failure + public typealias ResultSelector = (Upstream.Output, Other.Output) -> Output + + private let upstream: Upstream + private let second: Other + private let resultSelector: ResultSelector + private var latestValue: Other.Output? + + init(upstream: Upstream, + second: Other, + resultSelector: @escaping ResultSelector) { + self.upstream = upstream + self.second = second + self.resultSelector = resultSelector + } + + public func receive(subscriber: S) where Failure == S.Failure, Output == S.Input { + subscriber.receive(subscription: Subscription(upstream: upstream, + downstream: subscriber, + second: second, + resultSelector: resultSelector)) + } + } +} + +// MARK: - Subscription +@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +private extension Publishers.WithLatestFrom { + class Subscription: Combine.Subscription, CustomStringConvertible where Downstream.Input == Output, Downstream.Failure == Failure { + private let resultSelector: ResultSelector + private var sink: Sink? + + private let upstream: Upstream + private let downstream: Downstream + private let second: Other + + // Secondary (other) publisher + private var latestValue: Other.Output? + private var otherSubscription: Cancellable? + private var preInitialDemand = Subscribers.Demand.none + + init(upstream: Upstream, + downstream: Downstream, + second: Other, + resultSelector: @escaping ResultSelector) { + self.upstream = upstream + self.second = second + self.downstream = downstream + self.resultSelector = resultSelector + + trackLatestFromSecond { [weak self] in + guard let self = self else { return } + self.request(self.preInitialDemand) + self.preInitialDemand = .none + } + } + + func request(_ demand: Subscribers.Demand) { + guard latestValue != nil else { + preInitialDemand += demand + return + } + + self.sink?.demand(demand) + } + + // Create an internal subscription to the `Other` publisher, + // constantly tracking its latest value + private func trackLatestFromSecond(onInitialValue: @escaping () -> Void) { + var gotInitialValue = false + + let subscriber = AnySubscriber( + receiveSubscription: { [weak self] subscription in + self?.otherSubscription = subscription + subscription.request(.unlimited) + }, + receiveValue: { [weak self] value in + guard let self = self else { return .none } + self.latestValue = value + + if !gotInitialValue { + // When getting initial value, start pulling values + // from upstream in the main sink + self.sink = Sink(upstream: self.upstream, + downstream: self.downstream, + transformOutput: { [weak self] value in + guard let self = self, + let other = self.latestValue else { return nil } + + return self.resultSelector(value, other) + }, + transformFailure: { $0 }) + + // Signal initial value to start fulfilling downstream demand + gotInitialValue = true + onInitialValue() + } + + return .unlimited + }, + receiveCompletion: nil) + + self.second.subscribe(subscriber) + } + + var description: String { + return "WithLatestFrom.Subscription<\(Output.self), \(Failure.self)>" + } + + func cancel() { + sink = nil + otherSubscription?.cancel() + } + } +} +#endif diff --git a/Multiplatform/Shared/SceneModel.swift b/Multiplatform/Shared/SceneModel.swift index a1f434d01..e090e4576 100644 --- a/Multiplatform/Shared/SceneModel.swift +++ b/Multiplatform/Shared/SceneModel.swift @@ -57,7 +57,7 @@ final class SceneModel: ObservableObject { func goToNextUnread() { if !timelineModel.goToNextUnread() { timelineModel.isSelectNextUnread = true - sidebarModel.goToNextUnread() + sidebarModel.selectNextUnread.send(true) } } diff --git a/Multiplatform/Shared/Sidebar/SidebarModel.swift b/Multiplatform/Shared/Sidebar/SidebarModel.swift index bca60bb37..87a2c57e6 100644 --- a/Multiplatform/Shared/Sidebar/SidebarModel.swift +++ b/Multiplatform/Shared/Sidebar/SidebarModel.swift @@ -21,6 +21,7 @@ class SidebarModel: ObservableObject, UndoableCommandRunner { weak var delegate: SidebarModelDelegate? var sidebarItemsPublisher: AnyPublisher<[SidebarItem], Never>? + var selectNextUnread = PassthroughSubject() @Published var selectedFeedIdentifiers = Set() @Published var selectedFeedIdentifier: FeedIdentifier? = .none @@ -35,27 +36,16 @@ class SidebarModel: ObservableObject, UndoableCommandRunner { init() { subscribeToSelectedFeedChanges() subscribeToRebuildSidebarItemsEvents() - } - - // MARK: API - - func goToNextUnread() { -// guard let startFeed = selectedFeeds.first ?? sidebarItems.first?.children.first?.feed else { return } -// -// if !goToNextUnread(startingAt: startFeed) { -// if let firstFeed = sidebarItems.first?.children.first?.feed { -// goToNextUnread(startingAt: firstFeed) -// } -// } + subscribeToNextUnread() } } // MARK: Side Context Menu Actions + extension SidebarModel { func markAllAsRead(feed: Feed) { - var articles = Set
() let fetchedArticles = try! feed.fetchArticles() for article in fetchedArticles { @@ -190,6 +180,20 @@ private extension SidebarModel { .eraseToAnyPublisher() } + func subscribeToNextUnread() { + guard let sidebarItemsPublisher = sidebarItemsPublisher else { return } + + selectNextUnread + .withLatestFrom(sidebarItemsPublisher, $selectedFeeds) + .compactMap { [weak self] (sidebarItems, selectedFeeds) in + return self?.nextUnread(sidebarItems: sidebarItems, selectedFeeds: selectedFeeds) + } + .sink { [weak self] nextFeedID in + self?.select(nextFeedID) + } + .store(in: &cancellables) + } + // MARK: Sidebar Building func sort(_ folders: Set) -> [Folder] { @@ -254,38 +258,47 @@ private extension SidebarModel { } } -// @discardableResult -// func goToNextUnread(startingAt: Feed) -> Bool { -// -// var foundStartFeed = false -// var nextSidebarItem: SidebarItem? = nil -// for section in sidebarItems { -// if nextSidebarItem == nil { -// section.visit { sidebarItem in -// if !foundStartFeed && sidebarItem.feed?.feedID == startingAt.feedID { -// foundStartFeed = true -// return false -// } -// if foundStartFeed && sidebarItem.unreadCount > 0 { -// nextSidebarItem = sidebarItem -// return true -// } -// return false -// } -// } -// } -// -// if let nextFeedID = nextSidebarItem?.feed?.feedID { -// select(nextFeedID) -// return true -// } -// -// return false -// } -// -// func select(_ feedID: FeedIdentifier) { -// selectedFeedIdentifiers = Set([feedID]) -// selectedFeedIdentifier = feedID -// } + func nextUnread(sidebarItems: [SidebarItem], selectedFeeds: [Feed]) -> FeedIdentifier? { + guard let startFeed = selectedFeeds.first ?? sidebarItems.first?.children.first?.feed else { return nil } + + if let feedID = nextUnread(sidebarItems: sidebarItems, startingAt: startFeed) { + return feedID + } else { + if let firstFeed = sidebarItems.first?.children.first?.feed { + return nextUnread(sidebarItems: sidebarItems, startingAt: firstFeed) + } + } + + return nil + } + + @discardableResult + func nextUnread(sidebarItems: [SidebarItem], startingAt: Feed) -> FeedIdentifier? { + var foundStartFeed = false + var nextSidebarItem: SidebarItem? = nil + + for section in sidebarItems { + if nextSidebarItem == nil { + section.visit { sidebarItem in + if !foundStartFeed && sidebarItem.feed?.feedID == startingAt.feedID { + foundStartFeed = true + return false + } + if foundStartFeed && sidebarItem.unreadCount > 0 { + nextSidebarItem = sidebarItem + return true + } + return false + } + } + } + + return nextSidebarItem?.feed?.feedID + } + + func select(_ feedID: FeedIdentifier) { + selectedFeedIdentifiers = Set([feedID]) + selectedFeedIdentifier = feedID + } } diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 52ee74c52..3b0a7d4d7 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -332,6 +332,12 @@ 51A5769624AE617200078888 /* ArticleContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A5769524AE617200078888 /* ArticleContainerView.swift */; }; 51A5769724AE617200078888 /* ArticleContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A5769524AE617200078888 /* ArticleContainerView.swift */; }; 51A66685238075AE00CB272D /* AddWebFeedDefaultContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */; }; + 51A8001224CA0FC700F41F1D /* Sink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51A8001124CA0FC700F41F1D /* Sink.swift */; }; + 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 */; }; + 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 */; }; 51A9A5E42380C8880033AADF /* ShareFolderPickerAccountCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 51A9A5E32380C8870033AADF /* ShareFolderPickerAccountCell.xib */; }; 51A9A5E62380C8B20033AADF /* ShareFolderPickerFolderCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 51A9A5E52380C8B20033AADF /* ShareFolderPickerFolderCell.xib */; }; @@ -2023,6 +2029,9 @@ 51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedbinAccountViewController.swift; sourceTree = ""; }; 51A5769524AE617200078888 /* ArticleContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleContainerView.swift; sourceTree = ""; }; 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 = ""; }; + 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 = ""; }; 51A9A5E72380CA130033AADF /* ShareFolderPickerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareFolderPickerCell.swift; sourceTree = ""; }; @@ -3016,6 +3025,16 @@ path = Article; sourceTree = ""; }; + 51A8001024CA0FAE00F41F1D /* CombineExt */ = { + isa = PBXGroup; + children = ( + 51A8001424CA0FEC00F41F1D /* DemandBuffer.swift */, + 51A8001124CA0FC700F41F1D /* Sink.swift */, + 51A8FFEC24CA0CF400F41F1D /* WIthLatestFrom.swift */, + ); + path = CombineExt; + sourceTree = ""; + }; 51B5C85A23F22A7A00032075 /* CommonExtension */ = { isa = PBXGroup; children = ( @@ -3080,6 +3099,7 @@ 51C0513824A77DF800194D5E /* Assets.xcassets */, 17930ED224AF10CD00A9BA52 /* Add */, 51A576B924AE617B00078888 /* Article */, + 51A8001024CA0FAE00F41F1D /* CombineExt */, 51919FB124AAB95300541E64 /* Images */, 17897AA724C281520014BA03 /* Inspector */, 514E6BFD24AD252400AC6F6E /* Previews */, @@ -4297,46 +4317,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 = { @@ -4346,7 +4366,7 @@ }; 849C645F1ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = FQLBNX3GP7; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.HardenedRuntime = { @@ -4356,7 +4376,7 @@ }; 849C64701ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = FQLBNX3GP7; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; TestTargetID = 849C645F1ED37A5D003D8FC0; }; @@ -5174,6 +5194,7 @@ 65082A5424C73D2F009FA994 /* AccountCredentialsError.swift in Sources */, 51E4990B24A808C500B667CB /* ImageDownloader.swift in Sources */, 51E498F424A8085D00B667CB /* SmartFeedDelegate.swift in Sources */, + 51A8001524CA0FEC00F41F1D /* DemandBuffer.swift in Sources */, 514E6BFF24AD255D00AC6F6E /* PreviewArticles.swift in Sources */, 51E4993024A8676400B667CB /* ArticleSorter.swift in Sources */, 51408B7E24A9EC6F0073CF4E /* SidebarItem.swift in Sources */, @@ -5196,6 +5217,7 @@ 51E4990124A808BB00B667CB /* FaviconURLFinder.swift in Sources */, 51E4991D24A8092100B667CB /* NSAttributedString+NetNewsWire.swift in Sources */, 65082A2F24C72AC8009FA994 /* SettingsCredentialsAccountView.swift in Sources */, + 51A8FFED24CA0CF400F41F1D /* WIthLatestFrom.swift in Sources */, 51E499FD24A9137600B667CB /* SidebarModel.swift in Sources */, 5181C66224B0C326002E0F70 /* SettingsModel.swift in Sources */, 51E4995324A8734D00B667CB /* RedditFeedProvider-Extensions.swift in Sources */, @@ -5248,6 +5270,7 @@ 5177472224B38CAE00EB0F74 /* ArticleExtractorButtonState.swift in Sources */, 5177471A24B3863000EB0F74 /* WebViewProvider.swift in Sources */, 51E4992124A8095000B667CB /* RSImage-Extensions.swift in Sources */, + 51A8001224CA0FC700F41F1D /* Sink.swift in Sources */, 51E4990324A808BB00B667CB /* FaviconDownloader.swift in Sources */, 172199ED24AB2E0100A31D04 /* SafariView.swift in Sources */, 65ACE48624B477C9003AE06A /* SettingsAccountLabelView.swift in Sources */, @@ -5284,11 +5307,13 @@ 51B8BCC324C25C3E00360B00 /* SidebarContextMenu.swift in Sources */, 51E498C724A8085D00B667CB /* StarredFeedDelegate.swift in Sources */, 5194736F24BBB937001A2939 /* HiddenModifier.swift in Sources */, + 51A8001624CA0FEC00F41F1D /* DemandBuffer.swift in Sources */, 51919FB724AABCA100541E64 /* IconImageView.swift in Sources */, 51B54A6924B54A490014348B /* IconView.swift in Sources */, 17897ACB24C281A40014BA03 /* InspectorView.swift in Sources */, 51E498FA24A808BA00B667CB /* SingleFaviconDownloader.swift in Sources */, 1727B39924C1368D00A4DBDC /* LayoutPreferencesView.swift in Sources */, + 51A8FFEE24CA0CF400F41F1D /* WIthLatestFrom.swift in Sources */, 51E4993F24A8713B00B667CB /* ArticleStatusSyncTimer.swift in Sources */, 51E4993724A8680E00B667CB /* Reachability.swift in Sources */, 51B80F4424BE58BF00C6C32D /* SharingServiceDelegate.swift in Sources */, @@ -5407,6 +5432,7 @@ 1769E32724BC5B6C000E1E8E /* AddAccountModel.swift in Sources */, 1729529424AA1CAA00D65E66 /* AdvancedPreferencesView.swift in Sources */, 5177470424B2657F00EB0F74 /* TimelineToolbarModifier.swift in Sources */, + 51A8001324CA0FC700F41F1D /* Sink.swift in Sources */, 51E4992D24A8676300B667CB /* FetchRequestOperation.swift in Sources */, 51E4992424A8098400B667CB /* SmartFeedPasteboardWriter.swift in Sources */, 51E4991424A808FF00B667CB /* ArticleStringFormatter.swift in Sources */,