diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..13566b81 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..e96f6e02 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/TestFlight/WhatToTest.en-US.txt b/TestFlight/WhatToTest.en-US.txt index f38844b4..e99afb66 100644 --- a/TestFlight/WhatToTest.en-US.txt +++ b/TestFlight/WhatToTest.en-US.txt @@ -1,6 +1,23 @@ -A big update is coming soon, sorry for the wait. +# Added ---- QUICK EXPRESS UPDATE --- -- Fixed subs order -- Fixed subs filtering -- Fixed favoriting subs not working properly +- Live Text +- Whats new Sheet +- Lock the app +- Theme Store +- Subscribed indicator / button when searching for subs +- Add settings import / export +- Add line spacing option for themes +- New comments indicator +- Added flairs in comments + +# Fixed + +- "Streamable" videos now working +- Blur nsfw when searching for subs +- Default search sort in settings +- Save sort per subreddit +- Save comment search per port +- Support for themable user flairs +- Add theming for load more button + +Special thanks to rbertus2000, mmynk, Nelson Dane, Zander Bobronnikov and ben-wheeler for contributing to this release! diff --git a/TestFlight/changelog.json b/TestFlight/changelog.json new file mode 100644 index 00000000..0611ba88 --- /dev/null +++ b/TestFlight/changelog.json @@ -0,0 +1,37 @@ +{ + "title": "What's new in Winston", + "version": "1.0.0", + "features": [ + { + "subtitle": "We've added a cool new What's New sheet!", + "systemImage": "star", + "title": "What's New sheet" + }, + { + "subtitle": "Want to copy text you see in an image? Now you can!", + "systemImage": "text.viewfinder", + "title": "Live Text" + }, + { + "subtitle": "You can now lock Winston with a pin or biometrics.", + "systemImage": "lock", + "title": "Lock Winston" + }, + { + "subtitle": "Download other peoples themes with the all new Theme Store.", + "systemImage": "paintpalette", + "title": "Theme Store" + }, + { + "subtitle": "Wait didn't I read this already? With new comments indicators you dont need to ask yourself this question anymore!", + "systemImage": "person", + "title": "New Comments Indicator" + }, + { + "subtitle": "Show some flair with...flairs! Winston now displays flairs in comments.", + "systemImage": "flag", + "title": "User Flairs in Comments" + } + ], + +} diff --git a/managed/CachedSub+CoreDataProperties.swift b/managed/CachedSub+CoreDataProperties.swift index 92b5381c..cb95795d 100644 --- a/managed/CachedSub+CoreDataProperties.swift +++ b/managed/CachedSub+CoreDataProperties.swift @@ -28,7 +28,7 @@ extension CachedSub { @NSManaged public var icon_img: String? @NSManaged public var key_color: String? @NSManaged public var name: String? - @NSManaged public var over_18: Bool + @NSManaged public var over18: Bool @NSManaged public var primary_color: String? @NSManaged public var restrict_commenting: Bool @NSManaged public var subscribers: Double @@ -51,7 +51,7 @@ extension CachedSub { self.allow_galleries = x.allow_galleries ?? false self.allow_images = x.allow_images ?? false self.allow_videos = x.allow_videos ?? false - self.over_18 = x.over_18 ?? false + self.over18 = x.over18 ?? false self.restrict_commenting = x.restrict_commenting ?? false self.user_has_favorited = x.user_has_favorited ?? false self.user_is_banned = x.user_is_banned ?? false diff --git a/winston.entitlements b/winston.entitlements new file mode 100644 index 00000000..c5f2c7d0 --- /dev/null +++ b/winston.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.associated-domains + + applinks:app.winston.lo.cafe + + + diff --git a/winston.xcodeproj/project.pbxproj b/winston.xcodeproj/project.pbxproj index 3e9e90c2..b2c89f2b 100644 --- a/winston.xcodeproj/project.pbxproj +++ b/winston.xcodeproj/project.pbxproj @@ -7,15 +7,45 @@ objects = { /* Begin PBXBuildFile section */ + 65010D032AC70ADD00A8F611 /* getDownloadURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65010D022AC70ADD00A8F611 /* getDownloadURL.swift */; }; 650ACAFC2A87A36900A7C600 /* calcVotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 650ACAFB2A87A36900A7C600 /* calcVotes.swift */; }; + 6514FFEC2AFD01520051D264 /* WhatsNewKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6514FFEB2AFD01520051D264 /* WhatsNewKit */; }; + 6514FFEE2AFD01AD0051D264 /* WhatsNewCollectionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6514FFED2AFD01AD0051D264 /* WhatsNewCollectionProvider.swift */; }; 651D19C72A90B7D6003867A9 /* PostFontSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 651D19C62A90B7D6003867A9 /* PostFontSettings.swift */; }; + 652011FA2AC2C46C005DD899 /* themeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652011F92AC2C46C005DD899 /* themeStore.swift */; }; + 652011FC2AC2C7BE005DD899 /* ThemeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652011FB2AC2C7BE005DD899 /* ThemeStore.swift */; }; + 652011FE2AC2D4F9005DD899 /* ThemeStoreDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652011FD2AC2D4F9005DD899 /* ThemeStoreDetailsView.swift */; }; + 652012002AC309FA005DD899 /* downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652011FF2AC309FA005DD899 /* downloader.swift */; }; + 652012022AC31E7C005DD899 /* ThemeStoreUploadSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652012012AC31E7C005DD899 /* ThemeStoreUploadSheet.swift */; }; + 652012042AC322A3005DD899 /* fetchAllThemes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652012032AC322A3005DD899 /* fetchAllThemes.swift */; }; + 652012062AC322BC005DD899 /* fetchThemeStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652012052AC322BC005DD899 /* fetchThemeStatus.swift */; }; + 652012082AC327C7005DD899 /* uploadTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 652012072AC327C7005DD899 /* uploadTheme.swift */; }; 653E2DA52A8E278800833556 /* GeneralPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653E2DA42A8E278800833556 /* GeneralPanel.swift */; }; 6543D8892A8D10B900A1FFF9 /* scaleEffectButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6543D8882A8D10B900A1FFF9 /* scaleEffectButtonStyle.swift */; }; + 657643782ACF1D2100805641 /* FittiingScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657643772ACF1D2100805641 /* FittiingScrollView.swift */; }; + 6576437A2ACF1E1A00805641 /* AppDetailScreenshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657643792ACF1E1A00805641 /* AppDetailScreenshots.swift */; }; + 6576437C2ACF1E3900805641 /* ScrolViewContentRTLFriendly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6576437B2ACF1E3900805641 /* ScrolViewContentRTLFriendly.swift */; }; + 6576437E2ACF20DA00805641 /* AppDetailDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6576437D2ACF20DA00805641 /* AppDetailDescription.swift */; }; + 657643802ACF20F100805641 /* ExpandableText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6576437F2ACF20F100805641 /* ExpandableText.swift */; }; + 657643822ACF224500805641 /* AppDetailFullView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657643812ACF224500805641 /* AppDetailFullView.swift */; }; + 657643842AD0083500805641 /* fetchThemesByName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657643832AD0083500805641 /* fetchThemesByName.swift */; }; + 65840EE82AE11BFD000315E1 /* exportImportSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65840EE72AE11BFD000315E1 /* exportImportSettings.swift */; }; + 658DB5A42AFBB8230062F8B1 /* fetchThemeByID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658DB5A32AFBB8230062F8B1 /* fetchThemeByID.swift */; }; 6593CC432A88C9E8003CA58D /* AccessibilityPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6593CC422A88C9E8003CA58D /* AccessibilityPanel.swift */; }; 6593CC452A88D128003CA58D /* hapticFeedbackModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6593CC442A88D128003CA58D /* hapticFeedbackModifier.swift */; }; 659F68022A84E32300989300 /* VotesCluster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 659F68012A84E32300989300 /* VotesCluster.swift */; }; 659FDEA82A87798300103414 /* VoteButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 659FDEA72A87798300103414 /* VoteButton.swift */; }; + 65A5FFF22ACED3BF000D1ADA /* getPreviewImages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A5FFF12ACED3BF000D1ADA /* getPreviewImages.swift */; }; 65AA535D2A8BB27900275299 /* FAQPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65AA535C2A8BB27900275299 /* FAQPanel.swift */; }; + 65B0619B2AFE8A05008B7AF3 /* changelog.json in Sources */ = {isa = PBXBuildFile; fileRef = 65E45A6D2AFD3C1D007DBBFC /* changelog.json */; }; + 65CDA3622ACB0EB50001EC44 /* ZoomableImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65CDA3612ACB0EB50001EC44 /* ZoomableImageView.swift */; }; + 65CDA3642ACB0F140001EC44 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65CDA3632ACB0F140001EC44 /* ShareSheet.swift */; }; + 65CDA3662ACB0F3A0001EC44 /* LiveTextInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65CDA3652ACB0F3A0001EC44 /* LiveTextInteraction.swift */; }; + 65D265412ABEC6FF009E01F9 /* urlCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65D265402ABEC6FF009E01F9 /* urlCleaner.swift */; }; + 65E45A6E2AFD3C1D007DBBFC /* changelog.json in Resources */ = {isa = PBXBuildFile; fileRef = 65E45A6D2AFD3C1D007DBBFC /* changelog.json */; }; + 65E45A702AFD3E48007DBBFC /* whatsNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E45A6F2AFD3E48007DBBFC /* whatsNew.swift */; }; + ABC2180F2ABA528D00F0DABA /* Biometrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC2180E2ABA528D00F0DABA /* Biometrics.swift */; }; + CE55C7472ADB51C300484EDA /* ObservedOptionalObject in Frameworks */ = {isa = PBXBuildFile; productRef = CE55C7462ADB51C300484EDA /* ObservedOptionalObject */; }; EC051C472ACAF2DC00E9D9ED /* themeFileIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = EC051C462ACAF2DC00E9D9ED /* themeFileIcon.png */; }; EC082F252A65FB4300BA2727 /* MeasureOnce.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC082F242A65FB4300BA2727 /* MeasureOnce.swift */; }; EC082F272A66132600BA2727 /* CommentLinkMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC082F262A66132600BA2727 /* CommentLinkMore.swift */; }; @@ -366,15 +396,42 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 65010D022AC70ADD00A8F611 /* getDownloadURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = getDownloadURL.swift; sourceTree = ""; }; 650ACAFB2A87A36900A7C600 /* calcVotes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = calcVotes.swift; sourceTree = ""; }; + 6514FFED2AFD01AD0051D264 /* WhatsNewCollectionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewCollectionProvider.swift; sourceTree = ""; }; 651D19C62A90B7D6003867A9 /* PostFontSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFontSettings.swift; sourceTree = ""; }; + 652011F92AC2C46C005DD899 /* themeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = themeStore.swift; sourceTree = ""; }; + 652011FB2AC2C7BE005DD899 /* ThemeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeStore.swift; sourceTree = ""; }; + 652011FD2AC2D4F9005DD899 /* ThemeStoreDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeStoreDetailsView.swift; sourceTree = ""; }; + 652011FF2AC309FA005DD899 /* downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = downloader.swift; sourceTree = ""; }; + 652012012AC31E7C005DD899 /* ThemeStoreUploadSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeStoreUploadSheet.swift; sourceTree = ""; }; + 652012032AC322A3005DD899 /* fetchAllThemes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = fetchAllThemes.swift; sourceTree = ""; }; + 652012052AC322BC005DD899 /* fetchThemeStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = fetchThemeStatus.swift; sourceTree = ""; }; + 652012072AC327C7005DD899 /* uploadTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = uploadTheme.swift; sourceTree = ""; }; 653E2DA42A8E278800833556 /* GeneralPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralPanel.swift; sourceTree = ""; }; 6543D8882A8D10B900A1FFF9 /* scaleEffectButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = scaleEffectButtonStyle.swift; sourceTree = ""; }; + 657643772ACF1D2100805641 /* FittiingScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FittiingScrollView.swift; sourceTree = ""; }; + 657643792ACF1E1A00805641 /* AppDetailScreenshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailScreenshots.swift; sourceTree = ""; }; + 6576437B2ACF1E3900805641 /* ScrolViewContentRTLFriendly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrolViewContentRTLFriendly.swift; sourceTree = ""; }; + 6576437D2ACF20DA00805641 /* AppDetailDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailDescription.swift; sourceTree = ""; }; + 6576437F2ACF20F100805641 /* ExpandableText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableText.swift; sourceTree = ""; }; + 657643812ACF224500805641 /* AppDetailFullView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailFullView.swift; sourceTree = ""; }; + 657643832AD0083500805641 /* fetchThemesByName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = fetchThemesByName.swift; sourceTree = ""; }; + 65840EE72AE11BFD000315E1 /* exportImportSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = exportImportSettings.swift; sourceTree = ""; }; + 658DB5A32AFBB8230062F8B1 /* fetchThemeByID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = fetchThemeByID.swift; sourceTree = ""; }; 6593CC422A88C9E8003CA58D /* AccessibilityPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityPanel.swift; sourceTree = ""; }; 6593CC442A88D128003CA58D /* hapticFeedbackModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = hapticFeedbackModifier.swift; sourceTree = ""; }; 659F68012A84E32300989300 /* VotesCluster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VotesCluster.swift; sourceTree = ""; }; 659FDEA72A87798300103414 /* VoteButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoteButton.swift; sourceTree = ""; }; + 65A5FFF12ACED3BF000D1ADA /* getPreviewImages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = getPreviewImages.swift; sourceTree = ""; }; 65AA535C2A8BB27900275299 /* FAQPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FAQPanel.swift; sourceTree = ""; }; + 65CDA3612ACB0EB50001EC44 /* ZoomableImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableImageView.swift; sourceTree = ""; }; + 65CDA3632ACB0F140001EC44 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; + 65CDA3652ACB0F3A0001EC44 /* LiveTextInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTextInteraction.swift; sourceTree = ""; }; + 65D265402ABEC6FF009E01F9 /* urlCleaner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = urlCleaner.swift; sourceTree = ""; }; + 65E45A6D2AFD3C1D007DBBFC /* changelog.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = changelog.json; sourceTree = ""; }; + 65E45A6F2AFD3E48007DBBFC /* whatsNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = whatsNew.swift; sourceTree = ""; }; + ABC2180E2ABA528D00F0DABA /* Biometrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Biometrics.swift; sourceTree = ""; }; EC051C2A2ACAEFC500E9D9ED /* QuickLookThumbnailing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuickLookThumbnailing.framework; path = System/Library/Frameworks/QuickLookThumbnailing.framework; sourceTree = SDKROOT; }; EC051C462ACAF2DC00E9D9ED /* themeFileIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = themeFileIcon.png; sourceTree = ""; }; EC082F242A65FB4300BA2727 /* MeasureOnce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasureOnce.swift; sourceTree = ""; }; @@ -694,6 +751,7 @@ ECAA8B842A76421000D9D636 /* YouTubePlayerKit in Frameworks */, EC9BB1C22A8B05AA0070CD97 /* NukeExtensions in Frameworks */, EC3188092A91991900CB3738 /* AlertToast in Frameworks */, + CE55C7472ADB51C300484EDA /* ObservedOptionalObject in Frameworks */, ECE08B8D2AAB44DC00F84DC9 /* SymbolPicker in Frameworks */, EC593B0C2A47B801002D6454 /* Alamofire in Frameworks */, EC593B092A47B42D002D6454 /* KeychainAccess in Frameworks */, @@ -708,6 +766,7 @@ ECDD515F2A748C5800FD9498 /* OpenGraph in Frameworks */, EC6650EA2AB7E21E006B83A4 /* Zip in Frameworks */, ECDD51622A748DF600FD9498 /* SkeletonUI in Frameworks */, + 6514FFEC2AFD01520051D264 /* WhatsNewKit in Frameworks */, EC50A5A82A9EEF8A00DE8122 /* Giffy in Frameworks */, EC9269F22A51A732003F33B1 /* HighlightedTextEditor in Frameworks */, ECAD0F2F2A8B4D4B001C88B5 /* Markdown in Frameworks */, @@ -724,6 +783,21 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 652011F82AC2C45D005DD899 /* ThemeStore */ = { + isa = PBXGroup; + children = ( + 652011F92AC2C46C005DD899 /* themeStore.swift */, + 652012032AC322A3005DD899 /* fetchAllThemes.swift */, + 652012052AC322BC005DD899 /* fetchThemeStatus.swift */, + 652012072AC327C7005DD899 /* uploadTheme.swift */, + 65010D022AC70ADD00A8F611 /* getDownloadURL.swift */, + 65A5FFF12ACED3BF000D1ADA /* getPreviewImages.swift */, + 657643832AD0083500805641 /* fetchThemesByName.swift */, + 658DB5A32AFBB8230062F8B1 /* fetchThemeByID.swift */, + ); + path = ThemeStore; + sourceTree = ""; + }; EC051C292ACAEFC500E9D9ED /* Frameworks */ = { isa = PBXGroup; children = ( @@ -980,6 +1054,7 @@ EC593AED2A4796C1002D6454 /* winston.entitlements */, EC593AEF2A47ACB0002D6454 /* Tabber.swift */, ECAEFF612A77AF8E0052FCD3 /* AppDelegate.swift */, + ABC2180E2ABA528D00F0DABA /* Biometrics.swift */, EC593AD72A4673B2002D6454 /* winstonApp.swift */, EC593AE02A4673B3002D6454 /* Persistence.swift */, ); @@ -1010,6 +1085,7 @@ isa = PBXGroup; children = ( ECBD8E292ABBC6CF00C74E87 /* caches */, + 652011F82AC2C45D005DD899 /* ThemeStore */, ECE08B5C2AA9C93E00F84DC9 /* WinstonTheme */, EC08541C2A4BFEF100E88389 /* RedditAPI */, ECDBA8C22A4A820800FB42DB /* Post.swift */, @@ -1035,6 +1111,7 @@ ECD2A3762A69C26900EDD02D /* consts.swift */, EC8F34982A6F7DDB00EB8249 /* winstonMDEditorPreset.swift */, ECE08B7F2AA9F9BA00F84DC9 /* defaultTheme.swift */, + 65E45A6F2AFD3E48007DBBFC /* whatsNew.swift */, ); path = globals; sourceTree = ""; @@ -1079,6 +1156,7 @@ isa = PBXGroup; children = ( EC8B814D2AC9650E000DC827 /* String.swift */, + 6514FFED2AFD01AD0051D264 /* WhatsNewCollectionProvider.swift */, ); path = extensions; sourceTree = ""; @@ -1111,6 +1189,7 @@ isa = PBXGroup; children = ( ECA30A162A5FA08500BB19D1 /* WhatToTest.en-US.txt */, + 65E45A6D2AFD3C1D007DBBFC /* changelog.json */, ); path = TestFlight; sourceTree = ""; @@ -1256,6 +1335,7 @@ ECC10C732A4A8729009124BE /* fontSize.swift */, EC9EAEC32A4AAB470074D06B /* fromHex.swift */, EC08542A2A4C2B6B00E88389 /* if.swift */, + 65D265402ABEC6FF009E01F9 /* urlCleaner.swift */, EC08543E2A4D0C0700E88389 /* doThisAfter.swift */, 6543D8882A8D10B900A1FFF9 /* scaleEffectButtonStyle.swift */, EC0854472A4D6E8C00E88389 /* getInitialSize.swift */, @@ -1295,7 +1375,9 @@ ECF314E42ABC187400DFDDBA /* hashableCGSize.swift */, ECF5B3062AC83AE600355589 /* getAppIcon.swift */, EC75D7422AC96D0200594250 /* getEnabledTheme.swift */, + 652011FF2AC309FA005DD899 /* downloader.swift */, EC97F0272ACB006A001814A6 /* importTheme.swift */, + 65840EE72AE11BFD000315E1 /* exportImportSettings.swift */, ); path = utils; sourceTree = ""; @@ -1315,6 +1397,8 @@ EC2D27582A4F782F006444D6 /* GoodNavigator.swift */, EC2D275D2A4FC512006444D6 /* TappableView.swift */, ECE2859A2A5010E2009CF9DD /* DataBlock.swift */, + 657643792ACF1E1A00805641 /* AppDetailScreenshots.swift */, + 6576437B2ACF1E3900805641 /* ScrolViewContentRTLFriendly.swift */, ECE285A02A50EC9E009CF9DD /* Badge.swift */, EC9269E72A514AA6003F33B1 /* DownAttributedString.swift */, ECD1FC082A570B4F00723016 /* PostFloatingPill.swift */, @@ -1323,7 +1407,10 @@ ECA30A182A604ACC00BB19D1 /* SubredditIcon.swift */, ECA30A362A622C2F00BB19D1 /* MD.swift */, ECE4497E2A6896B700E2A3B7 /* SubscribeButton.swift */, + 6576437D2ACF20DA00805641 /* AppDetailDescription.swift */, + 657643812ACF224500805641 /* AppDetailFullView.swift */, ECD1FC062A56D46D00723016 /* ObservableArray.swift */, + 6576437F2ACF20F100805641 /* ExpandableText.swift */, EC8F349A2A6F92DC00EB8249 /* PillTab.swift */, ECDD51512A7359D100FD9498 /* MDEditor.swift */, ECDD51652A74E4DD00FD9498 /* SubredditPostsContainer.swift */, @@ -1347,6 +1434,7 @@ EC3AC9CE2AACB2A30078EC78 /* LabeledSlider.swift */, EC3AC9D32AACCBE40078EC78 /* LabeledTextField.swift */, EC3AC9D62AAD0E300078EC78 /* TagsOptionsCarousel.swift */, + 65CDA3652ACB0F3A0001EC44 /* LiveTextInteraction.swift */, EC8B77A32AB4D89A00875C98 /* Dividers.swift */, EC91C8532AB9601000F2AD30 /* TabBarOverlay.swift */, EC46E9272ABA3B05001BC068 /* WNavigationLink.swift */, @@ -1354,6 +1442,8 @@ EC3B32402AC64DDE002C1291 /* Waterfall.swift */, EC3B32452AC64F4C002C1291 /* SubredditPostsIPAD.swift */, EC3B32432AC64F43002C1291 /* SubredditPostsIOS.swift */, + 657643772ACF1D2100805641 /* FittiingScrollView.swift */, + 65CDA3632ACB0F140001EC44 /* ShareSheet.swift */, ); path = components; sourceTree = ""; @@ -1399,6 +1489,9 @@ EC3AC9D52AAD0DF60078EC78 /* editing */, ECC2C7B32AAB4A64000E196C /* ThemeEditPanel.swift */, ECC2C7B12AAB48D1000E196C /* ThemesPanel.swift */, + 652011FB2AC2C7BE005DD899 /* ThemeStore.swift */, + 652011FD2AC2D4F9005DD899 /* ThemeStoreDetailsView.swift */, + 652012012AC31E7C005DD899 /* ThemeStoreUploadSheet.swift */, ); path = theming; sourceTree = ""; @@ -1420,6 +1513,7 @@ EC1E7B672A815BE100DB459A /* LightboxButton.swift */, EC1E7B692A815BF300DB459A /* LightBoxOverlay.swift */, EC1E7B652A815BC100DB459A /* LightBoxElementView.swift */, + 65CDA3612ACB0EB50001EC44 /* ZoomableImageView.swift */, EC08542F2A4CDF8A00E88389 /* LightBoxImage.swift */, ); path = LightBoxImage; @@ -1487,6 +1581,8 @@ EC3AC9D12AACC3DB0078EC78 /* DebouncedOnChange */, EC6650E92AB7E21E006B83A4 /* Zip */, EC2D72B22AC5335E002601E0 /* CHTCollectionViewWaterfallLayout */, + CE55C7462ADB51C300484EDA /* ObservedOptionalObject */, + 6514FFEB2AFD01520051D264 /* WhatsNewKit */, ); productName = winston; productReference = EC593AD42A4673B2002D6454 /* winston.app */; @@ -1556,6 +1652,8 @@ EC3AC9D02AACC3DB0078EC78 /* XCRemoteSwiftPackageReference "DebouncedOnChange" */, EC6650E82AB7E21E006B83A4 /* XCRemoteSwiftPackageReference "Zip" */, EC2D72B12AC5335E002601E0 /* XCRemoteSwiftPackageReference "CHTCollectionViewWaterfallLayout" */, + CE55C7452ADB517600484EDA /* XCRemoteSwiftPackageReference "observed-optional-object" */, + 6514FFEA2AFD01520051D264 /* XCRemoteSwiftPackageReference "WhatsNewKit" */, ); productRefGroup = EC593AD52A4673B2002D6454 /* Products */; projectDirPath = ""; @@ -1575,6 +1673,7 @@ EC22A3172A5B74CE0062A112 /* ci_post_clone.sh in Resources */, EC593ADC2A4673B3002D6454 /* Assets.xcassets in Resources */, EC6CBE332A7D6BF80089EAA0 /* README.md in Resources */, + 65E45A6E2AFD3C1D007DBBFC /* changelog.json in Resources */, ECA30A172A5FA08500BB19D1 /* WhatToTest.en-US.txt in Resources */, EC051C472ACAF2DC00E9D9ED /* themeFileIcon.png in Resources */, ); @@ -1602,6 +1701,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 65B0619B2AFE8A05008B7AF3 /* changelog.json in Sources */, EC593AF02A47ACB0002D6454 /* Tabber.swift in Sources */, EC3AC9D42AACCBE40078EC78 /* LabeledTextField.swift in Sources */, EC50B0E62A8460CD00AB8F72 /* replyModalPresenter.swift in Sources */, @@ -1622,16 +1722,20 @@ ECFDEAD02A7F151500DF102D /* PostsInBoxView.swift in Sources */, EC8B814E2AC9650E000DC827 /* String.swift in Sources */, 651D19C72A90B7D6003867A9 /* PostFontSettings.swift in Sources */, + 65CDA3622ACB0EB50001EC44 /* ZoomableImageView.swift in Sources */, EC0854262A4C013B00E88389 /* Avatar.swift in Sources */, ECE285A82A513342009CF9DD /* ReplyModal.swift in Sources */, ECEFC2652A5DE5910003C6C4 /* SubredditLink.swift in Sources */, EC46E92E2ABA7FC1001BC068 /* BuiltInBrowser.swift in Sources */, EC8F34662A6A348F00EB8249 /* SwipeActions.swift in Sources */, EC3188252A92F42800CB3738 /* MultiLink.swift in Sources */, + 652011FA2AC2C46C005DD899 /* themeStore.swift in Sources */, ECAA8B5A2A75004300D9D636 /* redditURLParser.swift in Sources */, EC63735D2A791E47000A03A3 /* Onboarding7Ending.swift in Sources */, ECC68D412A799CDD005BC7B4 /* SelectableText.swift in Sources */, ECDD515C2A73A90700FD9498 /* PreviewLink.swift in Sources */, + 652012062AC322BC005DD899 /* fetchThemeStatus.swift in Sources */, + 652012022AC31E7C005DD899 /* ThemeStoreUploadSheet.swift in Sources */, EC5EDA712A4DA94A00140A08 /* SwipeActionsOld.swift in Sources */, EC3AC9F02AB39BE50078EC78 /* FeedThemingPanel.swift in Sources */, ECF0B7E92A543795002B9FAD /* fetchUsers.swift in Sources */, @@ -1640,9 +1744,12 @@ ECF064ED2A7F405C0088C2C0 /* KeyWindow.swift in Sources */, ECE4497D2A688A2A00E2A3B7 /* fetchSubRules.swift in Sources */, EC5EDA792A4F4D4200140A08 /* vote.swift in Sources */, + 652012002AC309FA005DD899 /* downloader.swift in Sources */, ECE449792A68897600E2A3B7 /* SubredditRulesTab.swift in Sources */, ECF5535B2A5C9EEA004A0136 /* searchSubreddits.swift in Sources */, + 65CDA3642ACB0F140001EC44 /* ShareSheet.swift in Sources */, EC31881C2A92BBFB00CB3738 /* Multi.swift in Sources */, + 65840EE82AE11BFD000315E1 /* exportImportSettings.swift in Sources */, ECC2C7B22AAB48D1000E196C /* ThemesPanel.swift in Sources */, ECAA8B892A764A3C00D9D636 /* YTMediaPost.swift in Sources */, EC2A39152AC20155001E5354 /* PostLinkTitle.swift in Sources */, @@ -1657,6 +1764,7 @@ EC200C882A7795BA00E69543 /* PostContent.swift in Sources */, EC3AC9D72AAD0E300078EC78 /* TagsOptionsCarousel.swift in Sources */, 65AA535D2A8BB27900275299 /* FAQPanel.swift in Sources */, + 657643782ACF1D2100805641 /* FittiingScrollView.swift in Sources */, ECBD8E2D2ABBCECC00C74E87 /* caches.swift in Sources */, EC1E7B6A2A815BF300DB459A /* LightBoxOverlay.swift in Sources */, EC6495A72A999C4A00B1852C /* CachedSub+CoreDataProperties.swift in Sources */, @@ -1677,6 +1785,7 @@ ECDD514C2A72561500FD9498 /* getFlairs.swift in Sources */, EC91C8542AB9601000F2AD30 /* TabBarOverlay.swift in Sources */, ECE285992A50086D009CF9DD /* UserView.swift in Sources */, + 657643802ACF20F100805641 /* ExpandableText.swift in Sources */, ECD1FC072A56D46D00723016 /* ObservableArray.swift in Sources */, EC8F34A12A6FC99000EB8249 /* updatePostsInBox.swift in Sources */, 6543D8892A8D10B900A1FFF9 /* scaleEffectButtonStyle.swift in Sources */, @@ -1694,6 +1803,7 @@ 659FDEA82A87798300103414 /* VoteButton.swift in Sources */, ECDD51642A74C03C00FD9498 /* sendErrorEmail.swift in Sources */, EC0854172A4BF45C00E88389 /* Comment.swift in Sources */, + ABC2180F2ABA528D00F0DABA /* Biometrics.swift in Sources */, EC8F34A32A6FCB3500EB8249 /* timeSince.swift in Sources */, EC8B77A22AB4D5F000875C98 /* LineThemeEditor.swift in Sources */, EC3AC9CF2AACB2A30078EC78 /* LabeledSlider.swift in Sources */, @@ -1713,6 +1823,7 @@ ECF5B3072AC83AE600355589 /* getAppIcon.swift in Sources */, ECA30A3B2A6329CC00BB19D1 /* colors.swift in Sources */, EC6373612A792B44000A03A3 /* KeyboardAware.swift in Sources */, + 652012042AC322A3005DD899 /* fetchAllThemes.swift in Sources */, EC3AC9E52AAD1F260078EC78 /* TextsSettings.swift in Sources */, EC593AF22A47AD97002D6454 /* Subreddits.swift in Sources */, EC8F34932A6F497700EB8249 /* submitPost.swift in Sources */, @@ -1720,6 +1831,7 @@ EC593AE12A4673B3002D6454 /* Persistence.swift in Sources */, EC58D4532AC0251700FF5B6B /* getPostContentWidth.swift in Sources */, ECC2C7B72AAC90BD000E196C /* PostSample.swift in Sources */, + 657643822ACF224500805641 /* AppDetailFullView.swift in Sources */, EC92FBDE2A589BE200391D1D /* SquircleUIKit.swift in Sources */, ECAA8B602A75216E00D9D636 /* ShortPostLink.swift in Sources */, ECE08B5B2AA9C43B00F84DC9 /* saveAndLoadImages.swift in Sources */, @@ -1751,12 +1863,16 @@ EC0854482A4D6E8C00E88389 /* getInitialSize.swift in Sources */, EC5EDA752A4E937700140A08 /* GenericRedditEntity.swift in Sources */, EC91C8562AB9838D00F2AD30 /* CommonListsThemingPanel.swift in Sources */, + 652012082AC327C7005DD899 /* uploadTheme.swift in Sources */, EC3188162A92A46100CB3738 /* updateMulti.swift in Sources */, EC08540E2A4BD26600E88389 /* ObservedScrollView.swift in Sources */, + 652011FC2AC2C7BE005DD899 /* ThemeStore.swift in Sources */, ECE08B7E2AA9F6A300F84DC9 /* PostThemingPanel.swift in Sources */, + 6514FFEE2AFD01AD0051D264 /* WhatsNewCollectionProvider.swift in Sources */, ECDBA8C72A4A83DB00FB42DB /* SubredditPosts.swift in Sources */, ECE08B802AA9F9BA00F84DC9 /* defaultTheme.swift in Sources */, EC2D275E2A4FC512006444D6 /* TappableView.swift in Sources */, + 6576437E2ACF20DA00805641 /* AppDetailDescription.swift in Sources */, EC3B32442AC64F43002C1291 /* SubredditPostsIOS.swift in Sources */, EC593B922A494E7F002D6454 /* defaults.swift in Sources */, EC31882D2A94736E00CB3738 /* MediaPresenter.swift in Sources */, @@ -1771,6 +1887,7 @@ EC9BB1BD2A8ACD300070CD97 /* AlphabetJumper.swift in Sources */, ECEFC2632A5DDBE00003C6C4 /* onBindingUpdate.swift in Sources */, EC593AF42A47AE1F002D6454 /* Inbox.swift in Sources */, + 657643842AD0083500805641 /* fetchThemesByName.swift in Sources */, EC1E7B642A81507B00DB459A /* UIShortTapGestureRecognizer.swift in Sources */, ECF064EF2A80383C0088C2C0 /* sumCGSize.swift in Sources */, ECE08B622AA9C99D00F84DC9 /* ListsTheme.swift in Sources */, @@ -1780,9 +1897,11 @@ EC3B32412AC64DDE002C1291 /* Waterfall.swift in Sources */, EC3AC9E92AB317CB0078EC78 /* subtleSheet.swift in Sources */, EC3AC9D92AAD123E0078EC78 /* FontSelector.swift in Sources */, + 652011FE2AC2D4F9005DD899 /* ThemeStoreDetailsView.swift in Sources */, EC0854412A4D558D00E88389 /* Interpolator.swift in Sources */, 6593CC452A88D128003CA58D /* hapticFeedbackModifier.swift in Sources */, ECAEFF6B2A77DE490052FCD3 /* emojiCodes.swift in Sources */, + 65CDA3662ACB0F3A0001EC44 /* LiveTextInteraction.swift in Sources */, ECFDEAD22A7F154C00DF102D /* SubItem.swift in Sources */, EC8F34992A6F7DDB00EB8249 /* winstonMDEditorPreset.swift in Sources */, EC6495A62A999C4A00B1852C /* CachedSub+CoreDataClass.swift in Sources */, @@ -1838,6 +1957,7 @@ EC22A3202A5C7FB50062A112 /* Message.swift in Sources */, EC593AFD2A47AEBC002D6454 /* RedditAPI.swift in Sources */, EC6373492A78E49A000A03A3 /* OnboardingWelcome.swift in Sources */, + 6576437C2ACF1E3900805641 /* ScrolViewContentRTLFriendly.swift in Sources */, ECDD51542A73889A00FD9498 /* rootURL.swift in Sources */, EC1E7B6C2A81650700DB459A /* loader.swift in Sources */, EC58D4582AC02BA500FF5B6B /* PostLinkContext.swift in Sources */, @@ -1848,12 +1968,15 @@ ECC10C742A4A8729009124BE /* fontSize.swift in Sources */, EC1906F32A55AC6E00A0300E /* fetchMoreReplies.swift in Sources */, ECA30A372A622C2F00BB19D1 /* MD.swift in Sources */, + 658DB5A42AFBB8230062F8B1 /* fetchThemeByID.swift in Sources */, ECE08B7C2AA9F69A00F84DC9 /* CommentsThemingPanel.swift in Sources */, EC1E7B682A815BE100DB459A /* LightboxButton.swift in Sources */, + 6576437A2ACF1E1A00805641 /* AppDetailScreenshots.swift in Sources */, 6593CC432A88C9E8003CA58D /* AccessibilityPanel.swift in Sources */, ECEFC2732A5F70B70003C6C4 /* saveMedia.swift in Sources */, ECEFC2672A5DE60C0003C6C4 /* UserLink.swift in Sources */, ECC68D492A7A9136005BC7B4 /* WDivider.swift in Sources */, + 65010D032AC70ADD00A8F611 /* getDownloadURL.swift in Sources */, EC6373572A791A2D000A03A3 /* Onboarding5GettingSecret.swift in Sources */, ECF1478F2AB620A400572352 /* CommentSample.swift in Sources */, ECE08B682AA9E62900F84DC9 /* AvatarTheme.swift in Sources */, @@ -1865,6 +1988,7 @@ EC3188102A92A40600CB3738 /* fetchMyMultis.swift in Sources */, EC8B779E2AB3A9C500875C98 /* SchemesBGImagesPicker.swift in Sources */, EC828EA92A904E56009478B1 /* TestersCelebration.swift in Sources */, + 65A5FFF22ACED3BF000D1ADA /* getPreviewImages.swift in Sources */, ECE08B862AAA43F400F84DC9 /* NiceDivider.swift in Sources */, EC12D7182A59C7A800C300B3 /* Arrows.swift in Sources */, ECDD514E2A726E5900FD9498 /* modifyColor.swift in Sources */, @@ -1872,6 +1996,7 @@ ECE08B8A2AAB421400F84DC9 /* randWord.swift in Sources */, ECF314E52ABC187400DFDDBA /* hashableCGSize.swift in Sources */, EC593AF62A47AE30002D6454 /* Me.swift in Sources */, + 65D265412ABEC6FF009E01F9 /* urlCleaner.swift in Sources */, EC3188142A92A43300CB3738 /* fetchMultiInfo.swift in Sources */, EC12D71A2A59C81A00C300B3 /* CommentLinkContent.swift in Sources */, ECE08B592AA9A3C300F84DC9 /* WinstonTheme.swift in Sources */, @@ -1892,6 +2017,7 @@ EC2D27652A4FCE42006444D6 /* SubredditInfo.swift in Sources */, EC0854132A4BE13000E88389 /* PostView.swift in Sources */, ECE449712A67610A00E2A3B7 /* VotesPopover.swift in Sources */, + 65E45A702AFD3E48007DBBFC /* whatsNew.swift in Sources */, ECE449812A689C8A00E2A3B7 /* subscribe.swift in Sources */, EC3188322A94770700CB3738 /* GalleryThumb.swift in Sources */, EC0854432A4D6A4800E88389 /* MasterButton.swift in Sources */, @@ -2053,7 +2179,7 @@ CODE_SIGN_ENTITLEMENTS = winston/winston.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2; - DEVELOPMENT_TEAM = Z2NNLU4F7U; + DEVELOPMENT_TEAM = TCHY3UX6JP; ENABLE_PREVIEWS = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = winston/Info.plist; @@ -2061,6 +2187,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Winston needs access to your documents to save/import themes."; + INFOPLIST_KEY_NSFaceIDUsageDescription = "Uses FaceID to lock and unlock Winston"; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Winston needs access to your library to be able to download images."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -2072,7 +2199,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = lo.cafe.winston; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -2093,7 +2220,7 @@ CODE_SIGN_ENTITLEMENTS = winston/winston.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2; - DEVELOPMENT_TEAM = Z2NNLU4F7U; + DEVELOPMENT_TEAM = TCHY3UX6JP; ENABLE_PREVIEWS = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = winston/Info.plist; @@ -2101,6 +2228,7 @@ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Winston needs access to your documents to save/import themes."; + INFOPLIST_KEY_NSFaceIDUsageDescription = "Uses FaceID to lock and unlock Winston"; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Winston needs access to your library to be able to download images."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -2112,7 +2240,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = lo.cafe.winston; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -2126,7 +2254,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Z2NNLU4F7U; + DEVELOPMENT_TEAM = TCHY3UX6JP; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "winston-everywhere/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Winston Everywhere"; @@ -2156,7 +2284,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Z2NNLU4F7U; + DEVELOPMENT_TEAM = TCHY3UX6JP; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "winston-everywhere/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "Winston Everywhere"; @@ -2214,6 +2342,22 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 6514FFEA2AFD01520051D264 /* XCRemoteSwiftPackageReference "WhatsNewKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SvenTiigi/WhatsNewKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.1.0; + }; + }; + CE55C7452ADB517600484EDA /* XCRemoteSwiftPackageReference "observed-optional-object" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/fourplusone/observed-optional-object.git"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 0.1.0; + }; + }; EC08543B2A4CFE1C00E88389 /* XCRemoteSwiftPackageReference "swiftui-introspect" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/siteline/swiftui-introspect"; @@ -2369,6 +2513,16 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 6514FFEB2AFD01520051D264 /* WhatsNewKit */ = { + isa = XCSwiftPackageProductDependency; + package = 6514FFEA2AFD01520051D264 /* XCRemoteSwiftPackageReference "WhatsNewKit" */; + productName = WhatsNewKit; + }; + CE55C7462ADB51C300484EDA /* ObservedOptionalObject */ = { + isa = XCSwiftPackageProductDependency; + package = CE55C7452ADB517600484EDA /* XCRemoteSwiftPackageReference "observed-optional-object" */; + productName = ObservedOptionalObject; + }; EC08543C2A4CFE1C00E88389 /* SwiftUIIntrospect */ = { isa = XCSwiftPackageProductDependency; package = EC08543B2A4CFE1C00E88389 /* XCRemoteSwiftPackageReference "swiftui-introspect" */; diff --git a/winston.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/winston.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9c493410..3d018dd4 100644 --- a/winston.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/winston.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -99,6 +99,15 @@ "version" : "12.1.6" } }, + { + "identity" : "observed-optional-object", + "kind" : "remoteSourceControl", + "location" : "https://github.com/fourplusone/observed-optional-object.git", + "state" : { + "revision" : "74aa40a398688b90ee7724797861185e5a696aca", + "version" : "0.1.1" + } + }, { "identity" : "opengraph", "kind" : "remoteSourceControl", @@ -189,6 +198,15 @@ "version" : "1.5.0" } }, + { + "identity" : "whatsnewkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SvenTiigi/WhatsNewKit.git", + "state" : { + "revision" : "238dadc14dd58885f51037e63d08baf4275c480e", + "version" : "2.1.0" + } + }, { "identity" : "youtubeplayerkit", "kind" : "remoteSourceControl", diff --git a/winston.xcodeproj/xcuserdata/daniel.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/winston.xcodeproj/xcuserdata/daniel.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 6779d012..2aa5172d 100644 --- a/winston.xcodeproj/xcuserdata/daniel.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/winston.xcodeproj/xcuserdata/daniel.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -16,8 +16,8 @@ endingColumnNumber = "9223372036854775807" startingLineNumber = "50" endingLineNumber = "50" - landmarkName = "sort(_:)" - landmarkType = "7"> + landmarkName = "body" + landmarkType = "24"> diff --git a/winston/.DS_Store b/winston/.DS_Store new file mode 100644 index 00000000..a7f68dc6 Binary files /dev/null and b/winston/.DS_Store differ diff --git a/winston/AppDelegate.swift b/winston/AppDelegate.swift index 42127cd9..b78cd8e3 100644 --- a/winston/AppDelegate.swift +++ b/winston/AppDelegate.swift @@ -12,11 +12,22 @@ import AVKit import AVFoundation import Nuke -class AppDelegate: NSObject, UIApplicationDelegate { +class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers]) return true } + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + if let shortcutItem = options.shortcutItem { + shortcutItemToProcess = shortcutItem + } + + let sceneConfiguration = UISceneConfiguration(name: "Custom Configuration", sessionRole: connectingSceneSession.role) + sceneConfiguration.delegateClass = CustomSceneDelegate.self + + return sceneConfiguration + } func applicationDidFinishLaunching(_ application: UIApplication) { let defaultPipeline = ImagePipeline { config in config.dataCache = try? DataCache(name: "lo.cafe.winston.datacache") @@ -32,3 +43,9 @@ class AppDelegate: NSObject, UIApplicationDelegate { ImagePipeline.shared = defaultPipeline } } + +class CustomSceneDelegate: UIResponder, UIWindowSceneDelegate { + func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { + shortcutItemToProcess = shortcutItem + } +} diff --git a/winston/Assets.xcassets/.DS_Store b/winston/Assets.xcassets/.DS_Store new file mode 100644 index 00000000..768c6785 Binary files /dev/null and b/winston/Assets.xcassets/.DS_Store differ diff --git a/winston/Biometrics.swift b/winston/Biometrics.swift new file mode 100644 index 00000000..72800a71 --- /dev/null +++ b/winston/Biometrics.swift @@ -0,0 +1,70 @@ +// +// Biometrics.swift +// winston +// +// Created by Nelson Dane on 19/09/23. +// + +import SwiftUI +import Foundation +import LocalAuthentication + +class Biometrics { + let context = LAContext() + var error: NSError? + + func checkIfEnrolled() -> Bool { + var isEnrolled = false + if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { + print("Biometrics are available on this device") + isEnrolled = true + } else { + print("Biometrics are not available on this device") + } + return isEnrolled + } + + func biometricType() -> String { + if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { + if #available(iOS 11.0, *) { + switch context.biometryType { + case .opticID: + return "Optic ID" + case .faceID: + return "Face ID" + case .touchID: + return "Touch ID" + case .none: + return "None" + @unknown default: + return "Unknown" + } + } else { + // Fallback on earlier versions + return "Touch ID" + } + } + else { + // Fallback to passcode + return "Passcode" + } + } + + func authenticateUser(completion: @escaping (Bool) -> Void) { + // Check if biomtrics are available + if !checkIfEnrolled() { + completion(false) + return + } + // We're all good, let's authenticate + context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Identify Yourself!") { success, error in + if success { + print("Auth Success") + completion(true) + } else { + print("Auth Failure: \(error?.localizedDescription ?? "Failed to authenticate")") + completion(false) + } + } + } +} diff --git a/winston/Tabber.swift b/winston/Tabber.swift index 3a7e8df8..6a0b388b 100644 --- a/winston/Tabber.swift +++ b/winston/Tabber.swift @@ -46,12 +46,12 @@ class TabPayload: ObservableObject { struct Tabber: View { @ObservedObject var tempGlobalState = TempGlobalState.shared @ObservedObject var errorAlert = Oops.shared - @State var activeTab = TabIdentifier.posts + @State var activeTab: TabIdentifier @State var credModalOpen = false @State var importedThemeAlert = false - -// @State var tabBarHeight: CGFloat? + + // @State var tabBarHeight: CGFloat? @StateObject private var inboxPayload = TabPayload("inboxRouter") @StateObject private var mePayload = TabPayload("meRouter") @StateObject private var postsPayload = TabPayload("postsRouter") @@ -59,10 +59,14 @@ struct Tabber: View { @StateObject private var settingsPayload = TabPayload("settingsRouter") @Environment(\.useTheme) private var currentTheme @Environment(\.colorScheme) private var colorScheme + @EnvironmentObject var themeStoreAPI: ThemeStoreAPI @Default(.showUsernameInTabBar) private var showUsernameInTabBar - @Default(.showTestersCelebrationModal) private var showTestersCelebrationModal @Default(.showTipJarModal) private var showTipJarModal + + @State var sharedTheme: ThemeData? = nil + @State var showingSharedThemeSheet: Bool = false + var payload: [TabIdentifier:TabPayload] { [ .inbox: inboxPayload, .me: mePayload, @@ -79,7 +83,8 @@ struct Tabber: View { } } - init(theme: WinstonTheme, cs: ColorScheme) { + init(theme: WinstonTheme, cs: ColorScheme, activeTab: TabIdentifier) { + _activeTab = State(initialValue: activeTab) // Initialize activeTab Tabber.updateTabAndNavBar(tabTheme: theme.general.tabBarBG, navTheme: theme.general.navPanelBG, cs) } @@ -216,9 +221,6 @@ struct Tabber: View { Text("The theme was imported successfully. Enable it in \"Themes\" section in the Settings tab.") } .onAppear { - if showTestersCelebrationModal { - showTipJarModal = false - } Defaults[.themesPresets] = Defaults[.themesPresets].filter { $0.id != "default" } if Defaults[.multis].count != 0 || Defaults[.subreddits].count != 0 { Defaults[.multis] = [] @@ -235,12 +237,6 @@ struct Tabber: View { } } } -// .onChange(of: currentTheme.general.tabBarBG, perform: { val in -// Tabber.updateTabAndNavBar(tabTheme: val, navTheme: currentTheme.general.navPanelBG, colorScheme) -// }) -// .onChange(of: currentTheme.general.navPanelBG, perform: { val in -// Tabber.updateTabAndNavBar(tabTheme: currentTheme.general.tabBarBG, navTheme: val, colorScheme) -// }) .onChange(of: RedditAPI.shared.loggedUser) { user in if user.apiAppID == nil || user.apiAppSecret == nil { withAnimation(spring) { @@ -248,7 +244,21 @@ struct Tabber: View { } } } + .sheet(isPresented: $showingSharedThemeSheet, content: { + if let theme = sharedTheme { + ThemeStoreDetailsView(themeData: theme) + } + }) .onOpenURL { url in + + if url.absoluteString.contains("winstonapp://theme/") { + let themeID = url.lastPathComponent + Task { + sharedTheme = await themeStoreAPI.fetchThemeByID(id: themeID) + showingSharedThemeSheet.toggle() + } + } + if url.absoluteString.hasSuffix(".winston") || url.absoluteString.hasSuffix(".zip") { TempGlobalState.shared.globalLoader.enable("Importing...") let result = importTheme(at: url) @@ -275,9 +285,6 @@ struct Tabber: View { } } } - .sheet(isPresented: $showTestersCelebrationModal) { - TestersCelebration() - } .sheet(isPresented: $showTipJarModal) { TipJar() } @@ -286,7 +293,7 @@ struct Tabber: View { .interactiveDismissDisabled(true) } .accentColor(currentTheme.general.accentColor.cs(colorScheme).color()) -// .id(currentTheme.general.tabBarBG) + // .id(currentTheme.general.tabBarBG) } } diff --git a/winston/components/AppDetailDescription.swift b/winston/components/AppDetailDescription.swift new file mode 100644 index 00000000..b194e490 --- /dev/null +++ b/winston/components/AppDetailDescription.swift @@ -0,0 +1,28 @@ +// +// AppDetailDescription.swift +// winston +// +// Created by Daniel Inama on 05/10/23. +// + +import SwiftUI + +public struct AppDetailDescription: View { + @ScaledMetric private var spacing: CGFloat = 8 + + let text: String + + public init(text: String) { + self.text = text + } + + public var body: some View { + VStack(alignment: .leading, spacing: spacing) { + Text("Description") + .font(.title3) + .fontWeight(.semibold) + + ExpandableText(text) + } + } +} diff --git a/winston/components/AppDetailFullView.swift b/winston/components/AppDetailFullView.swift new file mode 100644 index 00000000..645f71d5 --- /dev/null +++ b/winston/components/AppDetailFullView.swift @@ -0,0 +1,84 @@ +// +// AppDetailFullView.swift +// winston +// +// Created by Daniel Inama on 05/10/23. +// +import SwiftUI + +public struct AppDetailInfoFullView: View { + + @ScaledMetric private var spacing: CGFloat = 8 + @ScaledMetric private var rowVerticalPadding: CGFloat = 12 + + let author: String? + let themeID: String? + let themeName: String? + + + private let entries: [Entry] + + public init(author: String?, themeId: String?, themeName: String?) { + self.author = author + self.themeID = themeId + self.themeName = themeName + + + self.entries = [ + .init(text: "Author", image: "person", value: author), + .init(text: "Theme ID", image: "qrcode", value: themeId), + .init(text: "Theme Name", image: "lanyardcard", value: themeName), + + ].filter({ $0.value != nil }) + } + + public var body: some View { + VStack(alignment: .leading, spacing: spacing) { + Text("Information") + .font(.title3) + .fontWeight(.semibold) + + VStack(spacing: 0) { + ForEach(Array(zip(entries.indices, entries)), id: \.1) { index, entry in + entryView(text: entry.text, image: entry.image, value: entry.value) + .overlay(alignment: .bottom, content: { + if index != entries.count - 1 { + Divider() + } + }) + } + } + .padding(.horizontal) + .background(Color.gray.opacity(0.15), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + } + + @ViewBuilder private func entryView(text: String, image: String, value: String?) -> some View { + if let value { + LabeledContent { + Text(value) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.trailing) + .textSelection(.enabled) + } label: { + Label { + Text(text) + } icon: { + Image(systemName: image) + } + + } + .font(.subheadline) + .padding(.vertical, rowVerticalPadding) + } + } + + private struct Entry: Identifiable, Hashable { + let text: String + let image: String + let value: String? + + var id: String { text } + } +} + diff --git a/winston/components/AppDetailScreenshots.swift b/winston/components/AppDetailScreenshots.swift new file mode 100644 index 00000000..75b767e7 --- /dev/null +++ b/winston/components/AppDetailScreenshots.swift @@ -0,0 +1,61 @@ +// +// AppDetailScreenshots.swift +// winston +// +// Created by Daniel Inama on 05/10/23. +// + +import SwiftUI +import NukeUI + +public struct AppDetailScreenshots: View { + + public static let maxHeight: CGFloat = 500 + + let screenshots: [String] + + public init(screenshots: [String]) { + self.screenshots = screenshots + } + + + public var body: some View { + if #available(iOS 17.0, *) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10){ + ForEach(screenshots, id:\.self){ screenshot in + URLImage(url: URL(string: screenshot)!) + .clipShape(RoundedRectangle(cornerSize: CGSize(width: 15, height: 15))) + .scaledToFit() + .frame(maxHeight: AppDetailScreenshots.maxHeight) + } + } + .padding(.horizontal) + .scrollViewContentRTLFriendly() + } + .frame(height: galleryHeight) + .scrollViewRTLFriendly() + .scrollTargetBehavior(.paging) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10){ + ForEach(screenshots, id:\.self){ screenshot in + URLImage(url: URL(string: screenshot)!) + .clipShape(RoundedRectangle(cornerSize: CGSize(width: 15, height: 15))) + .scaledToFit() + .frame(maxHeight: AppDetailScreenshots.maxHeight) + } + } + .padding(.horizontal) + .scrollViewContentRTLFriendly() + } + .frame(height: galleryHeight) + .scrollViewRTLFriendly() + } + } + + private var galleryHeight: CGFloat { + Self.maxHeight + } +} + diff --git a/winston/components/Badge.swift b/winston/components/Badge.swift index 236dac8b..abc25831 100644 --- a/winston/components/Badge.swift +++ b/winston/components/Badge.swift @@ -8,34 +8,41 @@ import Foundation import SwiftUI import Defaults +import ObservedOptionalObject struct BadgeView: View, Equatable { static let authorStatsSpacing: Double = 2 static func == (lhs: BadgeView, rhs: BadgeView) -> Bool { - lhs.extraInfo == rhs.extraInfo && lhs.theme == rhs.theme && lhs.avatarURL == rhs.avatarURL && lhs.saved == rhs.saved + lhs.extraInfo == rhs.extraInfo && lhs.theme == rhs.theme && lhs.avatarURL == rhs.avatarURL && lhs.saved == rhs.saved && lhs.id == rhs.id } + @ObservedOptionalObject var post: Post? + + var id: String var saved = false + var unseen = false var usernameColor: Color? var author: String + var flair: String? var fullname: String? = nil var created: Double var avatarURL: String? var theme: BadgeTheme + var commentTheme: CommentTheme? var extraInfo: [BadgeExtraInfo] = [] var routerProxy: RouterProxy? var cs: ColorScheme let flagY: CGFloat = 16 let delay: CGFloat = 0.4 - - nonisolated func openUser() { + + func openUser() { routerProxy?.router.path.append(User(id: author, api: RedditAPI.shared)) } var body: some View { let showAvatar = theme.avatar.visible - + HStack(spacing: theme.spacing) { if saved && !showAvatar { @@ -78,8 +85,33 @@ struct BadgeView: View, Equatable { VStack(alignment: .leading, spacing: BadgeView.authorStatsSpacing) { - Text(author).font(.system(size: theme.authorText.size, weight: theme.authorText.weight.t)).foregroundColor(author == "[deleted]" ? .red : usernameColor ?? theme.authorText.color.cs(cs).color()) - .onTapGesture(perform: openUser) + HStack(alignment: .center, spacing: 6) { + Text(author).font(.system(size: theme.authorText.size, weight: theme.authorText.weight.t)).foregroundColor(author == "[deleted]" ? .red : usernameColor ?? theme.authorText.color.cs(cs).color()) + .onTapGesture(perform: openUser) + + if unseen { + ZStack { + Circle() + .fill(commentTheme?.unseenDot.cs(cs).color() ?? .red) + .frame(width: 6, height: 6) + Circle() + .fill(commentTheme?.unseenDot.cs(cs).color() ?? .red) + .frame(width: 8, height: 8) + .blur(radius: 8) + } + } + + if let f = flair?.trimmingCharacters(in: .whitespacesAndNewlines) { + if !f.isEmpty { + let colonIndex = f.lastIndex(of: ":") + let flairWithoutEmoji = String(f[(f.contains(":") ? f.index(colonIndex!, offsetBy: min(2, max(0, f.count - f.distance(from: f.startIndex, to: colonIndex!)))) : f.startIndex)...]) + if !flairWithoutEmoji.isEmpty { + // TODO Load flair emojis via GET /api/v1/{subreddit}/emojis/{emoji_name} + Text(flairWithoutEmoji).font(.system(size: theme.flairText.size, weight: theme.flairText.weight.t)).lineLimit(1).foregroundColor(theme.flairText.color.cs(cs).color()).padding(EdgeInsets(top: 0, leading: 6, bottom: 0, trailing: 6)).background(theme.flairBackground.cs(cs).color()).clipShape(Capsule()) + } + } + } + } HStack(alignment: .center, spacing: 6) { ForEach(extraInfo, id: \.self){ elem in @@ -87,6 +119,13 @@ struct BadgeView: View, Equatable { Image(systemName: elem.systemImage) .foregroundColor(elem.iconColor ?? theme.statsText.color.cs(cs).color()) Text(elem.text) + + if elem.type == "comments", let seenComments = post?.data?.winstonSeenCommentCount, let totalComments = Int(elem.text) { + let unseenComments = totalComments - seenComments + if unseenComments > 0 { + Text("(\(Int(unseenComments)))").foregroundColor(.accentColor) + } + } } } @@ -111,7 +150,7 @@ struct Badge: View, Equatable { lhs.extraInfo == rhs.extraInfo && lhs.theme == rhs.theme && lhs.avatarURL == rhs.avatarURL } - @ObservedObject var post: Post + @ObservedOptionalObject var post: Post? var usernameColor: Color? var avatarURL: String? var theme: BadgeTheme @@ -119,35 +158,38 @@ struct Badge: View, Equatable { @EnvironmentObject private var routerProxy: RouterProxy @Environment(\.colorScheme) private var cs: ColorScheme - var body: some View { - if let data = post.data { - BadgeView(saved: data.saved, usernameColor: usernameColor, author: data.author, fullname: data.author_fullname, created: data.created, avatarURL: avatarURL, theme: theme, extraInfo: extraInfo, routerProxy: routerProxy, cs: cs) + if let data = post?.data { + BadgeView(post: post, id: data.id, usernameColor: usernameColor, author: data.author, flair: data.author_flair_text, fullname: data.author_fullname, created: data.created, avatarURL: avatarURL, theme: theme, extraInfo: extraInfo, routerProxy: routerProxy, cs: cs) } } } struct BadgeComment: View, Equatable { static func == (lhs: BadgeComment, rhs: BadgeComment) -> Bool { - lhs.extraInfo == rhs.extraInfo && lhs.theme == rhs.theme && lhs.avatarURL == rhs.avatarURL + lhs.extraInfo == rhs.extraInfo && lhs.theme == rhs.theme && lhs.avatarURL == rhs.avatarURL && lhs.comment.data?.id == rhs.comment.data?.id } @ObservedObject var comment: Comment + var unseen: Bool var usernameColor: Color? var avatarURL: String? var theme: BadgeTheme + var commentTheme: CommentTheme? var extraInfo: [BadgeExtraInfo] = [] @EnvironmentObject private var routerProxy: RouterProxy @Environment(\.colorScheme) private var cs: ColorScheme var body: some View { if let data = comment.data, let author = data.author, let created = data.created { - BadgeView(saved: data.saved ?? false, usernameColor: usernameColor, author: author, fullname: data.author_fullname, created: created, avatarURL: avatarURL, theme: theme, extraInfo: extraInfo, routerProxy: routerProxy, cs: cs) + BadgeView(id: data.id, unseen: unseen, usernameColor: usernameColor, author: author, flair: data.author_flair_text, fullname: data.author_fullname, created: created, avatarURL: avatarURL, theme: theme, commentTheme: commentTheme, extraInfo: extraInfo, routerProxy: routerProxy, cs: cs) + } } } struct BadgeExtraInfo: Hashable { + var type: String var systemImage: String = "" var text: String // var textColor: Color = Color.primary @@ -163,11 +205,11 @@ struct PresetBadgeExtraInfo { // let upvoted = data.likes != nil && data.likes! // let downvoted = data.likes != nil && !data.likes! // return BadgeExtraInfo(systemImage: upvoted ? "arrow.up" : (downvoted ? "arrow.down" : "arrow.up"), text: "\(formatBigNumber(data.ups))",textColor: upvoted ? .orange : (downvoted ? .blue : .primary), iconColor: upvoted ? .orange : (downvoted ? .blue : .primary)) - return BadgeExtraInfo(systemImage: "arrow.up", text: "\(formatBigNumber(data.ups))") + return BadgeExtraInfo(type: "ups", systemImage: "arrow.up", text: "\(formatBigNumber(data.ups))") } func commentsExtraInfo(data: PostData) -> BadgeExtraInfo{ - return BadgeExtraInfo(systemImage: "message.fill", text: "\(formatBigNumber(data.num_comments))") + return BadgeExtraInfo(type: "comments", systemImage: "message.fill", text: "\(formatBigNumber(data.num_comments))") } } diff --git a/winston/components/ExpandableText.swift b/winston/components/ExpandableText.swift new file mode 100644 index 00000000..f77e96e2 --- /dev/null +++ b/winston/components/ExpandableText.swift @@ -0,0 +1,175 @@ +// +// ExpandableText.swift +// winston +// +// Created by Daniel Inama on 05/10/23. +// + +import SwiftUI + +struct ExpandableText: View { + + @State private var isExpanded: Bool = false + @State private var isTruncated: Bool = false + + @State private var intrinsicSize: CGSize = .zero + @State private var truncatedSize: CGSize = .zero + @State private var moreTextSize: CGSize = .zero + + let text: String + let font: Font + let lineLimit: Int + let moreText: String + + init( + _ text: String, + font: Font = .callout, + lineLimit: Int = 3, + moreText: String = "more" + ) { + self.text = text.trimmingCharacters(in: .whitespacesAndNewlines) + self.font = font + self.lineLimit = lineLimit + self.moreText = moreText + } + + var body: some View { + content + .lineLimit(isExpanded ? nil : lineLimit) + .applyingTruncationMask(moreTextSize: moreTextSize, isExpanded: isExpanded, isTruncated: isTruncated) + .readSize { size in + truncatedSize = size + isTruncated = truncatedSize != intrinsicSize + } + .background( + content + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .hidden() + .readSize { size in + intrinsicSize = size + isTruncated = truncatedSize != intrinsicSize + } + ) + .background( + Text(moreText) + .font(font) + .hidden() + .readSize { moreTextSize = $0 } + ) + .contentShape(Rectangle()) + .onTapGesture { + if !isExpanded, isTruncated { + withAnimation { isExpanded.toggle() } + } + } + .overlay(alignment: .trailingLastTextBaseline) { + if !isExpanded, isTruncated { + Button { + withAnimation { isExpanded.toggle() } + } label: { + Text(moreText) + .font(font) + } + } + } + } + + private var content: some View { + Text(.init(!isExpanded && isTruncated ? textTrimmingDoubleNewlines : text)) + .font(font) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var textTrimmingDoubleNewlines: String { + text.replacingOccurrences(of: #"\n\s*\n"#, with: "\n", options: .regularExpression) + } +} + +private struct TruncationTextMask: ViewModifier { + + let moreTextSize: CGSize + let isExpanded: Bool + let isTruncated: Bool + + @Environment(\.layoutDirection) private var layoutDirection + + func body(content: Content) -> some View { + if !isExpanded, isTruncated { + content + .mask( + VStack(spacing: 0) { + Rectangle() + HStack(spacing: 0){ + Rectangle() + HStack(spacing: 0) { + LinearGradient( + gradient: Gradient(stops: [ + Gradient.Stop(color: .black, location: 0), + Gradient.Stop(color: .clear, location: 0.85)]), + startPoint: layoutDirection == .rightToLeft ? .trailing : .leading, + endPoint: layoutDirection == .rightToLeft ? .leading : .trailing + ).frame(width: moreTextSize.width, height: moreTextSize.height) + + Rectangle() + .foregroundColor(.clear) + .frame(width: moreTextSize.width) + } + }.frame(height: moreTextSize.height) + } + ) + } else { + content + .fixedSize(horizontal: false, vertical: true) + } + } +} + +private extension View { + func applyingTruncationMask(moreTextSize: CGSize, isExpanded: Bool, isTruncated: Bool) -> some View { + modifier(TruncationTextMask(moreTextSize: moreTextSize, isExpanded: isExpanded, isTruncated: isTruncated)) + } +} + +struct ExpandableText_Previews: PreviewProvider { + static var previews: some View { + ScrollView { + VStack(alignment: .leading) { + ExpandableText("This is a test\nThis is the second line\nThis is the third line huihiu huih g ytf tfytf tf \nFourth!") + .border(.red) + .padding() + .environment(\.layoutDirection, .rightToLeft) + ExpandableText("This is a test\nThis is the second line\nThis is the third line huihiu huih g ytf tfytf tf \nFourth!") + .border(.red) + .padding() + ExpandableText("This is a test\nThis is the second line\nThis is the third line\nFourth!", lineLimit: 4) + .border(.red) + .padding() + ExpandableText("This is a test\nThis is the second line\nThis is the third line\nFourth!", font: .title3) + .border(.red) + .padding() + Spacer() + } + } + } +} + + + +// https://www.fivestars.blog/articles/swiftui-share-layout-information/ +private struct SizePreferenceKey: PreferenceKey { + static var defaultValue: CGSize = .zero + static func reduce(value: inout CGSize, nextValue: () -> CGSize) {} +} + +extension View { + func readSize(onChange: @escaping (CGSize) -> Void) -> some View { + background( + GeometryReader { geometryProxy in + Color.clear + .preference(key: SizePreferenceKey.self, value: geometryProxy.size) + } + ) + .onPreferenceChange(SizePreferenceKey.self, perform: onChange) + } +} diff --git a/winston/components/FittiingScrollView.swift b/winston/components/FittiingScrollView.swift new file mode 100644 index 00000000..78f6ea38 --- /dev/null +++ b/winston/components/FittiingScrollView.swift @@ -0,0 +1,72 @@ +// +// FittiingScrollView.swift +// winston +// +// Created by Daniel Inama on 05/10/23. +// + +import SwiftUI + +/// Source: https://github.com/shaps80/SwiftUIBackports +/// A scrollview that behaves more similarly to a `VStack` when its content size is small enough. +public struct FittingScrollView: View { + private let content: Content + let onOffsetChange: ((CGFloat) -> Void)? + + public init(@ViewBuilder content: () -> Content, onOffsetChange: ((CGFloat) -> Void)? = nil) { + self.content = content() + self.onOffsetChange = onOffsetChange + } + + public var body: some View { + GeometryReader { geo in + ScrollViewOffset { + onOffsetChange?($0) + } content: { + VStack { + content + }.frame(maxWidth: geo.size.width, minHeight: geo.size.height) + } + } + } +} + +// Source: https://www.fivestars.blog/articles/scrollview-offset/ +public struct ScrollViewOffset: View { + let onOffsetChange: (CGFloat) -> Void + let content: () -> Content + + public init( + onOffsetChange: @escaping (CGFloat) -> Void, + @ViewBuilder content: @escaping () -> Content + ) { + self.onOffsetChange = onOffsetChange + self.content = content + } + + public var body: some View { + ScrollView { + offsetReader + content() + .padding(.top, -8) + } + .coordinateSpace(name: "frameLayer") + .onPreferenceChange(OffsetPreferenceKey.self, perform: onOffsetChange) + } + + var offsetReader: some View { + GeometryReader { proxy in + Color.clear + .preference( + key: OffsetPreferenceKey.self, + value: proxy.frame(in: .named("frameLayer")).minY + ) + } + .frame(height: 0) + } +} + +private struct OffsetPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = .zero + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {} +} diff --git a/winston/components/LabeledSlider.swift b/winston/components/LabeledSlider.swift index 405c502a..5893ecad 100644 --- a/winston/components/LabeledSlider.swift +++ b/winston/components/LabeledSlider.swift @@ -11,19 +11,20 @@ struct LabeledSlider: View { var label: String @Binding var value: CGFloat var range: ClosedRange + var step: CGFloat = 1 var body: some View { VStack(spacing: 10) { HStack { Text(label) Spacer() - Text(Int(value).description) + Text((step == 1 ? Int(value).description : String(format: "%.2f", value))) .opacity(0.5) } // .padding(.vertical, 8) HStack { Text(Int(range.lowerBound).description) .fontSize(15) - Slider(value: $value, in: range, step: 1) + Slider(value: $value, in: range, step: step) Text(Int(range.upperBound).description) .fontSize(15) } diff --git a/winston/components/LightBoxImage/LightBoxElementView.swift b/winston/components/LightBoxImage/LightBoxElementView.swift index 84f028ac..9844044d 100644 --- a/winston/components/LightBoxImage/LightBoxElementView.swift +++ b/winston/components/LightBoxImage/LightBoxElementView.swift @@ -17,24 +17,19 @@ struct LightBoxElement: Identifiable, Equatable { struct LightBoxElementView: View { var el: MediaExtracted var onTap: (()->())? + @State var doLiveText: Bool @Binding var isPinching: Bool - @State private var scale: CGFloat = 1.0 - @State private var anchor: UnitPoint = .center - @State private var offset: CGSize = .zero @State private var altSize: CGSize = .zero + @Binding var isZoomed: Bool var body: some View { - URLImage(url: el.url) - .scaledToFit() - .background( - el.size != .zero - ? nil - : GeometryReader { geo in - Color.clear - .onAppear { altSize = geo.size } - .onChange(of: geo.size) { newValue in altSize = newValue } - } - ) - .pinchToZoom(onTap: onTap, size: el.size == .zero ? altSize : el.size, isPinching: $isPinching, scale: $scale, anchor: $anchor, offset: $offset) + ZoomableScrollView(onTap: onTap, isZoomed: $isZoomed){ + URLImage(url: el.url, doLiveText: doLiveText) + .scaledToFit() + } .id("\(el.id)\(altSize.width + altSize.height)") + .frame(width: UIScreen.screenWidth) + .preferredColorScheme(.dark) + .edgesIgnoringSafeArea(.all) + .statusBar(hidden: true) } } diff --git a/winston/components/LightBoxImage/LightBoxImage.swift b/winston/components/LightBoxImage/LightBoxImage.swift index 2b74268d..823e7eec 100644 --- a/winston/components/LightBoxImage/LightBoxImage.swift +++ b/winston/components/LightBoxImage/LightBoxImage.swift @@ -11,170 +11,171 @@ import Defaults private let SPACING = 24.0 struct LightBoxImage: View { - var post: Post - var i: Int - var imagesArr: [MediaExtracted] - @Environment(\.dismiss) private var dismiss - @State private var appearBlack = false - @State private var appearContent = false - @State private var dragOffset: CGSize? - @State private var drag: CGSize = .zero - @State private var xPos: CGFloat = 0 - @State private var dragAxis: Axis? - @State private var activeIndex = 0 - @State private var loading = false - @State private var done = false - @State private var showOverlay = true - @Default(.lightboxViewsPost) private var lightboxViewsPost - - @State private var isPinching: Bool = false - @State private var scale: CGFloat = 1.0 - - private enum Axis { - case horizontal - case vertical - } - - func toggleOverlay() { - withAnimation(.easeOut(duration: 0.2)) { - showOverlay.toggle() + var post: Post + var i: Int + var imagesArr: [MediaExtracted] + @State var doLiveText: Bool + @Environment(\.dismiss) private var dismiss + @State private var appearBlack = false + @State private var appearContent = false + @State private var dragOffset: CGSize? + @State private var drag: CGSize = .zero + @State private var xPos: CGFloat = 0 + @State private var dragAxis: Axis? + @State private var activeIndex = 0 + @State private var loading = false + @State private var done = false + @State private var showOverlay = true + @Default(.lightboxViewsPost) private var lightboxViewsPost + + @State private var isPinching: Bool = false + @State private var isZoomed: Bool = false + + private enum Axis { + case horizontal + case vertical } - } - - var body: some View { - let interpolate = interpolatorBuilder([0, 100], value: abs(drag.height)) - HStack(spacing: SPACING) { - ForEach(Array(imagesArr.enumerated()), id: \.element.id) { index, img in - let selected = index == activeIndex - LightBoxElementView(el: img, onTap: toggleOverlay, isPinching: $isPinching) - .allowsHitTesting(selected) - .scaleEffect(!selected ? 1 : interpolate([1, 0.9], true)) - .blur(radius: selected && loading ? 24 : 0) - .offset(x: !selected ? 0 : dragAxis == .vertical ? drag.width : 0, y: i != activeIndex ? 0 : dragAxis == .vertical ? drag.height : 0) - } + + func toggleOverlay() { + withAnimation(.easeOut(duration: 0.2)) { + showOverlay.toggle() + } } - .fixedSize(horizontal: true, vertical: false) - .offset(x: xPos + (dragAxis == .horizontal ? drag.width : 0)) - .frame(maxWidth: UIScreen.screenWidth, maxHeight: UIScreen.screenHeight, alignment: .leading) - .highPriorityGesture( - scale > 1 - ? nil - : DragGesture(minimumDistance: 20) - .onChanged { val in - - if dragAxis == nil || dragOffset == nil { - dragOffset = val.translation - if abs(val.predictedEndTranslation.width) > abs(val.predictedEndTranslation.height) { - dragAxis = .horizontal - } else if abs(val.predictedEndTranslation.width) < abs(val.predictedEndTranslation.height) { - dragAxis = .vertical - } - } - - if let dragAxis = dragAxis, let dragOffset = dragOffset { - var transaction = Transaction() - transaction.isContinuous = true - transaction.animation = .interpolatingSpring(stiffness: 1000, damping: 100, initialVelocity: 0) - - var endPos = val.translation - if dragAxis == .horizontal { - endPos.height = 0 - } - withTransaction(transaction) { - drag = endPos - dragOffset + + var body: some View { + let interpolate = interpolatorBuilder([0, 100], value: abs(drag.height)) + HStack(spacing: SPACING) { + ForEach(Array(imagesArr.enumerated()), id: \.element.id) { index, img in + let selected = index == activeIndex + LightBoxElementView(el: img, onTap: toggleOverlay, doLiveText: doLiveText, isPinching: $isPinching, isZoomed: $isZoomed) + .allowsHitTesting(selected) + .scaleEffect(!selected ? 1 : interpolate([1, 0.9], true)) + .blur(radius: selected && loading ? 24 : 0) + .offset(x: !selected ? 0 : dragAxis == .vertical ? drag.width : 0, y: i != activeIndex ? 0 : dragAxis == .vertical ? drag.height : 0) //no } - } } - .onEnded { val in - if dragAxis == .horizontal { - let predictedEnd = val.predictedEndTranslation.width - (dragOffset?.width ?? 0) - drag = .zero - xPos += val.translation.width - (dragOffset?.width ?? 0) - dragOffset = nil - let newActiveIndex = min(imagesArr.count - 1, max(0, activeIndex + (predictedEnd < -(UIScreen.screenWidth / 2) ? 1 : predictedEnd > UIScreen.screenWidth / 2 ? -1 : 0))) - let finalXPos = -(CGFloat(newActiveIndex) * (UIScreen.screenWidth + (SPACING))) - let distance = abs(finalXPos - xPos) - activeIndex = newActiveIndex - var initialVel = abs(predictedEnd / distance) - initialVel = initialVel < 3.75 ? 0 : initialVel * 2 - withAnimation(.interpolatingSpring(stiffness: 150, damping: 17, initialVelocity: initialVel)) { - xPos = finalXPos - dragAxis = nil + .fixedSize(horizontal: true, vertical: false) + .offset(x: xPos + (dragAxis == .horizontal ? drag.width : 0)) //no + .frame(maxWidth: UIScreen.screenWidth, maxHeight: UIScreen.screenHeight, alignment: .leading) + .highPriorityGesture( + isZoomed + ? nil + : DragGesture(minimumDistance: 20) + .onChanged { val in + + if dragAxis == nil || dragOffset == nil { + dragOffset = val.translation + if abs(val.predictedEndTranslation.width) > abs(val.predictedEndTranslation.height) { + dragAxis = .horizontal + } else if abs(val.predictedEndTranslation.width) < abs(val.predictedEndTranslation.height) { + dragAxis = .vertical + } + } + + if let dragAxis = dragAxis, let dragOffset = dragOffset { + var transaction = Transaction() + transaction.isContinuous = true + transaction.animation = .interpolatingSpring(stiffness: 1000, damping: 100, initialVelocity: 0) + // + var endPos = val.translation + if dragAxis == .horizontal { + endPos.height = 0 + } + withTransaction(transaction) { + drag = endPos - dragOffset + } + } + } + .onEnded { val in + if dragAxis == .horizontal { + let predictedEnd = val.predictedEndTranslation.width - (dragOffset?.width ?? 0) + drag = .zero + xPos += val.translation.width - (dragOffset?.width ?? 0) + dragOffset = nil + let newActiveIndex = min(imagesArr.count - 1, max(0, activeIndex + (predictedEnd < -(UIScreen.screenWidth / 2) ? 1 : predictedEnd > UIScreen.screenWidth / 2 ? -1 : 0))) + let finalXPos = -(CGFloat(newActiveIndex) * (UIScreen.screenWidth + (SPACING))) + let distance = abs(finalXPos - xPos) + activeIndex = newActiveIndex + var initialVel = abs(predictedEnd / distance) + initialVel = initialVel < 3.75 ? 0 : initialVel * 2 + withAnimation(.interpolatingSpring(stiffness: 150, damping: 17, initialVelocity: initialVel)) { + xPos = finalXPos + dragAxis = nil + } + } else { + let shouldClose = abs(val.translation.width) > 100 || abs(val.translation.height) > 100 + + if shouldClose { + withAnimation(.easeOut) { + appearBlack = false + } + } + withAnimation(.interpolatingSpring(stiffness: 200, damping: 20, initialVelocity: 0)) { + drag = .zero + dragAxis = nil + if shouldClose { + dismiss() + } + } + } + } + ) + .overlay(LightBoxOverlay(post: post, opacity: !showOverlay || isPinching ? 0 : interpolate([1, 0], false), imagesArr: imagesArr, activeIndex: activeIndex, loading: $loading, done: $done)) + .background( + !appearBlack + ? nil + : Color.black + .opacity(interpolate([1, 0], false)) + .onTapGesture { + withAnimation(.easeOut) { + appearBlack = false + } + } + .allowsHitTesting(appearBlack) + .transition(.opacity) + ) + .overlay( + !loading && !done + ? nil + : ZStack { + if done { + Image(systemName: "checkmark.circle.fill") + .fontSize(40) + .transition(.scaleAndBlur) + } else { + ProgressView() + .transition(.scaleAndBlur) + } } - } else { - let shouldClose = abs(val.translation.width) > 100 || abs(val.translation.height) > 100 - - if shouldClose { - withAnimation(.easeOut) { - appearBlack = false - } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.black.opacity(0.25)) + ) + .ignoresSafeArea(edges: .all) + .compositingGroup() + .opacity(appearContent ? 1 : 0) + .onChange(of: done) { val in + if val { + doThisAfter(0.5) { + withAnimation(spring) { + done = false + loading = false + } + } } - withAnimation(.interpolatingSpring(stiffness: 200, damping: 20, initialVelocity: 0)) { - drag = .zero - dragAxis = nil - if shouldClose { - dismiss() - } - } - } } - ) - .overlay(LightBoxOverlay(post: post, opacity: !showOverlay || isPinching ? 0 : interpolate([1, 0], false), imagesArr: imagesArr, activeIndex: activeIndex, loading: $loading, done: $done)) - .background( - !appearBlack - ? nil - : Color.black - .opacity(interpolate([1, 0], false)) - .onTapGesture { - withAnimation(.easeOut) { - appearBlack = false - } + .onAppear { + if lightboxViewsPost { Task(priority: .background) { await post.toggleSeen(true) } } + xPos = -CGFloat(i) * (UIScreen.screenWidth + SPACING) + activeIndex = i + doThisAfter(0) { + withAnimation(.easeOut) { + appearContent = true + appearBlack = true + } + } } - .allowsHitTesting(appearBlack) .transition(.opacity) - ) - .overlay( - !loading && !done - ? nil - : ZStack { - if done { - Image(systemName: "checkmark.circle.fill") - .fontSize(40) - .transition(.scaleAndBlur) - } else { - ProgressView() - .transition(.scaleAndBlur) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.black.opacity(0.25)) - ) - .ignoresSafeArea(edges: .all) - .compositingGroup() - .opacity(appearContent ? 1 : 0) - .onChange(of: done) { val in - if val { - doThisAfter(0.5) { - withAnimation(spring) { - done = false - loading = false - } - } - } - } - .onAppear { - if lightboxViewsPost { Task(priority: .background) { await post.toggleSeen(true) } } - xPos = -CGFloat(i) * (UIScreen.screenWidth + SPACING) - activeIndex = i - doThisAfter(0) { - withAnimation(.easeOut) { - appearContent = true - appearBlack = true - } - } } - .transition(.opacity) - } } diff --git a/winston/components/LightBoxImage/LightBoxOverlay.swift b/winston/components/LightBoxImage/LightBoxOverlay.swift index f5692288..fd572a12 100644 --- a/winston/components/LightBoxImage/LightBoxOverlay.swift +++ b/winston/components/LightBoxImage/LightBoxOverlay.swift @@ -14,91 +14,119 @@ struct LightBoxOverlay: View { var imagesArr: [MediaExtracted] var activeIndex: Int @Binding var loading: Bool + @Environment(\.dismiss) var dismiss @Binding var done: Bool @Environment(\.useTheme) private var selectedTheme + @EnvironmentObject private var routerProxy: RouterProxy + + @State private var isPresentingShareSheet = false + @State private var sharedImageData: Data? + var body: some View { - VStack(alignment: .leading) { - VStack(alignment: .leading, spacing: 8) { - if let title = post.data?.title { - Text(title) - .fontSize(20, .semibold) - .allowsHitTesting(false) - } - if let data = post.data, let fullname = data.author_fullname { - Badge(post: post, theme: selectedTheme.postLinks.theme.badge) - .equatable() - .id("post-badge") - .listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 8, trailing: 8)) - } + VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 8) { + if let title = post.data?.title { + Text(title) + .fontSize(20, .semibold) + .allowsHitTesting(false) } - Spacer() + Badge(post: post,usernameColor: .primary, theme: selectedTheme.postLinks.theme.badge) + .equatable() + .foregroundColor(.primary) + .id("post-badge") + .listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 8, trailing: 8)) + + } + + Spacer() + + if imagesArr.count > 1 { + Text("\(activeIndex + 1)/\(imagesArr.count)") + .fontSize(16, .semibold) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Capsule(style: .continuous).fill(.regularMaterial)) + .frame(maxWidth: .infinity) + .allowsHitTesting(false) + } + + HStack(spacing: 12) { - if imagesArr.count > 1 { - Text("\(activeIndex + 1)/\(imagesArr.count)") - .fontSize(16, .semibold) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Capsule(style: .continuous).fill(.regularMaterial)) - .frame(maxWidth: .infinity) - .allowsHitTesting(false) + LightBoxButton(icon: "bubble.right") { + if let data = post.data { + routerProxy.router.path.append(PostViewPayload(post: Post(id: post.id, api: post.redditAPI), sub: Subreddit(id: data.subreddit, api: post.redditAPI))) + dismiss() + } } - HStack(spacing: 12) { - LightBoxButton(icon: "square.and.arrow.down.fill") { + + LightBoxButton(icon: "square.and.arrow.down") { + withAnimation(spring) { + loading = true + } + saveMedia(imagesArr[activeIndex].url.absoluteString, .image) { result in withAnimation(spring) { - loading = true - } - saveMedia(imagesArr[activeIndex].url.absoluteString, .image) { result in - withAnimation(spring) { - done = true - } + done = true } } - ShareLink(item: imagesArr[activeIndex].url.absoluteString) { - LightBoxButton(icon: "square.and.arrow.up.fill") {} - .allowsHitTesting(false) - .contentShape(Circle()) + } + LightBoxButton(icon: "square.and.arrow.up"){ + Task{ + sharedImageData = try await downloadAndSaveImage(url: imagesArr[activeIndex].url) } + isPresentingShareSheet.toggle() } - .compositingGroup() - .frame(maxWidth: .infinity) - } - .multilineTextAlignment(.leading) - .foregroundColor(.white) - .padding(.horizontal, 12) - .padding(.bottom, 32) - .padding(.top, 64) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background( - VStack(spacing: 0) { - Rectangle() - .fill(LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.black.opacity(1), location: 0), - .init(color: Color.black.opacity(0), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - )) - .frame(height: 150) - Spacer() - Rectangle() - .fill(LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.black.opacity(1), location: 0), - .init(color: Color.black.opacity(0), location: 1) - ]), - startPoint: .bottom, - endPoint: .top - )) - .frame(height: 150) + .contentShape(Circle()) + .sheet(isPresented: $isPresentingShareSheet) { + if let sharedImageData = sharedImageData, let uiimg = UIImage(data: sharedImageData){ + let image = ShareImage(placeholderItem: uiimg) + ShareSheet(items: [image]) + .onAppear{ + print("Share sheet") + print(image) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .allowsHitTesting(false) - ) - .compositingGroup() - .opacity(opacity) - .allowsHitTesting(opacity != 0) + } + } + .compositingGroup() + .frame(maxWidth: .infinity) } + .multilineTextAlignment(.leading) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.bottom, 32) + .padding(.top, 64) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background( + VStack(spacing: 0) { + Rectangle() + .fill(LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.black.opacity(1), location: 0), + .init(color: Color.black.opacity(0), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + )) + .frame(height: 150) + Spacer() + Rectangle() + .fill(LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.black.opacity(1), location: 0), + .init(color: Color.black.opacity(0), location: 1) + ]), + startPoint: .bottom, + endPoint: .top + )) + .frame(height: 150) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + ) + .compositingGroup() + .opacity(opacity) + .allowsHitTesting(opacity != 0) + } } diff --git a/winston/components/LightBoxImage/ZoomableImageView.swift b/winston/components/LightBoxImage/ZoomableImageView.swift new file mode 100644 index 00000000..af19aeb0 --- /dev/null +++ b/winston/components/LightBoxImage/ZoomableImageView.swift @@ -0,0 +1,121 @@ +// +// ZoomableImageView.swift +// winston +// +// Created by Daniel Inama on 27/08/23. +// + +import Foundation +import UIKit +import SwiftUI + +//Source https://stackoverflow.com/a/64110231 +struct ZoomableScrollView: UIViewRepresentable { + private var content: Content + private var onTap: (()->())? + @Binding var isZoomed: Bool // Add the binding parameter + + init(onTap: (()->())? = nil,isZoomed: Binding, @ViewBuilder content: () -> Content) { + self.content = content() + //set up helper class + self.onTap = onTap + self._isZoomed = isZoomed // Initialize the binding + } + + func makeUIView(context: Context) -> UIScrollView { + // set up the UIScrollView + let scrollView = UIScrollView() + scrollView.delegate = context.coordinator // for viewForZooming(in:) + scrollView.maximumZoomScale = 20 + scrollView.minimumZoomScale = 1 + scrollView.bouncesZoom = true + scrollView.bounces = true + scrollView.showsVerticalScrollIndicator = false + scrollView.showsHorizontalScrollIndicator = false + + // create a UIHostingController to hold our SwiftUI content + let hostedView = context.coordinator.hostingController.view! + hostedView.translatesAutoresizingMaskIntoConstraints = true + hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + hostedView.frame = scrollView.bounds + scrollView.addSubview(hostedView) + + + let doubleTapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleDoubleTap(_:))) + doubleTapGesture.numberOfTapsRequired = 2 + scrollView.addGestureRecognizer(doubleTapGesture) + + let tapGesture = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:))) + tapGesture.numberOfTapsRequired = 1 + tapGesture.require(toFail: doubleTapGesture) + scrollView.addGestureRecognizer(tapGesture) + + return scrollView + } + + + func updateUIView(_ uiView: UIScrollView, context: Context) { + // update the hosting controller's SwiftUI content + context.coordinator.hostingController.rootView = self.content + assert(context.coordinator.hostingController.view.superview == uiView) + } + + func makeCoordinator() -> Coordinator { + return Coordinator(hostingController: UIHostingController(rootView: self.content), onTap: self.onTap, isZoomed: $isZoomed) + } + + // MARK: - Coordinator + + class Coordinator: NSObject, UIScrollViewDelegate { + var hostingController: UIHostingController + var onTap: (() -> ())? + @Binding var isZoomed: Bool // Use the binding here + + + init(hostingController: UIHostingController, onTap: (() -> ())? = nil, isZoomed: Binding) { + self.hostingController = hostingController + self.onTap = onTap + _isZoomed = isZoomed + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return hostingController.view + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + isZoomed = scrollView.zoomScale > scrollView.minimumZoomScale + } + + @objc func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { + onTap?() + } + + @objc func handleDoubleTap(_ gestureRecognizer: UITapGestureRecognizer) { + if let scrollView = gestureRecognizer.view as? UIScrollView { + if scrollView.zoomScale == 1 { + let tapLocation = gestureRecognizer.location(in: scrollView ) + //CGPoint(x: tapLocation.x / 1.35, y: tapLocation.y * 0.87) -- Magic numbers go brrrr + let zoomRect = CGRect(origin: CGPoint(x: tapLocation.x / 1.35, y: tapLocation.y * 0.87), size: CGSize(width: 100, height: 100)) + scrollView.zoom(to: zoomRect, animated: true) + } else { + scrollView.setZoomScale(1, animated: true) + } + } + } + +// func scrollViewDidScroll(_ scrollView: UIScrollView) { +// let offsetY = scrollView.contentOffset.y +// let contentHeight = scrollView.contentSize.height +// let scrollViewHeight = scrollView.bounds.height +// print() +// print(offsetY) +// +// if offsetY < (contentHeight / 2) || offsetY > (contentHeight / 2) { +// scrollView.setContentOffset(CGPoint(x: 0, y: contentHeight - scrollViewHeight), animated: false) +// } +// } + } + +} + + diff --git a/winston/components/Links/CommentLink/CommentLink.swift b/winston/components/Links/CommentLink/CommentLink.swift index 45dfa413..0d56d266 100644 --- a/winston/components/Links/CommentLink/CommentLink.swift +++ b/winston/components/Links/CommentLink/CommentLink.swift @@ -77,6 +77,7 @@ struct CommentLink: View, Equatable { var avatarsURL: [String:String]? = nil var postFullname: String? var showReplies = true + var seenComments: String? var parentElement: CommentParentElement? = nil @ObservedObject var comment: Comment @@ -93,10 +94,11 @@ struct CommentLink: View, Equatable { CommentLinkFull(post: post, subreddit: subreddit, arrowKinds: arrowKinds, comment: comment, indentLines: indentLines) } } else { - CommentLinkMore(arrowKinds: arrowKinds, comment: comment, postFullname: postFullname, parentElement: parentElement, indentLines: indentLines) + CommentLinkMore(arrowKinds: arrowKinds, comment: comment, post: post, postFullname: postFullname, parentElement: parentElement, indentLines: indentLines) } } else { - CommentLinkContent(highlightID: highlightID, showReplies: showReplies, arrowKinds: arrowKinds, indentLines: indentLines, lineLimit: lineLimit, post: post, comment: comment, avatarsURL: avatarsURL) + CommentLinkContent(highlightID: highlightID, seenComments: seenComments, showReplies: showReplies, arrowKinds: arrowKinds, indentLines: indentLines, lineLimit: lineLimit, post: post, comment: comment, avatarsURL: avatarsURL) + } } @@ -104,7 +106,7 @@ struct CommentLink: View, Equatable { ForEach(Array(comment.childrenWinston.data.enumerated()), id: \.element.id) { index, commentChild in let childrenCount = comment.childrenWinston.data.count if let _ = commentChild.data { - CommentLink(post: post, arrowKinds: arrowKinds.map { $0.child } + [(childrenCount - 1 == index ? ArrowKind.curve : ArrowKind.straightCurve)], postFullname: postFullname, parentElement: .comment(comment), comment: commentChild) + CommentLink(post: post, arrowKinds: arrowKinds.map { $0.child } + [(childrenCount - 1 == index ? ArrowKind.curve : ArrowKind.straightCurve)], postFullname: postFullname, seenComments: seenComments, parentElement: .comment(comment), comment: commentChild) // .equatable() } } diff --git a/winston/components/Links/CommentLink/CommentLinkContent.swift b/winston/components/Links/CommentLink/CommentLinkContent.swift index 15399a0b..bbaa5919 100644 --- a/winston/components/Links/CommentLink/CommentLinkContent.swift +++ b/winston/components/Links/CommentLink/CommentLinkContent.swift @@ -39,6 +39,7 @@ class MyDefaults { struct CommentLinkContent: View { var highlightID: String? // @Default(.commentSwipeActions) private var commentSwipeActions + var seenComments: String? var forcedBodySize: CGSize? var showReplies = true var arrowKinds: [ArrowKind] @@ -65,10 +66,11 @@ struct CommentLinkContent: View { @State var commentViewLoaded = false - var body: some View { + var body: some View { let theme = selectedTheme.comments let selectable = (comment.data?.winstonSelecting ?? false) let horPad = theme.theme.innerPadding.horizontal + if let data = comment.data { let collapsed = data.collapsed ?? false Group { @@ -86,9 +88,8 @@ struct CommentLinkContent: View { } } HStack(spacing: 8) { - if let author = data.author { - BadgeComment(comment: comment, usernameColor: (post?.data?.author ?? "") == author ? Color.green : nil, avatarURL: avatarsURL?[data.author_fullname!], theme: theme.theme.badge) - } + let unseen = seenComments != nil && !seenComments!.contains(data.id) + BadgeComment(comment: comment, unseen: unseen, usernameColor: (post?.data?.author ?? "") == data.author ? Color.green : nil, avatarURL: avatarsURL?[data.author_fullname!], theme: theme.theme.badge, commentTheme: theme.theme) Spacer() @@ -118,17 +119,25 @@ struct CommentLinkContent: View { HStack(alignment: .center, spacing: 4) { Image(systemName: "arrow.up") .foregroundColor(data.likes != nil && data.likes! ? .orange : .gray) - + .contentShape(Rectangle()) + .onTapGesture { + Task { _ = await comment.vote(action: .up) } + } + let downup = Int(ups) Text(formatBigNumber(downup)) .foregroundColor(data.likes != nil ? (data.likes! ? .orange : .blue) : .gray) .contentTransition(.numericText()) - + // .foregroundColor(downup == 0 ? .gray : downup > 0 ? .orange : .blue) .fontSize(14, .semibold) Image(systemName: "arrow.down") .foregroundColor(data.likes != nil && !data.likes! ? .blue : .gray) + .contentShape(Rectangle()) + .onTapGesture { + Task { _ = await comment.vote(action: .down) } + } } .fontSize(14, .medium) .padding(.horizontal, 6) @@ -166,7 +175,7 @@ struct CommentLinkContent: View { cell.layer.masksToBounds = false } .padding(.horizontal, horPad) - .frame(height: max((theme.theme.badge.authorText.size + theme.theme.badge.statsText.size + 2), theme.theme.badge.avatar.size) + (data.depth != 0 ? theme.theme.innerPadding.vertical + theme.theme.repliesSpacing : 0), alignment: .leading) + .frame(height: max((max(theme.theme.badge.authorText.size, theme.theme.badge.flairText.size + 4) + theme.theme.badge.statsText.size + 2), theme.theme.badge.avatar.size) + (data.depth != 0 ? theme.theme.innerPadding.vertical + theme.theme.repliesSpacing : 0), alignment: .leading) .mask(Color.black) .background(Color.accentColor.opacity(highlight ? 0.2 : 0)) .background(showReplies ? theme.theme.bg.cs(cs).color() : .clear) @@ -216,6 +225,7 @@ struct CommentLinkContent: View { .lineLimit(lineLimit) } else { MD(data.winstonBodyAttrEncoded == nil ? .str(body) : .json(data.winstonBodyAttrEncoded!), fontSize: theme.theme.bodyText.size) + .lineSpacing(theme.theme.linespacing) .fixedSize(horizontal: false, vertical: true) .overlay( !selectable diff --git a/winston/components/Links/CommentLink/CommentLinkMore.swift b/winston/components/Links/CommentLink/CommentLinkMore.swift index 7119190c..6246b08c 100644 --- a/winston/components/Links/CommentLink/CommentLinkMore.swift +++ b/winston/components/Links/CommentLink/CommentLinkMore.swift @@ -11,6 +11,7 @@ import Defaults struct CommentLinkMore: View { var arrowKinds: [ArrowKind] var comment: Comment + var post: Post? var postFullname: String? var parentElement: CommentParentElement? var indentLines: Int? @@ -31,7 +32,8 @@ struct CommentLinkMore: View { ForEach(shapes, id: \.self) { i in if arrowKinds.indices.contains(i - 1) { let actualArrowKind = arrowKinds[i - 1] - Arrows(kind: actualArrowKind) + Arrows(kind: actualArrowKind, offset: selectedTheme.comments.theme.loadMoreOuterTopPadding) + } } } @@ -56,14 +58,15 @@ struct CommentLinkMore: View { } } } - .padding(.vertical, 8) - .padding(.horizontal, 12) + .padding(.vertical, selectedTheme.comments.theme.loadMoreInnerPadding.vertical) + .padding(.horizontal, selectedTheme.comments.theme.loadMoreInnerPadding.horizontal) .opacity(loadMoreLoading ? 0.5 : 1) - .background(Capsule(style: .continuous).fill(curveColor)) - .padding(.vertical, 4) + .background(Capsule(style: .continuous).fill(selectedTheme.comments.theme.loadMoreBackground.cs(cs).color())) .compositingGroup() - .fontSize(selectedTheme.comments.theme.bodyText.size, .medium) - .foregroundColor(.accentColor) + .fontSize(selectedTheme.comments.theme.loadMoreText.size, selectedTheme.comments.theme.loadMoreText.weight.t) + .foregroundColor(selectedTheme.comments.theme.loadMoreText.color.cs(cs).color()) + .padding(.top, selectedTheme.comments.theme.loadMoreOuterTopPadding) + .padding(.bottom, 2) } .padding(.horizontal, horPad) .frame(maxWidth: .infinity, alignment: .leading) @@ -75,7 +78,7 @@ struct CommentLinkMore: View { loadMoreLoading = true } Task(priority: .background) { - await comment.loadChildren(parent: parentElement, postFullname: postFullname, avatarSize: selectedTheme.comments.theme.badge.avatar.size) + await comment.loadChildren(parent: parentElement, postFullname: postFullname, avatarSize: selectedTheme.comments.theme.badge.avatar.size, post: post) await MainActor.run { doThisAfter(0.5) { withAnimation(spring) { diff --git a/winston/components/Links/PostLink/PostLink.swift b/winston/components/Links/PostLink/PostLink.swift index 0be13e50..085019f8 100644 --- a/winston/components/Links/PostLink/PostLink.swift +++ b/winston/components/Links/PostLink/PostLink.swift @@ -80,7 +80,7 @@ struct PostLinkView: View, Equatable { .compositingGroup() .contentShape(Rectangle()) .swipyUI(onTap: openPost, actionsSet: postSwipeActions, entity: post) - .opacity(fadeReadPosts && seen ? 0.6 : 1) + .opacity(fadeReadPosts && seen ? theme.theme.unseenFadeOpacity : 1) .contextMenu(menuItems: { PostLinkContext(post: post) }, preview: { PostLinkContextPreview(post: post, sub: sub, routerProxy: routerProxy).id("\(post.id)-preview-navigation-stack") }) .foregroundColor(.primary) .multilineTextAlignment(.leading) diff --git a/winston/components/Links/PostLink/PostLinkNormal.swift b/winston/components/Links/PostLink/PostLinkNormal.swift index 73807793..57b336ad 100644 --- a/winston/components/Links/PostLink/PostLinkNormal.swift +++ b/winston/components/Links/PostLink/PostLinkNormal.swift @@ -53,6 +53,7 @@ struct PostLinkNormal: View { if !data.selftext.isEmpty && showSelfText { PostLinkNormalSelftext(selftext: data.selftext, theme: theme.theme.bodyText, cs: cs) + .lineSpacing(theme.theme.linespacing) // .background(GeometryReader { geo in Color.clear.onAppear { print(data.title, "body", post.winstonData?.postDimensions?.bodySize?.height, geo.size.height) } }) } diff --git a/winston/components/Links/PostLink/getPostContentWidth.swift b/winston/components/Links/PostLink/getPostContentWidth.swift index 427450fa..8ccb7839 100644 --- a/winston/components/Links/PostLink/getPostContentWidth.swift +++ b/winston/components/Links/PostLink/getPostContentWidth.swift @@ -68,7 +68,7 @@ func getPostDimensions(post: Post, columnWidth: Double = UIScreen.screenWidth, s let extractedMedia = post.winstonData?.extractedMedia let compactImgSize = scaledCompactModeThumbSize() let theme = selectedTheme.postLinks.theme - let postGeneralSpacing = theme.verticalElementsSpacing + let postGeneralSpacing = theme.verticalElementsSpacing + theme.linespacing let title = data.title let body = data.selftext diff --git a/winston/components/Links/SubredditLink.swift b/winston/components/Links/SubredditLink.swift index 172d61e4..f5e80291 100644 --- a/winston/components/Links/SubredditLink.swift +++ b/winston/components/Links/SubredditLink.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Defaults struct SubredditLinkContainer: View { var noHPad = false @@ -20,30 +21,40 @@ struct SubredditLink: View { var sub: Subreddit @State var opened = false @EnvironmentObject private var routerProxy: RouterProxy - var body: some View { - if let data = sub.data { - HStack(spacing: 12) { - SubredditIcon(data: data, size: 64) - - VStack(alignment: .leading) { + var body: some View { + if var data = sub.data { + @State var isSubbed = data.user_is_subscriber ?? false + HStack(spacing: 12) { + SubredditIcon(data: data, size: 64) + .nsfw(Defaults[.blurPostLinkNSFW] ? data.over18 ?? false : false, smallIcon: true) + + VStack(alignment: .leading) { + HStack{ Text("r/\(data.display_name ?? "?")") .fontSize(18, .semibold) - Text("\(formatBigNumber(data.subscribers ?? 0)) subscribers") - .fontSize(14).opacity(0.5) - Text((data.public_description).md()).lineLimit(2) - .fontSize(15).opacity(0.75) + Spacer() + SubscribeButton(subreddit: sub, isSmall: true) + .frame(height: 24) // Adjust the height as needed + } + Text("\(formatBigNumber(data.subscribers ?? 0)) subscribers") + .fontSize(14).opacity(0.5) + Text((data.public_description).md()).lineLimit(2) + .fontSize(15).opacity(0.75) } - .padding(.horizontal, noHPad ? 0 : 16) - .padding(.vertical, 14) - .frame(maxWidth: .infinity, alignment: .leading) - .themedListRowBG(disableBG: noHPad) - .mask(RR(20, .black)) - .onTapGesture { - routerProxy.router.path.append(SubViewType.posts(sub)) - } + + + } + .padding(.horizontal, noHPad ? 0 : 16) + .padding(.vertical, 14) + .frame(maxWidth: .infinity, alignment: .leading) + .themedListRowBG(disableBG: noHPad) + .mask(RR(20, .black)) + .onTapGesture { + routerProxy.router.path.append(SubViewType.posts(sub)) } } + } } //struct SubredditLink_Previews: PreviewProvider { diff --git a/winston/components/LiveTextInteraction.swift b/winston/components/LiveTextInteraction.swift new file mode 100644 index 00000000..7129d04f --- /dev/null +++ b/winston/components/LiveTextInteraction.swift @@ -0,0 +1,94 @@ +// +// LiveTextInteraction.swift +// winston +// +// Created by Daniel Inama on 25/08/23. +// + +import UIKit +import SwiftUI +import VisionKit + +@MainActor +struct LiveTextInteraction: UIViewRepresentable { + var image: Image + let imageView = LiveTextImageView() + let analyzer = ImageAnalyzer() + let interaction = ImageAnalysisInteraction() + + + func makeUIView(context: Context) -> some UIView { + guard let image = ImageRenderer(content: image).uiImage else { + imageView.image = UIImage(named: "emptyThumb") + return imageView + } + imageView.image = image + imageView.addInteraction(interaction) + imageView.contentMode = .scaleAspectFit + return imageView + } + + func updateUIView(_ uiView: UIViewType, context: Context) { + Task { + let configuration = ImageAnalyzer.Configuration([.text, .machineReadableCode, .visualLookUp]) + do { + if let image = imageView.image { + let analysis = try? await analyzer.analyze(image, configuration: configuration) + if let analysis { + interaction.preferredInteractionTypes = .automatic + interaction.isSupplementaryInterfaceHidden = false + interaction.analysis = analysis; + } + } + + } + + } + } +} + +@MainActor +struct ZoomableLiveTextInteraction: UIViewRepresentable { + var image: Image + let imageView = LiveTextImageView() + let analyzer = ImageAnalyzer() + let interaction = ImageAnalysisInteraction() + + + func makeUIView(context: Context) -> some UIView { + guard let image = ImageRenderer(content: image).uiImage else { + imageView.image = UIImage(named: "emptyThumb") + return imageView + } + imageView.image = image + imageView.addInteraction(interaction) + return imageView + } + + func updateUIView(_ uiView: UIViewType, context: Context) { + Task { + let configuration = ImageAnalyzer.Configuration([.text, .machineReadableCode, .visualLookUp]) + do { + if let image = imageView.image { + let analysis = try? await analyzer.analyze(image, configuration: configuration) + if let analysis { + interaction.preferredInteractionTypes = .automatic + interaction.analysis = analysis; + } + } + + } + + } + } +} + + +class LiveTextImageView: UIImageView { + // Use intrinsicContentSize to change the default image size + // so that we can change the size in our SwiftUI View + override var intrinsicContentSize: CGSize { + CGSize(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height) + } + +} diff --git a/winston/components/Markdownosaur.swift b/winston/components/Markdownosaur.swift index 190f8f0c..3c012771 100644 --- a/winston/components/Markdownosaur.swift +++ b/winston/components/Markdownosaur.swift @@ -285,7 +285,6 @@ public struct Markdownosaur: MarkupVisitor { quoteAttributes[.listDepth] = blockQuote.quoteDepth let quoteAttributedString = visit(child).mutableCopy() as! NSMutableAttributedString - quoteAttributedString.insert(NSAttributedString(string: "\t", attributes: quoteAttributes), at: 0) quoteAttributedString.addAttribute(.foregroundColor, value: UIColor.systemGray) diff --git a/winston/components/Media/ImageMediaPost/ImageMediaPost.swift b/winston/components/Media/ImageMediaPost/ImageMediaPost.swift index bb7a7d0c..851a4b1f 100644 --- a/winston/components/Media/ImageMediaPost/ImageMediaPost.swift +++ b/winston/components/Media/ImageMediaPost/ImageMediaPost.swift @@ -17,7 +17,7 @@ struct ImageMediaPost: View, Equatable { } var compact = false - var post: Post + @ObservedObject var post: Post var images: [MediaExtracted] var contentWidth: CGFloat @State var fullscreen = false @@ -89,7 +89,7 @@ struct ImageMediaPost: View, Equatable { } .frame(maxWidth: compact ? nil : .infinity) .fullScreenCover(item: $fullscreenIndex) { i in - LightBoxImage(post: post, i: i, imagesArr: images) + LightBoxImage(post: post, i: i, imagesArr: images, doLiveText: Defaults[.doLiveText]) } } } diff --git a/winston/components/Media/MediaPresenter.swift b/winston/components/Media/MediaPresenter.swift index dad29a75..8d07ae54 100644 --- a/winston/components/Media/MediaPresenter.swift +++ b/winston/components/Media/MediaPresenter.swift @@ -17,7 +17,7 @@ struct OnlyURL: View { var body: some View { HStack { Image(systemName: "link") - Text(url.absoluteString.replacingOccurrences(of: "https://", with: "")) + Text(cleanURL(url: url, showPath: false)) } .padding(.horizontal, 6) .padding(.vertical, 2) diff --git a/winston/components/Media/PreviewLink/PreviewLinkContent.swift b/winston/components/Media/PreviewLink/PreviewLinkContent.swift index 910c5f32..c7de3983 100644 --- a/winston/components/Media/PreviewLink/PreviewLinkContent.swift +++ b/winston/components/Media/PreviewLink/PreviewLinkContent.swift @@ -58,7 +58,7 @@ struct PreviewLinkContentRaw: View, Equatable { .truncationMode(.tail) .fixedSize(horizontal: false, vertical: true) - Text(url.absoluteString) + Text(cleanURL(url: url)) .fontSize(13) .opacity(0.5) .lineLimit(1) diff --git a/winston/components/Media/VideoPlayerPost.swift b/winston/components/Media/VideoPlayerPost.swift index 497ff3e7..0501a355 100644 --- a/winston/components/Media/VideoPlayerPost.swift +++ b/winston/components/Media/VideoPlayerPost.swift @@ -68,7 +68,7 @@ class SharedVideo: ObservableObject { } struct VideoPlayerPost: View { - var post: Post + @ObservedObject var post: Post var compact = false var overrideWidth: CGFloat? var url: URL diff --git a/winston/components/Media/YTMediaPost.swift b/winston/components/Media/YTMediaPost.swift index b274594f..360a865f 100644 --- a/winston/components/Media/YTMediaPost.swift +++ b/winston/components/Media/YTMediaPost.swift @@ -53,7 +53,7 @@ struct YTMediaPostPlayer: View, Equatable { Group { if !showPlayer { ZStack { - URLImage(url: ytMediaExtracted.thumbnailURL, imgRequest: ytMediaExtracted.thumbnailRequest) + URLImage(url: ytMediaExtracted.thumbnailURL, doLiveText: false, imgRequest: ytMediaExtracted.thumbnailRequest) .scaledToFill() Image(systemName: "play.circle.fill").fontSize(compact ? 22 : 32) } diff --git a/winston/components/Media/mediaExtractor.swift b/winston/components/Media/mediaExtractor.swift index 36d0f587..4a136d88 100644 --- a/winston/components/Media/mediaExtractor.swift +++ b/winston/components/Media/mediaExtractor.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI import NukeUI import YouTubePlayerKit +import Alamofire struct MediaExtracted: Hashable, Equatable, Identifiable { let url: URL @@ -45,7 +46,7 @@ enum MediaExtractedType: Hashable, Equatable { } // ORDER MATTERS! -func mediaExtractor(contentWidth: Double = UIScreen.screenWidth, _ data: PostData) -> MediaExtractedType? { +func mediaExtractor(contentWidth: Double = UIScreen.screenWidth, _ data: PostData) async -> MediaExtractedType? { guard !data.is_self else { return nil } if let is_gallery = data.is_gallery, is_gallery, let galleryData = data.gallery_data?.items, let metadata = data.media_metadata { @@ -100,6 +101,24 @@ func mediaExtractor(contentWidth: Double = UIScreen.screenWidth, _ data: PostDat return .video(MediaExtracted(url: url, size: CGSize(width: 0, height: 0))) } + if data.url.contains("streamable") { + let url = data.url; + let shortCode = url[url.index(url.lastIndex(of: "/") ?? url.startIndex, offsetBy: 1)...] + + let response = await AF.request( + "https://api.streamable.com/videos/\(shortCode)" + ).serializingDecodable(StreamableAPIResponse.self).response + + switch response.result { + case .success(let data): + if let mp4 = data.files?.mp4Mobile ?? data.files?.mp4 { + return .video(MediaExtracted(url: URL(string: mp4.url)!, size: CGSize(width: mp4.width, height: mp4.height))) + } + case .failure: + return nil + } + } + let actualURL = data.url.hasPrefix("/r/") || data.url.hasPrefix("/u/") ? "https://reddit.com\(data.url)" : data.url guard let urlComponents = URLComponents(string: actualURL) else { return nil @@ -146,3 +165,25 @@ private func extractYoutubeIdFromOEmbed(_ text: String) -> String? { String(text[Range($0.range, in: text)!]) } } + +struct StreamableAPIParams: Codable {} + +struct StreamableAPIResponse: Codable { + let files: StreamableAPIFiles? +} + +struct StreamableAPIFiles: Codable { + let mp4 : StreamableAPIFile? + let mp4Mobile: StreamableAPIFile? + + enum CodingKeys : String, CodingKey { + case mp4 = "mp4" + case mp4Mobile = "mp4-mobile" + } +} + +struct StreamableAPIFile: Codable { + let url: String + let width: Int + let height: Int +} diff --git a/winston/components/Modals/ReplyModal.swift b/winston/components/Modals/ReplyModal.swift index dd1881c7..a5b6401c 100644 --- a/winston/components/Modals/ReplyModal.swift +++ b/winston/components/Modals/ReplyModal.swift @@ -135,7 +135,7 @@ struct ReplyModal: View { VStack(alignment: .leading) { if let me = redditAPI.me?.data { - BadgeView(author: me.name, fullname: "t2_\(me.id)", created: Date().timeIntervalSince1970, avatarURL: me.icon_img ?? me.snoovatar_img, theme: selectedTheme.comments.theme.badge, routerProxy: routerProxy, cs: cs) + BadgeView(id: "replies", author: me.name, fullname: "t2_\(me.id)", created: Date().timeIntervalSince1970, avatarURL: me.icon_img ?? me.snoovatar_img, theme: selectedTheme.comments.theme.badge, routerProxy: routerProxy, cs: cs) .equatable() } MDEditor(text: $textWrapper.replyText) diff --git a/winston/components/ScrolViewContentRTLFriendly.swift b/winston/components/ScrolViewContentRTLFriendly.swift new file mode 100644 index 00000000..2b817d0b --- /dev/null +++ b/winston/components/ScrolViewContentRTLFriendly.swift @@ -0,0 +1,43 @@ +// +// ScrolViewContentRTLFriendly.swift +// winston +// +// Created by Daniel Inama on 05/10/23. +// + +import SwiftUI + +private struct ScrollViewContentRTLFriendly: ViewModifier { + @Environment(\.layoutDirection) private var layoutDirection + + func body(content: Content) -> some View { + content + .rotation3DEffect(Angle(degrees: layoutDirection == .rightToLeft ? -180 : 0), axis: ( + x: .zero, + y: CGFloat(layoutDirection == .rightToLeft ? -10 : 0), + z: .zero + )) + } +} + +private struct ScrollViewRTLFriendly: ViewModifier { + @Environment(\.layoutDirection) private var layoutDirection + + func body(content: Content) -> some View { + content + .rotation3DEffect(Angle(degrees: layoutDirection == .rightToLeft ? 180 : 0), axis: ( + x: .zero, + y: CGFloat(layoutDirection == .rightToLeft ? 10 : 0), + z: .zero + )) + } +} + +public extension View { + func scrollViewContentRTLFriendly() -> some View { + modifier(ScrollViewContentRTLFriendly()) + } + func scrollViewRTLFriendly() -> some View { + modifier(ScrollViewRTLFriendly()) + } +} diff --git a/winston/components/ShareSheet.swift b/winston/components/ShareSheet.swift new file mode 100644 index 00000000..bba0176c --- /dev/null +++ b/winston/components/ShareSheet.swift @@ -0,0 +1,61 @@ +// +// ShareSheet.swift +// winston +// +// Created by Daniel Inama on 31/08/23. +// + +import SwiftUI +import LinkPresentation + +struct ShareSheet: UIViewControllerRepresentable { + var items: [Any] + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: items, applicationActivities: nil) + controller.title = "Test" + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { + + } + +} + +/// Image that you can share using a Share Sheet +/// This will also display all the metadata correctly +class ShareImage: UIActivityItemProvider { + var image: UIImage + + override var item: Any { + get { + return self.image + } + } + + override init(placeholderItem: Any) { + guard let image = placeholderItem as? UIImage else { + fatalError("Couldn't create image from provided item") + } + + self.image = image + super.init(placeholderItem: placeholderItem) + } + + @available(iOS 13.0, *) + override func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? { + + let metadata = LPLinkMetadata() + metadata.title = "Image" + + var thumbnail: NSSecureCoding = NSNull() + if let imageData = self.image.pngData() { + thumbnail = NSData(data: imageData) + } + + metadata.imageProvider = NSItemProvider(item: thumbnail, typeIdentifier: "public.png") + + return metadata + } + +} diff --git a/winston/components/SubscribeButton.swift b/winston/components/SubscribeButton.swift index e6e2b240..b73879d2 100644 --- a/winston/components/SubscribeButton.swift +++ b/winston/components/SubscribeButton.swift @@ -9,39 +9,58 @@ import SwiftUI import Defaults struct SubscribeButton: View { + @Environment(\.colorScheme) var colorScheme: ColorScheme // @Default(.subreddits) var subs @FetchRequest(sortDescriptors: [], animation: .default) var subs: FetchedResults @ObservedObject var subreddit: Subreddit + var isSmall: Bool = false @State var loading = false @GestureState var pressing = false - var body: some View { + + var body: some View { let subscribed = subs.contains(where: { $0.name == subreddit.data?.name }) if let _ = subreddit.data { HStack { Group { if loading { - ProgressView() - .padding(.trailing, 8) - .colorScheme(subscribed ? .dark : colorScheme) + ProgressView() + .padding(.trailing, isSmall ? 0 : 8) + .colorScheme(subscribed ? .dark : colorScheme) + } else { if subscribed { - Image(systemName: "checkmark.circle.fill") + if isSmall { + Image(systemName: "checkmark").padding(.horizontal, 5) + } else { + Image(systemName: "checkmark.circle.fill") + } + } else { + if isSmall { + Text("Sub") + } } } let label = subscribed ? "Subscribed" : "Not subscribed" - Text(label) - .id(label) + if !isSmall { + Text(label) + .id(label) + } } .transition(.scaleAndBlur) } - .fontSize(16, .semibold) - .foregroundColor(subscribed ? .white : .primary) - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(RR(16, subscribed ? .green : .secondary.opacity(0.2))) - .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(.secondary.opacity(subscribed ? 0 : 0.2))) + .ifIOS17{ view in + if #available(iOS 17.0, *) { + view.contentTransition(.symbolEffect) + } + } + .fontSize(16, isSmall ? .medium : .semibold) + .foregroundColor(subscribed ? .white : isSmall ? .accentColor : .primary) + .padding(.horizontal, isSmall ? 5 : 16) + .padding(.vertical, isSmall ? 4 : 12) + .background(RR(16, (subscribed ? .green : isSmall ? .thickMaterial : .secondary.opacity(0.2)))) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(isSmall ? Color.accentColor.opacity(subscribed ? 0 : 1) : .secondary.opacity(subscribed ? 0 : 0.2), lineWidth: 1)) .brightness(pressing ? -0.1 : 0) .contentShape(Rectangle()) // .animation(spring, value: subs) diff --git a/winston/components/URLImage.swift b/winston/components/URLImage.swift index 1373113c..74786e22 100644 --- a/winston/components/URLImage.swift +++ b/winston/components/URLImage.swift @@ -10,6 +10,7 @@ import NukeUI import Nuke import NukeExtensions import Giffy +import VisionKit struct URLImage: View, Equatable { static func == (lhs: URLImage, rhs: URLImage) -> Bool { @@ -17,10 +18,10 @@ struct URLImage: View, Equatable { } let url: URL + var doLiveText: Bool = false var imgRequest: ImageRequest? = nil var pipeline: ImagePipeline? = nil var processors: [ImageProcessing]? = nil - var body: some View { if url.absoluteString.hasSuffix(".gif") { AsyncGiffy(url: url) { phase in @@ -37,7 +38,14 @@ struct URLImage: View, Equatable { if let imgRequest = imgRequest { LazyImage(request: imgRequest) { state in if let image = state.image { - image.resizable() + if doLiveText && ImageAnalyzer.isSupported { + LiveTextInteraction(image: image) + .scaledToFill() + } else { + image + .resizable() + .scaledToFit() + } } else if state.error != nil { Color.red.opacity(0.1) .overlay(Image(systemName: "xmark.circle.fill").foregroundColor(.red)) @@ -54,7 +62,14 @@ struct URLImage: View, Equatable { } else { LazyImage(url: url) { state in if let image = state.image { - image.resizable() + if doLiveText && ImageAnalyzer.isSupported { + LiveTextInteraction(image: image) + .scaledToFill() + } else { + image + .resizable() + .scaledToFit() + } } else if state.error != nil { Color.red.opacity(0.1) .overlay(Image(systemName: "xmark.circle.fill").foregroundColor(.red)) diff --git a/winston/extensions/WhatsNewCollectionProvider.swift b/winston/extensions/WhatsNewCollectionProvider.swift new file mode 100644 index 00000000..5f043a00 --- /dev/null +++ b/winston/extensions/WhatsNewCollectionProvider.swift @@ -0,0 +1,12 @@ +// +// WhatsNewCollectionProvider.swift +// winston +// +// Created by Daniel Inama on 09/11/23. +// + +import Foundation +import WhatsNewKit +import SwiftUI + + diff --git a/winston/globals/defaultTheme.swift b/winston/globals/defaultTheme.swift index 2c970b43..7401ad6c 100644 --- a/winston/globals/defaultTheme.swift +++ b/winston/globals/defaultTheme.swift @@ -22,6 +22,8 @@ let defaultThemeBG: ThemeBG = .color(defaultBG) let badgeTheme: BadgeTheme = .init( avatar: AvatarTheme(size: 30, cornerRadius: 15, visible: true), authorText: .init(size: 13, color: themeFontPrimary, weight: .semibold), + flairText: .init(size: 12, color: .init(light: .init(hex: "999999"), dark: .init(hex: "767676")), weight: .bold), + flairBackground: .init(light: .init(hex: "EEEEEE"), dark: .init(hex: "2C2C2C")), statsText: .init(size: 12, color: .init(light: .init(hex: "000000", alpha: 0.5), dark: .init(hex: "ffffff", alpha: 0.5)), weight: .medium), spacing: 5) let defaultTheme = WinstonTheme( @@ -42,10 +44,12 @@ let defaultTheme = WinstonTheme( stickyPostBorderColor: .init(thickness: 4, color: .init(light: .init(hex: "2FD058", alpha: 0.3), dark: .init(hex: "2FD058", alpha: 0.3))), titleText: .init(size: 16, color: themeFontPrimary, weight: .medium), bodyText: .init(size: 15, color: .init(light: ThemeColor(hex: "000000", alpha: 0.75), dark: .init(hex: "ffffff", alpha: 0.75))), + linespacing: 0, badge: badgeTheme, verticalElementsSpacing: 8, bg: .init(blurry: false, color: listSectionBGTheme), - unseenType: .dot(.init(light: .init(hex: "4FFF85"), dark: .init(hex: "4FFF85"))) + unseenType: .dot(.init(light: .init(hex: "4FFF85"), dark: .init(hex: "4FFF85"))), + unseenFadeOpacity : 0.6 ), spacing: 16, divider: .init(style: .no, thickness: 6, color: listSectionBGTheme), @@ -58,7 +62,8 @@ let defaultTheme = WinstonTheme( bg: defaultThemeBG, commentsDistance: 16, titleText: .init(size: 20, color: themeFontPrimary), - bodyText: .init(size: 15, color: themeFontPrimary) + bodyText: .init(size: 15, color: themeFontPrimary), + linespacing: 0 ), comments: .init( theme: .init( @@ -71,7 +76,13 @@ let defaultTheme = WinstonTheme( badge: badgeTheme, bodyText: .init(size: 15, color: themeFontPrimary), bodyAuthorSpacing: 6, - bg: listSectionBGTheme + linespacing: 0, + bg: listSectionBGTheme, + loadMoreInnerPadding: .init(horizontal: 10, vertical: 6), + loadMoreOuterTopPadding: 12, + loadMoreText: .init(size: 15, color: .init(light: .init(hex: "0B84FE"), dark: .init(hex: "0B84FE")), weight: .semibold), + loadMoreBackground: defaultThemeDividerColor, + unseenDot : .init(light: .init(hex: "0B84FE"), dark: .init(hex: "0B84FE")) ), spacing: 12, divider: .init(style: .no, thickness: 1, color: defaultThemeDividerColor) diff --git a/winston/globals/defaults.swift b/winston/globals/defaults.swift index 61a64a04..f9dbc0c2 100644 --- a/winston/globals/defaults.swift +++ b/winston/globals/defaults.swift @@ -49,6 +49,7 @@ extension Defaults.Keys { static let postsInBox = Key<[PostInBox]>("postsInBox-v2", default: []) static let likedButNotSubbed = Key<[Subreddit]>("likedButNotSubbed", default: []) static let preferredSort = Key("preferredSort", default: .best) + static let preferredSearchSort = Key("preferredSearchSort", default: .best) static let blurPostLinkNSFW = Key("blurPostLinkNSFW", default: true) static let blurPostNSFW = Key("blurPostNSFW", default: false) static let collapseAutoModerator = Key("collapseAutoModerator", default: false) @@ -93,6 +94,7 @@ extension Defaults.Keys { static let showUsernameInTabBar = Key("showUsernameInTabBar", default: false) static let openYoutubeApp = Key("openYoutubeApp", default: true) static let preferenceDefaultFeed = Key("preferenceDefaultFeed", default: "subList") + static let useAuth = Key("useAuth", default: false) static let showHomeFeed = Key("showHomeFeed", default: true) static let showPopularFeed = Key("showPopularFeed", default: true) static let showAllFeed = Key("showAllFeed", default: true) @@ -125,6 +127,15 @@ extension Defaults.Keys { static let themesPresets = Key<[WinstonTheme]>("themesPresets", default: []) static let selectedThemeID = Key("selectedThemeID", default: "default") static let feedPostsLoadLimit = Key("feedPostsLoadLimit", default: 35) + + static let themeStoreTint = Key("themeStoreTint", default: true) + + static let perSubredditSort = Key("perSubredditSort", default: true) + static let subredditSorts = Key>("subredditSorts", default: [String: SubListingSortOption]()) + + static let perPostSort = Key("perPostSort", default: true) + static let postSorts = Key>("postSorts", default: [String: CommentSortOption]()) + static let doLiveText = Key("doLiveText", default: true) } extension UIScreen { diff --git a/winston/globals/modelSamples/CommentSample.swift b/winston/globals/modelSamples/CommentSample.swift index 913e7f27..eeeeab32 100644 --- a/winston/globals/modelSamples/CommentSample.swift +++ b/winston/globals/modelSamples/CommentSample.swift @@ -11,6 +11,7 @@ func getCommentSampleData(_ withChild: Bool = true) -> CommentData { var commentSampleData = CommentData(id: "winstonSample") commentSampleData.author = "Winston" commentSampleData.author_fullname = "t2_winston_sample" + commentSampleData.author_flair_text = "flair" commentSampleData.body = "My best friend was called apollo, but he passed away a few ago :(" commentSampleData.created = Date().timeIntervalSince1970 - 115200 commentSampleData.ups = 483 diff --git a/winston/globals/whatsNew.swift b/winston/globals/whatsNew.swift new file mode 100644 index 00000000..05b94c0d --- /dev/null +++ b/winston/globals/whatsNew.swift @@ -0,0 +1,111 @@ +// +// whatsNew.swift +// winston +// +// Created by Daniel Inama on 09/11/23. +// + +import Foundation +import WhatsNewKit + +// Function to read JSON file and append WhatsNew to the collection +func getCurrentChangelog() -> WhatsNewCollection { + let filePath = Bundle.main.path(forResource: "changelog", ofType: "json") ?? "" + print("Get") + + // Read JSON content from the file + guard let jsonData = try? Data(contentsOf: URL(fileURLWithPath: filePath)) else { return [] } + + let decoder = JSONDecoder() + + // Decode JSON data into a WhatsNewRelease object + do { + let changelog = try decoder.decode(WhatsNewRelease.self, from: jsonData) + print(changelog) + + // Extract relevant information from the WhatsNewRelease object + let version = changelog.version + let title = changelog.title + let features = changelog.features + + // Create a new mutable copy of the current collection + var newCollection: WhatsNewCollection = [] + + // Create an array to store WhatsNew.Feature instances for the current feature + var featureArray: [WhatsNew.Feature] = [] + + // Loop through all features and add them to the feature array + if let features = features { + for feature in features { + let systemImage = feature.systemImage + let featureTitle = feature.title + let featureSubtitle = feature.subtitle + + // Create a new WhatsNew.Feature for each feature detail + let newFeature = WhatsNew.Feature( + image: .init(systemName: systemImage), + title: WhatsNew.Text(stringLiteral: featureTitle), + subtitle: WhatsNew.Text(stringLiteral: featureSubtitle) + ) + + // Append the new feature to the array + featureArray.append(newFeature) + } + } + + // Create a new WhatsNew object with the array of features + let newWhatsNew = WhatsNew( + version: WhatsNew.Version(stringLiteral: version ?? "0.0.0"), + title: WhatsNew.Title(stringLiteral: title ?? ""), + features: featureArray, + primaryAction: getDefaultPrimaryAction(), + secondaryAction: getDefaultSecondaryAction() + ) + + // Append the new WhatsNew to the new collection + newCollection.append(newWhatsNew) + + // Return the updated collection + return newCollection + + } catch { + print(error) + } + + return [] +} + +func getDefaultPrimaryAction() -> WhatsNew.PrimaryAction { + return WhatsNew.PrimaryAction( + title: "Continue", + backgroundColor: .accentColor, + foregroundColor: .white, + hapticFeedback: .notification(.success), + onDismiss: { + } + ) +} + +func getDefaultSecondaryAction() -> WhatsNew.SecondaryAction { + return WhatsNew.SecondaryAction( + title: "Learn more", + foregroundColor: .accentColor, + hapticFeedback: .selection, + action: .openURL( + .init(string: "https://github.com/lo-cafe/winston") + ) + ) +} + +struct WhatsNewRelease: Decodable { + let title: String? + let version: String? + let features: [WhatsNewFeatureDetail]? +} + +struct WhatsNewFeatureDetail: Decodable { + let subtitle: String + let systemImage: String + let title: String +} + diff --git a/winston/models/Comment.swift b/winston/models/Comment.swift index 7b10e18d..24b31f21 100644 --- a/winston/models/Comment.swift +++ b/winston/models/Comment.swift @@ -80,6 +80,7 @@ extension Comment { commentData.parent_id = message.parent_id commentData.score = nil commentData.author_fullname = message.author_fullname + commentData.author_flair_text = message.author_flair_text commentData.approved_by = nil commentData.mod_note = nil commentData.collapsed = false @@ -160,7 +161,7 @@ extension Comment { } } - func loadChildren(parent: CommentParentElement, postFullname: String, avatarSize: Double) async { + func loadChildren(parent: CommentParentElement, postFullname: String, avatarSize: Double, post: Post?) async { if let kind = kind, kind == "more", let data = data, let count = data.count, let parent_id = data.parent_id, let childrenIDS = data.children { var actualID = id // if actualID.hasSuffix("-more") { @@ -186,6 +187,7 @@ extension Comment { let loadedComments: [Comment] = nestComments(children, parentID: parentID, api: RedditAPI.shared) Task(priority: .background) { [loadedComments] in + await post?.saveMoreComments(comments: loadedComments) await RedditAPI.shared.updateAvatarURLCacheFromComments(comments: loadedComments, avatarSize: avatarSize) } await MainActor.run { [loadedComments] in @@ -433,7 +435,7 @@ struct CommentData: GenericRedditEntityDataType { // let locked: Bool? // let report_reasons: String? var created: Double? - // let author_flair_text: String? + var author_flair_text: String? // let treatment_tags: [String]? var link_id: String? var link_title: String? diff --git a/winston/models/Message.swift b/winston/models/Message.swift index 791c1b44..03fcc9bd 100644 --- a/winston/models/Message.swift +++ b/winston/models/Message.swift @@ -52,6 +52,7 @@ struct MessageData: GenericRedditEntityDataType { // let associated_awarding_id: String? // let score: Int? let author: String? + let author_flair_text: String? // let num_comments: Int? let parent_id: String? let subreddit_name_prefixed: String? diff --git a/winston/models/Post.swift b/winston/models/Post.swift index e0133c25..243ff398 100644 --- a/winston/models/Post.swift +++ b/winston/models/Post.swift @@ -22,31 +22,14 @@ extension Post { self.init(data: data, api: api, typePrefix: "\(Post.prefix)_") self.winstonData = PostWinstonData() self.winstonData?.permaURL = URL(string: "https://reddit.com\(data.permalink.escape.urlEncoded)") - let extractedMedia = mediaExtractor(contentWidth: contentWidth, data) - self.winstonData?.extractedMedia = extractedMedia - self.winstonData?.postDimensions = getPostDimensions(post: self, columnWidth: contentWidth, secondary: secondary) + if fetchSub { self.winstonData?.subreddit = Subreddit(id: data.subreddit, api: RedditAPI.shared) } - let compact = Defaults[.compactMode] - switch extractedMedia { - case .image(let url): - let processors: [ImageProcessing] = contentWidth == 0 ? [] : [.resize(width: compact ? scaledCompactModeThumbSize() : contentWidth)] - self.winstonData?.mediaImageRequest = [ImageRequest(url: url.url, processors: processors)] - case .gallery(let imgs): - let halfWidthProcessor: [ImageProcessing] = contentWidth == 0 ? [] : [.resize(width: compact ? scaledCompactModeThumbSize() : ((contentWidth - 8) / 2))] - let fullWidthProcessor: [ImageProcessing] = contentWidth == 0 ? [] : [.resize(width: compact ? scaledCompactModeThumbSize() : contentWidth)] - var requests: [ImageRequest] = [] - requests.append(ImageRequest(url: imgs[0].url, processors: halfWidthProcessor)) - requests.append(ImageRequest(url: imgs[1].url, processors: halfWidthProcessor)) - if imgs.count >= 3 { requests.append(ImageRequest(url: imgs[2].url, processors: imgs.count > 3 ? halfWidthProcessor : fullWidthProcessor)) } - requests += imgs.dropFirst(3).map { ImageRequest(url: $0.url, priority: .low) } - self.winstonData?.mediaImageRequest = requests - case .link(let url): - Caches.postsPreviewModels.addKeyValue(key: url.absoluteString, data: { PreviewModel(url) }) - break - default: - break + + Task (priority: .high) { + await self.initMedia(data: data, contentWidth: contentWidth, secondary: secondary) + self.winstonData?.loaded = true } if let body = self.data?.selftext { @@ -57,6 +40,44 @@ extension Post { } } + func waitForMediaToLoad() async { + while ((self.winstonData?.loaded ?? false) != true) { + continue; + } + + return + } + + func initMedia(data: T, contentWidth: Double, secondary: Bool) async { + let extractedMedia = await mediaExtractor(contentWidth: contentWidth, data) + + DispatchQueue.main.async { + self.winstonData?.extractedMedia = extractedMedia + self.winstonData?.postDimensions = getPostDimensions(post: self, columnWidth: contentWidth, secondary: secondary) + + let compact = Defaults[.compactMode] + switch extractedMedia { + case .image(let url): + let processors: [ImageProcessing] = contentWidth == 0 ? [] : [.resize(width: compact ? scaledCompactModeThumbSize() : contentWidth)] + self.winstonData?.mediaImageRequest = [ImageRequest(url: url.url, processors: processors)] + case .gallery(let imgs): + let halfWidthProcessor: [ImageProcessing] = contentWidth == 0 ? [] : [.resize(width: compact ? scaledCompactModeThumbSize() : ((contentWidth - 8) / 2))] + let fullWidthProcessor: [ImageProcessing] = contentWidth == 0 ? [] : [.resize(width: compact ? scaledCompactModeThumbSize() : contentWidth)] + var requests: [ImageRequest] = [] + requests.append(ImageRequest(url: imgs[0].url, processors: halfWidthProcessor)) + requests.append(ImageRequest(url: imgs[1].url, processors: halfWidthProcessor)) + if imgs.count >= 3 { requests.append(ImageRequest(url: imgs[2].url, processors: imgs.count > 3 ? halfWidthProcessor : fullWidthProcessor)) } + requests += imgs.dropFirst(3).map { ImageRequest(url: $0.url, priority: .low) } + self.winstonData?.mediaImageRequest = requests + case .link(let url): + Caches.postsPreviewModels.addKeyValue(key: url.absoluteString, data: { PreviewModel(url) }) + break + default: + break + } + } + } + convenience init(id: String, api: RedditAPI) { self.init(id: id, api: api, typePrefix: "\(Post.prefix)_") } @@ -78,6 +99,13 @@ extension Post { let priority = i > 19 ? .veryLow : priorityIMap[priorityIMap.keys.first { $0 > i } ?? 19]! let newPost = Post.init(data: data, api: api, fetchSub: fetchSubs, contentWidth: contentWidth, imgPriority: i > 7 ? .veryLow : priority) newPost.data?.winstonSeen = isSeen + + if (isSeen) { + let foundPost = results.first(where: { $0.postID == data.id }) + newPost.data?.winstonSeenCommentCount = Int(foundPost?.numComments ?? 0) + newPost.data?.winstonSeenComments = foundPost?.seenComments + } + return newPost } } @@ -85,6 +113,7 @@ extension Post { let imgRequests = posts.reduce(into: []) { prev, curr in prev = prev + (curr.winstonData?.mediaImageRequest ?? []) } + Post.prefetcher.startPrefetching(with: imgRequests) return posts } @@ -108,7 +137,6 @@ extension Post { await context.perform(schedule: .enqueued) { let foundPost = results.first(where: { obj in obj.postID == self.id }) - if let foundPost = foundPost { if seen == nil || seen == false { context.delete(foundPost) @@ -147,6 +175,119 @@ extension Post { Defaults[.filteredSubreddits] = filteredSubreddits } + func saveCommentCount(numComments: Int) async -> Void { + let context = PersistenceController.shared.container.viewContext + + let fetchRequest = NSFetchRequest(entityName: "SeenPost") + if let results = (await context.perform(schedule: .enqueued) { try? context.fetch(fetchRequest) as? [SeenPost] }) { + await context.perform(schedule: .enqueued) { + let foundPost = results.first(where: { obj in obj.postID == self.id }) + + if let seenPost = foundPost { + seenPost.numComments = Int32(numComments) + try? context.save() + + DispatchQueue.main.async { + withAnimation { + self.data?.winstonSeenCommentCount = numComments + } + } + } + } + } + } + + func saveSeenComments(comments: ListingData?) async -> Void { + let context = PersistenceController.shared.container.viewContext + let newComments = self.getCommentIds(comments: comments) + + let fetchRequest = NSFetchRequest(entityName: "SeenPost") + if let results = (await context.perform(schedule: .enqueued) { try? context.fetch(fetchRequest) as? [SeenPost] }) { + await context.perform(schedule: .enqueued) { + let foundPost = results.first(where: { obj in obj.postID == self.id }) + + if let seenPost = foundPost { + var seenComments = seenPost.seenComments ?? "" + newComments.forEach { id in + if (!seenComments.contains(id)) { + seenComments += "\(seenComments.isEmpty ? "" : ",")\(id)" + } + } + + let finalSeen = seenComments + seenPost.seenComments = finalSeen + try? context.save() + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + withAnimation { + self.data?.winstonSeenComments = finalSeen + } + } + } + } + } + } + + func saveMoreComments(comments: [Comment]) async -> Void { + let context = PersistenceController.shared.container.viewContext + + let fetchRequest = NSFetchRequest(entityName: "SeenPost") + if let results = (await context.perform(schedule: .enqueued) { try? context.fetch(fetchRequest) as? [SeenPost] }) { + await context.perform(schedule: .enqueued) { + let foundPost = results.first(where: { obj in obj.postID == self.id }) + + if let seenPost = foundPost { + var seenComments = seenPost.seenComments ?? "" + let newComments: [String] = comments.map { $0.data?.id ?? "" } + + newComments.forEach { id in + if (!seenComments.contains(id)) { + seenComments += "\(seenComments.isEmpty ? "" : ",")\(id)" + } + } + + let finalSeen = seenComments + seenPost.seenComments = finalSeen + try? context.save() + + DispatchQueue.main.async { + withAnimation { + self.data?.winstonSeenComments = finalSeen + } + } + } + } + } + } + + + func getCommentIds(comments: ListingData?) -> Array { + var ids = Array() + + if let children = comments?.children { + for i in 0...children.count - 1 { + let child = children[i] + + if (child.kind == "more") { continue } + + if let commentId = child.data?.id { + ids.append(commentId) + } + + if let replies = child.data?.replies { + switch replies { + case .first(_): + break + case .second(let actualData): + ids += getCommentIds(comments: actualData.data) + } + } + } + } + + return ids + } + func reply(_ text: String, updateComments: (() -> ())? = nil) async -> Bool { if let fullname = data?.name { let result = await RedditAPI.shared.newReply(text, fullname) ?? false @@ -210,6 +351,10 @@ extension Post { if let post = response[0] { switch post { case .first(let actualData): + if let numComments = actualData.data?.children?[0].data?.num_comments { + await saveCommentCount(numComments: numComments) + } + if full { await MainActor.run { let newData = actualData.data?.children?[0].data @@ -226,11 +371,11 @@ extension Post { return nil case .second(let actualData): if let data = actualData.data { + await saveSeenComments(comments: data) + if let dataArr = data.children?.compactMap({ $0 }) { - return ( - Comment.initMultiple(datas: dataArr, api: RedditAPI.shared), - data.after - ) + let comments = Comment.initMultiple(datas: dataArr, api: RedditAPI.shared); + return ( comments, data.after ) } return nil } @@ -302,6 +447,7 @@ class PostWinstonData: Hashable { var subreddit: Subreddit? var mediaImageRequest: [ImageRequest] = [] var postDimensions: PostDimensions? + var loaded = false func hash(into hasher: inout Hasher) { hasher.combine(permaURL) @@ -410,6 +556,8 @@ struct PostData: GenericRedditEntityDataType { var preview: Preview? = nil var winstonSeen: Bool? = nil var winstonHidden: Bool? = nil + var winstonSeenCommentCount: Int? = nil + var winstonSeenComments: String? = nil } struct GalleryData: Codable, Hashable { diff --git a/winston/models/RedditAPI/subs/fetchSubPosts.swift b/winston/models/RedditAPI/subs/fetchSubPosts.swift index 3d077bd5..a1fb4467 100644 --- a/winston/models/RedditAPI/subs/fetchSubPosts.swift +++ b/winston/models/RedditAPI/subs/fetchSubPosts.swift @@ -60,6 +60,8 @@ extension RedditAPI { case .top(let topSortOption): subID += "top\(appendedFileType)" subID += buildTopSortQuery(topSortOption) + case .controversial: + subID += "controversial\(appendedFileType)" } } @@ -72,6 +74,9 @@ extension RedditAPI { if let searchText = searchText { subID += subID.contains("?") ? "&q=\(searchText)" : "?q=\(searchText)" subID += "&restrict_sr=on" + + // Add preferred sort to search url + subID += "&sort=\(Defaults[.preferredSearchSort])" } if let after = after { diff --git a/winston/models/RedditAPI/subs/searchSubreddits.swift b/winston/models/RedditAPI/subs/searchSubreddits.swift index 9a4ef147..8f4748d2 100644 --- a/winston/models/RedditAPI/subs/searchSubreddits.swift +++ b/winston/models/RedditAPI/subs/searchSubreddits.swift @@ -21,6 +21,7 @@ extension RedditAPI { encoder: URLEncodedFormParameterEncoder(destination: .queryString), headers: headers ) + .serializingDecodable(Listing.self).result switch response { case .success(let data): @@ -46,3 +47,4 @@ extension RedditAPI { var sort = "relevance" } } + diff --git a/winston/models/Subreddit.swift b/winston/models/Subreddit.swift index b5d4c225..449d218b 100644 --- a/winston/models/Subreddit.swift +++ b/winston/models/Subreddit.swift @@ -194,7 +194,7 @@ struct SubredditData: Codable, GenericRedditEntityDataType, Defaults.Serializabl var banner_background_image: String? = nil var original_content_tag_enabled: Bool? = nil var community_reviewed: Bool? = nil - var over_18: Bool? = nil + var over18: Bool? = nil var submit_text: String? = nil var description_html: String? = nil var spoilers_enabled: Bool? = nil @@ -266,7 +266,7 @@ struct SubredditData: Codable, GenericRedditEntityDataType, Defaults.Serializabl enum CodingKeys: String, CodingKey { - case user_flair_background_color, submit_text_html, restrict_posting, user_is_banned, free_form_reports, wiki_enabled, user_is_muted, user_can_flair_in_sr, display_name, header_img, title, allow_galleries, icon_size, primary_color, active_user_count, icon_img, display_name_prefixed, accounts_active, public_traffic, subscribers, name, quarantine, hide_ads, prediction_leaderboard_entry_type, emojis_enabled, advertiser_category, public_description, comment_score_hide_mins, allow_predictions, user_has_favorited, user_flair_template_id, community_icon, banner_background_image, original_content_tag_enabled, community_reviewed, submit_text, description_html, spoilers_enabled, allow_talks, is_enrolled_in_new_modmail, key_color, can_assign_user_flair, created, show_media_preview, user_is_subscriber, allow_videogifs, should_archive_posts, user_flair_type, allow_polls, public_description_html, allow_videos, banner_img, user_flair_text, banner_background_color, show_media, id, user_is_moderator, description, is_chat_post_feature_enabled, submit_link_label, user_flair_text_color, restrict_commenting, user_flair_css_class, allow_images, url, created_utc, user_is_contributor, winstonFlairs, subreddit_type + case user_flair_background_color, submit_text_html, restrict_posting, user_is_banned, free_form_reports, wiki_enabled, user_is_muted, user_can_flair_in_sr, display_name, header_img, title, allow_galleries, icon_size, primary_color, active_user_count, icon_img, display_name_prefixed, accounts_active, public_traffic, subscribers, name, quarantine, hide_ads, prediction_leaderboard_entry_type, emojis_enabled, advertiser_category, public_description, comment_score_hide_mins, allow_predictions, user_has_favorited, user_flair_template_id, community_icon, banner_background_image, original_content_tag_enabled, community_reviewed, submit_text, description_html, spoilers_enabled, allow_talks, is_enrolled_in_new_modmail, key_color, can_assign_user_flair, created, show_media_preview, user_is_subscriber, allow_videogifs, should_archive_posts, user_flair_type, allow_polls, public_description_html, allow_videos, banner_img, user_flair_text, banner_background_color, show_media, id, user_is_moderator, description, is_chat_post_feature_enabled, submit_link_label, user_flair_text_color, restrict_commenting, user_flair_css_class, allow_images, url, created_utc, user_is_contributor, winstonFlairs, subreddit_type, over18 } @@ -304,7 +304,7 @@ struct SubredditData: Codable, GenericRedditEntityDataType, Defaults.Serializabl self.banner_background_image = nil self.original_content_tag_enabled = nil self.community_reviewed = nil - self.over_18 = nil + self.over18 = nil self.submit_text = nil self.description_html = nil self.spoilers_enabled = nil @@ -341,12 +341,11 @@ struct SubredditData: Codable, GenericRedditEntityDataType, Defaults.Serializabl self.title = nil - let x = entity self.allow_galleries = x.allow_galleries self.allow_images = x.allow_images self.allow_videos = x.allow_videos - self.over_18 = x.over_18 + self.over18 = x.over18 self.restrict_commenting = x.restrict_commenting self.user_has_favorited = x.user_has_favorited self.user_is_banned = x.user_is_banned @@ -457,6 +456,7 @@ struct SubredditData: Codable, GenericRedditEntityDataType, Defaults.Serializabl self.created_utc = try container.decodeIfPresent(Double.self, forKey: .created_utc) self.user_is_contributor = try container.decodeIfPresent(Bool.self, forKey: .user_is_contributor) self.winstonFlairs = try container.decodeIfPresent([Flair].self, forKey: .winstonFlairs) + self.over18 = try container.decodeIfPresent(Bool.self, forKey: .over18) } } @@ -480,6 +480,7 @@ enum SubListingSortOption: Codable, Identifiable, Defaults.Serializable, Hashabl case best case hot case new + case controversial case top(TopListingSortOption) enum TopListingSortOption: String, Codable, CaseIterable, Hashable { @@ -489,6 +490,7 @@ enum SubListingSortOption: Codable, Identifiable, Defaults.Serializable, Hashabl case month case year case all + var icon: String { switch self { @@ -505,6 +507,7 @@ enum SubListingSortOption: Codable, Identifiable, Defaults.Serializable, Hashabl var rawVal: SubListingSort { switch self { case .best: return SubListingSort(icon: "trophy", value: "best") + case .controversial: return SubListingSort(icon: "figure.fencing", value: "controversial") case .hot: return SubListingSort(icon: "flame", value: "hot") case .new: return SubListingSort(icon: "newspaper", value: "new") case .top(let subOption): @@ -519,6 +522,6 @@ enum SubListingSortOption: Codable, Identifiable, Defaults.Serializable, Hashabl extension SubListingSortOption: CaseIterable { static var allCases: [SubListingSortOption] { - return [.best, .hot, .new, .top(.all)] + return [.best, .hot, .new, .controversial, .top(.all)] } } diff --git a/winston/models/ThemeStore/fetchAllThemes.swift b/winston/models/ThemeStore/fetchAllThemes.swift new file mode 100644 index 00000000..27ed6d1e --- /dev/null +++ b/winston/models/ThemeStore/fetchAllThemes.swift @@ -0,0 +1,40 @@ +// +// fetchAllThemes.swift +// winston +// +// Created by Daniel Inama on 26/09/23. +// + +import Foundation +import Alamofire + +extension ThemeStoreAPI { + func fetchAllThemes(fetchLimit: Int? = nil, offset: Int? = nil) async -> [ThemeData]? { + if let headers = self.getRequestHeaders() { + let parameters: [String: Any] = [ + "fetchLimit": fetchLimit ?? 100, + "offset": offset == nil ?? 0 + ] + + let response = await AF.request( + "\(ThemeStoreAPI.baseURL)/themes", + method: .get, + parameters: parameters, + headers: headers + ) + .serializingDecodable([ThemeData].self).response + + switch response.result { + case .success(let data): + return data + case .failure(let error): + Oops.shared.sendError(error) + print(error) + return nil + } + } else { + return nil + } + } +} + diff --git a/winston/models/ThemeStore/fetchThemeByID.swift b/winston/models/ThemeStore/fetchThemeByID.swift new file mode 100644 index 00000000..475e5b5c --- /dev/null +++ b/winston/models/ThemeStore/fetchThemeByID.swift @@ -0,0 +1,33 @@ +// +// fetchThemeByID.swift +// winston +// +// Created by Daniel Inama on 08/11/23. +// + +import Foundation +import Alamofire + +extension ThemeStoreAPI { + func fetchThemeByID(id: String) async -> ThemeData? { + if let headers = self.getRequestHeaders() { + let response = await AF.request( + "\(ThemeStoreAPI.baseURL)/themes/" + id, + method: .get, + headers: headers + ) + .serializingDecodable(ThemeData.self).response + + switch response.result { + case .success(let data): + return data + case .failure(let error): + Oops.shared.sendError(error) + print(error) + return nil + } + } else { + return nil + } + } +} diff --git a/winston/models/ThemeStore/fetchThemeStatus.swift b/winston/models/ThemeStore/fetchThemeStatus.swift new file mode 100644 index 00000000..0b25b48e --- /dev/null +++ b/winston/models/ThemeStore/fetchThemeStatus.swift @@ -0,0 +1,36 @@ +// +// fetchThemeStatus.swift +// winston +// +// Created by Daniel Inama on 26/09/23. +// + +import Foundation +import Alamofire + +extension ThemeStoreAPI { + func fetchThemeStatus(id: String) async -> ThemeStatus? { + if let headers = self.getRequestHeaders() { + let response = await AF.request( + "\(ThemeStoreAPI.baseURL)/themes/status/" + id, + method: .get, + headers: headers + ) + .serializingDecodable(ThemeStatus.self).response + switch response.result { + case .success(let data): + return data + case .failure(let error): + Oops.shared.sendError(error) + print(error) + return nil + } + } else { + return nil + } + } +} + +struct ThemeStatus: Codable { + var status: String? +} diff --git a/winston/models/ThemeStore/fetchThemesByName.swift b/winston/models/ThemeStore/fetchThemesByName.swift new file mode 100644 index 00000000..89de47df --- /dev/null +++ b/winston/models/ThemeStore/fetchThemesByName.swift @@ -0,0 +1,34 @@ +// +// fetchThemesByName.swift +// winston +// +// Created by Daniel Inama on 06/10/23. +// + +import Foundation +import Alamofire + +extension ThemeStoreAPI { + func fetchThemesByName(name: String) async -> [ThemeData]? { + if let headers = self.getRequestHeaders() { + let response = await AF.request( + "\(ThemeStoreAPI.baseURL)/themes/name/" + name, + method: .get, + headers: headers + ) + .serializingDecodable([ThemeData].self).response + + switch response.result { + case .success(let data): + return data + case .failure(let error): + Oops.shared.sendError(error) + print(error) + return nil + } + } else { + return nil + } + } +} + diff --git a/winston/models/ThemeStore/getDownloadURL.swift b/winston/models/ThemeStore/getDownloadURL.swift new file mode 100644 index 00000000..03c98902 --- /dev/null +++ b/winston/models/ThemeStore/getDownloadURL.swift @@ -0,0 +1,45 @@ +// +// getDownloadURL.swift +// winston +// +// Created by Daniel Inama on 29/09/23. +// + +import Foundation +import Alamofire + +extension ThemeStoreAPI { + func getDownloadedFilePath(filename: String, completion: @escaping (URL?) -> Void) { + if let headers = self.getRequestHeaders() { + let destination: DownloadRequest.Destination = { _, _ in + let tempDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let fileURL = tempDirectoryURL.appendingPathComponent(filename) + return (fileURL, [.removePreviousFile, .createIntermediateDirectories]) + } + + AF.download( + "\(ThemeStoreAPI.baseURL)/themes/attachment/" + filename, + method: .get, + headers: headers, + to: destination + ) + .responseData { response in + switch response.result { + case .success: + if let destinationURL = response.fileURL { + completion(destinationURL) + } else { + completion(nil) + } + case .failure(let error): + Oops.shared.sendError(error) + print(error) + completion(nil) + } + } + } else { + completion(nil) + } + } +} + diff --git a/winston/models/ThemeStore/getPreviewImages.swift b/winston/models/ThemeStore/getPreviewImages.swift new file mode 100644 index 00000000..19948a5b --- /dev/null +++ b/winston/models/ThemeStore/getPreviewImages.swift @@ -0,0 +1,36 @@ +// +// getPreviewImages.swift +// winston +// +// Created by Daniel Inama on 05/10/23. +// + +import Foundation +import Alamofire + +extension ThemeStoreAPI { + func getPreviewImages(id: String) async -> PreviewImages? { + if let headers = self.getRequestHeaders() { + let response = await AF.request( + "\(ThemeStoreAPI.baseURL)/themes/previews/" + id, + method: .get, + headers: headers + ) + .serializingDecodable(PreviewImages.self).response + switch response.result { + case .success(let data): + return data + case .failure(let error): + Oops.shared.sendError(error) + print(error) + return nil + } + } else { + return nil + } + } +} + +struct PreviewImages: Codable{ + var previews: [String] +} diff --git a/winston/models/ThemeStore/themeStore.swift b/winston/models/ThemeStore/themeStore.swift new file mode 100644 index 00000000..d9e4e3a6 --- /dev/null +++ b/winston/models/ThemeStore/themeStore.swift @@ -0,0 +1,50 @@ +// +// themeStore.swift +// winston +// +// Created by Daniel Inama on 26/09/23. +// + +import Foundation +import Alamofire +class ThemeStoreAPI: ObservableObject { + static let baseURL = "https://preview.webhook.metisbot.xyz" +// static let baseURL = "http://localhost:3000" + static let bearerToken = "2cYk@dXT!ZjXagF_-h6x" + + func getRequestHeaders(includeAuth: Bool = true) -> HTTPHeaders? { + var headers: HTTPHeaders = [] + headers["Authorization"] = "Bearer \(ThemeStoreAPI.bearerToken)" + + return headers + } +} + + + +struct ThemeData: Codable, Hashable { + var file_name: String? + var file_id: String? + var theme_name: String? + var theme_author: String? + var theme_description: String? + var approval_state: String? + var attachment_url: String? + var color: ThemeColor? + var icon: String? + var thumbnails_urls: [String]? +} + +//{ +// "file_name": "1695662953750-0867145e-a195-4bbc-858c-26d9d7dd43ce.zip", +// "file_id": "1695662953750-0867145e-a195-4bbc-858c-26d9d7dd43ce", +// "theme_name": "Nalryf", +// "theme_author": "@bberries", +// "theme_description": "", +// "approval_state": "accepted", +// "attachment_url": "https://cdn.discordapp.com/attachments/1155789985006493707/1155919023981215844/1695662953750-0867145e-a195-4bbc-858c-26d9d7dd43ce.zip", +// "color": { +// "hex": "c48fff", +// "alpha": 1 +// } +// }, diff --git a/winston/models/ThemeStore/uploadTheme.swift b/winston/models/ThemeStore/uploadTheme.swift new file mode 100644 index 00000000..a7dff01a --- /dev/null +++ b/winston/models/ThemeStore/uploadTheme.swift @@ -0,0 +1,66 @@ +// +// uploadTheme.swift +// winston +// +// Created by Daniel Inama on 26/09/23. +// + +import Foundation +import Alamofire + +import Foundation +import Alamofire + +extension ThemeStoreAPI { + func uploadTheme(theme: WinstonTheme) async -> UploadResponse? { + + do { + var zipURL: URL? = nil + // Create a zip file with the theme's images + createZipFile(with: [], theme: theme, completion: { url in + zipURL = url + }) + + let headers: HTTPHeaders = [ + .authorization(bearerToken: ThemeStoreAPI.bearerToken) + ] + + let response = await AF.upload( + multipartFormData: { multipartFormData in + multipartFormData.append( + zipURL!, + withName: "file", + fileName: "theme.zip", + mimeType: "application/zip" + ) + }, + to: ThemeStoreAPI.baseURL + "/themes/upload", + headers: headers + ) + .uploadProgress { progress in + // Handle upload progress updates if needed + } + .serializingDecodable(UploadResponse.self).response + switch response.result { + case .success(let data): + return data + case .failure(let error): + Oops.shared.sendError(error) + print(error) + return nil + } + + } catch { + Oops.shared.sendError(error) + print(error) + return nil + } + return nil + } +} + + + +struct UploadResponse: Codable { + var message: String? +} diff --git a/winston/models/WinstonTheme/BadgeTheme.swift b/winston/models/WinstonTheme/BadgeTheme.swift index 20669f49..9105f6d0 100644 --- a/winston/models/WinstonTheme/BadgeTheme.swift +++ b/winston/models/WinstonTheme/BadgeTheme.swift @@ -9,25 +9,31 @@ import Foundation struct BadgeTheme: Codable, Hashable { enum CodingKeys: String, CodingKey { - case avatar, authorText, statsText, spacing + case avatar, authorText, flairText, flairBackground, statsText, spacing } var avatar: AvatarTheme var authorText: ThemeText + var flairText: ThemeText + var flairBackground: ColorSchemes var statsText: ThemeText var spacing: CGFloat - init(avatar: AvatarTheme, authorText: ThemeText, statsText: ThemeText, spacing: CGFloat) { + init(avatar: AvatarTheme, authorText: ThemeText, flairText: ThemeText, flairBackground: ColorSchemes, statsText: ThemeText, spacing: CGFloat) { self.avatar = avatar self.authorText = authorText + self.flairText = flairText + self.flairBackground = flairBackground self.statsText = statsText self.spacing = spacing - } + } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(avatar, forKey: .avatar) try container.encodeIfPresent(authorText, forKey: .authorText) + try container.encodeIfPresent(flairText, forKey: .flairText) + try container.encodeIfPresent(flairBackground, forKey: .flairBackground) try container.encodeIfPresent(statsText, forKey: .statsText) try container.encodeIfPresent(spacing, forKey: .spacing) } @@ -37,6 +43,8 @@ struct BadgeTheme: Codable, Hashable { let container = try decoder.container(keyedBy: CodingKeys.self) self.avatar = try container.decodeIfPresent(AvatarTheme.self, forKey: .avatar) ?? t.avatar self.authorText = try container.decodeIfPresent(ThemeText.self, forKey: .authorText) ?? t.authorText + self.flairText = try container.decodeIfPresent(ThemeText.self, forKey: .flairText) ?? t.flairText + self.flairBackground = try container.decodeIfPresent(ColorSchemes.self, forKey: .flairBackground) ?? t.flairBackground self.statsText = try container.decodeIfPresent(ThemeText.self, forKey: .statsText) ?? t.statsText self.spacing = try container.decodeIfPresent(CGFloat.self, forKey: .spacing) ?? t.spacing } diff --git a/winston/models/WinstonTheme/CommentsTheme.swift b/winston/models/WinstonTheme/CommentsTheme.swift index 2b66f771..8053e4e7 100644 --- a/winston/models/WinstonTheme/CommentsTheme.swift +++ b/winston/models/WinstonTheme/CommentsTheme.swift @@ -40,7 +40,7 @@ struct CommentsSectionTheme: Codable, Hashable { struct CommentTheme: Codable, Hashable { enum CodingKeys: String, CodingKey { - case innerPadding, outerHPadding, repliesSpacing, indentCurve, indentColor, cornerRadius, badge, bodyText, bodyAuthorSpacing, bg + case innerPadding, outerHPadding, repliesSpacing, indentCurve, indentColor, cornerRadius, badge, bodyText, bodyAuthorSpacing, linespacing, bg, loadMoreInnerPadding, loadMoreOuterTopPadding, loadMoreText, loadMoreBackground, unseenDot } var innerPadding: ThemePadding @@ -52,9 +52,17 @@ struct CommentTheme: Codable, Hashable { var badge: BadgeTheme var bodyText: ThemeText var bodyAuthorSpacing: CGFloat + var linespacing: CGFloat var bg: ColorSchemes + + var loadMoreInnerPadding: ThemePadding + var loadMoreOuterTopPadding: CGFloat + var loadMoreText : ThemeText + var loadMoreBackground : ColorSchemes - init(innerPadding: ThemePadding, outerHPadding: CGFloat, repliesSpacing: CGFloat, indentCurve: CGFloat, indentColor: ColorSchemes, cornerRadius: CGFloat, badge: BadgeTheme, bodyText: ThemeText, bodyAuthorSpacing: CGFloat, bg: ColorSchemes) { + var unseenDot : ColorSchemes + + init(innerPadding: ThemePadding, outerHPadding: CGFloat, repliesSpacing: CGFloat, indentCurve: CGFloat, indentColor: ColorSchemes, cornerRadius: CGFloat, badge: BadgeTheme, bodyText: ThemeText, bodyAuthorSpacing: CGFloat, linespacing: CGFloat, bg: ColorSchemes, loadMoreInnerPadding: ThemePadding, loadMoreOuterTopPadding: CGFloat, loadMoreText : ThemeText, loadMoreBackground : ColorSchemes, unseenDot : ColorSchemes) { self.innerPadding = innerPadding self.outerHPadding = outerHPadding self.repliesSpacing = repliesSpacing @@ -64,7 +72,14 @@ struct CommentTheme: Codable, Hashable { self.badge = badge self.bodyText = bodyText self.bodyAuthorSpacing = bodyAuthorSpacing + self.linespacing = linespacing self.bg = bg + self.loadMoreInnerPadding = loadMoreInnerPadding + self.loadMoreOuterTopPadding = loadMoreOuterTopPadding + self.loadMoreText = loadMoreText + self.loadMoreBackground = loadMoreBackground + self.unseenDot = unseenDot + } func encode(to encoder: Encoder) throws { @@ -78,7 +93,13 @@ struct CommentTheme: Codable, Hashable { try container.encodeIfPresent(badge, forKey: .badge) try container.encodeIfPresent(bodyText, forKey: .bodyText) try container.encodeIfPresent(bodyAuthorSpacing, forKey: .bodyAuthorSpacing) + try container.encodeIfPresent(linespacing, forKey: .linespacing) try container.encodeIfPresent(bg, forKey: .bg) + try container.encodeIfPresent(loadMoreInnerPadding, forKey: .loadMoreInnerPadding) + try container.encodeIfPresent(loadMoreOuterTopPadding, forKey: .loadMoreOuterTopPadding) + try container.encodeIfPresent(loadMoreText, forKey: .loadMoreText) + try container.encodeIfPresent(loadMoreBackground, forKey: .loadMoreBackground) + try container.encodeIfPresent(unseenDot, forKey: .unseenDot) } init(from decoder: Decoder) throws { @@ -93,6 +114,13 @@ struct CommentTheme: Codable, Hashable { self.badge = try container.decodeIfPresent(BadgeTheme.self, forKey: .badge) ?? t.badge self.bodyText = try container.decodeIfPresent(ThemeText.self, forKey: .bodyText) ?? t.bodyText self.bodyAuthorSpacing = try container.decodeIfPresent(CGFloat.self, forKey: .bodyAuthorSpacing) ?? t.bodyAuthorSpacing + self.linespacing = try container.decodeIfPresent(CGFloat.self, forKey: .linespacing) ?? t.linespacing self.bg = try container.decodeIfPresent(ColorSchemes.self, forKey: .bg) ?? t.bg + self.loadMoreInnerPadding = try container.decodeIfPresent(ThemePadding.self, forKey: .loadMoreInnerPadding) ?? t.loadMoreInnerPadding + self.loadMoreOuterTopPadding = try container.decodeIfPresent(CGFloat.self, forKey: .loadMoreOuterTopPadding) ?? t.loadMoreOuterTopPadding + self.loadMoreText = try container.decodeIfPresent(ThemeText.self, forKey: .loadMoreText) ?? t.loadMoreText + self.loadMoreText = try container.decodeIfPresent(ThemeText.self, forKey: .loadMoreText) ?? t.loadMoreText + self.loadMoreBackground = try container.decodeIfPresent(ColorSchemes.self, forKey: .loadMoreBackground) ?? t.loadMoreBackground + self.unseenDot = try container.decodeIfPresent(ColorSchemes.self, forKey: .unseenDot) ?? t.unseenDot } } diff --git a/winston/models/WinstonTheme/PostLinkTheme.swift b/winston/models/WinstonTheme/PostLinkTheme.swift index 9bf20c49..f32fed37 100644 --- a/winston/models/WinstonTheme/PostLinkTheme.swift +++ b/winston/models/WinstonTheme/PostLinkTheme.swift @@ -64,7 +64,7 @@ struct SubPostsListTheme: Codable, Equatable, Hashable { struct PostLinkTheme: Codable, Hashable { enum CodingKeys: String, CodingKey { - case cornerRadius, mediaCornerRadius, innerPadding, outerHPadding, stickyPostBorderColor, titleText, bodyText, badge, verticalElementsSpacing, bg, unseenType + case cornerRadius, mediaCornerRadius, innerPadding, outerHPadding, stickyPostBorderColor, titleText, bodyText, linespacing, badge, verticalElementsSpacing, bg, unseenType, unseenFadeOpacity } var cornerRadius: CGFloat @@ -74,12 +74,14 @@ struct PostLinkTheme: Codable, Hashable { var stickyPostBorderColor: LineTheme var titleText: ThemeText var bodyText: ThemeText + var linespacing: CGFloat var badge: BadgeTheme var verticalElementsSpacing: CGFloat var bg: ThemeForegroundBG var unseenType: UnseenType + var unseenFadeOpacity: CGFloat - init(cornerRadius: CGFloat, mediaCornerRadius: CGFloat, innerPadding: ThemePadding, outerHPadding: CGFloat, stickyPostBorderColor: LineTheme, titleText: ThemeText, bodyText: ThemeText, badge: BadgeTheme, verticalElementsSpacing: CGFloat, bg: ThemeForegroundBG, unseenType: UnseenType) { + init(cornerRadius: CGFloat, mediaCornerRadius: CGFloat, innerPadding: ThemePadding, outerHPadding: CGFloat, stickyPostBorderColor: LineTheme, titleText: ThemeText, bodyText: ThemeText, linespacing: CGFloat, badge: BadgeTheme, verticalElementsSpacing: CGFloat, bg: ThemeForegroundBG, unseenType: UnseenType, unseenFadeOpacity: CGFloat) { self.cornerRadius = cornerRadius self.mediaCornerRadius = mediaCornerRadius self.innerPadding = innerPadding @@ -87,10 +89,12 @@ struct PostLinkTheme: Codable, Hashable { self.stickyPostBorderColor = stickyPostBorderColor self.titleText = titleText self.bodyText = bodyText + self.linespacing = linespacing self.badge = badge self.verticalElementsSpacing = verticalElementsSpacing self.bg = bg self.unseenType = unseenType + self.unseenFadeOpacity = unseenFadeOpacity } func encode(to encoder: Encoder) throws { @@ -102,10 +106,12 @@ struct PostLinkTheme: Codable, Hashable { try container.encodeIfPresent(stickyPostBorderColor, forKey: .stickyPostBorderColor) try container.encodeIfPresent(titleText, forKey: .titleText) try container.encodeIfPresent(bodyText, forKey: .bodyText) + try container.encodeIfPresent(linespacing, forKey: .linespacing) try container.encodeIfPresent(badge, forKey: .badge) try container.encodeIfPresent(verticalElementsSpacing, forKey: .verticalElementsSpacing) try container.encodeIfPresent(bg, forKey: .bg) try container.encodeIfPresent(unseenType, forKey: .unseenType) + try container.encodeIfPresent(unseenFadeOpacity, forKey: .unseenFadeOpacity) } init(from decoder: Decoder) throws { @@ -118,9 +124,11 @@ struct PostLinkTheme: Codable, Hashable { self.stickyPostBorderColor = try container.decodeIfPresent(LineTheme.self, forKey: .stickyPostBorderColor) ?? t.stickyPostBorderColor self.titleText = try container.decodeIfPresent(ThemeText.self, forKey: .titleText) ?? t.titleText self.bodyText = try container.decodeIfPresent(ThemeText.self, forKey: .bodyText) ?? t.bodyText + self.linespacing = try container.decodeIfPresent(CGFloat.self, forKey: .linespacing) ?? t.linespacing self.badge = try container.decodeIfPresent(BadgeTheme.self, forKey: .badge) ?? t.badge self.verticalElementsSpacing = try container.decodeIfPresent(CGFloat.self, forKey: .verticalElementsSpacing) ?? t.verticalElementsSpacing self.bg = try container.decodeIfPresent(ThemeForegroundBG.self, forKey: .bg) ?? t.bg self.unseenType = try container.decodeIfPresent(UnseenType.self, forKey: .unseenType) ?? t.unseenType + self.unseenFadeOpacity = try container.decodeIfPresent(CGFloat.self, forKey: .unseenFadeOpacity) ?? t.unseenFadeOpacity } } diff --git a/winston/models/WinstonTheme/PostTheme.swift b/winston/models/WinstonTheme/PostTheme.swift index d0430b96..c889c963 100644 --- a/winston/models/WinstonTheme/PostTheme.swift +++ b/winston/models/WinstonTheme/PostTheme.swift @@ -9,7 +9,7 @@ import Foundation struct PostTheme: Codable, Hashable { enum CodingKeys: String, CodingKey { - case padding, spacing, badge, bg, commentsDistance, titleText, bodyText + case padding, spacing, badge, bg, commentsDistance, titleText, bodyText, linespacing } var padding: ThemePadding @@ -19,8 +19,9 @@ struct PostTheme: Codable, Hashable { var commentsDistance: CGFloat var titleText: ThemeText var bodyText: ThemeText + var linespacing: CGFloat - init(padding: ThemePadding, spacing: CGFloat, badge: BadgeTheme, bg: ThemeBG, commentsDistance: CGFloat, titleText: ThemeText, bodyText: ThemeText) { + init(padding: ThemePadding, spacing: CGFloat, badge: BadgeTheme, bg: ThemeBG, commentsDistance: CGFloat, titleText: ThemeText, bodyText: ThemeText, linespacing: CGFloat) { self.padding = padding self.spacing = spacing self.badge = badge @@ -28,6 +29,7 @@ struct PostTheme: Codable, Hashable { self.commentsDistance = commentsDistance self.titleText = titleText self.bodyText = bodyText + self.linespacing = linespacing } func encode(to encoder: Encoder) throws { @@ -39,6 +41,7 @@ struct PostTheme: Codable, Hashable { try container.encodeIfPresent(commentsDistance, forKey: .commentsDistance) try container.encodeIfPresent(titleText, forKey: .titleText) try container.encodeIfPresent(bodyText, forKey: .bodyText) + try container.encodeIfPresent(linespacing, forKey: .linespacing) } init(from decoder: Decoder) throws { @@ -51,5 +54,6 @@ struct PostTheme: Codable, Hashable { self.commentsDistance = try container.decodeIfPresent(CGFloat.self, forKey: .commentsDistance) ?? t.commentsDistance self.titleText = try container.decodeIfPresent(ThemeText.self, forKey: .titleText) ?? t.titleText self.bodyText = try container.decodeIfPresent(ThemeText.self, forKey: .bodyText) ?? t.bodyText + self.linespacing = try container.decodeIfPresent(CGFloat.self, forKey: .linespacing) ?? t.linespacing } } diff --git a/winston/modifiers/nsfw.swift b/winston/modifiers/nsfw.swift index 97053303..43235aba 100644 --- a/winston/modifiers/nsfw.swift +++ b/winston/modifiers/nsfw.swift @@ -5,11 +5,11 @@ // Created by Igor Marcossi on 02/08/23. // -import Foundation import SwiftUI struct NSFWMod: ViewModifier { var isIt: Bool + var smallIcon: Bool = false @State private var unblur = false func body(content: Content) -> some View { let blur = !unblur && isIt @@ -17,6 +17,7 @@ struct NSFWMod: ViewModifier { .frame(minHeight: isIt ? 75 : 0) .opacity(blur ? 0.75 : 1) .blur(radius: blur ? 30 : 0) + .mask(content) .overlay( !blur ? nil @@ -27,18 +28,18 @@ struct NSFWMod: ViewModifier { .padding(.vertical, 3) .background(.red, in: Capsule(style: .continuous)) .foregroundColor(.white) - Text("Tap to unblur") + smallIcon ? nil : Text("Tap to unblur") } ) - .allowsHitTesting(!blur) + .allowsHitTesting(smallIcon ? false : !blur) .contentShape(Rectangle()) - .highPriorityGesture(blur ? TapGesture().onEnded { withAnimation { unblur = true } } : nil ) + .highPriorityGesture(smallIcon ? nil : blur ? TapGesture().onEnded { withAnimation { unblur = true } } : nil ) } } extension View { - func nsfw(_ isIt: Bool) -> some View { + func nsfw(_ isIt: Bool,smallIcon: Bool = false) -> some View { self - .modifier(NSFWMod(isIt: isIt)) + .modifier(NSFWMod(isIt: isIt, smallIcon: smallIcon)) } } diff --git a/winston/utils/downloader.swift b/winston/utils/downloader.swift new file mode 100644 index 00000000..660e3be2 --- /dev/null +++ b/winston/utils/downloader.swift @@ -0,0 +1,95 @@ +// +// downloader.swift +// winston +// +// Created by Daniel Inama on 26/09/23. +// + +import Foundation + +class FileDownloader { + + static func loadFileSync(url: URL, completion: @escaping (String?, Error?) -> Void) + { + let documentsUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + + let destinationUrl = documentsUrl.appendingPathComponent(url.lastPathComponent) + + if FileManager().fileExists(atPath: destinationUrl.path) + { + print("File already exists [\(destinationUrl.path)]") + completion(destinationUrl.path, nil) + } + else if let dataFromURL = NSData(contentsOf: url) + { + if dataFromURL.write(to: destinationUrl, atomically: true) + { + print("file saved [\(destinationUrl.path)]") + completion(destinationUrl.path, nil) + } + else + { + print("error saving file") + let error = NSError(domain:"Error saving file", code:1001, userInfo:nil) + completion(destinationUrl.path, error) + } + } + else + { + let error = NSError(domain:"Error downloading file", code:1002, userInfo:nil) + completion(destinationUrl.path, error) + } + } + + static func loadFileAsync(url: URL, completion: @escaping (String?, Error?) -> Void) + { + let documentsUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + + let destinationUrl = documentsUrl.appendingPathComponent(url.lastPathComponent) + + if FileManager().fileExists(atPath: destinationUrl.path) + { + print("File already exists [\(destinationUrl.path)]") + completion(destinationUrl.path, nil) + } + else + { + let session = URLSession(configuration: URLSessionConfiguration.default, delegate: nil, delegateQueue: nil) + var request = URLRequest(url: url) + request.httpMethod = "GET" + let task = session.dataTask(with: request, completionHandler: + { + data, response, error in + if error == nil + { + if let response = response as? HTTPURLResponse + { + if response.statusCode == 200 + { + if let data = data + { + if let _ = try? data.write(to: destinationUrl, options: Data.WritingOptions.atomic) + { + completion(destinationUrl.path, error) + } + else + { + completion(destinationUrl.path, error) + } + } + else + { + completion(destinationUrl.path, error) + } + } + } + } + else + { + completion(destinationUrl.path, error) + } + }) + task.resume() + } + } +} diff --git a/winston/utils/exportImportSettings.swift b/winston/utils/exportImportSettings.swift new file mode 100644 index 00000000..456194eb --- /dev/null +++ b/winston/utils/exportImportSettings.swift @@ -0,0 +1,100 @@ +// +// exportImportSettings.swift +// winston +// +// Created by Daniel Inama on 19/10/23. +// + +import Foundation + +func exportUserDefaultsToJSON(fileName: String) -> String? { + // Get all UserDefaults keys and values as a dictionary + let userDefaults = UserDefaults.standard + let userDefaultsDictionary = userDefaults.dictionaryRepresentation() + + // Create a dictionary to hold the serialized values + var serializedDictionary: [String: Any] = [:] + + for (key, value) in userDefaultsDictionary { + if let date = value as? Date { + // Convert Date to a string representation + serializedDictionary[key] = date.iso8601String + } else { + // For other types, use the value as is + serializedDictionary[key] = value + } + } + + do { + // Serialize the modified dictionary as JSON data + let jsonData = try JSONSerialization.data(withJSONObject: serializedDictionary, options: .prettyPrinted) + + // Define the file URL where you want to save the JSON file + if let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + let fileURL = documentsDirectory.appendingPathComponent(fileName) + + // Write the JSON data to the file + try jsonData.write(to: fileURL) + + print("UserDefaults exported to: \(fileURL.absoluteString)") + return fileURL.absoluteString + } + } catch { + print("Error exporting UserDefaults to JSON: \(error)") + } + + return nil +} + +func importUserDefaultsFromJSON(jsonFilePath: URL) -> Bool { + // Check if the file exists at the provided path + let gotAccess = jsonFilePath.startAccessingSecurityScopedResource() + if !gotAccess { + print("Can't get file access") + return false + } + do { + // Read the JSON data from the file + let jsonData = try Data(contentsOf:jsonFilePath) + + // Deserialize the JSON data into a dictionary + if let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] { + // Iterate through the dictionary and set the values in UserDefaults + for (key, value) in jsonObject { + if let dateStr = value as? String, + let date = Date.dateFromISO8601String(dateStr) { + UserDefaults.standard.set(date, forKey: key) + } else { + UserDefaults.standard.set(value, forKey: key) + } + } + + // Synchronize UserDefaults to save the changes + UserDefaults.standard.synchronize() + + print("UserDefaults imported from: \(jsonFilePath)") + jsonFilePath.stopAccessingSecurityScopedResource() + return true + } + } catch { + print("Error importing UserDefaults from JSON: \(error)") + jsonFilePath.stopAccessingSecurityScopedResource() + } + return false +} + +// Extension to convert Date to ISO8601 string +extension Date { + var iso8601String: String { + let formatter = ISO8601DateFormatter() + return formatter.string(from: self) + } +} + +// Extension to convert ISO8601 string to Date +extension Date { + static func dateFromISO8601String(_ iso8601String: String) -> Date? { + let formatter = ISO8601DateFormatter() + return formatter.date(from: iso8601String) + } +} diff --git a/winston/utils/importTheme.swift b/winston/utils/importTheme.swift index 14aed09d..7b2b8280 100644 --- a/winston/utils/importTheme.swift +++ b/winston/utils/importTheme.swift @@ -13,7 +13,10 @@ func importTheme(at rawFileURL: URL) -> Bool { do { let fileManager = FileManager.default let docUrls = fileManager.urls(for: .documentDirectory, in: .userDomainMask) - guard let documentDirectory: URL = docUrls.first else { return false } + guard let documentDirectory: URL = docUrls.first else { + print("Error getting directory") + return false + } let fileURL = documentDirectory.appendingPathComponent("\(UUID().uuidString).zip") if fileManager.fileExists(atPath: fileURL.path()) { @@ -21,7 +24,10 @@ func importTheme(at rawFileURL: URL) -> Bool { } let gotAccess = rawFileURL.startAccessingSecurityScopedResource() - if !gotAccess { return false } + if !rawFileURL.path.hasPrefix(NSTemporaryDirectory()) && !gotAccess { + print("Error getting file access") + return false + } try? fileManager.copyItem(at: rawFileURL, to: fileURL) rawFileURL.stopAccessingSecurityScopedResource() let unzipDirectory = try Zip.quickUnzipFile(fileURL) diff --git a/winston/utils/saveMedia.swift b/winston/utils/saveMedia.swift index df9638bf..b8d46fd1 100644 --- a/winston/utils/saveMedia.swift +++ b/winston/utils/saveMedia.swift @@ -8,6 +8,7 @@ import Foundation import Photos import UIKit +import Nuke enum MediaType { case image @@ -40,3 +41,13 @@ func saveMedia(_ urlString: String, _ mediaType: MediaType, _ completion: ((Bool } } } + +func downloadAndSaveImage(url: URL) async throws -> Data? { + let image = try? await ImagePipeline.shared.image(for: url) + if (image != nil){ + let data = image!.jpegData(compressionQuality: 1.0) + return data + } + + return nil +} diff --git a/winston/utils/stringToAttr.swift b/winston/utils/stringToAttr.swift index 879c2921..2810886f 100644 --- a/winston/utils/stringToAttr.swift +++ b/winston/utils/stringToAttr.swift @@ -9,7 +9,9 @@ import SwiftUI import Markdown func stringToAttr(_ str: String, fontSize: CGFloat = 15) -> AttributedString { - let document = Document(parsing: str) + let formatted = str.replacing(">", with: ">") + + let document = Document(parsing: formatted) var markdownosaur = Markdownosaur(baseFontSize: fontSize) let attributedString = markdownosaur.attributedString(from: document) return AttributedString(attributedString) diff --git a/winston/utils/urlCleaner.swift b/winston/utils/urlCleaner.swift new file mode 100644 index 00000000..d74e4bd8 --- /dev/null +++ b/winston/utils/urlCleaner.swift @@ -0,0 +1,32 @@ +// +// urlCleaner.swift +// winston +// +// Created by Daniel Inama on 23/09/23. +// + +import Foundation + +func cleanURL(url: URL, showPath: Bool = true) -> String { + var newURL = "" + + // Extract the host from the URL and remove "www." prefix if present + if let host = url.host?.replacingOccurrences(of: "www.", with: "") { + newURL = host + } + + // Optionally add the path + if showPath { + if !url.path.isEmpty { + newURL += url.path() + } + } + + // Handle cases where both host and path are empty + if newURL.isEmpty { + newURL = "Invalid URL" + } + + return newURL +} + diff --git a/winston/views/ChangeAuthAPIKey.swift b/winston/views/ChangeAuthAPIKey.swift index 51068cc1..30646ffb 100644 --- a/winston/views/ChangeAuthAPIKey.swift +++ b/winston/views/ChangeAuthAPIKey.swift @@ -172,7 +172,7 @@ struct ChangeAuthAPIKey: View { SmallStep { VStack (alignment: .leading) { - Text("Tap below's URL to copy and paste it in the \"redirect uri\" field:") + Text("Tap the URL below to copy and paste it in the \"redirect uri\" field:") CopiableValue(value: "https://app.winston.lo.cafe/auth-success") } } @@ -226,7 +226,7 @@ struct ChangeAuthAPIKey: View { } .fixedSize(horizontal: false, vertical: true) - Text("Now click below's button and grant full access to the app you created:") + Text("Now click the button below and grant full access to the app you created:") HStack { MasterButton(label: "Back", mode: .soft, color: .gray, height: 44, fullWidth: true) { withAnimation(spring) { diff --git a/winston/views/Onboarding/Steps/Onboarding1OpeningSettings.swift b/winston/views/Onboarding/Steps/Onboarding1OpeningSettings.swift index 91022c71..c9d118e9 100644 --- a/winston/views/Onboarding/Steps/Onboarding1OpeningSettings.swift +++ b/winston/views/Onboarding/Steps/Onboarding1OpeningSettings.swift @@ -15,7 +15,7 @@ struct Onboarding1OpeningSettings: View { VStack(spacing: 16) { OnboardingBigStep(step: 1) - Text("Open Reddit API settings in Safari by clicking below's button, then, switch back to Winston.") + Text("Open Reddit API settings in Safari by clicking the button below, then switch back to Winston.") .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: 300) diff --git a/winston/views/Onboarding/Steps/Onboarding6Auth.swift b/winston/views/Onboarding/Steps/Onboarding6Auth.swift index 2b723e24..160abe46 100644 --- a/winston/views/Onboarding/Steps/Onboarding6Auth.swift +++ b/winston/views/Onboarding/Steps/Onboarding6Auth.swift @@ -24,7 +24,7 @@ struct Onboarding6Auth: View { .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: 300) - Text("For that, just click below's button, scroll down the page and click \"Accept\".") + Text("For that, just click the button below, scroll down the page and click \"Accept\".") .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: 300) diff --git a/winston/views/PostView/PostContent.swift b/winston/views/PostView/PostContent.swift index bf657096..841d8ae8 100644 --- a/winston/views/PostView/PostContent.swift +++ b/winston/views/PostView/PostContent.swift @@ -67,6 +67,7 @@ struct PostContent: View, Equatable { if data.selftext != "" { VStack { MD(selfAttr == nil ? .str(data.selftext) : .attr(selfAttr!), fontSize: postsTheme.bodyText.size) + .lineSpacing(postsTheme.linespacing) .foregroundColor(postsTheme.bodyText.color.cs(cs).color()) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/winston/views/PostView/PostReplies.swift b/winston/views/PostView/PostReplies.swift index a4582399..52aaf0b1 100644 --- a/winston/views/PostView/PostReplies.swift +++ b/winston/views/PostView/PostReplies.swift @@ -10,7 +10,7 @@ import Defaults struct PostReplies: View { var update: Bool - var post: Post + @ObservedObject var post: Post @ObservedObject var subreddit: Subreddit var ignoreSpecificComment: Bool var highlightID: String? @@ -22,6 +22,7 @@ struct PostReplies: View { @StateObject private var comments = ObservableArray() @ObservedObject private var globalLoader = TempGlobalState.shared.globalLoader @State private var loading = true + @State var seenComments : String? func asyncFetch(_ full: Bool, _ altIgnoreSpecificComment: Bool? = nil) async { if let result = await post.refreshPost(commentID: (altIgnoreSpecificComment ?? ignoreSpecificComment) ? nil : highlightID, sort: sort, after: nil, subreddit: subreddit.data?.display_name ?? subreddit.id, full: full), let newComments = result.0 { @@ -74,7 +75,7 @@ struct PostReplies: View { .id("\(comment.id)-top-decoration") .listRowInsets(EdgeInsets(top: 0, leading: horPad, bottom: 0, trailing: horPad)) - CommentLink(highlightID: ignoreSpecificComment ? nil : highlightID, post: post, subreddit: subreddit, postFullname: postFullname, parentElement: .post(comments), comment: comment) + CommentLink(highlightID: ignoreSpecificComment ? nil : highlightID, post: post, subreddit: subreddit, postFullname: postFullname, seenComments: seenComments, parentElement: .post(comments), comment: comment) // .equatable() Spacer() @@ -143,6 +144,8 @@ struct PostReplies: View { .listRowBackground(Color.clear) .id("no-comments-placeholder") } + }.onAppear() { + seenComments = post.data?.winstonSeenComments } } } diff --git a/winston/views/PostView/PostView.swift b/winston/views/PostView/PostView.swift index 80691068..a5c61ec7 100644 --- a/winston/views/PostView/PostView.swift +++ b/winston/views/PostView/PostView.swift @@ -37,6 +37,38 @@ struct PostView: View, Equatable { @EnvironmentObject private var routerProxy: RouterProxy @State var update = false + + init(post: Post, subreddit: Subreddit) { + self.post = post + self.subreddit = subreddit + + _sort = State(initialValue: Defaults[.perPostSort] ? (Defaults[.postSorts][post.id] ?? Defaults[.preferredCommentSort]) : Defaults[.preferredCommentSort]); + } + + init(post: Post, subreddit: Subreddit, forceCollapse: Bool) { + self.post = post + self.subreddit = subreddit + self.forceCollapse = forceCollapse + + _sort = State(initialValue: Defaults[.perPostSort] ? (Defaults[.postSorts][post.id] ?? Defaults[.preferredCommentSort]) : Defaults[.preferredCommentSort]); + } + + init(post: Post, subreddit: Subreddit, highlightID: String?) { + self.post = post + self.subreddit = subreddit + self.highlightID = highlightID + + _sort = State(initialValue: Defaults[.perPostSort] ? (Defaults[.postSorts][post.id] ?? Defaults[.preferredCommentSort]) : Defaults[.preferredCommentSort]); + } + + init(post: Post, selfAttr: AttributedString?, subreddit: Subreddit, highlightID: String?) { + self.post = post + self.selfAttr = selfAttr + self.subreddit = subreddit + self.highlightID = highlightID + + _sort = State(initialValue: Defaults[.perPostSort] ? (Defaults[.postSorts][post.id] ?? Defaults[.preferredCommentSort]) : Defaults[.preferredCommentSort]); + } func asyncFetch(_ full: Bool = true) async { if full { @@ -115,7 +147,7 @@ struct PostView: View, Equatable { , alignment: .bottomTrailing ) .navigationBarTitle("\(post.data?.num_comments ?? 0) comments", displayMode: .inline) - .toolbar { Toolbar(hideElements: hideElements, subreddit: subreddit, routerProxy: routerProxy, sort: $sort) } + .toolbar { Toolbar(hideElements: hideElements, subreddit: subreddit, post: post, routerProxy: routerProxy, sort: $sort) } .onChange(of: sort) { val in updatePost() } @@ -140,6 +172,7 @@ struct PostView: View, Equatable { private struct Toolbar: View { var hideElements: Bool var subreddit: Subreddit + var post: Post var routerProxy: RouterProxy @Binding var sort: CommentSortOption var body: some View { @@ -149,6 +182,7 @@ private struct Toolbar: View { ForEach(CommentSortOption.allCases) { opt in Button { sort = opt + Defaults[.postSorts][post.id] = opt } label: { HStack { Text(opt.rawVal.value.capitalized) diff --git a/winston/views/Settings/Settings.swift b/winston/views/Settings/Settings.swift index 94752c0c..9f85d0e1 100644 --- a/winston/views/Settings/Settings.swift +++ b/winston/views/Settings/Settings.swift @@ -7,10 +7,11 @@ import SwiftUI import Defaults +import WhatsNewKit //import SceneKit enum SettingsPages { - case behavior, appearance, account, about, commentSwipe, postSwipe, accessibility, faq, general, postFontSettings, themes, filteredSubreddits, appIcon + case behavior, appearance, account, about, commentSwipe, postSwipe, accessibility, faq, general, postFontSettings, themes, filteredSubreddits, appIcon, themeStore } struct Settings: View { @@ -21,6 +22,9 @@ struct Settings: View { @Environment(\.useTheme) private var selectedTheme @Environment(\.colorScheme) private var cs @State private var id = UUID().uuidString + + @State var presentingWhatsNew: Bool = false + var body: some View { NavigationStack(path: $router.path) { RouterProxyInjector(routerProxy: RouterProxy(router)) { routerProxy in @@ -49,6 +53,13 @@ struct Settings: View { Label("About", systemImage: "cup.and.saucer.fill") } + WListButton { + presentingWhatsNew.toggle() + } label: { + Label("Whats New", systemImage: "star") + } + .disabled(getCurrentChangelog().isEmpty) + WListButton { sendCustomEmail() } label: { @@ -79,6 +90,11 @@ struct Settings: View { .themedListDividers() } + .sheet(isPresented: $presentingWhatsNew){ + if let isNew = getCurrentChangelog().first { + WhatsNewView(whatsNew: isNew) + } + } .themedListBG(selectedTheme.lists.bg) .scrollContentBackground(.hidden) .navigationDestination(for: SettingsPages.self) { x in @@ -108,6 +124,8 @@ struct Settings: View { FAQPanel() case .themes: ThemesPanel() + case .themeStore: + ThemeStore() case .appIcon: AppIconSetting() } @@ -121,6 +139,7 @@ struct Settings: View { .onChange(of: reset) { _ in router.path.removeLast(router.path.count) } } } + } } diff --git a/winston/views/Settings/views/AboutPanel.swift b/winston/views/Settings/views/AboutPanel.swift index 24dfdbb3..f6abf13e 100644 --- a/winston/views/Settings/views/AboutPanel.swift +++ b/winston/views/Settings/views/AboutPanel.swift @@ -9,6 +9,7 @@ import SwiftUI struct AboutPanel: View { let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String @Environment(\.openURL) private var openURL @Environment(\.useTheme) private var theme var body: some View { @@ -24,7 +25,7 @@ struct AboutPanel: View { Text("Winston") .fontSize(20, .bold) HStack{ - Text("Beta v" + (appVersion ?? "-1")) + Text("Beta v" + (appVersion ?? "-1") + " Build \(build ?? "-1")") } } } diff --git a/winston/views/Settings/views/AppIconSetting.swift b/winston/views/Settings/views/AppIconSetting.swift index 9202b581..9d3cd42f 100644 --- a/winston/views/Settings/views/AppIconSetting.swift +++ b/winston/views/Settings/views/AppIconSetting.swift @@ -38,12 +38,17 @@ struct AppIconSetting: View { // Spacer() - Toggle("", isOn: Binding( - get: { icon == appIcon }, - set: { _ in appIcon = icon })) + if(icon == appIcon) + { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } } } .themedListRowBG(enablePadding: true) + .onTapGesture { + appIcon = icon + } } } .themedListDividers() diff --git a/winston/views/Settings/views/AppearancePanel.swift b/winston/views/Settings/views/AppearancePanel.swift index 1e988058..37955460 100644 --- a/winston/views/Settings/views/AppearancePanel.swift +++ b/winston/views/Settings/views/AppearancePanel.swift @@ -26,7 +26,7 @@ struct AppearancePanel: View { @Default(.voteButtonPositionRight) var voteButtonPositionRight @Default(.showSelfPostThumbnails) var showSelfPostThumbnails @Default(.disableAlphabetLettersSectionsInSubsList) var disableAlphabetLettersSectionsInSubsList - + @Default(.themeStoreTint) var themeStoreTint @Environment(\.useTheme) private var theme var body: some View { @@ -35,18 +35,26 @@ struct AppearancePanel: View { Group { Toggle("Show Username in Tab Bar", isOn: $showUsernameInTabBar) Toggle("Disable subs list letter sections", isOn: $disableAlphabetLettersSectionsInSubsList) + Toggle("Theme Store Tint", isOn: $themeStoreTint) } .themedListRowBG(enablePadding: true) } .themedListDividers() - Section { + Section{ WNavigationLink(value: SettingsPages.themes) { Label("Themes", systemImage: "paintbrush.fill") } WNavigationLink(value: SettingsPages.appIcon) { Label("App icon", systemImage: "appclip") } + WNavigationLink(value: SettingsPages.themeStore){ + Label("Theme Store (alpha)", systemImage: "giftcard.fill") + } + } header: { + Text("Themeing") + } footer: { + Text("This is a special menu because in Winston you can change 90% of what you see. Enjoy the theming system!") } .themedListDividers() diff --git a/winston/views/Settings/views/BehaviorPanel.swift b/winston/views/Settings/views/BehaviorPanel.swift index f45e7fe1..4e614eb5 100644 --- a/winston/views/Settings/views/BehaviorPanel.swift +++ b/winston/views/Settings/views/BehaviorPanel.swift @@ -7,12 +7,15 @@ import SwiftUI import Defaults +import VisionKit struct BehaviorPanel: View { @Default(.maxPostLinkImageHeightPercentage) var maxPostLinkImageHeightPercentage @Default(.openYoutubeApp) var openYoutubeApp @Default(.preferenceDefaultFeed) var preferenceDefaultFeed + @Default(.useAuth) var useAuth @Default(.preferredSort) var preferredSort + @Default(.preferredSearchSort) var preferredSearchSort @Default(.preferredCommentSort) var preferredCommentSort @Default(.blurPostLinkNSFW) var blurPostLinkNSFW @Default(.blurPostNSFW) var blurPostNSFW @@ -25,29 +28,54 @@ struct BehaviorPanel: View { @Default(.lightboxViewsPost) private var lightboxViewsPost @Default(.openLinksInSafari) private var openLinksInSafari @Default(.feedPostsLoadLimit) private var feedPostsLoadLimit + @Default(.perSubredditSort) private var perSubredditSort + @Default(.perPostSort) private var perPostSort + @Default(.doLiveText) var doLiveText + @Environment(\.useTheme) private var theme - + @State private var imageAnalyzerSupport: Bool = true var body: some View { List { Section("General") { Group { - Toggle("Open links in safari", isOn: $openLinksInSafari) - Toggle("Open Youtube Videos Externally", isOn: $openYoutubeApp) - + Toggle("Open links in Safari", isOn: $openLinksInSafari) + Toggle("Open Youtube Videos Externally", isOn: $openYoutubeApp) + let auth_type = Biometrics().biometricType() + Toggle("Lock Winston With \(auth_type)", isOn: $useAuth) + + VStack{ + Toggle("Live Text Analyzer", isOn: $doLiveText) + .disabled(!imageAnalyzerSupport) + .onAppear{ + imageAnalyzerSupport = ImageAnalyzer.isSupported + if !ImageAnalyzer.isSupported { + doLiveText = false + } + } + + + if !imageAnalyzerSupport{ + HStack{ + Text("Your iPhone does not support Live Text :(") + .fontSize(12) + .opacity(0.5) + Spacer() + } + + } + } Picker("Default Launch Feed", selection: $preferenceDefaultFeed) { Text("Home").tag("home") Text("Popular").tag("popular") Text("All").tag("all") - Text("Subscription List").tag("subList") } .pickerStyle(DefaultPickerStyle()) } .themedListRowBG(enablePadding: true) - - + WSNavigationLink(SettingsPages.filteredSubreddits, "Filtered Subreddits") } .themedListDividers() @@ -77,7 +105,9 @@ struct BehaviorPanel: View { Toggle("Read on scroll", isOn: $readPostOnScroll) Toggle("Hide read posts", isOn: $hideReadPosts) Toggle("Blur NSFW in opened posts", isOn: $blurPostNSFW) - Toggle("Blur NSFW in posts links", isOn: $blurPostLinkNSFW) + Toggle("Blur NSFW", isOn: $blurPostLinkNSFW) + Toggle("Save sort per subreddit", isOn: $perSubredditSort) + Toggle("Save comment sort per post", isOn: $perPostSort) Menu { ForEach(SubListingSortOption.allCases) { opt in if case .top(_) = opt { @@ -118,6 +148,47 @@ struct BehaviorPanel: View { .foregroundColor(.primary) } } + + Menu { + ForEach(SubListingSortOption.allCases) { opt in + if case .top(_) = opt { + Menu { + ForEach(SubListingSortOption.TopListingSortOption.allCases, id: \.self) { topOpt in + Button { + preferredSearchSort = .top(topOpt) + } label: { + HStack { + Text(topOpt.rawValue.capitalized) + Spacer() + Image(systemName: topOpt.icon) + } + } + } + } label: { + Label(opt.rawVal.value.capitalized, systemImage: opt.rawVal.icon) + } + } else { + Button { + preferredSearchSort = opt + } label: { + HStack { + Text(opt.rawVal.value.capitalized) + Spacer() + Image(systemName: opt.rawVal.icon) + } + } + } + } + } label: { + Button { } label: { + HStack { + Text("Default search sorting") + Spacer() + Image(systemName: preferredSearchSort.rawVal.icon) + } + .foregroundColor(.primary) + } + } VStack(alignment: .leading) { HStack { diff --git a/winston/views/Settings/views/FAQPanel.swift b/winston/views/Settings/views/FAQPanel.swift index 75d5f484..a47d45e6 100644 --- a/winston/views/Settings/views/FAQPanel.swift +++ b/winston/views/Settings/views/FAQPanel.swift @@ -13,9 +13,9 @@ struct FAQPanel: View { VStack{ List{ QuestionAnswer(question: "What does the Box Icon do?", answer: "Save posts in the Posts Box to be read later. These will live in Winston and wont be synced to Reddit.", systemImage: "shippingbox") - QuestionAnswer(question: "Whats Winston Anywhere?", answer: "Winston Anywhere is a Safari extension, that autmatically redirects Reddit links to Winston.", systemImage: "safari") + QuestionAnswer(question: "What's Winston Everywhere?", answer: "Winston Everywhere is a Safari extension, that autmatically redirects Reddit links to Winston.", systemImage: "safari") QuestionAnswer(question: "Is Winston against Reddit's TOS?", answer: "Actually **not**, even though Reddit doesn't like it, accordingly to the TOS, the API limits are only applicable when there are profit involved. Winston is open source and free, and it works just like any bot in the internet: by allowing you to use your own API key the way you like it, the way it was supposed to be.", systemImage: "safari") - QuestionAnswer(question: "Will Winston be released in the App Store at some point?", answer: "Yes. Winston is planned to be released in the App Store soon, still allowing users to use their own API key.", systemImage: "safari") + QuestionAnswer(question: "Will Winston ever be released in the App Store at some point?", answer: "Yes. Winston is planned to be released in the App Store soon, still allowing users to use their own API key.", systemImage: "safari") QuestionAnswer(question: "What if Reddit takes Winston down?", answer: "Then we'll release another version which uses our own single API key (it won't require any of you to enter your own anymore) and allow you to recharge your account and use it however you like. That's what Reddit wants at the end, but our bet is that Reddit won't find a way to take Winston down because the previously mentioned similarity with a bot in the technical manners.", systemImage: "safari") QuestionAnswer(question: "Who are you?", answer: "We're lo.cafe, a group of friends (Igor (me), Ernesto, LaĆ­s, Oeste (teenager cat) and Bidu(old cat)) that produces amazing software together. We made lo-rain, an app that makes it rain over your desktop on MacOS, we're making a game and many other crazy stuff. [Check our website!](https://lo.cafe)", systemImage: "safari") } diff --git a/winston/views/Settings/views/GeneralPanel.swift b/winston/views/Settings/views/GeneralPanel.swift index ceb6b933..4044d6d6 100644 --- a/winston/views/Settings/views/GeneralPanel.swift +++ b/winston/views/Settings/views/GeneralPanel.swift @@ -8,14 +8,51 @@ import SwiftUI import Defaults import WebKit +import UniformTypeIdentifiers struct GeneralPanel: View { @Default(.likedButNotSubbed) var likedButNotSubbed @State private var totalCacheSize: String = "" @Environment(\.useTheme) private var theme - + @State var isMoving: Bool = false + @State var settingsFileURL: String = "" + @State var doImport: Bool = false var body: some View { List{ + Section("Backup"){ + Button{ + let date = Date() + let file = exportUserDefaultsToJSON(fileName: "WinstonSettings-" + date.ISO8601Format() + ".json") + if let file { + isMoving.toggle() + settingsFileURL = file + } + } label: { + Label("Export Settings", systemImage: "arrowshape.turn.up.left") + } + .fileMover(isPresented: $isMoving, file: URL(string: settingsFileURL), onCompletion: { completion in + print(completion) + }) + Button{ + doImport.toggle() + } label: { + Label("Import Settings", systemImage: "square.and.arrow.down") + }.fileImporter(isPresented: $doImport, allowedContentTypes: [UTType.json], allowsMultipleSelection: false, onCompletion: { result in + switch result { + case .success(let file): + let success = importUserDefaultsFromJSON(jsonFilePath: file[0]) + if success { + print("success") + } else { + print("error") + } + case .failure(let error): + print(error.localizedDescription) + } + + }) + } + Section("Advanced") { WListButton { diff --git a/winston/views/Settings/views/theming/ThemeStore.swift b/winston/views/Settings/views/theming/ThemeStore.swift new file mode 100644 index 00000000..0f2a837f --- /dev/null +++ b/winston/views/Settings/views/theming/ThemeStore.swift @@ -0,0 +1,194 @@ +// +// ThemeStore.swift +// winston +// +// Created by Daniel Inama on 26/09/23. +// + +import SwiftUI +import Defaults +import FileProvider + +struct ThemeStore: View { + @EnvironmentObject var themeStore: ThemeStoreAPI + @State var themes: [ThemeData] = [] + @State private var isRefreshing = false // Track the refreshing state + @State private var isPresentingUploadSheet = false + @StateObject var searchQuery = DebouncedText(delay: 0.35) + @Environment(\.useTheme) private var theme + var body: some View { + HStack{ + List { + if !themes.isEmpty { + Section{ + ForEach(themes, id: \.self) { theme in + NavigationLink(destination: ThemeStoreDetailsView(themeData: theme), label: { + OnlineThemeItem(theme: theme) + }) + .themedListRowBG(enablePadding: true) + + } + } + .themedListDividers() + } else { + ProgressView() + } + } + .themedListBG(theme.lists.bg) + .searchable(text: $searchQuery.text) + .onChange(of: searchQuery.debounced) { val in + Task{ + if val == "" { + await fetchThemes() + } else { + themes = await themeStore.fetchThemesByName(name: val) ?? [] + } + } + } + .refreshable { + await fetchThemes() + } + .onAppear{ + Task{ + await fetchThemes() + } + } + .navigationTitle("Theme Store") + .navigationBarTitleDisplayMode(.large) + } + + .sheet(isPresented: $isPresentingUploadSheet){ + ThemeStoreUploadSheet() + } + + } + + private func fetchThemes() async { + isRefreshing = true // Start refreshing animation + themes = await themeStore.fetchAllThemes() ?? [] + isRefreshing = false // Stop refreshing animation + + } +} + +struct OnlineThemeItem: View { + var theme: ThemeData + var accentColor: Color = .blue + @Environment(\.openURL) private var openURL + + + var showShareButton: Bool = false + + var body: some View { + HStack(spacing: 8){ + Group { + Image(systemName: theme.icon ?? "xmark") + .fontSize(24) + .foregroundColor(.white) + } + .frame(width: 52, height: 52) + .background(RR(16, theme.color?.color() ?? .blue)) + VStack(alignment: .leading, spacing: 0) { + Text(theme.theme_name) + .fontSize(16, .semibold) + .frame(maxWidth: 200) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: true) + Text("\(theme.theme_author ?? "")") + .fontSize(14, .medium) + .opacity(0.75) + .frame(maxWidth: 200) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: true) + } + Spacer() + ThemeItemDownloadButton(theme: theme) + .accentColor(accentColor) + if showShareButton { + ThemeItemShareButton(theme: theme) + .accentColor(accentColor) + } + } + + } + +} + + +struct ThemeItemShareButton: View { + var theme: ThemeData + var body: some View { + ShareLink(item: URL(string: "winstonapp://theme/\(theme.file_id ?? "")")!) + .labelStyle(.iconOnly) + } +} + + +struct ThemeItemDownloadButton: View { + var theme: ThemeData + @State var downloading: Bool = false + @State var showingImportError: Bool = false + @Default(.themesPresets) private var themesPresets + @EnvironmentObject var themeStore: ThemeStoreAPI + @Environment(\.useTheme) private var themeTheme + // Computed property to check if themesPresets contains the current theme + private var isThemeInPresets: Bool { + themesPresets.contains { $0.id == theme.file_id } + } + var body: some View { + if isThemeInPresets{ + Button { + //Delete the Theme + themesPresets = themesPresets.filter { $0.id != theme.file_id} + } label: { + Label("Delete", systemImage: "trash") + .labelStyle(.iconOnly) + .foregroundColor(.red) + }.highPriorityGesture( + TapGesture() + .onEnded{ + //Delete the Theme + themesPresets = themesPresets.filter { $0.id != theme.file_id} + } + ) + } else { + if downloading { + ProgressView() + } else { + Button{ + downloadTheme() + } label: { + Label("Download", systemImage: "arrow.down.to.line") + .labelStyle(.iconOnly) + + } + .highPriorityGesture( //Is this really the solution for it working inside a NavigationLink?? + TapGesture() + .onEnded{ + downloadTheme() + } + ) + .alert(isPresented: $showingImportError){ + Alert(title: Text("There was an error importing this theme")) + } + } + + } + + } + + func downloadTheme(){ + downloading = true + Task { + if let theme_id = theme.file_name { + themeStore.getDownloadedFilePath(filename: theme_id, completion: { path in + if let path{ + print(path) + showingImportError = !importTheme(at: path) + } + downloading = false + }) + } + } + } +} diff --git a/winston/views/Settings/views/theming/ThemeStoreDetailsView.swift b/winston/views/Settings/views/theming/ThemeStoreDetailsView.swift new file mode 100644 index 00000000..2c034cff --- /dev/null +++ b/winston/views/Settings/views/theming/ThemeStoreDetailsView.swift @@ -0,0 +1,101 @@ +// +// ThemeStoreDetailsView.swift +// winston +// +// Created by Daniel Inama on 26/09/23. +// + +import SwiftUI +import Defaults + +struct ThemeStoreDetailsView: View { + let themeData: ThemeData + @EnvironmentObject var themeStore: ThemeStoreAPI + @StateObject var viewModel = AppDetailViewObject() + @Environment(\.useTheme) private var theme + @Default(.themeStoreTint) var themeStoreTint + var body: some View { + FittingScrollView{ + VStack(spacing: 0){ + OnlineThemeItem(theme: themeData, accentColor: Color(uiColor: UIColor(hex: themeData.color!.hex)), showShareButton: true) + .padding() + Divider() + .padding(.horizontal) + + AppDetailScreenshots(screenshots: viewModel.previews) + .shadow(radius: 1) + .padding() + + Divider() + .padding(.horizontal) + + AppDetailDescription(text: themeData.theme_description ?? "Uh oh! Someone was lazy and didn't add a description :(") + .padding() + + Divider() + .padding(.horizontal) + + AppDetailInfoFullView( + author: themeData.theme_author, themeId: themeData.file_id, themeName: themeData.theme_name + ) + .padding() + + } + } onOffsetChange: { + viewModel.scrollOffset = $0 + } + .animation(.default, value: viewModel.hasScrolledPastNavigationBar) + .toolbarBackground(.visible, for: .tabBar) + .navigationBarTitleDisplayMode(.inline) + .onAppear{ + Task{ + let urls = await themeStore.getPreviewImages(id: themeData.file_id ?? "") + viewModel.previews = urls?.previews ?? [] + viewModel.accent = Color(themeData.color?.hex ?? "#0000FF") + } + } + .toolbar{ + if viewModel.hasScrolledPastNavigationBar { + ToolbarItem(placement: .principal){ + Group { + Image(systemName: themeData.icon ?? "xmark") + .fontSize(12) + .foregroundColor(.white) + } + .frame(width: 32, height: 32) + .background(RR(8, themeData.color?.color() ?? .blue)) + } + + ToolbarItem(placement: .navigationBarTrailing){ + ThemeItemDownloadButton(theme: themeData) + } + } + } + .if(themeStoreTint){ view in + view.background{ + LinearGradient(gradient: Gradient(colors: [Color(uiColor: UIColor(hex: themeData.color!.hex)).opacity(0.3), Color(UIColor.systemBackground)]), startPoint: .topLeading, endPoint: .bottomTrailing) + .ignoresSafeArea(.all) + } + } + .if(!themeStoreTint){ view in + view.themedListBG(theme.lists.bg) + } + .tint(Color(uiColor: UIColor(hex: themeData.color!.hex))) + .accentColor(Color(uiColor: UIColor(hex: themeData.color!.hex))) + } +} + +extension ThemeStoreDetailsView { + @MainActor class AppDetailViewObject: ObservableObject { + @Published var accent: Color = .blue + @Published var hasScrolledPastNavigationBar: Bool = false + @Published var previews: [String] = [] + @Published var scrollOffset: CGFloat = .zero { + didSet { + hasScrolledPastNavigationBar = scrollOffset < -60 + } + } + } + +} + diff --git a/winston/views/Settings/views/theming/ThemeStoreUploadSheet.swift b/winston/views/Settings/views/theming/ThemeStoreUploadSheet.swift new file mode 100644 index 00000000..4d87b3f0 --- /dev/null +++ b/winston/views/Settings/views/theming/ThemeStoreUploadSheet.swift @@ -0,0 +1,103 @@ +// +// ThemeStoreUploadSheet.swift +// winston +// +// Created by Daniel Inama on 26/09/23. +// + +import SwiftUI +import Defaults + +struct ThemeStoreUploadSheet: View { + @Default(.themesPresets) private var themesPresets + @Environment(\.presentationMode) var presentationMode + var body: some View { + NavigationView { + if themesPresets.isEmpty { + VStack { + HStack { + Text("You don't have any local themes") + } + } + } else { + List { + ForEach(themesPresets, id: \.self) { theme in + LocalUploadThemeItem(theme: theme) + } + } + + + } + } + } +} + + +struct LocalUploadThemeItem: View { + var theme: WinstonTheme + @State var upload_state: String = "local" + @EnvironmentObject var themeStore: ThemeStoreAPI + @State var uploading: Bool = false + @State var uploadError: Bool = false + @State var uploadErrorMessage: String = "" + var body: some View { + HStack(spacing: 8){ + Group { + Image(systemName: theme.metadata.icon) + .fontSize(24) + .foregroundColor(.white) + } + .frame(width: 52, height: 52) + .background(RR(16, theme.metadata.color.color())) + VStack(alignment: .leading, spacing: 0) { + Text(theme.metadata.name) + .fontSize(16, .semibold) + .fixedSize(horizontal: true, vertical: false) + HStack{ + Text("Status:") + .multilineTextAlignment(.trailing) + + Text("\(upload_state.localizedCapitalized)") + .foregroundColor(upload_state == "accepted" ? .green : upload_state == "denied" ? .red : .primary) + .multilineTextAlignment(.leading) + Spacer() + } + .fontSize(14, .medium) + .opacity(0.75) + .fixedSize(horizontal: true, vertical: false) + + } + Spacer() + + if upload_state == "local" { + Button{ + uploading = true + Task{ + let uploadresponse = await themeStore.uploadTheme(theme: theme) + if uploadresponse?.message != "File uploaded successfully" { + uploadErrorMessage = uploadresponse?.message ?? "" + uploadError.toggle() + } else { + upload_state = "waiting for approval" + } + + } + uploading = false + } label: { + Label("Upload Theme", systemImage: "arrow.up.to.line") + .labelStyle(.iconOnly) + } + } + + } + .alert(isPresented: $uploadError){ + Alert(title: Text("Upload Error"), message: Text(uploadErrorMessage)) + } + .onAppear{ + Task{ + upload_state = await themeStore.fetchThemeStatus(id: theme.id)?.status ?? "local" + } + } + } +} + diff --git a/winston/views/Settings/views/theming/ThemesPanel.swift b/winston/views/Settings/views/theming/ThemesPanel.swift index 16206522..80d8cb62 100644 --- a/winston/views/Settings/views/theming/ThemesPanel.swift +++ b/winston/views/Settings/views/theming/ThemesPanel.swift @@ -182,14 +182,14 @@ struct ThemeNavLink: View { Text("This theme changes a few settings that requires an app restart to take effect.") } } - - func createZipFile(with imgNames: [String], theme: WinstonTheme, completion: @escaping(_ url: URL?) -> Void) { - do { - let zipURL = try createZip(images: imgNames, theme: theme) - completion(zipURL) - } catch { - print("Failed to create zip file with error \(error.localizedDescription)") - completion(nil) - } +} + +func createZipFile(with imgNames: [String], theme: WinstonTheme, completion: @escaping(_ url: URL?) -> Void) { + do { + let zipURL = try createZip(images: imgNames, theme: theme) + completion(zipURL) + } catch { + print("Failed to create zip file with error \(error.localizedDescription)") + completion(nil) } } diff --git a/winston/views/Settings/views/theming/editTypes/CommentsThemingPanel.swift b/winston/views/Settings/views/theming/editTypes/CommentsThemingPanel.swift index a4969e68..34567b3d 100644 --- a/winston/views/Settings/views/theming/editTypes/CommentsThemingPanel.swift +++ b/winston/views/Settings/views/theming/editTypes/CommentsThemingPanel.swift @@ -35,6 +35,9 @@ struct CommentsGeneralSettings: View { Divider() LabeledSlider(label: "Body/author spacing", value: $theme.comments.theme.bodyAuthorSpacing, range: 0...64) .resetter($theme.comments.theme.bodyAuthorSpacing, defaultTheme.comments.theme.bodyAuthorSpacing) + Divider() + LabeledSlider(label: "Line spacing", value: $theme.comments.theme.linespacing, range: 0...64) + .resetter($theme.comments.theme.linespacing, defaultTheme.comments.theme.linespacing) } } @@ -58,6 +61,29 @@ struct CommentsGeneralSettings: View { FakeSection("Comments divider") { LineThemeEditor(theme: $theme.comments.divider, defaultVal: defaultTheme.comments.divider) } + + FakeSection("Load More spacing") { + LabeledSlider(label: "Inner horizontal padding", value: $theme.comments.theme.loadMoreInnerPadding.horizontal, range: 0...64) + .resetter($theme.comments.theme.innerPadding.horizontal, defaultTheme.comments.theme.loadMoreInnerPadding.horizontal) + Divider() + LabeledSlider(label: "Inner vertical padding", value: $theme.comments.theme.loadMoreInnerPadding.vertical, range: 0...64) + .resetter($theme.comments.theme.innerPadding.vertical, defaultTheme.comments.theme.loadMoreInnerPadding.vertical) + Divider() + LabeledSlider(label: "Outer top padding", value: $theme.comments.theme.loadMoreOuterTopPadding, range: 0...64) + .resetter($theme.comments.theme.outerHPadding, defaultTheme.comments.theme.loadMoreOuterTopPadding) + } + + FakeSection("Load More text") { + FontSelector(theme: $theme.comments.theme.loadMoreText, defaultVal: defaultTheme.comments.theme.loadMoreText) + } + + FakeSection("Load More background") { + SchemesColorPicker(theme: $theme.comments.theme.loadMoreBackground, defaultVal: defaultTheme.comments.theme.loadMoreBackground) + } + + FakeSection("Unseen Dot") { + SchemesColorPicker(theme: $theme.comments.theme.unseenDot, defaultVal: defaultTheme.comments.theme.unseenDot) + } } } } diff --git a/winston/views/Settings/views/theming/editTypes/post/PostTextsTheming.swift b/winston/views/Settings/views/theming/editTypes/post/PostTextsTheming.swift index 2dc3b187..aaf459b9 100644 --- a/winston/views/Settings/views/theming/editTypes/post/PostTextsTheming.swift +++ b/winston/views/Settings/views/theming/editTypes/post/PostTextsTheming.swift @@ -20,6 +20,10 @@ struct PostTextsTheming: View { FontSelector(theme: $theme.bodyText, defaultVal: defaultTheme.posts.bodyText) } + FakeSection("Line Spacing") { + LabeledSlider(label: "Line spacing", value: $theme.linespacing, range: 0...64) + .resetter($theme.linespacing, defaultTheme.posts.linespacing) + } } } } diff --git a/winston/views/Settings/views/theming/editTypes/postLink/CardSettings.swift b/winston/views/Settings/views/theming/editTypes/postLink/CardSettings.swift index 1b7cfe84..e31c0a6b 100644 --- a/winston/views/Settings/views/theming/editTypes/postLink/CardSettings.swift +++ b/winston/views/Settings/views/theming/editTypes/postLink/CardSettings.swift @@ -59,7 +59,6 @@ struct CardSettings: View { CarouselTagElement(label: "Fade", value: UnseenType.fade, active: theme.unseenType.isEqual(.fade)) ] ) - } .padding(.horizontal, 16) .resetter($theme.unseenType, defaultTheme.postLinks.theme.unseenType) @@ -71,7 +70,8 @@ struct CardSettings: View { theme.unseenType = .dot(val) }), defaultVal: .init(light: .init(hex: "4FFF85"), dark: .init(hex: "4FFF85"))) case .fade: - EmptyView() + LabeledSlider(label: "Fade Opacity", value: $theme.unseenFadeOpacity, range: 0...1, step: 0.01) + .resetter($theme.unseenFadeOpacity, defaultTheme.postLinks.theme.unseenFadeOpacity) } } diff --git a/winston/views/Settings/views/theming/editTypes/postLink/TextsSettings.swift b/winston/views/Settings/views/theming/editTypes/postLink/TextsSettings.swift index bd080f1b..4cb707d4 100644 --- a/winston/views/Settings/views/theming/editTypes/postLink/TextsSettings.swift +++ b/winston/views/Settings/views/theming/editTypes/postLink/TextsSettings.swift @@ -19,7 +19,11 @@ struct TextsSettings: View { FakeSection("Body") { FontSelector(theme: $theme.bodyText, defaultVal: defaultTheme.postLinks.theme.bodyText) } - + + FakeSection("Line Spacing") { + LabeledSlider(label: "Line spacing", value: $theme.linespacing, range: 0...64) + .resetter($theme.linespacing, defaultTheme.postLinks.theme.linespacing) + } } } } diff --git a/winston/views/Settings/views/theming/editing/BadgeSettings.swift b/winston/views/Settings/views/theming/editing/BadgeSettings.swift index 6cde1321..8203bf02 100644 --- a/winston/views/Settings/views/theming/editing/BadgeSettings.swift +++ b/winston/views/Settings/views/theming/editing/BadgeSettings.swift @@ -32,6 +32,14 @@ struct BadgeSettings: View { FakeSection("Author font") { FontSelector(theme: $theme.authorText, defaultVal: defaultVal.authorText) } + + FakeSection("Flair font") { + FontSelector(theme: $theme.flairText, defaultVal: defaultVal.flairText) + } + + FakeSection("Flair Background") { + SchemesColorPicker(theme: $theme.flairBackground, defaultVal: defaultVal.flairBackground) + } FakeSection("Stats font") { FontSelector(theme: $theme.statsText, defaultVal: defaultTheme.posts.badge.statsText) diff --git a/winston/views/SubredditPosts.swift b/winston/views/SubredditPosts.swift index e5d8db8a..fed47b79 100644 --- a/winston/views/SubredditPosts.swift +++ b/winston/views/SubredditPosts.swift @@ -8,7 +8,7 @@ import SwiftUI import Defaults import SwiftUIIntrospect - +import CoreData enum SubViewType: Hashable { case posts(Subreddit) @@ -27,7 +27,7 @@ struct SubredditPosts: View, Equatable { @State private var loadedPosts: Set = [] @State private var lastPostAfter: String? @State private var searchText: String = "" - @State private var sort: SubListingSortOption = Defaults[.preferredSort] + @State private var sort: SubListingSortOption @State private var newPost = false @EnvironmentObject private var routerProxy: RouterProxy @@ -35,6 +35,14 @@ struct SubredditPosts: View, Equatable { @Environment(\.colorScheme) private var cs @Environment(\.contentWidth) private var contentWidth + let context = PersistenceController.shared.container.newBackgroundContext() + let fetchRequest = NSFetchRequest(entityName: "SeenPost") + + init(subreddit: Subreddit) { + self.subreddit = subreddit; + _sort = State(initialValue: Defaults[.perSubredditSort] ? (Defaults[.subredditSorts][subreddit.id] ?? Defaults[.preferredSort]) : Defaults[.preferredSort]); + } + func asyncFetch(force: Bool = false, loadMore: Bool = false, searchText: String? = nil) async { if (subreddit.data == nil || force) && !feedsAndSuch.contains(subreddit.id) { await subreddit.refreshSubreddit() @@ -46,6 +54,8 @@ struct SubredditPosts: View, Equatable { loading = true } if let result = await subreddit.fetchPosts(sort: sort, after: loadMore ? lastPostAfter : nil, searchText: searchText, contentWidth: contentWidth), let newPosts = result.0 { + + await waitForPostsToLoadMediaInfo(posts: newPosts) withAnimation { let newPostsFiltered = newPosts.filter { !loadedPosts.contains($0.id) && !filteredSubreddits.contains($0.data?.subreddit ?? "") } @@ -68,6 +78,12 @@ struct SubredditPosts: View, Equatable { } } + func waitForPostsToLoadMediaInfo(posts: [Post]) async { + for i in 0...posts.count - 1 { + await posts[i].waitForMediaToLoad() + } + } + func fetch(_ loadMore: Bool = false, _ searchText: String? = nil) { Task(priority: .background) { await asyncFetch(loadMore: loadMore, searchText: searchText) @@ -87,7 +103,7 @@ struct SubredditPosts: View, Equatable { } } - + var body: some View { Group { if IPAD { @@ -160,6 +176,7 @@ struct SubredditPostsNavBtns: View, Equatable { ForEach(SubListingSortOption.TopListingSortOption.allCases, id: \.self) { topOpt in Button { sort = .top(topOpt) + Defaults[.subredditSorts][subreddit.id] = .top(topOpt) } label: { HStack { Text(topOpt.rawValue.capitalized) @@ -174,10 +191,14 @@ struct SubredditPostsNavBtns: View, Equatable { Label(opt.rawVal.value.capitalized, systemImage: opt.rawVal.icon) .foregroundColor(Color.accentColor) .font(.system(size: 17, weight: .bold)) + .onAppear{ + print(opt.rawVal) + } } } else { Button { sort = opt + Defaults[.subredditSorts][subreddit.id] = opt } label: { HStack { Text(opt.rawVal.value.capitalized) diff --git a/winston/views/Subreddits/Subreddits.swift b/winston/views/Subreddits/Subreddits.swift index 765dc9a1..b3c66770 100644 --- a/winston/views/Subreddits/Subreddits.swift +++ b/winston/views/Subreddits/Subreddits.swift @@ -106,8 +106,13 @@ struct Subreddits: View, Equatable { SubItem(forcedMaskType: CommentBGSide.getFromArray(count: favs.count, i: i), selectedSub: $selectedSub, sub: Subreddit(data: SubredditData(entity: cachedSub), api: RedditAPI.shared), cachedSub: cachedSub) // .equatable() .id("\(cachedSub.uuid ?? "")-fav") + .onAppear{ + print("Adding" + cachedSub.display_name) + UIApplication.shared.shortcutItems?.append(UIApplicationShortcutItem(type: "subFav", localizedTitle: cachedSub.display_name ?? "Test", localizedSubtitle: "", icon: UIApplicationShortcutIcon(type: .love), userInfo: ["name" : "sub" as NSSecureCoding])) + } } .onDelete(perform: deleteFromFavorites) + } } diff --git a/winston/winston.entitlements b/winston/winston.entitlements index c5f2c7d0..0c67376e 100644 --- a/winston/winston.entitlements +++ b/winston/winston.entitlements @@ -1,10 +1,5 @@ - - com.apple.developer.associated-domains - - applinks:app.winston.lo.cafe - - + diff --git a/winston/winston.xcdatamodeld/winston.xcdatamodel/contents b/winston/winston.xcdatamodeld/winston.xcdatamodel/contents index dcea161a..37bfeeab 100644 --- a/winston/winston.xcdatamodeld/winston.xcdatamodel/contents +++ b/winston/winston.xcdatamodeld/winston.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -28,7 +28,7 @@ - + @@ -67,6 +67,8 @@ + + \ No newline at end of file diff --git a/winston/winstonApp.swift b/winston/winstonApp.swift index 851883d8..6fe4123f 100644 --- a/winston/winstonApp.swift +++ b/winston/winstonApp.swift @@ -8,37 +8,125 @@ import SwiftUI import AlertToast import Defaults +import WhatsNewKit +var shortcutItemToProcess: UIApplicationShortcutItem? @main struct winstonApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate let persistenceController = PersistenceController.shared + @Environment(\.scenePhase) var phase + @State private var activeTab: TabIdentifier = .posts + var body: some Scene { WindowGroup { - AppContent() + AppContent(activeTab: activeTab) + .environment(\.managedObjectContext, persistenceController.container.viewContext) + .onAppear{ + print(getCurrentChangelog()) + } + .environment( + \.whatsNew, + WhatsNewEnvironment(currentVersion: .current(), whatsNewCollection: getCurrentChangelog()) + ) + } + .onChange(of: phase) { (newPhase) in + switch newPhase { + case .active : + guard let name = shortcutItemToProcess?.userInfo?["name"] as? String else { + return + } + switch name { + case "saved": + print("saved is selected") + case "search": + print("search is selected") + activeTab = .search // Set the active tab to "Search" + default: + print("default " + name) + } + case .inactive: + // inactive + break + case .background: + addQuickActions() + @unknown default: + print("default") + } } } + + func addQuickActions() { + @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)], animation: .default) var subreddits: FetchedResults + + var searchUserInfo: [String: NSSecureCoding] { + return ["name" : "search" as NSSecureCoding] + } + var savedInfo: [String: NSSecureCoding] { + return ["name" : "saved" as NSSecureCoding] + } + var statususerInfo: [String: NSSecureCoding] { + return ["name" : "status" as NSSecureCoding] + } + var contactuserInfo: [String: NSSecureCoding] { + return ["name" : "contact" as NSSecureCoding] + } + + UIApplication.shared.shortcutItems = [ + UIApplicationShortcutItem(type: "Search", localizedTitle: "Search", localizedSubtitle: "Search a Subreddit", icon: UIApplicationShortcutIcon(type: .search), userInfo: searchUserInfo), + UIApplicationShortcutItem(type: "Saved", localizedTitle: "Saved", localizedSubtitle: "", icon: UIApplicationShortcutIcon(type: .bookmark), userInfo: savedInfo), + ] + + } } struct AppContent: View { @ObservedObject private var redditAPI = RedditAPI.shared + @StateObject private var themeStore = ThemeStoreAPI() @Default(.themesPresets) private var themesPresets @Default(.selectedThemeID) private var selectedThemeID @Environment(\.colorScheme) private var cs + @Environment(\.scenePhase) var scenePhase + @State var activeTab: TabIdentifier + + let biometrics = Biometrics() + @State private var isAuthenticating = false + @State private var lockBlur = UserDefaults.standard.bool(forKey: "useAuth") ? 50 : 0 // Set initial startup blur var selectedThemeRaw: WinstonTheme? { themesPresets.first { $0.id == selectedThemeID } } var body: some View { let selectedTheme = selectedThemeRaw ?? defaultTheme - Tabber(theme: selectedTheme, cs: cs) + Tabber(theme: selectedTheme, cs: cs, activeTab: activeTab) + .whatsNewSheet() .onAppear { themesPresets = themesPresets.filter { $0.id != "default" } if selectedThemeRaw == nil { selectedThemeID = "default" } } .environment(\.useTheme, selectedTheme) + .environmentObject(themeStore) // .alertToastRoot() // .tint(selectedTheme.general.accentColor.cs(cs).color()) + .onChange(of: scenePhase) { newPhase in + let useAuth = UserDefaults.standard.bool(forKey: "useAuth") // Get fresh value + + if (useAuth && !isAuthenticating) { + if (newPhase == .active && lockBlur == 50){ + // Not authing, active and blur visible = Need to auth + isAuthenticating = true + biometrics.authenticateUser { success in + if success { + lockBlur = 0 + } + } + isAuthenticating = false + } + else if (newPhase != .active) { + lockBlur = 50 + } + } + }.blur(radius: CGFloat(lockBlur)) // Set lockscreen blur } } @@ -60,3 +148,4 @@ extension EnvironmentValues { set { self[CurrentThemeKey.self] = newValue } } } +