Merge pull request #3848 from omnivore-app/feat/ios-digest-view

iOS Explain and Digest views
This commit is contained in:
Jackson Harper 2024-04-24 16:13:50 -07:00 committed by GitHub
commit ae8790b545
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 956 additions and 543 deletions

View file

@ -1,123 +1,269 @@
import SwiftUI
import Models
import Services
import Views
import MarkdownUI
import Utils
@MainActor
public class FullScreenDigestViewModel: ObservableObject {
@Published var isLoading = false
@Published var digest: DigestResult?
@AppStorage(UserDefaultKey.lastVisitedDigestId.rawValue) var lastVisitedDigestId = ""
func load(dataService: DataService) async {
isLoading = true
if digest == nil {
do {
digest = try await dataService.getLatestDigest(timeoutInterval: 10)
} catch {
print("ERROR WITH DIGEST: ", error)
}
func load(dataService: DataService, audioController: AudioController) async {
if let digest = dataService.loadStoredDigest() {
self.digest = digest
} else {
isLoading = true
}
do {
if let digest = try await dataService.getLatestDigest(timeoutInterval: 10) {
self.digest = digest
lastVisitedDigestId = digest.id
if let playingDigest = audioController.itemAudioProperties as? DigestAudioItem, playingDigest.digest.id == digest.id {
// Don't think we need to do anything here
} else {
audioController.play(itemAudioProperties: DigestAudioItem(digest: digest))
}
}
} catch {
print("ERROR WITH DIGEST: ", error)
}
isLoading = false
}
}
struct DigestAudioItem: AudioItemProperties {
let audioItemType = Models.AudioItemType.digest
var itemID = ""
var title = "TITLE"
var byline: String? = "byline"
var imageURL: URL? = nil
var language: String?
var startIndex: Int = 0
var startOffset: Double = 0.0
func refreshDigest(dataService: DataService) async {
do {
try await dataService.refreshDigest()
} catch {
print("ERROR WITH DIGEST: ", error)
}
}
}
@available(iOS 17.0, *)
@MainActor
struct FullScreenDigestView: View {
let viewModel: DigestViewModel = DigestViewModel()
@StateObject var viewModel = FullScreenDigestViewModel()
let dataService: DataService
let audioController: AudioController
@Environment(\.dismiss) private var dismiss
let textBody = "In a significant political turn, the SOTU response faces unexpected collapse, " +
"marking a stark contrast to Trump's latest downturn, alongside an unprecedented " +
"surge in Biden's fundraising efforts as of 3/11/24, according to the TDPS Podcast. " +
"The analysis provides insights into the shifting dynamics of political support and " +
"the potential implications for future electoral strategies. Based on the information " +
"you provided, the video seems to discuss a recent event where former President " +
"Donald Trump made a controversial statement that shocked even his own audience. " +
"The video likely covers Trump's response to the State of the Union (SOTU) address " +
"and how it received negative feedback, possibly leading to a decline in his support " +
"or approval ratings. Additionally, it appears that the video touches upon a surge " +
"in fundraising for President Joe Biden's administration around March 11, 2024."
public init(dataService: DataService, audioController: AudioController) {
self.dataService = dataService
self.audioController = audioController
}
var body: some View {
// ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)) {
Group {
if viewModel.isLoading {
ProgressView()
} else {
itemBody
.task {
await viewModel.load(dataService: dataService)
}.onAppear {
self.audioController.play(itemAudioProperties: DigestAudioItem())
}
}
} .navigationTitle("Omnivore digest")
.navigationBarTitleDisplayMode(.inline)
var titleBlock: some View {
HStack {
Text("Omnivore Digest")
.font(Font.system(size: 18, weight: .semibold))
Image.tabDigestSelected
Spacer()
closeButton
}
.padding(.top, 20)
.padding(.horizontal, 20)
}
// HStack(alignment: .top) {
// Spacer()
// closeButton
// }
// .padding(20)
// }
var createdString: String {
if let createdAt = viewModel.digest?.createdAt,
let date = DateFormatter.formatterISO8601.date(from: createdAt) {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .medium
dateFormatter.locale = Locale(identifier: "en_US")
return "Created " + dateFormatter.string(from: date)
}
return ""
}
var body: some View {
VStack {
titleBlock
Group {
if viewModel.isLoading {
VStack {
Spacer()
ProgressView()
Spacer()
}
} else {
itemBody
}
}
.edgesIgnoringSafeArea(.bottom)
}.task {
await viewModel.load(dataService: dataService, audioController: audioController)
}
}
var closeButton: some View {
Button(action: {
dismiss()
}, label: {
ZStack {
Circle()
.foregroundColor(Color.appGrayText)
.frame(width: 36, height: 36)
.opacity(0.1)
Image(systemName: "xmark")
.font(.appCallout)
.frame(width: 36, height: 36)
}
Text("Close")
.foregroundColor(Color.blue)
})
.buttonStyle(.plain)
}
func getChapterData(digest: DigestResult) -> [String:(time: String, start: Int, end: Int)] {
let speed = 1.0
var chapterData: [String:(time: String, start: Int, end: Int)] = [:]
var currentAudioIndex = 0
var currentWordCount = 0.0
for (index, speechFile) in digest.speechFiles.enumerated() {
let chapter = digest.chapters[index]
let duration = currentWordCount / SpeechDocument.averageWPM / speed * 60.0
chapterData[chapter.id] = (
time: formatTimeInterval(duration) ?? "00:00",
start: Int(currentAudioIndex),
end: currentAudioIndex + Int(speechFile.utterances.count)
)
currentAudioIndex += Int(speechFile.utterances.count)
currentWordCount += chapter.wordCount
}
return chapterData
}
func formatTimeInterval(_ time: TimeInterval) -> String? {
let componentFormatter = DateComponentsFormatter()
componentFormatter.unitsStyle = .positional
componentFormatter.allowedUnits = time >= 3600 ? [.second, .minute, .hour] : [.second, .minute]
componentFormatter.zeroFormattingBehavior = .pad
return componentFormatter.string(from: time)
}
@available(iOS 17.0, *)
var itemBody: some View {
VStack {
ScrollView(.vertical) {
VStack(spacing: 20) {
Text("SOTU response collapses, Trump hits new low, Biden fundraising explodes 3/11/24 TDPS Podcast")
.font(.title)
Text(textBody)
.font(.body)
ScrollView {
VStack(alignment: .leading, spacing: 20) {
HStack {
Image.coloredSmallOmnivoreLogo
.resizable()
.frame(width: 20, height: 20)
Text("Omnivore.app")
.font(Font.system(size: 14))
.foregroundColor(Color.themeLibraryItemSubtle)
Spacer()
}
if let digest = viewModel.digest {
Text(digest.title)
.font(Font.system(size: 17, weight: .semibold))
.lineSpacing(5)
.lineLimit(3)
Text(createdString)
.font(Font.system(size: 12))
.foregroundColor(Color(hex: "#898989"))
.lineLimit(1)
Text(digest.description)
.font(Font.system(size: 14))
.lineSpacing(/*@START_MENU_TOKEN@*/10.0/*@END_MENU_TOKEN@*/)
.foregroundColor(Color.themeLibraryItemSubtle)
.lineLimit(6)
} else {
Text("We're building you a new digest")
.font(Font.system(size: 17, weight: .semibold))
.lineLimit(3)
ProgressView()
}
}
}
// .scrollTargetBehavior(.paging)
// .ignoresSafeArea()
MiniPlayerViewer()
.padding(15)
.background(Color.themeLabelBackground.opacity(0.6))
.cornerRadius(5)
if let digest = viewModel.digest {
VStack(alignment: .leading, spacing: 10) {
Text("Chapters")
.font(Font.system(size: 17, weight: .semibold))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(0)
let chapterData = getChapterData(digest: digest)
ForEach(digest.chapters, id: \.id) { chapter in
if let startTime = chapterData[chapter.id]?.time, let skipIndex = chapterData[chapter.id]?.start {
let currentChapter = audioController.currentAudioIndex >= (chapterData[chapter.id]?.start ?? 0) &&
audioController.currentAudioIndex < (chapterData[chapter.id]?.end ?? 0)
ChapterView(
startTime: startTime,
skipIndex: skipIndex,
chapter: chapter
)
.onTapGesture {
audioController.seek(toIdx: skipIndex)
}
.background(
currentChapter ? Color.themeLabelBackground.opacity(0.6) : Color.clear
)
.cornerRadius(5)
}
}
}
.padding(.top, 20)
}
if let digest = viewModel.digest {
Text("Transcript")
.font(Font.system(size: 17, weight: .semibold))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 20)
VStack {
Markdown(digest.content)
.foregroundColor(Color.appGrayTextContrast)
}
.padding(15)
.background(Color.themeLabelBackground.opacity(0.6))
.cornerRadius(5)
}
Spacer(minLength: 60)
if viewModel.digest != nil {
Text("Rate today's digest")
.font(Font.system(size: 17, weight: .semibold))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 15)
.padding(.horizontal, 15)
RatingWidget()
Spacer(minLength: 60)
}
VStack(alignment: .leading, spacing: 20) {
Text("If you didn't like today's digest or would like another one you can create another one. The process takes a few minutes")
Button(action: {
Task {
await viewModel.refreshDigest(dataService: dataService)
}
}, label: {
Text("Create new digest")
.font(Font.system(size: 13, weight: .medium))
.padding(.horizontal, 8)
.padding(.vertical, 5)
.tint(Color.blue)
.background(Color.themeLabelBackground)
.cornerRadius(5)
})
}
.padding(15)
.background(Color.themeLabelBackground.opacity(0.6))
.cornerRadius(5)
}.contentMargins(10, for: .scrollContent)
Spacer()
MiniPlayerViewer(showStopButton: false)
.padding(.top, 10)
.padding(.bottom, 40)
.background(Color.themeTabBarColor)
@ -128,6 +274,50 @@ struct FullScreenDigestView: View {
}
}
struct ChapterView: View {
let startTime: String
let skipIndex: Int
let chapter: DigestChapter
var body: some View {
HStack(spacing: 15) {
if let thumbnail = chapter.thumbnail, let thumbnailURL = URL(string: thumbnail) {
AsyncImage(url: thumbnailURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 90, height: 50)
.clipped()
} placeholder: {
Rectangle()
.foregroundColor(.gray)
.frame(width: 90, height: 50)
}
.cornerRadius(8)
} else {
Rectangle()
.foregroundColor(.gray)
.frame(width: 90, height: 50)
.cornerRadius(8)
}
VStack(alignment: .leading) {
(Text(startTime)
.foregroundColor(.blue)
.font(.caption)
+
Text(" - " + chapter.title)
.foregroundColor(.primary)
.font(.caption))
.lineLimit(2)
}
Spacer()
}
.padding(.leading, 4)
.padding(.vertical, 15)
}
}
@MainActor
public class PreviewItemViewModel: ObservableObject {
let dataService: DataService
@ -145,24 +335,6 @@ public class PreviewItemViewModel: ObservableObject {
}
func loadResult() async {
// isLoading = true
// let taskId = try? await dataService.createAITask(
// extraText: extraText,
// libraryItemId: item?.id ?? "",
// promptName: "summarize-001"
// )
//
// if let taskId = taskId {
// do {
// let fetchedText = try await dataService.pollAITask(jobId: taskId, timeoutInterval: 30)
// resultText = fetchedText
// } catch {
// print("ERROR WITH RESULT TEXT: ", error)
// }
// } else {
// print("NO TASK ID: ", taskId)
// }
// isLoading = false
}
}
@ -200,7 +372,7 @@ struct PreviewItemView: View {
.foregroundColor(Color(hex: "898989"))
.frame(maxWidth: .infinity, alignment: .topLeading)
Color(hex: "2A2A2A")
Color.themeLabelBackground
.frame(height: 1)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 20)
@ -296,7 +468,7 @@ struct RatingWidget: View {
}
}
.padding()
.background(Color(hex: "313131"))
.background(Color.themeLabelBackground.opacity(0.6))
.cornerRadius(8)
// .shadow(radius: 3)
}

View file

@ -8,12 +8,16 @@
import Views
public struct MiniPlayerViewer: View {
var showStopButton = true
@EnvironmentObject var audioController: AudioController
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@State var expanded = true
var playPauseButtonImage: String {
#if targetEnvironment(simulator)
return "play.circle"
#endif
switch audioController.state {
case .playing:
return "pause.circle"
@ -27,6 +31,17 @@
}
var playPauseButtonItem: some View {
#if targetEnvironment(simulator)
return AnyView(Button(
action: {},
label: {
Image(systemName: playPauseButtonImage)
.resizable(resizingMode: Image.ResizingMode.stretch)
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.light))
}
).buttonStyle(.plain))
#endif
if audioController.playbackError {
return AnyView(Color.clear)
}
@ -138,9 +153,11 @@
.frame(width: 40, height: 40)
.foregroundColor(.themeAudioPlayerGray)
}
stopButton
.frame(width: 40, height: 40)
.foregroundColor(.themeAudioPlayerGray)
if showStopButton {
stopButton
.frame(width: 40, height: 40)
.foregroundColor(.themeAudioPlayerGray)
}
}
.padding(.vertical, 5)
.padding(.horizontal, 15)

View file

@ -181,7 +181,7 @@ struct AnimatingCellHeight: AnimatableModifier {
// swiftlint:disable file_length
#if os(iOS)
private let enableGrid = UIDevice.isIPad || FeatureFlag.enableGridCardsOnPhone
private let enableGrid = UIDevice.isIPad
@MainActor
struct HomeFeedContainerView: View {
@ -204,9 +204,6 @@ struct AnimatingCellHeight: AnimatableModifier {
@ObservedObject var viewModel: HomeFeedViewModel
@State private var selection = Set<String>()
@AppStorage("LibraryList::digestEnabled") var digestEnabled = false
@AppStorage("LibraryList::hasCheckedForDigestFeature") var hasCheckedForDigestFeature = false
init(viewModel: HomeFeedViewModel, isEditMode: Binding<EditMode>) {
_viewModel = ObservedObject(wrappedValue: viewModel)
_isEditMode = isEditMode
@ -274,7 +271,11 @@ struct AnimatingCellHeight: AnimatableModifier {
.padding(.bottom, 20)
.background(Color.themeTabBarColor)
.onTapGesture {
showExpandedAudioPlayer = true
if audioController.itemAudioProperties?.audioItemType == .digest {
showLibraryDigest = true
} else {
showExpandedAudioPlayer = true
}
}
}
}
@ -351,20 +352,6 @@ struct AnimatingCellHeight: AnimatableModifier {
viewModel.stopUsingFollowingPrimer = true
}
}
.task {
do {
if let viewer = try await dataService.fetchViewer() {
digestEnabled = viewer.digestEnabled ?? false
if !hasCheckedForDigestFeature {
hasCheckedForDigestFeature = true
// selectedTab = "digest"
}
}
} catch {
print("ERROR FETCHING VIEWER: ", error)
print("")
}
}
.environment(\.editMode, self.$isEditMode)
.navigationBarTitleDisplayMode(.inline)
}
@ -409,10 +396,10 @@ struct AnimatingCellHeight: AnimatableModifier {
if isEditMode == .active {
Button(action: { isEditMode = .inactive }, label: { Text("Cancel") })
} else {
if #available(iOS 17.0, *) {
if #available(iOS 17.0, *), dataService.featureFlags.digestEnabled {
Button(
action: { showLibraryDigest = true },
label: { Image.tabDigestSelected }
label: { viewModel.digestIsUnread ? Image.tabDigestSelected : Image.tabDigest }
)
.buttonStyle(.plain)
.padding(.trailing, 4)

View file

@ -38,6 +38,8 @@ enum LoadingBarStyle {
@Published var selectedLabels = [LinkedItemLabel]()
@Published var negatedLabels = [LinkedItemLabel]()
@Published var appliedSort = LinkedItemSort.newest.rawValue
@Published var digestIsUnread = false
@State var lastMoreFetched: Date?
@State var lastFiltersFetched: Date?
@ -47,6 +49,7 @@ enum LoadingBarStyle {
@AppStorage(UserDefaultKey.hideFeatureSection.rawValue) var hideFeatureSection = false
@AppStorage(UserDefaultKey.stopUsingFollowingPrimer.rawValue) var stopUsingFollowingPrimer = false
@AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false
@AppStorage(UserDefaultKey.lastVisitedDigestId.rawValue) var lastVisitedDigestId = ""
@AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue
@ -395,4 +398,14 @@ enum LoadingBarStyle {
isEmptyingTrash = false
}
}
func checkForDigestUpdate(dataService: DataService) async {
do {
if let result = try? await dataService.getLatestDigest(timeoutInterval: 2) {
if result.id != lastVisitedDigestId {
digestIsUnread = true
}
}
}
}
}

View file

@ -15,6 +15,7 @@ struct LibraryTabView: View {
@AppStorage(UserDefaultKey.lastSelectedTabItem.rawValue) var selectedTab = "inbox"
@State var isEditMode: EditMode = .inactive
@State var showLibraryDigest = false
@State var showExpandedAudioPlayer = false
@State var presentPushContainer = true
@State var pushLinkRequest: String?
@ -69,27 +70,13 @@ struct LibraryTabView: View {
@State var operationStatus: OperationStatus = .none
@State var operationMessage: String?
@State var digestEnabled = false
var showDigest: Bool {
if digestEnabled, #available(iOS 17.0, *) {
return true
}
return false
}
var displayTabs: [String] {
var res = [String]()
if !hideFollowingTab {
res.append("following")
}
if showDigest {
res.append("digest")
}
res.append("inbox")
if !showDigest {
res.append("profile")
}
res.append("profile")
return res
}
@ -133,34 +120,26 @@ struct LibraryTabView: View {
}.tag("following")
}
if showDigest, #available(iOS 17.0, *) {
NavigationView {
DigestView(dataService: dataService)
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
}.tag("digest")
NavigationView {
HomeFeedContainerView(viewModel: inboxViewModel, isEditMode: $isEditMode)
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
}.tag("inbox")
} else {
NavigationView {
HomeFeedContainerView(viewModel: inboxViewModel, isEditMode: $isEditMode)
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
}.tag("inbox")
NavigationView {
ProfileView()
.navigationViewStyle(.stack)
}.tag("profile")
}
NavigationView {
HomeFeedContainerView(viewModel: inboxViewModel, isEditMode: $isEditMode)
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(.stack)
}.tag("inbox")
NavigationView {
ProfileView()
.navigationViewStyle(.stack)
}.tag("profile")
}
if audioController.itemAudioProperties != nil {
MiniPlayerViewer()
.onTapGesture {
showExpandedAudioPlayer = true
if audioController.itemAudioProperties?.audioItemType == .digest {
showLibraryDigest = true
} else {
showExpandedAudioPlayer = true
}
}
.padding(0)
Color(hex: "#3D3D3D")
@ -193,6 +172,15 @@ struct LibraryTabView: View {
}
)
}
.fullScreenCover(isPresented: $showLibraryDigest) {
if #available(iOS 17.0, *) {
NavigationView {
FullScreenDigestView(dataService: dataService, audioController: audioController)
}
} else {
Text("Sorry digest is only available on iOS 17 and above")
}
}
.navigationBarHidden(true)
.onReceive(NSNotification.performSyncPublisher) { _ in
Task {

View file

@ -23,7 +23,7 @@
var body: some View {
Group {
Form {
if FeatureFlag.enableUltraRealisticVoices, language.key == "en" {
if language.key == "en" {
if viewModel.waitingForRealisticVoices {
HStack {
Text(LocalText.texttospeechBetaSignupInProcess)

View file

@ -48,7 +48,7 @@ func removeLibraryItemAction(dataService: DataService, objectID: NSManagedObject
}
func archiveLibraryItemAction(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) {
var localPdf: String? = nil
var localPdf: String?
dataService.viewContext.performAndWait {
if let item = dataService.viewContext.object(with: objectID) as? Models.LibraryItem {
item.isArchived = archived

View file

@ -37,6 +37,8 @@ public struct RootView: View {
Services.scheduleBackgroundFetch()
#endif
}
}.task {
await viewModel.services.dataService.tryUpdateFeatureFlags()
}
}
}

View file

@ -0,0 +1,57 @@
// swiftlint:disable line_length
import Foundation
import Models
import SwiftUI
import Views
import WebKit
import Services
@MainActor public final class ExplainViewModel: ObservableObject {
@Published var isLoading = true
@Published var explanation = ""
func load(dataService: DataService, text: String, libraryItemId: String) async {
isLoading = true
do {
explanation = try await dataService.explain(text: text, libraryItemId: libraryItemId)
} catch {
print("ERROR: ", error)
explanation = "There was an error generating your explanation"
}
isLoading = false
}
}
@MainActor
struct ExplainView: View {
let dataService: DataService
let text: String
let item: Models.LibraryItem
@StateObject var viewModel = ExplainViewModel()
init(dataService: DataService, text: String, item: Models.LibraryItem) {
self.text = text
self.item = item
self.dataService = dataService
}
var body: some View {
if viewModel.isLoading {
ProgressView()
.task {
await viewModel.load(dataService: dataService, text: text, libraryItemId: item.unwrappedID)
}
} else {
Text(viewModel.explanation)
.font(Font.system(size: 19))
.lineSpacing(12)
.padding(20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
}

View file

@ -1,93 +0,0 @@
import Models
import SwiftUI
import Utils
import Views
import WebKit
#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)
}
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)
}
}
extension HighlightViewer {
func makeUIView(context: Context) -> WKWebView {
makePlatformView(context: context)
}
func updateUIView(_ webView: WKWebView, context: Context) {
updatePlatformView(webView, context: context)
}
}
#endif

View file

@ -27,6 +27,7 @@ struct WebReaderContainerView: View {
@State var annotationSaveTransactionID: UUID?
@State var showNavBarActionID: UUID?
@State var showExpandedAudioPlayer = false
@State var showLibraryDigest = false
@State var shareActionID: UUID?
@State var annotation = String()
@State private var bottomBarOpacity = 0.0
@ -327,6 +328,9 @@ struct WebReaderContainerView: View {
.formSheet(isPresented: $showPreferencesFormsheet, modalSize: CGSize(width: 400, height: 475)) {
webPreferencesPopoverView
}
.formSheet(isPresented: $showExplainSheet, modalSize: CGSize(width: 400, height: 475)) {
explainView
}
#endif
#if os(macOS)
@ -372,6 +376,10 @@ struct WebReaderContainerView: View {
}
#endif
var explainView: some View {
ExplainView(dataService: dataService, text: viewModel.explainText ?? "Nothing to explain", item: item)
}
var body: some View {
ZStack {
if let articleContent = viewModel.articleContent {
@ -392,7 +400,7 @@ struct WebReaderContainerView: View {
#endif
},
tapHandler: tapHandler,
explainHandler: explainHandler,
explainHandler: dataService.featureFlags.explainEnabled ? explainHandler : nil,
scrollPercentHandler: scrollPercentHandler,
webViewActionHandler: webViewActionHandler,
navBarVisibilityUpdater: { visible in
@ -476,6 +484,15 @@ struct WebReaderContainerView: View {
showExpandedAudioPlayer = false
})
}
.fullScreenCover(isPresented: $showLibraryDigest) {
if #available(iOS 17.0, *) {
NavigationView {
FullScreenDigestView(dataService: dataService, audioController: audioController)
}
} else {
Text("Sorry digest is only available on iOS 17 and above")
}
}
#endif
.alert(errorAlertMessage ?? LocalText.readerError, isPresented: $showErrorAlertMessage) {
Button(LocalText.genericOk, role: .cancel, action: {
@ -621,7 +638,11 @@ struct WebReaderContainerView: View {
.padding(.bottom, showBottomBar ? 10 : 40)
.background(Color.themeTabBarColor)
.onTapGesture {
showExpandedAudioPlayer = true
if audioController.itemAudioProperties?.audioItemType == .digest {
showLibraryDigest = true
} else {
showExpandedAudioPlayer = true
}
}
}
if showBottomBar {

View file

@ -137,7 +137,6 @@
<attribute name="username" optional="YES" attributeType="String"/>
</entity>
<entity name="Viewer" representedClassName="Viewer" syncable="YES" codeGenerationType="class">
<attribute name="digestEnabled" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="name" attributeType="String"/>
<attribute name="profileImageURL" optional="YES" attributeType="String"/>
<attribute name="userID" attributeType="String"/>

View file

@ -1,3 +1,4 @@
import SwiftUI
public struct FeatureInternal {
public let name: String

View file

@ -20,7 +20,7 @@ struct UtteranceRequest: Codable {
let isOpenAIVoice: Bool
}
public struct Utterance: Decodable {
public struct Utterance: Codable {
public let idx: String
public let text: String
public let voice: String?
@ -39,10 +39,10 @@ public struct Utterance: Decodable {
}
}
public struct SpeechDocument: Decodable {
static let averageWPM: Double = 195
public struct SpeechDocument: Codable {
public static let averageWPM: Double = 195
public let pageId: String
public let pageId: String?
public let wordCount: Double
public let language: String
public let defaultVoice: String
@ -54,7 +54,7 @@ public struct SpeechDocument: Decodable {
}
var audioDirectory: URL {
Self.audioDirectory(pageId: pageId)
Self.audioDirectory(pageId: pageId ?? "pageid")
}
static func audioDirectory(pageId: String) -> URL {

View file

@ -7,17 +7,40 @@ struct AITaskRequest: Decodable {
public let requestId: String
}
public struct DigestResult: Decodable {
public struct DigestResult: Codable {
public let id: String
public let title: String
public let byline: String
public let content: String
public let description: String
public let urlsToAudio: [String]
public let speechFile: SpeechDocument
public let chapters: [DigestChapter]
public let speechFiles: [SpeechDocument]
public let jobState: String
public let createdAt: String
}
public struct DigestItem: Decodable {
public struct DigestChapter: Codable {
public let title: String
public let id: String
public let url: String
public let wordCount: Double
public let thumbnail: String?
public init(title: String, id: String, url: String, wordCount: Double, thumbnail: String?) {
self.title = title
self.id = id
self.url = url
self.wordCount = wordCount
self.thumbnail = thumbnail
}
}
public struct RefreshDigestResult: Codable {
public let jobId: String
}
public struct DigestItem: Codable {
public let id: String
public let site: String
public let siteIcon: URL?
@ -26,7 +49,7 @@ public struct DigestItem: Decodable {
public let summaryText: String
public let keyPointsText: String
public let highlightsText: String
public init(id: String, site: String, siteIcon: URL?,
public init(id: String, site: String, siteIcon: URL?,
author: String, title: String, summaryText: String,
keyPointsText: String, highlightsText: String) {
self.id = id
@ -40,32 +63,53 @@ public struct DigestItem: Decodable {
}
}
public struct DigestRequest: Codable {
public let schedule: String
public let voices: [String]
public init(schedule: String, voices: [String]) {
self.schedule = schedule
self.voices = voices
}
}
public struct ExplainRequest: Codable {
public let text: String
public let libraryItemId: String
public init(text: String, libraryItemId: String) {
self.text = text
self.libraryItemId = libraryItemId
}
}
public struct ExplainResult: Codable {
public let text: String
}
extension DataService {
// public func createAITask(extraText: String?, libraryItemId: String, promptName: String) async throws -> String? {
// let jsonData = try JSONSerialization.data(withJSONObject: [
// "libraryItemId": libraryItemId,
// "promptName": promptName,
// "extraText": extraText
// ])
//
// let urlRequest = URLRequest.create(
// baseURL: appEnvironment.serverBaseURL,
// urlPath: "/api/ai-task",
// requestMethod: .post(params: jsonData),
// includeAuthToken: true
// )
// let resource = ServerResource<AITaskRequest>(
// urlRequest: urlRequest,
// decode: AITaskRequest.decode
// )
//
// do {
// let taskRequest = try await networker.urlSession.performRequest(resource: resource)
// return taskRequest.requestId
// } catch {
// return nil
// }
// }
public func refreshDigest() async throws {
let encoder = JSONEncoder()
let digestRequest = DigestRequest(schedule: "daily", voices: ["openai-nova"])
let data = (try? encoder.encode(digestRequest)) ?? Data()
let urlRequest = URLRequest.create(
baseURL: appEnvironment.serverBaseURL,
urlPath: "/api/digest/v1/",
requestMethod: .post(params: data),
includeAuthToken: true
)
let resource = ServerResource<DigestResult>(
urlRequest: urlRequest,
decode: RefreshDigestResult.decode
)
do {
let digest = try await networker.urlSession.performRequest(resource: resource)
print("GOT RESPONSE: ", digest)
} catch {
print("ERROR FETCHING TASK: ", error)
}
}
// Function to poll the status of the AI task with timeout
public func getLatestDigest(timeoutInterval: TimeInterval) async throws -> DigestResult? {
@ -76,43 +120,81 @@ extension DataService {
if count > 3 {
return nil
}
do {
// Check if timeout has occurred
if -startTime.timeIntervalSinceNow >= timeoutInterval {
throw NSError(domain: "Timeout Error", code: -1, userInfo: nil)
}
let urlRequest = URLRequest.create(
baseURL: appEnvironment.serverBaseURL,
urlPath: "/api/digest/v1/",
requestMethod: .get,
includeAuthToken: true
)
let resource = ServerResource<DigestResult>(
urlRequest: urlRequest,
decode: DigestResult.decode
)
do {
let digest = try await networker.urlSession.performRequest(resource: resource)
print("GOT RESPONSE: ", digest)
return digest
} catch {
print("ERROR FETCHING TASK: ", error)
// if let response = error as? ServerError {
// if response != .stillProcessing {
// return nil
// }
// }
}
// Wait for some time before polling again
try? await Task.sleep(nanoseconds: 3_000_000_000)
} catch let error {
throw error
do {
// Check if timeout has occurred
if -startTime.timeIntervalSinceNow >= timeoutInterval {
throw NSError(domain: "Timeout Error", code: -1, userInfo: nil)
}
let urlRequest = URLRequest.create(
baseURL: appEnvironment.serverBaseURL,
urlPath: "/api/digest/v1/",
requestMethod: .get,
includeAuthToken: true
)
let resource = ServerResource<DigestResult>(
urlRequest: urlRequest,
decode: DigestResult.decode
)
do {
let digest = try await networker.urlSession.performRequest(resource: resource)
let oldDigest = loadStoredDigest()
saveDigest(digest)
return digest
} catch {
print("ERROR FETCHING TASK: ", error)
}
// Wait for some time before polling again
try? await Task.sleep(nanoseconds: 3_000_000_000)
} catch let error {
throw error
}
}
}
public func loadStoredDigest() -> DigestResult? {
let decoder = JSONDecoder()
let localPath = URL.om_cachesDirectory.appendingPathComponent("digest.json")
if let data = try? Data(contentsOf: localPath),
let digest = try? decoder.decode(DigestResult.self, from: data) {
return digest
}
return nil
}
func saveDigest(_ digest: DigestResult) {
let localPath = URL.om_cachesDirectory.appendingPathComponent("digest.json")
if let data = try? JSONEncoder().encode(digest) {
try? data.write(to: localPath)
}
}
public func explain(text: String, libraryItemId: String) async throws -> String {
let encoder = JSONEncoder()
let explainRequest = ExplainRequest(text: text, libraryItemId: libraryItemId)
let data = (try? encoder.encode(explainRequest)) ?? Data()
do {
let urlRequest = URLRequest.create(
baseURL: appEnvironment.serverBaseURL,
urlPath: "/api/explain/",
requestMethod: .post(params: data),
includeAuthToken: true
)
let resource = ServerResource<ExplainResult>(
urlRequest: urlRequest,
decode: ExplainResult.decode
)
let response = try await networker.urlSession.performRequest(resource: resource)
return response.text
} catch let error {
throw error
}
}
}

View file

@ -33,6 +33,8 @@ public final class DataService: ObservableObject {
persistentContainer.viewContext
}
public var featureFlags = FeatureFlags()
public var lastItemSyncTime: Date {
get {
guard
@ -298,4 +300,11 @@ public final class DataService: ObservableObject {
}
return objectID
}
public func tryUpdateFeatureFlags() async {
if let features = (try? await fetchViewer())?.enabledFeatures {
featureFlags.digestEnabled = features.contains("ai-digest")
featureFlags.explainEnabled = features.contains("ai-explain")
}
}
}

View file

@ -35,7 +35,7 @@ public extension DataService {
if let linkedItemID = linkedItemID {
Task {
AudioController.removeAudioFiles(itemID: linkedItemID)
await AudioController.removeAudioFiles(itemID: linkedItemID)
}
}
}

View file

@ -168,6 +168,7 @@ extension DataService {
try $0.search(
after: OptionalArgument(cursor),
first: OptionalArgument(limit),
includeContent: OptionalArgument(true),
query: OptionalArgument(searchQuery),
selection: selection
)

View file

@ -18,8 +18,7 @@ public extension DataService {
selection: .init { try $0.pictureUrl() }
),
intercomHash: try $0.intercomHash(),
digestEnabled: true // (try $0.featureList(selection: featureSelection.list.nullable)?
// .filter { $0.enabled && $0.name == "digest" } ?? []).count > 0
enabledFeatures: try $0.featureList(selection: featureSelection.list.nullable)?.filter { $0.enabled }.map { $0.name }
)
}
@ -67,7 +66,11 @@ public struct ViewerInternal {
public let name: String
public let profileImageURL: String?
public let intercomHash: String?
public let digestEnabled: Bool?
public let enabledFeatures: [String]? // We don't persist these as they can be dynamic
public func hasFeatureGranted(_ name: String) -> Bool {
return enabledFeatures?.contains(name) ?? false
}
func persist(context: NSManagedObjectContext) throws {
try context.performAndWait {
@ -76,7 +79,6 @@ public struct ViewerInternal {
viewer.username = username
viewer.name = name
viewer.profileImageURL = profileImageURL
viewer.digestEnabled = digestEnabled ?? false
do {
try context.save()

View file

@ -1,4 +1,5 @@
import Foundation
import SwiftUI
#if DEBUG
public let isDebug = true
@ -6,11 +7,13 @@ import Foundation
public let isDebug = false
#endif
public enum FeatureFlag {
public static let enableSnoozeFromShareExtension = false
public static let enableRemindersFromShareExtension = false
public static let enableShareButton = false
public static let enableSnooze = false
public static let enableGridCardsOnPhone = false
public static let enableUltraRealisticVoices = true
public struct FeatureFlags {
@AppStorage("FeatureFlag::digestEnabled")
public var digestEnabled = false
@AppStorage("FeatureFlag::explainEnabled")
public var explainEnabled = false
public init() {
}
}

View file

@ -38,4 +38,5 @@ public enum UserDefaultKey: String {
case openExternalLinksIn
case prefersHideStatusBarInReader
case visibleShareExtensionTab
case lastVisitedDigestId
}

View file

@ -27,6 +27,8 @@ public final class OmnivoreWebView: WKWebView {
private var currentMenu: ContextMenu = .defaultMenu
private var explainEnabled = false
override init(frame: CGRect, configuration: WKWebViewConfiguration) {
super.init(frame: frame, configuration: configuration)
@ -340,7 +342,6 @@ public final class OmnivoreWebView: WKWebView {
Task {
let selection = try? await self.evaluateJavaScript("window.getSelection().toString()")
if let selection = selection as? String, let explainHandler = explainHandler {
print("Explaining \(selection)")
explainHandler(selection)
} else {
showInReaderSnackbar("Error getting text to explain")
@ -400,8 +401,12 @@ public final class OmnivoreWebView: WKWebView {
return
}
let highlight = UICommand(title: LocalText.genericHighlight, action: #selector(highlightSelection))
// let explain = UICommand(title: "Explain", action: #selector(explainSelection))
items = [highlight, /* explain, */ annotate]
if explainHandler != nil {
let explain = UICommand(title: "Explain", action: #selector(explainSelection))
items = [highlight, explain, annotate]
} else {
items = [highlight, annotate]
}
} else {
let remove = UICommand(title: "Remove", action: #selector(removeSelection))
let setLabels = UICommand(title: LocalText.labelsGeneric, action: #selector(setLabels))

View file

@ -2,6 +2,7 @@ import SwiftUI
public extension Image {
static var smallOmnivoreLogo: Image { Image("_smallOmnivoreLogo", bundle: .module) }
static var coloredSmallOmnivoreLogo: Image { Image("app-icon", bundle: .module) }
static var omnivoreTitleLogo: Image { Image("_omnivoreTitleLogo", bundle: .module) }
static var googleIcon: Image { Image("_googleIcon", bundle: .module) }

View file

@ -0,0 +1,58 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { htmlToSpeechFile } from '@omnivore/text-to-speech-handler'
import cors from 'cors'
import express from 'express'
import { userRepository } from '../repository/user'
import { getClaimsByToken } from '../utils/auth'
import { corsConfig } from '../utils/corsConfig'
import { getAISummary } from '../services/ai-summaries'
import { explainText } from '../services/explain'
import { FeatureName, findGrantedFeatureByName } from '../services/features'
export function explainRouter() {
const router = express.Router()
// Get an indexed summary for an individual library item
router.post('/', cors<express.Request>(corsConfig), async (req, res) => {
const token = req?.cookies?.auth || req?.headers?.authorization
const claims = await getClaimsByToken(token)
if (!claims) {
return res.status(401).send('UNAUTHORIZED')
}
const { uid } = claims
const user = await userRepository.findById(uid)
if (!user) {
return res.status(400).send('Bad Request')
}
if (!(await findGrantedFeatureByName(FeatureName.AIExplain, user.id))) {
return res.status(403).send('Not granted')
}
const libraryItemId = req.body.libraryItemId
if (!libraryItemId) {
return res.status(400).send('Bad request - no library item id provided')
}
const text = req.body.text
if (!text) {
return res.status(400).send('Bad request - no idx provided')
}
try {
const result = await explainText(uid, text, libraryItemId)
return res.send({
text: result,
})
} catch (err) {
console.log('Error: ', err)
}
return res.status(500).send('Error')
})
return router
}

View file

@ -44,6 +44,7 @@ import { analytics } from './utils/analytics'
import { corsConfig } from './utils/corsConfig'
import { buildLogger, buildLoggerTransport, logger } from './utils/logger'
import { apiLimiter, authLimiter } from './utils/rate_limit'
import { explainRouter } from './routers/explain_router'
const PORT = process.env.PORT || 4000
@ -85,6 +86,7 @@ export const createApp = (): Express => {
app.use('/api/user', userRouter())
app.use('/api/article', articleRouter())
app.use('/api/ai-summary', aiSummariesRouter())
app.use('/api/explain', explainRouter())
app.use('/api/text-to-speech', textToSpeechRouter())
app.use('/api/notification', notificationRouter())
app.use('/api/integration', integrationRouter())

View file

@ -0,0 +1,52 @@
import { OpenAI } from '@langchain/openai'
import { PromptTemplate } from '@langchain/core/prompts'
import { authTrx } from '../repository'
import { libraryItemRepository } from '../repository/library_item'
import { htmlToMarkdown } from '../utils/parser'
export const explainText = async (
userId: string,
text: string,
libraryItemId: string
): Promise<string> => {
const llm = new OpenAI({
modelName: 'gpt-4-0125-preview',
configuration: {
apiKey: process.env.OPENAI_API_KEY,
},
})
const libraryItem = await authTrx(
async (tx) =>
tx.withRepository(libraryItemRepository).findById(libraryItemId),
undefined,
userId
)
if (!libraryItem) {
throw 'No library item found'
}
const content = htmlToMarkdown(libraryItem.readableContent)
const contextualTemplate = PromptTemplate.fromTemplate(
`Create a brief, less than 300 character explanation of the provided
term. Use the article text for additional context.
Term: {text}
Article text: {content}
`
)
console.log('template: ', contextualTemplate)
const chain = contextualTemplate.pipe(llm)
const result = await chain.invoke({
text: text,
content,
})
console.log('result: ', result)
return result
}

View file

@ -17,6 +17,7 @@ export enum FeatureName {
UltraRealisticVoice = 'ultra-realistic-voice',
Notion = 'notion',
AIDigest = 'ai-digest',
AIExplain = 'ai-explain',
}
export const getFeatureName = (name: string): FeatureName | undefined => {