Compare commits
54 Commits
15699b0b85
...
e242510c5e
Author | SHA1 | Date |
---|---|---|
Shadowfacts | e242510c5e | |
Shadowfacts | ed0a2f1ba3 | |
Shadowfacts | c89df7604b | |
Shadowfacts | 53d4b0bae8 | |
Shadowfacts | dbfc76fc6a | |
Shadowfacts | b8a415b6fd | |
Shadowfacts | 75be4141dd | |
Shadowfacts | df00108dae | |
Shadowfacts | 0450fe2c0e | |
Shadowfacts | be579b849d | |
Shadowfacts | c276bbdea6 | |
Shadowfacts | 6fbda7dc78 | |
Shadowfacts | 1726a7c711 | |
Shadowfacts | 36fda4d51f | |
Shadowfacts | 1d2e666c00 | |
Shadowfacts | 0be678063b | |
Shadowfacts | 949f2bca01 | |
Shadowfacts | c3d0174f23 | |
Shadowfacts | dbd274f57c | |
Shadowfacts | 4b0bda88b8 | |
Shadowfacts | 83c3bc927e | |
Shadowfacts | f8026125cc | |
Shadowfacts | 30ec5e54e0 | |
Shadowfacts | a368bc4365 | |
Shadowfacts | be788bd0a6 | |
Shadowfacts | 9deafa4b33 | |
Shadowfacts | 4c4044c382 | |
Shadowfacts | b2a8174099 | |
Shadowfacts | b2b99c6a11 | |
Shadowfacts | 503d35f301 | |
Shadowfacts | 8e6bf219c8 | |
Shadowfacts | 415340882e | |
Shadowfacts | e1296223fe | |
Shadowfacts | 85819ea6aa | |
Shadowfacts | 755f98a2e2 | |
Shadowfacts | 703b936676 | |
Shadowfacts | c9b12a6b70 | |
Shadowfacts | 12e0e3cdfd | |
Shadowfacts | 3d07069ee5 | |
Shadowfacts | a1cf4a5789 | |
Shadowfacts | 736e8283e1 | |
Shadowfacts | 96255b2a1f | |
Shadowfacts | b4d288bd29 | |
Shadowfacts | ba186fd1b2 | |
Shadowfacts | 220fbf7b75 | |
Shadowfacts | f53f198071 | |
Shadowfacts | dab4d6075a | |
Shadowfacts | 61f073109c | |
Shadowfacts | dd71c06257 | |
Shadowfacts | 8d385e61f5 | |
Shadowfacts | eded49b266 | |
Shadowfacts | 2b38b883fe | |
Shadowfacts | 8acc303a80 | |
Shadowfacts | 3ca42e9916 |
|
@ -0,0 +1,3 @@
|
|||
[submodule "lol-html"]
|
||||
path = lol-html
|
||||
url = https://github.com/cloudflare/lol-html.git
|
|
@ -10,7 +10,7 @@ import Foundation
|
|||
public struct Feed: Decodable {
|
||||
public let id: FervorID
|
||||
public let title: String
|
||||
public let url: URL
|
||||
public let url: URL?
|
||||
public let serviceURL: URL?
|
||||
public let feedURL: URL
|
||||
public let lastUpdated: Date
|
||||
|
@ -21,7 +21,7 @@ public struct Feed: Decodable {
|
|||
|
||||
self.id = try container.decode(FervorID.self, forKey: .id)
|
||||
self.title = try container.decode(String.self, forKey: .title)
|
||||
self.url = try container.decode(URL.self, forKey: .url)
|
||||
self.url = try container.decode(URL?.self, forKey: .url)
|
||||
self.serviceURL = try container.decodeIfPresent(URL.self, forKey: .serviceURL)
|
||||
self.feedURL = try container.decode(URL.self, forKey: .feedURL)
|
||||
self.lastUpdated = try container.decode(Date.self, forKey: .lastUpdated)
|
||||
|
|
|
@ -8,4 +8,4 @@
|
|||
import Foundation
|
||||
|
||||
// todo: fervor: ids should be strings
|
||||
public typealias FervorID = Int
|
||||
public typealias FervorID = String
|
||||
|
|
|
@ -13,7 +13,26 @@ public class FervorClient {
|
|||
let session: URLSession
|
||||
public var accessToken: String?
|
||||
|
||||
private let decoder = JSONDecoder()
|
||||
private let decoder: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
let withFractionalSeconds = ISO8601DateFormatter()
|
||||
withFractionalSeconds.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let without = ISO8601DateFormatter()
|
||||
without.formatOptions = [.withInternetDateTime]
|
||||
// because fucking ISO8601DateFormatter isn't a DateFormatter
|
||||
d.dateDecodingStrategy = .custom({ decoder in
|
||||
let s = try decoder.singleValueContainer().decode(String.self)
|
||||
// try both because Elixir's DateTime.to_iso8601 omits the .0 if the date doesn't have fractional seconds
|
||||
if let d = withFractionalSeconds.date(from: s) {
|
||||
return d
|
||||
} else if let d = without.date(from: s) {
|
||||
return d
|
||||
} else {
|
||||
throw DateDecodingError()
|
||||
}
|
||||
})
|
||||
return d
|
||||
}()
|
||||
|
||||
public init(instanceURL: URL, accessToken: String?, session: URLSession = .shared) {
|
||||
self.instanceURL = instanceURL
|
||||
|
@ -21,9 +40,10 @@ public class FervorClient {
|
|||
self.session = session
|
||||
}
|
||||
|
||||
private func buildURL(path: String) -> URL {
|
||||
private func buildURL(path: String, queryItems: [URLQueryItem] = []) -> URL {
|
||||
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
|
||||
components.path = path
|
||||
components.queryItems = queryItems
|
||||
return components.url!
|
||||
}
|
||||
|
||||
|
@ -32,7 +52,10 @@ public class FervorClient {
|
|||
if let accessToken = accessToken {
|
||||
request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
let (data, _) = try await session.data(for: request, delegate: nil)
|
||||
let (data, response) = try await session.data(for: request, delegate: nil)
|
||||
if (response as! HTTPURLResponse).statusCode == 404 {
|
||||
throw Error.notFound
|
||||
}
|
||||
let decoded = try decoder.decode(T.self, from: data)
|
||||
return decoded
|
||||
}
|
||||
|
@ -61,6 +84,53 @@ public class FervorClient {
|
|||
return try await performRequest(request)
|
||||
}
|
||||
|
||||
public func groups() async throws -> [Group] {
|
||||
let request = URLRequest(url: buildURL(path: "/api/v1/groups"))
|
||||
return try await performRequest(request)
|
||||
}
|
||||
|
||||
public func feeds() async throws -> [Feed] {
|
||||
let request = URLRequest(url: buildURL(path: "/api/v1/feeds"))
|
||||
return try await performRequest(request)
|
||||
}
|
||||
|
||||
public func syncItems(lastSync: Date?) async throws -> ItemsSyncUpdate {
|
||||
let request = URLRequest(url: buildURL(path: "/api/v1/items/sync", queryItems: [
|
||||
URLQueryItem(name: "last_sync", value: lastSync?.formatted(.iso8601))
|
||||
]))
|
||||
return try await performRequest(request)
|
||||
}
|
||||
|
||||
public func items(feed id: FervorID) async throws -> [Item] {
|
||||
let request = URLRequest(url: buildURL(path: "/api/v1/feeds/\(id)/items"))
|
||||
return try await performRequest(request)
|
||||
}
|
||||
|
||||
public func item(id: FervorID) async throws -> Item? {
|
||||
let request = URLRequest(url: buildURL(path: "/api/v1/items/\(id)"))
|
||||
do {
|
||||
return try await performRequest(request)
|
||||
} catch {
|
||||
if let error = error as? Error, case .notFound = error {
|
||||
return nil
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func read(item id: FervorID) async throws -> Item {
|
||||
var request = URLRequest(url: buildURL(path: "/api/v1/items/\(id)/read"))
|
||||
request.httpMethod = "POST"
|
||||
return try await performRequest(request)
|
||||
}
|
||||
|
||||
public func unread(item id: FervorID) async throws -> Item {
|
||||
var request = URLRequest(url: buildURL(path: "/api/v1/items/\(id)/unread"))
|
||||
request.httpMethod = "POST"
|
||||
return try await performRequest(request)
|
||||
}
|
||||
|
||||
public struct Auth {
|
||||
public let accessToken: String
|
||||
public let refreshToken: String?
|
||||
|
@ -69,6 +139,11 @@ public class FervorClient {
|
|||
public enum Error: Swift.Error {
|
||||
case urlSession(Swift.Error)
|
||||
case decode(Swift.Error)
|
||||
case notFound
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct DateDecodingError: Error {
|
||||
|
||||
}
|
||||
|
|
|
@ -8,25 +8,25 @@
|
|||
import Foundation
|
||||
|
||||
public struct Item: Decodable {
|
||||
let id: FervorID
|
||||
let feedID: FervorID
|
||||
let title: String
|
||||
let author: String
|
||||
let published: Date?
|
||||
let createdAt: Date?
|
||||
let content: String?
|
||||
let summary: String?
|
||||
let url: URL
|
||||
let serviceURL: URL?
|
||||
let read: Bool?
|
||||
public let id: FervorID
|
||||
public let feedID: FervorID
|
||||
public let title: String?
|
||||
public let author: String?
|
||||
public let published: Date?
|
||||
public let createdAt: Date?
|
||||
public let content: String?
|
||||
public let summary: String?
|
||||
public let url: URL
|
||||
public let serviceURL: URL?
|
||||
public let read: Bool?
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.id = try container.decode(FervorID.self, forKey: .id)
|
||||
self.feedID = try container.decode(FervorID.self, forKey: .feedID)
|
||||
self.title = try container.decode(String.self, forKey: .title)
|
||||
self.author = try container.decode(String.self, forKey: .author)
|
||||
self.title = try container.decodeIfPresent(String.self, forKey: .title)
|
||||
self.author = try container.decodeIfPresent(String.self, forKey: .author)
|
||||
self.published = try container.decodeIfPresent(Date.self, forKey: .published)
|
||||
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
|
||||
self.content = try container.decodeIfPresent(String.self, forKey: .content)
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
//
|
||||
// ItemsSyncUpdate.swift
|
||||
// Fervor
|
||||
//
|
||||
// Created by Shadowfacts on 1/9/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct ItemsSyncUpdate: Decodable {
|
||||
|
||||
public let syncTimestamp: Date
|
||||
public let delete: [FervorID]
|
||||
public let upsert: [Item]
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.syncTimestamp = try container.decode(Date.self, forKey: .syncTimestamp)
|
||||
self.delete = try container.decode([FervorID].self, forKey: .delete)
|
||||
self.upsert = try container.decode([Item].self, forKey: .upsert)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case syncTimestamp = "sync_timestamp"
|
||||
case delete
|
||||
case upsert
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
# Reader
|
||||
|
||||
In order to build reader you need the appropriate targets added to your Rust toolchain.
|
||||
|
||||
```sh
|
||||
$ rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
```
|
||||
|
||||
x86_64-apple-ios is only necessary if you're on an Intel Mac, and aarch-64-apple-ios-sim if you're on Apple Silicon.
|
||||
|
||||
The Xcode build script will take care of actually building the Rust code.
|
|
@ -15,6 +15,16 @@
|
|||
D65B18BC27504FE7004A9448 /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18BB27504FE7004A9448 /* Token.swift */; };
|
||||
D65B18BE275051A1004A9448 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18BD275051A1004A9448 /* LocalData.swift */; };
|
||||
D65B18C127505348004A9448 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18C027505348004A9448 /* HomeViewController.swift */; };
|
||||
D68408E827947D0800E327D2 /* PrefsSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68408E727947D0800E327D2 /* PrefsSceneDelegate.swift */; };
|
||||
D68408ED2794803D00E327D2 /* PrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68408EC2794803D00E327D2 /* PrefsView.swift */; };
|
||||
D68408EF2794808E00E327D2 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68408EE2794808E00E327D2 /* Preferences.swift */; };
|
||||
D68409132794870000E327D2 /* ReaderMac.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68409122794870000E327D2 /* ReaderMac.swift */; };
|
||||
D6840914279487DC00E327D2 /* ReaderMac.bundle in Embed PlugIns */ = {isa = PBXBuildFile; fileRef = D684090D279486BF00E327D2 /* ReaderMac.bundle */; platformFilter = maccatalyst; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
|
||||
D68B303627907D9200E8B3FA /* ExcerptGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68B303527907D9200E8B3FA /* ExcerptGenerator.swift */; };
|
||||
D68B303D2792204B00E8B3FA /* read.js in Resources */ = {isa = PBXBuildFile; fileRef = D68B303C2792204B00E8B3FA /* read.js */; };
|
||||
D68B30402792729A00E8B3FA /* AppSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68B303F2792729A00E8B3FA /* AppSplitViewController.swift */; };
|
||||
D68B304227932ED500E8B3FA /* UserActivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68B304127932ED500E8B3FA /* UserActivities.swift */; };
|
||||
D6A8A33427766C2800CCEC72 /* PersistentContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A8A33327766C2800CCEC72 /* PersistentContainer.swift */; };
|
||||
D6C687EC272CD27600874C10 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C687EB272CD27600874C10 /* AppDelegate.swift */; };
|
||||
D6C687EE272CD27600874C10 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C687ED272CD27600874C10 /* SceneDelegate.swift */; };
|
||||
D6C687F6272CD27600874C10 /* Reader.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */; };
|
||||
|
@ -31,9 +41,32 @@
|
|||
D6C68834272CD44900874C10 /* Fervor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68833272CD44900874C10 /* Fervor.swift */; };
|
||||
D6C68856272CD7C600874C10 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68855272CD7C600874C10 /* Item.swift */; };
|
||||
D6C68858272CD8CD00874C10 /* Group.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68857272CD8CD00874C10 /* Group.swift */; };
|
||||
D6E2434C278B456A0005E546 /* ItemsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E2434B278B456A0005E546 /* ItemsViewController.swift */; };
|
||||
D6E24352278B6DF90005E546 /* ItemsSyncUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E24351278B6DF90005E546 /* ItemsSyncUpdate.swift */; };
|
||||
D6E24357278B96E40005E546 /* Feed+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E24355278B96E40005E546 /* Feed+CoreDataClass.swift */; };
|
||||
D6E24358278B96E40005E546 /* Feed+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E24356278B96E40005E546 /* Feed+CoreDataProperties.swift */; };
|
||||
D6E2435D278B97240005E546 /* Item+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E24359278B97240005E546 /* Item+CoreDataClass.swift */; };
|
||||
D6E2435E278B97240005E546 /* Item+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E2435A278B97240005E546 /* Item+CoreDataProperties.swift */; };
|
||||
D6E2435F278B97240005E546 /* Group+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E2435B278B97240005E546 /* Group+CoreDataClass.swift */; };
|
||||
D6E24360278B97240005E546 /* Group+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E2435C278B97240005E546 /* Group+CoreDataProperties.swift */; };
|
||||
D6E24363278BA1410005E546 /* ItemCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E24361278BA1410005E546 /* ItemCollectionViewCell.swift */; };
|
||||
D6E24369278BABB40005E546 /* UIColor+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E24368278BABB40005E546 /* UIColor+App.swift */; };
|
||||
D6E2436B278BB1880005E546 /* HomeCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E2436A278BB1880005E546 /* HomeCollectionViewCell.swift */; };
|
||||
D6E2436E278BD8160005E546 /* ReadViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E2436D278BD8160005E546 /* ReadViewController.swift */; };
|
||||
D6E24371278BE1250005E546 /* HTMLEntities in Frameworks */ = {isa = PBXBuildFile; productRef = D6E24370278BE1250005E546 /* HTMLEntities */; };
|
||||
D6E24373278BE2B80005E546 /* read.css in Resources */ = {isa = PBXBuildFile; fileRef = D6E24372278BE2B80005E546 /* read.css */; };
|
||||
D6EB531D278C89C300AD2E61 /* AppNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EB531C278C89C300AD2E61 /* AppNavigationController.swift */; };
|
||||
D6EB531F278E4A7500AD2E61 /* StretchyMenuInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EB531E278E4A7500AD2E61 /* StretchyMenuInteraction.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
D6840915279487DC00E327D2 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = D6C687E0272CD27600874C10 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = D684090C279486BF00E327D2;
|
||||
remoteInfo = ReaderMac;
|
||||
};
|
||||
D6C68802272CD27700874C10 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = D6C687E0272CD27600874C10 /* Project object */;
|
||||
|
@ -58,6 +91,17 @@
|
|||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
D6840917279487DD00E327D2 /* Embed PlugIns */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
D6840914279487DC00E327D2 /* ReaderMac.bundle in Embed PlugIns */,
|
||||
);
|
||||
name = "Embed PlugIns";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D6C6882E272CD2BA00874C10 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
|
@ -80,6 +124,19 @@
|
|||
D65B18BB27504FE7004A9448 /* Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Token.swift; sourceTree = "<group>"; };
|
||||
D65B18BD275051A1004A9448 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; };
|
||||
D65B18C027505348004A9448 /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = "<group>"; };
|
||||
D68408E727947D0800E327D2 /* PrefsSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsSceneDelegate.swift; sourceTree = "<group>"; };
|
||||
D68408EC2794803D00E327D2 /* PrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefsView.swift; sourceTree = "<group>"; };
|
||||
D68408EE2794808E00E327D2 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
|
||||
D684090D279486BF00E327D2 /* ReaderMac.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReaderMac.bundle; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D68409122794870000E327D2 /* ReaderMac.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderMac.swift; sourceTree = "<group>"; };
|
||||
D68B3032278FDD1A00E8B3FA /* Reader-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Reader-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
D68B303527907D9200E8B3FA /* ExcerptGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExcerptGenerator.swift; sourceTree = "<group>"; };
|
||||
D68B3037279099FD00E8B3FA /* liblolhtml.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = liblolhtml.a; path = "lol-html/c-api/target/aarch64-apple-ios-sim/release/liblolhtml.a"; sourceTree = "<group>"; };
|
||||
D68B303C2792204B00E8B3FA /* read.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = read.js; sourceTree = "<group>"; };
|
||||
D68B303E27923C0000E8B3FA /* Reader.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Reader.entitlements; sourceTree = "<group>"; };
|
||||
D68B303F2792729A00E8B3FA /* AppSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSplitViewController.swift; sourceTree = "<group>"; };
|
||||
D68B304127932ED500E8B3FA /* UserActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivities.swift; sourceTree = "<group>"; };
|
||||
D6A8A33327766C2800CCEC72 /* PersistentContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentContainer.swift; sourceTree = "<group>"; };
|
||||
D6C687E8272CD27600874C10 /* Reader.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Reader.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
D6C687EB272CD27600874C10 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
D6C687ED272CD27600874C10 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
|
@ -99,14 +156,37 @@
|
|||
D6C68833272CD44900874C10 /* Fervor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fervor.swift; sourceTree = "<group>"; };
|
||||
D6C68855272CD7C600874C10 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; };
|
||||
D6C68857272CD8CD00874C10 /* Group.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Group.swift; sourceTree = "<group>"; };
|
||||
D6E2434B278B456A0005E546 /* ItemsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsViewController.swift; sourceTree = "<group>"; };
|
||||
D6E24351278B6DF90005E546 /* ItemsSyncUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsSyncUpdate.swift; sourceTree = "<group>"; };
|
||||
D6E24355278B96E40005E546 /* Feed+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Feed+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
D6E24356278B96E40005E546 /* Feed+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Feed+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||
D6E24359278B97240005E546 /* Item+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Item+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
D6E2435A278B97240005E546 /* Item+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Item+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||
D6E2435B278B97240005E546 /* Group+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Group+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
D6E2435C278B97240005E546 /* Group+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Group+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||
D6E24361278BA1410005E546 /* ItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D6E24368278BABB40005E546 /* UIColor+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+App.swift"; sourceTree = "<group>"; };
|
||||
D6E2436A278BB1880005E546 /* HomeCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D6E2436D278BD8160005E546 /* ReadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadViewController.swift; sourceTree = "<group>"; };
|
||||
D6E24372278BE2B80005E546 /* read.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = read.css; sourceTree = "<group>"; };
|
||||
D6EB531C278C89C300AD2E61 /* AppNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationController.swift; sourceTree = "<group>"; };
|
||||
D6EB531E278E4A7500AD2E61 /* StretchyMenuInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StretchyMenuInteraction.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
D684090A279486BF00E327D2 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D6C687E5272CD27600874C10 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D6C68829272CD2BA00874C10 /* Fervor.framework in Frameworks */,
|
||||
D6E24371278BE1250005E546 /* HTMLEntities in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -137,8 +217,13 @@
|
|||
D65B18AF2750468B004A9448 /* Screens */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6EB531C278C89C300AD2E61 /* AppNavigationController.swift */,
|
||||
D68B303F2792729A00E8B3FA /* AppSplitViewController.swift */,
|
||||
D65B18BF2750533E004A9448 /* Home */,
|
||||
D65B18B027504691004A9448 /* Login */,
|
||||
D6E2434A278B455C0005E546 /* Items */,
|
||||
D6E2436C278BD80B0005E546 /* Read */,
|
||||
D68408E927947E3800E327D2 /* Preferences */,
|
||||
);
|
||||
path = Screens;
|
||||
sourceTree = "<group>";
|
||||
|
@ -155,18 +240,60 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
D65B18C027505348004A9448 /* HomeViewController.swift */,
|
||||
D6E2436A278BB1880005E546 /* HomeCollectionViewCell.swift */,
|
||||
);
|
||||
path = Home;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D68408E927947E3800E327D2 /* Preferences */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D68408EC2794803D00E327D2 /* PrefsView.swift */,
|
||||
);
|
||||
path = Preferences;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6840911279486C400E327D2 /* ReaderMac */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D68409122794870000E327D2 /* ReaderMac.swift */,
|
||||
);
|
||||
path = ReaderMac;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D68B302E278FDCE200E8B3FA /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D68B3037279099FD00E8B3FA /* liblolhtml.a */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6A8A33527766E9300CCEC72 /* CoreData */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */,
|
||||
D6A8A33327766C2800CCEC72 /* PersistentContainer.swift */,
|
||||
D6E24355278B96E40005E546 /* Feed+CoreDataClass.swift */,
|
||||
D6E24356278B96E40005E546 /* Feed+CoreDataProperties.swift */,
|
||||
D6E2435B278B97240005E546 /* Group+CoreDataClass.swift */,
|
||||
D6E2435C278B97240005E546 /* Group+CoreDataProperties.swift */,
|
||||
D6E24359278B97240005E546 /* Item+CoreDataClass.swift */,
|
||||
D6E2435A278B97240005E546 /* Item+CoreDataProperties.swift */,
|
||||
);
|
||||
path = CoreData;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6C687DF272CD27600874C10 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6C687EA272CD27600874C10 /* Reader */,
|
||||
D6C68804272CD27700874C10 /* ReaderTests */,
|
||||
D6C6880E272CD27700874C10 /* ReaderUITests */,
|
||||
D6840911279486C400E327D2 /* ReaderMac */,
|
||||
D6C68824272CD2BA00874C10 /* Fervor */,
|
||||
D6C687E9272CD27600874C10 /* Products */,
|
||||
D68B302E278FDCE200E8B3FA /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
|
@ -177,6 +304,7 @@
|
|||
D6C68801272CD27700874C10 /* ReaderTests.xctest */,
|
||||
D6C6880B272CD27700874C10 /* ReaderUITests.xctest */,
|
||||
D6C68823272CD2BA00874C10 /* Fervor.framework */,
|
||||
D684090D279486BF00E327D2 /* ReaderMac.bundle */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
|
@ -184,15 +312,25 @@
|
|||
D6C687EA272CD27600874C10 /* Reader */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D68B303E27923C0000E8B3FA /* Reader.entitlements */,
|
||||
D68B3032278FDD1A00E8B3FA /* Reader-Bridging-Header.h */,
|
||||
D6C687EB272CD27600874C10 /* AppDelegate.swift */,
|
||||
D6C687ED272CD27600874C10 /* SceneDelegate.swift */,
|
||||
D68408E727947D0800E327D2 /* PrefsSceneDelegate.swift */,
|
||||
D65B18B527504920004A9448 /* FervorController.swift */,
|
||||
D65B18BD275051A1004A9448 /* LocalData.swift */,
|
||||
D6E24368278BABB40005E546 /* UIColor+App.swift */,
|
||||
D6EB531E278E4A7500AD2E61 /* StretchyMenuInteraction.swift */,
|
||||
D68B303527907D9200E8B3FA /* ExcerptGenerator.swift */,
|
||||
D68B304127932ED500E8B3FA /* UserActivities.swift */,
|
||||
D68408EE2794808E00E327D2 /* Preferences.swift */,
|
||||
D6A8A33527766E9300CCEC72 /* CoreData */,
|
||||
D65B18AF2750468B004A9448 /* Screens */,
|
||||
D6C687F7272CD27700874C10 /* Assets.xcassets */,
|
||||
D6C687F9272CD27700874C10 /* LaunchScreen.storyboard */,
|
||||
D6C687FC272CD27700874C10 /* Info.plist */,
|
||||
D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */,
|
||||
D6E24372278BE2B80005E546 /* read.css */,
|
||||
D68B303C2792204B00E8B3FA /* read.js */,
|
||||
);
|
||||
path = Reader;
|
||||
sourceTree = "<group>";
|
||||
|
@ -226,11 +364,29 @@
|
|||
D6C68857272CD8CD00874C10 /* Group.swift */,
|
||||
D6C6882F272CD2CF00874C10 /* Instance.swift */,
|
||||
D6C68855272CD7C600874C10 /* Item.swift */,
|
||||
D6E24351278B6DF90005E546 /* ItemsSyncUpdate.swift */,
|
||||
D65B18BB27504FE7004A9448 /* Token.swift */,
|
||||
);
|
||||
path = Fervor;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6E2434A278B455C0005E546 /* Items */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6E2434B278B456A0005E546 /* ItemsViewController.swift */,
|
||||
D6E24361278BA1410005E546 /* ItemCollectionViewCell.swift */,
|
||||
);
|
||||
path = Items;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6E2436C278BD80B0005E546 /* Read */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6E2436D278BD8160005E546 /* ReadViewController.swift */,
|
||||
);
|
||||
path = Read;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXHeadersBuildPhase section */
|
||||
|
@ -245,21 +401,44 @@
|
|||
/* End PBXHeadersBuildPhase section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
D684090C279486BF00E327D2 /* ReaderMac */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = D6840910279486BF00E327D2 /* Build configuration list for PBXNativeTarget "ReaderMac" */;
|
||||
buildPhases = (
|
||||
D6840909279486BF00E327D2 /* Sources */,
|
||||
D684090A279486BF00E327D2 /* Frameworks */,
|
||||
D684090B279486BF00E327D2 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = ReaderMac;
|
||||
productName = ReaderMac;
|
||||
productReference = D684090D279486BF00E327D2 /* ReaderMac.bundle */;
|
||||
productType = "com.apple.product-type.bundle";
|
||||
};
|
||||
D6C687E7272CD27600874C10 /* Reader */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = D6C68815272CD27700874C10 /* Build configuration list for PBXNativeTarget "Reader" */;
|
||||
buildPhases = (
|
||||
D68B303B2791D2A900E8B3FA /* Compile lol-html c-api */,
|
||||
D6C687E4272CD27600874C10 /* Sources */,
|
||||
D6C687E5272CD27600874C10 /* Frameworks */,
|
||||
D6C687E6272CD27600874C10 /* Resources */,
|
||||
D6C6882E272CD2BA00874C10 /* Embed Frameworks */,
|
||||
D6840917279487DD00E327D2 /* Embed PlugIns */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
D6C68828272CD2BA00874C10 /* PBXTargetDependency */,
|
||||
D6840916279487DC00E327D2 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Reader;
|
||||
packageProductDependencies = (
|
||||
D6E24370278BE1250005E546 /* HTMLEntities */,
|
||||
);
|
||||
productName = Reader;
|
||||
productReference = D6C687E8272CD27600874C10 /* Reader.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
|
@ -328,6 +507,10 @@
|
|||
LastSwiftUpdateCheck = 1320;
|
||||
LastUpgradeCheck = 1320;
|
||||
TargetAttributes = {
|
||||
D684090C279486BF00E327D2 = {
|
||||
CreatedOnToolsVersion = 13.2;
|
||||
LastSwiftMigration = 1320;
|
||||
};
|
||||
D6C687E7272CD27600874C10 = {
|
||||
CreatedOnToolsVersion = 13.2;
|
||||
};
|
||||
|
@ -354,6 +537,9 @@
|
|||
Base,
|
||||
);
|
||||
mainGroup = D6C687DF272CD27600874C10;
|
||||
packageReferences = (
|
||||
D6E2436F278BE1250005E546 /* XCRemoteSwiftPackageReference "swift-html-entities" */,
|
||||
);
|
||||
productRefGroup = D6C687E9272CD27600874C10 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
|
@ -362,17 +548,27 @@
|
|||
D6C68800272CD27700874C10 /* ReaderTests */,
|
||||
D6C6880A272CD27700874C10 /* ReaderUITests */,
|
||||
D6C68822272CD2BA00874C10 /* Fervor */,
|
||||
D684090C279486BF00E327D2 /* ReaderMac */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
D684090B279486BF00E327D2 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D6C687E6272CD27600874C10 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D6C687FB272CD27700874C10 /* LaunchScreen.storyboard in Resources */,
|
||||
D6C687F8272CD27700874C10 /* Assets.xcassets in Resources */,
|
||||
D68B303D2792204B00E8B3FA /* read.js in Resources */,
|
||||
D6E24373278BE2B80005E546 /* read.css in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -399,16 +595,66 @@
|
|||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
D68B303B2791D2A900E8B3FA /* Compile lol-html c-api */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Compile lol-html c-api";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/bash;
|
||||
shellScript = "pushd \"$PROJECT_DIR/lol-html/c-api/\"\n\nbuild() {\n echo \"Building lol-html with CARGO_TARGET: $1\"\n\n ~/.cargo/bin/cargo build --release --target $1\n}\n\nbuild_std() {\n echo \"Building lol-html with CARGO_TARGET: $1\"\n echo \"Building std enabled\"\n \n ~/.cargo/bin/cargo +nightly build -Z build-std=panic_abort,std --release --target $1\n}\n\nif [ \"$PLATFORM_NAME\" == \"iphonesimulator\" ]; then\n if [ \"$ARCHS\" == \"arm64\" ]; then\n build \"aarch64-apple-ios-sim\"\n elif [ \"$ARCHS\" == \"x86_64\" ]; then\n build \"x86_64-apple-ios\"\n else\n echo \"error: unknown value for \\$ARCHS\"\n exit 1\n fi\nelif [ \"$PLATFORM_NAME\" == \"iphoneos\" ]; then\n build \"aarch64-apple-ios\"\nelif [ \"$PLATFORM_NAME\" == \"macosx\" ]; then\n if grep -q \"arm64\" <<< \"$ARCHS\"; then\n build_std \"aarch64-apple-ios-macabi\"\n fi\n if grep -q \"x86_64\" <<< \"$ARCHS\"; then\n build_std \"x86_64-apple-ios-macabi\"\n fi\nelse\n echo \"error: unknown value for \\$PLATFORM_NAME\"\n exit 1\nfi\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
D6840909279486BF00E327D2 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D68409132794870000E327D2 /* ReaderMac.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
D6C687E4272CD27600874C10 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D6A8A33427766C2800CCEC72 /* PersistentContainer.swift in Sources */,
|
||||
D6E24357278B96E40005E546 /* Feed+CoreDataClass.swift in Sources */,
|
||||
D65B18B627504920004A9448 /* FervorController.swift in Sources */,
|
||||
D68B304227932ED500E8B3FA /* UserActivities.swift in Sources */,
|
||||
D6C687EC272CD27600874C10 /* AppDelegate.swift in Sources */,
|
||||
D6C687F6272CD27600874C10 /* Reader.xcdatamodeld in Sources */,
|
||||
D6E2436B278BB1880005E546 /* HomeCollectionViewCell.swift in Sources */,
|
||||
D6E2435F278B97240005E546 /* Group+CoreDataClass.swift in Sources */,
|
||||
D6E24369278BABB40005E546 /* UIColor+App.swift in Sources */,
|
||||
D6E2435D278B97240005E546 /* Item+CoreDataClass.swift in Sources */,
|
||||
D68408E827947D0800E327D2 /* PrefsSceneDelegate.swift in Sources */,
|
||||
D6EB531D278C89C300AD2E61 /* AppNavigationController.swift in Sources */,
|
||||
D6E24360278B97240005E546 /* Group+CoreDataProperties.swift in Sources */,
|
||||
D6E2434C278B456A0005E546 /* ItemsViewController.swift in Sources */,
|
||||
D6E2435E278B97240005E546 /* Item+CoreDataProperties.swift in Sources */,
|
||||
D6EB531F278E4A7500AD2E61 /* StretchyMenuInteraction.swift in Sources */,
|
||||
D6E24358278B96E40005E546 /* Feed+CoreDataProperties.swift in Sources */,
|
||||
D68B30402792729A00E8B3FA /* AppSplitViewController.swift in Sources */,
|
||||
D65B18BE275051A1004A9448 /* LocalData.swift in Sources */,
|
||||
D65B18B22750469D004A9448 /* LoginViewController.swift in Sources */,
|
||||
D68408ED2794803D00E327D2 /* PrefsView.swift in Sources */,
|
||||
D68408EF2794808E00E327D2 /* Preferences.swift in Sources */,
|
||||
D68B303627907D9200E8B3FA /* ExcerptGenerator.swift in Sources */,
|
||||
D6E24363278BA1410005E546 /* ItemCollectionViewCell.swift in Sources */,
|
||||
D6E2436E278BD8160005E546 /* ReadViewController.swift in Sources */,
|
||||
D65B18C127505348004A9448 /* HomeViewController.swift in Sources */,
|
||||
D6C687EE272CD27600874C10 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
|
@ -438,6 +684,7 @@
|
|||
D65B18BC27504FE7004A9448 /* Token.swift in Sources */,
|
||||
D6C68856272CD7C600874C10 /* Item.swift in Sources */,
|
||||
D6C68830272CD2CF00874C10 /* Instance.swift in Sources */,
|
||||
D6E24352278B6DF90005E546 /* ItemsSyncUpdate.swift in Sources */,
|
||||
D6C68832272CD40600874C10 /* Feed.swift in Sources */,
|
||||
D6C68858272CD8CD00874C10 /* Group.swift in Sources */,
|
||||
D6C68834272CD44900874C10 /* Fervor.swift in Sources */,
|
||||
|
@ -450,6 +697,12 @@
|
|||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
D6840916279487DC00E327D2 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
platformFilter = maccatalyst;
|
||||
target = D684090C279486BF00E327D2 /* ReaderMac */;
|
||||
targetProxy = D6840915279487DC00E327D2 /* PBXContainerItemProxy */;
|
||||
};
|
||||
D6C68803272CD27700874C10 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = D6C687E7272CD27600874C10 /* Reader */;
|
||||
|
@ -479,6 +732,65 @@
|
|||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
D684090E279486BF00E327D2 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSPrincipalClass = "";
|
||||
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
"@loader_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.Reader.ReaderMac;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
WRAPPER_EXTENSION = bundle;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D684090F279486BF00E327D2 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSPrincipalClass = "";
|
||||
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
"@loader_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.Reader.ReaderMac;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
WRAPPER_EXTENSION = bundle;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
D6C68813272CD27700874C10 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
|
@ -530,6 +842,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
HEADER_SEARCH_PATHS = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
|
@ -537,6 +850,7 @@
|
|||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
|
@ -585,12 +899,14 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
HEADER_SEARCH_PATHS = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
|
@ -601,10 +917,13 @@
|
|||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Reader/Reader.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
||||
EXCLUDED_ARCHS = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/lol-html/c-api/include/";
|
||||
INFOPLIST_FILE = Reader/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
|
@ -615,12 +934,21 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(inherited)";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/lol-html/c-api/target/aarch64-apple-ios/release/";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*][arch=arm64]" = "$(PROJECT_DIR)/lol-html/c-api/target/aarch64-apple-ios-sim/release/";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*][arch=x86_64]" = "$(PROJECT_DIR)/lol-html/c-api/target/x86_64-apple-ios/release/";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=macosx*][arch=arm64]" = "$(PROJECT_DIR)/lol-html/c-api/target/aarch64-apple-ios-macabi/release";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=macosx*][arch=x86_64]" = "$(PROJECT_DIR)/lol-html/c-api/target/x86_64-apple-ios-macabi/release";
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_LDFLAGS = "-llolhtml";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.Reader;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Reader/Reader-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
|
@ -630,10 +958,13 @@
|
|||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Reader/Reader.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
||||
EXCLUDED_ARCHS = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/lol-html/c-api/include/";
|
||||
INFOPLIST_FILE = Reader/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
|
@ -644,12 +975,21 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(inherited)";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/lol-html/c-api/target/aarch64-apple-ios/release/";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*][arch=arm64]" = "$(PROJECT_DIR)/lol-html/c-api/target/aarch64-apple-ios-sim/release/";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*][arch=x86_64]" = "$(PROJECT_DIR)/lol-html/c-api/target/x86_64-apple-ios/release/";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=macosx*][arch=arm64]" = "$(PROJECT_DIR)/lol-html/c-api/target/aarch64-apple-ios-macabi/release";
|
||||
"LIBRARY_SEARCH_PATHS[sdk=macosx*][arch=x86_64]" = "$(PROJECT_DIR)/lol-html/c-api/target/x86_64-apple-ios-macabi/release";
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_LDFLAGS = "-llolhtml";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.Reader;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Reader/Reader-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
@ -662,6 +1002,7 @@
|
|||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/lol-html/c-api/include/";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.ReaderTests;
|
||||
|
@ -682,6 +1023,7 @@
|
|||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = ZPBBSK8L8B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
HEADER_SEARCH_PATHS = "$(PROJECT_DIR)/lol-html/c-api/include/";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.ReaderTests;
|
||||
|
@ -797,6 +1139,15 @@
|
|||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
D6840910279486BF00E327D2 /* Build configuration list for PBXNativeTarget "ReaderMac" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
D684090E279486BF00E327D2 /* Debug */,
|
||||
D684090F279486BF00E327D2 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
D6C687E3272CD27600874C10 /* Build configuration list for PBXProject "Reader" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
@ -844,6 +1195,25 @@
|
|||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
D6E2436F278BE1250005E546 /* XCRemoteSwiftPackageReference "swift-html-entities" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Kitura/swift-html-entities.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 4.0.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
D6E24370278BE1250005E546 /* HTMLEntities */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D6E2436F278BE1250005E546 /* XCRemoteSwiftPackageReference "swift-html-entities" */;
|
||||
productName = HTMLEntities;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
|
||||
/* Begin XCVersionGroup section */
|
||||
D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */ = {
|
||||
isa = XCVersionGroup;
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1320"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D6C687E7272CD27600874C10"
|
||||
BuildableName = "Reader.app"
|
||||
BlueprintName = "Reader"
|
||||
ReferencedContainer = "container:Reader.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D6C68800272CD27700874C10"
|
||||
BuildableName = "ReaderTests.xctest"
|
||||
BlueprintName = "ReaderTests"
|
||||
ReferencedContainer = "container:Reader.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D6C6880A272CD27700874C10"
|
||||
BuildableName = "ReaderUITests.xctest"
|
||||
BlueprintName = "ReaderUITests"
|
||||
ReferencedContainer = "container:Reader.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D6C687E7272CD27600874C10"
|
||||
BuildableName = "Reader.app"
|
||||
BlueprintName = "Reader"
|
||||
ReferencedContainer = "container:Reader.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
<CommandLineArgument
|
||||
argument = "-com.apple.CoreData.ConcurrencyDebug 1"
|
||||
isEnabled = "YES">
|
||||
</CommandLineArgument>
|
||||
</CommandLineArguments>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D6C687E7272CD27600874C10"
|
||||
BuildableName = "Reader.app"
|
||||
BlueprintName = "Reader"
|
||||
ReferencedContainer = "container:Reader.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
|
@ -6,24 +6,63 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import CoreData
|
||||
import WebKit
|
||||
import OSLog
|
||||
import Combine
|
||||
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
private var readerMac: NSObject!
|
||||
#endif
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
swizzleWKWebView()
|
||||
|
||||
Preferences.shared.objectWillChange
|
||||
.debounce(for: .milliseconds(250), scheduler: RunLoop.main)
|
||||
.sink { _ in
|
||||
Preferences.save()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
let macBundleURL = Bundle.main.builtInPlugInsURL!.appendingPathComponent("ReaderMac.bundle")
|
||||
let bundle = Bundle(url: macBundleURL)!
|
||||
do {
|
||||
try bundle.loadAndReturnError()
|
||||
|
||||
let clazz = NSClassFromString("ReaderMac.ReaderMac")! as! NSObject.Type
|
||||
readerMac = clazz.init()
|
||||
readerMac.perform(Selector(("setup")))
|
||||
} catch {
|
||||
print("Unable to load ReaderMac bundle: \(error)")
|
||||
}
|
||||
#endif
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(updateAppearance), name: .appearanceChanged, object: nil)
|
||||
updateAppearance()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: UISceneSession Lifecycle
|
||||
|
||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
// Called when a new scene session is being created.
|
||||
// Use this method to select a configuration to create the new scene with.
|
||||
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||
let name: String
|
||||
#if targetEnvironment(macCatalyst)
|
||||
if options.userActivities.first?.activityType == NSUserActivity.preferencesType {
|
||||
name = "prefs"
|
||||
} else {
|
||||
name = "main"
|
||||
}
|
||||
#else
|
||||
name = "main"
|
||||
#endif
|
||||
return UISceneConfiguration(name: name, sessionRole: connectingSceneSession.role)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
|
||||
|
@ -31,51 +70,80 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
|
||||
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
|
||||
}
|
||||
|
||||
// MARK: - Core Data stack
|
||||
|
||||
lazy var persistentContainer: NSPersistentContainer = {
|
||||
/*
|
||||
The persistent container for the application. This implementation
|
||||
creates and returns a container, having loaded the store for the
|
||||
application to it. This property is optional since there are legitimate
|
||||
error conditions that could cause the creation of the store to fail.
|
||||
*/
|
||||
let container = NSPersistentContainer(name: "Reader")
|
||||
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
|
||||
if let error = error as NSError? {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
|
||||
/*
|
||||
Typical reasons for an error here include:
|
||||
* The parent directory does not exist, cannot be created, or disallows writing.
|
||||
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
|
||||
* The device is out of space.
|
||||
* The store could not be migrated to the current model version.
|
||||
Check the error message to determine what the actual problem was.
|
||||
*/
|
||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
}
|
||||
})
|
||||
return container
|
||||
}()
|
||||
|
||||
// MARK: - Core Data Saving support
|
||||
|
||||
func saveContext () {
|
||||
let context = persistentContainer.viewContext
|
||||
if context.hasChanges {
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
let nserror = error as NSError
|
||||
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
|
||||
|
||||
override func buildMenu(with builder: UIMenuBuilder) {
|
||||
if builder.system == .main {
|
||||
builder.insertSibling(UIMenu(options: .displayInline, children: [
|
||||
UIKeyCommand(title: "Preferences…", action: #selector(showPreferences), input: ",", modifierFlags: .command)
|
||||
]), afterMenu: .about)
|
||||
|
||||
var children = [UIMenuElement]()
|
||||
let accounts: [UIMenuElement] = LocalData.accounts.map { account in
|
||||
var title = account.instanceURL.host!
|
||||
if let port = account.instanceURL.port, port != 80 && port != 443 {
|
||||
title += ":\(port)"
|
||||
}
|
||||
|
||||
let state: UIAction.State
|
||||
if let activeScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }),
|
||||
let sceneDelegate = activeScene.delegate as? SceneDelegate,
|
||||
sceneDelegate.fervorController?.account?.id == account.id {
|
||||
state = .on
|
||||
} else {
|
||||
state = .off
|
||||
}
|
||||
return UIAction(title: title, attributes: [], state: state) { _ in
|
||||
let activity = NSUserActivity.activateAccount(account)
|
||||
let options = UIScene.ActivationRequestOptions()
|
||||
#if targetEnvironment(macCatalyst)
|
||||
options.collectionJoinBehavior = .disallowed
|
||||
#endif
|
||||
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: options, errorHandler: nil)
|
||||
}
|
||||
}
|
||||
children.append(UIMenu(options: .displayInline, children: accounts))
|
||||
children.append(UIAction(title: "Add Account...", handler: { _ in
|
||||
let activity = NSUserActivity.addAccount()
|
||||
let options = UIScene.ActivationRequestOptions()
|
||||
#if targetEnvironment(macCatalyst)
|
||||
options.collectionJoinBehavior = .disallowed
|
||||
#endif
|
||||
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: options, errorHandler: nil)
|
||||
}))
|
||||
let account = UIMenu(title: "Account", image: nil, identifier: nil, options: [], children: children)
|
||||
builder.insertSibling(account, afterMenu: .file)
|
||||
}
|
||||
}
|
||||
|
||||
private func swizzleWKWebView() {
|
||||
let selector = Selector(("_updateScrollViewBackground"))
|
||||
var originalIMP: IMP?
|
||||
let imp = imp_implementationWithBlock({ (self: WKWebView) in
|
||||
if let originalIMP = originalIMP {
|
||||
let original = unsafeBitCast(originalIMP, to: (@convention(c) (WKWebView, Selector) -> Void).self)
|
||||
original(self, selector)
|
||||
} else {
|
||||
os_log(.error, "Missing originalIMP for -[WKWebView _updateScrollViewBackground], did WebKit change?")
|
||||
}
|
||||
|
||||
self.scrollView.indicatorStyle = .default
|
||||
|
||||
} as (@convention(block) (WKWebView) -> Void))
|
||||
originalIMP = class_replaceMethod(WKWebView.self, selector, imp, "v@:")
|
||||
}
|
||||
|
||||
@objc private func showPreferences() {
|
||||
let existing = UIApplication.shared.connectedScenes.first {
|
||||
$0.session.configuration.name == "prefs"
|
||||
}
|
||||
UIApplication.shared.requestSceneSessionActivation(existing?.session, userActivity: .preferences(), options: nil, errorHandler: nil)
|
||||
}
|
||||
|
||||
@objc private func updateAppearance() {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
readerMac.perform(Selector(("updateAppearance:")), with: Preferences.shared.appearance.rawValue)
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
//
|
||||
// Feed+CoreDataClass.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/9/22.
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Fervor
|
||||
|
||||
@objc(Feed)
|
||||
public class Feed: NSManagedObject {
|
||||
|
||||
func updateFromServer(_ serverFeed: Fervor.Feed) {
|
||||
guard self.id == nil || self.id == serverFeed.id else { return }
|
||||
self.id = serverFeed.id
|
||||
self.title = serverFeed.title
|
||||
self.url = serverFeed.url
|
||||
self.lastUpdated = serverFeed.lastUpdated
|
||||
// todo: check this
|
||||
self.removeFromGroups(self.groups!.filtered(using: NSPredicate(format: "NOT id IN %@", serverFeed.groupIDs)) as NSSet)
|
||||
let groupsToAddReq = Group.fetchRequest()
|
||||
groupsToAddReq.predicate = NSPredicate(format: "id IN %@", serverFeed.groupIDs.filter { g in !self.groups!.contains { ($0 as! Group).id == g } })
|
||||
let groupsToAdd = try! self.managedObjectContext!.fetch(groupsToAddReq)
|
||||
self.addToGroups(NSSet(array: groupsToAdd))
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
//
|
||||
// Feed+CoreDataProperties.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/9/22.
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
|
||||
extension Feed {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Feed> {
|
||||
return NSFetchRequest<Feed>(entityName: "Feed")
|
||||
}
|
||||
|
||||
@NSManaged public var id: String?
|
||||
@NSManaged public var lastUpdated: Date?
|
||||
@NSManaged public var title: String?
|
||||
@NSManaged public var url: URL?
|
||||
@NSManaged public var groups: NSSet?
|
||||
@NSManaged public var items: NSSet?
|
||||
|
||||
}
|
||||
|
||||
// MARK: Generated accessors for groups
|
||||
extension Feed {
|
||||
|
||||
@objc(addGroupsObject:)
|
||||
@NSManaged public func addToGroups(_ value: Group)
|
||||
|
||||
@objc(removeGroupsObject:)
|
||||
@NSManaged public func removeFromGroups(_ value: Group)
|
||||
|
||||
@objc(addGroups:)
|
||||
@NSManaged public func addToGroups(_ values: NSSet)
|
||||
|
||||
@objc(removeGroups:)
|
||||
@NSManaged public func removeFromGroups(_ values: NSSet)
|
||||
|
||||
}
|
||||
|
||||
// MARK: Generated accessors for items
|
||||
extension Feed {
|
||||
|
||||
@objc(addItemsObject:)
|
||||
@NSManaged public func addToItems(_ value: Item)
|
||||
|
||||
@objc(removeItemsObject:)
|
||||
@NSManaged public func removeFromItems(_ value: Item)
|
||||
|
||||
@objc(addItems:)
|
||||
@NSManaged public func addToItems(_ values: NSSet)
|
||||
|
||||
@objc(removeItems:)
|
||||
@NSManaged public func removeFromItems(_ values: NSSet)
|
||||
|
||||
}
|
||||
|
||||
extension Feed : Identifiable {
|
||||
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// Group+CoreDataClass.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/9/22.
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Fervor
|
||||
|
||||
@objc(Group)
|
||||
public class Group: NSManagedObject {
|
||||
|
||||
func updateFromServer(_ serverGroup: Fervor.Group) {
|
||||
guard self.id == nil || self.id == serverGroup.id else { return }
|
||||
self.id = serverGroup.id
|
||||
self.title = serverGroup.title
|
||||
// feeds relationships will be updated after feeds are created in PersistentContainer.sync
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
//
|
||||
// Group+CoreDataProperties.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/9/22.
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
|
||||
extension Group {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Group> {
|
||||
return NSFetchRequest<Group>(entityName: "Group")
|
||||
}
|
||||
|
||||
@NSManaged public var id: String?
|
||||
@NSManaged public var title: String
|
||||
@NSManaged public var feeds: NSSet?
|
||||
|
||||
}
|
||||
|
||||
// MARK: Generated accessors for feeds
|
||||
extension Group {
|
||||
|
||||
@objc(addFeedsObject:)
|
||||
@NSManaged public func addToFeeds(_ value: Feed)
|
||||
|
||||
@objc(removeFeedsObject:)
|
||||
@NSManaged public func removeFromFeeds(_ value: Feed)
|
||||
|
||||
@objc(addFeeds:)
|
||||
@NSManaged public func addToFeeds(_ values: NSSet)
|
||||
|
||||
@objc(removeFeeds:)
|
||||
@NSManaged public func removeFromFeeds(_ values: NSSet)
|
||||
|
||||
}
|
||||
|
||||
extension Group : Identifiable {
|
||||
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
//
|
||||
// Item+CoreDataClass.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/9/22.
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import Fervor
|
||||
|
||||
@objc(Item)
|
||||
public class Item: NSManagedObject {
|
||||
|
||||
func updateFromServer(_ serverItem: Fervor.Item) {
|
||||
guard self.id == nil || self.id == serverItem.id else { return }
|
||||
self.id = serverItem.id
|
||||
self.author = serverItem.author
|
||||
self.content = serverItem.content
|
||||
self.title = serverItem.title
|
||||
self.read = serverItem.read ?? false
|
||||
self.published = serverItem.published
|
||||
self.url = serverItem.url
|
||||
if self.feed?.id != serverItem.feedID {
|
||||
let feedReq = Feed.fetchRequest()
|
||||
feedReq.predicate = NSPredicate(format: "id = %@", serverItem.feedID)
|
||||
self.feed = try! self.managedObjectContext!.fetch(feedReq).first!
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// Item+CoreDataProperties.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/9/22.
|
||||
//
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
|
||||
extension Item {
|
||||
|
||||
@nonobjc public class func fetchRequest() -> NSFetchRequest<Item> {
|
||||
return NSFetchRequest<Item>(entityName: "Item")
|
||||
}
|
||||
|
||||
@NSManaged public var author: String?
|
||||
@NSManaged public var content: String?
|
||||
@NSManaged public var excerpt: String?
|
||||
@NSManaged public var generatedExcerpt: Bool
|
||||
@NSManaged public var id: String?
|
||||
@NSManaged public var needsReadStateSync: Bool
|
||||
@NSManaged public var published: Date?
|
||||
@NSManaged public var read: Bool
|
||||
@NSManaged public var title: String?
|
||||
@NSManaged public var url: URL?
|
||||
@NSManaged public var feed: Feed?
|
||||
|
||||
}
|
||||
|
||||
extension Item : Identifiable {
|
||||
|
||||
}
|
|
@ -0,0 +1,141 @@
|
|||
//
|
||||
// PersistentContainer.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 12/24/21.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Fervor
|
||||
import OSLog
|
||||
|
||||
class PersistentContainer: NSPersistentContainer {
|
||||
|
||||
private static let managedObjectModel: NSManagedObjectModel = {
|
||||
let url = Bundle.main.url(forResource: "Reader", withExtension: "momd")!
|
||||
return NSManagedObjectModel(contentsOf: url)!
|
||||
}()
|
||||
|
||||
private(set) lazy var backgroundContext: NSManagedObjectContext = {
|
||||
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
||||
// todo: should the background context really be parented to the view context, or should they both be direct children of the PSC?
|
||||
context.parent = self.viewContext
|
||||
return context
|
||||
}()
|
||||
|
||||
private weak var fervorController: FervorController?
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentContainer")
|
||||
|
||||
init(account: LocalData.Account, fervorController: FervorController) {
|
||||
self.fervorController = fervorController
|
||||
|
||||
super.init(name: "\(account.id)", managedObjectModel: PersistentContainer.managedObjectModel)
|
||||
|
||||
loadPersistentStores { description, error in
|
||||
if let error = error {
|
||||
fatalError("Unable to load persistent store: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func saveViewContext() async throws {
|
||||
if viewContext.hasChanges {
|
||||
try viewContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
func lastSyncDate() async throws -> Date? {
|
||||
return try await backgroundContext.perform {
|
||||
let state = try self.backgroundContext.fetch(SyncState.fetchRequest()).first
|
||||
return state?.lastSync
|
||||
}
|
||||
}
|
||||
|
||||
func updateLastSyncDate(_ date: Date) async throws {
|
||||
try await backgroundContext.perform {
|
||||
if let state = try self.backgroundContext.fetch(SyncState.fetchRequest()).first {
|
||||
state.lastSync = date
|
||||
} else {
|
||||
let state = SyncState(context: self.backgroundContext)
|
||||
state.lastSync = date
|
||||
}
|
||||
|
||||
try self.backgroundContext.save()
|
||||
}
|
||||
try await self.saveViewContext()
|
||||
}
|
||||
|
||||
func sync(serverGroups: [Fervor.Group], serverFeeds: [Fervor.Feed]) async throws {
|
||||
try await backgroundContext.perform {
|
||||
let existingGroups = try self.backgroundContext.fetch(Group.fetchRequest())
|
||||
for group in serverGroups {
|
||||
if let existing = existingGroups.first(where: { $0.id == group.id }) {
|
||||
existing.updateFromServer(group)
|
||||
} else {
|
||||
let mo = Group(context: self.backgroundContext)
|
||||
mo.updateFromServer(group)
|
||||
}
|
||||
}
|
||||
for removed in existingGroups where !serverGroups.contains(where: { $0.id == removed.id }) {
|
||||
self.backgroundContext.delete(removed)
|
||||
}
|
||||
|
||||
let existingFeeds = try self.backgroundContext.fetch(Feed.fetchRequest())
|
||||
for feed in serverFeeds {
|
||||
if let existing = existingFeeds.first(where: { $0.id == feed.id }) {
|
||||
existing.updateFromServer(feed)
|
||||
} else {
|
||||
let mo = Feed(context: self.backgroundContext)
|
||||
mo.updateFromServer(feed)
|
||||
}
|
||||
}
|
||||
for removed in existingFeeds where !serverFeeds.contains(where: { $0.id == removed.id }) {
|
||||
self.backgroundContext.delete(removed)
|
||||
}
|
||||
|
||||
if self.backgroundContext.hasChanges {
|
||||
try self.backgroundContext.save()
|
||||
}
|
||||
}
|
||||
try await self.saveViewContext()
|
||||
}
|
||||
|
||||
func syncItems(_ syncUpdate: ItemsSyncUpdate) async throws {
|
||||
try await backgroundContext.perform {
|
||||
self.logger.debug("syncItems: deleting \(syncUpdate.delete.count, privacy: .public) items")
|
||||
let deleteReq = Item.fetchRequest()
|
||||
deleteReq.predicate = NSPredicate(format: "id in %@", syncUpdate.delete)
|
||||
let delete = NSBatchDeleteRequest(fetchRequest: deleteReq as! NSFetchRequest<NSFetchRequestResult>)
|
||||
delete.resultType = .resultTypeObjectIDs
|
||||
let result = try self.backgroundContext.execute(delete)
|
||||
|
||||
if let deleteResult = result as? NSBatchDeleteResult,
|
||||
let objectIDs = deleteResult.result as? [NSManagedObjectID] {
|
||||
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: [NSDeletedObjectsKey: objectIDs], into: [self.viewContext])
|
||||
// todo: does the background/view contexts need to get saved then?
|
||||
}
|
||||
|
||||
let req = Item.fetchRequest()
|
||||
req.predicate = NSPredicate(format: "id in %@", syncUpdate.upsert.map(\.id))
|
||||
let existing = try self.backgroundContext.fetch(req)
|
||||
self.logger.debug("syncItems: updating \(existing.count, privacy: .public) items, inserting \(syncUpdate.upsert.count - existing.count, privacy: .public)")
|
||||
// todo: this feels like it'll be slow when there are many items
|
||||
for item in syncUpdate.upsert {
|
||||
if let existing = existing.first(where: { $0.id == item.id }) {
|
||||
existing.updateFromServer(item)
|
||||
} else {
|
||||
let mo = Item(context: self.backgroundContext)
|
||||
mo.updateFromServer(item)
|
||||
}
|
||||
}
|
||||
|
||||
if self.backgroundContext.hasChanges {
|
||||
try self.backgroundContext.save()
|
||||
}
|
||||
}
|
||||
try await self.saveViewContext()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="19574" systemVersion="21C52" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Feed" representedClassName="Feed" syncable="YES">
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="title" attributeType="String"/>
|
||||
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||
<relationship name="groups" toMany="YES" deletionRule="Nullify" destinationEntity="Group" inverseName="feeds" inverseEntity="Group"/>
|
||||
<relationship name="items" toMany="YES" deletionRule="Cascade" destinationEntity="Item" inverseName="feed" inverseEntity="Item"/>
|
||||
</entity>
|
||||
<entity name="Group" representedClassName="Group" syncable="YES">
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="title" attributeType="String"/>
|
||||
<relationship name="feeds" toMany="YES" deletionRule="Nullify" destinationEntity="Feed" inverseName="groups" inverseEntity="Feed"/>
|
||||
</entity>
|
||||
<entity name="Item" representedClassName="Item" syncable="YES">
|
||||
<attribute name="author" optional="YES" attributeType="String"/>
|
||||
<attribute name="content" optional="YES" attributeType="String"/>
|
||||
<attribute name="excerpt" optional="YES" attributeType="String"/>
|
||||
<attribute name="generatedExcerpt" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="id" attributeType="String"/>
|
||||
<attribute name="needsReadStateSync" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="published" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="read" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="title" optional="YES" attributeType="String"/>
|
||||
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||
<relationship name="feed" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="items" inverseEntity="Feed"/>
|
||||
</entity>
|
||||
<entity name="SyncState" representedClassName="SyncState" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="lastSync" attributeType="Date" usesScalarValueType="NO"/>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Feed" positionX="-54" positionY="9" width="128" height="119"/>
|
||||
<element name="Group" positionX="-63" positionY="-18" width="128" height="74"/>
|
||||
<element name="Item" positionX="-45" positionY="63" width="128" height="194"/>
|
||||
<element name="SyncState" positionX="-63" positionY="90" width="128" height="44"/>
|
||||
</elements>
|
||||
</model>
|
|
@ -0,0 +1,141 @@
|
|||
//
|
||||
// ExcerptGenerator.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/13/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import CoreData
|
||||
|
||||
// public so that it can be imported in ReaderTests even when Reader is compiled in release mode (w/ testing disabled)
|
||||
public struct ExcerptGenerator {
|
||||
private init() {}
|
||||
|
||||
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ExcerptGenerator")
|
||||
|
||||
static func generateAll(_ fervorController: FervorController) {
|
||||
let req = Item.fetchRequest()
|
||||
req.predicate = NSPredicate(format: "generatedExcerpt = NO")
|
||||
req.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
|
||||
req.fetchBatchSize = 50
|
||||
fervorController.persistentContainer.performBackgroundTask { ctx in
|
||||
guard let items = try? ctx.fetch(req) else { return }
|
||||
var count = 0
|
||||
for item in items {
|
||||
if let excerpt = excerpt(for: item) {
|
||||
item.excerpt = excerpt
|
||||
count += 1
|
||||
if count % 50 == 0 {
|
||||
logger.debug("Generated \(count, privacy: .public) excerpts")
|
||||
}
|
||||
}
|
||||
item.generatedExcerpt = true
|
||||
}
|
||||
logger.log("Generated excerpts for \(count, privacy: .public) items")
|
||||
if ctx.hasChanges {
|
||||
do {
|
||||
// get the updated objects now, because this set is empty after .save is called
|
||||
let updated = ctx.updatedObjects
|
||||
try ctx.save()
|
||||
|
||||
// make sure the view context has the newly added excerpts
|
||||
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: [
|
||||
NSUpdatedObjectsKey: Array(updated)
|
||||
], into: [fervorController.persistentContainer.viewContext])
|
||||
} catch {
|
||||
logger.error("Unable to save context: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func excerpt(for item: Item) -> String? {
|
||||
guard let content = item.content else {
|
||||
return nil
|
||||
}
|
||||
return excerpt(from: content)
|
||||
}
|
||||
|
||||
public static func excerpt(from html: String) -> String? {
|
||||
var html = html
|
||||
|
||||
let builder = lol_html_rewriter_builder_new()!
|
||||
let pSelector = lol_html_selector_parse("p", 1)!
|
||||
var userData = UserData()
|
||||
withUnsafeMutablePointer(to: &userData) { userDataPtr in
|
||||
let rawPtr = UnsafeMutableRawPointer(userDataPtr)
|
||||
let res = lol_html_rewriter_builder_add_element_content_handlers(builder, pSelector, elementHandler, rawPtr, nil, nil, textHandler, rawPtr)
|
||||
guard res == 0 else {
|
||||
lolHtmlError()
|
||||
}
|
||||
let memSettings = lol_html_memory_settings_t(preallocated_parsing_buffer_size: 1024, max_allowed_memory_usage: .max)
|
||||
let rewriter = lol_html_rewriter_build(builder, "utf-8", 5, memSettings, outputSink, nil, true)
|
||||
lol_html_rewriter_builder_free(builder)
|
||||
lol_html_selector_free(pSelector)
|
||||
|
||||
guard let rewriter = rewriter else {
|
||||
lolHtmlError()
|
||||
}
|
||||
|
||||
_ = html.withUTF8 { buffer in
|
||||
buffer.withMemoryRebound(to: CChar.self) { buffer in
|
||||
lol_html_rewriter_write(rewriter, buffer.baseAddress!, buffer.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
if userData.isInParagraph {
|
||||
return userData.paragraphText.htmlUnescape().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
// todo: steal css whitespace collapsing from tusker
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func lolHtmlError() -> Never {
|
||||
let lastError = lol_html_take_last_error()
|
||||
let message = String(bytesNoCopy: UnsafeMutableRawPointer(mutating: lastError.data!), length: lastError.len, encoding: .utf8, freeWhenDone: false)
|
||||
fatalError(message ?? "Unknown lol-html error")
|
||||
}
|
||||
}
|
||||
|
||||
private struct UserData {
|
||||
var isInParagraph = false
|
||||
var paragraphText = ""
|
||||
}
|
||||
|
||||
private func elementHandler(element: OpaquePointer!, userData: UnsafeMutableRawPointer!) -> lol_html_rewriter_directive_t {
|
||||
let userDataPtr = userData.assumingMemoryBound(to: UserData.self)
|
||||
if userDataPtr.pointee.isInParagraph {
|
||||
return LOL_HTML_STOP
|
||||
} else {
|
||||
let s = lol_html_element_tag_name_get(element)
|
||||
let tagName = String(bytesNoCopy: UnsafeMutableRawPointer(mutating: s.data), length: s.len, encoding: .utf8, freeWhenDone: false)!
|
||||
userDataPtr.pointee.isInParagraph = tagName == "p" || tagName == "P"
|
||||
lol_html_str_free(s)
|
||||
return LOL_HTML_CONTINUE
|
||||
}
|
||||
}
|
||||
|
||||
private func textHandler(chunk: OpaquePointer!, userData: UnsafeMutableRawPointer!) -> lol_html_rewriter_directive_t {
|
||||
let userDataPtr = userData.assumingMemoryBound(to: UserData.self)
|
||||
if userDataPtr.pointee.isInParagraph {
|
||||
let s = lol_html_text_chunk_content_get(chunk)
|
||||
let content = String(bytesNoCopy: UnsafeMutableRawPointer(mutating: s.data), length: s.len, encoding: .utf8, freeWhenDone: false)!
|
||||
userDataPtr.pointee.paragraphText += content
|
||||
if userDataPtr.pointee.paragraphText.underestimatedCount >= 1024 {
|
||||
// lol-html seems to get confused by img tags with hundreds of kilobytes of data in their src attributes
|
||||
// and returns that data as text even though it's a tag
|
||||
// if the text is over 1024 characters so far, we assume that's what's happened
|
||||
// and abandon this attempt and try again at the next paragraph
|
||||
userDataPtr.pointee.paragraphText = ""
|
||||
userDataPtr.pointee.isInParagraph = false
|
||||
}
|
||||
}
|
||||
return LOL_HTML_CONTINUE
|
||||
}
|
||||
|
||||
private func outputSink(chunk: UnsafePointer<CChar>!, chunkLen: Int, userData: UnsafeMutableRawPointer!) {
|
||||
// no-op
|
||||
}
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
import Fervor
|
||||
import OSLog
|
||||
|
||||
class FervorController {
|
||||
|
||||
|
@ -14,11 +15,16 @@ class FervorController {
|
|||
|
||||
let instanceURL: URL
|
||||
|
||||
private let client: FervorClient
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "FervorController")
|
||||
|
||||
let client: FervorClient
|
||||
private(set) var account: LocalData.Account?
|
||||
private(set) var clientID: String?
|
||||
private(set) var clientSecret: String?
|
||||
private(set) var accessToken: String?
|
||||
|
||||
private(set) var persistentContainer: PersistentContainer!
|
||||
|
||||
init(instanceURL: URL) {
|
||||
self.instanceURL = instanceURL
|
||||
self.client = FervorClient(instanceURL: instanceURL, accessToken: nil)
|
||||
|
@ -26,9 +32,14 @@ class FervorController {
|
|||
|
||||
convenience init(account: LocalData.Account) {
|
||||
self.init(instanceURL: account.instanceURL)
|
||||
self.account = account
|
||||
self.clientID = account.clientID
|
||||
self.clientSecret = account.clientSecret
|
||||
self.accessToken = account.accessToken
|
||||
|
||||
self.client.accessToken = account.accessToken
|
||||
|
||||
self.persistentContainer = PersistentContainer(account: account, fervorController: self)
|
||||
}
|
||||
|
||||
func register() async throws -> ClientRegistration {
|
||||
|
@ -44,4 +55,80 @@ class FervorController {
|
|||
accessToken = token.accessToken
|
||||
}
|
||||
|
||||
func syncAll() async throws {
|
||||
logger.info("Syncing groups and feeds")
|
||||
async let groups = try client.groups()
|
||||
async let feeds = try client.feeds()
|
||||
try await persistentContainer.sync(serverGroups: groups, serverFeeds: feeds)
|
||||
|
||||
let lastSync = try await persistentContainer.lastSyncDate()
|
||||
logger.info("Syncing items with last sync date: \(String(describing: lastSync), privacy: .public)")
|
||||
let update = try await client.syncItems(lastSync: lastSync)
|
||||
try await persistentContainer.syncItems(update)
|
||||
try await persistentContainer.updateLastSyncDate(update.syncTimestamp)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func syncReadToServer() async throws {
|
||||
var count = 0
|
||||
// todo: there should be a batch update api endpoint
|
||||
for case let item as Item in persistentContainer.viewContext.updatedObjects {
|
||||
let f = item.read ? client.read(item:) : client.unread(item:)
|
||||
do {
|
||||
let _ = try await f(item.id!)
|
||||
count += 1
|
||||
} catch {
|
||||
logger.error("Failed to sync read state: \(error.localizedDescription, privacy: .public)")
|
||||
item.needsReadStateSync = true
|
||||
}
|
||||
}
|
||||
|
||||
// try to sync items which failed last time
|
||||
let req = Item.fetchRequest()
|
||||
req.predicate = NSPredicate(format: "needsReadStateSync = YES")
|
||||
if let needsSync = try? persistentContainer.viewContext.fetch(req) {
|
||||
for item in needsSync {
|
||||
let f = item.read ? client.read(item:) : client.unread(item:)
|
||||
do {
|
||||
let _ = try await f(item.id!)
|
||||
count += 1
|
||||
item.needsReadStateSync = false
|
||||
} catch {
|
||||
logger.error("Failed to sync read state again: \(error.localizedDescription, privacy: .public)")
|
||||
item.needsReadStateSync = true
|
||||
// todo: this should probably fail after a certain number of attempts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Synced \(count, privacy: .public) read/unread to server")
|
||||
|
||||
do {
|
||||
try persistentContainer.viewContext.save()
|
||||
} catch {
|
||||
logger.error("Failed to save view context: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func markItem(_ item: Item, read: Bool) async {
|
||||
item.read = read
|
||||
do {
|
||||
let f = item.read ? client.read(item:) : client.unread(item:)
|
||||
_ = try await f(item.id!)
|
||||
item.needsReadStateSync = false
|
||||
} catch {
|
||||
logger.error("Failed to mark item (un)read: \(error.localizedDescription, privacy: .public)")
|
||||
item.needsReadStateSync = true
|
||||
}
|
||||
|
||||
if persistentContainer.viewContext.hasChanges {
|
||||
do {
|
||||
try persistentContainer.viewContext.save()
|
||||
} catch {
|
||||
logger.error("Failed to save view context: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -2,20 +2,32 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.preferences</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.add-account</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.activate-account</string>
|
||||
</array>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<true/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>Default Configuration</string>
|
||||
<string>main</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).PrefsSceneDelegate</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>prefs</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
|
|
|
@ -14,25 +14,54 @@ struct LocalData {
|
|||
private static let encoder = JSONEncoder()
|
||||
private static let decoder = JSONDecoder()
|
||||
|
||||
static var account: Account? {
|
||||
static var accounts: [Account] {
|
||||
get {
|
||||
guard let data = UserDefaults.standard.data(forKey: "account") else {
|
||||
return nil
|
||||
guard let data = UserDefaults.standard.data(forKey: "accounts"),
|
||||
let accounts = try? decoder.decode([Account].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return try? decoder.decode(Account.self, from: data)
|
||||
return accounts
|
||||
}
|
||||
set {
|
||||
let data = try! encoder.encode(newValue)
|
||||
UserDefaults.standard.set(data, forKey: "account")
|
||||
UserDefaults.standard.set(data, forKey: "accounts")
|
||||
}
|
||||
}
|
||||
|
||||
static var mostRecentAccountID: UUID? {
|
||||
get {
|
||||
guard let str = UserDefaults.standard.string(forKey: "mostRecentAccountID") else {
|
||||
return nil
|
||||
}
|
||||
return UUID(uuidString: str)
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue?.uuidString, forKey: "mostRecentAccountID")
|
||||
}
|
||||
}
|
||||
|
||||
static func mostRecentAccount() -> Account? {
|
||||
guard let id = mostRecentAccountID else {
|
||||
return nil
|
||||
}
|
||||
return accounts.first(where: { $0.id == id })
|
||||
}
|
||||
|
||||
struct Account: Codable {
|
||||
let id: UUID
|
||||
let instanceURL: URL
|
||||
let clientID: String
|
||||
let clientSecret: String
|
||||
let accessToken: String
|
||||
// todo: refresh tokens
|
||||
|
||||
init(instanceURL: URL, clientID: String, clientSecret: String, accessToken: String) {
|
||||
self.id = UUID()
|
||||
self.instanceURL = instanceURL
|
||||
self.clientID = clientID
|
||||
self.clientSecret = clientSecret
|
||||
self.accessToken = accessToken
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
//
|
||||
// Preferences.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/16/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class Preferences: Codable, ObservableObject {
|
||||
|
||||
private static let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
private static let archiveURL = Preferences.documentsDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
|
||||
private static let decoder = PropertyListDecoder()
|
||||
private static let encoder = PropertyListEncoder()
|
||||
|
||||
static var shared = load()
|
||||
|
||||
private static func load() -> Preferences {
|
||||
if let data = try? Data(contentsOf: archiveURL),
|
||||
let prefs = try? decoder.decode(Preferences.self, from: data) {
|
||||
return prefs
|
||||
} else {
|
||||
return Preferences()
|
||||
}
|
||||
}
|
||||
|
||||
static func save() {
|
||||
if let data = try? encoder.encode(shared) {
|
||||
try? data.write(to: archiveURL, options: .noFileProtection)
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
}
|
||||
|
||||
required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.appearance = try container.decode(Appearance.self, forKey: .appearance)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(appearance, forKey: .appearance)
|
||||
}
|
||||
|
||||
@Published var appearance = Appearance.unspecified {
|
||||
didSet {
|
||||
NotificationCenter.default.post(name: .appearanceChanged, object: nil, userInfo: [
|
||||
"appearance": appearance.rawValue
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case appearance
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum Appearance: Int, Codable {
|
||||
case unspecified = 0
|
||||
case light = 1
|
||||
case dark = 2
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let appearanceChanged = Notification.Name("appearanceChanged")
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
//
|
||||
// PrefsSceneDelegate.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/16/22.
|
||||
//
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
class PrefsSceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
guard let windowScene = scene as? UIWindowScene else {
|
||||
return
|
||||
}
|
||||
|
||||
window = UIWindow(windowScene: windowScene)
|
||||
window!.tintColor = .appTintColor
|
||||
windowScene.sizeRestrictions?.minimumSize = CGSize(width: 640, height: 480)
|
||||
windowScene.sizeRestrictions?.maximumSize = CGSize(width: 640, height: 480)
|
||||
|
||||
window!.rootViewController = UIHostingController(rootView: PrefsView())
|
||||
|
||||
if let titlebar = windowScene.titlebar {
|
||||
titlebar.toolbarStyle = .preference
|
||||
titlebar.toolbar = NSToolbar(identifier: .init("ReaderPrefsToolbar"))
|
||||
titlebar.toolbar!.delegate = self
|
||||
titlebar.toolbar!.allowsUserCustomization = false
|
||||
}
|
||||
|
||||
window!.makeKeyAndVisible()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(updateAppearance), name: .appearanceChanged, object: nil)
|
||||
updateAppearance()
|
||||
}
|
||||
|
||||
@objc private func updateAppearance() {
|
||||
switch Preferences.shared.appearance {
|
||||
case .unspecified:
|
||||
window!.overrideUserInterfaceStyle = .unspecified
|
||||
case .light:
|
||||
window!.overrideUserInterfaceStyle = .light
|
||||
case .dark:
|
||||
window!.overrideUserInterfaceStyle = .dark
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension PrefsSceneDelegate: NSToolbarDelegate {
|
||||
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
return []
|
||||
}
|
||||
|
||||
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
|
@ -0,0 +1,13 @@
|
|||
//
|
||||
// Reader-Bridging-Header.h
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/12/22.
|
||||
//
|
||||
|
||||
#ifndef Reader_Bridging_Header_h
|
||||
#define Reader_Bridging_Header_h
|
||||
|
||||
#import "lol_html.h"
|
||||
|
||||
#endif /* Reader_Bridging_Header_h */
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="1" systemVersion="11A491" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="false" userDefinedModelVersionIdentifier="">
|
||||
<elements/>
|
||||
</model>
|
|
@ -6,12 +6,16 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import OSLog
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
private(set) var fervorController: FervorController!
|
||||
private(set) var toggleReadBarButtonItem: UIBarButtonItem?
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "SceneDelegate")
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
|
||||
|
@ -20,8 +24,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
guard let windowScene = (scene as? UIWindowScene) else { return }
|
||||
|
||||
window = UIWindow(windowScene: windowScene)
|
||||
window!.tintColor = .appTintColor
|
||||
|
||||
if let account = LocalData.account {
|
||||
let activity = connectionOptions.userActivities.first
|
||||
if activity?.activityType == NSUserActivity.addAccountType {
|
||||
let loginVC = LoginViewController()
|
||||
loginVC.delegate = self
|
||||
window!.rootViewController = loginVC
|
||||
} else if activity?.activityType == NSUserActivity.activateAccountType,
|
||||
let account = LocalData.accounts.first(where: { $0.id.uuidString == activity!.userInfo?["accountID"] as? String }) {
|
||||
fervorController = FervorController(account: account)
|
||||
createAppUI()
|
||||
} else if let account = LocalData.mostRecentAccount() {
|
||||
fervorController = FervorController(account: account)
|
||||
createAppUI()
|
||||
} else {
|
||||
|
@ -30,7 +44,19 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
window!.rootViewController = loginVC
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
if let titlebar = windowScene.titlebar {
|
||||
titlebar.toolbarStyle = .unifiedCompact
|
||||
titlebar.toolbar = NSToolbar(identifier: .init("ReaderToolbar"))
|
||||
titlebar.toolbar!.delegate = self
|
||||
titlebar.toolbar!.allowsUserCustomization = false
|
||||
}
|
||||
#endif
|
||||
|
||||
window!.makeKeyAndVisible()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(updateAppearance), name: .appearanceChanged, object: nil)
|
||||
updateAppearance()
|
||||
}
|
||||
|
||||
func sceneDidDisconnect(_ scene: UIScene) {
|
||||
|
@ -43,11 +69,25 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
// Called when the scene has moved from an inactive state to an active state.
|
||||
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
|
||||
|
||||
UIMenuSystem.main.setNeedsRebuild()
|
||||
|
||||
syncFromServer()
|
||||
}
|
||||
|
||||
func sceneWillResignActive(_ scene: UIScene) {
|
||||
// Called when the scene will move from an active state to an inactive state.
|
||||
// This may occur due to temporary interruptions (ex. an incoming phone call).
|
||||
|
||||
if let fervorController = fervorController {
|
||||
Task(priority: .userInitiated) {
|
||||
do {
|
||||
try await fervorController.syncReadToServer()
|
||||
} catch {
|
||||
logger.error("Unable to sync read state to server: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sceneWillEnterForeground(_ scene: UIScene) {
|
||||
|
@ -59,23 +99,91 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
// Called as the scene transitions from the foreground to the background.
|
||||
// Use this method to save data, release shared resources, and store enough scene-specific state information
|
||||
// to restore the scene back to its current state.
|
||||
|
||||
// Save changes in the application's managed object context when the application transitions to the background.
|
||||
(UIApplication.shared.delegate as? AppDelegate)?.saveContext()
|
||||
}
|
||||
|
||||
private func createAppUI() {
|
||||
let home = HomeViewController(fervorController: fervorController)
|
||||
let nav = UINavigationController(rootViewController: home)
|
||||
window!.rootViewController = nav
|
||||
window!.rootViewController = AppSplitViewController(fervorController: fervorController)
|
||||
}
|
||||
|
||||
private func syncFromServer() {
|
||||
guard let fervorController = fervorController else {
|
||||
return
|
||||
}
|
||||
Task(priority: .userInitiated) {
|
||||
do {
|
||||
try await self.fervorController.syncAll()
|
||||
} catch {
|
||||
logger.error("Unable to sync from server: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
ExcerptGenerator.generateAll(fervorController)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func updateAppearance() {
|
||||
switch Preferences.shared.appearance {
|
||||
case .unspecified:
|
||||
window!.overrideUserInterfaceStyle = .unspecified
|
||||
case .light:
|
||||
window!.overrideUserInterfaceStyle = .light
|
||||
case .dark:
|
||||
window!.overrideUserInterfaceStyle = .dark
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SceneDelegate: LoginViewControllerDelegate {
|
||||
func didLogin(with controller: FervorController) {
|
||||
LocalData.account = .init(instanceURL: controller.instanceURL, clientID: controller.clientID!, clientSecret: controller.clientSecret!, accessToken: controller.accessToken!)
|
||||
fervorController = controller
|
||||
let account = LocalData.Account(instanceURL: controller.instanceURL, clientID: controller.clientID!, clientSecret: controller.clientSecret!, accessToken: controller.accessToken!)
|
||||
LocalData.accounts.append(account)
|
||||
LocalData.mostRecentAccountID = account.id
|
||||
fervorController = FervorController(account: account)
|
||||
|
||||
createAppUI()
|
||||
syncFromServer()
|
||||
|
||||
UIMenuSystem.main.setNeedsRebuild()
|
||||
}
|
||||
}
|
||||
|
||||
extension SceneDelegate: HomeViewControllerDelegate {
|
||||
func switchToAccount(_ account: LocalData.Account) {
|
||||
LocalData.mostRecentAccountID = account.id
|
||||
fervorController = FervorController(account: account)
|
||||
createAppUI()
|
||||
syncFromServer()
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
extension NSToolbarItem.Identifier {
|
||||
static let toggleItemRead = NSToolbarItem.Identifier("ToggleItemRead")
|
||||
static let shareItem = NSToolbarItem.Identifier("ShareItem")
|
||||
}
|
||||
|
||||
extension SceneDelegate: NSToolbarDelegate {
|
||||
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
|
||||
if itemIdentifier == .toggleItemRead {
|
||||
// need an item bar button item to make the size of the image match the share button
|
||||
let item = NSToolbarItem(itemIdentifier: .toggleItemRead, barButtonItem: UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil))
|
||||
item.image = UIImage(systemName: "checkmark.circle")
|
||||
item.target = nil
|
||||
item.action = #selector(ReadViewController.toggleItemRead(_:))
|
||||
return item
|
||||
} else if itemIdentifier == .shareItem {
|
||||
return NSSharingServicePickerToolbarItem(itemIdentifier: .shareItem)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
return [.shareItem, .toggleItemRead]
|
||||
}
|
||||
|
||||
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
return [.shareItem, .toggleItemRead]
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
@ -0,0 +1,129 @@
|
|||
//
|
||||
// AppNavigationController.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/10/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class AppNavigationController: UINavigationController, UINavigationControllerDelegate {
|
||||
|
||||
private var statusBarBlockingView: UIView!
|
||||
|
||||
static let panRecognizerName = "AppNavPanRecognizer"
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
let appearance = UINavigationBarAppearance()
|
||||
appearance.configureWithOpaqueBackground()
|
||||
navigationBar.scrollEdgeAppearance = appearance
|
||||
|
||||
interactivePopGestureRecognizer?.isEnabled = false
|
||||
let recognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognized))
|
||||
recognizer.allowedScrollTypesMask = .continuous
|
||||
recognizer.name = AppNavigationController.panRecognizerName
|
||||
view.addGestureRecognizer(recognizer)
|
||||
|
||||
isNavigationBarHidden = true
|
||||
|
||||
statusBarBlockingView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
||||
statusBarBlockingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
statusBarBlockingView.layer.zPosition = 101
|
||||
view.addSubview(statusBarBlockingView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
statusBarBlockingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
statusBarBlockingView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
statusBarBlockingView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
statusBarBlockingView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
])
|
||||
|
||||
delegate = self
|
||||
}
|
||||
|
||||
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
|
||||
statusBarBlockingView.isHidden = viewController.prefersStatusBarHidden
|
||||
}
|
||||
|
||||
private var poppingViewController: UIViewController?
|
||||
private var prevNavBarHidden = false
|
||||
private var dimmingView: UIView = {
|
||||
let v = UIView()
|
||||
v.backgroundColor = .black
|
||||
return v
|
||||
}()
|
||||
|
||||
@objc private func panGestureRecognized(_ recognizer: UIPanGestureRecognizer) {
|
||||
let translation = recognizer.translation(in: view)
|
||||
let translationProgress = max(0, translation.x) / view.bounds.width
|
||||
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
guard viewControllers.count > 1 else {
|
||||
break
|
||||
}
|
||||
prevNavBarHidden = isNavigationBarHidden
|
||||
poppingViewController = popViewController(animated: false)
|
||||
view.addSubview(poppingViewController!.view)
|
||||
poppingViewController!.view.transform = CGAffineTransform(translationX: max(0, translation.x), y: 0)
|
||||
poppingViewController!.view.layer.zPosition = 100
|
||||
dimmingView.frame = view.bounds
|
||||
dimmingView.layer.opacity = Float(1 - translationProgress) * 0.075
|
||||
dimmingView.layer.zPosition = 99
|
||||
view.addSubview(dimmingView)
|
||||
// changing the transform directly on topViewController.view doesn't work for some reason, have to go 2 superviews up
|
||||
topViewController!.view.superview?.superview?.transform = CGAffineTransform(translationX: (1 - translationProgress) * -0.3 * view.bounds.width, y: 0)
|
||||
|
||||
case .changed:
|
||||
guard let poppingViewController = poppingViewController else {
|
||||
break
|
||||
}
|
||||
|
||||
poppingViewController.view.transform = CGAffineTransform(translationX: max(0, translation.x), y: 0)
|
||||
dimmingView.layer.opacity = Float(1 - max(0, translation.x) / view.bounds.width) * 0.075
|
||||
topViewController!.view.superview?.superview?.transform = CGAffineTransform(translationX: (1 - translationProgress) * -0.3 * view.bounds.width, y: 0)
|
||||
|
||||
case .ended:
|
||||
guard let poppingViewController = poppingViewController else {
|
||||
break
|
||||
}
|
||||
|
||||
let velocity = recognizer.velocity(in: view)
|
||||
let shouldComplete = translation.x >= view.bounds.width / 2 || velocity.x >= 500
|
||||
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) {
|
||||
if shouldComplete {
|
||||
poppingViewController.view.transform = CGAffineTransform(translationX: self.view.bounds.width, y: 0)
|
||||
self.topViewController!.view.superview?.superview?.transform = .identity
|
||||
} else {
|
||||
poppingViewController.view.transform = .identity
|
||||
self.topViewController!.view.superview?.superview?.transform = CGAffineTransform(translationX: -0.3 * self.view.bounds.width, y: 0)
|
||||
}
|
||||
self.dimmingView.layer.opacity = 0
|
||||
} completion: { _ in
|
||||
self.topViewController!.view.superview?.superview?.transform = .identity
|
||||
|
||||
if shouldComplete {
|
||||
poppingViewController.beginAppearanceTransition(false, animated: true)
|
||||
poppingViewController.willMove(toParent: nil)
|
||||
poppingViewController.removeFromParent()
|
||||
poppingViewController.view.removeFromSuperview()
|
||||
poppingViewController.endAppearanceTransition()
|
||||
} else {
|
||||
self.pushViewController(poppingViewController, animated: false)
|
||||
self.isNavigationBarHidden = self.prevNavBarHidden
|
||||
}
|
||||
|
||||
poppingViewController.view.layer.zPosition = 0
|
||||
self.poppingViewController = nil
|
||||
self.dimmingView.removeFromSuperview()
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
//
|
||||
// AppSplitViewController.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/14/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
class AppSplitViewController: UISplitViewController {
|
||||
|
||||
private let fervorController: FervorController
|
||||
|
||||
private var secondaryNav: UINavigationController!
|
||||
|
||||
init(fervorController: FervorController) {
|
||||
self.fervorController = fervorController
|
||||
|
||||
super.init(style: .doubleColumn)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
preferredDisplayMode = .oneBesideSecondary
|
||||
preferredSplitBehavior = .tile
|
||||
presentsWithGesture = true
|
||||
showsSecondaryOnlyButton = true
|
||||
primaryBackgroundStyle = .sidebar
|
||||
maximumPrimaryColumnWidth = 500
|
||||
|
||||
let sidebarHome = HomeViewController(fervorController: fervorController)
|
||||
sidebarHome.enableStretchyMenu = false
|
||||
sidebarHome.itemsDelegate = self
|
||||
let sidebarNav = UINavigationController(rootViewController: sidebarHome)
|
||||
sidebarNav.navigationBar.prefersLargeTitles = true
|
||||
setViewController(sidebarNav, for: .primary)
|
||||
|
||||
secondaryNav = UINavigationController()
|
||||
secondaryNav.isNavigationBarHidden = true
|
||||
secondaryNav.view.backgroundColor = .appBackground
|
||||
setViewController(secondaryNav, for: .secondary)
|
||||
|
||||
let home = HomeViewController(fervorController: fervorController)
|
||||
let nav = AppNavigationController(rootViewController: home)
|
||||
setViewController(nav, for: .compact)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AppSplitViewController: ItemsViewControllerDelegate {
|
||||
func showReadItem(_ item: Item) {
|
||||
secondaryNav.setViewControllers([ReadViewController(item: item, fervorController: fervorController)], animated: false)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// HomeCollectionViewCell.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/9/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class HomeCollectionViewCell: UICollectionViewListCell {
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
override func updateConfiguration(using state: UICellConfigurationState) {
|
||||
var backgroundConfig = UIBackgroundConfiguration.listGroupedCell().updated(for: state)
|
||||
if state.isHighlighted || state.isSelected {
|
||||
backgroundConfig.backgroundColor = .appCellHighlightBackground
|
||||
} else {
|
||||
backgroundConfig.backgroundColor = .appBackground
|
||||
}
|
||||
self.backgroundConfiguration = backgroundConfig
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
|
@ -6,11 +6,26 @@
|
|||
//
|
||||
|
||||
import UIKit
|
||||
import CoreData
|
||||
|
||||
protocol HomeViewControllerDelegate: AnyObject {
|
||||
func switchToAccount(_ account: LocalData.Account)
|
||||
}
|
||||
|
||||
class HomeViewController: UIViewController {
|
||||
|
||||
weak var delegate: HomeViewControllerDelegate?
|
||||
weak var itemsDelegate: ItemsViewControllerDelegate?
|
||||
|
||||
let fervorController: FervorController
|
||||
|
||||
var enableStretchyMenu = true
|
||||
|
||||
private var collectionView: UICollectionView!
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
private var groupResultsController: NSFetchedResultsController<Group>!
|
||||
private var feedResultsController: NSFetchedResultsController<Feed>!
|
||||
|
||||
init(fervorController: FervorController) {
|
||||
self.fervorController = fervorController
|
||||
|
||||
|
@ -24,16 +39,271 @@ class HomeViewController: UIViewController {
|
|||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = .systemBackground
|
||||
// todo: account info
|
||||
title = "Reader"
|
||||
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.text = "Logged in to \(fervorController.instanceURL.host!)"
|
||||
view.addSubview(label)
|
||||
NSLayoutConstraint.activate([
|
||||
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
])
|
||||
if enableStretchyMenu {
|
||||
view.addInteraction(StretchyMenuInteraction(delegate: self))
|
||||
}
|
||||
|
||||
if UIDevice.current.userInterfaceIdiom != .mac {
|
||||
view.backgroundColor = .appBackground
|
||||
}
|
||||
|
||||
var config = UICollectionLayoutListConfiguration(appearance: UIDevice.current.userInterfaceIdiom == .mac ? .sidebar : .grouped)
|
||||
config.headerMode = .supplementary
|
||||
config.backgroundColor = .clear
|
||||
config.separatorConfiguration.topSeparatorVisibility = .visible
|
||||
config.separatorConfiguration.topSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 0)
|
||||
config.separatorConfiguration.bottomSeparatorVisibility = .hidden
|
||||
config.itemSeparatorHandler = { indexPath, defaultConfig in
|
||||
var config = defaultConfig
|
||||
if indexPath.section == 0 && indexPath.row == 0 {
|
||||
config.topSeparatorVisibility = .hidden
|
||||
}
|
||||
return config
|
||||
}
|
||||
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
||||
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
|
||||
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
collectionView.delegate = self
|
||||
view.addSubview(collectionView)
|
||||
|
||||
dataSource = createDataSource()
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.all, .groups, .feeds])
|
||||
snapshot.appendItems([.unread, .all], toSection: .all)
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
let groupReq = Group.fetchRequest()
|
||||
groupReq.sortDescriptors = [NSSortDescriptor(key: "title", ascending: true)]
|
||||
groupResultsController = NSFetchedResultsController(fetchRequest: groupReq, managedObjectContext: fervorController.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)
|
||||
groupResultsController.delegate = self
|
||||
try! groupResultsController.performFetch()
|
||||
|
||||
let feedReq = Feed.fetchRequest()
|
||||
feedReq.sortDescriptors = [NSSortDescriptor(key: "title", ascending: true)]
|
||||
feedResultsController = NSFetchedResultsController(fetchRequest: feedReq, managedObjectContext: fervorController.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil)
|
||||
feedResultsController.delegate = self
|
||||
try! feedResultsController.performFetch()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if let indexPaths = collectionView.indexPathsForSelectedItems {
|
||||
for indexPath in indexPaths {
|
||||
collectionView.deselectItem(at: indexPath, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
var snapshot = dataSource.snapshot()
|
||||
// reconfigure so that unread counts update
|
||||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||
dataSource.apply(snapshot)
|
||||
}
|
||||
|
||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||
let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { supplementaryView, elementKind, indexPath in
|
||||
let section = self.dataSource.sectionIdentifier(for: indexPath.section)!
|
||||
var config = supplementaryView.defaultContentConfiguration()
|
||||
config.text = section.title
|
||||
supplementaryView.contentConfiguration = config
|
||||
}
|
||||
let listCell = UICollectionView.CellRegistration<HomeCollectionViewCell, Item> { cell, indexPath, item in
|
||||
var config = UIListContentConfiguration.valueCell()
|
||||
config.text = item.title
|
||||
if let req = item.countFetchRequest,
|
||||
let count = try? self.fervorController.persistentContainer.viewContext.count(for: req) {
|
||||
config.secondaryText = "\(count)"
|
||||
config.secondaryTextProperties.color = .tintColor
|
||||
}
|
||||
cell.contentConfiguration = config
|
||||
|
||||
cell.accessories = [.disclosureIndicator()]
|
||||
}
|
||||
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in
|
||||
return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: item)
|
||||
}
|
||||
dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in
|
||||
if elementKind == UICollectionView.elementKindSectionHeader {
|
||||
return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return dataSource
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension HomeViewController {
|
||||
enum Section: Hashable {
|
||||
case all
|
||||
case groups
|
||||
case feeds
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all:
|
||||
return ""
|
||||
case .groups:
|
||||
return "Groups"
|
||||
case .feeds:
|
||||
return "Feeds"
|
||||
}
|
||||
}
|
||||
}
|
||||
enum Item: Hashable {
|
||||
case unread
|
||||
case all
|
||||
case group(Group)
|
||||
case feed(Feed)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .unread:
|
||||
return "Unread Articles"
|
||||
case .all:
|
||||
return "All Articles"
|
||||
case let .group(group):
|
||||
return group.title
|
||||
case let .feed(feed):
|
||||
return feed.title!
|
||||
}
|
||||
}
|
||||
|
||||
var fetchRequest: NSFetchRequest<Reader.Item> {
|
||||
let req = Reader.Item.fetchRequest()
|
||||
switch self {
|
||||
case .unread:
|
||||
req.predicate = NSPredicate(format: "read = NO")
|
||||
case .all:
|
||||
break
|
||||
case .group(let group):
|
||||
req.predicate = NSPredicate(format: "feed in %@", group.feeds!)
|
||||
case .feed(let feed):
|
||||
req.predicate = NSPredicate(format: "feed = %@", feed)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
var idFetchRequest: NSFetchRequest<NSManagedObjectID> {
|
||||
let req = NSFetchRequest<NSManagedObjectID>(entityName: "Item")
|
||||
req.resultType = .managedObjectIDResultType
|
||||
switch self {
|
||||
case .unread:
|
||||
req.predicate = NSPredicate(format: "read = NO")
|
||||
case .all:
|
||||
break
|
||||
case .group(let group):
|
||||
req.predicate = NSPredicate(format: "feed in %@", group.feeds!)
|
||||
case .feed(let feed):
|
||||
req.predicate = NSPredicate(format: "feed = %@", feed)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
var countFetchRequest: NSFetchRequest<Reader.Item>? {
|
||||
let req = Reader.Item.fetchRequest()
|
||||
switch self {
|
||||
case .unread:
|
||||
req.predicate = NSPredicate(format: "read = NO")
|
||||
case .all:
|
||||
return nil
|
||||
case .group(let group):
|
||||
req.predicate = NSPredicate(format: "read = NO AND feed in %@", group.feeds!)
|
||||
case .feed(let feed):
|
||||
req.predicate = NSPredicate(format: "read = NO AND feed = %@", feed)
|
||||
}
|
||||
return req
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeViewController: NSFetchedResultsControllerDelegate {
|
||||
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
|
||||
var snapshot = dataSource.snapshot()
|
||||
if controller == groupResultsController {
|
||||
if snapshot.sectionIdentifiers.contains(.groups) {
|
||||
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .groups))
|
||||
}
|
||||
snapshot.appendItems(controller.fetchedObjects!.map { .group($0 as! Group) }, toSection: .groups)
|
||||
} else if controller == feedResultsController {
|
||||
if snapshot.sectionIdentifiers.contains(.feeds) {
|
||||
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .feeds))
|
||||
}
|
||||
snapshot.appendItems(controller.fetchedObjects!.map { .feed($0 as! Feed) }, toSection: .feeds)
|
||||
}
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeViewController: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else {
|
||||
return
|
||||
}
|
||||
let vc = ItemsViewController(fetchRequest: item.idFetchRequest, fervorController: fervorController)
|
||||
vc.title = item.title
|
||||
vc.delegate = itemsDelegate
|
||||
show(vc, sender: nil)
|
||||
UISelectionFeedbackGenerator().selectionChanged()
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else {
|
||||
return nil
|
||||
}
|
||||
return UIContextMenuConfiguration(identifier: nil, previewProvider: {
|
||||
return ItemsViewController(fetchRequest: item.idFetchRequest, fervorController: self.fervorController)
|
||||
}, actionProvider: nil)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
guard let vc = animator.previewViewController else {
|
||||
return
|
||||
}
|
||||
animator.preferredCommitStyle = .pop
|
||||
animator.addCompletion {
|
||||
self.show(vc, sender: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeViewController: StretchyMenuInteractionDelegate {
|
||||
func stretchyMenuTitle() -> String? {
|
||||
return "Switch Accounts"
|
||||
}
|
||||
func stretchyMenuItems() -> [StretchyMenuItem] {
|
||||
var items: [StretchyMenuItem] = LocalData.accounts.map { account in
|
||||
var title = account.instanceURL.host!
|
||||
if let port = account.instanceURL.port, port != 80 && port != 443 {
|
||||
title += ":\(port)"
|
||||
}
|
||||
let subtitle = account.id == fervorController.account!.id ? "Currently logged in" : nil
|
||||
return StretchyMenuItem(title: title, subtitle: subtitle) { [unowned self] in
|
||||
guard account.id != self.fervorController.account!.id else { return }
|
||||
self.delegate?.switchToAccount(account)
|
||||
}
|
||||
}
|
||||
items.append(StretchyMenuItem(title: "Add Account", subtitle: nil, action: { [unowned self] in
|
||||
let login = LoginViewController()
|
||||
login.delegate = self
|
||||
login.navigationItem.leftBarButtonItem = UIBarButtonItem(systemItem: .cancel, primaryAction: UIAction(handler: { (_) in
|
||||
self.dismiss(animated: true)
|
||||
}), menu: nil)
|
||||
let nav = UINavigationController(rootViewController: login)
|
||||
self.present(nav, animated: true)
|
||||
}))
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeViewController: LoginViewControllerDelegate {
|
||||
func didLogin(with controller: FervorController) {
|
||||
self.dismiss(animated: true)
|
||||
(view.window!.windowScene!.delegate as! SceneDelegate).didLogin(with: controller)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,172 @@
|
|||
//
|
||||
// ItemCollectionViewCell.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/9/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol ItemCollectionViewCellDelegate: AnyObject {
|
||||
var fervorController: FervorController { get }
|
||||
|
||||
func itemCellSelected(cell: ItemCollectionViewCell, item: Item)
|
||||
}
|
||||
|
||||
class ItemCollectionViewCell: UICollectionViewListCell {
|
||||
|
||||
weak var delegate: ItemCollectionViewCellDelegate?
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let feedTitleLabel = UILabel()
|
||||
private let contentLabel = UILabel()
|
||||
private var shouldInteractOnNextTouch = true
|
||||
var scrollView: UIScrollView?
|
||||
|
||||
private var item: Item!
|
||||
|
||||
private lazy var feedbackGenerator = UISelectionFeedbackGenerator()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
backgroundConfiguration = .clear()
|
||||
|
||||
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .title3).withSymbolicTraits(.traitBold)!.withDesign(.serif)!
|
||||
titleLabel.font = UIFont(descriptor: descriptor, size: 0)
|
||||
titleLabel.numberOfLines = 0
|
||||
|
||||
feedTitleLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .subheadline), size: 0)
|
||||
|
||||
contentLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).withDesign(.serif)!, size: 0)
|
||||
contentLabel.numberOfLines = 0
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [
|
||||
titleLabel,
|
||||
feedTitleLabel,
|
||||
contentLabel,
|
||||
])
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
stack.spacing = 8
|
||||
stack.axis = .vertical
|
||||
addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
|
||||
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
|
||||
stack.topAnchor.constraint(equalTo: topAnchor, constant: 8),
|
||||
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
|
||||
separatorLayoutGuide.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
||||
])
|
||||
|
||||
let doubleTap = DoubleTapRecognizer(target: self, action: #selector(cellDoubleTapped))
|
||||
doubleTap.onTouchesBegan = onDoubleTapBegan
|
||||
addGestureRecognizer(doubleTap)
|
||||
|
||||
let singleTap = UITapGestureRecognizer(target: self, action: #selector(cellSingleTapped))
|
||||
singleTap.require(toFail: doubleTap)
|
||||
addGestureRecognizer(singleTap)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func updateUI(item: Item) {
|
||||
self.item = item
|
||||
|
||||
titleLabel.text = item.title
|
||||
feedTitleLabel.text = item.feed!.title ?? item.feed!.url?.host
|
||||
if let excerpt = item.excerpt {
|
||||
contentLabel.text = excerpt
|
||||
contentLabel.isHidden = false
|
||||
} else {
|
||||
contentLabel.isHidden = true
|
||||
}
|
||||
|
||||
updateColors()
|
||||
}
|
||||
|
||||
func setRead(_ read: Bool, animated: Bool) {
|
||||
guard item.read != read else { return }
|
||||
|
||||
item.read = read
|
||||
|
||||
if animated {
|
||||
// i don't know why .transition works but .animate doesn't
|
||||
UIView.transition(with: self, duration: 0.2, options: .transitionCrossDissolve) {
|
||||
self.updateColors()
|
||||
}
|
||||
} else {
|
||||
updateColors()
|
||||
}
|
||||
|
||||
Task {
|
||||
await delegate?.fervorController.markItem(item, read: read)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateColors() {
|
||||
if item.read {
|
||||
titleLabel.textColor = .secondaryLabel
|
||||
feedTitleLabel.textColor = .secondaryLabel
|
||||
contentLabel.textColor = .secondaryLabel
|
||||
} else {
|
||||
titleLabel.textColor = .label
|
||||
feedTitleLabel.textColor = .tintColor
|
||||
contentLabel.textColor = .appContentPreviewLabel
|
||||
}
|
||||
}
|
||||
|
||||
private func onDoubleTapBegan() {
|
||||
guard shouldInteractOnNextTouch else { return }
|
||||
shouldInteractOnNextTouch = false
|
||||
feedbackGenerator.prepare()
|
||||
|
||||
// couldn't manage to do this with just the recognizers
|
||||
if scrollView?.isDragging == false && scrollView?.isDecelerating == false {
|
||||
UIView.animateKeyframes(withDuration: 0.4, delay: 0, options: .allowUserInteraction) {
|
||||
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
|
||||
self.backgroundColor = .appCellHighlightBackground
|
||||
}
|
||||
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
|
||||
self.backgroundColor = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func cellDoubleTapped() {
|
||||
setRead(!item.read, animated: true)
|
||||
shouldInteractOnNextTouch = true
|
||||
feedbackGenerator.selectionChanged()
|
||||
}
|
||||
|
||||
@objc private func cellSingleTapped() {
|
||||
delegate?.itemCellSelected(cell: self, item: item)
|
||||
shouldInteractOnNextTouch = true
|
||||
feedbackGenerator.selectionChanged()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private class DoubleTapRecognizer: UITapGestureRecognizer {
|
||||
var onTouchesBegan: (() -> Void)!
|
||||
|
||||
override init(target: Any?, action: Selector?) {
|
||||
super.init(target: target, action: action)
|
||||
numberOfTapsRequired = 2
|
||||
delaysTouchesBegan = true
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
onTouchesBegan()
|
||||
// shorten the delay before the single tap is recognized
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(400)) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.state != .recognized {
|
||||
self.state = .failed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,231 @@
|
|||
//
|
||||
// ItemsViewController.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/9/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import CoreData
|
||||
import SafariServices
|
||||
|
||||
protocol ItemsViewControllerDelegate: AnyObject {
|
||||
func showReadItem(_ item: Item)
|
||||
}
|
||||
|
||||
class ItemsViewController: UIViewController {
|
||||
|
||||
weak var delegate: ItemsViewControllerDelegate?
|
||||
|
||||
let fervorController: FervorController
|
||||
let fetchRequest: NSFetchRequest<NSManagedObjectID>
|
||||
|
||||
private var collectionView: UICollectionView!
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, NSManagedObjectID>!
|
||||
|
||||
private var batchUpdates: [() -> Void] = []
|
||||
|
||||
init(fetchRequest: NSFetchRequest<NSManagedObjectID>, fervorController: FervorController) {
|
||||
precondition(fetchRequest.entityName == "Item")
|
||||
|
||||
self.fervorController = fervorController
|
||||
self.fetchRequest = fetchRequest
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
if UIDevice.current.userInterfaceIdiom != .mac {
|
||||
view.backgroundColor = .appBackground
|
||||
}
|
||||
|
||||
var configuration = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||
configuration.backgroundColor = .clear
|
||||
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
|
||||
collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
|
||||
collectionView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(collectionView)
|
||||
|
||||
dataSource = createDataSource()
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
||||
collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
||||
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)]
|
||||
do {
|
||||
let ids = try fervorController.persistentContainer.viewContext.fetch(fetchRequest)
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, NSManagedObjectID>()
|
||||
snapshot.appendSections([.items])
|
||||
snapshot.appendItems(ids, toSection: .items)
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: fervorController.persistentContainer.viewContext)
|
||||
|
||||
} catch {
|
||||
let alert = UIAlertController(title: "Error fetching items", message: error.localizedDescription, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, NSManagedObjectID> {
|
||||
let itemCell = UICollectionView.CellRegistration<ItemCollectionViewCell, NSManagedObjectID> { [unowned self] cell, indexPath, itemIdentifier in
|
||||
cell.delegate = self
|
||||
cell.scrollView = self.collectionView
|
||||
let item = self.fervorController.persistentContainer.viewContext.object(with: itemIdentifier) as! Item
|
||||
cell.updateUI(item: item)
|
||||
}
|
||||
return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
|
||||
return collectionView.dequeueConfiguredReusableCell(using: itemCell, for: indexPath, item: itemIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func managedObjectsDidChange(_ notification: Notification) {
|
||||
let inserted = notification.userInfo?[NSInsertedObjectsKey] as? NSSet
|
||||
let updated = notification.userInfo?[NSUpdatedObjectsKey] as? NSSet
|
||||
let deleted = notification.userInfo?[NSDeletedObjectsKey] as? NSSet
|
||||
|
||||
// managed objectss from the notification are tied to the thread it was delivered on
|
||||
// so get the published dates and evaluate the predicate here
|
||||
let insertedItems = inserted?.compactMap { $0 as? Item }.filter { fetchRequest.predicate?.evaluate(with: $0) ?? true }.map { ($0, $0.published) }
|
||||
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
|
||||
if let updated = updated {
|
||||
snapshot.reconfigureItems(updated.compactMap { ($0 as? Item)?.objectID })
|
||||
}
|
||||
if let deleted = deleted {
|
||||
snapshot.deleteItems(deleted.compactMap { ($0 as? Item)?.objectID })
|
||||
}
|
||||
|
||||
if let insertedItems = insertedItems {
|
||||
self.fervorController.persistentContainer.performBackgroundTask { ctx in
|
||||
|
||||
// for newly inserted items, the ctx doesn't have the published date so we check the data we got from the notification
|
||||
func publishedForItem(_ id: NSManagedObjectID) -> Date? {
|
||||
if let (_, pub) = insertedItems.first(where: { $0.0.objectID == id }) {
|
||||
return pub
|
||||
} else {
|
||||
return (ctx.object(with: id) as! Item).published
|
||||
}
|
||||
}
|
||||
|
||||
// todo: this feels inefficient
|
||||
for (inserted, insertedPublished) in insertedItems {
|
||||
// todo: uhh, what does sql do if the published date is nil?
|
||||
guard let insertedPublished = insertedPublished else {
|
||||
snapshot.insertItems([inserted.objectID], beforeItem: snapshot.itemIdentifiers.first!)
|
||||
continue
|
||||
}
|
||||
|
||||
var index = 0
|
||||
while index < snapshot.itemIdentifiers.count,
|
||||
let pub = publishedForItem(snapshot.itemIdentifiers[index]),
|
||||
insertedPublished < pub {
|
||||
index += 1
|
||||
}
|
||||
|
||||
// index is the index of the first item which the inserted one was published after
|
||||
// (i.e., the item that should appear immediately after inserted in the list)
|
||||
|
||||
if index == snapshot.itemIdentifiers.count {
|
||||
snapshot.appendItems([inserted.objectID], toSection: .items)
|
||||
} else {
|
||||
snapshot.insertItems([inserted.objectID], beforeItem: snapshot.itemIdentifiers[index])
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.dataSource.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.dataSource.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ItemsViewController {
|
||||
enum Section: Hashable {
|
||||
case items
|
||||
}
|
||||
}
|
||||
|
||||
extension ItemsViewController: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||||
guard let id = dataSource.itemIdentifier(for: indexPath) else {
|
||||
return nil
|
||||
}
|
||||
let item = fervorController.persistentContainer.viewContext.object(with: id) as! Item
|
||||
// let item = resultsController.fetchedObjects![indexPath.row]
|
||||
return UIContextMenuConfiguration(identifier: nil, previewProvider: {
|
||||
ReadViewController(item: item, fervorController: self.fervorController)
|
||||
}, actionProvider: { _ in
|
||||
var children: [UIMenuElement] = []
|
||||
if let url = item.url {
|
||||
children.append(UIAction(title: "Open in Safari", image: UIImage(systemName: "safari"), handler: { [weak self] _ in
|
||||
let vc = SFSafariViewController(url: url)
|
||||
vc.preferredControlTintColor = .appTintColor
|
||||
self?.present(vc, animated: true)
|
||||
}))
|
||||
#if targetEnvironment(macCatalyst)
|
||||
self.activityItemsConfiguration = UIActivityItemsConfiguration(objects: [url as NSURL])
|
||||
children.append(UICommand(title: "Share…", action: Selector(("unused")), propertyList: UICommandTagShare))
|
||||
#else
|
||||
children.append(UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up"), handler: { [weak self] _ in
|
||||
self?.present(UIActivityViewController(activityItems: [url], applicationActivities: nil), animated: true)
|
||||
}))
|
||||
#endif
|
||||
}
|
||||
if item.read {
|
||||
children.append(UIAction(title: "Mark as Unread", image: UIImage(systemName: "checkmark.circle"), handler: { [unowned self] _ in
|
||||
Task {
|
||||
await self.fervorController.markItem(item, read: false)
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
children.append(UIAction(title: "Mark as Read", image: UIImage(systemName: "checkmark.circle.fill"), handler: { [unowned self] _ in
|
||||
Task {
|
||||
await self.fervorController.markItem(item, read: true)
|
||||
}
|
||||
}))
|
||||
}
|
||||
return UIMenu(children: children)
|
||||
})
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||
guard let vc = animator.previewViewController else {
|
||||
return
|
||||
}
|
||||
animator.preferredCommitStyle = .pop
|
||||
animator.addCompletion {
|
||||
self.show(vc, sender: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ItemsViewController: ItemCollectionViewCellDelegate {
|
||||
func itemCellSelected(cell: ItemCollectionViewCell, item: Item) {
|
||||
cell.setRead(true, animated: true)
|
||||
if let delegate = delegate {
|
||||
delegate.showReadItem(item)
|
||||
} else {
|
||||
show(ReadViewController(item: item, fervorController: fervorController), sender: nil)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,6 +9,7 @@ import UIKit
|
|||
import AuthenticationServices
|
||||
import Fervor
|
||||
|
||||
@MainActor
|
||||
protocol LoginViewControllerDelegate: AnyObject {
|
||||
func didLogin(with controller: FervorController)
|
||||
}
|
||||
|
@ -18,6 +19,7 @@ class LoginViewController: UIViewController {
|
|||
weak var delegate: LoginViewControllerDelegate?
|
||||
|
||||
private var textField: UITextField!
|
||||
private var activityIndicator: UIActivityIndicatorView!
|
||||
|
||||
private var authSession: ASWebAuthenticationSession?
|
||||
|
||||
|
@ -37,10 +39,18 @@ class LoginViewController: UIViewController {
|
|||
textField.placeholder = "example.com"
|
||||
textField.addTarget(self, action: #selector(doEnteredURL), for: .primaryActionTriggered)
|
||||
view.addSubview(textField)
|
||||
|
||||
activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(activityIndicator)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
textField.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
textField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
textField.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.75),
|
||||
|
||||
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
activityIndicator.topAnchor.constraint(equalTo: textField.bottomAnchor, constant: 8),
|
||||
])
|
||||
}
|
||||
|
||||
|
@ -59,14 +69,22 @@ class LoginViewController: UIViewController {
|
|||
return
|
||||
}
|
||||
|
||||
textField.isEnabled = false
|
||||
activityIndicator.startAnimating()
|
||||
|
||||
let controller = FervorController(instanceURL: components.url!)
|
||||
|
||||
let registration: ClientRegistration
|
||||
do {
|
||||
registration = try await controller.register()
|
||||
} catch {
|
||||
let alert = UIAlertController(title: "Unable to Register Client", message: error.localizedDescription, preferredStyle: .alert)
|
||||
let alert = UIAlertController(title: "Unable to register client", message: error.localizedDescription, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
|
||||
present(alert, animated: true)
|
||||
|
||||
textField.isEnabled = true
|
||||
activityIndicator.stopAnimating()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -81,13 +99,29 @@ class LoginViewController: UIViewController {
|
|||
let components = URLComponents(url: callbackURL!, resolvingAgainstBaseURL: false)
|
||||
guard let codeItem = components?.queryItems?.first(where: { $0.name == "code" }),
|
||||
let codeValue = codeItem.value else {
|
||||
fatalError()
|
||||
DispatchQueue.main.async {
|
||||
let alert = UIAlertController(title: "Unable to retrieve authorization code", message: error?.localizedDescription ?? "Unknown Error", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
|
||||
self.present(alert, animated: true)
|
||||
|
||||
self.textField.isEnabled = true
|
||||
self.activityIndicator.stopAnimating()
|
||||
}
|
||||
return
|
||||
}
|
||||
Task {
|
||||
try! await controller.getToken(authCode: codeValue)
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.didLogin(with: controller)
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await controller.getToken(authCode: codeValue)
|
||||
} catch {
|
||||
let alert = UIAlertController(title: "Unable to retrieve access token", message: error.localizedDescription, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
|
||||
self.present(alert, animated: true)
|
||||
|
||||
self.textField.isEnabled = true
|
||||
self.activityIndicator.stopAnimating()
|
||||
return
|
||||
}
|
||||
self.delegate?.didLogin(with: controller)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// PrefsView.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/16/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PrefsView: View {
|
||||
@ObservedObject private var preferences = Preferences.shared
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
GroupBox {
|
||||
VStack {
|
||||
appearance
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
|
||||
private var appearance: some View {
|
||||
Picker("Appearance", selection: $preferences.appearance) {
|
||||
Text("System").tag(Appearance.unspecified)
|
||||
Text("Dark").tag(Appearance.dark)
|
||||
Text("Light").tag(Appearance.light)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PrefsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PrefsView()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,239 @@
|
|||
//
|
||||
// ReadViewController.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/9/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
import HTMLEntities
|
||||
import SafariServices
|
||||
|
||||
class ReadViewController: UIViewController {
|
||||
|
||||
private static let publishedFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .medium
|
||||
f.timeStyle = .medium
|
||||
return f
|
||||
}()
|
||||
|
||||
let fervorController: FervorController
|
||||
let item: Item
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
private var itemReadObservation: NSKeyValueObservation?
|
||||
#endif
|
||||
|
||||
override var prefersStatusBarHidden: Bool {
|
||||
navigationController?.isNavigationBarHidden ?? false
|
||||
}
|
||||
|
||||
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
|
||||
.slide
|
||||
}
|
||||
|
||||
init(item: Item, fervorController: FervorController) {
|
||||
self.fervorController = fervorController
|
||||
self.item = item
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
|
||||
view.backgroundColor = .appBackground
|
||||
view.addInteraction(StretchyMenuInteraction(delegate: self))
|
||||
|
||||
let webView = WKWebView()
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
webView.navigationDelegate = self
|
||||
webView.uiDelegate = self
|
||||
// transparent background required to prevent white flash in dark mode, just using .appBackground doesn't work
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
if let content = itemContentHTML() {
|
||||
webView.loadHTMLString(content, baseURL: item.url)
|
||||
}
|
||||
view.addSubview(webView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
webView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
|
||||
if let url = item.url {
|
||||
activityItemsConfiguration = UIActivityItemsConfiguration(objects: [url as NSURL])
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
itemReadObservation = item.observe(\.read) { [unowned self] _, _ in
|
||||
self.updateToggleReadToolbarImage()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private static let css = try! String(contentsOf: Bundle.main.url(forResource: "read", withExtension: "css")!)
|
||||
private static let js = try! String(contentsOf: Bundle.main.url(forResource: "read", withExtension: "js")!)
|
||||
|
||||
private func itemContentHTML() -> String? {
|
||||
guard let content = item.content else {
|
||||
return nil
|
||||
}
|
||||
var info = ""
|
||||
if let title = item.title, !title.isEmpty {
|
||||
info += "<h1 id=\"item-title\">"
|
||||
if let url = item.url {
|
||||
info += "<a href=\"\(url.absoluteString)\">"
|
||||
}
|
||||
info += title.htmlEscape()
|
||||
if item.url != nil {
|
||||
info += "</a>"
|
||||
}
|
||||
info += "</h1>"
|
||||
}
|
||||
if let feedTitle = item.feed!.title, !feedTitle.isEmpty {
|
||||
info += "<h2 id=\"item-feed-title\">\(feedTitle.htmlEscape())</h2>"
|
||||
}
|
||||
if let author = item.author, !author.isEmpty {
|
||||
info += "<h3 id=\"item-author\">\(author)</h3>"
|
||||
}
|
||||
if let published = item.published {
|
||||
let formatted = ReadViewController.publishedFormatter.string(from: published)
|
||||
info += "<h3 id=\"item-published\">\(formatted)</h3>"
|
||||
}
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
|
||||
<style>\(ReadViewController.css)</style>
|
||||
<script async>\(ReadViewController.js)</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="item-info">
|
||||
\(info)
|
||||
</div>
|
||||
<div id="item-content">
|
||||
\(content)
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
}
|
||||
|
||||
private func createSafariVC(url: URL) -> SFSafariViewController {
|
||||
let vc = SFSafariViewController(url: url)
|
||||
vc.preferredControlTintColor = .appTintColor
|
||||
return vc
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
@objc func toggleItemRead(_ item: NSToolbarItem) {
|
||||
Task {
|
||||
await fervorController.markItem(self.item, read: !self.item.read)
|
||||
updateToggleReadToolbarImage()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateToggleReadToolbarImage() {
|
||||
guard let titlebar = view.window?.windowScene?.titlebar,
|
||||
let item = titlebar.toolbar?.items.first(where: { $0.itemIdentifier == .toggleItemRead }) else {
|
||||
return
|
||||
}
|
||||
|
||||
item.image = UIImage(systemName: self.item.read ? "checkmark.circle.fill" : "checkmark.circle")
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
extension ReadViewController: WKNavigationDelegate {
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
|
||||
if navigationAction.navigationType == .linkActivated {
|
||||
let url = navigationAction.request.url!
|
||||
if url.fragment != nil {
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
|
||||
components.fragment = nil
|
||||
if components.url == item.url {
|
||||
return .allow
|
||||
}
|
||||
}
|
||||
present(createSafariVC(url: url), animated: true)
|
||||
return .cancel
|
||||
} else {
|
||||
return .allow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ReadViewController: WKUIDelegate {
|
||||
func webView(_ webView: WKWebView, contextMenuConfigurationFor elementInfo: WKContextMenuElementInfo) async -> UIContextMenuConfiguration? {
|
||||
guard let url = elementInfo.linkURL,
|
||||
["http", "https"].contains(url.scheme?.lowercased()) else {
|
||||
return nil
|
||||
}
|
||||
return UIContextMenuConfiguration(identifier: nil) { [unowned self] in
|
||||
self.createSafariVC(url: url)
|
||||
} actionProvider: { _ in
|
||||
return UIMenu(children: [
|
||||
UIAction(title: "Open in Safari", image: UIImage(systemName: "safari"), handler: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.present(self.createSafariVC(url: url), animated: true)
|
||||
}),
|
||||
UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up"), handler: { [weak self] _ in
|
||||
self?.present(UIActivityViewController(activityItems: [url], applicationActivities: nil), animated: true)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) {
|
||||
if let vc = animator.previewViewController as? SFSafariViewController {
|
||||
animator.preferredCommitStyle = .pop
|
||||
animator.addCompletion {
|
||||
self.present(vc, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ReadViewController: StretchyMenuInteractionDelegate {
|
||||
func stretchyMenuTitle() -> String? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func stretchyMenuItems() -> [StretchyMenuItem] {
|
||||
guard let url = item.url else {
|
||||
return []
|
||||
}
|
||||
var items = [
|
||||
StretchyMenuItem(title: "Open in Safari", subtitle: nil, action: { [unowned self] in
|
||||
self.present(createSafariVC(url: url), animated: true)
|
||||
}),
|
||||
StretchyMenuItem(title: item.read ? "Mark as Unread" : "Mark as Read", subtitle: nil, action: { [unowned self] in
|
||||
Task {
|
||||
await self.fervorController.markItem(item, read: !item.read)
|
||||
}
|
||||
}),
|
||||
]
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
items.insert(StretchyMenuItem(title: "Share", subtitle: nil, action: { [unowned self] in
|
||||
self.present(UIActivityViewController(activityItems: [url], applicationActivities: nil), animated: true)
|
||||
}), at: 1)
|
||||
#endif
|
||||
return items
|
||||
}
|
||||
}
|
|
@ -0,0 +1,406 @@
|
|||
//
|
||||
// StretchyMenuInteraction.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/11/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
struct StretchyMenuItem {
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let action: () -> Void
|
||||
}
|
||||
|
||||
protocol StretchyMenuInteractionDelegate: AnyObject {
|
||||
func stretchyMenuTitle() -> String?
|
||||
func stretchyMenuItems() -> [StretchyMenuItem]
|
||||
}
|
||||
|
||||
class StretchyMenuInteraction: NSObject, UIInteraction {
|
||||
|
||||
weak var delegate: StretchyMenuInteractionDelegate?
|
||||
|
||||
private(set) weak var view: UIView? = nil
|
||||
|
||||
private let menuHintView = MenuHintView()
|
||||
fileprivate let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
|
||||
private var snapshot: UIView?
|
||||
private var menuView: MenuView?
|
||||
|
||||
private let menuOpenThreshold: CGFloat = 100
|
||||
|
||||
fileprivate var isShowingMenu = false
|
||||
|
||||
init(delegate: StretchyMenuInteractionDelegate) {
|
||||
self.delegate = delegate
|
||||
|
||||
menuHintView.translatesAutoresizingMaskIntoConstraints = false
|
||||
}
|
||||
|
||||
func willMove(to view: UIView?) {
|
||||
if self.view != nil {
|
||||
fatalError("removing StretchyMenuInteraction from view is unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
func didMove(to view: UIView?) {
|
||||
self.view = view
|
||||
|
||||
guard let view = view else {
|
||||
return
|
||||
}
|
||||
|
||||
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognized))
|
||||
panRecognizer.delegate = self
|
||||
panRecognizer.allowedScrollTypesMask = [.continuous]
|
||||
view.addGestureRecognizer(panRecognizer)
|
||||
|
||||
}
|
||||
|
||||
private var prevTranslation: CGFloat = 0
|
||||
|
||||
@objc private func panGestureRecognized(_ recognizer: UIPanGestureRecognizer) {
|
||||
guard let view = view,
|
||||
!isShowingMenu,
|
||||
let delegate = delegate else {
|
||||
return
|
||||
}
|
||||
|
||||
let prevTranslation = self.prevTranslation
|
||||
let translation = recognizer.translation(in: view)
|
||||
self.prevTranslation = translation.x
|
||||
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
snapshot = view.snapshotView(afterScreenUpdates: false)
|
||||
let effectiveTranslation = 0.5 * max(0, -translation.x)
|
||||
snapshot!.transform = CGAffineTransform(translationX: -effectiveTranslation, y: 0)
|
||||
view.addSubview(snapshot!)
|
||||
view.insertSubview(menuHintView, belowSubview: snapshot!)
|
||||
menuHintView.backgroundColor = view.backgroundColor
|
||||
menuHintView.frame = CGRect(x: view.bounds.width - effectiveTranslation, y: 0, width: effectiveTranslation, height: view.bounds.height)
|
||||
menuHintView.updateForProgress(0, animate: false)
|
||||
feedbackGenerator.prepare()
|
||||
|
||||
case .changed:
|
||||
let effectiveTranslation = 0.5 * max(0, -translation.x)
|
||||
snapshot!.transform = CGAffineTransform(translationX: -effectiveTranslation, y: 0)
|
||||
if -prevTranslation < menuOpenThreshold && -translation.x >= menuOpenThreshold {
|
||||
feedbackGenerator.impactOccurred()
|
||||
}
|
||||
menuHintView.frame = CGRect(x: view.bounds.width - effectiveTranslation, y: 0, width: effectiveTranslation, height: view.bounds.height)
|
||||
menuHintView.updateForProgress(-translation.x / menuOpenThreshold, animate: true)
|
||||
|
||||
case .ended:
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) {
|
||||
self.snapshot!.transform = .identity
|
||||
} completion: { _ in
|
||||
self.snapshot!.removeFromSuperview()
|
||||
self.menuHintView.removeFromSuperview()
|
||||
}
|
||||
|
||||
|
||||
if -translation.x > menuOpenThreshold {
|
||||
guard let rootView = view.window?.rootViewController?.view else {
|
||||
return
|
||||
}
|
||||
let menuView = MenuView(title: delegate.stretchyMenuTitle(), items: delegate.stretchyMenuItems(), owner: self)
|
||||
self.menuView = menuView
|
||||
menuView.translatesAutoresizingMaskIntoConstraints = false
|
||||
menuView.layer.zPosition = 102
|
||||
rootView.addSubview(menuView)
|
||||
NSLayoutConstraint.activate([
|
||||
menuView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor),
|
||||
menuView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor),
|
||||
menuView.topAnchor.constraint(equalTo: rootView.topAnchor),
|
||||
menuView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor),
|
||||
])
|
||||
rootView.layoutIfNeeded()
|
||||
menuView.animateIn()
|
||||
|
||||
isShowingMenu = true
|
||||
feedbackGenerator.prepare()
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func hideMenu(completion: (() -> Void)? = nil) {
|
||||
guard let menuView = menuView else {
|
||||
return
|
||||
}
|
||||
|
||||
menuView.animateOut() {
|
||||
menuView.removeFromSuperview()
|
||||
self.isShowingMenu = false
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StretchyMenuInteraction: UIGestureRecognizerDelegate {
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
return otherGestureRecognizer.name == AppNavigationController.panRecognizerName
|
||||
}
|
||||
}
|
||||
|
||||
private class MenuHintView: UIView {
|
||||
private let pill = UIView()
|
||||
private var progress: CGFloat = 0
|
||||
|
||||
private var animator: UIViewPropertyAnimator?
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
pill.frame = CGRect(x: 0, y: 0, width: 5, height: 50)
|
||||
pill.backgroundColor = .systemGray
|
||||
pill.layer.cornerRadius = 2.5
|
||||
addSubview(pill)
|
||||
|
||||
pill.backgroundColor = .systemGray
|
||||
pill.frame.size.height = 50
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
let width: CGFloat = 5
|
||||
pill.frame.origin.x = bounds.midX - width / 2
|
||||
pill.frame.origin.y = bounds.midY - pill.frame.height / 2
|
||||
}
|
||||
|
||||
func updateForProgress(_ progress: CGFloat, animate: Bool) {
|
||||
let oldCompleted = self.progress >= 1
|
||||
let completed = progress >= 1
|
||||
|
||||
self.progress = progress
|
||||
|
||||
if oldCompleted != completed {
|
||||
func updatePill() {
|
||||
pill.backgroundColor = completed ? .tintColor : .systemGray
|
||||
let height: CGFloat = completed ? 75 : 50
|
||||
pill.frame.origin.y = bounds.midY - height / 2
|
||||
pill.frame.size.height = height
|
||||
}
|
||||
|
||||
animator?.stopAnimation(true)
|
||||
if animate {
|
||||
animator = UIViewPropertyAnimator(duration: 0.5, dampingRatio: 0.5, animations: updatePill)
|
||||
animator!.startAnimation()
|
||||
} else {
|
||||
updatePill()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class MenuView: UIView {
|
||||
|
||||
weak var owner: StretchyMenuInteraction?
|
||||
|
||||
private let blurEffect = UIBlurEffect(style: .systemUltraThinMaterialDark)
|
||||
private var blurView: UIView!
|
||||
private let optionsStack = UIStackView()
|
||||
|
||||
private let items: [StretchyMenuItem]
|
||||
|
||||
init(title: String?, items: [StretchyMenuItem], owner: StretchyMenuInteraction) {
|
||||
self.items = items
|
||||
self.owner = owner
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
blurView = UIVisualEffectView(effect: blurEffect)
|
||||
blurView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(blurView)
|
||||
|
||||
optionsStack.axis = .vertical
|
||||
optionsStack.alignment = .fill
|
||||
optionsStack.spacing = 2
|
||||
optionsStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
for item in items {
|
||||
optionsStack.addArrangedSubview(MenuItemView(item: item, owner: owner))
|
||||
}
|
||||
addSubview(optionsStack)
|
||||
|
||||
if let title = title {
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleLabel.text = title
|
||||
titleLabel.textAlignment = .right
|
||||
titleLabel.font = .preferredFont(forTextStyle: .title1)
|
||||
let vibrancyView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: blurEffect, style: .label))
|
||||
vibrancyView.contentView.addSubview(titleLabel)
|
||||
optionsStack.insertArrangedSubview(vibrancyView, at: 0)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
titleLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: vibrancyView.contentView.leadingAnchor, multiplier: 1),
|
||||
vibrancyView.contentView.trailingAnchor.constraint(equalToSystemSpacingAfter: titleLabel.trailingAnchor, multiplier: 1),
|
||||
titleLabel.topAnchor.constraint(equalTo: vibrancyView.contentView.topAnchor),
|
||||
titleLabel.bottomAnchor.constraint(equalTo: vibrancyView.contentView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
blurView.topAnchor.constraint(equalTo: topAnchor),
|
||||
blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
|
||||
optionsStack.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
optionsStack.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
optionsStack.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
])
|
||||
|
||||
blurView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(blurTapped)))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func animateIn() {
|
||||
blurView.layer.opacity = 0
|
||||
let count = optionsStack.arrangedSubviews.count
|
||||
for (index, item) in optionsStack.arrangedSubviews.enumerated() {
|
||||
let multiplier = (1 + CGFloat(index) * 1 / CGFloat(count - 1))
|
||||
item.transform = CGAffineTransform(translationX: bounds.width * multiplier, y: 0)
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: 0.35, delay: 0, options: .curveEaseInOut) {
|
||||
self.blurView.layer.opacity = 1
|
||||
for item in self.optionsStack.arrangedSubviews {
|
||||
item.transform = .identity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func animateOut(completion: @escaping () -> Void) {
|
||||
UIView.animate(withDuration: 0.35, delay: 0, options: .curveEaseInOut) {
|
||||
self.blurView.layer.opacity = 0
|
||||
let count = self.optionsStack.arrangedSubviews.count
|
||||
for (index, item) in self.optionsStack.arrangedSubviews.enumerated() {
|
||||
let multiplier = (1 + CGFloat(index) * 1 / CGFloat(count - 1))
|
||||
item.transform = CGAffineTransform(translationX: self.bounds.width * -multiplier, y: 0)
|
||||
}
|
||||
} completion: { _ in
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func blurTapped() {
|
||||
owner?.hideMenu()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private class MenuItemView: UIView {
|
||||
weak var owner: StretchyMenuInteraction?
|
||||
|
||||
private let item: StretchyMenuItem
|
||||
|
||||
init(item: StretchyMenuItem, owner: StretchyMenuInteraction) {
|
||||
self.item = item
|
||||
self.owner = owner
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
backgroundColor = .appBackground
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
titleLabel.text = item.title
|
||||
titleLabel.textColor = .tintColor
|
||||
titleLabel.textAlignment = .right
|
||||
titleLabel.font = .preferredFont(forTextStyle: .title2)
|
||||
addSubview(titleLabel)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 4),
|
||||
titleLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: leadingAnchor, multiplier: 1),
|
||||
trailingAnchor.constraint(equalToSystemSpacingAfter: titleLabel.trailingAnchor, multiplier: 1),
|
||||
|
||||
heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
|
||||
])
|
||||
|
||||
if let subtitle = item.subtitle {
|
||||
let subtitleLabel = UILabel()
|
||||
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
subtitleLabel.text = subtitle
|
||||
subtitleLabel.textColor = .secondaryLabel
|
||||
subtitleLabel.textAlignment = .right
|
||||
addSubview(subtitleLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
subtitleLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: leadingAnchor, multiplier: 1),
|
||||
trailingAnchor.constraint(equalToSystemSpacingAfter: subtitleLabel.trailingAnchor, multiplier: 1),
|
||||
subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor),
|
||||
bottomAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 4),
|
||||
])
|
||||
} else {
|
||||
NSLayoutConstraint.activate([
|
||||
bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
|
||||
])
|
||||
}
|
||||
|
||||
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(itemTapped)))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@objc private func itemTapped() {
|
||||
owner?.feedbackGenerator.impactOccurred()
|
||||
|
||||
UIView.animateKeyframes(withDuration: 0.15, delay: 0, options: []) {
|
||||
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
|
||||
self.backgroundColor = .appSecondaryBackground
|
||||
}
|
||||
UIView.addKeyframe(withRelativeStartTime: 0.5, relativeDuration: 0.5) {
|
||||
self.backgroundColor = .appBackground
|
||||
}
|
||||
} completion: { _ in
|
||||
self.owner?.hideMenu() {
|
||||
self.item.action()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addBorders(top: Bool, bottom: Bool) {
|
||||
if top {
|
||||
let view = UIView()
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.backgroundColor = .systemGray
|
||||
addSubview(view)
|
||||
NSLayoutConstraint.activate([
|
||||
view.heightAnchor.constraint(equalToConstant: 1),
|
||||
view.topAnchor.constraint(equalTo: topAnchor),
|
||||
view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
if bottom {
|
||||
let view = UIView()
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.backgroundColor = .systemGray
|
||||
addSubview(view)
|
||||
NSLayoutConstraint.activate([
|
||||
view.heightAnchor.constraint(equalToConstant: 1),
|
||||
view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
//
|
||||
// UIColor+App.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/9/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIColor {
|
||||
|
||||
static let appBackground = UIColor { traitCollection in
|
||||
switch traitCollection.userInterfaceStyle {
|
||||
case .dark:
|
||||
return UIColor(red: 25/255, green: 25/255, blue: 25/255, alpha: 1)
|
||||
case .unspecified, .light:
|
||||
fallthrough
|
||||
@unknown default:
|
||||
return .white
|
||||
}
|
||||
}
|
||||
|
||||
static let appSecondaryBackground = UIColor { traitCollection in
|
||||
switch traitCollection.userInterfaceStyle {
|
||||
case .dark:
|
||||
return UIColor(white: 0.2, alpha: 1)
|
||||
case .unspecified, .light:
|
||||
fallthrough
|
||||
@unknown default:
|
||||
return UIColor(white: 0.8, alpha: 1)
|
||||
}
|
||||
}
|
||||
|
||||
static let appCellHighlightBackground = UIColor { traitCollection in
|
||||
switch traitCollection.userInterfaceStyle {
|
||||
case .dark:
|
||||
return UIColor(white: 0.15, alpha: 1)
|
||||
case .unspecified, .light:
|
||||
fallthrough
|
||||
@unknown default:
|
||||
return UIColor(white: 0.9, alpha: 1)
|
||||
}
|
||||
}
|
||||
|
||||
static let appContentPreviewLabel = UIColor { traitCollection in
|
||||
switch traitCollection.userInterfaceStyle {
|
||||
case .dark:
|
||||
return .lightGray
|
||||
case .unspecified, .light:
|
||||
fallthrough
|
||||
@unknown default:
|
||||
return .darkGray
|
||||
}
|
||||
}
|
||||
|
||||
static let appTintColor = UIColor.systemRed
|
||||
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// UserActivities.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 1/15/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension NSUserActivity {
|
||||
|
||||
static let preferencesType = "net.shadowfacts.Reader.activity.preferences"
|
||||
static let addAccountType = "net.shadowfacts.Reader.activity.add-account"
|
||||
static let activateAccountType = "net.shadowfacts.Reader.activity.activate-account"
|
||||
|
||||
static func preferences() -> NSUserActivity {
|
||||
return NSUserActivity(activityType: preferencesType)
|
||||
}
|
||||
|
||||
static func addAccount() -> NSUserActivity {
|
||||
return NSUserActivity(activityType: addAccountType)
|
||||
}
|
||||
|
||||
static func activateAccount(_ account: LocalData.Account) -> NSUserActivity {
|
||||
let activity = NSUserActivity(activityType: activateAccountType)
|
||||
activity.userInfo = [
|
||||
"accountID": account.id.uuidString
|
||||
]
|
||||
return activity
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
--tint-color: rgb(255, 59, 48); /* .systemRed */
|
||||
--dark-tint-color: rgb(255, 69, 58); /* .systemRed */
|
||||
--secondary-text-color: rgb(85, 85, 85); /* .darkGray */
|
||||
--dark-secondary-text-color: rgb(170, 170, 170); /* .lightGray */
|
||||
--background-color: white; /* .appBackground */
|
||||
--dark-background-color: rgb(25, 25, 25); /* .appBackground */
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 12px;
|
||||
font-family: ui-serif;
|
||||
overflow-wrap: break-word;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--tint-color);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
color: var(--secondary-text-color);
|
||||
font-style: italic;
|
||||
font-family: ui-sans-serif;
|
||||
font-size: 12pt;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
pre, code {
|
||||
/* ui-monospace doesn't work for some reason, even though the other ui- fonts do */
|
||||
font-family: ui-monospace, "SF Mono";
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid var(--tint-color);
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding: 0 20px;
|
||||
color: gray;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.item-content-table {
|
||||
overflow-x: scroll;
|
||||
}
|
||||
|
||||
#item-info {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
#item-title {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
#item-title a {
|
||||
text-decoration: none;
|
||||
color: text;
|
||||
}
|
||||
|
||||
#item-feed-title,
|
||||
#item-author,
|
||||
#item-published {
|
||||
margin: 0.25em 0;
|
||||
font-family: ui-sans-serif;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
#item-feed-title {
|
||||
color: var(--tint-color);
|
||||
}
|
||||
|
||||
#item-author,
|
||||
#item-published {
|
||||
font-style: italic;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
#item-content {
|
||||
font-size: 14pt;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--tint-color: var(--dark-tint-color);
|
||||
--secondary-text-color: var(--dark-secondary-text-color);
|
||||
--background-color: var(--dark-background-color);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
document.addEventListener("DOMContentLoaded", () => {
|
||||
for (const table of document.querySelectorAll("table")) {
|
||||
// wrap tables in divs to which we can apply overflow-x: scroll;
|
||||
table.outerHTML = `<div class="item-content-table">${table.outerHTML}</div>`;
|
||||
}
|
||||
});
|
|
@ -0,0 +1,40 @@
|
|||
//
|
||||
// ReaderMac.swift
|
||||
// ReaderMac
|
||||
//
|
||||
// Created by Shadowfacts on 1/16/22.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
|
||||
class ReaderMac: NSObject {
|
||||
|
||||
private(set) static var shared: ReaderMac!
|
||||
|
||||
override init() {
|
||||
if ReaderMac.shared != nil {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
super.init()
|
||||
|
||||
ReaderMac.shared = self
|
||||
}
|
||||
|
||||
@objc func setup() {
|
||||
}
|
||||
|
||||
@objc func updateAppearance(_ appearance: NSNumber) {
|
||||
switch appearance {
|
||||
case 0:
|
||||
NSApp.appearance = nil
|
||||
case 1:
|
||||
NSApp.appearance = NSAppearance(named: .aqua)
|
||||
case 2:
|
||||
NSApp.appearance = NSAppearance(named: .darkAqua)
|
||||
default:
|
||||
fatalError("unexpected appeaerance value: \(appearance)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1 @@
|
|||
Subproject commit f32bd14b229ed1088c25725cce242817ea2fe43a
|
Loading…
Reference in New Issue