Merge pull request #3288 from omnivore-app/feat/ios-accesibility-issues
iOS 1.40
|
|
@ -7,7 +7,7 @@ import com.google.gson.Gson
|
|||
|
||||
enum class WebFont(val displayText: String, val rawValue: String) {
|
||||
INTER("Inter", "Inter"),
|
||||
SYSTEM("System Default", "unset"),
|
||||
SYSTEM("System Default", "system-ui"),
|
||||
OPEN_DYSLEXIC("Open Dyslexic", "OpenDyslexic"),
|
||||
MERRIWEATHER("Merriweather", "Merriweather"),
|
||||
LORA("Lora", "Lora"),
|
||||
|
|
|
|||
|
|
@ -189,6 +189,24 @@
|
|||
"version" : "1.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-async-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
||||
"state" : {
|
||||
"revision" : "da4e36f86544cdf733a40d59b3a2267e3a7bbf36",
|
||||
"version" : "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "d029d9d39c87bed85b1c50adee7c41795261a192",
|
||||
"version" : "1.0.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-graphql",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ let package = Package(
|
|||
"Valet",
|
||||
.product(name: "SwiftGraphQL", package: "swift-graphql"),
|
||||
"Models",
|
||||
"Utils"
|
||||
"Utils",
|
||||
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms")
|
||||
]
|
||||
),
|
||||
.testTarget(name: "ServicesTests", dependencies: ["Services"]),
|
||||
|
|
@ -73,7 +74,8 @@ var dependencies: [Package.Dependency] {
|
|||
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.0.0"),
|
||||
.package(url: "https://github.com/exyte/PopupView.git", from: "2.6.0"),
|
||||
.package(url: "https://github.com/PostHog/posthog-ios.git", from: "2.0.0"),
|
||||
.package(url: "https://github.com/nathantannar4/Transmission", from: "1.0.1")
|
||||
.package(url: "https://github.com/nathantannar4/Transmission", from: "1.0.1"),
|
||||
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0")
|
||||
]
|
||||
// Comment out following line for macOS build
|
||||
deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", from: "13.1.0"))
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ public struct ShareExtensionView: View {
|
|||
Spacer()
|
||||
}
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(hasNoteText ?
|
||||
Color.appGrayTextContrast : Color.extensionTextSubtle
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,32 @@
|
|||
#if os(iOS)
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Transmission
|
||||
import Views
|
||||
|
||||
// swiftlint:disable file_length type_body_length
|
||||
public struct ExpandedAudioPlayer: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let delete: (_: NSManagedObjectID) -> Void
|
||||
let archive: (_: NSManagedObjectID) -> Void
|
||||
let viewArticle: (_: NSManagedObjectID) -> Void
|
||||
|
||||
@State var showVoiceSheet = false
|
||||
@State var tabIndex: Int = 0
|
||||
@State var showLabelsModal = false
|
||||
@State var showNotebookView = false
|
||||
|
||||
@State var showOperationToast = false
|
||||
@State var operationStatus: OperationStatus = .none
|
||||
@State var operationMessage: String?
|
||||
|
||||
var playPauseButtonImage: String {
|
||||
switch audioController.state {
|
||||
|
|
@ -54,70 +68,83 @@
|
|||
.aspectRatio(contentMode: .fit)
|
||||
.font(Font.title.weight(.light))
|
||||
}
|
||||
))
|
||||
)
|
||||
.buttonStyle(.plain)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var closeButton: some View {
|
||||
Button(
|
||||
action: {
|
||||
dismiss()
|
||||
},
|
||||
label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color.appGrayText)
|
||||
.frame(width: 36, height: 36)
|
||||
.opacity(0.1)
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color.appGrayText)
|
||||
.frame(width: 36, height: 36)
|
||||
.opacity(0.1)
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.appCallout)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var menuButton: some View {
|
||||
Menu {
|
||||
Menu(String(format: "Playback Speed (%.1f×)", audioController.playbackRate)) {
|
||||
playbackRateButton(rate: 0.8, title: "0.8×", selected: audioController.playbackRate == 0.8)
|
||||
playbackRateButton(rate: 0.9, title: "0.9×", selected: audioController.playbackRate == 0.9)
|
||||
playbackRateButton(rate: 1.0, title: "1.0×", selected: audioController.playbackRate == 1.0)
|
||||
playbackRateButton(rate: 1.1, title: "1.1×", selected: audioController.playbackRate == 1.1)
|
||||
playbackRateButton(rate: 1.2, title: "1.2×", selected: audioController.playbackRate == 1.2)
|
||||
playbackRateButton(rate: 1.3, title: "1.3×", selected: audioController.playbackRate == 1.3)
|
||||
playbackRateButton(rate: 1.5, title: "1.5×", selected: audioController.playbackRate == 1.5)
|
||||
playbackRateButton(rate: 1.7, title: "1.7×", selected: audioController.playbackRate == 1.7)
|
||||
playbackRateButton(rate: 2.0, title: "2.0×", selected: audioController.playbackRate == 2.0)
|
||||
playbackRateButton(rate: 2.2, title: "2.2×", selected: audioController.playbackRate == 2.2)
|
||||
playbackRateButton(rate: 2.5, title: "2.5×", selected: audioController.playbackRate == 2.5)
|
||||
}
|
||||
Button(action: { showVoiceSheet = true }, label: { Label("Change Voice", systemImage: "person.wave.2") })
|
||||
Button(action: { viewArticle() }, label: { Label("View Article", systemImage: "book") })
|
||||
Button(action: { audioController.stop() }, label: { Label("Stop", systemImage: "xmark.circle") })
|
||||
Button(action: { dismiss() }, label: { Label(LocalText.dismissButton, systemImage: "arrow.down.to.line") })
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color.appGrayText)
|
||||
.frame(width: 36, height: 36)
|
||||
.opacity(0.1)
|
||||
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.appCallout)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.appCallout)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
func viewArticle() {
|
||||
if let objectID = audioController.itemAudioProperties?.objectID {
|
||||
NSNotification.pushReaderItem(objectID: objectID)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
dismiss()
|
||||
var toolbarItems: some ToolbarContent {
|
||||
ToolbarItemGroup(placement: .barTrailing) {
|
||||
Button(
|
||||
action: { performDelete() },
|
||||
label: {
|
||||
Image
|
||||
.toolbarTrash
|
||||
.foregroundColor(Color.toolbarItemForeground)
|
||||
}
|
||||
).padding(.trailing, 5)
|
||||
|
||||
if !(audioController.itemAudioProperties?.isArchived ?? false) {
|
||||
Button(
|
||||
action: { performArchive() },
|
||||
label: {
|
||||
if audioController.itemAudioProperties?.isArchived ?? false {
|
||||
Image
|
||||
.toolbarUnarchive
|
||||
.foregroundColor(Color.toolbarItemForeground)
|
||||
} else {
|
||||
Image
|
||||
.toolbarArchive
|
||||
.foregroundColor(Color.toolbarItemForeground)
|
||||
}
|
||||
}
|
||||
).padding(.trailing, 5)
|
||||
}
|
||||
|
||||
// Menu(content: {
|
||||
// Button(
|
||||
// action: { performViewArticle() },
|
||||
// label: {
|
||||
// Text("View article")
|
||||
// }
|
||||
// )
|
||||
// }, label: {
|
||||
// Image
|
||||
// .utilityMenu
|
||||
// .foregroundColor(ThemeManager.currentTheme.toolbarColor)
|
||||
// })
|
||||
}
|
||||
}
|
||||
|
||||
func performViewArticle() {
|
||||
if let objectID = audioController.itemAudioProperties?.objectID {
|
||||
viewArticle(objectID)
|
||||
}
|
||||
}
|
||||
|
||||
func performDelete() {
|
||||
if let objectID = audioController.itemAudioProperties?.objectID {
|
||||
delete(objectID)
|
||||
}
|
||||
}
|
||||
|
||||
func performArchive() {
|
||||
if let objectID = audioController.itemAudioProperties?.objectID {
|
||||
archive(objectID)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -297,6 +324,7 @@
|
|||
.resizable()
|
||||
.frame(width: 18, height: 18)
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
.padding(.trailing, 32)
|
||||
|
||||
Button(
|
||||
|
|
@ -307,6 +335,7 @@
|
|||
.font(Font.title.weight(.light))
|
||||
}
|
||||
)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 16, height: 16)
|
||||
.padding(.trailing, 16)
|
||||
.foregroundColor(.themeAudioPlayerGray)
|
||||
|
|
@ -324,6 +353,7 @@
|
|||
.font(Font.title.weight(.light))
|
||||
}
|
||||
)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 16, height: 16)
|
||||
.padding(.trailing, 32 - 4) // -4 to account for the menu touch padding
|
||||
.foregroundColor(.themeAudioPlayerGray)
|
||||
|
|
@ -343,6 +373,7 @@
|
|||
Text("\(String(format: "%.1f", audioController.playbackRate))×")
|
||||
.font(.appCaption)
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
.padding(4)
|
||||
|
||||
Spacer()
|
||||
|
|
@ -353,6 +384,13 @@
|
|||
|
||||
func playerContent(_: LinkedItemAudioProperties) -> some View {
|
||||
ZStack {
|
||||
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $showOperationToast) {
|
||||
OperationToast(operationMessage: $operationMessage, showOperationToast: $showOperationToast, operationStatus: $operationStatus)
|
||||
.offset(y: -90)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}.buttonStyle(.plain)
|
||||
|
||||
if audioController.playbackError {
|
||||
Text("There was an error playing back your audio.").foregroundColor(Color.red).font(.footnote)
|
||||
}
|
||||
|
|
@ -416,9 +454,12 @@
|
|||
NavigationView {
|
||||
innerBody
|
||||
.background(Color.themeDisabledBG)
|
||||
.navigationTitle(audioController.itemAudioProperties?.title ?? LocalText.textToSpeechGeneric)
|
||||
.navigationBarItems(trailing: Button(action: { dismiss() }, label: { Text("Hide") }))
|
||||
.navigationTitle("")
|
||||
.navigationBarItems(leading: Button(action: { dismiss() }, label: { closeButton }))
|
||||
.navigationBarTitleDisplayMode(NavigationBarItem.TitleDisplayMode.inline)
|
||||
.toolbar {
|
||||
toolbarItems
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@
|
|||
.aspectRatio(contentMode: .fit)
|
||||
.font(Font.title.weight(.light))
|
||||
}
|
||||
))
|
||||
).buttonStyle(.plain)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,6 +79,7 @@
|
|||
}
|
||||
}
|
||||
)
|
||||
.buttonStyle(.plain)
|
||||
.background(Color.clear)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@
|
|||
.padding()
|
||||
}
|
||||
)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 16, height: 16, alignment: .center)
|
||||
.onTapGesture { isContextMenuOpen = true }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,10 @@ struct NoteItemParams: Identifiable {
|
|||
buffer += "by: \(author)\n"
|
||||
}
|
||||
if let url = item.pageURLString {
|
||||
buffer += "url: \(url)\n"
|
||||
buffer += "\(url)\n"
|
||||
}
|
||||
if let noteText = item.noteText {
|
||||
buffer += "\n\n\(noteText)\n\n"
|
||||
}
|
||||
return buffer + "\n\n" + highlightItems.map { highlightAsMarkdown(item: $0) }.lazy.joined(separator: "\n\n")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ import Views
|
|||
|
||||
@AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue
|
||||
|
||||
var limit = 6
|
||||
var cursor: String?
|
||||
var totalCount: Int?
|
||||
|
||||
// These are used to make sure we handle search result
|
||||
// responses in the right order
|
||||
|
|
@ -25,6 +27,7 @@ import Views
|
|||
|
||||
func setItems(_ context: NSManagedObjectContext, _ items: [Models.LibraryItem]) {
|
||||
self.items = items
|
||||
|
||||
if let filter = FeaturedItemFilter(rawValue: featureFilter) {
|
||||
updateFeatureFilter(context: context, filter: filter)
|
||||
}
|
||||
|
|
@ -54,11 +57,17 @@ import Views
|
|||
return
|
||||
}
|
||||
|
||||
let queryResult = try? await dataService.loadLinkedItems(
|
||||
limit: 10,
|
||||
searchQuery: searchQuery(filterState),
|
||||
cursor: isRefresh ? nil : loadCursor ?? cursor
|
||||
)
|
||||
var queryResult: LinkedItemQueryResult?
|
||||
|
||||
do {
|
||||
queryResult = try await dataService.loadLinkedItems(
|
||||
limit: limit,
|
||||
searchQuery: searchQuery(filterState),
|
||||
cursor: isRefresh ? nil : loadCursor ?? cursor
|
||||
)
|
||||
} catch {
|
||||
print("SYNCCURSOR ERROR loading library items: ", error)
|
||||
}
|
||||
|
||||
if let appliedFilter = filterState.appliedFilter, let queryResult = queryResult {
|
||||
let newItems: [Models.LibraryItem] = {
|
||||
|
|
@ -81,16 +90,16 @@ import Views
|
|||
}
|
||||
|
||||
receivedIdx = thisSearchIdx
|
||||
|
||||
limit = 15 // Once we have one successful fetch we increase the limit
|
||||
cursor = queryResult.cursor
|
||||
if let username = dataService.currentViewer?.username {
|
||||
await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID), username: username)
|
||||
}
|
||||
totalCount = queryResult.totalCount
|
||||
} else {
|
||||
updateFetchController(dataService: dataService, filterState: filterState)
|
||||
}
|
||||
}
|
||||
|
||||
func loadItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool) async {
|
||||
func loadItems(dataService: DataService, filterState: FetcherFilterState, isRefresh: Bool, forceRemote: Bool = false) async {
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask { await self.loadCurrentViewer(dataService: dataService) }
|
||||
group.addTask { await self.loadLabels(dataService: dataService) }
|
||||
|
|
@ -99,7 +108,7 @@ import Views
|
|||
}
|
||||
|
||||
if let appliedFilter = filterState.appliedFilter {
|
||||
let shouldRemoteSearch = items.count < 1 || isRefresh && appliedFilter.shouldRemoteSearch
|
||||
let shouldRemoteSearch = forceRemote || items.count < 1 || isRefresh && appliedFilter.shouldRemoteSearch
|
||||
if shouldRemoteSearch {
|
||||
await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
|
||||
} else {
|
||||
|
|
@ -112,10 +121,32 @@ import Views
|
|||
BadgeCountHandler.updateBadgeCount(dataService: dataService)
|
||||
}
|
||||
|
||||
func loadMoreItems(dataService: DataService, filterState: FetcherFilterState) async {
|
||||
func loadNewItems(dataService: DataService, filterState: FetcherFilterState) async {
|
||||
let lastSyncDate = dataService.lastItemSyncTime
|
||||
_ = try? await dataService.syncLinkedItems(since: lastSyncDate, cursor: nil)
|
||||
updateFetchController(dataService: dataService, filterState: filterState)
|
||||
}
|
||||
|
||||
func loadMoreItems(dataService: DataService, filterState: FetcherFilterState, loadCursor: String? = nil) async {
|
||||
var useCursor = loadCursor
|
||||
if let appliedFilter = filterState.appliedFilter, appliedFilter.shouldRemoteSearch {
|
||||
let idx = max(items.count - 1, 0)
|
||||
await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: false, loadCursor: idx.description)
|
||||
let idx = max(items.count, 0)
|
||||
|
||||
// If the cursor is greater than the index we want to use the cursor instead
|
||||
// this can occur if there are non-contiguous items in our list causing older
|
||||
// items to be synced back into those "holes" in the list
|
||||
if let cursor = cursor, let currentCursor = Int(cursor) {
|
||||
if currentCursor > idx {
|
||||
useCursor = currentCursor.description
|
||||
}
|
||||
}
|
||||
|
||||
await loadSearchQuery(
|
||||
dataService: dataService,
|
||||
filterState: filterState,
|
||||
isRefresh: false,
|
||||
loadCursor: useCursor ?? idx.description
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -179,7 +210,6 @@ import Views
|
|||
sectionNameKeyPath: nil,
|
||||
cacheName: nil
|
||||
)
|
||||
|
||||
guard let fetchedResultsController = fetchedResultsController else {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,11 +62,6 @@ struct LibraryItemGridCardNavigationLink: View {
|
|||
@State private var scale = 1.0
|
||||
|
||||
@ObservedObject var item: Models.LibraryItem
|
||||
|
||||
let actionHandler: (GridCardAction) -> Void
|
||||
|
||||
@Binding var isContextMenuOpen: Bool
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -83,19 +78,12 @@ struct LibraryItemGridCardNavigationLink: View {
|
|||
isPDF: item.isPDF
|
||||
)
|
||||
}, label: {
|
||||
GridCard(item: LibraryItemData.make(from: item), isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler)
|
||||
GridCard(item: LibraryItemData.make(from: item))
|
||||
}
|
||||
)
|
||||
.buttonStyle(.plain)
|
||||
.aspectRatio(1.0, contentMode: .fill)
|
||||
.background(
|
||||
Color.secondarySystemGroupedBackground
|
||||
.onTapGesture {
|
||||
if isContextMenuOpen {
|
||||
isContextMenuOpen = false
|
||||
}
|
||||
}
|
||||
)
|
||||
.background(Color.systemBackground)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import Services
|
|||
class LibrarySyncManager {
|
||||
var syncCursor: String?
|
||||
|
||||
func syncItems(dataService: DataService) async {
|
||||
func syncUpdates(dataService: DataService) async {
|
||||
let syncStart = Date.now
|
||||
let lastSyncDate = dataService.lastItemSyncTime
|
||||
|
||||
|
|
@ -15,10 +15,7 @@ class LibrarySyncManager {
|
|||
|
||||
try? await dataService.syncOfflineItemsWithServerIfNeeded()
|
||||
|
||||
let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate,
|
||||
cursor: nil)
|
||||
|
||||
syncCursor = syncResult?.cursor
|
||||
let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate, cursor: nil)
|
||||
if let syncResult = syncResult, syncResult.hasMore {
|
||||
dataService.syncLinkedItemsInBackground(since: lastSyncDate) {
|
||||
// do nothing
|
||||
|
|
@ -26,15 +23,5 @@ class LibrarySyncManager {
|
|||
} else {
|
||||
dataService.lastItemSyncTime = syncStart
|
||||
}
|
||||
|
||||
// If possible start prefetching new pages in the background
|
||||
if
|
||||
let itemIDs = syncResult?.updatedItemIDs,
|
||||
!itemIDs.isEmpty
|
||||
{
|
||||
Task.detached(priority: .background) {
|
||||
await dataService.prefetchPages(itemIDs: itemIDs, username: "username")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,23 +19,23 @@ struct FiltersHeader: View {
|
|||
viewModel.searchTerm = ""
|
||||
}.frame(maxWidth: reader.size.width * 0.66)
|
||||
} else {
|
||||
if UIDevice.isIPhone {
|
||||
Menu(
|
||||
content: {
|
||||
ForEach(viewModel.filters) { filter in
|
||||
Button(filter.name, action: {
|
||||
viewModel.appliedFilter = filter
|
||||
})
|
||||
}
|
||||
},
|
||||
label: {
|
||||
TextChipButton.makeMenuButton(
|
||||
title: viewModel.appliedFilter?.name ?? "-",
|
||||
color: .systemGray6
|
||||
)
|
||||
// if UIDevice.isIPhone {
|
||||
Menu(
|
||||
content: {
|
||||
ForEach(viewModel.filters.filter { $0.folder == viewModel.currentFolder }) { filter in
|
||||
Button(filter.name, action: {
|
||||
viewModel.appliedFilter = filter
|
||||
})
|
||||
}
|
||||
).buttonStyle(.plain)
|
||||
}
|
||||
},
|
||||
label: {
|
||||
TextChipButton.makeMenuButton(
|
||||
title: viewModel.appliedFilter?.name ?? "-",
|
||||
color: .systemGray6
|
||||
)
|
||||
}
|
||||
).buttonStyle(.plain)
|
||||
// }
|
||||
}
|
||||
Menu(
|
||||
content: {
|
||||
|
|
@ -77,6 +77,50 @@ struct FiltersHeader: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct EmptyState: View {
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
var body: some View {
|
||||
if viewModel.currentFolder == "following" {
|
||||
return AnyView(
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
Text("You don't have any Feed items.")
|
||||
.font(Font.system(size: 18, weight: .bold))
|
||||
|
||||
Text("Add an RSS/Atom feed")
|
||||
.foregroundColor(Color.blue)
|
||||
.onTapGesture {
|
||||
viewModel.showAddFeedView = true
|
||||
}
|
||||
|
||||
Text("Hide the Following tab")
|
||||
.foregroundColor(Color.blue)
|
||||
.onTapGesture {
|
||||
viewModel.showHideFollowingAlert = true
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 400)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
)
|
||||
} else {
|
||||
return AnyView(Group {
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
Text("No results found for this query")
|
||||
.font(Font.system(size: 18, weight: .bold))
|
||||
}
|
||||
.frame(minHeight: 400)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AnimatingCellHeight: AnimatableModifier {
|
||||
var height: CGFloat = 0
|
||||
|
||||
|
|
@ -103,7 +147,6 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@State var isListScrolled = false
|
||||
@State var listTitle = ""
|
||||
@State var isEditMode: EditMode = .inactive
|
||||
@State var showOpenAIVoices = false
|
||||
@State var showExpandedAudioPlayer = false
|
||||
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
|
@ -111,7 +154,6 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@Environment(\.horizontalSizeClass) var horizontalSizeClass
|
||||
|
||||
@AppStorage(UserDefaultKey.homeFeedlayoutPreference.rawValue) var prefersListLayout = true
|
||||
@AppStorage(UserDefaultKey.openAIPrimerDisplayed.rawValue) var openAIPrimerDisplayed = false
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
@State private var selection = Set<String>()
|
||||
|
|
@ -126,7 +168,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
|
||||
var showFeatureCards: Bool {
|
||||
isEditMode == .inactive &&
|
||||
viewModel.listConfig.hasFeatureCards &&
|
||||
(viewModel.currentListConfig?.hasFeatureCards ?? false) &&
|
||||
!viewModel.hideFeatureSection &&
|
||||
viewModel.fetcher.items.count > 0 &&
|
||||
viewModel.searchTerm.isEmpty &&
|
||||
|
|
@ -198,14 +240,44 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
.sheet(item: $viewModel.itemForHighlightsView) { item in
|
||||
NotebookView(viewModel: NotebookViewModel(item: item), hasHighlightMutations: $hasHighlightMutations)
|
||||
}
|
||||
.sheet(isPresented: $showAddFeedView) {
|
||||
NavigationView {
|
||||
LibraryAddFeedView(dismiss: {
|
||||
showAddFeedView = false
|
||||
}, toastOperationHandler: nil)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddLinkView) {
|
||||
NavigationView {
|
||||
LibraryAddLinkView()
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showExpandedAudioPlayer) {
|
||||
ExpandedAudioPlayer()
|
||||
ExpandedAudioPlayer(
|
||||
delete: {
|
||||
showExpandedAudioPlayer = false
|
||||
audioController.stop()
|
||||
viewModel.removeLibraryItem(dataService: dataService, objectID: $0)
|
||||
},
|
||||
archive: {
|
||||
showExpandedAudioPlayer = false
|
||||
audioController.stop()
|
||||
viewModel.setLinkArchived(dataService: dataService, objectID: $0, archived: true)
|
||||
},
|
||||
viewArticle: { itemID in
|
||||
if let article = try? dataService.viewContext.existingObject(with: itemID) as? Models.LibraryItem {
|
||||
viewModel.pushFeedItem(item: article)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.toolbar {
|
||||
toolbarItems
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
|
||||
loadItems(isRefresh: false)
|
||||
Task {
|
||||
await viewModel.loadNewItems(dataService: dataService)
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushJSONArticle"))) { notification in
|
||||
guard let jsonArticle = notification.userInfo?["article"] as? JSONArticle else { return }
|
||||
|
|
@ -218,16 +290,11 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
.fullScreenCover(isPresented: $searchPresented) {
|
||||
LibrarySearchView(homeFeedViewModel: self.viewModel)
|
||||
}
|
||||
.sheet(isPresented: $showAddLinkView) {
|
||||
NavigationView {
|
||||
LibraryAddLinkView()
|
||||
}
|
||||
}
|
||||
.introspectNavigationController { nav in
|
||||
nav.delegate = viewModel
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadFilters(dataService: dataService)
|
||||
if viewModel.appliedFilter == nil {
|
||||
viewModel.setDefaultFilter()
|
||||
}
|
||||
}
|
||||
.environment(\.editMode, self.$isEditMode)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
|
@ -258,53 +325,71 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
Button(
|
||||
action: { isEditMode = isEditMode == .active ? .inactive : .active },
|
||||
label: {
|
||||
Image.selectMultiple
|
||||
Image
|
||||
.selectMultiple
|
||||
.foregroundColor(Color.toolbarItemForeground)
|
||||
}
|
||||
)
|
||||
).buttonStyle(.plain)
|
||||
}
|
||||
if enableGrid {
|
||||
Button(
|
||||
action: { prefersListLayout.toggle() },
|
||||
label: {
|
||||
Label("Toggle Feed Layout", systemImage: prefersListLayout ? "square.grid.2x2" : "list.bullet")
|
||||
Image(systemName: prefersListLayout ? "square.grid.2x2" : "list.bullet")
|
||||
.foregroundColor(Color.toolbarItemForeground)
|
||||
}
|
||||
)
|
||||
).buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Button(
|
||||
action: {
|
||||
if viewModel.folder == "inbox" {
|
||||
if viewModel.currentFolder == "inbox" {
|
||||
showAddLinkView = true
|
||||
} else if viewModel.folder == "following" {
|
||||
} else if viewModel.currentFolder == "following" {
|
||||
showAddFeedView = true
|
||||
}
|
||||
},
|
||||
label: {
|
||||
Image.addLink
|
||||
.foregroundColor(Color.toolbarItemForeground)
|
||||
}
|
||||
)
|
||||
).buttonStyle(.plain)
|
||||
|
||||
Button(
|
||||
action: { searchPresented = true },
|
||||
action: {
|
||||
searchPresented = true
|
||||
isEditMode = .inactive
|
||||
},
|
||||
label: {
|
||||
Image.magnifyingGlass
|
||||
Image
|
||||
.magnifyingGlass
|
||||
.foregroundColor(Color.toolbarItemForeground)
|
||||
}
|
||||
)
|
||||
).buttonStyle(.plain)
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
if isEditMode == .active {
|
||||
Button(action: {
|
||||
viewModel.bulkAction(dataService: dataService, action: .archive, items: Array(selection))
|
||||
isEditMode = .inactive
|
||||
}, label: { Image(systemName: "archivebox") })
|
||||
.padding(.trailing, 10)
|
||||
Button(action: {
|
||||
viewModel.bulkAction(dataService: dataService, action: .delete, items: Array(selection))
|
||||
isEditMode = .inactive
|
||||
}, label: { Image(systemName: "trash") })
|
||||
.padding(.trailing, 10)
|
||||
.alignmentGuide(HorizontalAlignment.center, computeValue: { dim in
|
||||
dim[HorizontalAlignment.center]
|
||||
})
|
||||
|
||||
Button(action: {
|
||||
viewModel.bulkAction(dataService: dataService, action: .archive, items: Array(selection))
|
||||
isEditMode = .inactive
|
||||
}, label: { Image(systemName: "archivebox") })
|
||||
.alignmentGuide(HorizontalAlignment.center, computeValue: { dim in
|
||||
dim[HorizontalAlignment.center]
|
||||
})
|
||||
|
||||
Spacer()
|
||||
Text("\(selection.count) selected").font(.footnote)
|
||||
Spacer()
|
||||
|
||||
Button(action: { isEditMode = .inactive }, label: { Text("Cancel") })
|
||||
}
|
||||
}
|
||||
|
|
@ -328,7 +413,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let linkRequest = viewModel.linkRequest, viewModel.listConfig.hasReadNowSection {
|
||||
if let linkRequest = viewModel.linkRequest, viewModel.currentListConfig?.hasReadNowSection ?? false {
|
||||
PresentationLink(
|
||||
transition: PresentationLinkTransition.slide(
|
||||
options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing,
|
||||
|
|
@ -351,7 +436,6 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
isListScrolled: $isListScrolled,
|
||||
prefersListLayout: $prefersListLayout,
|
||||
isEditMode: $isEditMode,
|
||||
showAddFeedView: $showAddFeedView,
|
||||
selection: $selection,
|
||||
viewModel: viewModel,
|
||||
showFeatureCards: showFeatureCards
|
||||
|
|
@ -405,9 +489,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@Binding var isListScrolled: Bool
|
||||
@Binding var prefersListLayout: Bool
|
||||
@Binding var isEditMode: EditMode
|
||||
@Binding var showAddFeedView: Bool
|
||||
@State private var showHideFeatureAlert = false
|
||||
@State private var showHideFollowingAlert = false
|
||||
|
||||
@Binding var selection: Set<String>
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
|
@ -423,7 +505,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
.overlay(Rectangle()
|
||||
.padding(.leading, 15)
|
||||
.frame(width: nil, height: 0.5, alignment: .bottom)
|
||||
.foregroundColor(isListScrolled ? Color(hex: "#3D3D3D") : Color.systemBackground), alignment: .bottom)
|
||||
.foregroundColor(isListScrolled && UIDevice.isIPhone ? Color(hex: "#3D3D3D") : Color.systemBackground), alignment: .bottom)
|
||||
.dynamicTypeSize(.small ... .accessibility1)
|
||||
}
|
||||
|
||||
|
|
@ -576,48 +658,8 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}.redacted(reason: .placeholder)
|
||||
}
|
||||
|
||||
var emptyState: some View {
|
||||
if viewModel.folder == "following" {
|
||||
return AnyView(
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
Text("You don't have any Feed items.")
|
||||
.font(Font.system(size: 18, weight: .bold))
|
||||
|
||||
Text("Add an RSS/Atom feed")
|
||||
.foregroundColor(Color.blue)
|
||||
.onTapGesture {
|
||||
showAddFeedView = true
|
||||
}
|
||||
|
||||
Text("Hide the Following tab")
|
||||
.foregroundColor(Color.blue)
|
||||
.onTapGesture {
|
||||
showHideFollowingAlert = true
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 400)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
)
|
||||
} else {
|
||||
return AnyView(Group {
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
Text("No results found for this query")
|
||||
.font(Font.system(size: 18, weight: .bold))
|
||||
}
|
||||
.frame(minHeight: 400)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var listItems: some View {
|
||||
ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.unwrappedID) { _, item in
|
||||
ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.unwrappedID) { idx, item in
|
||||
let horizontalInset = CGFloat(UIDevice.isIPad ? 20 : 10)
|
||||
|
||||
LibraryItemListNavigationLink(
|
||||
|
|
@ -641,13 +683,24 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
menuItems(for: item)
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
ForEach(viewModel.listConfig.leadingSwipeActions, id: \.self) { action in
|
||||
swipeActionButton(action: action, item: item)
|
||||
if let listConfig = viewModel.currentListConfig {
|
||||
ForEach(listConfig.leadingSwipeActions, id: \.self) { action in
|
||||
swipeActionButton(action: action, item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
ForEach(viewModel.listConfig.trailingSwipeActions, id: \.self) { action in
|
||||
swipeActionButton(action: action, item: item)
|
||||
if let listConfig = viewModel.currentListConfig {
|
||||
ForEach(listConfig.trailingSwipeActions, id: \.self) { action in
|
||||
swipeActionButton(action: action, item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if idx >= viewModel.fetcher.items.count - 5 {
|
||||
Task {
|
||||
await viewModel.loadMore(dataService: dataService)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -695,7 +748,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
if viewModel.showLoadingBar {
|
||||
redactedItems
|
||||
} else if viewModel.fetcher.items.isEmpty {
|
||||
emptyState
|
||||
EmptyState(viewModel: viewModel)
|
||||
.listRowSeparator(.hidden, edges: .all)
|
||||
} else {
|
||||
listItems
|
||||
|
|
@ -723,13 +776,6 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
shouldScrollToTop = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddFeedView) {
|
||||
NavigationView {
|
||||
LibraryAddFeedView(dismiss: {
|
||||
showAddFeedView = false
|
||||
}, toastOperationHandler: nil)
|
||||
}
|
||||
}
|
||||
.alert("The Feature Section will be removed from your library. You can add it back from the filter settings in your profile.",
|
||||
isPresented: $showHideFeatureAlert) {
|
||||
Button("OK", role: .destructive) {
|
||||
|
|
@ -738,11 +784,11 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
Button(LocalText.cancelGeneric, role: .cancel) { self.showHideFeatureAlert = false }
|
||||
}
|
||||
.alert("The Following tab will be hidden. You can add it back from the filter settings in your profile.",
|
||||
isPresented: $showHideFollowingAlert) {
|
||||
isPresented: $viewModel.showHideFollowingAlert) {
|
||||
Button("OK", role: .destructive) {
|
||||
viewModel.hideFollowingTab = true
|
||||
}
|
||||
Button(LocalText.cancelGeneric, role: .cancel) { self.showHideFollowingAlert = false }
|
||||
Button(LocalText.cancelGeneric, role: .cancel) { viewModel.showHideFollowingAlert = false }
|
||||
}
|
||||
.introspectNavigationController { nav in
|
||||
nav.navigationBar.shadowImage = UIImage()
|
||||
|
|
@ -830,6 +876,15 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
|
||||
var filtersHeader: some View {
|
||||
FiltersHeader(viewModel: viewModel)
|
||||
.overlay(Rectangle()
|
||||
.padding(.leading, 15)
|
||||
.frame(width: nil, height: 0.5, alignment: .bottom)
|
||||
.foregroundColor(isListScrolled && UIDevice.isIPhone ? Color(hex: "#3D3D3D") : Color.systemBackground), alignment: .bottom)
|
||||
.dynamicTypeSize(.small ... .accessibility1)
|
||||
}
|
||||
|
||||
func menuItems(for item: Models.LibraryItem) -> some View {
|
||||
libraryItemMenu(dataService: dataService, viewModel: viewModel, item: item)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -852,39 +907,39 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 325, maximum: 400), spacing: 16)], alignment: .center, spacing: 30) {
|
||||
if viewModel.showLoadingBar {
|
||||
ForEach(Array(fakeLibraryItems(dataService: dataService).enumerated()), id: \.1.id) { _, item in
|
||||
GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: { _ in
|
||||
|
||||
})
|
||||
ForEach(fakeLibraryItems(dataService: dataService), id: \.id) { item in
|
||||
GridCard(item: item)
|
||||
.aspectRatio(1.0, contentMode: .fill)
|
||||
.background(
|
||||
Color.secondarySystemGroupedBackground
|
||||
.onTapGesture {
|
||||
if isContextMenuOpen {
|
||||
isContextMenuOpen = false
|
||||
}
|
||||
}
|
||||
)
|
||||
.background(Color.systemBackground)
|
||||
.cornerRadius(6)
|
||||
}.redacted(reason: .placeholder)
|
||||
} else {
|
||||
ForEach(viewModel.fetcher.items) { item in
|
||||
LibraryItemGridCardNavigationLink(
|
||||
item: item,
|
||||
actionHandler: { contextMenuActionHandler(item: item, action: $0) },
|
||||
isContextMenuOpen: $isContextMenuOpen,
|
||||
viewModel: viewModel
|
||||
)
|
||||
if !viewModel.fetcher.items.isEmpty {
|
||||
ForEach(Array(viewModel.fetcher.items.enumerated()), id: \.1.id) { idx, item in
|
||||
LibraryItemGridCardNavigationLink(
|
||||
item: item,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.contextMenu {
|
||||
menuItems(for: item)
|
||||
}
|
||||
.onAppear {
|
||||
if idx >= viewModel.fetcher.items.count - 5 {
|
||||
Task {
|
||||
await viewModel.loadMore(dataService: dataService)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BottomView(viewModel: viewModel)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding()
|
||||
.background(
|
||||
GeometryReader {
|
||||
Color(.systemGroupedBackground).preference(
|
||||
Color(.systemBackground).preference(
|
||||
key: ScrollViewOffsetPreferenceKey.self,
|
||||
value: $0.frame(in: .global).origin.y
|
||||
)
|
||||
|
|
@ -898,11 +953,21 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
}
|
||||
|
||||
if viewModel.fetcher.items.isEmpty {
|
||||
EmptyState(viewModel: viewModel)
|
||||
} else {
|
||||
HStack {
|
||||
Spacer()
|
||||
BottomView(viewModel: viewModel).frame(maxWidth: 300)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.fetcher.items.isEmpty, viewModel.isLoading {
|
||||
LoadingSection()
|
||||
}
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.background(Color(.systemBackground))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
|
@ -1001,12 +1066,12 @@ struct BottomView: View {
|
|||
}
|
||||
|
||||
var innerBody: some View {
|
||||
if viewModel.fetcher.items.count < 5 {
|
||||
if viewModel.fetcher.items.count < 3 {
|
||||
AnyView(Color.clear)
|
||||
} else {
|
||||
AnyView(HStack {
|
||||
if !autoLoading {
|
||||
Text("You are all caught up.")
|
||||
if let totalCount = viewModel.fetcher.totalCount {
|
||||
Text("\(viewModel.fetcher.items.count) of \(totalCount) items.")
|
||||
}
|
||||
Spacer()
|
||||
if viewModel.isLoading {
|
||||
|
|
@ -1017,7 +1082,11 @@ struct BottomView: View {
|
|||
await viewModel.loadMore(dataService: dataService)
|
||||
}
|
||||
}, label: {
|
||||
Text("Refresh library")
|
||||
if let totalCount = viewModel.fetcher.totalCount, viewModel.fetcher.items.count >= totalCount {
|
||||
Text("Check for more")
|
||||
} else {
|
||||
Text("Fetch more")
|
||||
}
|
||||
})
|
||||
.foregroundColor(Color.blue)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ import Views
|
|||
action: {
|
||||
addLinkPresented = true
|
||||
},
|
||||
label: { Label("Add Link", systemImage: "plus") }
|
||||
label: { Label("Add link", systemImage: "plus") }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,15 +5,12 @@ import SwiftUI
|
|||
import Utils
|
||||
import Views
|
||||
|
||||
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject, UINavigationControllerDelegate {
|
||||
let folder: String
|
||||
let fetcher: LibraryItemFetcher
|
||||
let listConfig: LibraryListConfig
|
||||
|
||||
private var fetchedResultsController: NSFetchedResultsController<Models.LibraryItem>?
|
||||
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject {
|
||||
let filterKey: String
|
||||
@ObservedObject var fetcher: LibraryItemFetcher
|
||||
let folderConfigs: [String: LibraryListConfig]
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var showPushNotificationPrimer = false
|
||||
@Published var itemUnderLabelEdit: Models.LibraryItem?
|
||||
@Published var itemUnderTitleEdit: Models.LibraryItem?
|
||||
@Published var itemForHighlightsView: Models.LibraryItem?
|
||||
|
|
@ -26,6 +23,8 @@ import Views
|
|||
|
||||
@Published var showLabelsSheet = false
|
||||
@Published var showSnackbar = false
|
||||
@Published var showAddFeedView = false
|
||||
@Published var showHideFollowingAlert = false
|
||||
@Published var snackbarOperation: SnackbarOperation?
|
||||
|
||||
@Published var filters = [InternalFilter]()
|
||||
|
|
@ -36,67 +35,95 @@ import Views
|
|||
@Published var appliedSort = LinkedItemSort.newest.rawValue
|
||||
|
||||
@State var lastMoreFetched: Date?
|
||||
@State var lastFiltersFetched: Date?
|
||||
|
||||
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
|
||||
@AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false
|
||||
|
||||
@Published var appliedFilter: InternalFilter? {
|
||||
didSet {
|
||||
let filterKey = UserDefaults.standard.string(forKey: "lastSelected-\(folder)-filter") ?? folder
|
||||
UserDefaults.standard.setValue(appliedFilter?.name, forKey: filterKey)
|
||||
if let filterName = appliedFilter?.name.lowercased() {
|
||||
UserDefaults.standard.setValue(filterName, forKey: filterKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filterState: FetcherFilterState {
|
||||
FetcherFilterState(folder: folder, searchTerm: searchTerm, selectedLabels: selectedLabels, negatedLabels: negatedLabels, appliedSort: appliedSort, appliedFilter: appliedFilter)
|
||||
}
|
||||
init(filterKey: String, fetcher: LibraryItemFetcher, folderConfigs: [String: LibraryListConfig]) {
|
||||
self.filterKey = filterKey
|
||||
|
||||
init(folder: String, fetcher: LibraryItemFetcher, listConfig: LibraryListConfig) {
|
||||
self.folder = folder
|
||||
self.fetcher = fetcher
|
||||
self.listConfig = listConfig
|
||||
self.folderConfigs = folderConfigs
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
func loadFilters(dataService: DataService) async {
|
||||
switch folder {
|
||||
case "following":
|
||||
updateFilters(newFilters: InternalFilter.DefaultFollowingFilters, defaultName: "following")
|
||||
default:
|
||||
var hasLocalResults = false
|
||||
let fetchRequest: NSFetchRequest<Models.Filter> = Filter.fetchRequest()
|
||||
|
||||
// Load from disk
|
||||
if let results = try? dataService.viewContext.fetch(fetchRequest) {
|
||||
hasLocalResults = true
|
||||
updateFilters(newFilters: InternalFilter.make(from: results), defaultName: "inbox")
|
||||
}
|
||||
|
||||
let hasResults = hasLocalResults
|
||||
Task.detached {
|
||||
if let downloadedFilters = try? await dataService.filters() {
|
||||
await self.updateFilters(newFilters: downloadedFilters, defaultName: "inbox")
|
||||
} else if !hasResults {
|
||||
await self.updateFilters(newFilters: InternalFilter.DefaultInboxFilters, defaultName: "inbox")
|
||||
}
|
||||
}
|
||||
private var filterState: FetcherFilterState? {
|
||||
if let appliedFilter = appliedFilter {
|
||||
return FetcherFilterState(
|
||||
folder: appliedFilter.folder,
|
||||
searchTerm: searchTerm,
|
||||
selectedLabels: selectedLabels,
|
||||
negatedLabels: negatedLabels,
|
||||
appliedSort: appliedSort,
|
||||
appliedFilter: appliedFilter
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadMore(dataService: DataService) async {
|
||||
if isLoading { return }
|
||||
var currentFolder: String? {
|
||||
appliedFilter?.folder
|
||||
}
|
||||
|
||||
let start = Date.now
|
||||
if let lastMoreFetched, lastMoreFetched.timeIntervalSinceNow > -4 {
|
||||
print("skipping fetching more as last fetch was too recent: ", lastMoreFetched)
|
||||
var currentListConfig: LibraryListConfig? {
|
||||
if let currentFolder = currentFolder {
|
||||
return folderConfigs[currentFolder]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadFilters(dataService: DataService) async {
|
||||
let start = Date()
|
||||
var hasLocalResults = false
|
||||
let fetchRequest: NSFetchRequest<Models.Filter> = Filter.fetchRequest()
|
||||
|
||||
if let lastFiltersFetched, lastFiltersFetched.timeIntervalSinceNow > -100 {
|
||||
print("skipping fetching filters as last fetch was too recent: ", lastFiltersFetched)
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
await fetcher.loadMoreItems(dataService: dataService, filterState: filterState)
|
||||
isLoading = false
|
||||
// Load from disk
|
||||
if let results = try? dataService.viewContext.fetch(fetchRequest) {
|
||||
hasLocalResults = true
|
||||
updateFilters(newFilters: InternalFilter.make(from: results))
|
||||
}
|
||||
|
||||
lastMoreFetched = start
|
||||
let hasResults = hasLocalResults
|
||||
if let downloadedFilters = try? await dataService.filters() {
|
||||
updateFilters(newFilters: downloadedFilters)
|
||||
} else if !hasResults {
|
||||
updateFilters(newFilters: InternalFilter.DefaultInboxFilters)
|
||||
}
|
||||
|
||||
lastFiltersFetched = start
|
||||
}
|
||||
|
||||
func loadMore(dataService: DataService, loadCursor: String? = nil) async {
|
||||
if let filterState = filterState {
|
||||
if isLoading { return }
|
||||
|
||||
let start = Date.now
|
||||
if let lastMoreFetched, lastMoreFetched.timeIntervalSinceNow > -4 {
|
||||
print("skipping fetching more as last fetch was too recent: ", lastMoreFetched)
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
await fetcher.loadMoreItems(dataService: dataService, filterState: filterState, loadCursor: loadCursor)
|
||||
isLoading = false
|
||||
|
||||
lastMoreFetched = start
|
||||
}
|
||||
}
|
||||
|
||||
func pushFeedItem(item _: Models.LibraryItem) {
|
||||
|
|
@ -120,24 +147,62 @@ import Views
|
|||
}
|
||||
}
|
||||
|
||||
func updateFilters(newFilters: [InternalFilter], defaultName: String) {
|
||||
let appliedFilterName = UserDefaults.standard.string(forKey: "lastSelected-\(filterState.folder)-filter") ?? defaultName
|
||||
var defaultFilters: [InternalFilter] {
|
||||
[InternalFilter.InboxUnreadFilter,
|
||||
InternalFilter.InboxDeletedFilter,
|
||||
InternalFilter.InboxDownloadedFilter]
|
||||
+ InternalFilter.DefaultFollowingFilters
|
||||
}
|
||||
|
||||
func updateFilters(newFilters: [InternalFilter]) {
|
||||
let availableFolders = folderConfigs.keys
|
||||
let appliedFilterName = UserDefaults.standard.string(forKey: filterKey)
|
||||
|
||||
filters = newFilters
|
||||
.filter { $0.folder == filterState.folder }
|
||||
.filter { availableFolders.contains($0.folder) }
|
||||
.sorted(by: { $0.position < $1.position })
|
||||
+ (folder == "inbox" ? [InternalFilter.UnreadFilter, InternalFilter.DeletedFilter, InternalFilter.DownloadedFilter] : [InternalFilter.DownloadedFilter])
|
||||
+ defaultFilters
|
||||
|
||||
if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id {
|
||||
appliedFilter = newFilter
|
||||
}
|
||||
}
|
||||
|
||||
func loadItems(dataService: DataService, isRefresh: Bool) async {
|
||||
func setDefaultFilter() {
|
||||
let availableFolders = folderConfigs.keys
|
||||
let appliedFilterName = UserDefaults.standard.string(forKey: filterKey)
|
||||
if let newFilter = filters.first(where: { $0.name.lowercased() == appliedFilterName }), newFilter.id != appliedFilter?.id {
|
||||
appliedFilter = newFilter
|
||||
return
|
||||
}
|
||||
|
||||
if let defaultFilter = filters.first(where: { availableFolders.contains($0.folder) }) {
|
||||
appliedFilter = defaultFilter
|
||||
}
|
||||
}
|
||||
|
||||
func loadNewItems(dataService: DataService) async {
|
||||
if let filterState = filterState {
|
||||
await fetcher.loadNewItems(
|
||||
dataService: dataService,
|
||||
filterState: filterState
|
||||
)
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
func loadItems(dataService: DataService, isRefresh: Bool, forceRemote: Bool = false) async {
|
||||
isLoading = true
|
||||
showLoadingBar = isRefresh
|
||||
|
||||
await fetcher.loadItems(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
|
||||
if let filterState = filterState {
|
||||
await fetcher.loadItems(
|
||||
dataService: dataService,
|
||||
filterState: filterState,
|
||||
isRefresh: isRefresh,
|
||||
forceRemote: forceRemote
|
||||
)
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
showLoadingBar = false
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ struct LibraryAddFeedView: View {
|
|||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
innerBody
|
||||
.navigationTitle("Add Feed URL")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
Form {
|
||||
innerBody
|
||||
.navigationTitle("Add Feed URL")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
#else
|
||||
innerBody
|
||||
#endif
|
||||
|
|
@ -80,7 +82,7 @@ struct LibraryAddFeedView: View {
|
|||
}
|
||||
|
||||
var innerBody: some View {
|
||||
List {
|
||||
Group {
|
||||
Section {
|
||||
TextField("Feed or site URL", text: $feedURL)
|
||||
#if os(iOS)
|
||||
|
|
|
|||
|
|
@ -1,135 +0,0 @@
|
|||
// swiftlint:disable line_length
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
struct OpenAIVoiceItem {
|
||||
let name: String
|
||||
let key: String
|
||||
}
|
||||
|
||||
public struct OpenAIVoicesModal: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let audioController: AudioController
|
||||
|
||||
let message: String = """
|
||||
We've added six new voices powered by OpenAI and enabled them for all users. If you are already using our Ultra Realistic voices, don't worry, trying these voices will not remove you from the ultra realistic beta.
|
||||
|
||||
[Tell your friends about Omnivore](https://omnivore.app)
|
||||
"""
|
||||
|
||||
@State var playbackSample: String?
|
||||
|
||||
let voices = [
|
||||
OpenAIVoiceItem(name: "Alloy", key: "openai-alloy"),
|
||||
OpenAIVoiceItem(name: "Echo", key: "openai-echo"),
|
||||
OpenAIVoiceItem(name: "Fable", key: "openai-fable"),
|
||||
OpenAIVoiceItem(name: "Onyx", key: "openai-onyx"),
|
||||
OpenAIVoiceItem(name: "Nova", key: "openai-nova"),
|
||||
OpenAIVoiceItem(name: "Shimmer", key: "openai-shimmer")
|
||||
]
|
||||
|
||||
var closeButton: some View {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}, label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color.circleButtonBackground)
|
||||
.frame(width: 30, height: 30)
|
||||
|
||||
Image(systemName: "xmark")
|
||||
.resizable(resizingMode: Image.ResizingMode.stretch)
|
||||
.foregroundColor(Color.circleButtonForeground)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.font(Font.title.weight(.bold))
|
||||
.frame(width: 12, height: 12)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack {
|
||||
Text("New voices powered by OpenAI")
|
||||
.font(Font.system(size: 20, weight: .bold))
|
||||
Spacer()
|
||||
closeButton
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
List {
|
||||
Section {
|
||||
let parsedMessage = try? AttributedString(markdown: message,
|
||||
options: .init(interpretedSyntax: .inlineOnly))
|
||||
Text(parsedMessage ?? "")
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
.accentColor(.blue)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(voices, id: \.self.name) { voice in
|
||||
voiceRow(for: voice)
|
||||
}
|
||||
}
|
||||
}
|
||||
.environmentObject(audioController)
|
||||
}
|
||||
|
||||
func voiceRow(for voice: OpenAIVoiceItem) -> some View {
|
||||
Button(action: {
|
||||
if audioController.isPlayingSample(voice: voice.key) {
|
||||
playbackSample = nil
|
||||
audioController.stopVoiceSample()
|
||||
}
|
||||
playbackSample = voice.key
|
||||
audioController.currentVoice = voice.key
|
||||
audioController.playVoiceSample(voice: voice.key)
|
||||
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { timer in
|
||||
let playing = audioController.isPlayingSample(voice: voice.key)
|
||||
if playing {
|
||||
playbackSample = voice.key
|
||||
} else if !playing {
|
||||
// If the playback sample is something else, its taken ownership
|
||||
// of the value so we just ignore it and shut down our timer.
|
||||
if playbackSample == voice.key {
|
||||
playbackSample = nil
|
||||
}
|
||||
timer.invalidate()
|
||||
}
|
||||
}
|
||||
}, label: {
|
||||
HStack {
|
||||
if playbackSample == voice.key {
|
||||
Image(systemName: "stop.circle")
|
||||
.font(.appTitleTwo)
|
||||
.padding(.trailing, 16)
|
||||
} else {
|
||||
Image(systemName: "play.circle")
|
||||
.font(.appTitleTwo)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
Text(voice.name)
|
||||
Spacer()
|
||||
|
||||
if audioController.currentVoice == voice.key {
|
||||
if audioController.isPlaying, audioController.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}.contentShape(Rectangle())
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -125,7 +125,7 @@ struct ApplyLabelsView: View {
|
|||
label: {
|
||||
HStack {
|
||||
let trimmedLabelName = viewModel.labelSearchFilter.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
Image(systemName: "tag").foregroundColor(.blue)
|
||||
Image.addLink.foregroundColor(.blue).foregroundColor(.blue)
|
||||
Text(
|
||||
viewModel.labelSearchFilter.count > 0 ?
|
||||
"Create: \"\(trimmedLabelName)\" label" :
|
||||
|
|
@ -202,7 +202,7 @@ func isSystemLabel(_ label: LinkedItemLabel) -> Bool {
|
|||
|
||||
extension Sequence where Element == LinkedItemLabel {
|
||||
func applySearchFilter(_ searchFilter: String) -> [LinkedItemLabel] {
|
||||
let hideSystemLabels = UserDefaults(suiteName: "group.app.omnivoreapp")?.bool(forKey: UserDefaultKey.hideSystemLabels.rawValue) ?? false
|
||||
let hideSystemLabels = PublicValet.hideLabels
|
||||
|
||||
if searchFilter.isEmpty || searchFilter == ZWSP {
|
||||
return map { $0 } // return the identity of the sequence
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import Views
|
|||
@Published var labelSearchFilter = ""
|
||||
|
||||
func setLabels(_ labels: [LinkedItemLabel]) {
|
||||
let hideSystemLabels = UserDefaults(suiteName: "group.app.omnivoreapp")?.bool(forKey: UserDefaultKey.hideSystemLabels.rawValue) ?? false
|
||||
let hideSystemLabels = PublicValet.hideLabels
|
||||
|
||||
self.labels = labels.filter { !hideSystemLabels || !isSystemLabel($0) }.sorted { left, right in
|
||||
let aTrimmed = left.unwrappedName.trimmingCharacters(in: .whitespaces)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ struct LabelsView: View {
|
|||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@AppStorage(UserDefaultKey.hideSystemLabels.rawValue, store: UserDefaults(suiteName: "group.app.omnivoreapp")) var hideSystemLabels = false
|
||||
@AppStorage(UserDefaultKey.hideSystemLabels.rawValue) var hideSystemLabels = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
|
|
@ -21,13 +21,15 @@ struct LabelsView: View {
|
|||
HStack {
|
||||
TextChip(feedItemLabel: label).allowsHitTesting(false)
|
||||
Spacer()
|
||||
Button(
|
||||
action: {
|
||||
labelToRemove = label
|
||||
showDeleteConfirmation = true
|
||||
},
|
||||
label: { Image(systemName: "trash") }
|
||||
)
|
||||
if !isSystemLabel(label) {
|
||||
Button(
|
||||
action: {
|
||||
labelToRemove = label
|
||||
showDeleteConfirmation = true
|
||||
},
|
||||
label: { Image(systemName: "trash") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
createLabelButton
|
||||
|
|
@ -52,7 +54,9 @@ struct LabelsView: View {
|
|||
}
|
||||
Button(LocalText.cancelGeneric, role: .cancel) { self.labelToRemove = nil }
|
||||
}
|
||||
.onChange(of: hideSystemLabels) { _ in
|
||||
.onChange(of: hideSystemLabels) { newValue in
|
||||
PublicValet.hideLabels = newValue
|
||||
|
||||
Task {
|
||||
await viewModel.loadLabels(dataService: dataService, item: nil)
|
||||
}
|
||||
|
|
@ -70,25 +74,19 @@ struct LabelsView: View {
|
|||
Button(
|
||||
action: { viewModel.showCreateLabelModal = true },
|
||||
label: {
|
||||
HStack {
|
||||
Label(title: {
|
||||
let trimmedLabelName = viewModel.labelSearchFilter.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
Image(systemName: "tag").foregroundColor(.blue)
|
||||
Text(
|
||||
viewModel.labelSearchFilter.count > 0 && viewModel.labelSearchFilter != ZWSP ?
|
||||
"Create: \"\(trimmedLabelName)\" label" :
|
||||
LocalText.createLabelMessage
|
||||
).foregroundColor(.blue)
|
||||
.font(Font.system(size: 14))
|
||||
Spacer()
|
||||
}
|
||||
)
|
||||
}, icon: {
|
||||
Image.addLink
|
||||
})
|
||||
}
|
||||
)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.disabled(viewModel.isLoading)
|
||||
#if os(iOS)
|
||||
.listRowSeparator(.hidden, edges: .bottom)
|
||||
#endif
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import Utils
|
|||
public init() {}
|
||||
|
||||
func setLabels(_ labels: [LinkedItemLabel]) {
|
||||
let hideSystemLabels = UserDefaults(suiteName: "group.app.omnivoreapp")?.bool(forKey: UserDefaultKey.hideSystemLabels.rawValue) ?? false
|
||||
let hideSystemLabels = PublicValet.hideLabels
|
||||
|
||||
self.labels = labels.filter { !hideSystemLabels || !isSystemLabel($0) }.sorted { left, right in
|
||||
let aTrimmed = left.unwrappedName.trimmingCharacters(in: .whitespaces)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@ import Services
|
|||
import SwiftUI
|
||||
|
||||
@MainActor struct LibrarySidebar: View {
|
||||
@ObservedObject var inboxViewModel: HomeFeedViewModel
|
||||
@ObservedObject var followingViewModel: HomeFeedViewModel
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
@State private var addLinkPresented = false
|
||||
|
|
@ -22,83 +20,78 @@ import SwiftUI
|
|||
@AppStorage("followingMenuState") var followingMenuState = "open"
|
||||
|
||||
var innerBody: some View {
|
||||
ZStack {
|
||||
NavigationLink("", destination: HomeView(viewModel: inboxViewModel), isActive: $inboxActive)
|
||||
NavigationLink("", destination: HomeView(viewModel: followingViewModel), isActive: $followingActive)
|
||||
List {
|
||||
Section {
|
||||
Button(action: { inboxMenuState = inboxMenuState == "open" ? "closed" : "open" }, label: {
|
||||
HStack {
|
||||
Image.tabLibrary
|
||||
Text("Library")
|
||||
Spacer()
|
||||
|
||||
List {
|
||||
Section {
|
||||
Button(action: { inboxMenuState = inboxMenuState == "open" ? "closed" : "open" }, label: {
|
||||
HStack {
|
||||
Image.tabLibrary
|
||||
Text("Library")
|
||||
Spacer()
|
||||
|
||||
if inboxMenuState == "open" {
|
||||
Image(systemName: "chevron.down")
|
||||
} else {
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if inboxMenuState == "open" {
|
||||
ForEach(inboxViewModel.filters, id: \.self) { filter in
|
||||
Button(action: {
|
||||
inboxViewModel.appliedFilter = filter
|
||||
selectedFilter = filter
|
||||
followingActive = false
|
||||
inboxActive = true
|
||||
}, label: {
|
||||
HStack {
|
||||
Spacer().frame(width: 35)
|
||||
Text(filter.name)
|
||||
.lineLimit(1)
|
||||
}
|
||||
})
|
||||
.listRowBackground(
|
||||
selectedFilter == filter && inboxActive
|
||||
? Color.systemBackground.cornerRadius(8) : Color.clear.cornerRadius(8)
|
||||
)
|
||||
if inboxMenuState == "open" {
|
||||
Image(systemName: "chevron.down")
|
||||
} else {
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Section {
|
||||
Button(action: { followingMenuState = followingMenuState == "open" ? "closed" : "open" }, label: {
|
||||
HStack {
|
||||
Image.tabFollowing
|
||||
Text("Following")
|
||||
Spacer()
|
||||
|
||||
if followingMenuState == "open" {
|
||||
Image(systemName: "chevron.down")
|
||||
} else {
|
||||
Image(systemName: "chevron.right")
|
||||
if inboxMenuState == "open" {
|
||||
ForEach(viewModel.filters.filter { $0.folder == "inbox" }, id: \.self) { filter in
|
||||
Button(action: {
|
||||
viewModel.appliedFilter = filter
|
||||
selectedFilter = filter
|
||||
followingActive = false
|
||||
inboxActive = true
|
||||
}, label: {
|
||||
HStack {
|
||||
Spacer().frame(width: 35)
|
||||
Text(filter.name)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.listRowBackground(
|
||||
selectedFilter == filter && inboxActive
|
||||
? Color.systemBackground.cornerRadius(8) : Color.clear.cornerRadius(8)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if followingMenuState == "open" {
|
||||
ForEach(followingViewModel.filters, id: \.self) { filter in
|
||||
Button(action: {
|
||||
followingViewModel.appliedFilter = filter
|
||||
selectedFilter = filter
|
||||
inboxActive = false
|
||||
followingActive = true
|
||||
}, label: {
|
||||
HStack {
|
||||
Spacer().frame(width: 35)
|
||||
Text(filter.name)
|
||||
.lineLimit(1)
|
||||
}
|
||||
})
|
||||
.listRowBackground(
|
||||
selectedFilter == filter && followingActive
|
||||
? Color.systemBackground.cornerRadius(8) : Color.clear.cornerRadius(8)
|
||||
)
|
||||
Section {
|
||||
Button(action: { followingMenuState = followingMenuState == "open" ? "closed" : "open" }, label: {
|
||||
HStack {
|
||||
Image.tabFollowing
|
||||
Text("Following")
|
||||
Spacer()
|
||||
|
||||
if followingMenuState == "open" {
|
||||
Image(systemName: "chevron.down")
|
||||
} else {
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if followingMenuState == "open" {
|
||||
ForEach(viewModel.filters.filter { $0.folder == "following" }, id: \.self) { filter in
|
||||
Button(action: {
|
||||
viewModel.appliedFilter = filter
|
||||
selectedFilter = filter
|
||||
inboxActive = false
|
||||
followingActive = true
|
||||
}, label: {
|
||||
HStack {
|
||||
Spacer().frame(width: 35)
|
||||
Text(filter.name)
|
||||
.lineLimit(1)
|
||||
}
|
||||
})
|
||||
.listRowBackground(
|
||||
selectedFilter == filter && followingActive
|
||||
? Color.systemBackground.cornerRadius(8) : Color.clear.cornerRadius(8)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
|
|
@ -125,23 +118,18 @@ import SwiftUI
|
|||
}
|
||||
}
|
||||
}.task {
|
||||
await inboxViewModel.loadFilters(dataService: dataService)
|
||||
await followingViewModel.loadFilters(dataService: dataService)
|
||||
await viewModel.loadFilters(dataService: dataService)
|
||||
|
||||
if inboxActive {
|
||||
selectedFilter = inboxViewModel.appliedFilter
|
||||
selectedFilter = viewModel.appliedFilter
|
||||
} else {
|
||||
selectedFilter = followingViewModel.appliedFilter
|
||||
selectedFilter = viewModel.appliedFilter
|
||||
}
|
||||
}.onChange(of: inboxViewModel.appliedFilter) { filter in
|
||||
}.onChange(of: viewModel.appliedFilter) { filter in
|
||||
// When the user uses the dropdown menu to change filter we need to update in the sidebar
|
||||
if inboxActive, filter != selectedFilter {
|
||||
selectedFilter = filter
|
||||
}
|
||||
}.onChange(of: followingViewModel.appliedFilter) { filter in
|
||||
if followingActive, filter != selectedFilter {
|
||||
selectedFilter = filter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,28 +7,25 @@ import SwiftUI
|
|||
public struct LibrarySplitView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
@StateObject private var inboxViewModel = HomeFeedViewModel(
|
||||
folder: "inbox",
|
||||
@StateObject private var viewModel = HomeFeedViewModel(
|
||||
filterKey: "lastSelected",
|
||||
fetcher: LibraryItemFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
hasReadNowSection: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
)
|
||||
|
||||
@StateObject private var followingViewModel = HomeFeedViewModel(
|
||||
folder: "following",
|
||||
fetcher: LibraryItemFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: false,
|
||||
hasReadNowSection: false,
|
||||
leadingSwipeActions: [.moveToInbox],
|
||||
trailingSwipeActions: [.delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
folderConfigs: [
|
||||
"inbox": LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
hasReadNowSection: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .library
|
||||
),
|
||||
"following": LibraryListConfig(
|
||||
hasFeatureCards: false,
|
||||
hasReadNowSection: false,
|
||||
leadingSwipeActions: [.moveToInbox],
|
||||
trailingSwipeActions: [.delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
private let syncManager = LibrarySyncManager()
|
||||
|
|
@ -36,11 +33,11 @@ public struct LibrarySplitView: View {
|
|||
#if os(iOS)
|
||||
public var body: some View {
|
||||
NavigationView {
|
||||
LibrarySidebar(inboxViewModel: inboxViewModel, followingViewModel: followingViewModel)
|
||||
LibrarySidebar(viewModel: viewModel)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle("")
|
||||
|
||||
HomeFeedContainerView(viewModel: inboxViewModel)
|
||||
HomeFeedContainerView(viewModel: viewModel)
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
|
@ -50,30 +47,35 @@ public struct LibrarySplitView: View {
|
|||
$0.preferredPrimaryColumnWidth = 230
|
||||
$0.displayModeButtonVisibility = .always
|
||||
}
|
||||
.onOpenURL { url in
|
||||
inboxViewModel.linkRequest = nil
|
||||
if let deepLink = DeepLink.make(from: url) {
|
||||
switch deepLink {
|
||||
case let .search(query):
|
||||
inboxViewModel.searchTerm = query
|
||||
case let .savedSearch(named):
|
||||
if let filter = inboxViewModel.findFilter(dataService, named: named) {
|
||||
inboxViewModel.appliedFilter = filter
|
||||
}
|
||||
case let .webAppLinkRequest(requestID):
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
withoutAnimation {
|
||||
inboxViewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID)
|
||||
inboxViewModel.presentWebContainer = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// .onOpenURL { url in
|
||||
// inboxViewModel.linkRequest = nil
|
||||
// if let deepLink = DeepLink.make(from: url) {
|
||||
// switch deepLink {
|
||||
// case let .search(query):
|
||||
// inboxViewModel.searchTerm = query
|
||||
// case let .savedSearch(named):
|
||||
// if let filter = inboxViewModel.findFilter(dataService, named: named) {
|
||||
// inboxViewModel.appliedFilter = filter
|
||||
// }
|
||||
// case let .webAppLinkRequest(requestID):
|
||||
// DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
// withoutAnimation {
|
||||
// inboxViewModel.linkRequest = LinkRequest(id: UUID(), serverID: requestID)
|
||||
// inboxViewModel.presentWebContainer = true
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// // selectedTab = "inbox"
|
||||
// }
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
|
||||
Task {
|
||||
await syncManager.syncUpdates(dataService: dataService)
|
||||
}
|
||||
// selectedTab = "inbox"
|
||||
}
|
||||
.onReceive(NSNotification.performSyncPublisher) { _ in
|
||||
Task {
|
||||
await syncManager.syncItems(dataService: dataService)
|
||||
await syncManager.syncUpdates(dataService: dataService)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,42 +21,67 @@ struct LibraryTabView: View {
|
|||
|
||||
@AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false
|
||||
@AppStorage(UserDefaultKey.lastSelectedTabItem.rawValue) var selectedTab = "inbox"
|
||||
@AppStorage(UserDefaultKey.followingPrimerDisplayed.rawValue) var followingPrimerDisplayed = false
|
||||
|
||||
@State var showFollowingPrimer = false
|
||||
@State var showExpandedAudioPlayer = false
|
||||
|
||||
private let syncManager = LibrarySyncManager()
|
||||
|
||||
@MainActor
|
||||
public init() {
|
||||
UITabBar.appearance().isHidden = true
|
||||
}
|
||||
|
||||
@StateObject private var inboxViewModel = HomeFeedViewModel(
|
||||
folder: "inbox",
|
||||
filterKey: "lastSelectedFilter-inbox",
|
||||
fetcher: LibraryItemFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
hasReadNowSection: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
folderConfigs: [
|
||||
"inbox": LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
hasReadNowSection: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@StateObject private var followingViewModel = HomeFeedViewModel(
|
||||
folder: "following",
|
||||
filterKey: "lastSelectedFilter-following",
|
||||
fetcher: LibraryItemFetcher(),
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: false,
|
||||
hasReadNowSection: false,
|
||||
leadingSwipeActions: [.moveToInbox],
|
||||
trailingSwipeActions: [.delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
folderConfigs: [
|
||||
"following": LibraryListConfig(
|
||||
hasFeatureCards: false,
|
||||
hasReadNowSection: false,
|
||||
leadingSwipeActions: [.moveToInbox],
|
||||
trailingSwipeActions: [.delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
private let syncManager = LibrarySyncManager()
|
||||
var currentViewModel: HomeFeedViewModel? {
|
||||
switch selectedTab {
|
||||
case "inbox":
|
||||
return inboxViewModel
|
||||
case "following":
|
||||
return followingViewModel
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if showFollowingPrimer {
|
||||
PresentationLink(transition: UIDevice.isIPad ? .popover : .sheet(detents: [.medium]), isPresented: $showFollowingPrimer) {
|
||||
FollowingViewModal()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
TabView(selection: $selectedTab) {
|
||||
if !hideFollowingTab {
|
||||
NavigationView {
|
||||
|
|
@ -91,12 +116,28 @@ struct LibraryTabView: View {
|
|||
.padding(0)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showExpandedAudioPlayer) {
|
||||
ExpandedAudioPlayer()
|
||||
ExpandedAudioPlayer(
|
||||
delete: {
|
||||
showExpandedAudioPlayer = false
|
||||
audioController.stop()
|
||||
currentViewModel?.removeLibraryItem(dataService: dataService, objectID: $0)
|
||||
},
|
||||
archive: {
|
||||
showExpandedAudioPlayer = false
|
||||
audioController.stop()
|
||||
currentViewModel?.setLinkArchived(dataService: dataService, objectID: $0, archived: true)
|
||||
},
|
||||
viewArticle: { itemID in
|
||||
if let article = try? dataService.viewContext.existingObject(with: itemID) as? Models.LibraryItem {
|
||||
currentViewModel?.pushFeedItem(item: article)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.onReceive(NSNotification.performSyncPublisher) { _ in
|
||||
Task {
|
||||
await syncManager.syncItems(dataService: dataService)
|
||||
await syncManager.syncUpdates(dataService: dataService)
|
||||
}
|
||||
}
|
||||
.onOpenURL { url in
|
||||
|
|
|
|||
|
|
@ -17,12 +17,11 @@ import Views
|
|||
if let item = item {
|
||||
pdfItem = PDFItem.make(item: item)
|
||||
self.item = item
|
||||
trackReadEvent(reader: item.isPDF ? "PDF" : "WEB")
|
||||
}
|
||||
|
||||
trackReadEvent()
|
||||
}
|
||||
|
||||
private func trackReadEvent() {
|
||||
private func trackReadEvent(reader: String) {
|
||||
guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return }
|
||||
guard let slug = item?.unwrappedSlug ?? pdfItem?.slug else { return }
|
||||
guard let originalArticleURL = item?.unwrappedPageURLString ?? pdfItem?.downloadURL else { return }
|
||||
|
|
@ -31,6 +30,7 @@ import Views
|
|||
.linkRead(
|
||||
linkID: itemID,
|
||||
slug: slug,
|
||||
reader: reader,
|
||||
originalArticleURL: originalArticleURL
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -149,11 +149,11 @@
|
|||
Button(
|
||||
action: { viewModel.showCreateSheet = true },
|
||||
label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle.fill").foregroundColor(.green)
|
||||
Label(title: {
|
||||
Text(LocalText.clubsCreate)
|
||||
Spacer()
|
||||
}
|
||||
}, icon: {
|
||||
Image.addLink
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,14 @@ struct PushNotificationDevicesView: View {
|
|||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
if viewModel.devices.isEmpty {
|
||||
AnyView(Text("No devices registered"))
|
||||
} else {
|
||||
AnyView(deviceList)
|
||||
}
|
||||
}
|
||||
|
||||
private var deviceList: some View {
|
||||
List {
|
||||
Section(header: Text(LocalText.devicesTokensTitle)) {
|
||||
ForEach(viewModel.devices) { device in
|
||||
|
|
|
|||
|
|
@ -460,6 +460,54 @@ struct SubscriptionSettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
var feedRow: some View {
|
||||
VStack {
|
||||
Text("Feed URL")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(subscription.url ?? "")
|
||||
.foregroundColor(Color.appGrayText)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.contextMenu(ContextMenu(menuItems: {
|
||||
Button(action: {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = subscription.url
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
let pasteBoard = NSPasteboard.general
|
||||
pasteBoard.clearContents()
|
||||
pasteBoard.writeObjects([subscription.url as NSString])
|
||||
#endif
|
||||
}, label: { Text("Copy URL") })
|
||||
}))
|
||||
}
|
||||
|
||||
var emailRow: some View {
|
||||
VStack {
|
||||
Text("Received by")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(subscription.newsletterEmailAddress ?? "")
|
||||
.foregroundColor(Color.appGrayText)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.contextMenu(ContextMenu(menuItems: {
|
||||
Button(action: {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = subscription.newsletterEmailAddress
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
let pasteBoard = NSPasteboard.general
|
||||
pasteBoard.clearContents()
|
||||
pasteBoard.writeObjects([subscription.newsletterEmailAddress as NSString])
|
||||
#endif
|
||||
}, label: { Text("Copy Address") })
|
||||
}))
|
||||
}
|
||||
|
||||
var labelRuleRow: some View {
|
||||
HStack {
|
||||
Text("Add Labels")
|
||||
|
|
@ -472,7 +520,7 @@ struct SubscriptionSettingsView: View {
|
|||
} else {
|
||||
Text("Create Rule")
|
||||
}
|
||||
})
|
||||
}).tint(Color.blue)
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
|
|
@ -511,6 +559,13 @@ struct SubscriptionSettingsView: View {
|
|||
// }
|
||||
folderRow
|
||||
labelRuleRow
|
||||
|
||||
if subscription.type == .feed {
|
||||
feedRow
|
||||
}
|
||||
if subscription.type == .newsletter {
|
||||
emailRow
|
||||
}
|
||||
}.listStyle(.insetGrouped)
|
||||
|
||||
Spacer()
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ struct InnerRootView: View {
|
|||
@ViewBuilder private var innerBody: some View {
|
||||
if authenticator.isLoggedIn {
|
||||
PrimaryContentView()
|
||||
.task {
|
||||
try? await dataService.syncOfflineItemsWithServerIfNeeded()
|
||||
}
|
||||
} else {
|
||||
if authenticator.isLoggingOut {
|
||||
LogoutView()
|
||||
|
|
|
|||
|
|
@ -90,6 +90,16 @@ struct SelfHostSettingsView: View {
|
|||
.accentColor(.blue)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button(action: {
|
||||
AppEnvironment.setCustom(serverBaseURL: "https://api-prod.omnivore.app",
|
||||
webAppBaseURL: "https://omnivore.app",
|
||||
ttsBaseURL: "https://tts.omnivore.app")
|
||||
dataService.switchAppEnvironment(appEnvironment: AppEnvironment.prod)
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text("Reset self hosting settings")
|
||||
})
|
||||
|
||||
#if os(macOS)
|
||||
Spacer()
|
||||
HStack {
|
||||
|
|
|
|||
|
|
@ -453,7 +453,17 @@ struct WebReaderContainerView: View {
|
|||
.ignoresSafeArea(.all, edges: .bottom)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showExpandedAudioPlayer) {
|
||||
ExpandedAudioPlayer()
|
||||
ExpandedAudioPlayer(delete: { _ in
|
||||
showExpandedAudioPlayer = false
|
||||
audioController.stop()
|
||||
delete()
|
||||
}, archive: { _ in
|
||||
showExpandedAudioPlayer = false
|
||||
audioController.stop()
|
||||
archive()
|
||||
}, viewArticle: { _ in
|
||||
showExpandedAudioPlayer = false
|
||||
})
|
||||
}
|
||||
#endif
|
||||
.alert(errorAlertMessage ?? LocalText.readerError, isPresented: $showErrorAlertMessage) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import Views
|
|||
.linkRead(
|
||||
linkID: item.unwrappedID,
|
||||
slug: item.unwrappedSlug,
|
||||
reader: "WEB",
|
||||
originalArticleURL: item.unwrappedPageURLString
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ import Utils
|
|||
public struct LinkedItemQueryResult {
|
||||
public let itemIDs: [NSManagedObjectID]
|
||||
public let cursor: String?
|
||||
public let totalCount: Int?
|
||||
|
||||
public init(itemIDs: [NSManagedObjectID], cursor: String?) {
|
||||
public init(itemIDs: [NSManagedObjectID], cursor: String?, totalCount: Int?) {
|
||||
self.itemIDs = itemIDs
|
||||
self.cursor = cursor
|
||||
self.totalCount = totalCount
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -17,13 +19,21 @@ public struct LinkedItemSyncResult {
|
|||
public let cursor: String?
|
||||
public let hasMore: Bool
|
||||
public let mostRecentUpdatedAt: Date?
|
||||
public let oldestUpdatedAt: Date?
|
||||
public let isEmpty: Bool
|
||||
|
||||
public init(updatedItemIDs: [String], cursor: String?, hasMore: Bool, mostRecentUpdatedAt: Date?, isEmpty: Bool) {
|
||||
public init(updatedItemIDs: [String],
|
||||
cursor: String?,
|
||||
hasMore: Bool,
|
||||
mostRecentUpdatedAt: Date?,
|
||||
oldestUpdatedAt: Date?,
|
||||
isEmpty: Bool)
|
||||
{
|
||||
self.updatedItemIDs = updatedItemIDs
|
||||
self.cursor = cursor
|
||||
self.hasMore = hasMore
|
||||
self.mostRecentUpdatedAt = mostRecentUpdatedAt
|
||||
self.oldestUpdatedAt = oldestUpdatedAt
|
||||
self.isEmpty = isEmpty
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +42,7 @@ public struct LinkedItemAudioProperties {
|
|||
public let itemID: String
|
||||
public let objectID: NSManagedObjectID
|
||||
public let title: String
|
||||
public var isArchived: Bool
|
||||
public let byline: String?
|
||||
public let imageURL: URL?
|
||||
public let language: String?
|
||||
|
|
@ -240,6 +251,7 @@ public extension LibraryItem {
|
|||
itemID: unwrappedID,
|
||||
objectID: objectID,
|
||||
title: unwrappedTitle,
|
||||
isArchived: isArchived,
|
||||
byline: formattedByline,
|
||||
imageURL: imageURL,
|
||||
language: language,
|
||||
|
|
|
|||
|
|
@ -6,5 +6,4 @@ public enum ServerSyncStatus: Int {
|
|||
case needsDeletion
|
||||
case needsCreation
|
||||
case needsUpdate
|
||||
case needsMove
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,27 +3,20 @@ import Foundation
|
|||
import Models
|
||||
import Utils
|
||||
|
||||
struct PendingLink {
|
||||
let itemID: String
|
||||
let retryCount: Int
|
||||
}
|
||||
|
||||
extension DataService {
|
||||
func prefetchPage(pendingLink: PendingLink, username: String) async {
|
||||
let content = try? await loadArticleContent(username: username, itemID: pendingLink.itemID, useCache: true)
|
||||
public func prefetchPage(itemID: String, retryCount: Int, username: String) async {
|
||||
let content = try? await loadArticleContent(username: username, itemID: itemID, useCache: true)
|
||||
|
||||
if content?.contentStatus == .processing, pendingLink.retryCount < 7 {
|
||||
let retryDelayInNanoSeconds = UInt64(pendingLink.retryCount * 2 * 1_000_000_000)
|
||||
if content?.contentStatus == .processing, retryCount < 4 {
|
||||
let retryDelayInNanoSeconds = UInt64(retryCount * 2 * 1_000_000_000)
|
||||
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: retryDelayInNanoSeconds)
|
||||
logger.debug("fetching content for \(pendingLink.itemID). retry count: \(pendingLink.retryCount)")
|
||||
logger.debug("fetching content for \(itemID). retry count: \(retryCount)")
|
||||
|
||||
await prefetchPage(
|
||||
pendingLink: PendingLink(
|
||||
itemID: pendingLink.itemID,
|
||||
retryCount: pendingLink.retryCount + 1
|
||||
),
|
||||
itemID: itemID,
|
||||
retryCount: retryCount + 1,
|
||||
username: username
|
||||
)
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import AsyncAlgorithms
|
||||
import CoreData
|
||||
import CoreImage
|
||||
import Foundation
|
||||
|
|
@ -24,6 +25,9 @@ public final class DataService: ObservableObject {
|
|||
public let appEnvironment: AppEnvironment
|
||||
public let networker: Networker
|
||||
|
||||
public let prefetchQueue = OperationQueue()
|
||||
public let itemLoaderChannel = AsyncChannel<String>()
|
||||
|
||||
var persistentContainer: PersistentContainer
|
||||
public var backgroundContext: NSManagedObjectContext
|
||||
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@ public extension DataService {
|
|||
}
|
||||
}
|
||||
|
||||
try await syncMoveToFolder(itemID: itemID, folder: folder)
|
||||
syncMoveToFolder(itemID: itemID, folder: folder)
|
||||
}
|
||||
|
||||
func syncMoveToFolder(itemID: String, folder: String) async throws {
|
||||
func syncMoveToFolder(itemID: String, folder: String) {
|
||||
enum MutationResult {
|
||||
case result(success: Bool)
|
||||
case error(errorMessage: String)
|
||||
|
|
@ -48,23 +48,22 @@ public extension DataService {
|
|||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
let context = backgroundContext
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(mutation, to: path, headers: headers) { queryResult in
|
||||
guard let payload = try? queryResult.get() else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "network error"))
|
||||
return
|
||||
}
|
||||
send(mutation, to: path, headers: headers) { queryResult in
|
||||
let data = try? queryResult.get()
|
||||
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
|
||||
|
||||
switch payload.data {
|
||||
case let .result(success: success):
|
||||
if success {
|
||||
continuation.resume()
|
||||
} else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "operation failed"))
|
||||
}
|
||||
case let .error(errorMessage: errorMessage):
|
||||
continuation.resume(throwing: BasicError.message(messageText: errorMessage))
|
||||
context.perform {
|
||||
guard let linkedItem = LibraryItem.lookup(byID: itemID, inContext: context) else { return }
|
||||
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
logger.debug("LinkedItem updated succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
logger.debug("Failed to sync library item move: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,21 +151,18 @@ public extension DataService {
|
|||
case .needsUpdate:
|
||||
item.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
|
||||
syncLinkArchiveStatus(itemID: item.unwrappedID, archived: item.isArchived)
|
||||
syncLinkReadingProgress(
|
||||
itemID: item.unwrappedID,
|
||||
readingProgress: item.readingProgress,
|
||||
anchorIndex: Int(item.readingProgressAnchor),
|
||||
force: item.isPDF
|
||||
)
|
||||
case .needsMove:
|
||||
|
||||
item.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
|
||||
syncLinkArchiveStatus(itemID: item.unwrappedID, archived: item.isArchived)
|
||||
syncLinkReadingProgress(
|
||||
itemID: item.unwrappedID,
|
||||
readingProgress: item.readingProgress,
|
||||
anchorIndex: Int(item.readingProgressAnchor),
|
||||
force: item.isPDF
|
||||
)
|
||||
// If the items folder might have changed, sync that.
|
||||
if let itemID = item.id, let folder = item.folder, folder != "following" {
|
||||
syncMoveToFolder(itemID: itemID, folder: folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -193,9 +190,6 @@ public extension DataService {
|
|||
} else {
|
||||
highlight.serverSyncStatus = Int64(ServerSyncStatus.isNSync.rawValue)
|
||||
}
|
||||
case .needsMove:
|
||||
// Highlights can't be moved
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,10 @@ import Models
|
|||
import Utils
|
||||
|
||||
public extension DataService {
|
||||
func prefetchPages(itemIDs: [String], username: String) async {
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for itemID in itemIDs {
|
||||
group.addTask {
|
||||
await self.prefetchPage(pendingLink: PendingLink(itemID: itemID, retryCount: 1), username: username)
|
||||
}
|
||||
}
|
||||
await group.waitForAll()
|
||||
}
|
||||
func prefetchPages(itemIDs _: [String], username _: String) async {
|
||||
// for itemID in itemIDs {
|
||||
// prefetchQueue.addOperation(PrefetchJob)
|
||||
// }
|
||||
}
|
||||
|
||||
func loadArticleContentWithRetries(
|
||||
|
|
|
|||
|
|
@ -15,17 +15,28 @@ public extension DataService {
|
|||
|
||||
LibraryItem.deleteItems(ids: fetchResult.deletedItemIDs, context: backgroundContext)
|
||||
|
||||
if fetchResult.items.persist(context: backgroundContext) == nil {
|
||||
throw BasicError.message(messageText: "CoreData error")
|
||||
if !fetchResult.newItems.isEmpty {
|
||||
if fetchResult.newItems.persist(context: backgroundContext) == nil {
|
||||
throw BasicError.message(messageText: "CoreData error")
|
||||
}
|
||||
}
|
||||
|
||||
let newestChange = fetchResult.items.max { $0.updatedAt < $1.updatedAt }
|
||||
if !fetchResult.updatedItems.isEmpty {
|
||||
if fetchResult.updatedItems.persist(context: backgroundContext) == nil {
|
||||
throw BasicError.message(messageText: "CoreData error")
|
||||
}
|
||||
}
|
||||
|
||||
let newestChange = fetchResult.updatedItems.max { $0.updatedAt < $1.updatedAt }
|
||||
let oldestChange = fetchResult.updatedItems.min { $0.updatedAt < $1.updatedAt }
|
||||
|
||||
let result = LinkedItemSyncResult(
|
||||
updatedItemIDs: fetchResult.items.map(\.id),
|
||||
updatedItemIDs: fetchResult.updatedItems.map(\.id),
|
||||
cursor: fetchResult.cursor,
|
||||
hasMore: fetchResult.hasMoreItems,
|
||||
mostRecentUpdatedAt: newestChange?.updatedAt,
|
||||
isEmpty: fetchResult.deletedItemIDs.isEmpty && fetchResult.items.isEmpty
|
||||
oldestUpdatedAt: oldestChange?.updatedAt,
|
||||
isEmpty: fetchResult.deletedItemIDs.isEmpty && fetchResult.updatedItems.isEmpty
|
||||
)
|
||||
|
||||
return result
|
||||
|
|
@ -42,16 +53,12 @@ public extension DataService {
|
|||
searchQuery: String?,
|
||||
cursor: String?
|
||||
) async throws -> LinkedItemQueryResult {
|
||||
// Send offline changes to server before fetching items
|
||||
// try? await syncOfflineItemsWithServerIfNeeded()
|
||||
|
||||
let fetchResult = try await fetchLinkedItems(limit: limit, searchQuery: searchQuery, cursor: cursor)
|
||||
|
||||
guard let itemIDs = fetchResult.items.persist(context: backgroundContext) else {
|
||||
throw BasicError.message(messageText: "CoreData error")
|
||||
}
|
||||
|
||||
return LinkedItemQueryResult(itemIDs: itemIDs, cursor: fetchResult.cursor)
|
||||
return LinkedItemQueryResult(itemIDs: itemIDs, cursor: fetchResult.cursor, totalCount: fetchResult.totalCount)
|
||||
}
|
||||
|
||||
/// Requests a single `LinkedItem` from the server and stores it in CoreData
|
||||
|
|
|
|||
|
|
@ -43,12 +43,14 @@ extension DataService {
|
|||
slug: try $0.slug(),
|
||||
isArchived: try $0.isArchived(),
|
||||
contentReader: try $0.contentReader().rawValue,
|
||||
htmlContent: try $0.content(),
|
||||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
wordsCount: try $0.wordsCount(),
|
||||
downloadURL: try $0.url(),
|
||||
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [],
|
||||
highlights: try $0.highlights(selection: highlightSelection.list)
|
||||
),
|
||||
htmlContent: try $0.content(),
|
||||
highlights: try $0.highlights(selection: highlightSelection.list)
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import SwiftGraphQL
|
|||
struct InternalLinkedItemQueryResult {
|
||||
let items: [InternalLibraryItem]
|
||||
let cursor: String?
|
||||
let totalCount: Int?
|
||||
}
|
||||
|
||||
struct InternalLinkedItemUpdatesQueryResult {
|
||||
let items: [InternalLibraryItem]
|
||||
let deletedItemIDs: [String]
|
||||
let newItems: [InternalLibraryItem]
|
||||
let updatedItems: [InternalLibraryItem]
|
||||
let cursor: String?
|
||||
let hasMoreItems: Bool
|
||||
let totalCount: Int
|
||||
|
|
@ -19,6 +21,7 @@ struct InternalLinkedItemUpdatesQueryResult {
|
|||
private struct SyncItemEdge {
|
||||
let itemID: String
|
||||
let isDeletedItem: Bool
|
||||
let isUpdatedItem: Bool
|
||||
let item: InternalLibraryItem?
|
||||
}
|
||||
|
||||
|
|
@ -90,21 +93,25 @@ extension DataService {
|
|||
|
||||
switch payload.data {
|
||||
case let .success(result: result):
|
||||
var items = [InternalLibraryItem]()
|
||||
var newItems = [InternalLibraryItem]()
|
||||
var updatedItems = [InternalLibraryItem]()
|
||||
var deletedItemIDs = [String]()
|
||||
|
||||
for edge in result.edges {
|
||||
if edge.isDeletedItem {
|
||||
deletedItemIDs.append(edge.itemID)
|
||||
} else if let item = edge.item, edge.isUpdatedItem {
|
||||
updatedItems.append(item)
|
||||
} else if let item = edge.item {
|
||||
items.append(item)
|
||||
newItems.append(item)
|
||||
}
|
||||
}
|
||||
|
||||
continuation.resume(
|
||||
returning: InternalLinkedItemUpdatesQueryResult(
|
||||
items: items,
|
||||
deletedItemIDs: deletedItemIDs,
|
||||
newItems: newItems,
|
||||
updatedItems: updatedItems,
|
||||
cursor: result.cursor,
|
||||
hasMoreItems: result.hasMoreItems,
|
||||
totalCount: result.totalCount
|
||||
|
|
@ -144,6 +151,9 @@ extension DataService {
|
|||
items: try $0.edges(selection: searchItemEdgeSelection.list),
|
||||
cursor: try $0.pageInfo(selection: Selection.PageInfo {
|
||||
try $0.endCursor()
|
||||
}),
|
||||
totalCount: try $0.pageInfo(selection: Selection.PageInfo {
|
||||
try $0.totalCount()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
|
@ -275,12 +285,14 @@ private let libraryArticleSelection = Selection.Article {
|
|||
slug: try $0.slug(),
|
||||
isArchived: try $0.isArchived(),
|
||||
contentReader: try $0.contentReader().rawValue,
|
||||
htmlContent: try $0.content(),
|
||||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
wordsCount: try $0.wordsCount(),
|
||||
downloadURL: try $0.url(),
|
||||
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [],
|
||||
highlights: try $0.highlights(selection: highlightSelection.list)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -288,6 +300,7 @@ private let syncItemEdgeSelection = Selection.SyncUpdatedItemEdge {
|
|||
SyncItemEdge(
|
||||
itemID: try $0.itemId(),
|
||||
isDeletedItem: try $0.updateReason() == .deleted,
|
||||
isUpdatedItem: try $0.updateReason() == .updated,
|
||||
item: try $0.node(selection: searchItemSelection.nullable)
|
||||
)
|
||||
}
|
||||
|
|
@ -316,12 +329,14 @@ private let searchItemSelection = Selection.SearchItem {
|
|||
slug: try $0.slug(),
|
||||
isArchived: try $0.isArchived(),
|
||||
contentReader: try $0.contentReader().rawValue,
|
||||
htmlContent: try $0.content(),
|
||||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
wordsCount: try $0.wordsCount(),
|
||||
downloadURL: try $0.url(),
|
||||
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? [],
|
||||
highlights: try $0.highlights(selection: highlightSelection.list.nullable) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
|
|||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
public static var DownloadedFilter: InternalFilter {
|
||||
public static var InboxDownloadedFilter: InternalFilter {
|
||||
InternalFilter(
|
||||
id: "downloaded",
|
||||
id: "inbox-downloaded",
|
||||
name: "Downloaded",
|
||||
folder: "inbox",
|
||||
filter: "",
|
||||
|
|
@ -27,9 +27,9 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
|
|||
)
|
||||
}
|
||||
|
||||
public static var DeletedFilter: InternalFilter {
|
||||
public static var InboxDeletedFilter: InternalFilter {
|
||||
InternalFilter(
|
||||
id: "deleted",
|
||||
id: "inbox-deleted",
|
||||
name: "Deleted",
|
||||
folder: "inbox",
|
||||
filter: "in:trash",
|
||||
|
|
@ -39,7 +39,43 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
|
|||
)
|
||||
}
|
||||
|
||||
public static var UnreadFilter: InternalFilter {
|
||||
public static var InboxUnreadFilter: InternalFilter {
|
||||
InternalFilter(
|
||||
id: "inbox-unread",
|
||||
name: "Unread",
|
||||
folder: "inbox",
|
||||
filter: "in:inbox is:unread",
|
||||
visible: true,
|
||||
position: -1,
|
||||
defaultFilter: true
|
||||
)
|
||||
}
|
||||
|
||||
public static var FollowingDownloadedFilter: InternalFilter {
|
||||
InternalFilter(
|
||||
id: "following-downloaded",
|
||||
name: "Downloaded",
|
||||
folder: "following",
|
||||
filter: "",
|
||||
visible: true,
|
||||
position: -1,
|
||||
defaultFilter: true
|
||||
)
|
||||
}
|
||||
|
||||
public static var FollowingDeletedFilter: InternalFilter {
|
||||
InternalFilter(
|
||||
id: "following-deleted",
|
||||
name: "Deleted",
|
||||
folder: "following",
|
||||
filter: "in:trash",
|
||||
visible: true,
|
||||
position: -1,
|
||||
defaultFilter: true
|
||||
)
|
||||
}
|
||||
|
||||
public static var FollowingUnreadFilter: InternalFilter {
|
||||
InternalFilter(
|
||||
id: "unread",
|
||||
name: "Unread",
|
||||
|
|
|
|||
|
|
@ -25,12 +25,14 @@ struct InternalLibraryItem {
|
|||
let slug: String
|
||||
let isArchived: Bool
|
||||
let contentReader: String?
|
||||
let htmlContent: String?
|
||||
let originalHtml: String?
|
||||
let language: String?
|
||||
let wordsCount: Int?
|
||||
let downloadURL: String
|
||||
let recommendations: [InternalRecommendation]
|
||||
var labels: [InternalLinkedItemLabel]
|
||||
var highlights: [InternalHighlight]
|
||||
|
||||
var isPDF: Bool {
|
||||
if let contentReader = contentReader {
|
||||
|
|
@ -65,6 +67,7 @@ struct InternalLibraryItem {
|
|||
linkedItem.slug = slug
|
||||
linkedItem.isArchived = isArchived
|
||||
linkedItem.contentReader = contentReader
|
||||
linkedItem.htmlContent = htmlContent
|
||||
linkedItem.originalHtml = originalHtml
|
||||
linkedItem.language = language
|
||||
linkedItem.wordsCount = Int64(wordsCount ?? 0)
|
||||
|
|
@ -87,6 +90,15 @@ struct InternalLibraryItem {
|
|||
linkedItem.addToRecommendations(recommendation.asManagedObject(inContext: context))
|
||||
}
|
||||
|
||||
// Remove existing labels in case a label had been deleted
|
||||
if let existingHighlights = linkedItem.highlights {
|
||||
linkedItem.removeFromHighlights(existingHighlights)
|
||||
}
|
||||
|
||||
for highlight in highlights {
|
||||
linkedItem.addToHighlights(highlight.asManagedObject(context: context))
|
||||
}
|
||||
|
||||
return linkedItem
|
||||
}
|
||||
}
|
||||
|
|
@ -148,12 +160,14 @@ extension JSONArticle {
|
|||
slug: slug,
|
||||
isArchived: isArchived,
|
||||
contentReader: contentReader,
|
||||
htmlContent: nil,
|
||||
originalHtml: nil,
|
||||
language: language,
|
||||
wordsCount: wordsCount,
|
||||
downloadURL: downloadURL,
|
||||
recommendations: [],
|
||||
labels: []
|
||||
labels: [],
|
||||
highlights: []
|
||||
)
|
||||
|
||||
context.performAndWait {
|
||||
|
|
|
|||
|
|
@ -10,12 +10,26 @@ public enum PublicValet {
|
|||
public static var authToken: String? {
|
||||
ValetKey.authToken.value()
|
||||
}
|
||||
|
||||
public static var hideLabels: Bool {
|
||||
get {
|
||||
ValetKey.hideLabels.exists
|
||||
}
|
||||
set {
|
||||
if newValue {
|
||||
try? ValetKey.hideLabels.setValue("true")
|
||||
} else {
|
||||
ValetKey.hideLabels.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ValetKey: String {
|
||||
case authToken = "app.omnivore.valet.auth-token"
|
||||
case authCookieString = "app.omnivore.valet.auth-cookie-raw-string"
|
||||
case appEnvironmentString = "app.omnivore.valet.app-environment"
|
||||
case hideLabels = "app.omnivore.valet.hide-labels"
|
||||
}
|
||||
|
||||
extension ValetKey {
|
||||
|
|
@ -23,6 +37,10 @@ extension ValetKey {
|
|||
value() != nil
|
||||
}
|
||||
|
||||
func clear() {
|
||||
try? ValetKey.valet.removeObject(forKey: rawValue)
|
||||
}
|
||||
|
||||
func value() -> String? {
|
||||
try? ValetKey.valet.string(forKey: rawValue)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Foundation
|
||||
|
||||
public enum TrackableEvent {
|
||||
case linkRead(linkID: String, slug: String, originalArticleURL: String)
|
||||
case linkRead(linkID: String, slug: String, reader: String, originalArticleURL: String)
|
||||
case debugMessage(message: String)
|
||||
case backgroundFetch(jobStatus: BackgroundFetchJobStatus, itemCount: Int, secondsElapsed: Int)
|
||||
case audioSessionStart(linkID: String)
|
||||
|
|
@ -33,10 +33,11 @@ public extension TrackableEvent {
|
|||
|
||||
var properties: [String: String]? {
|
||||
switch self {
|
||||
case let .linkRead(linkID: linkID, slug: slug, originalArticleURL: originalArticleURL):
|
||||
case let .linkRead(linkID: linkID, slug: slug, reader: reader, originalArticleURL: originalArticleURL):
|
||||
return [
|
||||
"link": linkID,
|
||||
"slug": slug,
|
||||
"reader": reader,
|
||||
"url": originalArticleURL
|
||||
]
|
||||
case let .debugMessage(message: message):
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ public enum UserDefaultKey: String {
|
|||
case recentSearchTerms
|
||||
case audioPlayerExpanded
|
||||
case themeName
|
||||
case openAIPrimerDisplayed
|
||||
case followingPrimerDisplayed
|
||||
case notificationsEnabled
|
||||
case deviceTokenID
|
||||
case userWordsPerMinute
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ public extension Color {
|
|||
static var themeLabelForeground: Color { Color("_labelForeground", bundle: .module) }
|
||||
static var themeLabelBackground: Color { Color("_labelBackground", bundle: .module) }
|
||||
|
||||
static var toolbarItemForeground: Color { Color("toolbarItemForeground", bundle: .module) }
|
||||
|
||||
static var themeAudioPlayerGray: Color { Color("_audioPlayerGray", bundle: .module) }
|
||||
static var themeGrayBg01: Color { Color("_themeGrayBg01", bundle: .module) }
|
||||
static var themeHighlightColor: Color { Color("_highlightColor", bundle: .module) }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x3C",
|
||||
"green" : "0x3C",
|
||||
"red" : "0x3C"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xD9",
|
||||
"green" : "0xD9",
|
||||
"red" : "0xD9"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -11,62 +11,12 @@ public enum GridCardAction {
|
|||
}
|
||||
|
||||
public struct GridCard: View {
|
||||
@Binding var isContextMenuOpen: Bool
|
||||
let item: LibraryItemData
|
||||
let actionHandler: (GridCardAction) -> Void
|
||||
// let tapAction: () -> Void
|
||||
|
||||
public init(
|
||||
item: LibraryItemData,
|
||||
isContextMenuOpen: Binding<Bool>,
|
||||
actionHandler: @escaping (GridCardAction) -> Void
|
||||
item: LibraryItemData
|
||||
) {
|
||||
self.item = item
|
||||
self._isContextMenuOpen = isContextMenuOpen
|
||||
self.actionHandler = actionHandler
|
||||
}
|
||||
|
||||
// Menu doesn't provide an API to observe it's open state
|
||||
// so we have keep track of it's state manually
|
||||
func tapHandler() {
|
||||
if isContextMenuOpen {
|
||||
isContextMenuOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
func menuActionHandler(_ action: GridCardAction) {
|
||||
isContextMenuOpen = false
|
||||
actionHandler(action)
|
||||
}
|
||||
|
||||
var contextMenuView: some View {
|
||||
Group {
|
||||
Button(
|
||||
action: { menuActionHandler(.viewHighlights) },
|
||||
label: { Label("Notebook", systemImage: "highlighter") }
|
||||
)
|
||||
Button(
|
||||
action: { menuActionHandler(.editTitle) },
|
||||
label: { Label("Edit Info", systemImage: "info.circle") }
|
||||
)
|
||||
Button(
|
||||
action: { menuActionHandler(.editLabels) },
|
||||
label: { Label(item.sortedLabels.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
|
||||
)
|
||||
Button(
|
||||
action: { menuActionHandler(.toggleArchiveStatus) },
|
||||
label: {
|
||||
Label(
|
||||
item.isArchived ? "Unarchive" : "Archive",
|
||||
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
|
||||
)
|
||||
}
|
||||
)
|
||||
Button(
|
||||
action: { menuActionHandler(.delete) },
|
||||
label: { Label("Delete", systemImage: "trash") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var imageBox: some View {
|
||||
|
|
@ -159,6 +109,117 @@ public struct GridCard: View {
|
|||
}
|
||||
}
|
||||
|
||||
var readingSpeed: Int64 {
|
||||
var result = UserDefaults.standard.integer(forKey: UserDefaultKey.userWordsPerMinute.rawValue)
|
||||
if result <= 0 {
|
||||
result = 235
|
||||
}
|
||||
return Int64(result)
|
||||
}
|
||||
|
||||
var estimatedReadingTime: String {
|
||||
if item.wordsCount > 0 {
|
||||
let readLen = max(1, item.wordsCount / readingSpeed)
|
||||
return "\(readLen) MIN READ • "
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var readingProgress: String {
|
||||
// If there is no wordsCount don't show progress because it will make no sense
|
||||
if item.wordsCount > 0 {
|
||||
return "\(String(format: "%d", Int(item.readingProgress)))%"
|
||||
}
|
||||
if item.isPDF {
|
||||
// base estimated reading time on page count
|
||||
return "\(String(format: "%d", Int(item.readingProgress)))%"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var hasMultipleInfoItems: Bool {
|
||||
item.wordsCount > 0 || item.highlights?.first { ($0 as? Highlight)?.annotation != nil } != nil
|
||||
}
|
||||
|
||||
var highlightsText: String {
|
||||
if let highlights = item.highlights, highlights.count > 0 {
|
||||
let fmted = LocalText.pluralizedText(key: "number_of_highlights", count: highlights.count)
|
||||
if item.wordsCount > 0 || item.isPDF {
|
||||
return " • \(fmted)"
|
||||
}
|
||||
return fmted
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var notesText: String {
|
||||
let notes = item.highlights?.filter { item in
|
||||
if let highlight = item as? Highlight {
|
||||
return !(highlight.annotation ?? "").isEmpty
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if let notes = notes, notes.count > 0 {
|
||||
let fmted = LocalText.pluralizedText(key: "number_of_notes", count: notes.count)
|
||||
if hasMultipleInfoItems {
|
||||
return " • \(fmted)"
|
||||
}
|
||||
return fmted
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var flairLabels: [FlairLabels] {
|
||||
item.sortedLabels.compactMap { label in
|
||||
if let name = label.name {
|
||||
return FlairLabels(rawValue: name.lowercased())
|
||||
}
|
||||
return nil
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
}
|
||||
|
||||
var isPartiallyRead: Bool {
|
||||
Int(item.readingProgress) > 0
|
||||
}
|
||||
|
||||
var nonFlairLabels: [LinkedItemLabel] {
|
||||
item.sortedLabels.filter { label in
|
||||
if let name = label.name, FlairLabels(rawValue: name.lowercased()) != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var readInfo: some View {
|
||||
HStack(alignment: .center, spacing: 5.0) {
|
||||
ForEach(flairLabels, id: \.self) {
|
||||
$0.icon
|
||||
}
|
||||
|
||||
Text("\(estimatedReadingTime)")
|
||||
.font(.caption2).fontWeight(.medium)
|
||||
.foregroundColor(Color.themeLibraryItemSubtle)
|
||||
|
||||
+
|
||||
Text("\(readingProgress)")
|
||||
.font(.caption2).fontWeight(.medium)
|
||||
.foregroundColor(isPartiallyRead ? Color.appGreenSuccess : Color.themeLibraryItemSubtle)
|
||||
|
||||
+
|
||||
Text("\(highlightsText)")
|
||||
.font(.caption2).fontWeight(.medium)
|
||||
.foregroundColor(Color.themeLibraryItemSubtle)
|
||||
|
||||
+
|
||||
Text("\(notesText)")
|
||||
.font(.caption2).fontWeight(.medium)
|
||||
.foregroundColor(Color.themeLibraryItemSubtle)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader { geo in
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
|
|
@ -167,17 +228,19 @@ public struct GridCard: View {
|
|||
.frame(height: geo.size.height / 2.0)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(item.title)
|
||||
.font(.appHeadline)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.lineLimit(1)
|
||||
}
|
||||
readInfo
|
||||
.dynamicTypeSize(.xSmall ... .medium)
|
||||
.padding(.horizontal, 15)
|
||||
|
||||
Text(item.title)
|
||||
.lineLimit(2)
|
||||
.font(.appHeadline)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.padding(.horizontal, 15)
|
||||
|
||||
byLine
|
||||
.padding(.horizontal, 15)
|
||||
}
|
||||
.frame(height: 30)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 10)
|
||||
.padding(.top, 10)
|
||||
|
||||
|
|
@ -186,30 +249,21 @@ public struct GridCard: View {
|
|||
Text(item.descriptionText ?? item.title)
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.lineLimit(3)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.horizontal, 15)
|
||||
|
||||
// Category Labels
|
||||
if item.hasLabels {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(item.sortedLabels, id: \.self) {
|
||||
TextChip(feedItemLabel: $0)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
if !nonFlairLabels.isEmpty {
|
||||
LabelsFlowLayout(labels: nonFlairLabels)
|
||||
.padding(.horizontal, 15)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 0)
|
||||
.padding(.top, 0)
|
||||
}
|
||||
.contextMenu { contextMenuView }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import Utils
|
|||
|
||||
public enum WebFont: String, CaseIterable {
|
||||
case inter = "Inter"
|
||||
case system = "unset"
|
||||
case system = "system-ui"
|
||||
case merriweather = "Merriweather"
|
||||
case lora = "Lora"
|
||||
case opensans = "Open Sans"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
// Labels View
|
||||
"labelsViewAssignNameColor" = "Assign a name and color.";
|
||||
"createLabelMessage" = "Create a new Label";
|
||||
"createLabelMessage" = "Create a new label";
|
||||
"labelsPurposeDescription" = "Use labels to create curated collections of links.";
|
||||
"labelNamePlaceholder" = "Label Name";
|
||||
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
"devicesCreated" = "Created: ";
|
||||
|
||||
// Push Notification Settings
|
||||
"notificationsEnabled" = "Notifications Enabled";
|
||||
"notificationsEnabled" = "Notifications enabled";
|
||||
"notificationsExplainer" = "Enabling push notifications gives Omnivore device permission to send notifications,\nbut you are in charge of which notifications are sent.";
|
||||
"notificationsTriggerExplainer" = "Push notifications are triggered using your \n[account rules](https://omnivore.app/settings/rules) which you can edit online.";
|
||||
"notificationsEnable" = "Enable Push Notifications?";
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ public struct SyncStatusIcon: View {
|
|||
switch status {
|
||||
case .isNSync:
|
||||
return "exclamationmark.icloud"
|
||||
case .isSyncing, .needsCreation, .needsDeletion, .needsUpdate, .needsMove:
|
||||
case .isSyncing, .needsCreation, .needsDeletion, .needsUpdate:
|
||||
return "icloud"
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ public struct SyncStatusIcon: View {
|
|||
switch status {
|
||||
case .isNSync:
|
||||
return .red
|
||||
case .isSyncing, .needsCreation, .needsDeletion, .needsUpdate, .needsMove:
|
||||
case .isSyncing, .needsCreation, .needsDeletion, .needsUpdate:
|
||||
return .appGrayText
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 844 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 711 B |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 7 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
|
@ -269,10 +269,12 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||
|
||||
return (
|
||||
<>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href={`/static/highlightjs/${highlightTheme}.min.css`}
|
||||
/>
|
||||
{!props.isAppleAppEmbed && (
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href={`/static/highlightjs/${highlightTheme}.min.css`}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
ref={articleContentRef}
|
||||
css={{
|
||||
|
|
|
|||
|
|
@ -258,8 +258,8 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
|||
const share = () => {
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: title ?? props.article.title,
|
||||
text: title ?? props.article.title,
|
||||
title: (title ?? props.article.title) + '\n',
|
||||
text: (title ?? props.article.title) + '\n',
|
||||
url: props.article.originalArticleUrl,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -243,6 +243,7 @@ export default function Home(): JSX.Element {
|
|||
posthog.capture('link_read', {
|
||||
link: article.id,
|
||||
slug: article.slug,
|
||||
reader: article.contentReader,
|
||||
url: article.originalArticleUrl,
|
||||
})
|
||||
}
|
||||
|
|
|
|||