mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Implement Timeline multiselect
This commit is contained in:
@@ -12,10 +12,10 @@ import Articles
|
||||
struct ArticleContainerView: View {
|
||||
|
||||
@EnvironmentObject private var sceneModel: SceneModel
|
||||
var article: Article
|
||||
var articles: [Article]
|
||||
|
||||
@ViewBuilder var body: some View {
|
||||
ArticleView(sceneModel: sceneModel, article: article)
|
||||
ArticleView(sceneModel: sceneModel, articles: articles)
|
||||
.modifier(ArticleToolbarModifier())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
//
|
||||
// ArticleManager.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Maurice Parker on 7/9/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
|
||||
protocol ArticleManager: class {
|
||||
var currentArticle: Article? { get }
|
||||
}
|
||||
@@ -33,7 +33,7 @@ struct ArticleToolbarModifier: ViewModifier {
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .bottomBar) {
|
||||
Button(action: { sceneModel.toggleReadForCurrentArticle() }, label: {
|
||||
Button(action: { }, label: {
|
||||
if sceneModel.readButtonState == .on {
|
||||
AppAssets.readClosedImage
|
||||
} else {
|
||||
@@ -49,7 +49,7 @@ struct ArticleToolbarModifier: ViewModifier {
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .bottomBar) {
|
||||
Button(action: { sceneModel.toggleStarForCurrentArticle() }, label: {
|
||||
Button(action: { }, label: {
|
||||
if sceneModel.starButtonState == .on {
|
||||
AppAssets.starClosedImage
|
||||
} else {
|
||||
|
||||
@@ -21,23 +21,17 @@ final class SceneModel: ObservableObject {
|
||||
private var refreshProgressModel: RefreshProgressModel? = nil
|
||||
private var articleIconSchemeHandler: ArticleIconSchemeHandler? = nil
|
||||
|
||||
var webViewProvider: WebViewProvider? = nil
|
||||
|
||||
var undoManager: UndoManager?
|
||||
var undoableCommands = [UndoableCommand]()
|
||||
|
||||
var sidebarModel: SidebarModel?
|
||||
var timelineModel: TimelineModel?
|
||||
var articleManager: ArticleManager?
|
||||
|
||||
var currentArticle: Article? {
|
||||
return articleManager?.currentArticle
|
||||
}
|
||||
private(set) var webViewProvider: WebViewProvider? = nil
|
||||
private(set) var sidebarModel = SidebarModel()
|
||||
private(set) var timelineModel = TimelineModel()
|
||||
|
||||
// MARK: Initialization API
|
||||
|
||||
/// Prepares the SceneModel to be used in the views
|
||||
func startup() {
|
||||
sidebarModel.delegate = self
|
||||
timelineModel.delegate = self
|
||||
|
||||
self.refreshProgressModel = RefreshProgressModel()
|
||||
self.refreshProgressModel!.$state.assign(to: self.$refreshProgressState)
|
||||
|
||||
@@ -48,58 +42,20 @@ final class SceneModel: ObservableObject {
|
||||
}
|
||||
|
||||
// MARK: Article Management API
|
||||
|
||||
/// Toggles the read indicator for the currently viewable article
|
||||
func toggleReadForCurrentArticle() {
|
||||
if let article = articleManager?.currentArticle {
|
||||
toggleRead(article)
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggles the read indicator for the given article
|
||||
func toggleRead(_ article: Article) {
|
||||
guard !article.status.read || article.isAvailableToMarkUnread else { return }
|
||||
markArticles([article], statusKey: .read, flag: !article.status.read)
|
||||
}
|
||||
|
||||
/// Toggles the star indicator for the currently viewable article
|
||||
func toggleStarForCurrentArticle() {
|
||||
if let article = articleManager?.currentArticle {
|
||||
toggleStar(article)
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggles the star indicator for the given article
|
||||
func toggleStar(_ article: Article) {
|
||||
markArticles([article], statusKey: .starred, flag: !article.status.starred)
|
||||
}
|
||||
|
||||
/// Retrieves the article before the given article in the Timeline
|
||||
func findPrevArticle(_ article: Article) -> Article? {
|
||||
return timelineModel?.findPrevArticle(article)
|
||||
return timelineModel.findPrevArticle(article)
|
||||
}
|
||||
|
||||
/// Retrieves the article after the given article in the Timeline
|
||||
func findNextArticle(_ article: Article) -> Article? {
|
||||
return timelineModel?.findNextArticle(article)
|
||||
}
|
||||
|
||||
/// Marks the article as read and selects it in the Timeline. Don't call until after the ArticleManager article has been set.
|
||||
func updateArticleSelection() {
|
||||
guard let article = currentArticle else { return }
|
||||
|
||||
timelineModel?.selectArticle(article)
|
||||
|
||||
if article.status.read {
|
||||
updateArticleState()
|
||||
} else {
|
||||
markArticles([article], statusKey: .read, flag: true)
|
||||
}
|
||||
return timelineModel.findNextArticle(article)
|
||||
}
|
||||
|
||||
/// Returns the article with the given articleID
|
||||
func articleFor(_ articleID: String) -> Article? {
|
||||
return timelineModel?.articleFor(articleID)
|
||||
return timelineModel.articleFor(articleID)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -124,19 +80,6 @@ extension SceneModel: TimelineModelDelegate {
|
||||
|
||||
}
|
||||
|
||||
// MARK: UndoableCommandRunner
|
||||
|
||||
extension SceneModel: UndoableCommandRunner {
|
||||
|
||||
func markArticlesWithUndo(_ articles: [Article], statusKey: ArticleStatus.Key, flag: Bool) {
|
||||
guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articles, statusKey: statusKey, flag: flag, undoManager: undoManager) else {
|
||||
return
|
||||
}
|
||||
runCommand(markReadCommand)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private extension SceneModel {
|
||||
@@ -144,30 +87,39 @@ private extension SceneModel {
|
||||
// MARK: Notifications
|
||||
|
||||
@objc func statusesDidChange(_ note: Notification) {
|
||||
guard let article = currentArticle, let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set<String> else {
|
||||
guard let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set<String> else {
|
||||
return
|
||||
}
|
||||
if articleIDs.contains(article.articleID) {
|
||||
let selectedArticleIDs = timelineModel.selectedArticles.map { $0.articleID }
|
||||
if !articleIDs.intersection(selectedArticleIDs).isEmpty {
|
||||
updateArticleState()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: State Updates
|
||||
// MARK: Button State Updates
|
||||
|
||||
func updateArticleState() {
|
||||
guard let article = currentArticle else {
|
||||
let articles = timelineModel.selectedArticles
|
||||
|
||||
guard !articles.isEmpty else {
|
||||
readButtonState = nil
|
||||
starButtonState = nil
|
||||
return
|
||||
}
|
||||
|
||||
if article.isAvailableToMarkUnread {
|
||||
readButtonState = article.status.read ? .off : .on
|
||||
if articles.anyArticleIsUnread() {
|
||||
readButtonState = .on
|
||||
} else if articles.anyArticleIsReadAndCanMarkUnread() {
|
||||
readButtonState = .off
|
||||
} else {
|
||||
readButtonState = nil
|
||||
}
|
||||
|
||||
starButtonState = article.status.starred ? .on : .off
|
||||
if articles.anyArticleIsUnstarred() {
|
||||
starButtonState = .on
|
||||
} else {
|
||||
starButtonState = .off
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import SwiftUI
|
||||
|
||||
struct SceneNavigationView: View {
|
||||
|
||||
@Environment(\.undoManager) var undoManager
|
||||
@StateObject private var sceneModel = SceneModel()
|
||||
@State private var showSheet: Bool = false
|
||||
@State private var sheetToShow: ToolbarSheets = .none
|
||||
@@ -49,7 +48,6 @@ struct SceneNavigationView: View {
|
||||
}
|
||||
.environmentObject(sceneModel)
|
||||
.onAppear {
|
||||
sceneModel.undoManager = undoManager
|
||||
sceneModel.startup()
|
||||
}
|
||||
.sheet(isPresented: $showSheet, onDismiss: { sheetToShow = .none }) {
|
||||
@@ -100,7 +98,7 @@ struct SceneNavigationView: View {
|
||||
}).help("Go to Next Unread").padding(.trailing, 40)
|
||||
}
|
||||
ToolbarItem {
|
||||
Button(action: { sceneModel.toggleReadForCurrentArticle() }, label: {
|
||||
Button(action: { }, label: {
|
||||
if sceneModel.readButtonState == .on {
|
||||
AppAssets.readClosedImage
|
||||
} else {
|
||||
@@ -111,7 +109,7 @@ struct SceneNavigationView: View {
|
||||
.help(sceneModel.readButtonState == .on ? "Mark as Unread" : "Mark as Read")
|
||||
}
|
||||
ToolbarItem {
|
||||
Button(action: { sceneModel.toggleStarForCurrentArticle() }, label: {
|
||||
Button(action: { }, label: {
|
||||
if sceneModel.starButtonState == .on {
|
||||
AppAssets.starClosedImage
|
||||
} else {
|
||||
|
||||
@@ -10,8 +10,8 @@ import SwiftUI
|
||||
|
||||
struct SidebarContainerView: View {
|
||||
|
||||
@Environment(\.undoManager) var undoManager
|
||||
@EnvironmentObject private var sceneModel: SceneModel
|
||||
@StateObject private var sidebarModel = SidebarModel()
|
||||
|
||||
@State private var showSettings: Bool = false
|
||||
|
||||
@@ -19,12 +19,11 @@ struct SidebarContainerView: View {
|
||||
SidebarView()
|
||||
.modifier(SidebarToolbarModifier())
|
||||
.modifier(SidebarListStyleModifier())
|
||||
.environmentObject(sidebarModel)
|
||||
.environmentObject(sceneModel.sidebarModel)
|
||||
.navigationTitle(Text("Feeds"))
|
||||
.onAppear {
|
||||
sceneModel.sidebarModel = sidebarModel
|
||||
sidebarModel.delegate = sceneModel
|
||||
sidebarModel.rebuildSidebarItems()
|
||||
sceneModel.sidebarModel.undoManager = undoManager
|
||||
sceneModel.sidebarModel.rebuildSidebarItems()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ protocol SidebarModelDelegate: class {
|
||||
func unreadCount(for: Feed) -> Int
|
||||
}
|
||||
|
||||
class SidebarModel: ObservableObject {
|
||||
class SidebarModel: ObservableObject, UndoableCommandRunner {
|
||||
|
||||
weak var delegate: SidebarModelDelegate?
|
||||
|
||||
@@ -27,6 +27,9 @@ class SidebarModel: ObservableObject {
|
||||
private var selectedFeedIdentifiersCancellable: AnyCancellable?
|
||||
private var selectedFeedIdentifierCancellable: AnyCancellable?
|
||||
|
||||
var undoManager: UndoManager?
|
||||
var undoableCommands = [UndoableCommand]()
|
||||
|
||||
init() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidInitialize(_:)), name: .UnreadCountDidInitialize, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(containerChildrenDidChange(_:)), name: .ChildrenDidChange, object: nil)
|
||||
@@ -49,7 +52,6 @@ class SidebarModel: ObservableObject {
|
||||
self.selectedFeeds = [feed]
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: API
|
||||
|
||||
@@ -17,8 +17,7 @@ struct SidebarView: View {
|
||||
@EnvironmentObject private var sidebarModel: SidebarModel
|
||||
@State var navigate = false
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
@ViewBuilder var body: some View {
|
||||
#if os(macOS)
|
||||
ZStack {
|
||||
NavigationLink(destination: TimelineContainerView(feeds: sidebarModel.selectedFeeds), isActive: $navigate) {
|
||||
|
||||
@@ -11,20 +11,19 @@ import Account
|
||||
|
||||
struct TimelineContainerView: View {
|
||||
|
||||
@Environment(\.undoManager) var undoManager
|
||||
@EnvironmentObject private var sceneModel: SceneModel
|
||||
@StateObject private var timelineModel = TimelineModel()
|
||||
var feeds: [Feed]? = nil
|
||||
|
||||
@ViewBuilder var body: some View {
|
||||
if let feeds = feeds {
|
||||
TimelineView()
|
||||
.modifier(TimelineTitleModifier(title: timelineModel.nameForDisplay))
|
||||
.modifier(TimelineTitleModifier(title: sceneModel.timelineModel.nameForDisplay))
|
||||
.modifier(TimelineToolbarModifier())
|
||||
.environmentObject(timelineModel)
|
||||
.environmentObject(sceneModel.timelineModel)
|
||||
.onAppear {
|
||||
sceneModel.timelineModel = timelineModel
|
||||
timelineModel.delegate = sceneModel
|
||||
timelineModel.rebuildTimelineItems(feeds: feeds)
|
||||
sceneModel.timelineModel.undoManager = undoManager
|
||||
sceneModel.timelineModel.rebuildTimelineItems(feeds: feeds)
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import RSCore
|
||||
import Account
|
||||
import Articles
|
||||
@@ -15,13 +16,22 @@ protocol TimelineModelDelegate: class {
|
||||
func timelineRequestedWebFeedSelection(_: TimelineModel, webFeed: WebFeed)
|
||||
}
|
||||
|
||||
class TimelineModel: ObservableObject {
|
||||
class TimelineModel: ObservableObject, UndoableCommandRunner {
|
||||
|
||||
weak var delegate: TimelineModelDelegate?
|
||||
|
||||
@Published var nameForDisplay = ""
|
||||
@Published var timelineItems = [TimelineItem]()
|
||||
@Published var selectedArticleIDs = Set<String>()
|
||||
@Published var selectedArticleID: String? = .none
|
||||
@Published var selectedArticles = [Article]()
|
||||
|
||||
var undoManager: UndoManager?
|
||||
var undoableCommands = [UndoableCommand]()
|
||||
|
||||
private var selectedArticleIDsCancellable: AnyCancellable?
|
||||
private var selectedArticleIDCancellable: AnyCancellable?
|
||||
|
||||
private var fetchSerialNumber = 0
|
||||
private let fetchRequestQueue = FetchRequestQueue()
|
||||
private var exceptionArticleFetcher: ArticleFetcher?
|
||||
@@ -60,6 +70,20 @@ class TimelineModel: ObservableObject {
|
||||
|
||||
init() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil)
|
||||
|
||||
// TODO: This should be rewritten to use Combine correctly
|
||||
selectedArticleIDsCancellable = $selectedArticleIDs.sink { [weak self] articleIDs in
|
||||
guard let self = self else { return }
|
||||
self.selectedArticles = articleIDs.compactMap { self.idToArticleDictionary[$0] }
|
||||
}
|
||||
|
||||
// TODO: This should be rewritten to use Combine correctly
|
||||
selectedArticleIDCancellable = $selectedArticleID.sink { [weak self] articleID in
|
||||
guard let self = self else { return }
|
||||
if let articleID = articleID, let article = self.idToArticleDictionary[articleID] {
|
||||
self.selectedArticles = [article]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: API
|
||||
@@ -73,14 +97,6 @@ class TimelineModel: ObservableObject {
|
||||
fetchAndReplaceArticlesAsync(feeds: feeds)
|
||||
}
|
||||
|
||||
// TODO: Replace this with ScrollViewReader if we have to keep it
|
||||
func loadMoreTimelineItemsIfNecessary(_ timelineItem: TimelineItem) {
|
||||
let thresholdIndex = timelineItems.index(timelineItems.endIndex, offsetBy: -10)
|
||||
if timelineItems.firstIndex(where: { $0.id == timelineItem.id }) == thresholdIndex {
|
||||
nextBatch()
|
||||
}
|
||||
}
|
||||
|
||||
func articleFor(_ articleID: String) -> Article? {
|
||||
return idToArticleDictionary[articleID]
|
||||
}
|
||||
@@ -182,18 +198,9 @@ private extension TimelineModel {
|
||||
|
||||
func replaceArticles(with unsortedArticles: Set<Article>) {
|
||||
articles = Array(unsortedArticles).sortedByDate(sortDirection ? .orderedDescending : .orderedAscending, groupByFeed: groupByFeed)
|
||||
timelineItems = [TimelineItem]()
|
||||
nextBatch()
|
||||
timelineItems = articles.map { TimelineItem(article: $0) }
|
||||
// TODO: Update unread counts and other item done in didSet on AppKit
|
||||
}
|
||||
|
||||
func nextBatch() {
|
||||
let rangeEndIndex = timelineItems.endIndex + 50 > articles.endIndex ? articles.endIndex : timelineItems.endIndex + 50
|
||||
let range = timelineItems.endIndex..<rangeEndIndex
|
||||
for i in range {
|
||||
timelineItems.append(TimelineItem(article: articles[i]))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
|
||||
|
||||
@@ -11,25 +11,41 @@ import SwiftUI
|
||||
struct TimelineView: View {
|
||||
|
||||
@EnvironmentObject private var timelineModel: TimelineModel
|
||||
@State var navigate = false
|
||||
|
||||
var body: some View {
|
||||
List(timelineModel.timelineItems) { timelineItem in
|
||||
ZStack {
|
||||
TimelineItemView(timelineItem: timelineItem)
|
||||
.onAppear {
|
||||
timelineModel.loadMoreTimelineItemsIfNecessary(timelineItem)
|
||||
}
|
||||
NavigationLink(destination: (ArticleContainerView(article: timelineItem.article))) {
|
||||
EmptyView()
|
||||
}.buttonStyle(PlainButtonStyle())
|
||||
@ViewBuilder var body: some View {
|
||||
#if os(macOS)
|
||||
ZStack {
|
||||
NavigationLink(destination: ArticleContainerView(articles: timelineModel.selectedArticles), isActive: $navigate) {
|
||||
EmptyView()
|
||||
}.hidden()
|
||||
List(timelineModel.timelineItems, selection: $timelineModel.selectedArticleIDs) { timelineItem in
|
||||
buildTimelineItemNavigation(timelineItem)
|
||||
}
|
||||
}
|
||||
.onChange(of: timelineModel.selectedArticleIDs) { value in
|
||||
navigate = !timelineModel.selectedArticleIDs.isEmpty
|
||||
}
|
||||
#else
|
||||
List(timelineModel.timelineItems) { timelineItem in
|
||||
buildTimelineItemNavigation(timelineItem)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// var body: some View {
|
||||
// List(timelineModel.timelineItems) { timelineItem in
|
||||
// TimelineItemView(timelineItem: timelineItem)
|
||||
// }
|
||||
// }
|
||||
|
||||
func buildTimelineItemNavigation(_ timelineItem: TimelineItem) -> some View {
|
||||
#if os(macOS)
|
||||
return TimelineItemView(timelineItem: timelineItem) //.tag(timelineItem.article.articleID)
|
||||
#else
|
||||
return ZStack {
|
||||
TimelineItemView(timelineItem: timelineItem)
|
||||
NavigationLink(destination: ArticleContainerView(articles: timelineModel.selectedArticles),
|
||||
tag: timelineItem.article.articleID,
|
||||
selection: $timelineModel.selectedArticleID) {
|
||||
EmptyView()
|
||||
}.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user