Merge pull request #3288 from omnivore-app/feat/ios-accesibility-issues

iOS 1.40
This commit is contained in:
Jackson Harper 2024-01-02 10:10:40 +08:00 committed by GitHub
commit 29d3935d67
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
91 changed files with 1146 additions and 770 deletions

File diff suppressed because one or more lines are too long

View file

@ -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"),

View file

@ -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",

View file

@ -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"))

View file

@ -124,6 +124,7 @@ public struct ShareExtensionView: View {
Spacer()
}
})
.buttonStyle(.plain)
.foregroundColor(hasNoteText ?
Color.appGrayTextContrast : Color.extensionTextSubtle
)

View file

@ -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
}
}
}

View file

@ -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())
}

View file

@ -129,6 +129,7 @@
.padding()
}
)
.buttonStyle(.plain)
.frame(width: 16, height: 16, alignment: .center)
.onTapGesture { isContextMenuOpen = true }
}

View file

@ -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")
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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")
}
}
}
}

View file

@ -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)
}

View file

@ -68,7 +68,7 @@ import Views
action: {
addLinkPresented = true
},
label: { Label("Add Link", systemImage: "plus") }
label: { Label("Add link", systemImage: "plus") }
)
}

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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
}
}
}

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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
)
)

View file

@ -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
})
}
)
}

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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 {

View file

@ -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) {

View file

@ -25,6 +25,7 @@ import Views
.linkRead(
linkID: item.unwrappedID,
slug: item.unwrappedSlug,
reader: "WEB",
originalArticleURL: item.unwrappedPageURLString
)
)

View file

@ -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,

View file

@ -6,5 +6,4 @@ public enum ServerSyncStatus: Int {
case needsDeletion
case needsCreation
case needsUpdate
case needsMove
}

View file

@ -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 {

View file

@ -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

View file

@ -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)")
}
}
}

View file

@ -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
}
}
}

View file

@ -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(

View file

@ -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

View file

@ -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)

View file

@ -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) ?? []
)
}

View file

@ -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",

View file

@ -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 {

View file

@ -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)
}

View file

@ -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):

View file

@ -28,7 +28,7 @@ public enum UserDefaultKey: String {
case recentSearchTerms
case audioPlayerExpanded
case themeName
case openAIPrimerDisplayed
case followingPrimerDisplayed
case notificationsEnabled
case deviceTokenID
case userWordsPerMinute

View file

@ -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) }

View file

@ -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
}
}

View file

@ -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 }
}
}
}

View file

@ -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"

File diff suppressed because one or more lines are too long

View file

@ -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?";

View file

@ -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
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

View file

@ -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={{

View file

@ -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,
})
}

View file

@ -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,
})
}