diff --git a/src/core/src/client/contexts/session/SessionContext.tsx b/src/core/src/client/contexts/session/SessionContext.tsx index 173d8fdc1..0ebb05545 100644 --- a/src/core/src/client/contexts/session/SessionContext.tsx +++ b/src/core/src/client/contexts/session/SessionContext.tsx @@ -18,6 +18,7 @@ import { ReadingFormat, RecapAttributes, } from '~/api'; +import { Locale, getLocale } from '~/locales'; import { useLocalStorage, usePlatformTools } from '~/utils'; export const SessionContext = React.createContext(DEFAULT_SESSION_CONTEXT); @@ -40,6 +41,7 @@ export function SessionContextProvider({ children }: React.PropsWithChildren) { const [lastRequestForReview, setLastRequestForReview] = React.useState(0); const [categories, setCategories] = React.useState>(); const [publishers, setPublishers] = React.useState>(); + const [loadedInitialUrl, setLoadedInitialUrl] = React.useState(false); // user state const [uuid, setUuid] = React.useState(); @@ -51,6 +53,7 @@ export function SessionContextProvider({ children }: React.PropsWithChildren) { const [bookmarkedSummaries, setBookmarkedSummaries] = React.useState<{ [key: number]: Bookmark }>(); const [readSummaries, setReadSummaries] = React.useState<{ [key: number]: Bookmark }>(); const [removedSummaries, setRemovedSummaries] = React.useState<{ [key: number]: boolean }>(); + const [locale, setLocale] = React.useState(); const [summaryTranslations, setSummaryTranslations] = React.useState<{ [key: number]: { [key in keyof PublicSummaryGroup]?: string } }>(); // bookmark state @@ -166,6 +169,9 @@ export function SessionContextProvider({ children }: React.PropsWithChildren) { case 'lastRequestForReview': setLastRequestForReview(newValue); break; + case 'loadedInitialUrl': + setLoadedInitialUrl(newValue); + break; // user state case 'uuid': @@ -545,11 +551,13 @@ export function SessionContextProvider({ children }: React.PropsWithChildren) { setBookmarkedSummaries(await getPreference('bookmarkedSummaries')); setReadSummaries(await getPreference('readSummaries')); setRemovedSummaries(await getPreference('removedSummaries')); - setSummaryTranslations(await getPreference('summaryTranslations')); + const locale = await getPreference('locale'); + setLocale(locale); + setSummaryTranslations(locale !== getLocale() ? {} : await getPreference('summaryTranslations')); // recap state setReadRecaps(await getPreference('readRecaps')); - setRecapTranslations(await getPreference('recapTranslations')); + setRecapTranslations(locale !== getLocale() ? {} : await getPreference('recapTranslations')); // publisher states setFollowedPublishers(await getPreference('followedPublishers')); @@ -567,7 +575,7 @@ export function SessionContextProvider({ children }: React.PropsWithChildren) { setFontSizeOffset(await getPreference('fontSizeOffset')); setLetterSpacing(await getPreference('letterSpacing')); setLineHeightMultiplier(await getPreference('lineHeightMultiplier')); - + // summary preferences setCompactSummaries(await getPreference('compactSummaries') ?? await getPreference('compactMode')); setShowShortSummary(await getPreference('showShortSummary')); @@ -629,6 +637,7 @@ export function SessionContextProvider({ children }: React.PropsWithChildren) { latestVersion, letterSpacing, lineHeightMultiplier, + loadedInitialUrl, preferredReadingFormat, preferredShortPressFormat, publisherIsFavorited, diff --git a/src/core/src/client/contexts/session/types.ts b/src/core/src/client/contexts/session/types.ts index 80ef2fd67..e8e2fa3ec 100644 --- a/src/core/src/client/contexts/session/types.ts +++ b/src/core/src/client/contexts/session/types.ts @@ -8,6 +8,7 @@ import { RecapAttributes, RequestParams, } from '~/api'; +import { Locale } from '~/locales'; export type BookmarkConstructorProps = { createdAt: Date; @@ -117,6 +118,7 @@ export type Preferences = { viewedFeatures?: { [key: string]: Bookmark }; hasReviewed?: boolean; lastRequestForReview: number; + loadedInitialUrl?: boolean; // user state uuid?: string; @@ -130,6 +132,7 @@ export type Preferences = { bookmarkCount: number; unreadBookmarkCount: number; removedSummaries?: { [key: number]: boolean }; + locale?: Locale; summaryTranslations?: { [key: number]: { [key in keyof PublicSummaryGroup]?: string } }; // recap state @@ -189,6 +192,8 @@ export const PREFERENCE_TYPES: { [key in keyof Preferences]: 'boolean' | 'number lastRequestForReview: 'number', letterSpacing: 'number', lineHeightMultiplier: 'number', + loadedInitialUrl: 'boolean', + locale: 'string', preferredReadingFormat: 'string', preferredShortPressFormat: 'string', pushNotifications: 'object', diff --git a/src/mobile/android/app/build.gradle b/src/mobile/android/app/build.gradle index e6e11e63f..7129006f2 100644 --- a/src/mobile/android/app/build.gradle +++ b/src/mobile/android/app/build.gradle @@ -98,8 +98,8 @@ android { applicationId "ai.readless.ReadLess" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 98 - versionName "1.15.2" + versionCode 99 + versionName "1.16.0" missingDimensionStrategy "store", "play" } diff --git a/src/mobile/ios/Extensions/Date.swift b/src/mobile/ios/Extensions/Date.swift index 6a4f2897e..c76c2f65f 100644 --- a/src/mobile/ios/Extensions/Date.swift +++ b/src/mobile/ios/Extensions/Date.swift @@ -11,11 +11,11 @@ extension Date { func distanceFromNow() -> String { let interval = Calendar.current.dateComponents([.minute, .hour, .day], from: self, to: Date()) if let day = interval.day, day > 0 { - return "\(day) day\(day == 1 ? "" : "s") ago" + return "\(day)d" } else if let hour = interval.hour, hour > 0 { - return "\(hour) hour\(hour == 1 ? "" : "s") ago" + return "\(hour)h" } else if let minute = interval.minute, minute > 0 { - return "\(minute) minute\(minute == 1 ? "" : "s") ago" + return "\(minute)m" } else { return "just now" } diff --git a/src/mobile/ios/Extensions/Image.swift b/src/mobile/ios/Extensions/Image.swift new file mode 100644 index 000000000..98617e976 --- /dev/null +++ b/src/mobile/ios/Extensions/Image.swift @@ -0,0 +1,51 @@ +// +// Image.swift +// ReadLess +// +// Created by thom on 10/4/23. +// + +import Foundation +import CoreGraphics +import SwiftUI + +public extension Image { + + static func loadAsync(from string: String, maxWidth: CGFloat? = nil) async -> Image? { + guard let url = URL(string: string) else { return nil } + return await self.loadAsync(from: url, maxWidth: maxWidth) + } + + static func loadAsync(from url: URL?, maxWidth: CGFloat? = nil) async -> Image? { + guard let url = url else { return nil } + let request = URLRequest(url: url) + guard let (data, _) = try? await URLSession.shared.data(for: request) else { return nil } + guard let image = UIImage(data: data) else { return nil } + if let maxWidth = maxWidth, let image = image.resized(toWidth: maxWidth) { + return Image(uiImage: image) + } + return Image(uiImage: image) + } + + static func load(from string: String, maxWidth: CGFloat? = nil, completion: @escaping @Sendable (_ image: Image?) -> Void) { + guard let imageUrl = URL(string: string) else { return } + return self.load(from: imageUrl, completion: completion) + } + + static func load(from url: URL?, maxWidth: CGFloat? = nil, completion: @escaping @Sendable (_ image: Image?) -> Void) { + guard let url = url else { + completion(nil) + return + } + URLSession.shared.dataTask(with: url) { data, _, error in + if let data = data, let image = UIImage(data: data) { + if let maxWidth = maxWidth, let image = image.resized(toWidth: maxWidth) { + completion(Image(uiImage: image)) + } else { + completion(Image(uiImage: image)) + } + } + }.resume() + } + +} diff --git a/src/mobile/ios/Extensions/UIImage.swift b/src/mobile/ios/Extensions/UIImage.swift new file mode 100644 index 000000000..51e640446 --- /dev/null +++ b/src/mobile/ios/Extensions/UIImage.swift @@ -0,0 +1,25 @@ +// +// UIImage.swift +// ReadLess +// +// Created by thom on 10/6/23. +// + +import Foundation +import UIKit + +extension UIImage { + + func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? { + let size = CGSize(width: width, height: width / size.width * size.height) + return self.resized(toSize: size, isOpaque: isOpaque) + } + + func resized(toSize size: CGSize, isOpaque: Bool = true) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(size, true, 1.0) + self.draw(in: CGRect(origin: .zero, size: size)) + defer { UIGraphicsEndImageContext() } + return UIGraphicsGetImageFromCurrentImageContext() + } + +} diff --git a/src/mobile/ios/Models/BulkResponse.swift b/src/mobile/ios/Models/BulkMetadataResponse.swift similarity index 67% rename from src/mobile/ios/Models/BulkResponse.swift rename to src/mobile/ios/Models/BulkMetadataResponse.swift index f947f25a6..043e843ca 100644 --- a/src/mobile/ios/Models/BulkResponse.swift +++ b/src/mobile/ios/Models/BulkMetadataResponse.swift @@ -10,7 +10,31 @@ import Foundation import AnyCodable #endif -public struct BulkResponse: Codable { +public struct BulkResponse: Codable { + + public var count: Int + public var rows: [T] + + public init(count: Int, rows: [T]) { + self.count = count + self.rows = rows + } + + public enum CodingKeys: String, CodingKey, CaseIterable { + case count + case rows + } + + // Encodable protocol methods + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(count, forKey: .count) + try container.encode(rows, forKey: .rows) + } +} + +public struct BulkMetadataResponse: Codable { public var count: Int public var rows: [T] diff --git a/src/mobile/ios/Models/PublicPublisherAttributes.swift b/src/mobile/ios/Models/PublicPublisherAttributes.swift index 744107cfe..875f02915 100644 --- a/src/mobile/ios/Models/PublicPublisherAttributes.swift +++ b/src/mobile/ios/Models/PublicPublisherAttributes.swift @@ -16,8 +16,8 @@ public struct PublicPublisherAttributes: Codable, Hashable { public var displayName: String public var description: String? - public var icon: String { - return "https://readless.nyc3.cdn.digitaloceanspaces.com/img/pub/\(self.name).png" + public var icon: URL { + return URL(string: "https://readless.nyc3.cdn.digitaloceanspaces.com/img/pub/\(self.name).png")! } public init(name: String, displayName: String, description: String? = nil) { diff --git a/src/mobile/ios/Models/PublicSummaryAttributes.swift b/src/mobile/ios/Models/PublicSummaryAttributes.swift index 66818f5bd..184d66630 100644 --- a/src/mobile/ios/Models/PublicSummaryAttributes.swift +++ b/src/mobile/ios/Models/PublicSummaryAttributes.swift @@ -6,6 +6,7 @@ // import Foundation +import SwiftUI #if canImport(AnyCodable) import AnyCodable #endif @@ -77,4 +78,102 @@ public struct PublicSummaryAttributes: Codable, Hashable { } -public var MOCK_SUMMARY = PublicSummaryAttributes(id: 1, url: "https://readless.ai", title: "Summary Preview", shortSummary: "Short Summary", publisher: PublicPublisherAttributes(name: "cnn", displayName: "CNN"), category: PublicCategoryAttributes(name: "sports", displayName: "Sports", icon: "basketball")) +public class Summary { + + public var root: PublicSummaryAttributes + public var id: Int + public var url: String + public var title: String + public var shortSummary: String? + public var publisher: PublicPublisherAttributes + public var category: PublicCategoryAttributes + public var imageUrl: String? + public var media: [String: String]? + public var originalDate: Date? + public var translations: [String: String]? + + public var deeplink: URL { + return URL(string: "https://readless.ai/read/?s=\(id)")! + } + + public var primaryImageUrl: URL? { + return URL(string: media?["imageArticle"] ?? media?["imageAi1"] ?? imageUrl ?? "") + } + + public init(_ summary: PublicSummaryAttributes) { + self.root = summary + self.id = summary.id + self.url = summary.url + self.title = summary.title + self.shortSummary = summary.shortSummary + self.publisher = summary.publisher + self.category = summary.category + self.imageUrl = summary.imageUrl + self.media = summary.media + self.originalDate = summary.originalDate + self.translations = summary.translations + } + + @Published public var image: Image? + @Published public var publisherIcon: Image? + + public func loadImages() { + Image.load(from: primaryImageUrl, maxWidth: 400) { self.image = $0 } + Image.load(from: publisher.icon) { self.publisherIcon = $0 } + } + + public func loadImagesAsync() async { + image = await Image.loadAsync(from: primaryImageUrl, maxWidth: 400) + publisherIcon = await Image.loadAsync(from: publisher.icon) + } + +} + + +public var MOCK_SUMMARY_1 = Summary( + PublicSummaryAttributes(id: 1, + url: "https://readless.ai", + title: "Summary Preview", + shortSummary: "Short Summary", + publisher: PublicPublisherAttributes(name: "cnn", + displayName: "CNN"), + category: PublicCategoryAttributes(name: "sports", + displayName: "Sports", + icon: "basketball") + )) + +public var MOCK_SUMMARY_2 = Summary( + PublicSummaryAttributes(id: 2, + url: "https://readless.ai", + title: "Summary Preview", + shortSummary: "Short Summary", + publisher: PublicPublisherAttributes(name: "forbes", + displayName: "Forbes"), + category: PublicCategoryAttributes(name: "politics", + displayName: "politics", + icon: "bank") + )) + +public var MOCK_SUMMARY_3 = Summary( + PublicSummaryAttributes(id: 3, + url: "https://readless.ai", + title: "Summary Preview", + shortSummary: "Short Summary", + publisher: PublicPublisherAttributes(name: "forbes", + displayName: "Forbes"), + category: PublicCategoryAttributes(name: "politics", + displayName: "politics", + icon: "bank") + )) + +public var MOCK_SUMMARY_4 = Summary( + PublicSummaryAttributes(id: 4, + url: "https://readless.ai", + title: "Summary Preview", + shortSummary: "Short Summary", + publisher: PublicPublisherAttributes(name: "forbes", + displayName: "Forbes"), + category: PublicCategoryAttributes(name: "politics", + displayName: "politics", + icon: "bank") + )) diff --git a/src/mobile/ios/ReadLess.xcodeproj/project.pbxproj b/src/mobile/ios/ReadLess.xcodeproj/project.pbxproj index a10d7e04c..ad09db1ae 100644 --- a/src/mobile/ios/ReadLess.xcodeproj/project.pbxproj +++ b/src/mobile/ios/ReadLess.xcodeproj/project.pbxproj @@ -35,7 +35,7 @@ 0125BD5229D791ED00EAC61C /* EvilIcons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0125BD3229D791EC00EAC61C /* EvilIcons.ttf */; }; 0125BD5829D791ED00EAC61C /* SimpleLineIcons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0125BD3829D791EC00EAC61C /* SimpleLineIcons.ttf */; }; 0125BD5929D791ED00EAC61C /* MaterialIcons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0125BD3929D791EC00EAC61C /* MaterialIcons.ttf */; }; - 013F32C329E8BA2E00CE8555 /* ConnectService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013F32C229E8BA2D00CE8555 /* ConnectService.swift */; }; + 013F32C329E8BA2E00CE8555 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013F32C229E8BA2D00CE8555 /* APIClient.swift */; }; 0143021C29FACEDC00D40293 /* FontAwesome5_Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0125BD2C29D791EC00EAC61C /* FontAwesome5_Regular.ttf */; }; 0143021D29FACEDC00D40293 /* Zocial.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0125BD3129D791EC00EAC61C /* Zocial.ttf */; }; 0143021E29FACEDC00D40293 /* FontAwesome5_Solid.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0125BD2B29D791EC00EAC61C /* FontAwesome5_Solid.ttf */; }; @@ -54,9 +54,8 @@ 0143022E29FACEDC00D40293 /* MaterialIcons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0125BD3929D791EC00EAC61C /* MaterialIcons.ttf */; }; 015A19D129EAEB1A0072EEE3 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 015A19D029EAEB1A0072EEE3 /* View.swift */; }; 018173BF29EA1842006D3509 /* PressActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018173BE29EA1842006D3509 /* PressActions.swift */; }; - 018173C129EA4529006D3509 /* SummaryCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018173C029EA4529006D3509 /* SummaryCard.swift */; }; 018173C329EA4833006D3509 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018173C229EA4833006D3509 /* Date.swift */; }; - 018CE3B029E98AB9005E6391 /* BulkResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018CE3AF29E98AB9005E6391 /* BulkResponse.swift */; }; + 018CE3B029E98AB9005E6391 /* BulkMetadataResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018CE3AF29E98AB9005E6391 /* BulkMetadataResponse.swift */; }; 01BC567C29DCF36700C69F9A /* main.jsbundle in Resources */ = {isa = PBXBuildFile; fileRef = 01BC567B29DCF36700C69F9A /* main.jsbundle */; }; 07A1D8E328E1A8A03D5EA3A8 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8CAFCC9631D60DF42ACBD13 /* ExpoModulesProvider.swift */; }; 0C80B921A6F3F58F76C31292 /* libPods-ReadLess.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-ReadLess.a */; }; @@ -65,7 +64,12 @@ 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; 5FFC8B3A3B346CCC17615C08 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5C7041D39DDE482042B57D /* ExpoModulesProvider.swift */; }; 7699B88040F8A987B510C191 /* libPods-ReadLess-ReadLessTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 19F6CBCC0A4E27FBF8BF4A61 /* libPods-ReadLess-ReadLessTests.a */; }; + CD4BF5A52AD074F7006BDC35 /* WidgetTopicConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4BF5A42AD074F7006BDC35 /* WidgetTopicConfiguration.swift */; }; + CD4BF5A62AD0797C006BDC35 /* WidgetTopicConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD4BF5A42AD074F7006BDC35 /* WidgetTopicConfiguration.swift */; }; CD78217A2A28FA2100F3B33A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD7821792A28FA2100F3B33A /* StoreKit.framework */; }; + CD92BEB22AD093940096D6DA /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = CDE160082ACC8CDB0036F0A4 /* Intents.intentdefinition */; }; + CDA6AD302AD22FD900514CD1 /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA6AD2F2AD22FD900514CD1 /* Channel.swift */; }; + CDA6AD312AD22FD900514CD1 /* Channel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA6AD2F2AD22FD900514CD1 /* Channel.swift */; }; CDE11B1F2A7701BE00C0BC39 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = CDE11B1E2A7701BE00C0BC39 /* GoogleService-Info.plist */; }; CDE11B212A786FF500C0BC39 /* PublicPublisherAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE11B202A786FF500C0BC39 /* PublicPublisherAttributes.swift */; }; CDE15EEA2ACB27A00036F0A4 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CDE15EEB2ACB27A00036F0A4 /* LaunchScreen.storyboard */; }; @@ -77,13 +81,13 @@ CDE15FC72ACB497F0036F0A4 /* ReadLessWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = CDE15FB82ACB497E0036F0A4 /* ReadLessWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; CDE15FCB2ACB498E0036F0A4 /* PublicPublisherAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE11B202A786FF500C0BC39 /* PublicPublisherAttributes.swift */; }; CDE15FCC2ACB498E0036F0A4 /* PublicSummaryAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010F0D2529E88EC40064DD97 /* PublicSummaryAttributes.swift */; }; - CDE15FCD2ACB498E0036F0A4 /* BulkResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018CE3AF29E98AB9005E6391 /* BulkResponse.swift */; }; + CDE15FCD2ACB498E0036F0A4 /* BulkMetadataResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018CE3AF29E98AB9005E6391 /* BulkMetadataResponse.swift */; }; CDE15FCE2ACB498E0036F0A4 /* PublicCategoryAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010F0D2129E88EC40064DD97 /* PublicCategoryAttributes.swift */; }; CDE15FCF2ACB49920036F0A4 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010F0D8029E89D770064DD97 /* Color.swift */; }; CDE15FD02ACB49920036F0A4 /* PressActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018173BE29EA1842006D3509 /* PressActions.swift */; }; CDE15FD12ACB49920036F0A4 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 015A19D029EAEB1A0072EEE3 /* View.swift */; }; CDE15FD22ACB49920036F0A4 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018173C229EA4833006D3509 /* Date.swift */; }; - CDE15FD32ACB49950036F0A4 /* ConnectService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013F32C229E8BA2D00CE8555 /* ConnectService.swift */; }; + CDE15FD32ACB49950036F0A4 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013F32C229E8BA2D00CE8555 /* APIClient.swift */; }; CDE15FD42ACB49970036F0A4 /* SummaryCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018173C029EA4529006D3509 /* SummaryCard.swift */; }; CDE15FD52ACB499E0036F0A4 /* FontAwesome.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0125BD2F29D791EC00EAC61C /* FontAwesome.ttf */; }; CDE15FD62ACB499E0036F0A4 /* Ionicons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0125BD2A29D791EC00EAC61C /* Ionicons.ttf */; }; @@ -115,6 +119,14 @@ CDE15FF02ACB499E0036F0A4 /* Faustina-Italic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = CDE9EB412A48A3770057217B /* Faustina-Italic.ttf */; }; CDE15FF12ACB499E0036F0A4 /* Newsreader72pt-BoldItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = CDE9EB402A48A3770057217B /* Newsreader72pt-BoldItalic.ttf */; }; CDE15FF22ACB499E0036F0A4 /* AntDesign.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0125BD2529D791EC00EAC61C /* AntDesign.ttf */; }; + CDE160092ACC8CDB0036F0A4 /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = CDE160082ACC8CDB0036F0A4 /* Intents.intentdefinition */; }; + CDE160102ACDEF770036F0A4 /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE1600F2ACDEF770036F0A4 /* Image.swift */; }; + CDE160112ACDEF770036F0A4 /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE1600F2ACDEF770036F0A4 /* Image.swift */; }; + CDE160122ACE08120036F0A4 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + CDE160132ACE08130036F0A4 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + CDE1AC1D2AD04CB800F0E430 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE1AC1C2AD04CB800F0E430 /* UIImage.swift */; }; + CDE1AC1E2AD04CB800F0E430 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE1AC1C2AD04CB800F0E430 /* UIImage.swift */; }; + CDE1AC252AD0585D00F0E430 /* SummaryCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018173C029EA4529006D3509 /* SummaryCard.swift */; }; CDE9EB4E2A48A3770057217B /* Newsreader72pt-BoldItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = CDE9EB402A48A3770057217B /* Newsreader72pt-BoldItalic.ttf */; }; CDE9EB4F2A48A3770057217B /* Newsreader72pt-BoldItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = CDE9EB402A48A3770057217B /* Newsreader72pt-BoldItalic.ttf */; }; CDE9EB502A48A3770057217B /* Faustina-Italic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = CDE9EB412A48A3770057217B /* Faustina-Italic.ttf */; }; @@ -206,6 +218,16 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + CDE160072ACC8C390036F0A4 /* Embed ExtensionKit Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(EXTENSIONS_FOLDER_PATH)"; + dstSubfolderSpec = 16; + files = ( + ); + name = "Embed ExtensionKit Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -242,7 +264,7 @@ 0125BD3229D791EC00EAC61C /* EvilIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = EvilIcons.ttf; sourceTree = ""; }; 0125BD3829D791EC00EAC61C /* SimpleLineIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = SimpleLineIcons.ttf; sourceTree = ""; }; 0125BD3929D791EC00EAC61C /* MaterialIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = MaterialIcons.ttf; sourceTree = ""; }; - 013F32C229E8BA2D00CE8555 /* ConnectService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectService.swift; sourceTree = ""; }; + 013F32C229E8BA2D00CE8555 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; 0150ACA429F9577400875CF7 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 0150ACA629F9577400875CF7 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 015A19D029EAEB1A0072EEE3 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; @@ -251,7 +273,7 @@ 018173C229EA4833006D3509 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; 018CE3AD29E9892B005E6391 /* ReadLessWatch-Watch-App-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "ReadLessWatch-Watch-App-Info.plist"; sourceTree = SOURCE_ROOT; }; 018CE3AE29E989F5005E6391 /* ReadLessWatch Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "ReadLessWatch Watch App.entitlements"; sourceTree = ""; }; - 018CE3AF29E98AB9005E6391 /* BulkResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulkResponse.swift; sourceTree = ""; }; + 018CE3AF29E98AB9005E6391 /* BulkMetadataResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulkMetadataResponse.swift; sourceTree = ""; }; 01BC567B29DCF36700C69F9A /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = main.jsbundle; path = ReadLess/main.jsbundle; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* ReadLess.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadLess.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = ReadLess/AppDelegate.h; sourceTree = ""; }; @@ -264,6 +286,7 @@ 5B7EB9410499542E8C5724F5 /* Pods-ReadLess-ReadLessTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadLess-ReadLessTests.debug.xcconfig"; path = "Target Support Files/Pods-ReadLess-ReadLessTests/Pods-ReadLess-ReadLessTests.debug.xcconfig"; sourceTree = ""; }; 5DCACB8F33CDC322A6C60F78 /* libPods-ReadLess.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-ReadLess.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 89C6BE57DB24E9ADA2F236DE /* Pods-ReadLess-ReadLessTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadLess-ReadLessTests.release.xcconfig"; path = "Target Support Files/Pods-ReadLess-ReadLessTests/Pods-ReadLess-ReadLessTests.release.xcconfig"; sourceTree = ""; }; + CD4BF5A42AD074F7006BDC35 /* WidgetTopicConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WidgetTopicConfiguration.swift; path = /Users/thom/git/noodleofdeath/readless/src/mobile/ios/ReadLessWidget/WidgetTopicConfiguration.swift; sourceTree = ""; }; CD7821792A28FA2100F3B33A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; CD84722A2ACB266C00683959 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Base; path = Base.lproj/Info.plist; sourceTree = ""; }; CD84722C2ACB268E00683959 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = en; path = en.lproj/Info.plist; sourceTree = ""; }; @@ -310,6 +333,7 @@ CD8472562ACB26CD00683959 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = tr; path = tr.lproj/Info.plist; sourceTree = ""; }; CD8472572ACB26CD00683959 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = uk; path = uk.lproj/Info.plist; sourceTree = ""; }; CD8472582ACB26CE00683959 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = vi; path = vi.lproj/Info.plist; sourceTree = ""; }; + CDA6AD2F2AD22FD900514CD1 /* Channel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Channel.swift; sourceTree = ""; }; CDE11B1E2A7701BE00C0BC39 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; CDE11B202A786FF500C0BC39 /* PublicPublisherAttributes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PublicPublisherAttributes.swift; sourceTree = ""; }; CDE15EEB2ACB27A00036F0A4 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = ReadLess/LaunchScreen.storyboard; sourceTree = ""; }; @@ -318,6 +342,11 @@ CDE15FC02ACB497E0036F0A4 /* ReadLessWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadLessWidget.swift; sourceTree = ""; }; CDE15FC22ACB497F0036F0A4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; CDE15FC42ACB497F0036F0A4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + CDE160082ACC8CDB0036F0A4 /* Intents.intentdefinition */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; path = Intents.intentdefinition; sourceTree = ""; }; + CDE1600F2ACDEF770036F0A4 /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; + CDE1601A2ACE23950036F0A4 /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; }; + CDE160252ACE23950036F0A4 /* IntentsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IntentsUI.framework; path = System/Library/Frameworks/IntentsUI.framework; sourceTree = SDKROOT; }; + CDE1AC1C2AD04CB800F0E430 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; CDE9EB402A48A3770057217B /* Newsreader72pt-BoldItalic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Newsreader72pt-BoldItalic.ttf"; sourceTree = ""; }; CDE9EB412A48A3770057217B /* Faustina-Italic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Faustina-Italic.ttf"; sourceTree = ""; }; CDE9EB422A48A3770057217B /* AnekLatin-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "AnekLatin-Regular.ttf"; sourceTree = ""; }; @@ -449,7 +478,7 @@ CDE11B202A786FF500C0BC39 /* PublicPublisherAttributes.swift */, 010F0D2529E88EC40064DD97 /* PublicSummaryAttributes.swift */, 010F0D2129E88EC40064DD97 /* PublicCategoryAttributes.swift */, - 018CE3AF29E98AB9005E6391 /* BulkResponse.swift */, + 018CE3AF29E98AB9005E6391 /* BulkMetadataResponse.swift */, ); path = Models; sourceTree = ""; @@ -498,6 +527,8 @@ 018173BE29EA1842006D3509 /* PressActions.swift */, 018173C229EA4833006D3509 /* Date.swift */, 015A19D029EAEB1A0072EEE3 /* View.swift */, + CDE1600F2ACDEF770036F0A4 /* Image.swift */, + CDE1AC1C2AD04CB800F0E430 /* UIImage.swift */, ); path = Extensions; sourceTree = ""; @@ -505,7 +536,7 @@ 0150ACC229F957E400875CF7 /* Services */ = { isa = PBXGroup; children = ( - 013F32C229E8BA2D00CE8555 /* ConnectService.swift */, + 013F32C229E8BA2D00CE8555 /* APIClient.swift */, ); path = Services; sourceTree = ""; @@ -548,6 +579,8 @@ 19F6CBCC0A4E27FBF8BF4A61 /* libPods-ReadLess-ReadLessTests.a */, 0150ACA429F9577400875CF7 /* WidgetKit.framework */, 0150ACA629F9577400875CF7 /* SwiftUI.framework */, + CDE1601A2ACE23950036F0A4 /* Intents.framework */, + CDE160252ACE23950036F0A4 /* IntentsUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -641,6 +674,9 @@ children = ( CDE15FBC2ACB497E0036F0A4 /* ReadLessWidgetBundle.swift */, CDE15FC02ACB497E0036F0A4 /* ReadLessWidget.swift */, + CDA6AD2F2AD22FD900514CD1 /* Channel.swift */, + CD4BF5A42AD074F7006BDC35 /* WidgetTopicConfiguration.swift */, + CDE160082ACC8CDB0036F0A4 /* Intents.intentdefinition */, CDE15FC22ACB497F0036F0A4 /* Assets.xcassets */, CDE15FC42ACB497F0036F0A4 /* Info.plist */, ); @@ -741,6 +777,7 @@ 01F6EA7829E6FA3C00F51BBA /* Embed Watch Content */, CD6345222A192BF100CB264B /* Embed Foundation Extensions */, 0C61BC585057FDAEA7DDC87A /* [CP-User] [RNFB] Core Configuration */, + CDE160072ACC8C390036F0A4 /* Embed ExtensionKit Extensions */, ); buildRules = ( ); @@ -779,7 +816,7 @@ BuildIndependentTargetsInParallel = YES; CLASSPREFIX = I; LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1500; TargetAttributes = { 00E356ED1AD99517003FC87E = { CreatedOnToolsVersion = 6.2; @@ -897,6 +934,7 @@ CDE9EB5B2A48A3770057217B /* Newsreader72pt-Bold.ttf in Resources */, 0143022E29FACEDC00D40293 /* MaterialIcons.ttf in Resources */, 0143022B29FACEDC00D40293 /* MaterialCommunityIcons.ttf in Resources */, + CDE160132ACE08130036F0A4 /* Images.xcassets in Resources */, CDE9EB4F2A48A3770057217B /* Newsreader72pt-BoldItalic.ttf in Resources */, 0143022529FACEDC00D40293 /* EvilIcons.ttf in Resources */, CDE9EB5D2A48A3770057217B /* AnekLatin-Bold.ttf in Resources */, @@ -977,6 +1015,7 @@ buildActionMask = 2147483647; files = ( CDE15FE12ACB499E0036F0A4 /* Faustina-BoldItalic.ttf in Resources */, + CDE160122ACE08120036F0A4 /* Images.xcassets in Resources */, CDE15FE82ACB499E0036F0A4 /* AnekLatin-Regular.ttf in Resources */, CDE15FC32ACB497F0036F0A4 /* Assets.xcassets in Resources */, CDE15FDB2ACB499E0036F0A4 /* FontAwesome5_Solid.ttf in Resources */, @@ -1227,18 +1266,20 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 018173C129EA4529006D3509 /* SummaryCard.swift in Sources */, 010F0CE629E88D470064DD97 /* ContentView.swift in Sources */, 018173BF29EA1842006D3509 /* PressActions.swift in Sources */, 018173C329EA4833006D3509 /* Date.swift in Sources */, - 013F32C329E8BA2E00CE8555 /* ConnectService.swift in Sources */, + 013F32C329E8BA2E00CE8555 /* APIClient.swift in Sources */, CDE11B212A786FF500C0BC39 /* PublicPublisherAttributes.swift in Sources */, - 018CE3B029E98AB9005E6391 /* BulkResponse.swift in Sources */, + CDE160102ACDEF770036F0A4 /* Image.swift in Sources */, + 018CE3B029E98AB9005E6391 /* BulkMetadataResponse.swift in Sources */, 010F0D8129E89D770064DD97 /* Color.swift in Sources */, 010F0CE429E88D470064DD97 /* ReadLessWatchApp.swift in Sources */, 010F0D5629E88EC40064DD97 /* PublicSummaryAttributes.swift in Sources */, + CDE1AC252AD0585D00F0E430 /* SummaryCard.swift in Sources */, 015A19D129EAEB1A0072EEE3 /* View.swift in Sources */, 010F0D5229E88EC40064DD97 /* PublicCategoryAttributes.swift in Sources */, + CDE1AC1D2AD04CB800F0E430 /* UIImage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1263,8 +1304,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + CD4BF5A62AD0797C006BDC35 /* WidgetTopicConfiguration.swift in Sources */, 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */, + CDA6AD302AD22FD900514CD1 /* Channel.swift in Sources */, 13B07FC11A68108700A75B9A /* main.m in Sources */, + CD92BEB22AD093940096D6DA /* Intents.intentdefinition in Sources */, 5FFC8B3A3B346CCC17615C08 /* ExpoModulesProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1274,17 +1318,22 @@ buildActionMask = 2147483647; files = ( CDE15FC12ACB497E0036F0A4 /* ReadLessWidget.swift in Sources */, - CDE15FD32ACB49950036F0A4 /* ConnectService.swift in Sources */, + CDE15FD32ACB49950036F0A4 /* APIClient.swift in Sources */, CDE15FD12ACB49920036F0A4 /* View.swift in Sources */, CDE15FCC2ACB498E0036F0A4 /* PublicSummaryAttributes.swift in Sources */, + CDE160112ACDEF770036F0A4 /* Image.swift in Sources */, CDE15FCB2ACB498E0036F0A4 /* PublicPublisherAttributes.swift in Sources */, - CDE15FCD2ACB498E0036F0A4 /* BulkResponse.swift in Sources */, + CDE15FCD2ACB498E0036F0A4 /* BulkMetadataResponse.swift in Sources */, + CDE160092ACC8CDB0036F0A4 /* Intents.intentdefinition in Sources */, + CDE1AC1E2AD04CB800F0E430 /* UIImage.swift in Sources */, CDE15FCF2ACB49920036F0A4 /* Color.swift in Sources */, CDE15FBD2ACB497E0036F0A4 /* ReadLessWidgetBundle.swift in Sources */, CDE15FD22ACB49920036F0A4 /* Date.swift in Sources */, CDE15FD02ACB49920036F0A4 /* PressActions.swift in Sources */, + CDA6AD312AD22FD900514CD1 /* Channel.swift in Sources */, CDE15FCE2ACB498E0036F0A4 /* PublicCategoryAttributes.swift in Sources */, CDE15FD42ACB49970036F0A4 /* SummaryCard.swift in Sources */, + CD4BF5A52AD074F7006BDC35 /* WidgetTopicConfiguration.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1460,7 +1509,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 1.2.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -1506,7 +1555,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 1.2.0; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = ai.readless.ios.watchkitapp; @@ -1668,11 +1717,12 @@ INFOPLIST_FILE = ReadLess/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Read Less "; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.magazines-and-newspapers"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.16.0; + MARKETING_VERSION = 1.16.3; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1681,7 +1731,6 @@ OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = ai.readless.ios; PRODUCT_NAME = ReadLess; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -1709,11 +1758,12 @@ INFOPLIST_FILE = ReadLess/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Read Less "; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.magazines-and-newspapers"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.16.0; + MARKETING_VERSION = 1.16.3; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1722,7 +1772,6 @@ OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = ai.readless.ios; PRODUCT_NAME = ReadLess; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_VERSION = 5.0; diff --git a/src/mobile/ios/ReadLess.xcodeproj/xcshareddata/xcschemes/ReadLess.xcscheme b/src/mobile/ios/ReadLess.xcodeproj/xcshareddata/xcschemes/ReadLess.xcscheme index 972ee7023..7fc437dbd 100644 --- a/src/mobile/ios/ReadLess.xcodeproj/xcshareddata/xcschemes/ReadLess.xcscheme +++ b/src/mobile/ios/ReadLess.xcodeproj/xcshareddata/xcschemes/ReadLess.xcscheme @@ -1,6 +1,6 @@ + + diff --git a/src/mobile/ios/ReadLess/Info.plist b/src/mobile/ios/ReadLess/Info.plist index df455b25c..bd0581c14 100644 --- a/src/mobile/ios/ReadLess/Info.plist +++ b/src/mobile/ios/ReadLess/Info.plist @@ -74,6 +74,10 @@ $(PRODUCT_NAME) wants to save photos NSPhotoLibraryUsageDescription $(PRODUCT_NAME) wants to access photos + NSUserActivityTypes + + IWidgetTopicConfigurationIntent + UIAppFonts AnekLatin-Regular.ttf @@ -108,6 +112,7 @@ UIBackgroundModes audio + fetch remote-notification UILaunchStoryboardName diff --git a/src/mobile/ios/ReadLessWatch Watch App/Views/ContentView.swift b/src/mobile/ios/ReadLessWatch Watch App/Views/ContentView.swift index db75bf635..ca0ef3cff 100644 --- a/src/mobile/ios/ReadLessWatch Watch App/Views/ContentView.swift +++ b/src/mobile/ios/ReadLessWatch Watch App/Views/ContentView.swift @@ -8,9 +8,9 @@ import SwiftUI struct ContentView: View { - @ObservedObject private var service: ConnectService = ConnectService() - @Environment(\.colorScheme) private var colorScheme + @ObservedObject private var service = APIClient() + @State var selectedSummary: PublicSummaryAttributes? var background: Color { @@ -32,11 +32,11 @@ struct ContentView: View { } else { List(self.service.summaries, id: \.id) { summary in NavigationLink( - destination: ScrollView { SummaryCard(summary: summary, compact: false) + destination: ScrollView { SummaryCard(summary: summary, style: .small, expanded: true) }.navigationTitle(summary.translations?["title"] ?? summary.title) , - tag: summary, + tag: summary.root, selection: $selectedSummary) { - SummaryCard(summary: summary, compact: true) + SummaryCard(summary: summary, style: .small) } }.refreshable { self.service.fetchSync() diff --git a/src/mobile/ios/ReadLessWidget/Channel.swift b/src/mobile/ios/ReadLessWidget/Channel.swift new file mode 100644 index 000000000..9290069cf --- /dev/null +++ b/src/mobile/ios/ReadLessWidget/Channel.swift @@ -0,0 +1,26 @@ +// +// Enum.swift +// +// +// Created by thom on 10/7/23. +// + +import Foundation +import AppIntents + +@available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *) +enum Channel: String, AppEnum { + case liveFeed + case topStories + case customTopic + + static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Feed Type") + static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .liveFeed: "Live Feed", + .topStories: "Top Stories", + .customTopic: "Custom Topic" + ] + +} + + diff --git a/src/mobile/ios/ReadLessWidget/Info.plist b/src/mobile/ios/ReadLessWidget/Info.plist index 0f118fb75..e4beea5ad 100644 --- a/src/mobile/ios/ReadLessWidget/Info.plist +++ b/src/mobile/ios/ReadLessWidget/Info.plist @@ -2,6 +2,19 @@ + NSAppTransportSecurity + + NSExceptionDomains + + readless.ai + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + NSExtension NSExtensionPointIdentifier diff --git a/src/mobile/ios/ReadLessWidget/Intents.intentdefinition b/src/mobile/ios/ReadLessWidget/Intents.intentdefinition new file mode 100644 index 000000000..5ed6e85a1 --- /dev/null +++ b/src/mobile/ios/ReadLessWidget/Intents.intentdefinition @@ -0,0 +1,281 @@ + + + + + INEnums + + + INEnumDisplayName + Channel + INEnumDisplayNameID + j3pgMG + INEnumGeneratesHeader + + INEnumName + Channel + INEnumType + Regular + INEnumValues + + + INEnumValueDisplayName + Live Feed + INEnumValueDisplayNameID + yHsbIT + INEnumValueName + unknown + + + INEnumValueDisplayName + Top Stories + INEnumValueDisplayNameID + jjAaOW + INEnumValueIndex + 1 + INEnumValueName + topStories + + + INEnumValueDisplayName + Custom Topic + INEnumValueDisplayNameID + GHz1XP + INEnumValueIndex + 2 + INEnumValueName + customTopic + + + + + INIntentDefinitionModelVersion + 1.2 + INIntentDefinitionNamespace + 4Ax91W + INIntentDefinitionSystemVersion + 22G91 + INIntentDefinitionToolsBuildVersion + 15A240d + INIntentDefinitionToolsVersion + 15.0 + INIntents + + + INIntentCategory + information + INIntentClassPrefix + I + INIntentDescription + Intent for configuring the topic of a widget + INIntentDescriptionID + vlfd95 + INIntentEligibleForWidgets + + INIntentIneligibleForSuggestions + + INIntentLastParameterTag + 20 + INIntentName + WidgetTopicConfiguration + INIntentParameters + + + INIntentParameterConfigurable + + INIntentParameterDisplayName + Channel + INIntentParameterDisplayNameID + INvacD + INIntentParameterDisplayPriority + 1 + INIntentParameterEnumType + Channel + INIntentParameterEnumTypeNamespace + 4Ax91W + INIntentParameterMetadata + + INIntentParameterMetadataDefaultValue + liveFeed + + INIntentParameterName + channel + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Primary + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Just to confirm, you wanted ‘${channel}’? + INIntentParameterPromptDialogFormatStringID + natRPv + INIntentParameterPromptDialogType + Confirmation + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + There are ${count} options matching ‘${channel}’. + INIntentParameterPromptDialogFormatStringID + 6pQo3g + INIntentParameterPromptDialogType + DisambiguationIntroduction + + + INIntentParameterTag + 20 + INIntentParameterType + Integer + + + INIntentParameterConfigurable + + INIntentParameterDisplayName + Topic + INIntentParameterDisplayNameID + Q88ETS + INIntentParameterDisplayPriority + 2 + INIntentParameterMetadata + + INIntentParameterMetadataCapitalization + Words + INIntentParameterMetadataDefaultValue + Technology + INIntentParameterMetadataDefaultValueID + 2Lnniv + INIntentParameterMetadataDisableAutocorrect + + INIntentParameterMetadataDisableSmartDashes + + INIntentParameterMetadataDisableSmartQuotes + + + INIntentParameterName + topic + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Topic + INIntentParameterPromptDialogFormatStringID + YAndPU + INIntentParameterPromptDialogType + Primary + + + INIntentParameterRelationship + + INIntentParameterRelationshipParentName + channel + INIntentParameterRelationshipPredicateName + EnumHasExactValue + INIntentParameterRelationshipPredicateValue + customTopic + + INIntentParameterTag + 5 + INIntentParameterType + String + + + INIntentParameterConfigurable + + INIntentParameterDisplayName + Update Interval + INIntentParameterDisplayNameID + AnaQy4 + INIntentParameterDisplayPriority + 3 + INIntentParameterMetadata + + INIntentParameterMetadataDefaultUnit + Seconds + INIntentParameterMetadataDefaultValue + 10 + INIntentParameterMetadataMaximumUnit + Days + INIntentParameterMetadataMaximumValue + 360 + INIntentParameterMetadataMinimumUnit + Seconds + INIntentParameterMetadataMinimumValue + 1 + INIntentParameterMetadataUnit + Minutes + + INIntentParameterName + updateInterval + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Primary + + + INIntentParameterTag + 12 + INIntentParameterType + TimeInterval + + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseCodeName + failure + + + INIntentResponseLastParameterTag + 1 + + INIntentTitle + Widget Topic Configuration + INIntentTitleID + jtISxm + INIntentType + Custom + INIntentVerb + View + + + INTypes + + + diff --git a/src/mobile/ios/ReadLessWidget/ReadLessWidget.swift b/src/mobile/ios/ReadLessWidget/ReadLessWidget.swift index d32c9dc05..a5a8f4a1c 100644 --- a/src/mobile/ios/ReadLessWidget/ReadLessWidget.swift +++ b/src/mobile/ios/ReadLessWidget/ReadLessWidget.swift @@ -6,51 +6,233 @@ // import WidgetKit +import AppIntents +import Intents import SwiftUI +let DEFAULT_TIMELINE_INTERVAL: Double = 10 + +struct CustomWidgetConfiguration { + var channel: Channel = .liveFeed + var topic: String? + var updateInterval: Measurement? +} + struct SummaryEntry: TimelineEntry { - let date: Date - var summary: PublicSummaryAttributes? + var date: Date = .now + var context: TimelineProviderContext? + var config: CustomWidgetConfiguration? + var summaries = [Summary]() } -struct Provider: TimelineProvider { - - @Environment(\.colorScheme) private var colorScheme +var WidgetPageSize: Dictionary = [ + .systemSmall: 1, + .systemMedium: 2, + .systemLarge: 5, + .systemExtraLarge: 5, +] + +var WidgetPlaceholders: Dictionary = [ + .systemSmall: [MOCK_SUMMARY_1, MOCK_SUMMARY_2, MOCK_SUMMARY_3], + .systemMedium: [MOCK_SUMMARY_1, MOCK_SUMMARY_2], + .systemLarge: [MOCK_SUMMARY_1, MOCK_SUMMARY_2, MOCK_SUMMARY_3, MOCK_SUMMARY_4], + .systemExtraLarge: [MOCK_SUMMARY_1, MOCK_SUMMARY_2, MOCK_SUMMARY_3, MOCK_SUMMARY_4], +] + +func buildEntries(in context: TimelineProviderContext, + for configuration: CustomWidgetConfiguration) async -> [SummaryEntry] { + let endpoint = configuration.channel == .topStories ? Endpoints.GetTopStories : Endpoints.GetSummaries + let filter = configuration.channel == .topStories ? "" : configuration.channel == .liveFeed ? "" : configuration.topic + let summaries = Array(await APIClient().fetchAsync(endpoint: endpoint, + filter: filter).reversed()) + let pageSize = WidgetPageSize[context.family] ?? 2 + var entries: [SummaryEntry] = [] + for i in stride(from: 0, to: summaries.count, by: pageSize) { + let first = summaries[i] + if context.family != .systemSmall { + await first.loadImagesAsync() + } + var subset = [first] + for j in 1 ..< pageSize { + if let next = i + j < summaries.count ? summaries[i + j] : nil { + if context.family != .systemSmall { + await next.loadImagesAsync() + } + subset.insert(next, at: 0) + } + } + let offset = (configuration.updateInterval?.value ?? DEFAULT_TIMELINE_INTERVAL) * 60 + let fireDate = Date(timeIntervalSinceNow: TimeInterval(floor(Double(i) / Double(pageSize)) * offset)) + let entry = SummaryEntry(date: fireDate, + context: context, + config: configuration, + summaries: subset) + entries.append(entry) + } + return entries +} + +struct Provider: IntentTimelineProvider { func placeholder(in context: Context) -> SummaryEntry { - SummaryEntry(date: Date(), summary: nil) + return SummaryEntry(context: context, + config: CustomWidgetConfiguration(topic: "Technology"), + summaries: WidgetPlaceholders[context.family] ?? []) } - func getSnapshot(in context: Context, completion: @escaping (SummaryEntry) -> ()) { - let entry = SummaryEntry(date: Date(), summary: nil) + func getSnapshot(for configuration: IWidgetTopicConfigurationIntent, + in context: Context, + completion: @escaping (SummaryEntry) -> ()) { + let entry = SummaryEntry(context: context, + config: CustomWidgetConfiguration(topic: configuration.topic, + updateInterval: Measurement(value: configuration.updateInterval?.doubleValue ?? DEFAULT_TIMELINE_INTERVAL, + unit: .minutes))) completion(entry) } - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - var entries: [SummaryEntry] = [] - let service = ConnectService() - service.fetchSync() { summaries in - for summary in summaries { - if let date = summary.originalDate { - let entry = SummaryEntry(date: date, summary: summary) - entries.append(entry) - } - } + func getTimeline(for configuration: IWidgetTopicConfigurationIntent, + in context: Context, + completion: @escaping (Timeline) -> Void) { + Task { + let config = CustomWidgetConfiguration(channel: Channel.allCases[configuration.channel.rawValue], + topic: configuration.topic, + updateInterval: Measurement(value: configuration.updateInterval?.doubleValue ?? DEFAULT_TIMELINE_INTERVAL, + unit: .minutes)) + let entries = await buildEntries(in: context, for: config) let timeline = Timeline(entries: entries, policy: .atEnd) completion(timeline) } } + +} + +@available(iOS 17.0, *) +struct TopicDetail: AppEntity { + + var id: String + let name: String + + static var defaultQuery = TopicQuery() + + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Topic" + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } + + static func allTopics() async -> [TopicDetail] { + return await APIClient().getCategories().map { TopicDetail(id: $0.name, name: $0.name) } + } + +} + +@available(iOS 17, *) +struct TopicQuery: EntityQuery { + func entities(for identifiers: [TopicDetail.ID]) async throws -> [TopicDetail] { + return await TopicDetail.allTopics() + } + + func suggestedEntities() async throws -> [TopicDetail] { + return await TopicDetail.allTopics() + } + + func defaultResult() async -> TopicDetail? { + try? await suggestedEntities().first + } +} + +@available(iOS 17.0, *) +struct AppIntentProvider: AppIntentTimelineProvider { + + typealias Entry = SummaryEntry + typealias Intent = WidgetTopicConfiguration + + func placeholder(in context: Context) -> SummaryEntry { + return SummaryEntry(context: context, + config: CustomWidgetConfiguration(topic: "Technology"), + summaries: WidgetPlaceholders[context.family] ?? []) + } + + func snapshot(for configuration: WidgetTopicConfiguration, + in context: Context) async -> SummaryEntry { + SummaryEntry(context: context, + config: CustomWidgetConfiguration(topic: configuration.topic, + updateInterval: configuration.updateInterval)) + } + + func timeline(for configuration: WidgetTopicConfiguration, + in context: Context) async -> Timeline { + let config = CustomWidgetConfiguration(channel: configuration.channel ?? .liveFeed, + topic: configuration.topic, + updateInterval: configuration.updateInterval) + let entries = await buildEntries(in: context, for: config) + let timeline = Timeline(entries: entries, policy: .atEnd) + return timeline + } + } struct ReadLessWidgetEntryView : View { + + @Environment(\.colorScheme) private var colorScheme + var entry: Provider.Entry + + let iconSize = 20.0 + + var deeplink: URL { + if entry.context?.family == .systemSmall { + return entry.summaries.first?.deeplink ?? URL(string: "https://readless.ai/top")! + } + if entry.config?.channel == .liveFeed { + return URL(string: "https://readless.ai/live")! + } + if entry.config?.channel == .topStories { + return URL(string: "https://readless.ai/top")! + } + return URL(string: "https://readless.ai/search?filter=\(entry.config?.topic ?? "")")! + } var body: some View { - VStack { - if let summary = entry.summary { - SummaryCard(summary: summary, compact: false) + VStack(spacing: 8.0) { + HStack { + Text(entry.config?.channel == .liveFeed ? "Live Feed" : + entry.config?.channel == .topStories ? "Top Stories" : + entry.config?.topic ?? "Topic") + .textCase(.uppercase) + .font(.subheadline) + .bold() + .padding(0) + Spacer() + if colorScheme == .light { + Image("LogoCompact") + .resizable() + .scaledToFit() + .frame(width: iconSize, height: iconSize) + } else { + Image("LogoCompact") + .resizable() + .colorInvert() + .scaledToFit() + .frame(width: iconSize, height: iconSize) + } + } + if (entry.config?.topic == nil) { + ForEach(0 ..< 2) { _ in + SummaryCard(style: entry.context?.family == .systemSmall ? .small : .medium) + } + } else + if (entry.summaries.count == 0) { + Text("No results found") + } else { + ForEach(entry.summaries, id: \.id) { + SummaryCard(summary: $0, + style: entry.context?.family == .systemSmall ? .small : .medium, + deeplink: true) + } } } + .widgetURL(deeplink) } } @@ -58,23 +240,28 @@ struct ReadLessWidget: Widget { let kind: String = "ReadLessWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: Provider()) { entry in - if #available(iOS 17.0, *) { - ReadLessWidgetEntryView(entry: entry) + if #available(iOS 17.0, macOS 14.0, watchOS 10.0, *) { + return AppIntentConfiguration(kind: kind, + intent: WidgetTopicConfiguration.self, + provider: AppIntentProvider()) { + ReadLessWidgetEntryView(entry: $0) .containerBackground(.fill.tertiary, for: .widget) - } else { - ReadLessWidgetEntryView(entry: entry) + } + .configurationDisplayName("Topic") + .description("Choose a topic") + .supportedFamilies([.systemMedium, .systemLarge]) + } else { + return IntentConfiguration(kind: kind, + intent: IWidgetTopicConfigurationIntent.self, + provider: Provider()) { + ReadLessWidgetEntryView(entry: $0) .padding() .background() } + .configurationDisplayName("Topic") + .description("Choose a topic") + .supportedFamilies([.systemMedium, .systemLarge]) } - .configurationDisplayName("My Widget") - .description("This is an example widget.") } } -#Preview(as: .systemSmall) { - ReadLessWidget() -} timeline: { - SummaryEntry(date: .now, summary: MOCK_SUMMARY) -} diff --git a/src/mobile/ios/ReadLessWidget/WidgetTopicConfiguration.swift b/src/mobile/ios/ReadLessWidget/WidgetTopicConfiguration.swift new file mode 100644 index 000000000..5c8f6a974 --- /dev/null +++ b/src/mobile/ios/ReadLessWidget/WidgetTopicConfiguration.swift @@ -0,0 +1,69 @@ +// +// WidgetTopicConfiguration.swift +// ReadLess +// +// Created by thom on 10/7/23. +// + +import Foundation +import AppIntents + +@available(iOS 17.0, macOS 14.0, watchOS 10.0, *) +struct WidgetTopicConfiguration: AppIntent, WidgetConfigurationIntent, CustomIntentMigratedAppIntent, PredictableIntent { + static let intentClassName = "IWidgetTopicConfigurationIntent" + + static var title: LocalizedStringResource = "Widget Topic Configuration" + static var description = IntentDescription("Intent for configuring the topic of a widget") + + @Parameter(title: "Channel", default: .liveFeed) + var channel: Channel? + + @Parameter(title: "Topic", default: "") + var topic: String? + + @Parameter(title: "Update Interval", defaultValue: 10, defaultUnit: .minutes) + var updateInterval: Measurement? + + static var parameterSummary: some ParameterSummary { + When(\.$channel, .equalTo, .customTopic) { + Summary { + \.$channel + \.$topic + \.$updateInterval + } + } otherwise: { + Summary { + \.$channel + \.$updateInterval + } + } + } + + static var predictionConfiguration: some IntentPredictionConfiguration { + IntentPrediction(parameters: (\.$channel, \.$topic, \.$updateInterval)) { feedType, topic, updateInterval in + DisplayRepresentation( + title: "", + subtitle: "" + ) + } + IntentPrediction(parameters: (\.$channel, \.$updateInterval)) { feedType, updateInterval in + DisplayRepresentation( + title: "", + subtitle: "" + ) + } + } + + func perform() async throws -> some IntentResult { + // TODO: Place your refactored intent handler code here. + return .result() + } +} + +@available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *) +fileprivate extension IntentDialog { + static var topicParameterPrompt: Self { + "Topic" + } +} + diff --git a/src/mobile/ios/Services/APIClient.swift b/src/mobile/ios/Services/APIClient.swift new file mode 100644 index 000000000..e51bbd611 --- /dev/null +++ b/src/mobile/ios/Services/APIClient.swift @@ -0,0 +1,116 @@ +// +// ConnectService.swift +// ReadLessWatch Watch App +// +// Created by tmorgan on 4/13/23. +// + +import SwiftUI + +struct Endpoints { + static let Root = "https://api.readless.ai/v1" + static let GetSummaries = "\(Root)/summary" + static let GetTopStories = "\(Root)/summary/top" + static let GetCategories = "\(Root)/category" +} + +func parseQuery(endpoint: String = Endpoints.GetSummaries, filter: String?) -> URL? { + guard let filter = filter?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) + else { return URL(string: endpoint) } + return URL(string: endpoint + "?filter=\(filter)") +} + +class APIClient: ObservableObject { + @Published var summaries = [Summary]() + @Published var loading = false + @Published var error: String? + + var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { (decoder) -> Date in + let container = try decoder.singleValueContainer() + let dateString = try container.decode(String.self) + let dateFormatter = DateFormatter() + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" + if let date = dateFormatter.date(from: dateString) { + return date + } + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + if let date = dateFormatter.date(from: dateString) { + return date + } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date format") + } + return decoder + } + + init(summaries: [Summary] = [Summary]()) { + self.summaries = summaries + } + + func fetchHandler(_ data: Data?) -> [Summary] { + if let data = data { + do { + let decodedResponse = try decoder.decode(BulkResponse.self, from: data) + self.summaries = decodedResponse.rows.map { Summary($0) } + } catch { + print(error) + self.error = error.localizedDescription + } + } + self.loading = false + return self.summaries + } + + @Sendable func fetchSync() { + return self.fetchSync(nil) + } + + @Sendable func fetchSync(endpoint: String = Endpoints.GetSummaries, filter: String? = "", _ callback: ((_ summaries: [Summary]) -> ())?) { + guard let url = parseQuery(endpoint: endpoint, filter: filter) else { + return + } + loading = true + error = nil + let request = URLRequest(url: url) + URLSession.shared.dataTask(with: request) { (data, _, _) in + DispatchQueue.main.async { + let summaries = self.fetchHandler(data) + callback?(summaries) + } + }.resume() + } + + @Sendable func fetchAsync(endpoint: String = Endpoints.GetSummaries, filter: String? = "") async -> [Summary] { + guard let url = parseQuery(endpoint: endpoint, filter: filter) else { + return [] + } + loading = true + error = nil + let request = URLRequest(url: url) + guard let (data, _) = try? await URLSession.shared.data(for: request) else { + return [] + } + return self.fetchHandler(data) + } + + @Sendable func getCategories() async -> [PublicCategoryAttributes] { + guard let url = URL(string: Endpoints.GetCategories) else { return [] } + print("fetching categories") + let request = URLRequest(url: url) + guard let (data, _) = try? await URLSession.shared.data(for: request) else { + return [] + } + print("fetched categories") + do { + let decodedResponse = try decoder.decode(BulkResponse.self, from: data) + print(decodedResponse.rows) + return decodedResponse.rows + } catch { + print(error) + } + return [] + } + +} diff --git a/src/mobile/ios/Services/ConnectService.swift b/src/mobile/ios/Services/ConnectService.swift deleted file mode 100644 index 71f47d86d..000000000 --- a/src/mobile/ios/Services/ConnectService.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// ConnectService.swift -// ReadLessWatch Watch App -// -// Created by tmorgan on 4/13/23. -// - -import SwiftUI - -struct Endpoints { - static let GetSummaries = "https://api.readless.ai/v1/summary" -} - -class ConnectService: ObservableObject { - @Published var summaries = [PublicSummaryAttributes]() - @Published var loading = false - @Published var error: String? - - init(summaries: [PublicSummaryAttributes] = [PublicSummaryAttributes]()) { - self.summaries = summaries - } - - func fetchHandler(_ data: Data?) -> [PublicSummaryAttributes] { - if let data = data { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { (decoder) -> Date in - let container = try decoder.singleValueContainer() - let dateString = try container.decode(String.self) - let dateFormatter = DateFormatter() - dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" - if let date = dateFormatter.date(from: dateString) { - return date - } - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" - if let date = dateFormatter.date(from: dateString) { - return date - } - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date format") - } - do { - let decodedResponse = try decoder.decode(BulkResponse.self, from: data) - self.summaries = decodedResponse.rows - } catch { - print(error) - self.error = error.localizedDescription - } - } - self.loading = false - return self.summaries - } - - @Sendable func fetchSync() { - return self.fetchSync(nil) - } - - @Sendable func fetchSync(_ callback: ((_ summaries: [PublicSummaryAttributes]) -> ())?) { - guard let url = URL(string: Endpoints.GetSummaries) else { - return - } - loading = true - error = nil - let request = URLRequest(url: url) - URLSession.shared.dataTask(with: request) { (data, _, _) in - DispatchQueue.main.async { - let summaries = self.fetchHandler(data) - callback?(summaries) - } - }.resume() - } - - @Sendable func fetchAsync() async -> [PublicSummaryAttributes] { - guard let url = URL(string: Endpoints.GetSummaries) else { - return [] - } - loading = true - error = nil - let request = URLRequest(url: url) - guard let (data, _) = try? await URLSession.shared.data(for: request) else { - return [] - } - return self.fetchHandler(data) - } - -} diff --git a/src/mobile/ios/Views/SummaryCard.swift b/src/mobile/ios/Views/SummaryCard.swift index c1d8629d2..45f74335c 100644 --- a/src/mobile/ios/Views/SummaryCard.swift +++ b/src/mobile/ios/Views/SummaryCard.swift @@ -7,88 +7,201 @@ import SwiftUI +enum SummaryCardStyle { + case small + case medium +} + struct SummaryCard: View { - let summary: PublicSummaryAttributes - let compact: Bool - @State private var image: Image? = nil - @State private var publisherIcon: Image? = nil + var summary: Summary? + let style: SummaryCardStyle + var expanded: Bool = false + var deeplink: Bool = false + + @State var image: Image? = nil + @State var publisherIcon: Image? = nil @Environment(\.colorScheme) private var colorScheme - var card: Color { - return colorScheme == .light ? Color(hex: 0xEEEEEE) : Color(hex: 0x111111) + var backdrop: Color { + return colorScheme == .light ? Color(hex: 0xffffff, alpha: 0.3) : Color(hex: 0x000000, alpha: 0.3) } - var primary = Color(hex: 0x8B0000) - - var body: some View { - VStack(spacing: 1) { - VStack(alignment: .leading) { - HStack { - if let image = publisherIcon { - image - .fixedSize() - .frame(width: 20, height: 20) - .aspectRatio(contentMode: .fit) - } else { - Text("") - .onAppear { - loadImage(summary.publisher.icon) { image in self.publisherIcon = image } - } - } - Text(summary.publisher.displayName) - .frame(maxWidth: .infinity) - .padding(3) + var placeholder: Color { + return colorScheme == .light ? Color(hex: 0xf0f0f0) : Color(hex: 0x303030) + } + + var headerHeight: CGFloat { + return 15.0 + } + + var headerFont: Font { + return .system(size: 10) + } + + var titleFont: Font { + return .caption + } + + var imageHeight: CGFloat { + return style == .small ? 170.0 : 55.0 + } + + var HEADER: some View { + HStack { + if let summary = summary { + if let image = summary.publisherIcon ?? publisherIcon { + image + .resizable() + .frame(width: headerHeight, height: headerHeight) + .aspectRatio(contentMode: .fit) + } else { + Text("") + .frame(width: headerHeight, height: headerHeight) + .onAppear { + Image.load(from: summary.publisher.icon) { image in DispatchQueue.main.async { self.publisherIcon = image } } + } } - .background(self.primary) - .cornerRadius(8) - .frame(maxWidth: .infinity) - .multilineTextAlignment(.leading) - Text(summary.category.displayName) - .frame(maxWidth: .infinity) - .padding(3) + Text(summary.publisher.displayName) + .font(headerFont) + Text("•") + .font(headerFont) Text(summary.originalDate?.distanceFromNow() ?? "") - .font(.footnote) - .frame(maxWidth: .infinity) - .padding(3) - .multilineTextAlignment(.leading) - Divider() - if let image = image { + .font(headerFont) + Spacer() + } + } + .multilineTextAlignment(.leading) + } + + var TITLE: some View { + HStack { + if let summary = summary { + Text(summary.translations?["title"] ?? summary.title) + .font(titleFont) + .bold() + .lineSpacing(-10.0) + .truncationMode(.tail) + .lineLimit(!expanded ? 2 : 10) + } + Spacer() + } + .multilineTextAlignment(.leading) + } + + var DESCRIPTION: some View { + HStack { + if let summary = summary { + Text(summary.translations?["shortSummary"] ?? summary.shortSummary ?? "") + .font(titleFont) + .bold() + .lineSpacing(-10.0) + } + } + .multilineTextAlignment(.leading) + } + + var IMAGE: some View { + VStack { + if let summary = summary { + if let image = summary.image ?? image { image - .resizable() - .aspectRatio(contentMode: .fit) + .resizable() + .scaledToFill() + .frame(width: imageHeight, height: imageHeight) } else { - Text("Loading Image...") + Text("Loading Image...") + .font(headerFont) + .frame(width: imageHeight, height: imageHeight) .onAppear { - if let url = summary.media?["imageArticle"] ?? summary.media?["imageAi1"] ?? summary.imageUrl { - loadImage(url) { image in self.image = image } + if let url = summary.primaryImageUrl { + Image.load(from: url) { image in DispatchQueue.main.async { self.image = image } } } } } - Text(compact ? (summary.translations?["title"] ?? summary.title) : (summary.translations?["shortSummary"] ?? summary.shortSummary ?? "")) - .padding(3) - .if(compact) { view in - view - .truncationMode(.tail) - .lineLimit(compact ? 3 : 10) - } } - .frame(maxWidth: .infinity) - .padding(10) } - .background(self.card) - .cornerRadius(8) } -} + var CONTENT: some View { + VStack { + if style == .small { + if !expanded { + IMAGE + .overlay { + VStack { + Spacer() + VStack { + HEADER + TITLE + } + .padding(4.0) + .cornerRadius(8.0) + .background(backdrop) + } + } + .padding(4.0) + } else { + VStack(spacing: 8.0) { + HEADER + TITLE + IMAGE + DESCRIPTION + } + } + } else + if style == .medium { + HStack { + VStack(spacing: 4.0) { + HEADER + TITLE + } + .multilineTextAlignment(.leading) + IMAGE + .cornerRadius(8.0) + } + .frame(maxWidth: .infinity) + } + } + } -func loadImage(_ url: String, completionHandler: @escaping @Sendable (_ image: Image) -> Void) { - guard let imageUrl = URL(string: url) else { return } - URLSession.shared.dataTask(with: imageUrl) { data, _, error in - if let data = data, let uiImage = UIImage(data: data) { - DispatchQueue.main.async { - completionHandler(Image(uiImage: uiImage)) + var body: some View { + if let summary = summary { + if deeplink == true { + Link(destination: summary.deeplink, label: { + CONTENT + }) + } else { + CONTENT + } + } else { + if style == .small { + + } else + if style == .medium { + HStack { + VStack(spacing: 4.0) { + HStack { + Spacer() + } + .frame(height: headerHeight) + .background(placeholder) + HStack { + Spacer() } + .frame(height: headerHeight) + .background(placeholder) + } + VStack { + Spacer() + } + .frame(width: imageHeight, height: imageHeight) + .background(placeholder) + .cornerRadius(8.0) } - }.resume() + .frame(maxWidth: .infinity) + } + } + } + } diff --git a/src/mobile/src/LeftDrawerScreen.tsx b/src/mobile/src/LeftDrawerScreen.tsx index 3ae93e3f9..e4432ff3b 100644 --- a/src/mobile/src/LeftDrawerScreen.tsx +++ b/src/mobile/src/LeftDrawerScreen.tsx @@ -14,7 +14,7 @@ import { DrawerItem, DrawerSection, Icon, - Screen, + RoutedScreen, View, } from '~/components'; import { SessionContext } from '~/contexts'; @@ -23,9 +23,9 @@ import { strings } from '~/locales'; function HomeDrawer() { return ( - - - + + + ); } diff --git a/src/mobile/src/NavigationController.tsx b/src/mobile/src/NavigationController.tsx index 1d998ebbb..e52ffe593 100644 --- a/src/mobile/src/NavigationController.tsx +++ b/src/mobile/src/NavigationController.tsx @@ -169,9 +169,7 @@ export default function NavigationController() { linking={ NAVIGATION_LINKING_OPTIONS }> - - - + diff --git a/src/mobile/src/RightDrawerScreen.tsx b/src/mobile/src/RightDrawerScreen.tsx index b0efa1dfe..bdc8c96dd 100644 --- a/src/mobile/src/RightDrawerScreen.tsx +++ b/src/mobile/src/RightDrawerScreen.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { Linking } from 'react-native'; import { DrawerContentComponentProps, @@ -62,24 +61,6 @@ function RightDrawerContent(props: DrawerContentComponentProps) { const RightDrawer = createDrawerNavigator(); export function RightDrawerScreen() { - - const { router } = useNavigation(); - - const [loadedInitialUrl, setLoadedInitialUrl] = React.useState(false); - - React.useEffect(() => { - const subscriber = Linking.addEventListener('url', router); - if (!loadedInitialUrl) { - Linking.getInitialURL().then((url) => { - if (url) { - router({ url } ); - setLoadedInitialUrl(true); - } - }); - } - return () => subscriber.remove(); - }, [router, loadedInitialUrl]); - return ( + {STACK_SCREENS.map((screen) => ( { + const subscriber = Linking.addEventListener('url', router); + if (!loadedInitialUrl) { + Linking.getInitialURL().then((url) => { + if (url) { + setPreference('loadedInitialUrl', true); + router({ navigator: navigation?.getParent('Stack'), url }); + } + }); + } + return () => subscriber.remove(); + }, [router, navigation, loadedInitialUrl, setPreference])); + + return ( + + ); +} \ No newline at end of file diff --git a/src/mobile/src/components/common/Screen.tsx b/src/mobile/src/components/common/Screen.tsx index 083d94614..173eaf273 100644 --- a/src/mobile/src/components/common/Screen.tsx +++ b/src/mobile/src/components/common/Screen.tsx @@ -1,11 +1,14 @@ import React from 'react'; -import { StyleSheet } from 'react-native'; -import { SafeAreaView, StatusBar } from 'react-native'; +import { + SafeAreaView, + StatusBar, + StyleSheet, +} from 'react-native'; import { View, ViewProps } from '~/components'; import { useTheme } from '~/hooks'; -export type ScreenViewProps = ViewProps & { +export type ScreenProps = ViewProps & { safeArea?: boolean; }; diff --git a/src/mobile/src/components/common/index.ts b/src/mobile/src/components/common/index.ts index 9d061c203..b2e5ed4c9 100644 --- a/src/mobile/src/components/common/index.ts +++ b/src/mobile/src/components/common/index.ts @@ -25,6 +25,7 @@ export * from './Markdown'; export * from './Popover'; export * from './Pulse'; export * from './MeterDial'; +export * from './RoutedScreen'; export * from './Screen'; export * from './ScrollView'; export * from './SearchMenu'; diff --git a/src/mobile/src/hooks/useNavigation.tsx b/src/mobile/src/hooks/useNavigation.tsx index e8c291d71..931891252 100644 --- a/src/mobile/src/hooks/useNavigation.tsx +++ b/src/mobile/src/hooks/useNavigation.tsx @@ -16,30 +16,30 @@ export function useNavigation() { const { emitEvent } = usePlatformTools(); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const navigation = useRNNavigation>() as any; + const navigation = useRNNavigation>(); const { preferredReadingFormat, setPreference } = React.useContext(SessionContext); - const navigate = React.useCallback((route: R, params?: RoutingParams[R]) => { + const navigate = React.useCallback((route: R, params?: RoutingParams[R], navigator?: any) => { emitEvent('navigate', route); // eslint-disable-next-line @typescript-eslint/no-explicit-any - return ((navigation as any).push ?? navigation.navigate)(route, params as RoutingParams[R]); + return (navigator?.push ?? (navigation as any).push ?? navigation.navigate)(route, params as RoutingParams[R]); }, [emitEvent, navigation]); - const search = React.useCallback((params: RoutingParams['search']) => { + const search = React.useCallback((params: RoutingParams['search'], navigator?: any) => { const prefilter = params.prefilter; if (!prefilter) { return; } setPreference('searchHistory', (prev) => Array.from(new Set([prefilter, ...(prev ?? [])])).slice(0, 10)); - navigate('search', params); + navigate('search', params, navigator); }, [navigate, setPreference]); - const openSummary = React.useCallback((props: RoutingParams['summary']) => { + const openSummary = React.useCallback((props: RoutingParams['summary'], navigator?: any) => { navigate('summary', { ...props, initialFormat: props.initialFormat ?? preferredReadingFormat ?? ReadingFormat.Bullets, - }); + }, navigator); }, [navigate, preferredReadingFormat]); const openPublisher = React.useCallback((publisher: PublicPublisherAttributes) => { @@ -50,7 +50,7 @@ export function useNavigation() { navigate('category', { category }); }, [navigate]); - const router = React.useCallback(({ url }: { url: string }) => { + const router = React.useCallback(({ url, navigator }: { url: string, navigator?: any }) => { // http://localhost:6969/read/?s=158&f=casual // https://dev.readless.ai/read/?s=158&f=casual // https://www.readless.ai/read/?s=4070&f=bullets @@ -65,15 +65,39 @@ export function useNavigation() { params[decodeURIComponent(key)] = decodeURIComponent(value || ''); }); } - const summary = Number.parseInt(params['s'] ?? '0'); - if (!summary) { - return; - } - const initialFormat = readingFormat(params['f']); - if (route === 'read' && summary) { - openSummary({ initialFormat, summary }); - } - }, [openSummary]); + if (route === 'read') { + const summary = Number.parseInt(params['s'] ?? '0'); + if (!summary) { + return; + } + const initialFormat = readingFormat(params['f']); + openSummary({ initialFormat, summary }, navigator); + } else + if (route === 'top') { + navigate('topStories'); + } else + if (route === 'search') { + const filter = params['filter']?.trim(); + if (!filter) { + return; + } + search({ prefilter: filter }, navigator); + } else + if (route === 'publisher') { + const publisher = params['publisher']?.trim(); + if (!publisher) { + return; + } + openPublisher({ name: publisher }); + } else + if (route === 'category') { + const category = params['category']?.trim(); + if (!category) { + return; + } + openCategory({ name: category }); + } + }, [navigate, navigation, search, openSummary, openPublisher, openCategory]); return { navigate, diff --git a/src/mobile/src/screens/RecapScreen.tsx b/src/mobile/src/screens/RecapScreen.tsx index 2b001a30b..fdeb472a2 100644 --- a/src/mobile/src/screens/RecapScreen.tsx +++ b/src/mobile/src/screens/RecapScreen.tsx @@ -1,21 +1,17 @@ import React from 'react'; -import { Recap, Screen } from '~/components'; -import { getLocale } from '~/locales'; +import { Recap, RoutedScreen } from '~/components'; import { ScreenProps } from '~/screens'; -export function RecapScreen({ - route, - navigation, -}: ScreenProps<'recap'>) { +export function RecapScreen({ route }: ScreenProps<'recap'>) { const recap = React.useMemo(() => route?.params?.recap, [route]); return ( - + {recap && ( )} - + ); } \ No newline at end of file diff --git a/src/mobile/src/screens/SearchScreen.tsx b/src/mobile/src/screens/SearchScreen.tsx index 5ec4312f0..3a1d0d191 100644 --- a/src/mobile/src/screens/SearchScreen.tsx +++ b/src/mobile/src/screens/SearchScreen.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Screen, SummaryList } from '~/components'; +import { RoutedScreen, SummaryList } from '~/components'; import { useApiClient } from '~/hooks'; import { ScreenProps } from '~/screens'; @@ -11,13 +11,13 @@ export function SearchScreen({ const { getSummaries } = useApiClient(); return ( - + - + ); } \ No newline at end of file diff --git a/src/mobile/src/screens/SummaryScreen.tsx b/src/mobile/src/screens/SummaryScreen.tsx index 62a90f5b2..691bb3d4e 100644 --- a/src/mobile/src/screens/SummaryScreen.tsx +++ b/src/mobile/src/screens/SummaryScreen.tsx @@ -11,7 +11,7 @@ import { import { ActivityIndicator, FlatList, - Screen, + RoutedScreen, Summary, Text, View, @@ -106,7 +106,7 @@ export function SummaryScreen({ }, [summary, format, navigation])); return ( - + {loading ? ( @@ -147,6 +147,6 @@ export function SummaryScreen({ ListFooterComponentStyle={ { paddingBottom: 64 } } estimatedItemSize={ 114 } /> ))} - + ); } diff --git a/src/server/src/api/v1/schema/resources/summary/queries/search_conservative.sql b/src/server/src/api/v1/schema/resources/summary/queries/search_conservative.sql new file mode 100644 index 000000000..e8728eb37 --- /dev/null +++ b/src/server/src/api/v1/schema/resources/summary/queries/search_conservative.sql @@ -0,0 +1,154 @@ +SELECT + "totalCount"::INT AS "count", + JSON_BUILD_OBJECT( + 'sentiment', "averageSentiment" + ) AS metadata, + JSON_AGG(c.*) AS rows +FROM ( + SELECT + s.id, + s.url, + b."originalDate", + s."createdAt", + s.title, + s."shortSummary", + s.summary, + s.bullets, + s."imageUrl", + JSON_BUILD_OBJECT( + 'id', pub.id, + 'name', pub.name, + 'displayName', pub."displayName" + ) AS publisher, + JSON_BUILD_OBJECT( + 'id', cat.id, + 'name', cat.name, + 'displayName', cat."displayName", + 'icon', cat.icon + ) AS category, + ss.sentiment, + ss.sentiments::JSONB AS sentiments, + sm.media::JSONB AS media, + st.translations::JSONB AS translations, + COALESCE( + JSON_AGG(sr."siblingId") + FILTER (WHERE sr."siblingId" IS NOT NULL), + '[]'::JSON) AS siblings, + "averageSentiment", + "totalCount" + FROM ( + SELECT + *, + AVG(sentiment) OVER() AS "averageSentiment", + COUNT(id) OVER() AS "totalCount" + FROM ( + SELECT + s.id, + s."originalDate", + ss.sentiment + FROM + summaries s + LEFT OUTER JOIN categories cat ON s."categoryId" = cat.id + LEFT OUTER JOIN publishers pub ON s."publisherId" = pub.id + LEFT OUTER JOIN summary_translations st ON st."parentId" = s.id + AND st.locale = :locale + LEFT OUTER JOIN summary_sentiment_view ss ON ss."parentId" = s.id + WHERE + s."deletedAt" IS NULL + AND ( + (s."originalDate" > NOW() - INTERVAL :interval) + OR ( + (s."originalDate" >= :startDate) + AND (s."originalDate" <= :endDate) + ) + ) + AND ( + (s.id IN (:ids)) + OR :noIds + ) + AND ( + (:excludeIds AND s.id NOT IN (:ids)) + OR NOT :excludeIds + ) + AND ( + (pub.name IN (:publishers)) + OR :noPublishers + ) + AND ( + (pub.name NOT IN (:excludedPublishers)) + OR :noExcludedPublishers + ) + AND ( + (cat.name IN (:categories)) + OR :noCategories + ) + AND ( + (cat.name NOT IN (:excludedCategories)) + OR :noExcludedCategories + ) + AND ( + :noFilter + OR (s.title ~* :filter) + OR (s."shortSummary" ~* :filter) + OR (s.summary ~* :filter) + OR (s.bullets::TEXT ~* :filter) + OR (st.value ~* :filter) + ) + ORDER BY + s."originalDate" DESC + ) a + ORDER BY + a."originalDate" DESC + LIMIT :limit + OFFSET :offset + ) b + LEFT OUTER JOIN summaries s + ON b.id = s.id + AND (s."deletedAt" IS NULL) + LEFT OUTER JOIN publisher_view pub + ON s."publisherId" = pub.id + AND (pub.locale = :locale OR pub.locale IS NULL) + LEFT OUTER JOIN category_view cat + ON s."categoryId" = cat.id + AND (cat.locale = :locale OR cat.locale IS NULL) + LEFT OUTER JOIN summary_sentiment_view ss + ON ss."parentId" = s.id + LEFT OUTER JOIN summary_media_view sm + ON sm."parentId" = s.id + LEFT OUTER JOIN summary_translation_view st + ON st."parentId" = s.id + AND st.locale = :locale + LEFT OUTER JOIN summary_relations sr + ON b.id = sr."parentId" + GROUP BY + s.id, + s.url, + b."originalDate", + s."createdAt", + s.title, + s."shortSummary", + s.summary, + s.bullets, + s."imageUrl", + pub.id, + pub.name, + pub."displayName", + pub.description, + pub.translations::JSONB, + cat.id, + cat.name, + cat."displayName", + cat.icon, + cat.translations::JSONB, + ss.sentiment, + ss.sentiments::JSONB, + sm.media::JSONB, + st.translations::JSONB, + "averageSentiment", + "totalCount" + ORDER BY + b."originalDate" DESC +) c +GROUP BY + "averageSentiment", + "totalCount"; \ No newline at end of file diff --git a/src/server/src/api/v1/schema/resources/summary/queries/top_stories_conservative.sql b/src/server/src/api/v1/schema/resources/summary/queries/top_stories_conservative.sql new file mode 100644 index 000000000..395fed0f9 --- /dev/null +++ b/src/server/src/api/v1/schema/resources/summary/queries/top_stories_conservative.sql @@ -0,0 +1,156 @@ +SELECT "totalCount"::INT AS "count", + JSON_AGG(c.*) AS rows +FROM ( + SELECT + b.id, + s."title", + s."shortSummary", + s.summary, + s.bullets, + s.url, + s."originalDate", + s."imageUrl", + JSON_BUILD_OBJECT( + 'id', pub.id, + 'name', pub.name, + 'displayName', pub."displayName" + ) AS publisher, + JSON_BUILD_OBJECT( + 'id', cat.id, + 'name', cat.name, + 'displayName', cat."displayName", + 'icon', cat.icon + ) AS category, + ss.sentiment, + ss.sentiments::JSONB AS sentiments, + sm.media::JSONB AS media, + st.translations::JSONB AS translations, + "siblingCount", + COALESCE( + JSON_AGG(sr."siblingId") + FILTER (WHERE sr."siblingId" IS NOT NULL), + '[]'::JSON) AS siblings, + "totalCount" + FROM ( + SELECT + *, + COUNT(a.id) OVER() AS "totalCount" + FROM ( + SELECT + s.id, + s."originalDate", + COUNT(sibling.id) AS "siblingCount" + FROM summaries s + LEFT OUTER JOIN categories cat ON s."categoryId" = cat.id + LEFT OUTER JOIN publishers pub ON s."publisherId" = pub.id + LEFT OUTER JOIN summary_translations st ON st."parentId" = s.id + AND st.locale = :locale + LEFT OUTER JOIN "summary_relations" sr ON (s.id = sr."parentId") + AND (sr."deletedAt" IS NULL) + LEFT OUTER JOIN summaries sibling ON (sibling.id = sr."siblingId") + AND (sibling."deletedAt" IS NULL) + AND (sibling."originalDate" > NOW() - INTERVAL :interval) + WHERE + s."deletedAt" IS NULL + AND ( + (s."originalDate" > NOW() - INTERVAL :interval) + OR ( + (s."originalDate" >= :startDate) + AND (s."originalDate" <= :endDate) + ) + ) + AND ( + (s.id IN (:ids)) + OR :noIds + ) + AND ( + (:excludeIds AND s.id NOT IN (:ids)) + OR NOT :excludeIds + ) + AND ( + (pub.name IN (:publishers)) + OR :noPublishers + ) + AND ( + (pub.name NOT IN (:excludedPublishers)) + OR :noExcludedPublishers + ) + AND ( + (cat.name IN (:categories)) + OR :noCategories + ) + AND ( + (cat.name NOT IN (:excludedCategories)) + OR :noExcludedCategories + ) + AND ( + :noFilter + OR (s.title ~* :filter) + OR (s."shortSummary" ~* :filter) + OR (s.summary ~* :filter) + OR (s.bullets::TEXT ~* :filter) + OR (st.value ~* :filter) + ) + GROUP BY + s.id, + s."originalDate" + ORDER BY + "siblingCount" DESC, + s."originalDate" DESC + ) a + ORDER BY + a."siblingCount" DESC, + a."originalDate" DESC + LIMIT :limit + OFFSET :offset + ) b + LEFT OUTER JOIN summaries s + ON b.id = s.id + AND (s."deletedAt" IS NULL) + LEFT OUTER JOIN publisher_view pub + ON s."publisherId" = pub.id + AND (pub.locale = :locale OR pub.locale IS NULL) + LEFT OUTER JOIN category_view cat + ON s."categoryId" = cat.id + AND (cat.locale = :locale OR cat.locale IS NULL) + LEFT OUTER JOIN summary_sentiment_view ss + ON ss."parentId" = s.id + LEFT OUTER JOIN summary_media_view sm + ON sm."parentId" = s.id + LEFT OUTER JOIN summary_translation_view st + ON st."parentId" = s.id + AND st.locale = :locale + LEFT OUTER JOIN summary_relations sr + ON b.id = sr."parentId" + GROUP BY + b.id, + s.url, + s."originalDate", + s."createdAt", + s.title, + s."shortSummary", + s.summary, + s.bullets, + s."imageUrl", + pub.id, + pub.name, + pub."displayName", + pub.description, + pub.translations::JSONB, + cat.id, + cat.name, + cat."displayName", + cat.icon, + cat.translations::JSONB, + ss.sentiment, + ss.sentiments::JSONB, + sm.media::JSONB, + st.translations::JSONB, + "siblingCount", + "totalCount" + ORDER BY + b."siblingCount" DESC, + s."originalDate" DESC +) c +GROUP BY + "totalCount"; \ No newline at end of file diff --git a/src/server/src/services/puppeteer/PuppeteerService.ts b/src/server/src/services/puppeteer/PuppeteerService.ts index f00f3402a..f3183ee1c 100644 --- a/src/server/src/services/puppeteer/PuppeteerService.ts +++ b/src/server/src/services/puppeteer/PuppeteerService.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { load } from 'cheerio'; import ms from 'ms'; import puppeteer, { Browser, @@ -169,6 +170,8 @@ export class PuppeteerService extends BaseService { waitUntil = 'domcontentloaded', }: PageOptions = {} ): Promise { + + console.log(`loaded ${actions.length} actions`); let browser: Browser; try { @@ -192,6 +195,7 @@ export class PuppeteerService extends BaseService { } for (const selectorAction of actions) { + console.log(`running action "${selectorAction?.selector}"`); try { const { selector, firstMatchOnly, pageOptions, action, @@ -336,7 +340,14 @@ export class PuppeteerService extends BaseService { public static async loot( url: string, publisher: PublisherCreationAttributes, - { content }: LootOptions = {} + { + content, exclude = [ + 'img', + 'script', + 'source', + 'style', + ], + }: LootOptions = {} ): Promise { const loot: Loot = { @@ -357,6 +368,102 @@ export class PuppeteerService extends BaseService { const dates: string[] = []; const imageUrls: string[] = []; const actions: SelectorAction[] = []; + + if (!content) { + + const { + article, author, date, title, image, + } = publisher.selectors; + + const rawHtml = await PuppeteerService.fetch(url); + + const authors: string[] = []; + const dates: string[] = []; + const imageUrls: string[] = []; + + if (rawHtml) { + + loot.rawText = rawHtml; + const $ = load(rawHtml); + + const nextData = $('script#__NEXT_DATA__').text(); + if (nextData) { + try { + console.log(nextData.substring(0, 500)); + const match = nextData.match(/"date"[\s\n]*:[\s\n]*"(.*?)"/m); + console.log(match); + if (match) { + dates.push(match[1]); + } + } catch (e) { + console.error(e); + } + } + + const extract = ( + sel: string, + attr?: string, + first?: boolean + ): string => { + if (attr && clean($(sel)?.attr(attr))) { + if (first) { + return clean($(sel)?.first()?.attr(attr)); + } + return clean($(sel).attr(attr)); + } + if (first) { + return clean($(sel)?.first()?.text()); + } + return $(sel)?.map((i, el) => clean($(el).text())).get().filter(Boolean).join(' '); + }; + + const extractAll = (sel: string, attr?: string): string[] => { + return $(sel)?.map((i, el) => clean(attr ? $(el).attr(attr) : $(el).text())).get().filter(Boolean) ?? []; + }; + + // image + for (const selector of [image?.selector, ...SELECTORS.image].filter(Boolean)) { + for (const attr of [image?.attribute, ...ATTRIBUTES.image].filter(Boolean)) { + imageUrls.push(...extractAll(selector, attr).flatMap((src) => parseSrcset(src, { publisher, targetUrl: url }))); + } + } + + exclude.forEach((tag) => $(tag).remove()); + + // title + loot.title = extract(title?.selector || 'title', title?.attribute); + // content + loot.content = article ? extract(article.selector, article.attribute) || extract('h1,h2,h3,h4,h5,h6,p,blockquote') : extract('h1,h2,h3,h4,h5,h6,p,blockquote'); + + // dates + + dates.push( + ...extractAll(date.selector), + ...extractAll(date.selector, 'datetime') + ); + if (date.attribute) { + dates.push( + ...extractAll(date.selector, date.attribute), + extract(date.selector, date.attribute) + ); + } + dates.push( + extract(date.selector), + extract(date.selector, 'datetime') + ); + + loot.date = maxDate(...dates); + if (!loot.date || Number.isNaN(loot.date.valueOf())) { + loot.date = parseDate(dates.join(' ')); + } + + loot.imageUrls = Array.from(new Set(imageUrls.filter((url) => url && !/\.(gif|svg)/i.test(url)))).slice(0, MAX_IMAGE_COUNT); + + // authors + authors.push(...$(author.selector || 'author').map((i, el) => $(el).text()).get()); + } + + } // content if (!loot.content) { @@ -411,49 +518,57 @@ export class PuppeteerService extends BaseService { } // dates - const dateSelectors = [date.selector, 'article time']; - if (date.firstOnly) { - dateSelectors.push(SELECTORS.article); - } - for (const selector of dateSelectors) { - actions.push({ - action: async (el) => { - dates.push( - await el.evaluate((el) => { - if (el.childNodes.length > 1) { - const parts: string[] = []; - el.childNodes.forEach((n) => parts.push(n.textContent.trim())); - return parts.join(' '); - } else { - return el.textContent.trim(); - } - }), - await el.evaluate((el) => el.getAttribute('datetime')) - ); - if (date.attribute) { + if (!loot.date) { + const dateSelectors = [date.selector, 'article time']; + if (date.firstOnly) { + dateSelectors.push(SELECTORS.article); + } + for (const selector of dateSelectors) { + actions.push({ + action: async (el) => { dates.push( - await el.evaluate((el, attr) => el.getAttribute(attr), date.attribute) + await el.evaluate((el) => { + if (el.childNodes.length > 1) { + const parts: string[] = []; + el.childNodes.forEach((n) => parts.push(n.textContent.trim())); + return parts.join(' '); + } else { + return el.textContent.trim(); + } + }), + await el.evaluate((el) => el.getAttribute('datetime')) ); - } - }, - firstMatchOnly: !date.firstOnly, - selector, + if (date.attribute) { + dates.push( + await el.evaluate((el, attr) => el.getAttribute(attr), date.attribute) + ); + } + }, + firstMatchOnly: !date.firstOnly, + selector, + }); + } + } + + if (actions.length > 0) { + await this.open(url, actions, { + timeout: typeof publisher.fetchPolicy?.timeout === 'string' ? ms(publisher.fetchPolicy.timeout) : publisher.fetchPolicy?.timeout, + waitUntil: publisher.fetchPolicy?.waitUntil, }); } - await this.open(url, actions, { - timeout: typeof publisher.fetchPolicy?.timeout === 'string' ? ms(publisher.fetchPolicy.timeout) : publisher.fetchPolicy?.timeout, - waitUntil: publisher.fetchPolicy?.waitUntil, - }); - loot.dateMatches = dates.filter(Boolean); - loot.date = maxDate(...dates); - if (!loot.date || Number.isNaN(loot.date.valueOf())) { - loot.date = parseDate(dates.join(' ')); + if (!loot.date) { + loot.date = maxDate(...dates); + if (!loot.date || Number.isNaN(loot.date.valueOf())) { + loot.date = parseDate(dates.join(' ')); + } } loot.authors = [...new Set(authors.map((a) => clean(a, /^\s*by:?\s*/i).split(/\s*(?:,|and)\s*/).flat()).flat().filter(Boolean))]; - loot.imageUrls = Array.from(new Set(imageUrls.filter((url) => url && !/\.(gif|svg)/i.test(url)))).slice(0, MAX_IMAGE_COUNT); - + if (!loot.imageUrls || loot.imageUrls.length === 0) { + loot.imageUrls = Array.from(new Set(imageUrls.filter((url) => url && !/\.(gif|svg)/i.test(url)))).slice(0, MAX_IMAGE_COUNT); + } + return loot; } diff --git a/src/web/public/images/ss/ss-custom-feed.png b/src/web/public/images/ss/ss-custom-feed.png index acf99e84c..06ccf87c8 100644 Binary files a/src/web/public/images/ss/ss-custom-feed.png and b/src/web/public/images/ss/ss-custom-feed.png differ diff --git a/src/web/public/images/ss/ss-localization.png b/src/web/public/images/ss/ss-localization.png index d7bb0212a..41fc24362 100644 Binary files a/src/web/public/images/ss/ss-localization.png and b/src/web/public/images/ss/ss-localization.png differ diff --git a/src/web/public/images/ss/ss-main.png b/src/web/public/images/ss/ss-main.png index 9cc5be832..cef058e26 100644 Binary files a/src/web/public/images/ss/ss-main.png and b/src/web/public/images/ss/ss-main.png differ diff --git a/src/web/public/images/ss/ss-publishers.png b/src/web/public/images/ss/ss-publishers.png index 4331d1f6b..26cdc3917 100644 Binary files a/src/web/public/images/ss/ss-publishers.png and b/src/web/public/images/ss/ss-publishers.png differ diff --git a/src/web/public/images/ss/ss-sentiment.png b/src/web/public/images/ss/ss-sentiment.png index 9162d8799..008376837 100644 Binary files a/src/web/public/images/ss/ss-sentiment.png and b/src/web/public/images/ss/ss-sentiment.png differ diff --git a/src/web/src/components/Screenshot.tsx b/src/web/src/components/Screenshot.tsx index 6ec6a91e6..6a49eee87 100644 --- a/src/web/src/components/Screenshot.tsx +++ b/src/web/src/components/Screenshot.tsx @@ -50,7 +50,7 @@ function SCREENS(formFactor = '') { bullets: { image: imageName('ss-bullets') }, customFeed: { image: imageName('ss-custom-feed') }, localization: { image: imageName('ss-localization') }, - main: { image: imageName('ss-main'), invert: true }, + main: { image: imageName('ss-main') }, preview: { image: imageName('ss-quick-preview') }, publishers: { image: imageName('ss-publishers') }, reader: { image: imageName('ss-reader') }, @@ -331,7 +331,7 @@ type ScreenshotCarouselProps = Partial & { }; export const SLIDE_ORDER = [ - 'bullets', + 'main', 'customFeed', 'publishers', 'reader',