Rewrite Sidebar select next unread in Combine

This commit is contained in:
Maurice Parker 2020-07-23 16:27:54 -05:00
parent 36aba33c3d
commit 75b9264d44
6 changed files with 587 additions and 58 deletions

View File

@ -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<S: Subscriber> {
private let lock = NSRecursiveLock()
private var buffer = [S.Input]()
private let subscriber: S
private var completion: Subscribers.Completion<S.Failure>?
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<S.Failure>) {
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

View File

@ -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<Upstream: Publisher, Downstream: Subscriber>: Subscriber {
typealias TransformFailure = (Upstream.Failure) -> Downstream.Failure?
typealias TransformOutput = (Upstream.Output) -> Downstream.Input?
private(set) var buffer: DemandBuffer<Downstream>
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<Upstream.Failure>) {
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

View File

@ -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: Publisher, Result>(_ other: Other,
resultSelector: @escaping (Output, Other.Output) -> Result)
-> Publishers.WithLatestFrom<Self, Other, Result> {
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: Publisher, Other1: Publisher, Result>(_ other: Other,
_ other1: Other1,
resultSelector: @escaping (Output, (Other.Output, Other1.Output)) -> Result)
-> Publishers.WithLatestFrom<Self, AnyPublisher<(Other.Output, Other1.Output), Self.Failure>, 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: Publisher, Other1: Publisher, Other2: Publisher, Result>(_ other: Other,
_ other1: Other1,
_ other2: Other2,
resultSelector: @escaping (Output, (Other.Output, Other1.Output, Other2.Output)) -> Result)
-> Publishers.WithLatestFrom<Self, AnyPublisher<(Other.Output, Other1.Output, Other2.Output), Self.Failure>, 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: Publisher>(_ other: Other)
-> Publishers.WithLatestFrom<Self, Other, Other.Output> {
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: Publisher, Other1: Publisher>(_ other: Other,
_ other1: Other1)
-> Publishers.WithLatestFrom<Self, AnyPublisher<(Other.Output, Other1.Output), Self.Failure>, (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: Publisher, Other1: Publisher, Other2: Publisher>(_ other: Other,
_ other1: Other1,
_ other2: Other2)
-> Publishers.WithLatestFrom<Self, AnyPublisher<(Other.Output, Other1.Output, Other2.Output), Self.Failure>, (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<Upstream: Publisher,
Other: Publisher,
Output>: 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<S: Subscriber>(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<Downstream: Subscriber>: Combine.Subscription, CustomStringConvertible where Downstream.Input == Output, Downstream.Failure == Failure {
private let resultSelector: ResultSelector
private var sink: Sink<Upstream, Downstream>?
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<Other.Output, Other.Failure>(
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

View File

@ -57,7 +57,7 @@ final class SceneModel: ObservableObject {
func goToNextUnread() {
if !timelineModel.goToNextUnread() {
timelineModel.isSelectNextUnread = true
sidebarModel.goToNextUnread()
sidebarModel.selectNextUnread.send(true)
}
}

View File

@ -21,6 +21,7 @@ class SidebarModel: ObservableObject, UndoableCommandRunner {
weak var delegate: SidebarModelDelegate?
var sidebarItemsPublisher: AnyPublisher<[SidebarItem], Never>?
var selectNextUnread = PassthroughSubject<Bool, Never>()
@Published var selectedFeedIdentifiers = Set<FeedIdentifier>()
@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<Article>()
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>) -> [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
}
}

View File

@ -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 = "<group>"; };
51A5769524AE617200078888 /* ArticleContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleContainerView.swift; sourceTree = "<group>"; };
51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddWebFeedDefaultContainer.swift; sourceTree = "<group>"; };
51A8001124CA0FC700F41F1D /* Sink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sink.swift; sourceTree = "<group>"; };
51A8001424CA0FEC00F41F1D /* DemandBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemandBuffer.swift; sourceTree = "<group>"; };
51A8FFEC24CA0CF400F41F1D /* WIthLatestFrom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WIthLatestFrom.swift; sourceTree = "<group>"; };
51A9A5E32380C8870033AADF /* ShareFolderPickerAccountCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ShareFolderPickerAccountCell.xib; sourceTree = "<group>"; };
51A9A5E52380C8B20033AADF /* ShareFolderPickerFolderCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ShareFolderPickerFolderCell.xib; sourceTree = "<group>"; };
51A9A5E72380CA130033AADF /* ShareFolderPickerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareFolderPickerCell.swift; sourceTree = "<group>"; };
@ -3016,6 +3025,16 @@
path = Article;
sourceTree = "<group>";
};
51A8001024CA0FAE00F41F1D /* CombineExt */ = {
isa = PBXGroup;
children = (
51A8001424CA0FEC00F41F1D /* DemandBuffer.swift */,
51A8001124CA0FC700F41F1D /* Sink.swift */,
51A8FFEC24CA0CF400F41F1D /* WIthLatestFrom.swift */,
);
path = CombineExt;
sourceTree = "<group>";
};
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 */,