Merge pull request #1753 from omnivore-app/feat/highlights-export

Export highlights/notes from iOS
This commit is contained in:
Jackson Harper 2023-04-05 18:33:53 +08:00 committed by GitHub
commit 89119add8a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
178 changed files with 6173 additions and 2159 deletions

File diff suppressed because one or more lines are too long

View file

@ -64,8 +64,8 @@ final class AppStoreScreenshots: XCTestCase {
//
snapshot("ReaderTTSPlayer")
/// app.children(matching: .window).element(boundBy: 0).tap()
// XCUIApplication().buttons["Back"].tap()
// app.children(matching: .window).element(boundBy: 0).tap()
// XCUIApplication().buttons["Back"].tap()
}
func testScreenshotReaderActions() throws {

View file

@ -1,3 +1,4 @@
// swiftlint:disable line_length
//
// SnapshotHelper.swift
// Example
@ -32,7 +33,8 @@ func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
/// - Parameters:
/// - name: The name of the snapshot
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
/// - timeout: Amount of seconds to wait until the network loading indicator disappears.
/// Pass `0` if you don't want to wait.
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
}

View file

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 52;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@ -15,7 +15,7 @@
045B1681279147E7005047F7 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 045B1680279147E7005047F7 /* FirebaseMessaging */; };
0465B9BE26CDD35F005558CD /* MainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81BE98F0CB588F5FC577A13 /* MainApp.swift */; };
046C5CD526A3F89A00AC5349 /* icon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 046C5CD426A3F89A00AC5349 /* icon.icns */; };
046C5CDF26A3F89A00AC5349 /* ShareExtension-Mac.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 046C5CD226A3F89A00AC5349 /* ShareExtension-Mac.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
046C5CDF26A3F89A00AC5349 /* ShareExtension-Mac.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 046C5CD226A3F89A00AC5349 /* ShareExtension-Mac.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
046C5CE626A3FE8C00AC5349 /* App in Frameworks */ = {isa = PBXBuildFile; productRef = 046C5CE526A3FE8C00AC5349 /* App */; };
047AD6F22724C934004FD5CF /* App in Frameworks */ = {isa = PBXBuildFile; productRef = 047AD6F12724C934004FD5CF /* App */; };
047AD6F42724C941004FD5CF /* App in Frameworks */ = {isa = PBXBuildFile; productRef = 047AD6F32724C941004FD5CF /* App */; };
@ -30,10 +30,10 @@
04E9707226BDE5F1002A523D /* ShareExtension.js in Resources */ = {isa = PBXBuildFile; fileRef = 04E9706E26BDE47D002A523D /* ShareExtension.js */; };
04EE742126DFD2EF001B3483 /* ScreenIdentifierHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04EE741F26DFD2CA001B3483 /* ScreenIdentifierHelpers.swift */; };
04EE742226DFD2F0001B3483 /* ScreenIdentifierHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04EE741F26DFD2CA001B3483 /* ScreenIdentifierHelpers.swift */; };
1BE07906EAA5555B6140B6DB /* ShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 95D337DDCEA2007A9705A969 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
1BE07906EAA5555B6140B6DB /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 95D337DDCEA2007A9705A969 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
260BBE4168E3C57D6D2E225D /* App in Frameworks */ = {isa = PBXBuildFile; productRef = 7DD0EF4D497D71D248C7B0DF /* App */; };
4214F6EF2714D2260096B644 /* SafariExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 42FF1B16271154A700B38C38 /* SafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
4214F6F22714D28E0096B644 /* SafariExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 42FF1B20271154A700B38C38 /* SafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
4214F6EF2714D2260096B644 /* SafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 42FF1B16271154A700B38C38 /* SafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
4214F6F22714D28E0096B644 /* SafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 42FF1B20271154A700B38C38 /* SafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
4225D59227164F82002A680F /* fonts in Resources */ = {isa = PBXBuildFile; fileRef = 4225D59127164F82002A680F /* fonts */; };
4225D59327164F82002A680F /* fonts in Resources */ = {isa = PBXBuildFile; fileRef = 4225D59127164F82002A680F /* fonts */; };
42321E852714E6B00056429F /* scripts in Resources */ = {isa = PBXBuildFile; fileRef = 42321E822714E6B00056429F /* scripts */; };
@ -127,16 +127,16 @@
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
046C5CE026A3F89A00AC5349 /* Embed App Extensions */ = {
046C5CE026A3F89A00AC5349 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
046C5CDF26A3F89A00AC5349 /* ShareExtension-Mac.appex in Embed App Extensions */,
4214F6F22714D28E0096B644 /* SafariExtension.appex in Embed App Extensions */,
046C5CDF26A3F89A00AC5349 /* ShareExtension-Mac.appex in Embed Foundation Extensions */,
4214F6F22714D28E0096B644 /* SafariExtension.appex in Embed Foundation Extensions */,
);
name = "Embed App Extensions";
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
089E832985F9DC4E3D727F87 /* Embed Frameworks */ = {
@ -169,15 +169,15 @@
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
BA17F8C8424E5121F1EE345C /* Embed App Extensions */ = {
BA17F8C8424E5121F1EE345C /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstSubfolderSpec = 13;
files = (
1BE07906EAA5555B6140B6DB /* ShareExtension.appex in Embed App Extensions */,
4214F6EF2714D2260096B644 /* SafariExtension.appex in Embed App Extensions */,
1BE07906EAA5555B6140B6DB /* ShareExtension.appex in Embed Foundation Extensions */,
4214F6EF2714D2260096B644 /* SafariExtension.appex in Embed Foundation Extensions */,
);
name = "Embed App Extensions";
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
@ -640,8 +640,8 @@
048ECFF826A0B1CB00469E57 /* Sources */,
048ECFF926A0B1CB00469E57 /* Frameworks */,
048ECFFA26A0B1CB00469E57 /* Resources */,
04B8B00426A0C19900D3ED0C /* ShellScript */,
046C5CE026A3F89A00AC5349 /* Embed App Extensions */,
04B8B00426A0C19900D3ED0C /* Format and Lint */,
046C5CE026A3F89A00AC5349 /* Embed Foundation Extensions */,
);
buildRules = (
);
@ -663,7 +663,6 @@
buildPhases = (
43512AEB8FEA59E4C1B62535 /* Sources */,
B147F11D6548C5B600AC0CBD /* Resources */,
AAC826E9061AEBA0BF527814 /* Embed Precompiled Frameworks */,
76DAC57C99AFCDF06896A42F /* Embed Frameworks */,
3C5F49DFFF5F79D9D0C7412E /* Frameworks */,
);
@ -743,8 +742,7 @@
buildPhases = (
8E4C1300A69A749FE3B720A3 /* Sources */,
ABA2CCAE947C7AFC88C991A5 /* Resources */,
BA17F8C8424E5121F1EE345C /* Embed App Extensions */,
6FD84AF08CA126EA8F6906A4 /* Embed Precompiled Frameworks */,
BA17F8C8424E5121F1EE345C /* Embed Foundation Extensions */,
4C02552307C731B3D3F28680 /* Embed Frameworks */,
F06D5ADCFDD68AC8775386BB /* Frameworks */,
909EF91C2946F26E9123C65F /* Format and Lint */,
@ -772,7 +770,6 @@
buildPhases = (
041E0C13E0057CDDC868A4DB /* Sources */,
BC2DC89A3D7E1E86540ECA14 /* Resources */,
DEA43E9E9C46BFC9EBE0D359 /* Embed Precompiled Frameworks */,
089E832985F9DC4E3D727F87 /* Embed Frameworks */,
7D03721CA2BD53145181C839 /* Frameworks */,
);
@ -793,7 +790,7 @@
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1400;
LastUpgradeCheck = 1300;
LastUpgradeCheck = 1420;
TargetAttributes = {
0411792626A22860004AE24F = {
CreatedOnToolsVersion = 12.5;
@ -962,39 +959,9 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
04B8B00426A0C19900D3ED0C /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"$SRCROOT\"/Scripts/format_and_lint.sh \n";
};
6FD84AF08CA126EA8F6906A4 /* Embed Precompiled Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Embed Precompiled Frameworks";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "echo \"Skipping, nothing to be embedded.\"";
};
909EF91C2946F26E9123C65F /* Format and Lint */ = {
04B8B00426A0C19900D3ED0C /* Format and Lint */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
@ -1011,33 +978,24 @@
shellPath = /bin/sh;
shellScript = "\"$SRCROOT\"/Scripts/format_and_lint.sh \n";
};
AAC826E9061AEBA0BF527814 /* Embed Precompiled Frameworks */ = {
909EF91C2946F26E9123C65F /* Format and Lint */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Embed Precompiled Frameworks";
name = "Format and Lint";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "echo \"Skipping, nothing to be embedded.\"";
};
DEA43E9E9C46BFC9EBE0D359 /* Embed Precompiled Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Embed Precompiled Frameworks";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "echo \"Skipping, nothing to be embedded.\"";
shellScript = "\"$SRCROOT\"/Scripts/format_and_lint.sh \n";
};
/* End PBXShellScriptBuildPhase section */
@ -1208,6 +1166,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = QJF2XZ86HB;
INFOPLIST_FILE = InfoPlists/MacUnitTests.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -1234,6 +1193,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = QJF2XZ86HB;
INFOPLIST_FILE = InfoPlists/MacUnitTests.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -1311,6 +1271,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 80;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist;
@ -1343,6 +1304,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 80;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = InfoPlists/ShareExtensionMac.plist;
@ -1370,6 +1332,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = QJF2XZ86HB;
INFOPLIST_FILE = InfoPlists/MacUITests.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -1395,6 +1358,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = QJF2XZ86HB;
INFOPLIST_FILE = InfoPlists/MacUITests.plist;
LD_RUNPATH_SEARCH_PATHS = (
@ -1425,6 +1389,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 80;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
@ -1459,6 +1424,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 80;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
@ -1669,6 +1635,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 80;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -1708,6 +1675,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 80;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = QJF2XZ86HB;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -1773,6 +1741,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@ -1940,6 +1909,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1400"
LastUpgradeVersion = "1420"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
LastUpgradeVersion = "1420"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
LastUpgradeVersion = "1420"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

View file

@ -189,6 +189,15 @@
"version" : "2.3.1"
}
},
{
"identity" : "swift-markdown-ui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
"state" : {
"revision" : "4392c3cefd08db10f13ffb019d7c7a4a622824f5",
"version" : "2.0.2"
}
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",

View file

@ -24,7 +24,8 @@ let package = Package(
name: "Views",
dependencies: [
"Models",
.product(name: "Introspect", package: "SwiftUI-Introspect")
.product(name: "Introspect", package: "SwiftUI-Introspect"),
.product(name: "MarkdownUI", package: "swift-markdown-ui")
],
resources: [.process("Resources")]
),
@ -66,7 +67,8 @@ var dependencies: [Package.Dependency] {
.package(url: "https://github.com/maticzav/swift-graphql", from: "2.3.1"),
.package(url: "https://github.com/siteline/SwiftUI-Introspect.git", from: "0.1.4"),
.package(url: "https://github.com/segmentio/analytics-swift.git", .upToNextMajor(from: "1.0.0")),
.package(url: "https://github.com/google/GoogleSignIn-iOS", from: "6.2.2")
.package(url: "https://github.com/google/GoogleSignIn-iOS", from: "6.2.2"),
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.0.0")
]
// Comment out following line for macOS build
deps.append(.package(url: "https://github.com/PSPDFKit/PSPDFKit-SP", from: "12.0.1"))

View file

@ -0,0 +1,32 @@
#if os(iOS)
import AppIntents
import Services
import SwiftUI
@available(iOS 16.0, *)
struct SaveLinkToOmnivoreIntent: AppIntent {
static var title: LocalizedStringResource = "Show Sport Progress"
static var parameterSummary: some ParameterSummary {
Summary("Save \(\.$link) to your Omnivore library.")
}
@Parameter(title: "link")
var link: URL
@MainActor
func perform() async throws -> some IntentResult {
do {
let services = Services()
let requestId = UUID().uuidString.lowercased()
_ = try await services.dataService.saveURL(id: requestId, url: link.absoluteString)
return .result(dialog: "Link saved to Omnivore")
} catch {
print("error saving URL: ", error)
}
return .result(dialog: "Error saving link")
}
}
#endif

View file

@ -164,9 +164,10 @@ public class ShareExtensionViewModel: ObservableObject {
updateStatusOnMain(requestId: newRequestID, newStatus: .synced)
// Prefetch the newly saved content
if let itemID = newRequestID,
let currentViewer = services.dataService.currentViewer?.username,
(try? await services.dataService.loadArticleContentWithRetries(itemID: itemID, username: currentViewer)) != nil
if
let itemID = newRequestID,
let currentViewer = services.dataService.currentViewer?.username,
(try? await services.dataService.loadArticleContentWithRetries(itemID: itemID, username: currentViewer)) != nil
{
updateStatusOnMain(requestId: requestId, newStatus: .synced, objectID: linkedItemObjectID)
}

View file

@ -4,7 +4,7 @@ import SwiftUI
import Utils
import Views
// swiftlint:disable:next type_body_length
// swiftlint:disable file_length type_body_length
public struct ShareExtensionView: View {
let extensionContext: NSExtensionContext?
@StateObject var labelsViewModel = LabelsViewModel()
@ -177,6 +177,7 @@ public struct ShareExtensionView: View {
VStack(spacing: 15) {
SearchBar(searchTerm: $labelsViewModel.labelSearchFilter)
// swiftlint:disable line_length
ScrollView {
LabelsMasonaryView(labels: labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter),
selectedLabels: labelsViewModel.selectedLabels.applySearchFilter(labelsViewModel.labelSearchFilter),
@ -199,6 +200,7 @@ public struct ShareExtensionView: View {
.buttonStyle(PlainButtonStyle())
.padding(10)
}.background(Color.appButtonBackground)
// swiftlint:enable line_length
}
}
}

View file

@ -9,6 +9,7 @@ import Views
)
@AppStorage(UserDefaultKey.preferredWebLineSpacing.rawValue) var storedLineSpacing = 150
@AppStorage(UserDefaultKey.preferredWebMaxWidthPercentage.rawValue) var storedMaxWidthPercentage = 100
@AppStorage(UserDefaultKey.enableHighlightOnRelease.rawValue) var enableHighlightOnRelease = false
@Binding var preferredFont: String
@Binding var prefersHighContrastText: Bool
@ -114,6 +115,11 @@ import Views
isOn: $prefersHighContrastText,
label: { Text(LocalText.genericHighContrastText) }
)
Toggle(
isOn: $enableHighlightOnRelease,
label: { Text(LocalText.genericHighContrastText) }
)
}
}
}

View file

@ -2,7 +2,7 @@ import Combine
import SwiftUI
import Utils
// swiftlint:disable file_length
// swiftlint:disable file_length type_body_length
#if os(iOS)
import PSPDFKit
import PSPDFKitUI

View file

@ -17,6 +17,7 @@ final class PDFViewerViewModel: ObservableObject {
onComplete(pdfItem.highlights.map { $0.patch ?? "" })
}
// swiftlint:disable:next function_parameter_count
func createHighlight(
dataService: DataService,
shortId: String,

View file

@ -1,11 +1,11 @@
#if os(iOS)
import Foundation
import Models
import Services
import SwiftUI
import Views
// swiftlint:disable file_length type_body_length
public struct ExpandedPlayer: View {
@EnvironmentObject var audioController: AudioController
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@ -47,7 +47,9 @@
},
label: {
Image(systemName: playPauseButtonImage)
.font(.appIconLarge)
.resizable(resizingMode: Image.ResizingMode.stretch)
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.light))
}
))
}
@ -113,42 +115,55 @@
}
}
func defaultArtwork(forDimensions dim: Double) -> some View {
ZStack(alignment: .center) {
Color.appButtonBackground
.frame(width: dim, height: dim)
.cornerRadius(6)
Image(systemName: "headphones")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: dim / 2, height: dim / 2)
}
}
struct SpeechCard: View {
let id: Int
@EnvironmentObject var audioController: AudioController
var intervalFormatter: DateComponentsFormatter {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.minute, .second]
formatter.zeroFormattingBehavior = .pad
return formatter
}
var body: some View {
Group {
let isCurrent = id == self.audioController.currentAudioIndex
ZStack(alignment: .top) {
Text(intervalFormatter.string(from: self.audioController.offsets?[id] ?? 0.0) ?? "")
.font(Font.system(size: 11, weight: isCurrent ? .medium : .regular))
.foregroundColor(isCurrent ? Color.themeTTSReadingText : Color(hex: "#898989"))
.padding(.leading, 8)
.padding(.top, 2)
.frame(maxWidth: .infinity, alignment: .leading)
if id != self.audioController.currentAudioIndex || self.audioController.isLoading {
Text(self.audioController.textItems?[id] ?? "\(id)")
.font(.textToSpeechRead.leading(.loose))
.foregroundColor(Color.appGrayTextContrast)
.font(.appCallout)
.foregroundColor(Color(hex: "#898989"))
.padding(.leading, 48 + 8)
.padding(.trailing, 16)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Group {
Text(audioController.readText)
.font(.textToSpeechRead.leading(.loose))
.foregroundColor(Color.appGrayTextContrast)
.font(.appTextToSpeechCurrent)
.foregroundColor(Color.themeTTSReadingText)
+
Text(audioController.unreadText)
.font(.textToSpeechRead.leading(.loose))
.foregroundColor(audioController.useUltraRealisticVoices ? Color.appGrayTextContrast : Color.appGrayText)
.font(.appTextToSpeechCurrent)
.foregroundColor(Color.themeTTSReadingText)
}
.padding(.leading, 48 + 8)
.padding(.trailing, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(16)
.padding(.horizontal, 8)
.padding(.vertical, 15)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isCurrent ? Color.themeHighlightColor : Color.themeDisabledBG)
}
init(id: Int) {
@ -160,27 +175,51 @@
ZStack {
let textItems = self.audioController.textItems ?? []
if textItems.count > 0 {
TabView(selection: $tabIndex) {
ForEach(0 ..< textItems.count, id: \.self) { id in
SpeechCard(id: id)
.tag(id)
ScrollViewReader { scroller in
List {
ForEach(0 ..< textItems.count, id: \.self) { id in
SpeechCard(id: id)
.tag(id)
.onTapGesture {
audioController.seek(toUtterance: id)
}
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
// Extra bottom padding, so the controls appear to be over the list
Color.themeDisabledBG
.frame(maxWidth: .infinity)
.frame(height: 150)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.onChange(of: tabIndex, perform: { index in
if index != audioController.currentAudioIndex, index < (audioController.textItems?.count ?? 0) {
audioController.seek(toUtterance: index)
}
})
.onChange(of: audioController.currentAudioIndex, perform: { index in
if index >= textItems.count {
return
.listStyle(.plain)
.onAppear {
if audioController.currentAudioIndex < textItems.count {
withAnimation {
scroller.scrollTo(audioController.currentAudioIndex, anchor: .center)
}
}
}
.onChange(of: audioController.currentAudioIndex, perform: { index in
if index >= textItems.count {
return
}
if self.audioController.state != .reachedEnd {
tabIndex = index
}
})
if self.audioController.state != .reachedEnd {
withAnimation {
scroller.scrollTo(index, anchor: .center)
}
}
})
// .simultaneousGesture(
// DragGesture().onChanged {
// let isScrollDown = $0.translation.height > 0
// print(isScrollDown)
// }
// )
}
.background(Color.themeDisabledBG)
}
if audioController.state == .reachedEnd {
@ -208,29 +247,12 @@
Text(LocalText.audioPlayerReplay)
}
}
).buttonStyle(RoundedRectButtonStyle())
)
.padding(.bottom, 138)
.buttonStyle(RoundedRectButtonStyle())
}
}
}
var header: some View {
ZStack {
closeButton
.padding(.top, 24)
.padding(.leading, 16)
.frame(maxWidth: .infinity, alignment: .leading)
Capsule()
.fill(.gray)
.frame(width: 60, height: 4)
.padding(.top, 8)
.transition(.opacity)
menuButton
.padding(.top, 24)
.padding(.trailing, 16)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
var scrubber: some View {
@ -244,65 +266,102 @@
self.audioController.scrubState = .scrubEnded(self.audioController.timeElapsed)
}
})
.padding(.top, 26)
HStack {
Text(audioController.timeElapsedString ?? "0:00")
.font(.appCaptionTwo)
.foregroundColor(.appGrayText)
.font(Font.system(size: 10))
.foregroundColor(Color(hex: "#9D9D9B"))
Spacer()
Text(audioController.durationString ?? "0:00")
.font(.appCaptionTwo)
.foregroundColor(.appGrayText)
.font(Font.system(size: 10))
.foregroundColor(Color(hex: "#9D9D9B"))
}
}
.padding(.leading, 16)
.padding(.trailing, 16)
.padding(.leading, 42)
.padding(.trailing, 42)
}
var audioButtons: some View {
HStack(alignment: .center) {
Spacer()
Button(action: { showVoiceSheet = true }, label: {
Image(systemName: "person.wave.2")
.resizable()
.frame(width: 18, height: 18)
})
.padding(.trailing, 32)
Button(
action: { self.audioController.skipBackwards(seconds: 30) },
label: {
Image(systemName: "gobackward.30")
.font(.appTitleTwo)
.resizable()
.font(Font.title.weight(.light))
}
)
Spacer()
.frame(width: 16, height: 16)
.padding(.trailing, 16)
.foregroundColor(.themeDarkGray)
playPauseButtonItem
.frame(width: 56, height: 56)
Spacer()
.frame(width: 45, height: 45)
.padding(.trailing, 16)
.foregroundColor(.themeDarkGray)
Button(
action: { self.audioController.skipForward(seconds: 30) },
label: {
Image(systemName: "goforward.30")
.font(.appTitleTwo)
.resizable()
.font(Font.title.weight(.light))
}
)
Spacer()
.frame(width: 16, height: 16)
.padding(.trailing, 32 - 4) // -4 to account for the menu touch padding
.foregroundColor(.themeDarkGray)
}.padding(.bottom, 16)
Menu(content: {
playbackRateButton(rate: 0.8, title: "0.8×", selected: audioController.playbackRate == 0.8)
playbackRateButton(rate: 0.9, title: "0.9×", selected: audioController.playbackRate == 0.9)
playbackRateButton(rate: 1.0, title: "1.0×", selected: audioController.playbackRate == 1.0)
playbackRateButton(rate: 1.1, title: "1.1×", selected: audioController.playbackRate == 1.1)
playbackRateButton(rate: 1.2, title: "1.2×", selected: audioController.playbackRate == 1.2)
playbackRateButton(rate: 1.5, title: "1.5×", selected: audioController.playbackRate == 1.5)
playbackRateButton(rate: 1.7, title: "1.7×", selected: audioController.playbackRate == 1.7)
playbackRateButton(rate: 2.0, title: "2.0×", selected: audioController.playbackRate == 2.0)
}, label: {
Text("\(String(format: "%.1f", audioController.playbackRate))×")
.font(.appCaption)
})
.padding(4)
Spacer()
}
.foregroundColor(Color.themeMediumGray)
.padding(.bottom, 16)
}
func playerContent(_: LinkedItemAudioProperties) -> some View {
VStack(spacing: 0) {
header
ZStack {
audioCards
.frame(maxHeight: .infinity)
Spacer()
VStack {
Spacer()
scrubber
audioButtons
VStack {
scrubber
audioButtons
}
.padding(.bottom, 8)
.cornerRadius(8)
.padding(.bottom, -8)
.frame(maxWidth: .infinity, maxHeight: 138)
.background(Color.themeSolidBackground.ignoresSafeArea())
}
}
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.onAppear {
self.tabIndex = audioController.currentAudioIndex
}
@ -317,10 +376,8 @@
TextToSpeechVoiceSelectionView(forLanguage: audioController.currentVoiceLanguage, showLanguageChanger: true)
.navigationBarTitle("Voice")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }, label: {
Image(systemName: "chevron.backward")
.font(.appNavbarIcon)
.tint(.appGrayTextContrast)
.navigationBarItems(trailing: Button(action: { self.showVoiceSheet = false }, label: {
Text("Done").bold()
}))
}
}
@ -342,16 +399,33 @@
.buttonStyle(PlainButtonStyle())
}
@State var queryString: String = ""
public var body: some View {
NavigationView {
innerBody
.background(Color.themeDisabledBG)
.navigationTitle(LocalText.textToSpeechGeneric)
.navigationBarItems(trailing: Button(action: { dismiss() }, label: { Text("Hide") }))
.navigationBarTitleDisplayMode(NavigationBarItem.TitleDisplayMode.inline)
// .searchable(text: $queryString, placement: .navigationBarDrawer(displayMode: .always)) {
// // print("searching: ", queryString)
// Text("content")
// }
// }
}
}
public var innerBody: some View {
if let itemAudioProperties = self.audioController.itemAudioProperties {
playerContent(itemAudioProperties)
return AnyView(playerContent(itemAudioProperties)
.tint(.appGrayTextContrast)
.alert("There was an error playing back your audio.",
isPresented: $audioController.playbackError) {
Button(LocalText.dismissButton, role: .none) {}
}
})
} else {
EmptyView()
return AnyView(EmptyView())
}
}

View file

@ -53,7 +53,9 @@
},
label: {
Image(systemName: playPauseButtonImage)
.font(.appTitleTwo)
.resizable(resizingMode: Image.ResizingMode.stretch)
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.light))
}
))
}
@ -65,10 +67,21 @@
audioController.stop()
},
label: {
Image(systemName: "xmark")
.font(.appTitleTwo)
ZStack {
Circle()
.foregroundColor(Color(hex: "#3D3D3D"))
Image(systemName: "xmark")
.resizable(resizingMode: Image.ResizingMode.stretch)
.foregroundColor(Color(hex: "#D9D9D9"))
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.medium))
.frame(width: 14, height: 14)
}
}
)
.background(Color.clear)
.buttonStyle(PlainButtonStyle())
}
func artwork(_ itemAudioProperties: LinkedItemAudioProperties, forDimensions dim: Double) -> some View {
@ -107,31 +120,25 @@
func playerContent(_ itemAudioProperties: LinkedItemAudioProperties) -> some View {
VStack(spacing: 0) {
HStack(alignment: .center, spacing: 8) {
artwork(itemAudioProperties, forDimensions: 64)
HStack(alignment: .center, spacing: 15) {
artwork(itemAudioProperties, forDimensions: 50)
VStack {
Text(itemAudioProperties.title)
.font(.appCallout)
.foregroundColor(.appGrayTextContrast)
.fixedSize(horizontal: false, vertical: false)
.frame(maxWidth: .infinity, alignment: .leading)
Text(itemAudioProperties.title)
.font(Font.system(size: 17, weight: .medium))
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
.foregroundColor(.appGrayTextContrast)
.frame(maxHeight: 40, alignment: .leading)
if let byline = itemAudioProperties.byline {
Text(byline)
.font(.appCaption)
.lineSpacing(1.25)
.foregroundColor(.appGrayText)
.fixedSize(horizontal: false, vertical: false)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Spacer(minLength: 0)
playPauseButtonItem
.frame(width: 28, height: 28)
.frame(width: 40, height: 40)
.foregroundColor(.themeDarkGray)
stopButton
.frame(width: 28, height: 28)
.frame(width: 40, height: 40)
.foregroundColor(.themeDarkGray)
}
.padding(16)
.frame(maxHeight: .infinity)
@ -144,11 +151,10 @@
)
.onTapGesture {
withAnimation(.easeIn(duration: 0.08)) { expanded = true }
}.sheet(isPresented: $expanded) {
}.fullScreenCover(isPresented: $expanded) {
ExpandedPlayer()
}
.offset(y: expanded ? 88 : 0)
.opacity(expanded ? 0.0 : 1.0)
.offset(y: expanded ? 110 : 0)
}
public var body: some View {
@ -157,14 +163,14 @@
if let itemAudioProperties = self.audioController.itemAudioProperties {
ZStack(alignment: .bottom) {
Color.systemBackground.edgesIgnoringSafeArea(.bottom)
.frame(height: expanded ? 0 : 88, alignment: .bottom)
.frame(height: expanded ? 0 : 110, alignment: .bottom)
VStack {
Spacer(minLength: 0)
playerContent(itemAudioProperties)
.frame(maxHeight: expanded ? 0 : 88)
.tint(.appGrayTextContrast)
.background(Color.systemBackground)
.frame(maxHeight: expanded ? 0 : 110)
// .tint(.appGrayTextContrast)
// .background(Color.systemBackground)
}
}
}

View file

@ -3,6 +3,15 @@
import Foundation
import SwiftUI
extension UIColor {
func image(_ size: CGSize = CGSize(width: 1, height: 1)) -> UIImage {
UIGraphicsImageRenderer(size: size).image { rendererContext in
self.setFill()
rendererContext.fill(CGRect(origin: .zero, size: size))
}
}
}
struct ScrubberView: UIViewRepresentable {
typealias UIViewType = UISlider
@ -22,20 +31,25 @@
slider.maximumValue = Float(maxValue)
let tintColor = UIColor(Color.appCtaYellow)
let image = UIImage(systemName: "circle.fill",
withConfiguration: UIImage.SymbolConfiguration(scale: .small))?
let thumbImage = UIImage(systemName: "circle.fill",
withConfiguration: UIImage.SymbolConfiguration(scale: .medium))?
.withTintColor(tintColor)
.withRenderingMode(.alwaysOriginal)
slider.setThumbImage(image, for: .selected)
slider.setThumbImage(image, for: .normal)
slider.setThumbImage(thumbImage, for: .selected)
slider.setThumbImage(thumbImage, for: .normal)
slider.minimumTrackTintColor = tintColor
slider.addTarget(context.coordinator,
action: #selector(Coordinator.valueChanged(_:)),
for: .valueChanged)
// let minImage = UIColor(Color.themeMediumGray).image(CGSize(width: 1, height: 5))
// let maxImage = UIColor(Color.themeLightGray).image(CGSize(width: 1, height: 5))
//
// slider.setMinimumTrackImage(minImage, for: .normal)
// slider.setMaximumTrackImage(maxImage, for: .normal)
return slider
}

View file

@ -1,170 +1,202 @@
import Models
import SwiftUI
import Views
#if os(iOS)
import MarkdownUI
import Models
import SwiftUI
import Views
struct HighlightsListCard: View {
@State var isContextMenuOpen = false
@State var annotation = String()
@State var showAnnotationModal = false
struct HighlightsListCard: View {
@State var isContextMenuOpen = false
@State var annotation = String()
@State var showAnnotationModal = false
let highlightParams: HighlightListItemParams
@Binding var hasHighlightMutations: Bool
let onSaveAnnotation: (String) -> Void
let onDeleteHighlight: () -> Void
let onSetLabels: (String) -> Void
let viewModel: HighlightsListViewModel
let highlightParams: HighlightListItemParams
@Binding var hasHighlightMutations: Bool
let onSaveAnnotation: (String) -> Void
let onDeleteHighlight: () -> Void
let onSetLabels: (String) -> Void
@State var errorAlertMessage: String?
@State var showErrorAlertMessage = false
@State var errorAlertMessage: String?
@State var showErrorAlertMessage = false
@State var showShareView = false
var contextMenuView: some View {
Group {
Button(
action: {
#if os(iOS)
UIPasteboard.general.string = highlightParams.quote
#endif
var contextMenuView: some View {
Group {
Button(
action: {
#if os(iOS)
UIPasteboard.general.string = highlightParams.quote
#endif
#if os(macOS)
let pasteBoard = NSPasteboard.general
pasteBoard.clearContents()
pasteBoard.writeObjects([highlightParams.quote as NSString])
#endif
#if os(macOS)
let pasteBoard = NSPasteboard.general
pasteBoard.clearContents()
pasteBoard.writeObjects([highlightParams.quote as NSString])
#endif
Snackbar.show(message: "Highlight copied")
},
label: { Label("Copy", systemImage: "doc.on.doc") }
)
Button(
action: {
onSetLabels(highlightParams.highlightID)
},
label: { Label(LocalText.labelsGeneric, systemImage: "tag") }
)
Button(
action: onDeleteHighlight,
label: { Label("Delete", systemImage: "trash") }
)
}
}
var noteSection: some View {
HStack {
let isEmpty = highlightParams.annotation.isEmpty
Spacer(minLength: 6)
Text(isEmpty ? "Add Notes..." : highlightParams.annotation)
.lineSpacing(6)
.accentColor(.appGraySolid)
.foregroundColor(isEmpty ? .appGrayText : .appGrayTextContrast)
.font(.appSubheadline)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.appButtonBackground)
.cornerRadius(8)
}
.onTapGesture {
annotation = highlightParams.annotation
showAnnotationModal = true
}
}
var labelsView: some View {
if highlightParams.labels.count > 0 {
return AnyView(ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(highlightParams.labels, id: \.self) {
TextChip(feedItemLabel: $0)
.padding(.leading, 0)
}
Spacer()
}
}.introspectScrollView { scrollView in
scrollView.bounces = false
}
.padding(.top, 0)
.padding(.leading, 0)
.padding(.bottom, 0))
} else {
return AnyView(EmptyView())
}
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Spacer()
Menu(
content: { contextMenuView },
label: {
Image(systemName: "ellipsis")
.foregroundColor(.appGrayTextContrast)
.padding()
}
Snackbar.show(message: "Highlight copied")
},
label: { Label("Copy", systemImage: "doc.on.doc") }
)
Button(
action: {
onSetLabels(highlightParams.highlightID)
},
label: { Label(LocalText.labelsGeneric, systemImage: "tag") }
)
Button(
action: { showShareView = true },
label: { Label(LocalText.exportGeneric, systemImage: "square.and.arrow.up") }
)
Button(
action: onDeleteHighlight,
label: { Label("Delete", systemImage: "trash") }
)
.frame(width: 16, height: 16, alignment: .center)
.onTapGesture { isContextMenuOpen = true }
}
.padding(.top, 16)
if let createdBy = highlightParams.createdBy {
HStack(alignment: .center) {
if let profileImageURL = createdBy.profileImageURL, let url = URL(string: profileImageURL) {
AsyncImage(
url: url,
content: { $0.resizable() },
placeholder: {
Image(systemName: "person.crop.circle")
.resizable()
.foregroundColor(.appGrayText)
}
)
.aspectRatio(contentMode: .fill)
.frame(width: 14, height: 14, alignment: .center)
.clipShape(Circle())
} else {
Image(systemName: "person.crop.circle")
.resizable()
.foregroundColor(.appGrayText)
.frame(width: 14, height: 14)
}
Text("\(LocalText.highlightCardHighlightByOther)\(highlightParams.createdBy?.name ?? "you")") // TODO: fix translation...add other/you
.font(.appFootnote)
.foregroundColor(.appGrayText)
}
}
HStack {
Divider()
.frame(width: 2)
.overlay(highlightParams.createdBy != nil ? Color(red: 206 / 255.0, green: 239 / 255.0, blue: 159 / 255.0) : Color.appYellow48)
.opacity(0.8)
.padding(.top, 2)
.padding(.trailing, 6)
VStack(alignment: .leading, spacing: 16) {
Text(highlightParams.quote)
labelsView
}
}
.padding(.bottom, 4)
noteSection
}
.sheet(isPresented: $showAnnotationModal) {
HighlightAnnotationSheet(
annotation: $annotation,
onSave: {
onSaveAnnotation(annotation)
showAnnotationModal = false
hasHighlightMutations = true
},
onCancel: {
showAnnotationModal = false
},
errorAlertMessage: $errorAlertMessage,
showErrorAlertMessage: $showErrorAlertMessage
)
var noteSection: some View {
HStack {
let isEmpty = highlightParams.annotation.isEmpty
Spacer(minLength: 6)
if isEmpty {
Text("Add Notes...")
.lineSpacing(6)
.accentColor(.appGraySolid)
.foregroundColor(isEmpty ? .appGrayText : .appGrayTextContrast)
.font(.appSubheadline)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.appButtonBackground)
.cornerRadius(8)
} else {
Markdown(highlightParams.annotation)
.lineSpacing(6)
.accentColor(.appGraySolid)
.foregroundColor(isEmpty ? .appGrayText : .appGrayTextContrast)
.font(.appSubheadline)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.appButtonBackground)
.cornerRadius(8)
}
}
.onTapGesture {
annotation = highlightParams.annotation
showAnnotationModal = true
}
}
var labelsView: some View {
if highlightParams.labels.count > 0 {
return AnyView(ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(highlightParams.labels, id: \.self) {
TextChip(feedItemLabel: $0)
.padding(.leading, 0)
}
Spacer()
}
}.introspectScrollView { scrollView in
#if os(iOS)
scrollView.bounces = false
#endif
}
.padding(.top, 0)
.padding(.leading, 0)
.padding(.bottom, 0))
} else {
return AnyView(EmptyView())
}
}
func markdownText(str: String) -> some View {
Markdown(str)
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Spacer()
Menu(
content: { contextMenuView },
label: {
Image(systemName: "ellipsis")
.foregroundColor(.appGrayTextContrast)
.padding()
}
)
.frame(width: 16, height: 16, alignment: .center)
.onTapGesture { isContextMenuOpen = true }
}
.padding(.top, 16)
if let createdBy = highlightParams.createdBy {
HStack(alignment: .center) {
if let profileImageURL = createdBy.profileImageURL, let url = URL(string: profileImageURL) {
AsyncImage(
url: url,
content: { $0.resizable() },
placeholder: {
Image(systemName: "person.crop.circle")
.resizable()
.foregroundColor(.appGrayText)
}
)
.aspectRatio(contentMode: .fill)
.frame(width: 14, height: 14, alignment: .center)
.clipShape(Circle())
} else {
Image(systemName: "person.crop.circle")
.resizable()
.foregroundColor(.appGrayText)
.frame(width: 14, height: 14)
}
// fix translation...add other/you
Text("\(LocalText.highlightCardHighlightByOther)\(highlightParams.createdBy?.name ?? "you")")
.font(.appFootnote)
.foregroundColor(.appGrayText)
}
}
HStack {
Divider()
.frame(width: 2)
// swiftlint:disable:next line_length
.overlay(highlightParams.createdBy != nil ? Color(red: 206 / 255.0, green: 239 / 255.0, blue: 159 / 255.0) : Color.appYellow48)
.opacity(0.8)
.padding(.top, 2)
.padding(.trailing, 6)
VStack(alignment: .leading, spacing: 16) {
Markdown(highlightParams.quote)
labelsView
}
}
.padding(.bottom, 4)
noteSection
}
.sheet(isPresented: $showAnnotationModal) {
HighlightAnnotationSheet(
annotation: $annotation,
onSave: {
onSaveAnnotation(annotation)
showAnnotationModal = false
hasHighlightMutations = true
},
onCancel: {
showAnnotationModal = false
},
errorAlertMessage: $errorAlertMessage,
showErrorAlertMessage: $showErrorAlertMessage
)
}
.formSheet(isPresented: $showShareView) {
ShareSheet(activityItems: [viewModel.highlightAsMarkdown(item: self.highlightParams)])
}
}
}
}
#endif

View file

@ -1,104 +1,127 @@
import CoreData
import Models
import Services
import SwiftUI
import Views
#if os(iOS)
import CoreData
import Models
import Services
import SwiftUI
import Views
struct HighlightsListView: View {
@EnvironmentObject var dataService: DataService
@Environment(\.presentationMode) private var presentationMode
@StateObject var viewModel = HighlightsListViewModel()
struct HighlightsListView: View {
@EnvironmentObject var dataService: DataService
@Environment(\.presentationMode) private var presentationMode
@StateObject var viewModel = HighlightsListViewModel()
let itemObjectID: NSManagedObjectID
@Binding var hasHighlightMutations: Bool
@State var setLabelsHighlight: Highlight?
let itemObjectID: NSManagedObjectID
@Binding var hasHighlightMutations: Bool
@State var setLabelsHighlight: Highlight?
@State var showShareView: Bool = false
var emptyView: some View {
Text(LocalText.highlightCardNoHighlightsOnPage)
.multilineTextAlignment(.center)
.padding(16)
}
var innerBody: some View {
(viewModel.highlightItems.count > 0 ? AnyView(listView) : AnyView(emptyView))
.navigationTitle("Notebook")
.listStyle(PlainListStyle())
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
dismissButton
}
}
#else
.toolbar {
ToolbarItemGroup {
dismissButton
}
}
#endif
}
var listView: some View {
List {
Section {
ForEach(viewModel.highlightItems) { highlightParams in
HighlightsListCard(
highlightParams: highlightParams,
hasHighlightMutations: $hasHighlightMutations,
onSaveAnnotation: {
viewModel.updateAnnotation(
highlightID: highlightParams.highlightID,
annotation: $0,
dataService: dataService
)
},
onDeleteHighlight: {
hasHighlightMutations = true
viewModel.deleteHighlight(
highlightID: highlightParams.highlightID,
dataService: dataService
)
},
onSetLabels: { highlightID in
setLabelsHighlight = Highlight.lookup(byID: highlightID, inContext: dataService.viewContext)
}
)
.listRowSeparator(.hidden)
}
}
}.sheet(item: $setLabelsHighlight) { highlight in
ApplyLabelsView(mode: .highlight(highlight), isSearchFocused: false, onSave: { selectedLabels in
hasHighlightMutations = true
viewModel.setLabelsForHighlight(highlightID: highlight.unwrappedID,
labels: selectedLabels,
dataService: dataService)
})
var emptyView: some View {
Text(LocalText.highlightCardNoHighlightsOnPage)
.multilineTextAlignment(.center)
.padding(16)
}
}
var dismissButton: some View {
Button(
action: { presentationMode.wrappedValue.dismiss() },
label: { Text(LocalText.doneGeneric).foregroundColor(.appGrayTextContrast) }
)
}
var body: some View {
Group {
var innerBody: some View {
(viewModel.highlightItems.count > 0 ? AnyView(listView) : AnyView(emptyView))
.navigationTitle("Notebook")
.listStyle(PlainListStyle())
#if os(iOS)
NavigationView {
innerBody
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
dismissButton
}
ToolbarItem(placement: .navigationBarTrailing) {
actionsMenu
}
}.formSheet(isPresented: $showShareView) {
ShareSheet(activityItems: [viewModel.highlightsAsMarkdown()])
}
#else
.toolbar {
ToolbarItemGroup {
dismissButton
}
}
#elseif os(macOS)
innerBody
.frame(minWidth: 400, minHeight: 400)
#endif
}
.task {
viewModel.load(itemObjectID: itemObjectID, dataService: dataService)
var listView: some View {
List {
Section {
ForEach(viewModel.highlightItems) { highlightParams in
HighlightsListCard(
viewModel: self.viewModel,
highlightParams: highlightParams,
hasHighlightMutations: $hasHighlightMutations,
onSaveAnnotation: {
viewModel.updateAnnotation(
highlightID: highlightParams.highlightID,
annotation: $0,
dataService: dataService
)
},
onDeleteHighlight: {
hasHighlightMutations = true
viewModel.deleteHighlight(
highlightID: highlightParams.highlightID,
dataService: dataService
)
},
onSetLabels: { highlightID in
setLabelsHighlight = Highlight.lookup(byID: highlightID, inContext: dataService.viewContext)
}
)
.listRowSeparator(.hidden)
}
}
}.sheet(item: $setLabelsHighlight) { highlight in
ApplyLabelsView(mode: .highlight(highlight), isSearchFocused: false, onSave: { selectedLabels in
hasHighlightMutations = true
viewModel.setLabelsForHighlight(highlightID: highlight.unwrappedID,
labels: selectedLabels,
dataService: dataService)
})
}
}
var dismissButton: some View {
Button(
action: { presentationMode.wrappedValue.dismiss() },
label: { Text(LocalText.genericClose) }
)
}
var actionsMenu: some View {
Menu(
content: {
Button(
action: { showShareView = true },
label: { Label(LocalText.exportGeneric, systemImage: "square.and.arrow.up") }
)
},
label: {
Image(systemName: "ellipsis")
.foregroundColor(.appGrayTextContrast)
}
)
}
var body: some View {
Group {
#if os(iOS)
NavigationView {
innerBody
}
#elseif os(macOS)
innerBody
.frame(minWidth: 400, minHeight: 400)
#endif
}
.task {
viewModel.load(itemObjectID: itemObjectID, dataService: dataService)
}
}
}
}
#endif

View file

@ -58,11 +58,27 @@ struct HighlightListItemParams: Identifiable {
}
}
private func loadHighlights(item: LinkedItem) {
let unsortedHighlights = item.highlights.asArray(of: Highlight.self)
func highlightAsMarkdown(item: HighlightListItemParams) -> String {
var buffer = "> \(item.quote)"
if !item.annotation.isEmpty {
buffer += "\n\n\(item.annotation)"
}
buffer += "\n"
return buffer
}
let highlights = unsortedHighlights.sorted {
($0.createdAt ?? Date()) < ($1.createdAt ?? Date())
func highlightsAsMarkdown() -> String {
highlightItems.map { highlightAsMarkdown(item: $0) }.lazy.joined(separator: "\n\n")
}
private func loadHighlights(item: LinkedItem) {
let unsortedHighlights = item.highlights.asArray(of: Highlight.self).filter { $0.type == "HIGHLIGHT" }
let highlights = unsortedHighlights.sorted { left, right in
if left.positionPercent > 0, right.positionPercent > 0 {
return left.positionPercent < right.positionPercent
}
return (left.createdAt ?? Date()) < (right.createdAt ?? Date())
}
highlightItems = highlights.map {

View file

@ -45,11 +45,10 @@ struct FeedCardNavigationLink: View {
var body: some View {
ZStack {
Button(
action: {
viewModel.selectedItem = item
viewModel.linkIsActive = true
}) {
Button {
viewModel.selectedItem = item
viewModel.linkIsActive = true
} label: {
NavigationLink(destination: EmptyView()) {
EmptyView()
}
@ -58,7 +57,7 @@ struct FeedCardNavigationLink: View {
.onAppear {
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
}
FeedCard(item: item, viewer: dataService.currentViewer)
LibraryItemCard(item: item, viewer: dataService.currentViewer)
}
}
}

View file

@ -0,0 +1,39 @@
//
// LibraryFeatureCardNavigationLink.swift
//
//
// Created by Jackson Harper on 4/4/23.
//
import Models
import Services
import SwiftUI
import Views
struct LibraryFeatureCardNavigationLink: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
let item: LinkedItem
@ObservedObject var viewModel: HomeFeedViewModel
var body: some View {
ZStack {
Button {
viewModel.selectedItem = item
viewModel.linkIsActive = true
} label: {
NavigationLink(destination: EmptyView()) {
EmptyView()
}
.opacity(0)
.buttonStyle(PlainButtonStyle())
.onAppear {
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
}
LibraryFeatureCard(item: item, viewer: dataService.currentViewer)
}
}
}
}

View file

@ -0,0 +1,41 @@
import Foundation
import Models
import Views
extension LinkedItemFilter {
var displayName: String {
switch self {
case .inbox:
return LocalText.inboxGeneric
case .readlater:
return LocalText.readLaterGeneric
case .newsletters:
return LocalText.newslettersGeneric
case .recommended:
return "Recommended"
case .all:
return LocalText.allGeneric
case .archived:
return LocalText.archivedGeneric
case .hasHighlights:
return LocalText.highlightedGeneric
case .files:
return LocalText.filesGeneric
}
}
}
public extension LinkedItemSort {
var displayName: String {
switch self {
case .newest:
return LocalText.newestGeneric
case .oldest:
return LocalText.oldestGeneric
case .recentlyRead:
return LocalText.recentlyReadGeneric
case .recentlyPublished:
return LocalText.recentlyPublishedGeneric
}
}
}

View file

@ -6,6 +6,19 @@ import UserNotifications
import Utils
import Views
struct AnimatingCellHeight: AnimatableModifier {
var height: CGFloat = 0
var animatableData: CGFloat {
get { height }
set { height = newValue }
}
func body(content: Content) -> some View {
content.frame(height: height, alignment: .top).clipped()
}
}
// swiftlint:disable file_length
#if os(iOS)
private let enableGrid = UIDevice.isIPad || FeatureFlag.enableGridCardsOnPhone
@ -13,6 +26,8 @@ import Views
struct HomeFeedContainerView: View {
@State var hasHighlightMutations = false
@State var searchPresented = false
@State var addLinkPresented = false
@State var settingsPresented = false
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@ -129,16 +144,18 @@ import Views
}
ToolbarItem(placement: .barTrailing) {
if UIDevice.isIPhone {
NavigationLink(
destination: { ProfileView() },
label: {
Image(systemName: "person.circle")
.resizable()
.frame(width: 22, height: 22)
.padding(.vertical, 16)
.foregroundColor(.appGrayTextContrast)
}
)
Menu(content: {
Button(action: { settingsPresented = true }, label: {
Label(LocalText.genericProfile, systemImage: "person.circle")
})
Button(action: { addLinkPresented = true }, label: {
Label("Add Link", systemImage: "plus.square")
})
}, label: {
Image(systemName: "ellipsis")
.foregroundColor(.appGrayTextContrast)
.frame(width: 24, height: 24)
})
} else {
EmptyView()
}
@ -189,6 +206,16 @@ import Views
.fullScreenCover(isPresented: $searchPresented) {
LibrarySearchView(homeFeedViewModel: self.viewModel)
}
.sheet(isPresented: $addLinkPresented) {
NavigationView {
LibraryAddLinkView()
}
}
.sheet(isPresented: $settingsPresented) {
NavigationView {
ProfileView()
}
}
.task {
if viewModel.items.isEmpty {
loadItems(isRefresh: false)
@ -229,6 +256,7 @@ import Views
@State private var itemToRemove: LinkedItem?
@State private var confirmationShown = false
@State private var showHideFeatureAlert = false
@ObservedObject var viewModel: HomeFeedViewModel
@ -340,6 +368,69 @@ import Views
}
}
var featureCard: some View {
VStack(alignment: .leading, spacing: 20) {
Menu(content: {
Button(action: {
viewModel.updateFeatureFilter(.continueReading)
}, label: {
Text("Continue Reading")
})
Button(action: {
viewModel.updateFeatureFilter(.pinned)
}, label: {
Text("Pinned")
})
Button(action: {
viewModel.updateFeatureFilter(.newsletters)
}, label: {
Text("Newsletters")
})
Button(action: {
showHideFeatureAlert = true
}, label: {
Text("Hide this Section")
})
}, label: {
HStack(alignment: .center) {
Text(viewModel.featureFilter.title.uppercased())
.font(Font.system(size: 14, weight: .regular))
Image(systemName: "chevron.down")
}.frame(maxWidth: .infinity, alignment: .leading)
})
.padding(.top, 20)
.padding(.bottom, 0)
GeometryReader { geo in
ScrollView(.horizontal, showsIndicators: false) {
if viewModel.featureItems.count > 0 {
LazyHStack(alignment: .top, spacing: 20) {
ForEach(viewModel.featureItems) { item in
LibraryFeatureCardNavigationLink(item: item, viewModel: viewModel)
.background(
RoundedRectangle(cornerRadius: 12) // << tune as needed
.fill(Color(UIColor.systemBackground)) // << fill with system color
)
}
}
} else {
Text(viewModel.featureFilter.emptyMessage)
.font(Font.system(size: 14, weight: .regular))
.foregroundColor(Color(hex: "#898989"))
.frame(maxWidth: geo.size.width)
.frame(height: 60, alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
}
}
}
Text((LinkedItemFilter(rawValue: viewModel.appliedFilter)?.displayName ?? "Inbox").uppercased())
.font(Font.system(size: 14, weight: .regular))
.padding(.bottom, 5)
}
}
var body: some View {
ZStack {
NavigationLink(
@ -357,13 +448,22 @@ import Views
List {
filtersHeader
.listRowSeparator(.hidden, edges: .top)
.listRowInsets(.init(top: 0, leading: 10, bottom: 10, trailing: 10))
// Only show the feature card section if we have items loaded
if !viewModel.hideFeatureSection, viewModel.items.count > 0 {
featureCard
.listRowInsets(.init(top: 0, leading: 10, bottom: 10, trailing: 10))
.modifier(AnimatingCellHeight(height: viewModel.featureItems.count > 0 ? 260 : 130))
}
ForEach(viewModel.items) { item in
FeedCardNavigationLink(
item: item,
viewModel: viewModel
)
.listRowSeparatorTint(Color.thBorderColor)
.listRowInsets(.init(top: 0, leading: 10, bottom: 10, trailing: 10))
.contextMenu {
menuItems(for: item)
}
@ -407,8 +507,9 @@ import Views
}
}
}
.padding(.top, 0)
.padding(0)
.listStyle(PlainListStyle())
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
.alert("Are you sure you want to delete this item? All associated notes and highlights will be deleted.",
isPresented: $confirmationShown) {
Button("Remove Item", role: .destructive) {
@ -422,6 +523,13 @@ import Views
Button(LocalText.cancelGeneric, role: .cancel) { self.itemToRemove = nil }
}
}
.alert("The Feature Section will be removed from your library. You can add it back from the filter settings in your profile.",
isPresented: $showHideFeatureAlert) {
Button("OK", role: .destructive) {
viewModel.hideFeatureSection = true
}
Button(LocalText.cancelGeneric, role: .cancel) { self.showHideFeatureAlert = false }
}
}
}
}
@ -498,6 +606,7 @@ import Views
}
}
}
// swiftlint:disable:next line_length
.alert("Are you sure you want to delete this item? All associated notes and highlights will be deleted.", isPresented: $confirmationShown) {
Button("Delete Item", role: .destructive) {
if let itemToRemove = itemToRemove {
@ -559,42 +668,3 @@ struct LinkDestination: View {
}
}
}
// TODO: move everything below this to another file
extension LinkedItemFilter {
var displayName: String {
switch self {
case .inbox:
return LocalText.inboxGeneric
case .readlater:
return LocalText.readLaterGeneric
case .newsletters:
return LocalText.newslettersGeneric
case .recommended:
return "Recommended"
case .all:
return LocalText.allGeneric
case .archived:
return LocalText.archivedGeneric
case .hasHighlights:
return LocalText.highlightedGeneric
case .files:
return LocalText.filesGeneric
}
}
}
public extension LinkedItemSort {
var displayName: String {
switch self {
case .newest:
return LocalText.newestGeneric
case .oldest:
return LocalText.oldestGeneric
case .recentlyRead:
return LocalText.recentlyReadGeneric
case .recentlyPublished:
return LocalText.recentlyPublishedGeneric
}
}
}

View file

@ -18,7 +18,6 @@ import Views
Task {
await viewModel.loadItems(
dataService: dataService,
audioController: audioController,
isRefresh: isRefresh
)
}
@ -43,7 +42,6 @@ import Views
viewModel: viewModel
)
.contextMenu {
// TODO: add highlights view button
Button(
action: { viewModel.itemUnderTitleEdit = item },
label: { Label("Edit Info", systemImage: "info.circle") }
@ -140,12 +138,11 @@ import Views
Button(LocalText.cancelGeneric, role: .cancel) { self.itemToRemove = nil }
}
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(mode: .item(item), onSave: nil)
ApplyLabelsView(mode: .item(item), isSearchFocused: false, onSave: nil)
}
.sheet(item: $viewModel.itemUnderTitleEdit) { item in
LinkedItemMetadataEditView(item: item)
}
// TODO: add highlights view sheet
.task {
if viewModel.items.isEmpty {
loadItems(isRefresh: true)

View file

@ -6,8 +6,6 @@ import Utils
import Views
@MainActor final class HomeFeedViewModel: NSObject, ObservableObject {
let dateFormatter = DateFormatter.formatterISO8601
var currentDetailViewModel: LinkItemDetailViewModel?
private var fetchedResultsController: NSFetchedResultsController<LinkedItem>?
@ -35,6 +33,9 @@ import Views
@Published var showLabelsSheet = false
@Published var showCommunityModal = false
@Published var featureFilter = FeaturedItemFilter.continueReading
@Published var featureItems = [LinkedItem]()
var cursor: String?
// These are used to make sure we handle search result
@ -44,8 +45,24 @@ import Views
var syncCursor: String?
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
@AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilter = LinkedItemFilter.inbox.rawValue
func setItems(_ items: [LinkedItem]) {
self.items = items
updateFeatureFilter(featureFilter)
}
func updateFeatureFilter(_ filter: FeaturedItemFilter) {
// now try to update the continue reading items:
featureItems = (items.filter { item in
filter.predicate.evaluate(with: item)
} as NSArray)
.sortedArray(using: [filter.sortDescriptor])
.compactMap { $0 as? LinkedItem }
featureFilter = filter
}
func handleReaderItemNotification(objectID: NSManagedObjectID, dataService: DataService) {
// Pop the current selected item if needed
if selectedItem != nil, selectedItem?.objectID != objectID {
@ -110,7 +127,7 @@ import Views
func syncItems(dataService: DataService) async {
let syncStart = Date.now
let lastSyncDate = dateFormatter.date(from: dataService.lastItemSyncTime) ?? Date(timeIntervalSinceReferenceDate: 0)
let lastSyncDate = dataService.lastItemSyncTime
try? await dataService.syncOfflineItemsWithServerIfNeeded()
@ -124,13 +141,14 @@ import Views
self.isLoading = false
}
} else {
dataService.lastItemSyncTime = DateFormatter.formatterISO8601.string(from: syncStart)
dataService.lastItemSyncTime = syncStart
}
// If possible start prefetching new pages in the background
if let itemIDs = syncResult?.updatedItemIDs,
let username = dataService.currentViewer?.username,
itemIDs.count > 0
if
let itemIDs = syncResult?.updatedItemIDs,
let username = dataService.currentViewer?.username,
!itemIDs.isEmpty
{
Task.detached(priority: .background) {
await dataService.prefetchPages(itemIDs: itemIDs, username: username)
@ -167,9 +185,9 @@ import Views
// Don't use FRC for searching. Use server results directly.
if fetchedResultsController != nil {
fetchedResultsController = nil
items = []
setItems([])
}
items = isRefresh ? newItems : items + newItems
setItems(isRefresh ? newItems : items + newItems)
}
isLoading = false
@ -267,7 +285,7 @@ import Views
fetchedResultsController.delegate = self
try? fetchedResultsController.performFetch()
items = fetchedResultsController.fetchedObjects ?? []
setItems(fetchedResultsController.fetchedObjects ?? [])
}
func setLinkArchived(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) {
@ -303,10 +321,21 @@ import Views
isLoading = false
}
private var queryContainsFilter: Bool {
if searchTerm.contains("in:inbox") || searchTerm.contains("in:all") || searchTerm.contains("in:archive") {
return true
}
return false
}
private var searchQuery: String {
let filter = LinkedItemFilter(rawValue: appliedFilter) ?? .inbox
let sort = LinkedItemSort(rawValue: appliedSort) ?? .newest
var query = "\(filter.queryString) \(sort.queryString)"
var query = sort.queryString
if !queryContainsFilter, let filter = LinkedItemFilter(rawValue: appliedFilter) {
query = "\(filter.queryString) \(sort.queryString)"
}
if !searchTerm.isEmpty {
query.append(" \(searchTerm)")
@ -322,12 +351,14 @@ import Views
query.append(negatedLabels.map { $0.name ?? "" }.joined(separator: ","))
}
print("QUERY: `\(query)`")
return query
}
}
extension HomeFeedViewModel: NSFetchedResultsControllerDelegate {
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
items = controller.fetchedObjects as? [LinkedItem] ?? []
setItems(controller.fetchedObjects as? [LinkedItem] ?? [])
}
}

View file

@ -0,0 +1,111 @@
#if os(iOS)
import Introspect
import Models
import Services
import SwiftUI
import UIKit
import Views
@MainActor final class LibraryAddLinkViewModel: NSObject, ObservableObject {
@Published var isLoading = false
@Published var errorMessage: String = ""
@Published var showErrorMessage: Bool = false
func addLink(dataService: DataService, newLinkURL: String, dismiss: DismissAction) {
isLoading = true
Task {
if URL(string: newLinkURL) == nil {
error("Invalid link")
} else {
let result = try? await dataService.saveURL(id: UUID().uuidString, url: newLinkURL)
if result == nil {
error("Error adding link")
} else {
dismiss()
}
}
isLoading = false
}
}
func error(_ msg: String) {
errorMessage = msg
showErrorMessage = true
isLoading = false
}
}
struct LibraryAddLinkView: View {
@StateObject var viewModel = LibraryAddLinkViewModel()
@State var newLinkURL: String = ""
@EnvironmentObject var dataService: DataService
@Environment(\.dismiss) private var dismiss
enum FocusField: Hashable {
case addLinkEditor
}
@FocusState private var focusedField: FocusField?
var body: some View {
innerBody
.navigationTitle("Add Link")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
focusedField = .addLinkEditor
}
}
var innerBody: some View {
Form {
TextField("Add Link", text: $newLinkURL)
.keyboardType(.URL)
.textFieldStyle(StandardTextFieldStyle())
.focused($focusedField, equals: .addLinkEditor)
Button(action: {
if let url = UIPasteboard.general.url {
newLinkURL = url.absoluteString
} else {
viewModel.error("No URL on pasteboard")
}
}, label: {
Text("Get from pasteboard")
})
}
.navigationTitle("Add Link")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
dismissButton
}
ToolbarItem(placement: .navigationBarTrailing) {
viewModel.isLoading ? AnyView(ProgressView()) : AnyView(addButton)
}
}
.alert(viewModel.errorMessage,
isPresented: $viewModel.showErrorMessage) {
Button(LocalText.genericOk, role: .cancel) { viewModel.showErrorMessage = false }
}
}
var addButton: some View {
Button(
action: {
viewModel.addLink(dataService: dataService, newLinkURL: newLinkURL, dismiss: dismiss)
},
label: { Text("Add").bold() }
)
.disabled(viewModel.isLoading)
}
var dismissButton: some View {
Button(
action: { dismiss() },
label: { Text(LocalText.genericClose) }
)
.disabled(viewModel.isLoading)
}
}
#endif

View file

@ -44,7 +44,12 @@ import Views
let fetchRequest: NSFetchRequest<Models.RecentSearchItem> = RecentSearchItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "term == %@", searchTerm)
let item = ((try? dataService.viewContext.fetch(fetchRequest))?.first) ?? RecentSearchItem(context: dataService.viewContext)
let item: RecentSearchItem
if let fetchedItem = (try? dataService.viewContext.fetch(fetchRequest))?.first {
item = fetchedItem
} else {
item = RecentSearchItem(context: dataService.viewContext)
}
item.term = searchTerm
item.savedAt = Date()

View file

@ -128,7 +128,9 @@ struct ApplyLabelsView: View {
)
.buttonStyle(PlainButtonStyle())
.disabled(viewModel.isLoading)
.listRowSeparator(.hidden, edges: .bottom)
#if os(iOS)
.listRowSeparator(.hidden, edges: .bottom)
#endif
.padding(.vertical, 10)
}

View file

@ -17,10 +17,11 @@ struct LabelsMasonaryView: View {
@State private var totalHeight = CGFloat.zero
private var labelItems: [(label: LinkedItemLabel, selected: Bool)]
init(labels allLabels: [LinkedItemLabel],
selectedLabels: [LinkedItemLabel],
onLabelTap: @escaping (LinkedItemLabel, TextChip) -> Void)
{
init(
labels allLabels: [LinkedItemLabel],
selectedLabels: [LinkedItemLabel],
onLabelTap: @escaping (LinkedItemLabel, TextChip) -> Void
) {
self.onLabelTap = onLabelTap
let selected = selectedLabels.map { (label: $0, selected: true) }

View file

@ -67,7 +67,9 @@ struct LabelsView: View {
)
.buttonStyle(PlainButtonStyle())
.disabled(viewModel.isLoading)
.listRowSeparator(.hidden, edges: .bottom)
#if os(iOS)
.listRowSeparator(.hidden, edges: .bottom)
#endif
.padding(.vertical, 10)
}
}

View file

@ -37,6 +37,7 @@
}
.frame(height: storedSize.height)
.overlay(content: {
// swiftlint:disable line_length
HStack {
let color: Color = .systemBackground
@ -47,6 +48,7 @@
LinearGradient(colors: [color, color.opacity(0.7), color.opacity(0.5), color.opacity(0.3)].reversed(), startPoint: .leading, endPoint: .trailing)
.frame(width: 8)
// swiftlint:enable line_length
}
})
.disabled(true)

View file

@ -168,7 +168,7 @@ struct LinkItemDetailView: View {
}
},
label: {
Image.profile
Image(systemName: "ellipsis")
.padding(.horizontal)
.scaleEffect(navBarVisibilityRatio)
}

View file

@ -3,7 +3,7 @@ import Services
import SwiftUI
import Views
@MainActor final class LinkedItemTitleEditViewModel: ObservableObject {
@MainActor final class LinkedItemMetadataEditViewModel: ObservableObject {
@Published var title = ""
@Published var description = ""
@Published var author = ""
@ -28,9 +28,15 @@ import Views
struct LinkedItemMetadataEditView: View {
@EnvironmentObject var dataService: DataService
@Environment(\.presentationMode) private var presentationMode
@StateObject var viewModel = LinkedItemTitleEditViewModel()
@StateObject var viewModel = LinkedItemMetadataEditViewModel()
let item: LinkedItem
let onSave: ((String, String) -> Void)?
init(item: LinkedItem, onSave: ((String, String) -> Void)? = nil) {
self.item = item
self.onSave = onSave
}
var editForm: some View {
ScrollView(showsIndicators: false) {
@ -96,6 +102,9 @@ struct LinkedItemMetadataEditView: View {
Button(
action: {
viewModel.submit(dataService: dataService, item: item)
if let onSave = self.onSave {
onSave(viewModel.title, viewModel.description)
}
presentationMode.wrappedValue.dismiss()
},
label: { Text(LocalText.genericSave).foregroundColor(.appGrayTextContrast) }

View file

@ -0,0 +1,43 @@
import Models
import Services
import SwiftUI
import Utils
import Views
@MainActor final class FiltersViewModel: ObservableObject {
@Published var isLoading = false
@Published var isCreating = false
@Published var networkError = false
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
}
struct FiltersView: View {
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = FiltersViewModel()
var body: some View {
Group {
#if os(iOS)
Form {
innerBody
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
}
.navigationTitle(LocalText.filtersGeneric)
}
private var innerBody: some View {
Group {
Section {
Toggle("Hide Feature Section", isOn: $viewModel.hideFeatureSection)
}
}
}
}

View file

@ -39,7 +39,7 @@ struct ManageAccountView: View {
)
Button(
action: {
dataService.resetCoreData()
dataService.resetLocalStorage()
},
label: { Text(LocalText.manageAccountResetCache) }
)

View file

@ -59,6 +59,7 @@ struct ProfileView: View {
@EnvironmentObject var authenticator: Authenticator
@EnvironmentObject var dataService: DataService
@Environment(\.openURL) var openURL
@Environment(\.dismiss) var dismiss
@StateObject private var viewModel = ProfileContainerViewModel()
@ -69,6 +70,13 @@ struct ProfileView: View {
Form {
innerBody
}
.navigationTitle(LocalText.genericProfile)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
dismissButton
}
}
#elseif os(macOS)
List {
innerBody
@ -77,6 +85,13 @@ struct ProfileView: View {
#endif
}
var dismissButton: some View {
Button(
action: { dismiss() },
label: { Text(LocalText.genericClose) }
)
}
private var accountSection: some View {
Section {
NavigationLink(destination: LabelsView()) {
@ -91,8 +106,14 @@ struct ProfileView: View {
Text(LocalText.subscriptionsGeneric)
}
NavigationLink(destination: GroupsView()) {
Text(LocalText.clubsGeneric)
#if os(iOS)
NavigationLink(destination: GroupsView()) {
Text(LocalText.clubsGeneric)
}
#endif
NavigationLink(destination: FiltersView()) {
Text(LocalText.filtersGeneric)
}
}
}
@ -164,14 +185,16 @@ struct ProfileView: View {
Alert(
title: Text(LocalText.profileConfirmLogoutMessage),
primaryButton: .destructive(Text(LocalText.genericConfirm)) {
authenticator.logout(dataService: dataService)
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
authenticator.logout(dataService: dataService)
}
},
secondaryButton: .cancel()
)
}
}
}
.navigationTitle(LocalText.genericProfile)
}
}

View file

@ -1,4 +1,3 @@
import Models
import Services
import SwiftUI
@ -20,7 +19,7 @@ import Views
func removeToken(dataService: DataService, tokenID: String) {
if let idx = devices.firstIndex(where: { $0.id == tokenID }) {
Task {
try await dataService.syncDeviceToken(deviceTokenOperation: .deleteToken(tokenID: tokenID))
_ = try await dataService.syncDeviceToken(deviceTokenOperation: .deleteToken(tokenID: tokenID))
devices.remove(at: idx)
}
}

View file

@ -1,90 +1,91 @@
#if os(iOS)
import Models
import Services
import SwiftUI
import Utils
import Views
import Models
import Services
import SwiftUI
import Utils
import Views
@MainActor final class PushNotificationSettingsViewModel: ObservableObject {
@Published var isLoading = false
@Published var emails = [NewsletterEmail]()
@Published var desiredNotificationsEnabled = false
@AppStorage(UserDefaultKey.notificationsEnabled.rawValue) var notificationsEnabled = false
@MainActor final class PushNotificationSettingsViewModel: ObservableObject {
@Published var isLoading = false
@Published var emails = [NewsletterEmail]()
@Published var desiredNotificationsEnabled = false
@AppStorage(UserDefaultKey.notificationsEnabled.rawValue) var notificationsEnabled = false
func checkPushNotificationsStatus() {
UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async {
self.desiredNotificationsEnabled = settings.alertSetting == UNNotificationSetting.enabled
func checkPushNotificationsStatus() {
UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async {
self.desiredNotificationsEnabled = settings.alertSetting == UNNotificationSetting.enabled
}
}
}
}
func tryUpdateToDesired(dataService: DataService) {
UserDefaults.standard.set(desiredNotificationsEnabled, forKey: UserDefaultKey.notificationsEnabled.rawValue)
func tryUpdateToDesired(dataService: DataService) {
UserDefaults.standard.set(desiredNotificationsEnabled, forKey: UserDefaultKey.notificationsEnabled.rawValue)
if desiredNotificationsEnabled {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { granted, _ in
DispatchQueue.main.async {
self.desiredNotificationsEnabled = granted
Task {
if let savedToken = UserDefaults.standard.string(forKey: UserDefaultKey.firebasePushToken.rawValue) {
try? await dataService.syncDeviceToken(
deviceTokenOperation: DeviceTokenOperation.addToken(token: savedToken))
if desiredNotificationsEnabled {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { granted, _ in
DispatchQueue.main.async {
self.desiredNotificationsEnabled = granted
Task {
if let savedToken = UserDefaults.standard.string(forKey: UserDefaultKey.firebasePushToken.rawValue) {
_ = try? await dataService.syncDeviceToken(
deviceTokenOperation: DeviceTokenOperation.addToken(token: savedToken))
}
NotificationCenter.default.post(name: Notification.Name("ReconfigurePushNotifications"), object: nil)
}
NotificationCenter.default.post(name: Notification.Name("ReconfigurePushNotifications"), object: nil)
}
}
} else {
if let tokenID = UserDefaults.standard.string(forKey: UserDefaultKey.deviceTokenID.rawValue) {
Task {
try? await Services().dataService.syncDeviceToken(deviceTokenOperation: .deleteToken(tokenID: tokenID))
}
}
}
} else {
if let tokenID = UserDefaults.standard.string(forKey: UserDefaultKey.deviceTokenID.rawValue) {
Task {
try? await Services().dataService.syncDeviceToken(deviceTokenOperation: .deleteToken(tokenID: tokenID))
}
}
}
}
}
struct PushNotificationSettingsView: View {
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = PushNotificationSettingsViewModel()
@State var desiredNotificationsEnabled: Bool = false
struct PushNotificationSettingsView: View {
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = PushNotificationSettingsViewModel()
@State var desiredNotificationsEnabled: Bool = false
var body: some View {
Group {
#if os(iOS)
Form {
innerBody
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
var body: some View {
Group {
#if os(iOS)
Form {
innerBody
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
}
.task { viewModel.checkPushNotificationsStatus() }
}
.task { viewModel.checkPushNotificationsStatus() }
}
private var innerBody: some View {
Group {
Section {
Toggle(isOn: $viewModel.desiredNotificationsEnabled, label: { Text(LocalText.notificationsEnabled) })
}.onChange(of: viewModel.desiredNotificationsEnabled) { _ in
viewModel.tryUpdateToDesired(dataService: dataService)
}
private var innerBody: some View {
Group {
Section {
Toggle(isOn: $viewModel.desiredNotificationsEnabled, label: { Text(LocalText.notificationsEnabled) })
}.onChange(of: viewModel.desiredNotificationsEnabled) { _ in
viewModel.tryUpdateToDesired(dataService: dataService)
}
Section { // TODO: double check this text
Text("\(LocalText.notificationsExplainer)\n\(LocalText.notificationsTriggerExplainer)")
.accentColor(.blue)
}
Section {
Text("\(LocalText.notificationsExplainer)\n\(LocalText.notificationsTriggerExplainer)")
.accentColor(.blue)
}
Section {
NavigationLink("Devices") {
PushNotificationDevicesView()
Section {
NavigationLink("Devices") {
PushNotificationDevicesView()
}
}
}
.navigationTitle(LocalText.pushNotificationsGeneric)
}
.navigationTitle(LocalText.pushNotificationsGeneric)
}
}
#endif

View file

@ -1,212 +1,214 @@
import Models
import Services
import SwiftUI
import Views
#if os(iOS)
import Models
import Services
import SwiftUI
import Views
@MainActor final class RecommendationsGroupViewModel: ObservableObject {
@Published var isLoading = false
@Published var isLeaving = false
@Published var networkError = false
@Published var showLeaveGroup = false
@Published var recommendationGroup: InternalRecommendationGroup
@MainActor final class RecommendationsGroupViewModel: ObservableObject {
@Published var isLoading = false
@Published var isLeaving = false
@Published var networkError = false
@Published var showLeaveGroup = false
@Published var recommendationGroup: InternalRecommendationGroup
init(recommendationGroup: InternalRecommendationGroup) {
self.recommendationGroup = recommendationGroup
}
var nonAdmins: [InternalUserProfile] {
recommendationGroup.members.filter { member in
!recommendationGroup.admins.contains(where: { member.id == $0.id })
}
}
func leaveGroup(dataService: DataService) async -> Bool {
isLeaving = true
defer {
isLeaving = false
init(recommendationGroup: InternalRecommendationGroup) {
self.recommendationGroup = recommendationGroup
}
do {
try await dataService.leaveGroup(groupID: recommendationGroup.id)
Snackbar.show(message: "You have left the club.")
} catch {
return false
}
return true
}
}
private struct SmallUserCard: View {
let data: ProfileCardData
var size: CGFloat
public init(data: ProfileCardData, size: CGFloat = 36) {
self.data = data
self.size = size
}
public var body: some View {
HStack(alignment: .center, spacing: 16) {
Group {
AsyncImage(
url: data.imageURL,
content: { $0.resizable() },
placeholder: {
Image(systemName: "person.crop.circle")
.resizable()
.foregroundColor(.appGrayTextContrast)
}
)
var nonAdmins: [InternalUserProfile] {
recommendationGroup.members.filter { member in
!recommendationGroup.admins.contains(where: { member.id == $0.id })
}
.padding(.leading, 0)
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size, alignment: .center)
.clipShape(Circle())
}
VStack(alignment: .leading, spacing: 4) {
Text(data.name)
.font(.appBody)
.foregroundColor(.appGrayTextContrast)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
func leaveGroup(dataService: DataService) async -> Bool {
isLeaving = true
defer {
isLeaving = false
}
Text("@\(data.username)")
.font(.appCaption)
.foregroundColor(.appGrayText)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
do {
try await dataService.leaveGroup(groupID: recommendationGroup.id)
Snackbar.show(message: "You have left the club.")
} catch {
return false
}
return true
}
}
private struct SmallUserCard: View {
let data: ProfileCardData
var size: CGFloat
public init(data: ProfileCardData, size: CGFloat = 36) {
self.data = data
self.size = size
}
public var body: some View {
HStack(alignment: .center, spacing: 16) {
Group {
AsyncImage(
url: data.imageURL,
content: { $0.resizable() },
placeholder: {
Image(systemName: "person.crop.circle")
.resizable()
.foregroundColor(.appGrayTextContrast)
}
)
}
.padding(.leading, 0)
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size, alignment: .center)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 4) {
Text(data.name)
.font(.appBody)
.foregroundColor(.appGrayTextContrast)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
Text("@\(data.username)")
.font(.appCaption)
.foregroundColor(.appGrayText)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity)
.multilineTextAlignment(.leading)
.padding(0)
}
.frame(maxWidth: .infinity)
.multilineTextAlignment(.leading)
.padding(0)
}
.frame(maxWidth: .infinity)
}
}
struct RecommendationGroupView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var dataService: DataService
@StateObject var viewModel: RecommendationsGroupViewModel
var body: some View {
Group {
#if os(iOS)
Form {
innerBody
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
}
}
private var shareView: some View {
if let shareLink = URL(string: viewModel.recommendationGroup.inviteUrl) {
return AnyView(ShareSheet(activityItems: [shareLink]))
} else {
return AnyView(Text(LocalText.clubsErrorCopying))
}
}
struct RecommendationGroupView: View {
@Environment(\.dismiss) private var dismiss
private var adminsSection: some View {
Section("Admins") {
ForEach(viewModel.recommendationGroup.admins) { admin in
SmallUserCard(data: ProfileCardData(
name: admin.name,
username: admin.username,
imageURL: admin.profileImageURL != nil ? URL(string: admin.profileImageURL!) : nil
))
@EnvironmentObject var dataService: DataService
@StateObject var viewModel: RecommendationsGroupViewModel
var body: some View {
Group {
#if os(iOS)
Form {
innerBody
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
}
}
}
private var membersSection: some View {
Section("Members") {
if !viewModel.recommendationGroup.canSeeMembers { // TODO: might need to fix text here
Text("""
\(LocalText.clubsAdminDenyViewing)
private var shareView: some View {
if let shareLink = URL(string: viewModel.recommendationGroup.inviteUrl) {
return AnyView(ShareSheet(activityItems: [shareLink]))
} else {
return AnyView(Text(LocalText.clubsErrorCopying))
}
}
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
} else if viewModel.nonAdmins.count > 0 {
ForEach(viewModel.nonAdmins) { member in
private var adminsSection: some View {
Section("Admins") {
ForEach(viewModel.recommendationGroup.admins) { admin in
SmallUserCard(data: ProfileCardData(
name: member.name,
username: member.username,
imageURL: member.profileImageURL != nil ? URL(string: member.profileImageURL!) : nil
name: admin.name,
username: admin.username,
imageURL: admin.profileImageURL != nil ? URL(string: admin.profileImageURL!) : nil
))
}
} else { // TODO: fix link text to use translation
Text("""
\(LocalText.clubsNoMembers)
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
}
}
}
private var leaveSection: some View {
if viewModel.isLeaving {
return AnyView(ProgressView())
}
return AnyView(Button(action: {
viewModel.showLeaveGroup = true
}, label: { Text(LocalText.clubsLeave) })
.accentColor(.red))
}
private var membersSection: some View {
Section("Members") {
if !viewModel.recommendationGroup.canSeeMembers {
Text("""
\(LocalText.clubsAdminDenyViewing)
private var innerBody: some View {
Group {
Section(LocalText.genericName) {
Text(viewModel.recommendationGroup.name)
}
Section("Invite Link") {
Button(action: {
#if os(iOS)
UIPasteboard.general.string = viewModel.recommendationGroup.inviteUrl
#endif
#if os(macOS)
let pasteBoard = NSPasteboard.general
pasteBoard.clearContents()
pasteBoard.writeObjects([highlightParams.quote as NSString])
#endif
Snackbar.show(message: "Invite link copied")
}, label: {
Text("[\(viewModel.recommendationGroup.inviteUrl)](\(viewModel.recommendationGroup.inviteUrl))")
.font(.appCaption)
})
}
adminsSection
membersSection
leaveSection
}
.alert(isPresented: $viewModel.showLeaveGroup) {
Alert(
title: Text(LocalText.clubsLeaveConfirm),
primaryButton: .destructive(Text(LocalText.clubsLeave)) {
Task {
let success = await viewModel.leaveGroup(dataService: dataService)
if success {
dismiss()
}
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
} else if viewModel.nonAdmins.count > 0 {
ForEach(viewModel.nonAdmins) { member in
SmallUserCard(data: ProfileCardData(
name: member.name,
username: member.username,
imageURL: member.profileImageURL != nil ? URL(string: member.profileImageURL!) : nil
))
}
},
secondaryButton: .cancel()
)
} else {
Text("""
\(LocalText.clubsNoMembers)
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
}
}
}
private var leaveSection: some View {
if viewModel.isLeaving {
return AnyView(ProgressView())
}
return AnyView(Button(action: {
viewModel.showLeaveGroup = true
}, label: { Text(LocalText.clubsLeave) })
.accentColor(.red))
}
private var innerBody: some View {
Group {
Section(LocalText.genericName) {
Text(viewModel.recommendationGroup.name)
}
Section("Invite Link") {
Button(action: {
#if os(iOS)
UIPasteboard.general.string = viewModel.recommendationGroup.inviteUrl
#endif
#if os(macOS)
let pasteBoard = NSPasteboard.general
pasteBoard.clearContents()
pasteBoard.writeObjects([highlightParams.quote as NSString])
#endif
Snackbar.show(message: "Invite link copied")
}, label: {
Text("[\(viewModel.recommendationGroup.inviteUrl)](\(viewModel.recommendationGroup.inviteUrl))")
.font(.appCaption)
})
}
adminsSection
membersSection
leaveSection
}
.alert(isPresented: $viewModel.showLeaveGroup) {
Alert(
title: Text(LocalText.clubsLeaveConfirm),
primaryButton: .destructive(Text(LocalText.clubsLeave)) {
Task {
let success = await viewModel.leaveGroup(dataService: dataService)
if success {
dismiss()
}
}
},
secondaryButton: .cancel()
)
}
.navigationTitle(viewModel.recommendationGroup.name)
}
.navigationTitle(viewModel.recommendationGroup.name)
}
}
#endif

View file

@ -1,174 +1,178 @@
import Models
import Services
import SwiftUI
import Views
#if os(iOS)
import Models
import Services
import SwiftUI
import Views
@MainActor final class RecommendationsGroupsViewModel: ObservableObject {
@Published var isLoading = false
@Published var isCreating = false
@Published var networkError = false
@Published var recommendationGroups = [InternalRecommendationGroup]()
@MainActor final class RecommendationsGroupsViewModel: ObservableObject {
@Published var isLoading = false
@Published var isCreating = false
@Published var networkError = false
@Published var recommendationGroups = [InternalRecommendationGroup]()
@Published var showCreateSheet = false
@Published var showCreateSheet = false
@Published var showCreateError = false
@Published var createGroupError: String?
@Published var showCreateError = false
@Published var createGroupError: String?
@Published var onlyAdminCanPost = false
@Published var onlyAdminCanSeeMembers = false
@Published var onlyAdminCanPost = false
@Published var onlyAdminCanSeeMembers = false
func loadGroups(dataService: DataService) async {
isLoading = true
func loadGroups(dataService: DataService) async {
isLoading = true
do {
recommendationGroups = try await dataService.recommendationGroups()
} catch {
networkError = true
}
isLoading = false
}
func createGroup(dataService: DataService, name: String) async {
isCreating = true
let group = try? await dataService.createRecommendationGroup(name: name,
onlyAdminCanPost: onlyAdminCanPost,
onlyAdminCanSeeMembers: onlyAdminCanSeeMembers)
if group != nil {
await loadGroups(dataService: dataService)
showCreateSheet = false
} else {
createGroupError = "Error creating club"
showCreateError = true
}
isCreating = false
}
}
struct CreateRecommendationGroupView: View {
@State var name = ""
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = RecommendationsGroupsViewModel()
var nextButton: some View {
if viewModel.isCreating {
return AnyView(ProgressView())
} else {
return AnyView(Button(action: {
Task {
await viewModel.createGroup(dataService: dataService, name: self.name)
}
}, label: {
Text(LocalText.genericNext)
})
.disabled(name.isEmpty)
)
}
}
var body: some View {
NavigationView {
Form {
TextField(LocalText.genericName, text: $name, prompt: Text(LocalText.clubsName))
Section("Club Rules") {
Toggle("Only admins can post", isOn: $viewModel.onlyAdminCanPost)
Toggle("Only admins can see members", isOn: $viewModel.onlyAdminCanSeeMembers)
}
Section {
Section {
Text("""
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
}
}
do {
recommendationGroups = try await dataService.recommendationGroups()
} catch {
networkError = true
}
.alert(isPresented: $viewModel.showCreateError) {
Alert(
title: Text(viewModel.createGroupError ?? "Error creating group"),
dismissButton: .cancel(Text(LocalText.genericOk)) {
viewModel.createGroupError = nil
viewModel.showCreateError = false
isLoading = false
}
func createGroup(dataService: DataService, name: String) async {
isCreating = true
let group = try? await dataService.createRecommendationGroup(name: name,
onlyAdminCanPost: onlyAdminCanPost,
onlyAdminCanSeeMembers: onlyAdminCanSeeMembers)
if group != nil {
await loadGroups(dataService: dataService)
showCreateSheet = false
} else {
createGroupError = "Error creating club"
showCreateError = true
}
isCreating = false
}
}
struct CreateRecommendationGroupView: View {
@State var name = ""
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = RecommendationsGroupsViewModel()
var nextButton: some View {
if viewModel.isCreating {
return AnyView(ProgressView())
} else {
return AnyView(Button(action: {
Task {
await viewModel.createGroup(dataService: dataService, name: self.name)
}
}, label: {
Text(LocalText.genericNext)
})
.disabled(name.isEmpty)
)
}
}
.navigationViewStyle(.stack)
.navigationTitle("Create Club")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading:
Button(action: {
viewModel.showCreateSheet = false
}, label: { Text(LocalText.cancelGeneric) }),
trailing: nextButton)
}
}
struct GroupsView: View {
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = RecommendationsGroupsViewModel()
var body: some View {
Group {
#if os(iOS)
Form {
innerBody
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
}
.sheet(isPresented: $viewModel.showCreateSheet) {
var body: some View {
NavigationView {
CreateRecommendationGroupView(viewModel: self.viewModel)
}
}
.task { await viewModel.loadGroups(dataService: dataService) }
.navigationTitle(LocalText.clubsGeneric)
}
Form {
TextField(LocalText.genericName, text: $name, prompt: Text(LocalText.clubsName))
private var innerBody: some View {
Group {
Section {
Button(
action: { viewModel.showCreateSheet = true },
label: {
HStack {
Image(systemName: "plus.circle.fill").foregroundColor(.green)
Text(LocalText.clubsCreate)
Spacer()
Section("Club Rules") {
Toggle("Only admins can post", isOn: $viewModel.onlyAdminCanPost)
Toggle("Only admins can see members", isOn: $viewModel.onlyAdminCanSeeMembers)
}
Section {
Section {
Text("""
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
}
}
)
}
.alert(isPresented: $viewModel.showCreateError) {
Alert(
title: Text(viewModel.createGroupError ?? "Error creating group"),
dismissButton: .cancel(Text(LocalText.genericOk)) {
viewModel.createGroupError = nil
viewModel.showCreateError = false
}
)
}
}
#if os(iOS)
.navigationViewStyle(.stack)
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading:
Button(action: {
viewModel.showCreateSheet = false
}, label: { Text(LocalText.cancelGeneric) }),
trailing: nextButton)
#endif
.navigationTitle("Create Club")
}
}
if !viewModel.isLoading {
if viewModel.recommendationGroups.count > 0 {
Section(header: Text(LocalText.clubsYours)) {
ForEach(viewModel.recommendationGroups) { recommendationGroup in
let vm = RecommendationsGroupViewModel(recommendationGroup: recommendationGroup)
NavigationLink(
destination: RecommendationGroupView(viewModel: vm)
) {
Text(recommendationGroup.name)
struct GroupsView: View {
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = RecommendationsGroupsViewModel()
var body: some View {
Group {
#if os(iOS)
Form {
innerBody
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
}
.sheet(isPresented: $viewModel.showCreateSheet) {
NavigationView {
CreateRecommendationGroupView(viewModel: self.viewModel)
}
}
.task { await viewModel.loadGroups(dataService: dataService) }
.navigationTitle(LocalText.clubsGeneric)
}
private var innerBody: some View {
Group {
Section {
Button(
action: { viewModel.showCreateSheet = true },
label: {
HStack {
Image(systemName: "plus.circle.fill").foregroundColor(.green)
Text(LocalText.clubsCreate)
Spacer()
}
}
}
} else {
Section {
Text(LocalText.clubsNotAMemberMessage)
.accentColor(.blue)
)
}
if !viewModel.isLoading {
if viewModel.recommendationGroups.count > 0 {
Section(header: Text(LocalText.clubsYours)) {
ForEach(viewModel.recommendationGroups) { recommendationGroup in
let viewModel = RecommendationsGroupViewModel(recommendationGroup: recommendationGroup)
NavigationLink(
destination: RecommendationGroupView(viewModel: viewModel)
) {
Text(recommendationGroup.name)
}
}
}
} else {
Section {
Text(LocalText.clubsNotAMemberMessage)
.accentColor(.blue)
}
}
}
}
}
}
}
#endif

View file

@ -46,44 +46,50 @@ struct SubscriptionsView: View {
@State private var progressViewOpacity = 0.0
var body: some View {
if viewModel.isLoading {
ProgressView()
.opacity(progressViewOpacity)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) {
progressViewOpacity = 1
Group {
if viewModel.isLoading {
ProgressView()
.opacity(progressViewOpacity)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) {
progressViewOpacity = 1
}
}
.task { await viewModel.loadSubscriptions(dataService: dataService) }
} else if viewModel.hasNetworkError {
VStack {
Text(LocalText.subscriptionsErrorRetrieving).multilineTextAlignment(.center)
Button(
action: { Task { await viewModel.loadSubscriptions(dataService: dataService) } },
label: { Text(LocalText.genericRetry) }
)
.buttonStyle(RoundedRectButtonStyle())
}
} else if viewModel.subscriptions.isEmpty {
VStack(alignment: .center) {
Spacer()
Text(LocalText.subscriptionsNone)
Spacer()
}
} else {
Group {
#if os(iOS)
Form {
innerBody
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
}
.task { await viewModel.loadSubscriptions(dataService: dataService) }
} else if viewModel.hasNetworkError {
VStack {
Text(LocalText.subscriptionsErrorRetrieving).multilineTextAlignment(.center)
Button(
action: { Task { await viewModel.loadSubscriptions(dataService: dataService) } },
label: { Text(LocalText.genericRetry) }
)
.buttonStyle(RoundedRectButtonStyle())
}
} else if viewModel.subscriptions.isEmpty {
VStack(alignment: .center) {
Spacer()
Text(LocalText.subscriptionsNone)
Spacer()
}
} else {
Group {
#if os(iOS)
Form {
innerBody
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
}
}
.navigationTitle("Subscriptions")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
private var innerBody: some View {

View file

@ -24,9 +24,9 @@
private var innerBody: some View {
ForEach(Voices.Languages, id: \.key.self) { language in
Button(action: {
Button {
audioController.defaultLanguage = language.key
}) {
} label: {
HStack {
Text(language.name)

View file

@ -4,6 +4,7 @@
import SwiftUI
import Views
// swiftlint:disable line_length
struct TextToSpeechView: View {
@EnvironmentObject var audioController: AudioController

View file

@ -47,6 +47,13 @@
}
if audioController.useUltraRealisticVoices {
if showLanguageChanger {
Section("Language") {
NavigationLink(destination: TextToSpeechLanguageView().navigationTitle("Language")) {
Text(audioController.currentVoiceLanguage.name)
}
}
}
ultraRealisticVoices
} else {
if showLanguageChanger {
@ -62,8 +69,10 @@
}
.navigationTitle("Choose a Voice")
.onAppear {
// swiftlint:disable:next line_length
viewModel.realisticVoicesToggle = (audioController.useUltraRealisticVoices && !audioController.ultraRealisticFeatureKey.isEmpty)
}.onChange(of: viewModel.realisticVoicesToggle) { value in
}
.onChange(of: viewModel.realisticVoicesToggle) { value in
if value, audioController.ultraRealisticFeatureKey.isEmpty {
// User wants to sign up
viewModel.waitingForRealisticVoices = true
@ -92,8 +101,9 @@
}
private var ultraRealisticVoices: some View {
ForEach([VoiceCategory.enUS, VoiceCategory.enCA, VoiceCategory.enUK], id: \.self) { category in
ForEach([VoiceCategory.enUS], id: \.self) { category in
Section(category.rawValue) {
// swiftlint:disable:next line_length
ForEach(audioController.realisticVoiceList?.filter { $0.category == category } ?? [], id: \.key.self) { voice in
voiceRow(for: voice)
}

View file

@ -5,51 +5,53 @@
// Created by Jackson Harper on 11/10/22.
//
import CoreData
import Foundation
import Models
import Services
import SwiftUI
import Views
#if os(iOS)
import CoreData
import Foundation
import Models
import Services
import SwiftUI
import Views
@MainActor final class TextToSpeechVoiceSelectionViewModel: ObservableObject {
@Published var playbackSample: String?
@Published var realisticVoicesToggle: Bool = false
@Published var waitingForRealisticVoices: Bool = false
@MainActor final class TextToSpeechVoiceSelectionViewModel: ObservableObject {
@Published var playbackSample: String?
@Published var realisticVoicesToggle: Bool = false
@Published var waitingForRealisticVoices: Bool = false
func requestUltraRealisticFeatureAccess(
dataService: DataService,
audioController: AudioController
) async {
do {
let feature = try await dataService.optInFeature(name: "ultra-realistic-voice")
DispatchQueue.main.async {
if let feature = feature {
audioController.useUltraRealisticVoices = true
audioController.ultraRealisticFeatureRequested = true
audioController.ultraRealisticFeatureKey = feature.granted ? feature.token : ""
if feature.granted, !Voices.isUltraRealisticVoice(audioController.currentVoice) {
// Attempt to set to an ultra voice
if let voice = Voices.UltraPairs.first {
audioController.currentVoice = voice.firstKey
func requestUltraRealisticFeatureAccess(
dataService: DataService,
audioController: AudioController
) async {
do {
let feature = try await dataService.optInFeature(name: "ultra-realistic-voice")
DispatchQueue.main.async {
if let feature = feature {
audioController.useUltraRealisticVoices = true
audioController.ultraRealisticFeatureRequested = true
audioController.ultraRealisticFeatureKey = feature.granted ? feature.token : ""
if feature.granted, !Voices.isUltraRealisticVoice(audioController.currentVoice) {
// Attempt to set to an ultra voice
if let voice = Voices.UltraPairs.first {
audioController.currentVoice = voice.firstKey
}
}
self.realisticVoicesToggle = true
} else {
audioController.useUltraRealisticVoices = false
audioController.ultraRealisticFeatureKey = ""
audioController.ultraRealisticFeatureRequested = false
self.realisticVoicesToggle = false
}
self.realisticVoicesToggle = true
} else {
audioController.useUltraRealisticVoices = false
audioController.ultraRealisticFeatureKey = ""
audioController.ultraRealisticFeatureRequested = false
self.realisticVoicesToggle = false
self.waitingForRealisticVoices = false
}
self.waitingForRealisticVoices = false
} catch {
print("ERROR OPTING INTO FEATURE", error)
audioController.useUltraRealisticVoices = false
realisticVoicesToggle = false
waitingForRealisticVoices = false
audioController.ultraRealisticFeatureRequested = false
Snackbar.show(message: "Error signing up for beta. Please try again.")
}
} catch {
print("ERROR OPTING INTO FEATURE", error)
audioController.useUltraRealisticVoices = false
realisticVoicesToggle = false
waitingForRealisticVoices = false
audioController.ultraRealisticFeatureRequested = false
Snackbar.show(message: "Error signing up for beta. Please try again.")
}
}
}
#endif

View file

@ -1,86 +1,90 @@
import Models
import Services
import SwiftUI
import Utils
import Views
#if os(iOS)
import Models
import Services
import SwiftUI
import Utils
import Views
class SelfHostSettingsViewModel: ObservableObject {
@State var showCreateError = false
}
struct SelfHostSettingsView: View {
@State var apiServerAddress = ""
@State var webServerAddress = ""
@State var ttsServerAddress = ""
@State var showConfirmAlert = false
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = SelfHostSettingsViewModel()
var allFieldsSet: Bool {
apiServerAddress.count > 0 && webServerAddress.count > 0 && ttsServerAddress.count > 0
class SelfHostSettingsViewModel: ObservableObject {
@State var showCreateError = false
}
var saveButton: some View {
Button(action: {
showConfirmAlert = true
}, label: {
Text(LocalText.genericSave)
})
.disabled(!allFieldsSet)
}
struct SelfHostSettingsView: View {
@State var apiServerAddress = ""
@State var webServerAddress = ""
@State var ttsServerAddress = ""
var body: some View {
Form {
Section("API Server Base URL") {
TextField("URL", text: $apiServerAddress, prompt: Text("https://api-prod.omnivore.app"))
.keyboardType(.URL)
}
@State var showConfirmAlert = false
Section("Web Server URL") {
TextField("URL", text: $webServerAddress, prompt: Text("https://omnivore.app"))
.keyboardType(.URL)
}
@Environment(\.dismiss) private var dismiss
Section("Text-to-speech Server URL") {
TextField("URL", text: $ttsServerAddress, prompt: Text("https://tts.omnivore.app"))
.keyboardType(.URL)
}
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = SelfHostSettingsViewModel()
Section {
Section {
Text("""
Omnivore is a free and open-source project and allows self-hosting.
If you have chosen to deploy your own server instance, fill in the \
above fields to connect to your private self-hosted instance.
[Learn more about self-hosting Omnivore](https://docs.omnivore.app/self-hosting/self-hosting.html)
""")
.accentColor(.blue)
}
}
var allFieldsSet: Bool {
apiServerAddress.count > 0 && webServerAddress.count > 0 && ttsServerAddress.count > 0
}
.accentColor(.appGrayText)
.alert(isPresented: $showConfirmAlert) {
Alert(
title: Text("Changing your environment settings will close the app."),
dismissButton: .cancel(Text(LocalText.genericOk)) {
AppEnvironment.setCustom(serverBaseURL: apiServerAddress, webAppBaseURL: webServerAddress, ttsBaseURL: ttsServerAddress)
dataService.switchAppEnvironment(appEnvironment: AppEnvironment.custom)
}
)
}
.navigationViewStyle(.stack)
.navigationTitle("Self-hosting Options")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading:
var saveButton: some View {
Button(action: {
dismiss()
}, label: { Text(LocalText.cancelGeneric) }),
trailing: saveButton)
showConfirmAlert = true
}, label: {
Text(LocalText.genericSave)
})
.disabled(!allFieldsSet)
}
var body: some View {
Form {
Section("API Server Base URL") {
TextField("URL", text: $apiServerAddress, prompt: Text("https://api-prod.omnivore.app"))
.keyboardType(.URL)
}
Section("Web Server URL") {
TextField("URL", text: $webServerAddress, prompt: Text("https://omnivore.app"))
.keyboardType(.URL)
}
Section("Text-to-speech Server URL") {
TextField("URL", text: $ttsServerAddress, prompt: Text("https://tts.omnivore.app"))
.keyboardType(.URL)
}
Section {
Section {
Text("""
Omnivore is a free and open-source project and allows self-hosting.
If you have chosen to deploy your own server instance, fill in the \
above fields to connect to your private self-hosted instance.
[Learn more about self-hosting Omnivore](https://docs.omnivore.app/self-hosting/self-hosting.html)
""")
.accentColor(.blue)
}
}
}
.accentColor(.appGrayText)
.alert(isPresented: $showConfirmAlert) {
Alert(
title: Text("Changing your environment settings will close the app."),
dismissButton: .cancel(Text(LocalText.genericOk)) {
AppEnvironment.setCustom(serverBaseURL: apiServerAddress,
webAppBaseURL: webServerAddress,
ttsBaseURL: ttsServerAddress)
dataService.switchAppEnvironment(appEnvironment: AppEnvironment.custom)
}
)
}
.navigationViewStyle(.stack)
.navigationTitle("Self-hosting Options")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading:
Button(action: {
dismiss()
}, label: { Text(LocalText.cancelGeneric) }),
trailing: saveButton)
}
}
}
#endif

View file

@ -4,81 +4,83 @@ import Utils
import Views
import WebKit
struct HighlightViewer: PlatformViewRepresentable {
let highlightData: HighlightData
#if os(iOS)
struct HighlightViewer: PlatformViewRepresentable {
let highlightData: HighlightData
func makeCoordinator() -> WebReaderCoordinator {
WebReaderCoordinator()
}
private func makePlatformView(context: Context) -> WKWebView {
let webView = WebViewManager.shared()
let contentController = WKUserContentController()
webView.navigationDelegate = context.coordinator
webView.configuration.userContentController = contentController
webView.configuration.userContentController.removeAllScriptMessageHandlers()
#if os(iOS)
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.delegate = context.coordinator
webView.scrollView.contentInset.top = readerViewNavBarHeight
webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight
webView.configuration.userContentController.add(webView, name: "viewerAction")
#else
webView.setValue(false, forKey: "drawsBackground")
#endif
for action in WebViewAction.allCases {
webView.configuration.userContentController.add(context.coordinator, name: action.rawValue)
func makeCoordinator() -> WebReaderCoordinator {
WebReaderCoordinator()
}
webView.configuration.userContentController.addScriptMessageHandler(
context.coordinator, contentWorld: .page, name: "articleAction"
)
private func makePlatformView(context: Context) -> WKWebView {
let webView = WebViewManager.shared()
let contentController = WKUserContentController()
loadContent(webView: webView)
webView.navigationDelegate = context.coordinator
webView.configuration.userContentController = contentController
webView.configuration.userContentController.removeAllScriptMessageHandlers()
return webView
}
#if os(iOS)
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.delegate = context.coordinator
webView.scrollView.contentInset.top = readerViewNavBarHeight
webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight
webView.configuration.userContentController.add(webView, name: "viewerAction")
#else
webView.setValue(false, forKey: "drawsBackground")
#endif
private func updatePlatformView(_: WKWebView, context _: Context) {
// If the webview had been terminated `needsReload` will have been set to true
// Or if the articleContent value has changed then it's id will be different from the coordinator's
for action in WebViewAction.allCases {
webView.configuration.userContentController.add(context.coordinator, name: action.rawValue)
}
webView.configuration.userContentController.addScriptMessageHandler(
context.coordinator, contentWorld: .page, name: "articleAction"
)
loadContent(webView: webView)
return webView
}
private func updatePlatformView(_: WKWebView, context _: Context) {
// If the webview had been terminated `needsReload` will have been set to true
// Or if the articleContent value has changed then it's id will be different from the coordinator's
// if context.coordinator.needsReload {
// loadContent(webView: webView)
// context.coordinator.needsReload = false
// return
// }
}
private func loadContent(webView: WKWebView) {
// swiftlint:disable line_length
let themeKey = ThemeManager.currentThemeName
let content = """
<!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>
@import url("highlight\(themeKey == "Gray" ? "-dark" : "").css");
</style>
</head>
<body>
<div id="root" />
<div id='_omnivore-highlight' class="highlight">
\(highlightData.highlightHTML)
</div>
</body>
</html>
"""
// swiftlint:enable line_length
webView.loadHTMLString(content, baseURL: ViewsPackage.resourceURL)
}
}
private func loadContent(webView: WKWebView) {
let themeKey = ThemeManager.currentThemeName
let content = """
<!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>
@import url("highlight\(themeKey == "Gray" ? "-dark" : "").css");
</style>
</head>
<body>
<div id="root" />
<div id='_omnivore-highlight' class="highlight">
\(highlightData.highlightHTML)
</div>
</body>
</html>
"""
webView.loadHTMLString(content, baseURL: ViewsPackage.resourceURL)
}
}
#if os(iOS)
extension HighlightViewer {
func makeUIView(context: Context) -> WKWebView {
makePlatformView(context: context)
@ -88,14 +90,4 @@ struct HighlightViewer: PlatformViewRepresentable {
updatePlatformView(webView, context: context)
}
}
#else
extension WebReader {
func makeNSView(context: Context) -> WKWebView {
makePlatformView(context: context)
}
func updateNSView(_ webView: WKWebView, context: Context) {
updatePlatformView(webView, context: context)
}
}
#endif

View file

@ -1,218 +1,220 @@
import CoreData
import Models
import Services
import SwiftUI
import Views
#if os(iOS)
import CoreData
import Models
import Services
import SwiftUI
import Views
@MainActor final class RecommendToViewModel: ObservableObject {
@Published var isLoading = false
@Published var networkError = false
@Published var recommendationGroups = [InternalRecommendationGroup]()
@Published var selectedGroups = [InternalRecommendationGroup]()
@Published var isRunning = false
@Published var showError = false
@Published var showNoteView = false
@Published var note: String = ""
@Published var withHighlights: Bool = true
@MainActor final class RecommendToViewModel: ObservableObject {
@Published var isLoading = false
@Published var networkError = false
@Published var recommendationGroups = [InternalRecommendationGroup]()
@Published var selectedGroups = [InternalRecommendationGroup]()
@Published var isRunning = false
@Published var showError = false
@Published var showNoteView = false
@Published var note: String = ""
@Published var withHighlights: Bool = true
let pageID: String
let highlightCount: Int
let pageID: String
let highlightCount: Int
init(pageID: String, highlightCount: Int) {
self.pageID = pageID
self.highlightCount = highlightCount
}
func loadGroups(dataService: DataService) async {
isLoading = true
do {
dataService.viewContext.performAndWait {
let fetchRequest: NSFetchRequest<Models.RecommendationGroup> = RecommendationGroup.fetchRequest()
let sort = NSSortDescriptor(key: #keyPath(RecommendationGroup.name), ascending: true)
fetchRequest.predicate = NSPredicate(format: "canPost == %@", NSNumber(value: true))
fetchRequest.sortDescriptors = [sort]
// If this fails we will fallback to making the API call
let groups = try? dataService.viewContext.fetch(fetchRequest).compactMap { object in
InternalRecommendationGroup.make(from: object)
}
if let groups = groups {
self.recommendationGroups = groups
}
}
recommendationGroups = try await dataService.recommendationGroups()
.filter(\.canPost)
.sorted(by: { $0.name < $1.name })
} catch {
print("ERROR fetching recommendationGroups: ", error)
networkError = true
init(pageID: String, highlightCount: Int) {
self.pageID = pageID
self.highlightCount = highlightCount
}
isLoading = false
}
func loadGroups(dataService: DataService) async {
isLoading = true
func recommend(dataService: DataService) async -> Bool {
isRunning = true
defer { isRunning = false }
do {
dataService.viewContext.performAndWait {
let fetchRequest: NSFetchRequest<Models.RecommendationGroup> = RecommendationGroup.fetchRequest()
let sort = NSSortDescriptor(key: #keyPath(RecommendationGroup.name), ascending: true)
fetchRequest.predicate = NSPredicate(format: "canPost == %@", NSNumber(value: true))
fetchRequest.sortDescriptors = [sort]
do {
try await dataService.recommendPage(pageID: pageID,
groupIDs: selectedGroups.map(\.id),
note: note.isEmpty ? nil : note,
withHighlights: withHighlights)
} catch {
showError = true
return false
}
isRunning = false
return true
}
}
struct RecommendToView: View {
var dataService: DataService
@StateObject var viewModel: RecommendToViewModel
@Environment(\.dismiss) private var dismiss
var nextButton: some View {
Button(action: {
self.viewModel.showNoteView = true
}, label: {
Text(LocalText.genericNext)
.bold()
})
.disabled(viewModel.selectedGroups.isEmpty)
}
var sendButton: some View {
if viewModel.isRunning {
return AnyView(ProgressView())
} else {
return AnyView(Button(action: {
Task {
if await viewModel.recommend(dataService: dataService) {
Snackbar.show(message: "Recommendation sent")
dismiss()
// If this fails we will fallback to making the API call
let groups = try? dataService.viewContext.fetch(fetchRequest).compactMap { object in
InternalRecommendationGroup.make(from: object)
}
if let groups = groups {
self.recommendationGroups = groups
}
}
recommendationGroups = try await dataService.recommendationGroups()
.filter(\.canPost)
.sorted(by: { $0.name < $1.name })
} catch {
print("ERROR fetching recommendationGroups: ", error)
networkError = true
}
isLoading = false
}
func recommend(dataService: DataService) async -> Bool {
isRunning = true
defer { isRunning = false }
do {
try await dataService.recommendPage(pageID: pageID,
groupIDs: selectedGroups.map(\.id),
note: note.isEmpty ? nil : note,
withHighlights: withHighlights)
} catch {
showError = true
return false
}
isRunning = false
return true
}
}
struct RecommendToView: View {
var dataService: DataService
@StateObject var viewModel: RecommendToViewModel
@Environment(\.dismiss) private var dismiss
var nextButton: some View {
Button(action: {
self.viewModel.showNoteView = true
}, label: {
Text(LocalText.genericSend)
Text(LocalText.genericNext)
.bold()
})
.disabled(viewModel.selectedGroups.isEmpty)
)
}
}
var noteView: some View {
VStack {
HStack {
Text(LocalText.recommendationToPrefix)
.font(.appCaption)
.foregroundColor(.appGrayText)
Text(InternalRecommendationGroup.readable(list: viewModel.selectedGroups))
.font(.appCaption)
var sendButton: some View {
if viewModel.isRunning {
return AnyView(ProgressView())
} else {
return AnyView(Button(action: {
Task {
if await viewModel.recommend(dataService: dataService) {
Snackbar.show(message: "Recommendation sent")
dismiss()
}
}
}, label: {
Text(LocalText.genericSend)
.bold()
})
.disabled(viewModel.selectedGroups.isEmpty)
)
}
}
var noteView: some View {
VStack {
HStack {
Text(LocalText.recommendationToPrefix)
.font(.appCaption)
.foregroundColor(.appGrayText)
Text(InternalRecommendationGroup.readable(list: viewModel.selectedGroups))
.font(.appCaption)
.foregroundColor(.appGrayTextContrast)
Spacer()
}
TextEditor(text: $viewModel.note)
.lineSpacing(6)
.accentColor(.appGraySolid)
.foregroundColor(.appGrayTextContrast)
.font(.appBody)
.padding(12)
.frame(height: 200)
.background(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.appGrayBorder, lineWidth: 1)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.systemBackground))
)
.overlay(
Text(LocalText.recommendationAddNote)
.allowsHitTesting(false)
.opacity(viewModel.note.isEmpty ? 0.4 : 0.0)
.font(.appBody)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.top, 24)
.padding(.leading, 16)
)
if viewModel.highlightCount > 0 {
Toggle(isOn: $viewModel.withHighlights, label: {
HStack(alignment: .firstTextBaseline) {
Text("Include your \(viewModel.highlightCount) highlight\(viewModel.highlightCount > 1 ? "s" : "")")
}
})
}
Spacer()
}
TextEditor(text: $viewModel.note)
.lineSpacing(6)
.accentColor(.appGraySolid)
.foregroundColor(.appGrayTextContrast)
.font(.appBody)
.padding(12)
.frame(height: 200)
.background(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.appGrayBorder, lineWidth: 1)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.systemBackground))
)
.overlay(
Text(LocalText.recommendationAddNote)
.allowsHitTesting(false)
.opacity(viewModel.note.isEmpty ? 0.4 : 0.0)
.font(.appBody)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.top, 24)
.padding(.leading, 16)
)
if viewModel.highlightCount > 0 {
Toggle(isOn: $viewModel.withHighlights, label: {
HStack(alignment: .firstTextBaseline) {
Text("Include your \(viewModel.highlightCount) highlight\(viewModel.highlightCount > 1 ? "s" : "")")
}
})
}
Spacer()
.padding(16)
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
.navigationBarItems(trailing: sendButton)
}
.padding(16)
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
.navigationBarItems(trailing: sendButton)
}
var body: some View {
VStack {
NavigationLink(destination: noteView,
isActive: $viewModel.showNoteView) {
EmptyView()
}
List {
if !viewModel.isLoading, viewModel.recommendationGroups.count < 1 {
Text("""
\(LocalText.clubsNoneJoined)
var body: some View {
VStack {
NavigationLink(destination: noteView,
isActive: $viewModel.showNoteView) {
EmptyView()
}
List {
if !viewModel.isLoading, viewModel.recommendationGroups.count < 1 {
Text("""
\(LocalText.clubsNoneJoined)
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
} else {
Section("Select clubs to recommend to") {
ForEach(viewModel.recommendationGroups) { group in
HStack {
Text(group.name)
[Learn more about clubs](https://blog.omnivore.app/p/dca38ba4-8a74-42cc-90ca-d5ffa5d075cc)
""")
.accentColor(.blue)
} else {
Section("Select clubs to recommend to") {
ForEach(viewModel.recommendationGroups) { group in
HStack {
Text(group.name)
Spacer()
Spacer()
if viewModel.selectedGroups.contains(where: { $0.id == group.id }) {
Image(systemName: "checkmark")
if viewModel.selectedGroups.contains(where: { $0.id == group.id }) {
Image(systemName: "checkmark")
}
}
}
.contentShape(Rectangle())
.onTapGesture {
let idx = viewModel.selectedGroups.firstIndex(where: { $0.id == group.id })
if let idx = idx {
viewModel.selectedGroups.remove(at: idx)
} else {
viewModel.selectedGroups.append(group)
.contentShape(Rectangle())
.onTapGesture {
let idx = viewModel.selectedGroups.firstIndex(where: { $0.id == group.id })
if let idx = idx {
viewModel.selectedGroups.remove(at: idx)
} else {
viewModel.selectedGroups.append(group)
}
}
}
}
}
}
}
.listStyle(.grouped)
.listStyle(.grouped)
Spacer()
}
.alert(isPresented: $viewModel.showError) {
Alert(
title: Text(LocalText.recommendationError),
dismissButton: .cancel(Text(LocalText.genericOk)) {
viewModel.showError = false
}
)
}
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
.navigationBarItems(leading: Button(action: {
dismiss()
}, label: { Text(LocalText.cancelGeneric) }),
trailing: nextButton)
.task {
await viewModel.loadGroups(dataService: dataService)
Spacer()
}
.alert(isPresented: $viewModel.showError) {
Alert(
title: Text(LocalText.recommendationError),
dismissButton: .cancel(Text(LocalText.genericOk)) {
viewModel.showError = false
}
)
}
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
.navigationBarItems(leading: Button(action: {
dismiss()
}, label: { Text(LocalText.cancelGeneric) }),
trailing: nextButton)
.task {
await viewModel.loadGroups(dataService: dataService)
}
}
}
}
#endif

View file

@ -8,6 +8,8 @@ struct WebReader: PlatformViewRepresentable {
let item: LinkedItem
let articleContent: ArticleContent
let openLinkAction: (URL) -> Void
let tapHandler: () -> Void
let scrollPercentHandler: (Int) -> Void
let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void
let navBarVisibilityRatioUpdater: (Double) -> Void
@ -46,13 +48,14 @@ struct WebReader: PlatformViewRepresentable {
let webView = WebViewManager.shared()
let contentController = WKUserContentController()
webView.tapHandler = tapHandler
webView.navigationDelegate = context.coordinator
webView.configuration.userContentController = contentController
webView.configuration.userContentController.removeAllScriptMessageHandlers()
#if os(iOS)
webView.isOpaque = false
webView.backgroundColor = .clear
webView.backgroundColor = Color.isDarkMode ? .systemBackground : .white
webView.scrollView.delegate = context.coordinator
webView.scrollView.contentInset.top = readerViewNavBarHeight
webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight
@ -72,6 +75,7 @@ struct WebReader: PlatformViewRepresentable {
context.coordinator.linkHandler = openLinkAction
context.coordinator.webViewActionHandler = webViewActionHandler
context.coordinator.updateNavBarVisibilityRatio = navBarVisibilityRatioUpdater
context.coordinator.scrollPercentHandler = scrollPercentHandler
context.coordinator.updateShowBottomBar = { newValue in
self.showBottomBar = newValue
}
@ -98,9 +102,11 @@ struct WebReader: PlatformViewRepresentable {
(webView as? OmnivoreWebView)?.updateFontFamily()
(webView as? OmnivoreWebView)?.updateFontSize()
(webView as? OmnivoreWebView)?.updateTextContrast()
(webView as? OmnivoreWebView)?.updateAutoHighlightMode()
(webView as? OmnivoreWebView)?.updateMaxWidthPercentage()
(webView as? OmnivoreWebView)?.updateLineHeight()
(webView as? OmnivoreWebView)?.updateLabels(labelsJSON: item.labelsJSONString)
(webView as? OmnivoreWebView)?.updateTitle(title: item.title ?? "")
}
if showNavBarActionID != context.coordinator.previousShowNavBarActionID {
@ -149,6 +155,15 @@ struct WebReader: PlatformViewRepresentable {
}
}()
let enableHighlightOnRelease: Bool = {
let key = UserDefaultKey.enableHighlightOnRelease.rawValue
if UserDefaults.standard.object(forKey: key) != nil {
return UserDefaults.standard.bool(forKey: key)
} else {
return false
}
}()
let fontFamily = fontFamilyValue.flatMap { WebFont(rawValue: $0) } ?? .system
let htmlString = WebReaderContent(
@ -159,7 +174,8 @@ struct WebReader: PlatformViewRepresentable {
lineHeight: lineHeight(),
maxWidthPercentage: maxWidthPercentage(),
fontFamily: fontFamily,
prefersHighContrastText: prefersHighContrastText
prefersHighContrastText: prefersHighContrastText,
enableHighlightOnRelease: enableHighlightOnRelease
)
.styledContent

View file

@ -6,7 +6,7 @@ import Utils
import Views
import WebKit
// swiftlint:disable:next type_body_length
// swiftlint:disable file_length type_body_length
struct WebReaderContainerView: View {
let item: LinkedItem
@ -30,6 +30,7 @@ struct WebReaderContainerView: View {
@State private var errorAlertMessage: String?
@State private var showErrorAlertMessage = false
@State private var showRecommendSheet = false
@State private var lastScrollPercentage: Int?
@State var safariWebLink: SafariWebLink?
@State var displayLinkSheet = false
@ -56,6 +57,10 @@ struct WebReaderContainerView: View {
}
}
func scrollPercentHandler(percent: Int) {
lastScrollPercentage = percent
}
func onHighlightListViewDismissal() {
// Reload the web view if mutation happened in highlights list modal
guard hasPerformedHighlightMutations else { return }
@ -73,6 +78,14 @@ struct WebReaderContainerView: View {
}
}
private func tapHandler() {
withAnimation(.easeIn(duration: 0.08)) {
navBarVisibilityRatio = navBarVisibilityRatio == 1 ? 0 : 1
showBottomBar = navBarVisibilityRatio == 1
showNavBarActionID = UUID()
}
}
private func handleHighlightAction(message: WKScriptMessage) {
guard let messageBody = message.body as? [String: String] else { return }
guard let actionID = messageBody["actionID"] else { return }
@ -89,6 +102,12 @@ struct WebReaderContainerView: View {
case "setHighlightLabels":
annotation = messageBody["highlightID"] ?? ""
showHighlightLabelsModal = true
case "pageTapped":
withAnimation {
navBarVisibilityRatio = navBarVisibilityRatio == 1 ? 0 : 1
showBottomBar = navBarVisibilityRatio == 1
showNavBarActionID = UUID()
}
default:
break
}
@ -160,7 +179,7 @@ struct WebReaderContainerView: View {
Image(systemName: "sparkles")
}).frame(width: 48, height: 48)
// TODO: We don't have a single note function yet
// We don't have a single note function yet
// Divider()
//
// Button(action: addNote, label: {
@ -176,6 +195,7 @@ struct WebReaderContainerView: View {
viewModel.downloadAudio(audioController: audioController, item: item)
},
label: {
// swiftlint:disable:next line_length
Label(viewModel.isDownloadingAudio ? "Downloading Audio" : "Download Audio", systemImage: "icloud.and.arrow.down")
}
)
@ -249,7 +269,7 @@ struct WebReaderContainerView: View {
Image(systemName: "chevron.backward")
.font(.appNavbarIcon)
.foregroundColor(.appGrayTextContrast)
.padding(.horizontal)
.padding()
}
)
.scaleEffect(navBarVisibilityRatio)
@ -274,9 +294,13 @@ struct WebReaderContainerView: View {
},
label: {
#if os(iOS)
Image.profile
.padding(.horizontal)
Image(systemName: "ellipsis")
.resizable(resizingMode: Image.ResizingMode.stretch)
.aspectRatio(contentMode: .fit)
.foregroundColor(.appGrayTextContrast)
.frame(width: 20, height: 20)
.scaleEffect(navBarVisibilityRatio)
.padding()
#else
Text(LocalText.genericOptions)
#endif
@ -285,6 +309,9 @@ struct WebReaderContainerView: View {
#if os(macOS)
.frame(maxWidth: 100)
.padding(.trailing, 16)
#else
.padding(.trailing, 3)
.padding(.bottom, 10)
#endif
}
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
@ -309,14 +336,20 @@ struct WebReaderContainerView: View {
})
}
.sheet(isPresented: $showTitleEdit) {
LinkedItemMetadataEditView(item: item)
}
.sheet(isPresented: $showHighlightsView, onDismiss: onHighlightListViewDismissal) {
HighlightsListView(
itemObjectID: item.objectID,
hasHighlightMutations: $hasPerformedHighlightMutations
)
LinkedItemMetadataEditView(item: item, onSave: { title, _ in
item.title = title
// We dont need to update description because its never rendered in this view
readerSettingsChangedTransactionID = UUID()
})
}
#if os(iOS)
.sheet(isPresented: $showHighlightsView, onDismiss: onHighlightListViewDismissal) {
HighlightsListView(
itemObjectID: item.objectID,
hasHighlightMutations: $hasPerformedHighlightMutations
)
}
#endif
#if os(macOS)
.buttonStyle(PlainButtonStyle())
#endif
@ -349,6 +382,8 @@ struct WebReaderContainerView: View {
}
#endif
},
tapHandler: tapHandler,
scrollPercentHandler: scrollPercentHandler,
webViewActionHandler: webViewActionHandler,
navBarVisibilityRatioUpdater: {
navBarVisibilityRatio = $0
@ -361,11 +396,22 @@ struct WebReaderContainerView: View {
showBottomBar: $showBottomBar,
showHighlightAnnotationModal: $showHighlightAnnotationModal
)
.onTapGesture {
withAnimation {
navBarVisibilityRatio = 1
showNavBarActionID = UUID()
.onAppear {
if item.isUnread {
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0.1, anchorIndex: 0)
}
Task {
await audioController.preload(itemIDs: [item.unwrappedID])
}
}
.onDisappear {
// if let lastScrollPercentage = self.lastScrollPercentage {
// dataService.updateLinkReadingProgress(
// itemID: item.unwrappedID,
// readingProgress: Double(lastScrollPercentage),
// anchorIndex: 0
// )
// }
}
.confirmationDialog(linkToOpen?.absoluteString ?? "", isPresented: $displayLinkSheet) {
Button(action: {
@ -374,7 +420,11 @@ struct WebReaderContainerView: View {
}
}, label: { Text(LocalText.genericOpen) })
Button(action: {
UIPasteboard.general.string = item.unwrappedPageURLString
#if os(iOS)
UIPasteboard.general.string = item.unwrappedPageURLString
#else
// Pasteboard.general.string = item.unwrappedPageURLString TODO: fix for mac
#endif
showInSnackbar("Link Copied")
}, label: { Text(LocalText.readerCopyLink) })
Button(action: {
@ -394,18 +444,21 @@ struct WebReaderContainerView: View {
showErrorAlertMessage = false
})
}
.formSheet(isPresented: $showRecommendSheet) {
let highlightCount = item.highlights.asArray(of: Highlight.self).filter(\.createdByMe).count
NavigationView {
RecommendToView(
dataService: dataService,
viewModel: RecommendToViewModel(pageID: item.unwrappedID,
highlightCount: highlightCount)
)
}.onDisappear {
showRecommendSheet = false
#if os(iOS)
.formSheet(isPresented: $showRecommendSheet) {
let highlightCount = item.highlights.asArray(of: Highlight.self).filter(\.createdByMe).count
NavigationView {
RecommendToView(
dataService: dataService,
viewModel: RecommendToViewModel(pageID: item.unwrappedID,
highlightCount: highlightCount)
)
}.onDisappear {
showRecommendSheet = false
}
}
}
#endif
.sheet(isPresented: $showHighlightAnnotationModal) {
HighlightAnnotationSheet(
annotation: $annotation,
@ -481,9 +534,12 @@ struct WebReaderContainerView: View {
readerSettingsChangedTransactionID = UUID()
}
#endif
.onAppear {
try? WebViewManager.shared().dispatchEvent(.saveReadPosition)
}
.onDisappear {
// Clear the shared webview content when exiting
WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
// WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
WebViewManager.shared().loadHTMLString(WebReaderContent.emptyContent(isDark: Color.isDarkMode), baseURL: nil)
}
}
@ -514,10 +570,11 @@ struct WebReaderContainerView: View {
func scrollToTop() {}
func openOriginalURL(urlString: String?) {
if let urlString = urlString,
let url = URL(string: urlString)
{
openURL(url)
}
guard
let urlString = urlString,
let url = URL(string: urlString)
else { return }
openURL(url)
}
}

View file

@ -8,10 +8,12 @@ struct WebReaderContent {
let lineHeight: Int
let maxWidthPercentage: Int
let item: LinkedItem
let isDark: Bool
let themeKey: String
let fontFamily: WebFont
let articleContent: ArticleContent
let prefersHighContrastText: Bool
let enableHighlightOnRelease: Bool
init(
item: LinkedItem,
@ -21,16 +23,19 @@ struct WebReaderContent {
lineHeight: Int,
maxWidthPercentage: Int,
fontFamily: WebFont,
prefersHighContrastText: Bool
prefersHighContrastText: Bool,
enableHighlightOnRelease: Bool
) {
self.textFontSize = fontSize
self.lineHeight = lineHeight
self.maxWidthPercentage = maxWidthPercentage
self.item = item
self.themeKey = isDark ? "Gray" : "LightGray"
self.isDark = isDark
self.themeKey = isDark ? "Dark" : "Light"
self.fontFamily = fontFamily
self.articleContent = articleContent
self.prefersHighContrastText = prefersHighContrastText
self.enableHighlightOnRelease = enableHighlightOnRelease
}
// swiftlint:disable line_length
@ -46,7 +51,7 @@ struct WebReaderContent {
<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>
@import url("highlight\(themeKey == "Gray" ? "-dark" : "").css");
@import url("highlight\(isDark ? "-dark" : "").css");
</style>
</head>
<body>
@ -81,13 +86,80 @@ struct WebReaderContent {
recommendations: \(item.recommendationsJSONString),
}
window.themeKey = "\(themeKey)"
window.fontSize = \(textFontSize)
window.fontFamily = "\(fontFamily.rawValue)"
window.maxWidthPercentage = \(maxWidthPercentage)
window.lineHeight = \(lineHeight)
window.localStorage.setItem("theme", "\(themeKey)")
window.prefersHighContrastFont = \(prefersHighContrastText)
window.enableHighlightBar = \(isMacApp)
window.highlightOnRelease = \(enableHighlightOnRelease)
</script>
<script src="bundle.js"></script>
<script src="mathJaxConfiguration.js" id="MathJax-script"></script>
<script src="mathjax.js" id="MathJax-script"></script>
</body>
</html>
"""
}
// swiftlint:disable line_length function_body_length
static func emptyContent(isDark: Bool) -> String {
let themeKey = isDark ? "Dark" : "Light"
// let savedAt = "new Date(\(item.unwrappedSavedAt.timeIntervalSince1970 * 1000)).toISOString()"
// let createdAt = "new Date(\(item.unwrappedCreatedAt.timeIntervalSince1970 * 1000)).toISOString()"
// let publishedAt = item.publishDate != nil ? "new Date(\(item.publishDate!.timeIntervalSince1970 * 1000)).toISOString()" : "undefined"
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>
@import url("highlight\(isDark ? "-dark" : "").css");
</style>
</head>
<body>
<div id="root" />
<div id='_omnivore-htmlContent' style="display: none;">
</div>
<script type="text/javascript">
window.omnivoreEnv = {
"NEXT_PUBLIC_APP_ENV": "prod",
"NEXT_PUBLIC_BASE_URL": "unset",
"NEXT_PUBLIC_SERVER_BASE_URL": "unset",
"NEXT_PUBLIC_HIGHLIGHTS_BASE_URL": "unset"
}
window.omnivoreArticle = {
id: "-1",
linkId: "-1",
slug: "none",
createdAt: new Date(),
savedAt: new Date(),
publishedAt: new Date(),
url: ``,
title: ``,
content: document.getElementById('_omnivore-htmlContent').innerHTML,
originalArticleUrl: "",
contentReader: "WEB",
readingProgressPercent: 0,
readingProgressAnchorIndex: 0,
labels: [],
highlights: [],
recommendations: [],
}
window.fontSize = 0
window.fontFamily = "Inter"
window.maxWidthPercentage = 0
window.lineHeight = 1.25
window.themeKey = "\(themeKey)"
window.prefersHighContrastFont = true
window.enableHighlightBar = false
window.highlightOnRelease = false
</script>
<script src="bundle.js"></script>
<script src="mathJaxConfiguration.js" id="MathJax-script"></script>

View file

@ -13,6 +13,7 @@ typealias WKScriptMessageReplyHandler = (Any?, String?) -> Void
final class WebReaderCoordinator: NSObject {
var webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void = { _, _ in }
var linkHandler: (URL) -> Void = { _ in }
var scrollPercentHandler: ((Int) -> Void) = { _ in }
var needsReload = false
var lastSavedAnnotationID: UUID?
var previousReaderSettingsChangedUUID: UUID?
@ -128,6 +129,9 @@ extension WebReaderCoordinator: WKNavigationDelegate {
} else {
updateShowBottomBar(false)
}
let percent = Int(((yOffset + scrollView.visibleSize.height) / scrollView.contentSize.height) * 100)
scrollPercentHandler(max(0, min(percent, 100)))
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {

View file

@ -35,6 +35,7 @@ public struct WebReaderLoadingContainer: View {
let requestID: String
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@StateObject var viewModel = WebReaderLoadingContainerViewModel()
public var body: some View {

View file

@ -15,10 +15,6 @@ struct SafariWebLink: Identifiable {
@Published var isDownloadingAudio: Bool = false
@Published var audioDownloadTask: Task<Void, Error>?
deinit {
print("deinit WebReaderViewModel")
}
func hasOriginalUrl(_ item: LinkedItem) -> Bool {
if let pageURLString = item.pageURLString, let host = URL(string: pageURLString)?.host {
if host == "omnivore.app" {
@ -162,6 +158,7 @@ struct SafariWebLink: Identifiable {
let readingProgress = messageBody["readingProgressPercent"] as? Double
let anchorIndex = messageBody["readingProgressAnchorIndex"] as? Int
print("READING PROGRESS FROM JS: ", messageBody)
guard let itemID = itemID, let readingProgress = readingProgress, let anchorIndex = anchorIndex else {
replyHandler(["result": false], nil)
return
@ -195,10 +192,11 @@ struct SafariWebLink: Identifiable {
}
}
func setLabelsForHighlight(highlightID: String,
labelIDs: [String],
dataService: DataService)
{
func setLabelsForHighlight(
highlightID: String,
labelIDs: [String],
dataService: DataService
) {
dataService.setLabelsForHighlight(highlightID: highlightID, labelIDs: labelIDs)
}

View file

@ -33,10 +33,9 @@ struct WelcomeView: View {
}
var headlineText: some View {
Group {
Text(LocalText.welcomeTitle)
}
.font(.appLargeTitle)
Text(LocalText.welcomeTitle)
.font(.appLargeTitle)
.fixedSize(horizontal: false, vertical: true)
}
var headlineView: some View {
@ -76,6 +75,7 @@ struct WelcomeView: View {
+ Text(LocalText.privacyPolicyGeneric).underline()
}
.font(.appSubheadline)
.fixedSize(horizontal: false, vertical: true)
.confirmationDialog("", isPresented: $showTermsLinks, titleVisibility: .hidden) {
Button("View Terms of Service") {
showTermsModal = true
@ -225,18 +225,10 @@ struct WelcomeView: View {
} else {
VStack(alignment: .leading, spacing: containerSize.height < 500 ? 12 : 50) {
logoView
.padding(.bottom, 20)
headlineView
if containerSize.width > 500 {
authProviderButtonStack
} else {
HStack {
Spacer()
authProviderButtonStack
Spacer()
}
}
authProviderButtonStack
footerView
Spacer()
Button(
@ -249,7 +241,6 @@ struct WelcomeView: View {
.frame(maxWidth: .infinity, alignment: .center)
}
)
.padding(.vertical)
}
.padding()
.sheet(isPresented: $showEmailLoginModal) {
@ -258,11 +249,13 @@ struct WelcomeView: View {
.sheet(isPresented: $showDebugModal) {
DebugMenuView(selectedEnvironment: $selectedEnvironment)
}
.sheet(isPresented: $showAdvancedLogin) {
NavigationView {
SelfHostSettingsView()
#if os(iOS)
.sheet(isPresented: $showAdvancedLogin) {
NavigationView {
SelfHostSettingsView()
}
}
}
#endif
.alert(deletedAccountConfirmationMessage, isPresented: $authenticator.showAppleRevokeTokenAlert) {
Button("View Details") {
openURL(URL(string: "https://support.apple.com/en-us/HT210426")!)

View file

@ -58,7 +58,10 @@ public extension AppEnvironment {
case .test, .local:
return URL(string: "http://localhost:4000")!
case .custom:
guard let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.serverBaseURL.rawValue), let url = URL(string: str) else {
guard
let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.serverBaseURL.rawValue),
let url = URL(string: str)
else {
fatalError("custom serverBaseURL not set")
}
return url
@ -74,7 +77,10 @@ public extension AppEnvironment {
case .test, .local:
return URL(string: "http://localhost:3000")!
case .custom:
guard let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.webAppBaseURL.rawValue), let url = URL(string: str) else {
guard
let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.webAppBaseURL.rawValue),
let url = URL(string: str)
else {
fatalError("custom webAppBaseURL not set")
}
return url
@ -90,7 +96,10 @@ public extension AppEnvironment {
case .test, .local:
return URL(string: "http://localhost:4000")!
case .custom:
guard let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.ttsBaseURL.rawValue), let url = URL(string: str) else {
guard
let str = UserDefaults.standard.string(forKey: AppEnvironmentUserDefaultKey.ttsBaseURL.rawValue),
let url = URL(string: str)
else {
fatalError("custom ttsBaseURL not set")
}
return url

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="21G115" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="21G419" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Highlight" representedClassName="Highlight" syncable="YES" codeGenerationType="class">
<attribute name="annotation" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
@ -14,6 +14,7 @@
<attribute name="serverSyncStatus" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="shortId" attributeType="String"/>
<attribute name="suffix" optional="YES" attributeType="String"/>
<attribute name="type" optional="YES" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="createdBy" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="UserProfile"/>
<relationship name="labels" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItemLabel" inverseName="highlights" inverseEntity="LinkedItemLabel"/>
@ -56,6 +57,7 @@
<attribute name="tempPDFURL" optional="YES" attributeType="URI"/>
<attribute name="title" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="wordsCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="highlights" toMany="YES" deletionRule="Cascade" destinationEntity="Highlight" inverseName="linkedItem" inverseEntity="Highlight"/>
<relationship name="labels" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItemLabel" inverseName="linkedItems" inverseEntity="LinkedItemLabel"/>
<relationship name="recommendations" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Recommendation" inverseName="linkeditem" inverseEntity="Recommendation"/>

View file

@ -21,7 +21,7 @@ public class PersistentContainer: NSPersistentContainer {
#endif
let appGroupContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID)
let appGroupContainerURL = appGroupContainer?.appendingPathComponent("store.sqlite")
print("starting with sqlite container", appGroupContainerURL)
logger.debug("starting with sqlite container \(appGroupContainerURL?.absoluteString ?? "(nil)")")
container.persistentStoreDescriptions.first!.url = appGroupContainerURL

View file

@ -55,6 +55,7 @@ public struct JSONArticle: Decodable {
public let url: String
public let isArchived: Bool
public let language: String?
public let wordsCount: Int?
}
public extension LinkedItem {
@ -69,6 +70,14 @@ public extension LinkedItem {
(labels?.count ?? 0) > 0
}
var isUnread: Bool {
readingProgress <= 0
}
var isPartiallyRead: Bool {
Int(readingProgress) > 0
}
var isRead: Bool {
readingProgress >= 0.98
}
@ -146,12 +155,14 @@ public extension LinkedItem {
"userID": NSString(string: recommendation.user?.userID ?? ""),
"name": NSString(string: recommendation.user?.name ?? ""),
"username": NSString(string: recommendation.user?.username ?? ""),
"profileImageURL": recommendation.user?.profileImageURL == nil ? nil : NSString(string: recommendation.user?.profileImageURL ?? "")
"profileImageURL": recommendation.user?.profileImageURL as NSString? as Any
]),
"recommendedAt": recommendedAt == nil ? nil : NSString(string: recommendedAt!)
]
}
guard let JSON = (try? JSONSerialization.data(withJSONObject: recommendations, options: .prettyPrinted)) else { return "[]" }
guard let JSON = try? JSONSerialization.data(withJSONObject: recommendations, options: .prettyPrinted) else {
return "[]"
}
return String(data: JSON, encoding: .utf8) ?? "[]"
}
@ -198,6 +209,7 @@ public extension LinkedItem {
return item
}
// swiftlint:disable:next cyclomatic_complexity
func update(
inContext context: NSManagedObjectContext,
newReadingProgress: Double? = nil,

View file

@ -89,3 +89,87 @@ public extension LinkedItemFilter {
}
}
}
public enum FeaturedItemFilter: String, CaseIterable {
case continueReading
case recommended
case newsletters
case pinned
}
public extension FeaturedItemFilter {
var title: String {
switch self {
case .continueReading:
return "Continue Reading"
default:
return rawValue
}
}
var emptyMessage: String {
switch self {
case .continueReading:
return "Your recently read items will appear here."
case .pinned:
return "Create a label named Pinned and add it to items you'd like to appear here"
case .recommended:
return "Reads recommended in your Clubs will appear here."
case .newsletters:
return "All your Newsletters will appear here."
}
}
var predicate: NSPredicate {
let undeletedPredicate = NSPredicate(
format: "%K != %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
)
let notInArchivePredicate = NSPredicate(
format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber
)
switch self {
case .continueReading:
// Use > 1 instead of 0 so its only reads they have made slight progress on.
let continueReadingPredicate = NSPredicate(
format: "readingProgress > 1 AND readingProgress < 100 AND readAt != nil"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
continueReadingPredicate, undeletedPredicate, notInArchivePredicate
])
case .pinned:
let newsletterLabelPredicate = NSPredicate(
format: "SUBQUERY(labels, $label, $label.name == \"Pinned\").@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate
])
case .newsletters:
// non-archived or deleted items with the Newsletter label
let newsletterLabelPredicate = NSPredicate(
format: "SUBQUERY(labels, $label, $label.name == \"Newsletter\").@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate
])
case .recommended:
// non-archived or deleted items with the Newsletter label
let recommendedPredicate = NSPredicate(
format: "recommendations.@count > 0"
)
return NSCompoundPredicate(andPredicateWithSubpredicates: [
notInArchivePredicate, undeletedPredicate, recommendedPredicate
])
}
}
var sortDescriptor: NSSortDescriptor {
let savedAtSort = NSSortDescriptor(key: #keyPath(LinkedItem.savedAt), ascending: false)
switch self {
case .continueReading:
return NSSortDescriptor(key: #keyPath(LinkedItem.readAt), ascending: false)
default:
return savedAtSort
}
}
}

View file

@ -13,9 +13,10 @@ public struct HighlightData {
public let highlightText: String
public static func make(dict: NSDictionary?) -> HighlightData? {
if let dict = dict,
let highlightHTML = dict["highlightHTML"] as? String,
let highlightText = dict["highlightText"] as? String
if
let dict = dict,
let highlightHTML = dict["highlightHTML"] as? String,
let highlightText = dict["highlightText"] as? String
{
return HighlightData(highlightHTML: highlightHTML, highlightText: highlightText)
}

View file

@ -64,6 +64,7 @@
super.init()
self.voiceList = generateVoiceList()
self.realisticVoiceList = generateRealisticVoiceList()
self._currentLanguage = defaultLanguage
}
deinit {
@ -82,6 +83,17 @@
)
}
public var offsets: [Double]? {
if let durations = durations {
var currentSum = 0.0
return durations.map {
currentSum += $0
return currentSum
}
}
return nil
}
public func stop() {
let stoppedId = itemAudioProperties?.itemID
let stoppedTimeElapsed = timeElapsed
@ -143,26 +155,14 @@
}
public func preload(itemIDs: [String], retryCount _: Int = 0) async -> Bool {
if !preloadEnabled {
return true
}
for itemID in itemIDs {
if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) {
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document, speechAuthHeader: speechAuthHeader)
do {
try await synthesizer.preload()
return true
} catch {
print("error preloading audio file", error)
}
}
_ = try? await downloadSpeechFile(itemID: itemID, priority: .low)
}
return false
}
public func downloadForOffline(itemID: String) async -> Bool {
if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) {
if let document = try? await getSpeechFile(itemID: itemID, priority: .low) {
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document, speechAuthHeader: speechAuthHeader)
for item in synthesizer.createPlayerItems(from: 0) {
do {
@ -177,6 +177,17 @@
return false
}
public static func removeAudioFiles(itemID: String) {
do {
let audioDirectory = pathForAudioDirectory(itemID: itemID)
try FileManager.default.removeItem(at: audioDirectory)
} catch {
// We don't need to throw here, as its likely the
// directory just doesn't exist
print("Error removing audio files", error)
}
}
public var scrubState: PlayerScrubState = .reset {
didSet {
switch scrubState {
@ -423,7 +434,7 @@
if let itemID = itemAudioProperties?.itemID {
Task {
let document = try? await self.downloadSpeechFile(itemID: itemID, priority: .high)
let document = try? await self.getSpeechFile(itemID: itemID, priority: .high)
DispatchQueue.main.async {
if let document = document {
@ -531,14 +542,13 @@
itemID + "-" + currentVoice + ".mp3"
}
public func pathForAudioDirectory(itemID: String) -> URL {
FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
public static func pathForAudioDirectory(itemID: String) -> URL {
URL.om_documentsDirectory
.appendingPathComponent("audio-\(itemID)/")
}
public func pathForSpeechFile(itemID: String) -> URL {
pathForAudioDirectory(itemID: itemID)
Self.pathForAudioDirectory(itemID: itemID)
.appendingPathComponent("speech-\(currentVoice).json")
}
@ -548,7 +558,7 @@
if let itemID = itemAudioProperties?.itemID {
Task {
let document = try? await downloadSpeechFile(itemID: itemID, priority: .high)
let document = try? await getSpeechFile(itemID: itemID, priority: .high)
DispatchQueue.main.async {
self.setTextItems()
@ -868,18 +878,21 @@
let str = String(decoding: data, as: UTF8.self)
print("result speech file: ", str)
document = try? JSONDecoder().decode(SpeechDocument.self, from: data)
// Cache the file - if it exists
if let document = document {
if let document = try? JSONDecoder().decode(SpeechDocument.self, from: data) {
do {
try? FileManager.default.createDirectory(at: document.audioDirectory, withIntermediateDirectories: true)
try data.write(to: speechFileUrl)
return document
} catch {
print("error writing file", error)
}
}
return nil
}
func getSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? {
document = try await downloadSpeechFile(itemID: itemID, priority: priority)
return document
}

View file

@ -7,5 +7,11 @@
public func preload(itemIDs _: [String]) {}
public func downloadForOffline(itemID _: String) -> Bool { true }
func updateDuration(forItem _: SpeechItem, newDuration _: TimeInterval) {}
public func stopWithError() {}
public static func removeAudioFiles(itemID _: String) {}
}
#endif

View file

@ -12,7 +12,7 @@ import Models
// Somewhat based on: https://github.com/neekeetab/CachingPlayerItem/blob/master/CachingPlayerItem.swift
class SpeechPlayerItem: AVPlayerItem {
let resourceLoaderDelegate = ResourceLoaderDelegate()
let resourceLoaderDelegate = ResourceLoaderDelegate() // swiftlint:disable:this weak_delegate
let session: AudioController
let speechItem: SpeechItem
var speechMarks: [SpeechMark]?
@ -22,6 +22,7 @@ class SpeechPlayerItem: AVPlayerItem {
var observer: Any?
// swiftlint:disable:next line_length
init(session: AudioController, prefetchQueue: OperationQueue, speechItem: SpeechItem, completed: @escaping () -> Void) {
self.speechItem = speechItem
self.session = session
@ -84,9 +85,10 @@ class SpeechPlayerItem: AVPlayerItem {
var pendingRequests = Set<AVAssetResourceLoadingRequest>()
weak var owner: SpeechPlayerItem?
func resourceLoader(_: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool
{
func resourceLoader(
_: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
) -> Bool {
if owner == nil {
return true
}
@ -119,6 +121,7 @@ class SpeechPlayerItem: AVPlayerItem {
}
do {
// swiftlint:disable:next line_length
let speechData = try await SpeechSynthesizer.download(speechItem: speechItem, session: self.session ?? URLSession.shared)
DispatchQueue.main.async {
@ -172,6 +175,7 @@ class SpeechPlayerItem: AVPlayerItem {
_ = requestsFulfilled.map { self.pendingRequests.remove($0) }
}
// swiftlint:disable:next line_length
func fillInContentInformationRequest(_ contentInformationRequest: AVAssetResourceLoadingContentInformationRequest?) {
contentInformationRequest?.contentType = UTType.mp3.identifier

View file

@ -52,8 +52,11 @@ struct SpeechDocument: Decodable {
}
var audioDirectory: URL {
FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
Self.audioDirectory(pageId: pageId)
}
static func audioDirectory(pageId: String) -> URL {
URL.om_documentsDirectory
.appendingPathComponent("audio-\(pageId)")
}
}
@ -141,7 +144,7 @@ struct SpeechSynthesizer {
text: utterance.text)
result.append(item)
} else {
// TODO: How do we want to handle completely skipped paragraphs?
// How do we want to handle completely skipped paragraphs?
}
}
@ -170,13 +173,13 @@ struct SpeechSynthesizer {
static func downloadData(session: URLSession, request: URLRequest) async throws -> Data {
do {
let result: (Data, URLResponse)? = try await session.data(for: request)
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
print("error: ", result?.1)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
print("error: ", response)
throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.")
}
guard let data = result?.0 else {
guard !data.isEmpty else {
throw BasicError.message(messageText: "audioFetch failed. no data received.")
}
@ -190,10 +193,11 @@ struct SpeechSynthesizer {
}
}
static func download(speechItem: SpeechItem,
redownloadCached: Bool = false,
session: URLSession = URLSession.shared) async throws -> SynthesizeData?
{
static func download(
speechItem: SpeechItem,
redownloadCached: Bool = false,
session: URLSession = URLSession.shared
) async throws -> SynthesizeData? {
let decoder = JSONDecoder()
if !redownloadCached {
@ -208,12 +212,10 @@ struct SpeechSynthesizer {
let data = try await downloadData(session: session, request: speechItem.urlRequest)
let tempPath = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let tempPath = URL.om_cachesDirectory
.appendingPathComponent(UUID().uuidString + ".mp3")
let tempSMPath = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let tempSMPath = URL.om_cachesDirectory
.appendingPathComponent(UUID().uuidString + ".speechMarks")
do {

View file

@ -31,8 +31,10 @@ public enum VoiceCategory: String, CaseIterable {
case enUK = "English (UK)"
case deDE = "German (Germany)"
case hiIN = "Hindi (India)"
case itIT = "Italian (Italy)"
case esES = "Spanish (Spain)"
case jaJP = "Japanese (Japan)"
case ptBR = "Portuguese (Brazil)"
case taIN = "Tamil (India)"
case taLK = "Tamil (Sri Lanka)"
case taMY = "Tamil (Malaysia)"
@ -51,6 +53,7 @@ public struct VoicePair {
let category: VoiceCategory
}
// swiftlint:disable line_length
public enum Voices {
public static func isUltraRealisticVoice(_ voiceKey: String) -> Bool {
UltraPairs.contains(where: { voice in
@ -68,12 +71,13 @@ public enum Voices {
VoiceLanguage(key: "zh", name: "Chinese", defaultVoice: "zh-CN-XiaochenNeural", categories: [.zhCN]),
VoiceLanguage(key: "de", name: "German", defaultVoice: "de-CH-JanNeural", categories: [.deDE]),
VoiceLanguage(key: "hi", name: "Hindi", defaultVoice: "hi-IN-MadhurNeural", categories: [.hiIN]),
VoiceLanguage(key: "it", name: "Italian", defaultVoice: "it-IT-BenignoNeural", categories: [.itIT]),
VoiceLanguage(key: "ja", name: "Japanese", defaultVoice: "ja-JP-NanamiNeural", categories: [.jaJP]),
VoiceLanguage(key: "es", name: "Spanish", defaultVoice: "es-ES-AlvaroNeural", categories: [.esES]),
VoiceLanguage(key: "pt", name: "Portuguese", defaultVoice: "pt-BR-AntonioNeural", categories: [.ptBR]),
VoiceLanguage(key: "ta", name: "Tamil", defaultVoice: "ta-IN-PallaviNeural", categories: [.taIN, .taLK, .taMY, .taSG])
]
// swiftlint:disable all
public static let Pairs = [
VoicePair(firstKey: "en-US-JennyNeural", secondKey: "en-US-BrandonNeural", firstName: "Jenny", secondName: "Brandon", language: "en-US", category: .enUS),
VoicePair(firstKey: "en-US-CoraNeural", secondKey: "en-US-ChristopherNeural", firstName: "Cora", secondName: "Christopher", language: "en-US", category: .enUS),
@ -92,25 +96,17 @@ public enum Voices {
VoicePair(firstKey: "de-DE-ChristophNeural", secondKey: "de-DE-LouisaNeural", firstName: "Christoph", secondName: "Louisa", language: "de-DE", category: .deDE),
VoicePair(firstKey: "ja-JP-NanamiNeural", secondKey: "ja-JP-KeitaNeural", firstName: "Nanami", secondName: "Keita", language: "ja-JP", category: .jaJP),
VoicePair(firstKey: "hi-IN-MadhurNeural", secondKey: "hi-IN-SwaraNeural", firstName: "Madhur", secondName: "Swara", language: "hi-IN", category: .hiIN),
VoicePair(firstKey: "pt-BR-AntonioNeural", secondKey: "pt-BR-BrendaNeural", firstName: "Antonio", secondName: "Brenda", language: "pt-BR", category: .ptBR),
VoicePair(firstKey: "ta-IN-PallaviNeural", secondKey: "ta-IN-ValluvarNeural", firstName: "Pallavi", secondName: "Valluvar", language: "ta-IN", category: .taIN),
VoicePair(firstKey: "ta-LK-KumarNeural", secondKey: "ta-LK-SaranyaNeural", firstName: "Kumar", secondName: "Saranya", language: "ta-LK", category: .taLK),
VoicePair(firstKey: "ta-MY-KaniNeural", secondKey: "ta-MY-SuryaNeural", firstName: "Kani", secondName: "Surya", language: "ta-MY", category: .taMY),
VoicePair(firstKey: "ta-SG-AnbuNeural", secondKey: "ta-SG-VenbaNeural", firstName: "Anbu", secondName: "Venba", language: "ta-SG", category: .taSG)
VoicePair(firstKey: "ta-SG-AnbuNeural", secondKey: "ta-SG-VenbaNeural", firstName: "Anbu", secondName: "Venba", language: "ta-SG", category: .taSG),
VoicePair(firstKey: "it-IT-BenignoNeural", secondKey: "it-IT-IsabellaNeural", firstName: "Benigno", secondName: "Isabella", language: "it-IT", category: .itIT)
]
public static let UltraPairs = [
VoicePair(firstKey: "Larry", secondKey: "Susan", firstName: "Larry", secondName: "Susan", language: "en-US", category: .enUS),
VoicePair(firstKey: "Jordan", secondKey: "William", firstName: "Jordan", secondName: "William", language: "en-US", category: .enUS),
VoicePair(firstKey: "Evelyn", secondKey: "Axel", firstName: "Evelyn", secondName: "Axel", language: "en-US", category: .enUS),
VoicePair(firstKey: "Nova", secondKey: "Owen", firstName: "Nova", secondName: "Owen", language: "en-US", category: .enUS),
VoicePair(firstKey: "Frankie", secondKey: "Natalie", firstName: "Frankie", secondName: "Natalie", language: "en-US", category: .enUS),
VoicePair(firstKey: "Daniel", secondKey: "Charlotte", firstName: "Daniel", secondName: "Charlotte", language: "en-CA", category: .enCA),
VoicePair(firstKey: "Lillian", secondKey: "Aurora", firstName: "Lillian", secondName: "Aurora", language: "en-UK", category: .enUK),
VoicePair(firstKey: "Oliver", secondKey: "Arthur", firstName: "Oliver", secondName: "Arthur", language: "en-UK", category: .enUK),
VoicePair(firstKey: "Frederick", secondKey: "Hunter", firstName: "Frederick", secondName: "Hunter", language: "en-UK", category: .enUK),
VoicePair(firstKey: "Nolan", secondKey: "Phoebe", firstName: "Nolan", secondName: "Phoebe", language: "en-UK", category: .enUK),
VoicePair(firstKey: "Daisy", secondKey: "Stella", firstName: "Daisy", secondName: "Stella", language: "en-UK", category: .enUK)
VoicePair(firstKey: "Josh", secondKey: "Bella", firstName: "Josh", secondName: "Bella", language: "en-US", category: .enUS)
// VoicePair(firstKey: "Arnold", secondKey: "Bella", firstName: "Arnold", secondName: "Bella", language: "en-US", category: .enUS),
// VoicePair(firstKey: "Adam", secondKey: "Elli", firstName: "Adam", secondName: "Elli", language: "en-US", category: .enUS)
]
}

View file

@ -38,7 +38,7 @@ public final class Authenticator: ObservableObject {
}
public func logout(dataService: DataService, isAccountDeletion: Bool = false) {
dataService.resetCoreData()
dataService.resetLocalStorage()
clearCreds()
Authenticator.unregisterIntercomUser?()
isLoggedIn = false

View file

@ -29,19 +29,34 @@ public final class DataService: ObservableObject {
persistentContainer.viewContext
}
@AppStorage(UserDefaultKey.lastItemSyncTime.rawValue) public var lastItemSyncTime = DateFormatter.formatterISO8601.string(
from: Date(timeIntervalSinceReferenceDate: 0)
)
public var lastItemSyncTime: Date {
get {
guard
let str = UserDefaults.standard.string(forKey: UserDefaultKey.lastItemSyncTime.rawValue),
let date = DateFormatter.formatterISO8601.date(from: str)
else {
return Date(timeIntervalSinceReferenceDate: 0)
}
return date
}
set {
logger.trace("last item sync updated to \(newValue)")
let str = DateFormatter.formatterISO8601.string(from: newValue)
UserDefaults.standard.set(str, forKey: UserDefaultKey.lastItemSyncTime.rawValue)
}
}
public init(appEnvironment: AppEnvironment, networker: Networker) {
self.appEnvironment = appEnvironment
self.networker = networker
self.persistentContainer = PersistentContainer.make()
self.backgroundContext = persistentContainer.newBackgroundContext()
backgroundContext.automaticallyMergesChangesFromParent = true
backgroundContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
if isFirstTimeRunningNewAppBuild() {
resetCoreData()
resetLocalStorage()
} else {
persistentContainer.loadPersistentStores { _, error in
if let error = error {
@ -100,10 +115,58 @@ public final class DataService: ObservableObject {
}
}
public func resetCoreData() {
lastItemSyncTime = DateFormatter.formatterISO8601.string(from: Date(timeIntervalSinceReferenceDate: 0))
private func clearDownloadedFiles() {
let relevantTypes = ["pdf", "mp3", "speechMarks"]
let fileMgr = FileManager()
logger.trace("removing cached downloads")
// clear the temporary files in the caches directory
if let cacheFileURLs = try? fileMgr.contentsOfDirectory(
at: URL.om_cachesDirectory,
includingPropertiesForKeys: .none,
options: .skipsHiddenFiles
) {
logger.trace("\(cacheFileURLs.count) file URLs in caches directory")
for fileURL in cacheFileURLs where relevantTypes.contains(fileURL.pathExtension) {
logger.trace("removing \(fileURL.absoluteString)")
try? fileMgr.removeItem(at: fileURL)
}
}
// and also the copies written to Documents
let resourceKeys = Set<URLResourceKey>([.nameKey, .isDirectoryKey])
if let documentsFileURLs = try? fileMgr.contentsOfDirectory(
at: URL.om_documentsDirectory,
includingPropertiesForKeys: Array(resourceKeys),
options: .skipsHiddenFiles
) {
logger.trace("\(documentsFileURLs.count) file URLs in documents directory")
for fileURL in documentsFileURLs {
guard
let resourceValues = try? fileURL.resourceValues(forKeys: resourceKeys),
let isDirectory = resourceValues.isDirectory,
let name = resourceValues.name
else {
continue
}
if isDirectory {
if name.hasPrefix("audio-") {
logger.trace("removing \(fileURL.absoluteString)")
try? fileMgr.removeItem(at: fileURL)
}
} else if relevantTypes.contains(fileURL.pathExtension) {
logger.trace("removing \(fileURL.absoluteString)")
try? fileMgr.removeItem(at: fileURL)
}
}
}
}
public func resetLocalStorage() {
lastItemSyncTime = Date(timeIntervalSinceReferenceDate: 0)
clearCoreData()
clearDownloadedFiles()
persistentContainer = PersistentContainer.make()
persistentContainer.loadPersistentStores { _, error in

File diff suppressed because it is too large Load diff

View file

@ -12,11 +12,11 @@ extension DataService {
linkedItem.update(inContext: self.backgroundContext, newIsArchivedValue: archived)
// Send update to server
self.syncLinkArchiveStatus(itemID: linkedItem.unwrappedID, objectID: objectID, archived: archived)
self.syncLinkArchiveStatus(itemID: linkedItem.unwrappedID, archived: archived)
}
}
func syncLinkArchiveStatus(itemID: String, objectID: NSManagedObjectID, archived: Bool) {
func syncLinkArchiveStatus(itemID: String, archived: Bool) {
enum MutationResult {
case success(linkId: String)
case error(errorCode: Enums.ArchiveLinkErrorCode)
@ -48,7 +48,7 @@ extension DataService {
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
context.perform {
guard let linkedItem = context.object(with: objectID) as? LinkedItem else { return }
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
do {

View file

@ -4,6 +4,7 @@ import Models
import SwiftGraphQL
extension DataService {
// swiftlint:disable:next function_parameter_count
public func createHighlight(
shortId: String,
highlightID: String,
@ -16,6 +17,7 @@ extension DataService {
) -> [String: Any]? {
let internalHighlight = InternalHighlight(
id: highlightID,
type: "HIGHLIGHT",
shortId: shortId,
quote: quote,
prefix: nil, suffix: nil,
@ -60,9 +62,10 @@ extension DataService {
articleId: articleId,
highlightPositionAnchorIndex: OptionalArgument(highlight.positionAnchorIndex),
highlightPositionPercent: OptionalArgument(highlight.positionPercent), id: highlight.id,
patch: highlight.patch,
quote: highlight.quote,
shortId: highlight.shortId
patch: OptionalArgument(highlight.patch),
quote: OptionalArgument(highlight.quote),
shortId: highlight.shortId,
type: OptionalArgument(Enums.HighlightType.highlight)
),
selection: selection
)

View file

@ -4,6 +4,7 @@ import Models
import SwiftGraphQL
public extension DataService {
// swiftlint:disable:next line_length
func createRecommendationGroup(name: String, onlyAdminCanPost: Bool, onlyAdminCanSeeMembers: Bool) async throws -> InternalRecommendationGroup {
enum MutationResult {
case saved(recommendationGroup: InternalRecommendationGroup)

View file

@ -5,34 +5,24 @@ import SwiftGraphQL
public extension DataService {
func deleteHighlight(highlightID: String) {
if let highlight = Highlight.lookup(byID: highlightID, inContext: viewContext) {
deleteHighlight(objectID: highlight.objectID)
}
}
private func deleteHighlight(objectID: NSManagedObjectID) {
// Update CoreData
viewContext.performAndWait {
guard let highlight = viewContext.object(with: objectID) as? Highlight else { return }
// Update CoreData so view updates immediately
viewContext.perform {
guard let highlight = Highlight.lookup(byID: highlightID, inContext: self.viewContext) else { return }
highlight.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
do {
try viewContext.save()
try self.viewContext.save()
logger.debug("Highlight succesfully marked for deletion")
} catch {
viewContext.rollback()
self.viewContext.rollback()
logger.debug("Failed to mark Highlight for deletion: \(error.localizedDescription)")
}
}
// Send update to server
backgroundContext.perform { [weak self] in
guard let highlight = self?.backgroundContext.object(with: objectID) as? Highlight else { return }
self?.syncHighlightDeletion(highlightID: highlight.unwrappedID, objectID: objectID)
self.syncHighlightDeletion(highlightID: highlightID)
}
}
internal func syncHighlightDeletion(highlightID: String, objectID: NSManagedObjectID) {
func syncHighlightDeletion(highlightID: String) {
enum MutationResult {
case saved(id: String)
case error(errorCode: Enums.DeleteHighlightErrorCode)
@ -63,7 +53,7 @@ public extension DataService {
let isSyncSuccess = data != nil
context.perform {
guard let highlight = context.object(with: objectID) as? Highlight else { return }
guard let highlight = Highlight.lookup(byID: highlightID, inContext: context) else { return }
if isSyncSuccess {
highlight.remove(inContext: context)

View file

@ -17,6 +17,7 @@ extension DataService {
) -> [String: Any]? {
let internalHighlight = InternalHighlight(
id: highlightID,
type: "HIGHLIGHT",
shortId: shortId,
quote: quote,
prefix: nil,
@ -39,7 +40,7 @@ extension DataService {
)
// Send update to server
syncHighlightCreation(highlight: internalHighlight, articleId: articleId)
syncHighlightMerge(highlight: internalHighlight, articleId: articleId, overlapHighlightIdList: overlapHighlightIdList)
return internalHighlight.encoded()
}
@ -100,6 +101,7 @@ extension DataService {
if let highlightObject = (try? context.fetch(fetchRequest))?.first {
if isSyncSuccess {
highlightObject.remove(inContext: context)
return
} else {
highlightObject.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
}

View file

@ -22,7 +22,9 @@ public extension DataService {
case error(errorCode: Enums.OptInFeatureErrorCode)
}
let featureSelection = Selection.Feature { Feature(name: try $0.name(), token: try $0.token(), granted: try $0.grantedAt() != nil) }
let featureSelection = Selection.Feature {
Feature(name: try $0.name(), token: try $0.token(), granted: try $0.grantedAt() != nil)
}
let selection = Selection<MutationResult, Unions.OptInFeatureResult> {
try $0.on(
optInFeatureError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) },

View file

@ -21,7 +21,10 @@ public extension DataService {
let mutation = Selection.Mutation {
try $0.recommend(
input: .init(groupIds: groupIDs, note: OptionalArgument(note), pageId: pageID, recommendedWithHighlights: OptionalArgument(withHighlights)),
input: .init(groupIds: groupIDs,
note: OptionalArgument(note),
pageId: pageID,
recommendedWithHighlights: OptionalArgument(withHighlights)),
selection: selection
)
}

View file

@ -57,14 +57,14 @@ extension DataService {
label.remove(inContext: context)
} else {
label.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
}
do {
try context.save()
logger.debug("LinkedItem deleted succesfully")
} catch {
context.rollback()
logger.debug("Failed to delete LinkedItem: \(error.localizedDescription)")
do {
try context.save()
logger.debug("LinkedItem deleted succesfully")
} catch {
context.rollback()
logger.debug("Failed to delete LinkedItem: \(error.localizedDescription)")
}
}
}
}

View file

@ -5,28 +5,41 @@ import SwiftGraphQL
public extension DataService {
func removeLink(objectID: NSManagedObjectID) {
// Update CoreData
viewContext.performAndWait {
guard let linkedItem = viewContext.object(with: objectID) as? LinkedItem else { return }
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
// First try to get the item synchronously, this is used later to delete files
// Then we can async update core data and make the API call to sync the deletion
do {
try viewContext.save()
logger.debug("LinkedItem succesfully marked for deletion")
} catch {
viewContext.rollback()
logger.debug("Failed to mark LinkedItem for deletion: \(error.localizedDescription)")
}
var linkedItemID: String?
viewContext.performAndWait {
guard let linkedItem = self.viewContext.object(with: objectID) as? LinkedItem else { return }
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
linkedItemID = linkedItem.id
}
// Send update to server
backgroundContext.perform { [weak self] in
guard let linkedItem = self?.backgroundContext.object(with: objectID) as? LinkedItem else { return }
self?.syncLinkDeletion(itemID: linkedItem.unwrappedID, objectID: objectID)
viewContext.perform {
guard let linkedItem = self.viewContext.object(with: objectID) as? LinkedItem else { return }
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
linkedItemID = linkedItem.id
do {
try self.viewContext.save()
logger.debug("LinkedItem succesfully marked for deletion")
} catch {
self.viewContext.rollback()
logger.debug("Failed to mark LinkedItem for deletion: \(error.localizedDescription)")
}
// Send update to server
self.syncLinkDeletion(itemID: linkedItem.unwrappedID)
}
if let linkedItemID = linkedItemID {
Task {
AudioController.removeAudioFiles(itemID: linkedItemID)
}
}
}
func syncLinkDeletion(itemID: String, objectID: NSManagedObjectID) {
func syncLinkDeletion(itemID: String) {
enum MutationResult {
case success(linkId: String)
case error(errorCode: Enums.SetBookmarkArticleErrorCode)
@ -64,20 +77,20 @@ public extension DataService {
let isSyncSuccess = data != nil
context.perform {
guard let linkedItem = context.object(with: objectID) as? LinkedItem else { return }
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
if isSyncSuccess {
linkedItem.remove(inContext: context)
} else {
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
}
do {
try context.save()
logger.debug("LinkedItem deleted succesfully")
} catch {
context.rollback()
logger.debug("Failed to delete LinkedItem: \(error.localizedDescription)")
do {
try context.save()
logger.debug("LinkedItem deleted succesfully")
} catch {
context.rollback()
logger.debug("Failed to delete LinkedItem: \(error.localizedDescription)")
}
}
}
}

View file

@ -9,6 +9,11 @@ extension DataService {
guard let self = self else { return }
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
if readingProgress != 0, readingProgress < linkedItem.readingProgress {
return
}
print("updating reading progress: ", readingProgress, anchorIndex)
linkedItem.update(
inContext: self.backgroundContext,
newReadingProgress: readingProgress,
@ -18,14 +23,13 @@ extension DataService {
// Send update to server
self.syncLinkReadingProgress(
itemID: linkedItem.unwrappedID,
objectID: linkedItem.objectID,
readingProgress: readingProgress,
anchorIndex: anchorIndex
)
}
}
func syncLinkReadingProgress(itemID: String, objectID: NSManagedObjectID, readingProgress: Double, anchorIndex: Int) {
func syncLinkReadingProgress(itemID: String, readingProgress: Double, anchorIndex: Int) {
enum MutationResult {
case saved(readAt: Date?)
case error(errorCode: Enums.SaveArticleReadingProgressErrorCode)
@ -62,7 +66,7 @@ extension DataService {
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
context.perform {
guard let linkedItem = context.object(with: objectID) as? LinkedItem else { return }
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
if let mutationResult = data?.data, case let MutationResult.saved(readAt) = mutationResult {
linkedItem.readAt = readAt

View file

@ -4,26 +4,19 @@ import Models
import SwiftGraphQL
extension DataService {
public func updateHighlightAttributes(
highlightID: String,
annotation: String
) {
public func updateHighlightAttributes(highlightID: String, annotation: String) {
guard let highlight = Highlight.lookup(byID: highlightID, inContext: backgroundContext) else { return }
backgroundContext.perform { [weak self] in
guard let self = self else { return }
guard let highlight = Highlight.lookup(byID: highlightID, inContext: self.backgroundContext) else { return }
highlight.update(inContext: self.backgroundContext, newAnnotation: annotation)
// Send update to server
self.syncHighlightAttributes(
highlightID: highlightID,
objectID: highlight.objectID,
annotation: annotation
)
}
// Send update to server
syncHighlightAttributes(highlightID: highlightID, annotation: annotation)
}
func syncHighlightAttributes(highlightID: String, objectID: NSManagedObjectID, annotation: String) {
func syncHighlightAttributes(highlightID: String, annotation: String) {
enum MutationResult {
case saved(highlight: InternalHighlight)
case error(errorCode: Enums.UpdateHighlightErrorCode)
@ -58,7 +51,7 @@ extension DataService {
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
context.perform {
guard let highlight = context.object(with: objectID) as? Highlight else { return }
guard let highlight = Highlight.lookup(byID: highlightID, inContext: context) else { return }
highlight.serverSyncStatus = Int64(syncStatus.rawValue)
do {

View file

@ -19,7 +19,6 @@ extension DataService {
// Send update to server
self.syncLinkedItemTitleAndDescription(
itemID: itemID,
objectID: linkedItem.objectID,
title: title,
author: author,
description: description
@ -29,7 +28,6 @@ extension DataService {
func syncLinkedItemTitleAndDescription(
itemID: String,
objectID: NSManagedObjectID,
title: String,
author: String?,
description: String
@ -50,7 +48,10 @@ extension DataService {
let mutation = Selection.Mutation {
try $0.updatePage(
input: .init(byline: OptionalArgument(author), description: OptionalArgument(description), pageId: itemID, title: OptionalArgument(title)),
input: .init(byline: OptionalArgument(author),
description: OptionalArgument(description),
pageId: itemID,
title: OptionalArgument(title)),
selection: selection
)
}
@ -64,7 +65,7 @@ extension DataService {
let syncStatus: ServerSyncStatus = data == nil ? .needsUpdate : .isNSync
context.perform {
guard let linkedItem = context.object(with: objectID) as? LinkedItem else { return }
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: context) else { return }
linkedItem.serverSyncStatus = Int64(syncStatus.rawValue)
do {

View file

@ -147,13 +147,12 @@ public extension DataService {
break
case .needsDeletion:
item.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
syncLinkDeletion(itemID: item.unwrappedID, objectID: item.objectID)
syncLinkDeletion(itemID: item.unwrappedID)
case .needsUpdate:
item.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
syncLinkArchiveStatus(itemID: item.unwrappedID, objectID: item.objectID, archived: item.isArchived)
syncLinkArchiveStatus(itemID: item.unwrappedID, archived: item.isArchived)
syncLinkReadingProgress(
itemID: item.unwrappedID,
objectID: item.objectID,
readingProgress: item.readingProgress,
anchorIndex: Int(item.readingProgressAnchor)
)
@ -176,15 +175,11 @@ public extension DataService {
)
case .needsDeletion:
highlight.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
syncHighlightDeletion(highlightID: highlight.unwrappedID, objectID: highlight.objectID)
syncHighlightDeletion(highlightID: highlight.unwrappedID)
case .needsUpdate:
if let annotation = highlight.annotation {
highlight.serverSyncStatus = Int64(ServerSyncStatus.isSyncing.rawValue)
syncHighlightAttributes(
highlightID: highlight.unwrappedID,
objectID: highlight.objectID,
annotation: annotation
)
syncHighlightAttributes(highlightID: highlight.unwrappedID, annotation: annotation)
} else {
highlight.serverSyncStatus = Int64(ServerSyncStatus.isNSync.rawValue)
}

View file

@ -93,7 +93,7 @@ public extension DataService {
}
}
DispatchQueue.main.sync {
self.lastItemSyncTime = DateFormatter.formatterISO8601.string(from: Date.now)
self.lastItemSyncTime = Date.now
onComplete()
}
}

View file

@ -21,8 +21,7 @@ public extension DataService {
var localPdfURL: URL?
let tempPath = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
let tempPath = URL.om_cachesDirectory
.appendingPathComponent(UUID().uuidString + ".pdf")
try await backgroundContext.perform { [weak self] in

View file

@ -44,6 +44,7 @@ extension DataService {
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
language: try $0.language(),
wordsCount: try $0.wordsCount(),
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
),

View file

@ -34,7 +34,6 @@ public extension DataService {
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
let context = backgroundContext
return try await withCheckedThrowingContinuation { continuation in
send(query, to: path, headers: headers) { queryResult in

View file

@ -68,7 +68,8 @@ extension DataService {
)
}
let sort = InputObjects.SortParams(by: Enums.SortBy.updatedTime, order: OptionalArgument(descending ? Enums.SortOrder.descending : Enums.SortOrder.ascending))
let sort = InputObjects.SortParams(by: .updatedTime,
order: OptionalArgument(descending ? .descending : .ascending))
let query = Selection.Query {
try $0.updatesSince(
@ -275,6 +276,7 @@ private let libraryArticleSelection = Selection.Article {
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
language: try $0.language(),
wordsCount: try $0.wordsCount(),
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
)
@ -313,6 +315,7 @@ private let searchItemSelection = Selection.SearchItem {
contentReader: try $0.contentReader().rawValue,
originalHtml: nil,
language: try $0.language(),
wordsCount: try $0.wordsCount(),
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
)

View file

@ -12,7 +12,6 @@ public struct TypeaheadSearchItem: Identifiable {
}
public extension DataService {
// swiftlint:disable:next function_body_length
func typeaheadSearch(searchTerm: String) async throws -> [TypeaheadSearchItem] {
enum QueryResult {
case success(result: [TypeaheadSearchItem])

View file

@ -14,11 +14,12 @@ let highlightLabelSelection = Selection.Label {
let highlightSelection = Selection.Highlight {
InternalHighlight(
id: try $0.id(),
type: (try $0.type()).rawValue,
shortId: try $0.shortId(),
quote: try $0.quote(),
quote: try $0.quote() ?? "",
prefix: try $0.prefix(),
suffix: try $0.suffix(),
patch: try $0.patch(),
patch: try $0.patch() ?? "",
annotation: try $0.annotation(),
createdAt: try $0.createdAt().value,
updatedAt: try $0.updatedAt().value,

View file

@ -4,6 +4,7 @@ import Models
struct InternalHighlight: Encodable {
let id: String
let type: String
let shortId: String
let quote: String
let prefix: String?
@ -28,6 +29,7 @@ struct InternalHighlight: Encodable {
highlight.markedForDeletion = false
highlight.id = id
highlight.type = type
highlight.shortId = shortId
highlight.quote = quote
highlight.prefix = prefix
@ -60,6 +62,7 @@ struct InternalHighlight: Encodable {
static func make(from highlight: Highlight) -> InternalHighlight {
InternalHighlight(
id: highlight.id ?? "",
type: highlight.type ?? "",
shortId: highlight.shortId ?? "",
quote: highlight.quote ?? "",
prefix: highlight.prefix,

View file

@ -26,6 +26,7 @@ struct InternalLinkedItem {
let contentReader: String?
let originalHtml: String?
let language: String?
let wordsCount: Int?
let recommendations: [InternalRecommendation]
var labels: [InternalLinkedItemLabel]
@ -63,6 +64,7 @@ struct InternalLinkedItem {
linkedItem.contentReader = contentReader
linkedItem.originalHtml = originalHtml
linkedItem.language = language
linkedItem.wordsCount = Int64(wordsCount ?? 0)
// Remove existing labels in case a label had been deleted
if let existingLabels = linkedItem.labels {
@ -142,7 +144,8 @@ extension JSONArticle {
contentReader: contentReader,
originalHtml: nil,
language: language,
recommendations: [], // TODO:
wordsCount: wordsCount,
recommendations: [],
labels: []
)

View file

@ -22,10 +22,11 @@ public struct InternalRecommendation {
public static func make(_ recommendations: NSSet?) -> [InternalRecommendation] {
recommendations?
.compactMap { recommendation in
if let recommendation = recommendation as? Recommendation,
let groupID = recommendation.groupID,
let name = recommendation.name,
let recommendedAt = recommendation.recommendedAt
if
let recommendation = recommendation as? Recommendation,
let groupID = recommendation.groupID,
let name = recommendation.name,
let recommendedAt = recommendation.recommendedAt
{
return InternalRecommendation(
groupID: groupID,

View file

@ -36,9 +36,10 @@ public struct InternalRecommendationGroup: Identifiable {
}
public static func make(from recommendationGroup: RecommendationGroup) -> InternalRecommendationGroup? {
if let id = recommendationGroup.id,
let name = recommendationGroup.name,
let inviteUrl = recommendationGroup.inviteUrl
if
let id = recommendationGroup.id,
let name = recommendationGroup.name,
let inviteUrl = recommendationGroup.inviteUrl
{
return InternalRecommendationGroup(
id: id,

View file

@ -43,37 +43,39 @@ public struct InternalUserProfile: Identifiable, Encodable {
}
public static func makeSingle(_ user: UserProfile?) -> InternalUserProfile? {
if let user = user,
let userID = user.userID,
let name = user.name,
let username = user.username
{
return InternalUserProfile(
userID: userID,
name: name,
username: username,
profileImageURL: user.profileImageURL
)
guard
let user = user,
let userID = user.userID,
let name = user.name,
let username = user.username
else {
return nil
}
return nil
return InternalUserProfile(
userID: userID,
name: name,
username: username,
profileImageURL: user.profileImageURL
)
}
public static func make(_ users: NSSet?) -> [InternalUserProfile] {
users?
.compactMap { user in
if let user = user as? UserProfile,
let userID = user.userID,
let name = user.name,
let username = user.username
{
return InternalUserProfile(
userID: userID,
name: name,
username: username,
profileImageURL: user.profileImageURL
)
guard
let user = user as? UserProfile,
let userID = user.userID,
let name = user.name,
let username = user.username
else {
return nil
}
return nil
return InternalUserProfile(
userID: userID,
name: name,
username: username,
profileImageURL: user.profileImageURL
)
} ?? []
}
}

View file

@ -68,7 +68,6 @@ public extension Color {
)
}
// TODO: remove this?
var isDark: Bool {
guard let lum = luminance else { return false }
return lum < 0.50
@ -87,7 +86,17 @@ public extension Color {
}
#endif
return 0.2126 * Float(components[0]) + 0.7152 * Float(components[1]) + 0.0722 * Float(components[2])
let colorR = channelColor(components[0]) * 0.2126
let colorG = channelColor(components[1]) * 0.7152
let colorB = channelColor(components[2]) * 0.0722
return Float(colorR + colorG + colorB)
}
private func channelColor(_ channel: CGFloat) -> Double {
channel <= 0.03928
? channel / 12.92
: pow((channel + 0.055) / 1.055, 2.4)
}
static var isDarkMode: Bool {

View file

@ -12,5 +12,5 @@ public enum FeatureFlag {
public static let enableShareButton = false
public static let enableSnooze = false
public static let enableGridCardsOnPhone = false
public static let enableUltraRealisticVoices = false
public static let enableUltraRealisticVoices = true
}

View file

@ -10,8 +10,7 @@ import QuickLookThumbnailing
public enum PDFUtils {
public static func copyToLocal(url: URL) throws -> String {
let subPath = UUID().uuidString + ".pdf"
let dest = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
let dest = URL.om_documentsDirectory
.appendingPathComponent(subPath)
try FileManager.default.copyItem(at: url, to: dest)
@ -20,8 +19,7 @@ public enum PDFUtils {
public static func moveToLocal(url: URL) throws -> String {
let subPath = UUID().uuidString + ".pdf"
let dest = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
let dest = URL.om_documentsDirectory
.appendingPathComponent(subPath)
try FileManager.default.moveItem(at: url, to: dest)
@ -29,8 +27,7 @@ public enum PDFUtils {
}
public static func localPdfURL(filename: String) -> URL? {
let url = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
let url = URL.om_documentsDirectory
.appendingPathComponent(filename)
return url

Some files were not shown because too many files have changed in this diff Show more