commit bf77e2c4c1584718c66e1fb980ecaae31ba91195 Author: Lumaa Date: Fri Dec 29 11:17:37 2023 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b2c072 --- /dev/null +++ b/.gitignore @@ -0,0 +1,91 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ +.DS_Store \ No newline at end of file diff --git a/AuthService/AuthenticationViewController.swift b/AuthService/AuthenticationViewController.swift new file mode 100644 index 0000000..86376e9 --- /dev/null +++ b/AuthService/AuthenticationViewController.swift @@ -0,0 +1,36 @@ +//Made by Lumaa + +import UIKit +import AuthenticationServices + +class AuthenticationViewController: UIViewController { + + var authorizationRequest: ASAuthorizationProviderExtensionAuthorizationRequest? + + override func loadView() { + super.loadView() + // Do any additional setup after loading the view. + } + + override var nibName: String? { + return "AuthenticationViewController" + } +} + +extension AuthenticationViewController: ASAuthorizationProviderExtensionAuthorizationRequestHandler { + + public func beginAuthorization(with request: ASAuthorizationProviderExtensionAuthorizationRequest) { + self.authorizationRequest = request + + // Call this to indicate immediate authorization succeeded. + let authorizationHeaders = [String: String]() // TODO: Fill in appropriate authorization headers. + request.complete(httpAuthorizationHeaders: authorizationHeaders) + + // Or present authorization view and call self.authorizationRequest.complete() later after handling interactive authorization. + // request.presentAuthorizationViewController(completion: { (success, error) in + // if error != nil { + // request.complete(error: error!) + // } + // }) + } +} diff --git a/AuthService/Base.lproj/AuthenticationViewController.xib b/AuthService/Base.lproj/AuthenticationViewController.xib new file mode 100644 index 0000000..dd4c042 --- /dev/null +++ b/AuthService/Base.lproj/AuthenticationViewController.xib @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/AuthService/Info.plist b/AuthService/Info.plist new file mode 100644 index 0000000..23103cb --- /dev/null +++ b/AuthService/Info.plist @@ -0,0 +1,15 @@ + + + + + NSExtension + + NSExtensionAttributes + + NSExtensionPointIdentifier + com.apple.AppSSO.idp-extension + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).AuthenticationViewController + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2b584c8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Lumaa + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Threaded.xcodeproj/project.pbxproj b/Threaded.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c6808c5 --- /dev/null +++ b/Threaded.xcodeproj/project.pbxproj @@ -0,0 +1,739 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + B97BCE242B3DD8400044756D /* HapticManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97BCE232B3DD8400044756D /* HapticManager.swift */; }; + B97BCE262B3DE5A10044756D /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97BCE252B3DE5A10044756D /* AccountView.swift */; }; + B97BCE282B3ED2A80044756D /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = B97BCE272B3ED2A80044756D /* .gitignore */; }; + B97BCE2A2B3ED2C80044756D /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = B97BCE292B3ED2C80044756D /* LICENSE */; }; + B9842C0E2B2F21B700D9F3C1 /* CompactPostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9842C0D2B2F21B700D9F3C1 /* CompactPostView.swift */; }; + B9842C102B2F228C00D9F3C1 /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9842C0F2B2F228C00D9F3C1 /* Status.swift */; }; + B9842C122B2F2A5800D9F3C1 /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9842C112B2F2A5800D9F3C1 /* TimelineView.swift */; }; + B9842C142B2F310C00D9F3C1 /* FetchTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9842C132B2F310C00D9F3C1 /* FetchTimeline.swift */; }; + B9842C162B2F363600D9F3C1 /* TimelineFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9842C152B2F363600D9F3C1 /* TimelineFilter.swift */; }; + B9842C182B2F36F500D9F3C1 /* AccountsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9842C172B2F36F500D9F3C1 /* AccountsList.swift */; }; + B9FB945B2B2DEECE00D81C07 /* ThreadedApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB945A2B2DEECE00D81C07 /* ThreadedApp.swift */; }; + B9FB945D2B2DEECE00D81C07 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB945C2B2DEECE00D81C07 /* ContentView.swift */; }; + B9FB94612B2DEECF00D81C07 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B9FB94602B2DEECF00D81C07 /* Assets.xcassets */; }; + B9FB94642B2DEECF00D81C07 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B9FB94632B2DEECF00D81C07 /* Preview Assets.xcassets */; }; + B9FB94702B2DF3CD00D81C07 /* Navigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB946F2B2DF3CD00D81C07 /* Navigator.swift */; }; + B9FB94722B2DF49700D81C07 /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB94712B2DF49700D81C07 /* ConnectView.swift */; }; + B9FB94742B2DF6A100D81C07 /* ButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB94732B2DF6A100D81C07 /* ButtonStyles.swift */; }; + B9FB94762B2E023D00D81C07 /* TabsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB94752B2E023D00D81C07 /* TabsView.swift */; }; + B9FB947D2B2E19E300D81C07 /* AccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB947C2B2E19E300D81C07 /* AccountManager.swift */; }; + B9FB947F2B2E1D5F00D81C07 /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB947E2B2E1D5F00D81C07 /* Account.swift */; }; + B9FB94812B2E1FEF00D81C07 /* HTMLString.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB94802B2E1FEF00D81C07 /* HTMLString.swift */; }; + B9FB94842B2E20AF00D81C07 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = B9FB94832B2E20AF00D81C07 /* SwiftSoup */; }; + B9FB94862B2E211200D81C07 /* Account+Elms.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB94852B2E211200D81C07 /* Account+Elms.swift */; }; + B9FB94882B2E223E00D81C07 /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB94872B2E223E00D81C07 /* Emoji.swift */; }; + B9FB948A2B2E227000D81C07 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB94892B2E227000D81C07 /* ProfileView.swift */; }; + B9FB948C2B2E232300D81C07 /* OnlineImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB948B2B2E232300D81C07 /* OnlineImage.swift */; }; + B9FB948E2B2E28E800D81C07 /* ShareableImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB948D2B2E28E800D81C07 /* ShareableImage.swift */; }; + B9FB94902B2E2B0E00D81C07 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B9FB948F2B2E2B0E00D81C07 /* Localizable.xcstrings */; }; + B9FB94922B2E35D000D81C07 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB94912B2E35D000D81C07 /* SettingsView.swift */; }; + B9FB94972B2EDABF00D81C07 /* PrivacyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB94962B2EDABF00D81C07 /* PrivacyView.swift */; }; + B9FB94992B2EEB9400D81C07 /* AddInstanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB94982B2EEB9400D81C07 /* AddInstanceView.swift */; }; + B9FB949B2B2EF09A00D81C07 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB949A2B2EF09A00D81C07 /* Client.swift */; }; + B9FB949D2B2EF0D600D81C07 /* Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB949C2B2EF0D600D81C07 /* Instance.swift */; }; + B9FB949F2B2EF0F200D81C07 /* MastodonRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB949E2B2EF0F200D81C07 /* MastodonRequest.swift */; }; + B9FB94A22B2EF24A00D81C07 /* AppInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB94A12B2EF24A00D81C07 /* AppInfo.swift */; }; + B9FB94AA2B2F009F00D81C07 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B9FB94A92B2F009F00D81C07 /* AuthenticationServices.framework */; }; + B9FB94AD2B2F009F00D81C07 /* AuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB94AC2B2F009F00D81C07 /* AuthenticationViewController.swift */; }; + B9FB94B02B2F009F00D81C07 /* AuthenticationViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = B9FB94AE2B2F009F00D81C07 /* AuthenticationViewController.xib */; }; + B9FB94B42B2F009F00D81C07 /* ThreadedAuthService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B9FB94A72B2F009F00D81C07 /* ThreadedAuthService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + B9FB94BC2B2F035500D81C07 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB94BB2B2F035500D81C07 /* Tag.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + B9FB94B22B2F009F00D81C07 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B9FB944F2B2DEECE00D81C07 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B9FB94A62B2F009F00D81C07; + remoteInfo = AuthService; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + B9FB94B82B2F009F00D81C07 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + B9FB94B42B2F009F00D81C07 /* ThreadedAuthService.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + B97BCE232B3DD8400044756D /* HapticManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticManager.swift; sourceTree = ""; }; + B97BCE252B3DE5A10044756D /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = ""; }; + B97BCE272B3ED2A80044756D /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; + B97BCE292B3ED2C80044756D /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; + B9842C0D2B2F21B700D9F3C1 /* CompactPostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactPostView.swift; sourceTree = ""; }; + B9842C0F2B2F228C00D9F3C1 /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; + B9842C112B2F2A5800D9F3C1 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = ""; }; + B9842C132B2F310C00D9F3C1 /* FetchTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchTimeline.swift; sourceTree = ""; }; + B9842C152B2F363600D9F3C1 /* TimelineFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineFilter.swift; sourceTree = ""; }; + B9842C172B2F36F500D9F3C1 /* AccountsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsList.swift; sourceTree = ""; }; + B9FB94572B2DEECE00D81C07 /* Threaded.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Threaded.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B9FB945A2B2DEECE00D81C07 /* ThreadedApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadedApp.swift; sourceTree = ""; }; + B9FB945C2B2DEECE00D81C07 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + B9FB94602B2DEECF00D81C07 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + B9FB94632B2DEECF00D81C07 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + B9FB946F2B2DF3CD00D81C07 /* Navigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Navigator.swift; sourceTree = ""; }; + B9FB94712B2DF49700D81C07 /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = ""; }; + B9FB94732B2DF6A100D81C07 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = ""; }; + B9FB94752B2E023D00D81C07 /* TabsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsView.swift; sourceTree = ""; }; + B9FB947C2B2E19E300D81C07 /* AccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountManager.swift; sourceTree = ""; }; + B9FB947E2B2E1D5F00D81C07 /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = ""; }; + B9FB94802B2E1FEF00D81C07 /* HTMLString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLString.swift; sourceTree = ""; }; + B9FB94852B2E211200D81C07 /* Account+Elms.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Elms.swift"; sourceTree = ""; }; + B9FB94872B2E223E00D81C07 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; + B9FB94892B2E227000D81C07 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + B9FB948B2B2E232300D81C07 /* OnlineImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineImage.swift; sourceTree = ""; }; + B9FB948D2B2E28E800D81C07 /* ShareableImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareableImage.swift; sourceTree = ""; }; + B9FB948F2B2E2B0E00D81C07 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + B9FB94912B2E35D000D81C07 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + B9FB94962B2EDABF00D81C07 /* PrivacyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyView.swift; sourceTree = ""; }; + B9FB94982B2EEB9400D81C07 /* AddInstanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddInstanceView.swift; sourceTree = ""; }; + B9FB949A2B2EF09A00D81C07 /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; + B9FB949C2B2EF0D600D81C07 /* Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instance.swift; sourceTree = ""; }; + B9FB949E2B2EF0F200D81C07 /* MastodonRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRequest.swift; sourceTree = ""; }; + B9FB94A02B2EF23100D81C07 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + B9FB94A12B2EF24A00D81C07 /* AppInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfo.swift; sourceTree = ""; }; + B9FB94A72B2F009F00D81C07 /* ThreadedAuthService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ThreadedAuthService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + B9FB94A92B2F009F00D81C07 /* AuthenticationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AuthenticationServices.framework; path = System/Library/Frameworks/AuthenticationServices.framework; sourceTree = SDKROOT; }; + B9FB94AC2B2F009F00D81C07 /* AuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewController.swift; sourceTree = ""; }; + B9FB94AF2B2F009F00D81C07 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/AuthenticationViewController.xib; sourceTree = ""; }; + B9FB94B12B2F009F00D81C07 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B9FB94BB2B2F035500D81C07 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + B9FB94542B2DEECE00D81C07 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B9FB94842B2E20AF00D81C07 /* SwiftSoup in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B9FB94A42B2F009F00D81C07 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B9FB94AA2B2F009F00D81C07 /* AuthenticationServices.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + B9FB944E2B2DEECE00D81C07 = { + isa = PBXGroup; + children = ( + B97BCE292B3ED2C80044756D /* LICENSE */, + B97BCE272B3ED2A80044756D /* .gitignore */, + B9FB94592B2DEECE00D81C07 /* Threaded */, + B9FB94AB2B2F009F00D81C07 /* AuthService */, + B9FB94A82B2F009F00D81C07 /* Frameworks */, + B9FB94582B2DEECE00D81C07 /* Products */, + ); + sourceTree = ""; + }; + B9FB94582B2DEECE00D81C07 /* Products */ = { + isa = PBXGroup; + children = ( + B9FB94572B2DEECE00D81C07 /* Threaded.app */, + B9FB94A72B2F009F00D81C07 /* ThreadedAuthService.appex */, + ); + name = Products; + sourceTree = ""; + }; + B9FB94592B2DEECE00D81C07 /* Threaded */ = { + isa = PBXGroup; + children = ( + B9FB94A02B2EF23100D81C07 /* Info.plist */, + B9FB945A2B2DEECE00D81C07 /* ThreadedApp.swift */, + B9FB946E2B2DF3BB00D81C07 /* Components */, + B9FB946D2B2DF3B800D81C07 /* Views */, + B9FB946C2B2DF3A600D81C07 /* Data */, + B9FB94602B2DEECF00D81C07 /* Assets.xcassets */, + B9FB94622B2DEECF00D81C07 /* Preview Content */, + B9FB948F2B2E2B0E00D81C07 /* Localizable.xcstrings */, + ); + path = Threaded; + sourceTree = ""; + }; + B9FB94622B2DEECF00D81C07 /* Preview Content */ = { + isa = PBXGroup; + children = ( + B9FB94632B2DEECF00D81C07 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + B9FB946C2B2DF3A600D81C07 /* Data */ = { + isa = PBXGroup; + children = ( + B9FB94BD2B2F038D00D81C07 /* Accounts */, + B9FB946F2B2DF3CD00D81C07 /* Navigator.swift */, + B9FB94BB2B2F035500D81C07 /* Tag.swift */, + B9FB94802B2E1FEF00D81C07 /* HTMLString.swift */, + B9FB94872B2E223E00D81C07 /* Emoji.swift */, + B9FB948D2B2E28E800D81C07 /* ShareableImage.swift */, + B9FB949A2B2EF09A00D81C07 /* Client.swift */, + B9FB949C2B2EF0D600D81C07 /* Instance.swift */, + B9FB949E2B2EF0F200D81C07 /* MastodonRequest.swift */, + B9FB94A12B2EF24A00D81C07 /* AppInfo.swift */, + B9842C0F2B2F228C00D9F3C1 /* Status.swift */, + B9842C132B2F310C00D9F3C1 /* FetchTimeline.swift */, + B9842C172B2F36F500D9F3C1 /* AccountsList.swift */, + B97BCE232B3DD8400044756D /* HapticManager.swift */, + ); + path = Data; + sourceTree = ""; + }; + B9FB946D2B2DF3B800D81C07 /* Views */ = { + isa = PBXGroup; + children = ( + B9FB94952B2EDAB600D81C07 /* Settings */, + B9FB94712B2DF49700D81C07 /* ConnectView.swift */, + B9FB94892B2E227000D81C07 /* ProfileView.swift */, + B9FB94982B2EEB9400D81C07 /* AddInstanceView.swift */, + B9FB945C2B2DEECE00D81C07 /* ContentView.swift */, + B9842C112B2F2A5800D9F3C1 /* TimelineView.swift */, + B97BCE252B3DE5A10044756D /* AccountView.swift */, + ); + path = Views; + sourceTree = ""; + }; + B9FB946E2B2DF3BB00D81C07 /* Components */ = { + isa = PBXGroup; + children = ( + B9FB94792B2E137100D81C07 /* TabsNavs */, + B9FB94732B2DF6A100D81C07 /* ButtonStyles.swift */, + B9FB948B2B2E232300D81C07 /* OnlineImage.swift */, + B9842C0D2B2F21B700D9F3C1 /* CompactPostView.swift */, + ); + path = Components; + sourceTree = ""; + }; + B9FB94792B2E137100D81C07 /* TabsNavs */ = { + isa = PBXGroup; + children = ( + B9FB94752B2E023D00D81C07 /* TabsView.swift */, + ); + path = TabsNavs; + sourceTree = ""; + }; + B9FB94952B2EDAB600D81C07 /* Settings */ = { + isa = PBXGroup; + children = ( + B9FB94912B2E35D000D81C07 /* SettingsView.swift */, + B9FB94962B2EDABF00D81C07 /* PrivacyView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + B9FB94A82B2F009F00D81C07 /* Frameworks */ = { + isa = PBXGroup; + children = ( + B9FB94A92B2F009F00D81C07 /* AuthenticationServices.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + B9FB94AB2B2F009F00D81C07 /* AuthService */ = { + isa = PBXGroup; + children = ( + B9FB94AC2B2F009F00D81C07 /* AuthenticationViewController.swift */, + B9FB94AE2B2F009F00D81C07 /* AuthenticationViewController.xib */, + B9FB94B12B2F009F00D81C07 /* Info.plist */, + ); + path = AuthService; + sourceTree = ""; + }; + B9FB94BD2B2F038D00D81C07 /* Accounts */ = { + isa = PBXGroup; + children = ( + B9FB947C2B2E19E300D81C07 /* AccountManager.swift */, + B9FB947E2B2E1D5F00D81C07 /* Account.swift */, + B9FB94852B2E211200D81C07 /* Account+Elms.swift */, + B9842C152B2F363600D9F3C1 /* TimelineFilter.swift */, + ); + path = Accounts; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + B9FB94562B2DEECE00D81C07 /* Threaded */ = { + isa = PBXNativeTarget; + buildConfigurationList = B9FB94672B2DEECF00D81C07 /* Build configuration list for PBXNativeTarget "Threaded" */; + buildPhases = ( + B9FB94532B2DEECE00D81C07 /* Sources */, + B9FB94542B2DEECE00D81C07 /* Frameworks */, + B9FB94552B2DEECE00D81C07 /* Resources */, + B9FB94B82B2F009F00D81C07 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + B9FB94B32B2F009F00D81C07 /* PBXTargetDependency */, + ); + name = Threaded; + packageProductDependencies = ( + B9FB94832B2E20AF00D81C07 /* SwiftSoup */, + ); + productName = Threaded; + productReference = B9FB94572B2DEECE00D81C07 /* Threaded.app */; + productType = "com.apple.product-type.application"; + }; + B9FB94A62B2F009F00D81C07 /* ThreadedAuthService */ = { + isa = PBXNativeTarget; + buildConfigurationList = B9FB94B52B2F009F00D81C07 /* Build configuration list for PBXNativeTarget "ThreadedAuthService" */; + buildPhases = ( + B9FB94A32B2F009F00D81C07 /* Sources */, + B9FB94A42B2F009F00D81C07 /* Frameworks */, + B9FB94A52B2F009F00D81C07 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ThreadedAuthService; + productName = AuthService; + productReference = B9FB94A72B2F009F00D81C07 /* ThreadedAuthService.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B9FB944F2B2DEECE00D81C07 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1510; + LastUpgradeCheck = 1510; + TargetAttributes = { + B9FB94562B2DEECE00D81C07 = { + CreatedOnToolsVersion = 15.1; + }; + B9FB94A62B2F009F00D81C07 = { + CreatedOnToolsVersion = 15.1; + }; + }; + }; + buildConfigurationList = B9FB94522B2DEECE00D81C07 /* Build configuration list for PBXProject "Threaded" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = B9FB944E2B2DEECE00D81C07; + packageReferences = ( + B9FB94822B2E20AF00D81C07 /* XCRemoteSwiftPackageReference "SwiftSoup" */, + ); + productRefGroup = B9FB94582B2DEECE00D81C07 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + B9FB94562B2DEECE00D81C07 /* Threaded */, + B9FB94A62B2F009F00D81C07 /* ThreadedAuthService */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B9FB94552B2DEECE00D81C07 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B97BCE282B3ED2A80044756D /* .gitignore in Resources */, + B9FB94642B2DEECF00D81C07 /* Preview Assets.xcassets in Resources */, + B9FB94612B2DEECF00D81C07 /* Assets.xcassets in Resources */, + B9FB94902B2E2B0E00D81C07 /* Localizable.xcstrings in Resources */, + B97BCE2A2B3ED2C80044756D /* LICENSE in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B9FB94A52B2F009F00D81C07 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B9FB94B02B2F009F00D81C07 /* AuthenticationViewController.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + B9FB94532B2DEECE00D81C07 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B9FB94922B2E35D000D81C07 /* SettingsView.swift in Sources */, + B9FB94882B2E223E00D81C07 /* Emoji.swift in Sources */, + B9FB94762B2E023D00D81C07 /* TabsView.swift in Sources */, + B9FB947D2B2E19E300D81C07 /* AccountManager.swift in Sources */, + B9FB945D2B2DEECE00D81C07 /* ContentView.swift in Sources */, + B9842C0E2B2F21B700D9F3C1 /* CompactPostView.swift in Sources */, + B9FB94992B2EEB9400D81C07 /* AddInstanceView.swift in Sources */, + B9FB94972B2EDABF00D81C07 /* PrivacyView.swift in Sources */, + B9FB948A2B2E227000D81C07 /* ProfileView.swift in Sources */, + B9842C142B2F310C00D9F3C1 /* FetchTimeline.swift in Sources */, + B9842C162B2F363600D9F3C1 /* TimelineFilter.swift in Sources */, + B9FB949B2B2EF09A00D81C07 /* Client.swift in Sources */, + B9FB949D2B2EF0D600D81C07 /* Instance.swift in Sources */, + B9842C102B2F228C00D9F3C1 /* Status.swift in Sources */, + B9FB94722B2DF49700D81C07 /* ConnectView.swift in Sources */, + B9FB945B2B2DEECE00D81C07 /* ThreadedApp.swift in Sources */, + B9FB94862B2E211200D81C07 /* Account+Elms.swift in Sources */, + B9FB94BC2B2F035500D81C07 /* Tag.swift in Sources */, + B9FB94812B2E1FEF00D81C07 /* HTMLString.swift in Sources */, + B9FB947F2B2E1D5F00D81C07 /* Account.swift in Sources */, + B9842C122B2F2A5800D9F3C1 /* TimelineView.swift in Sources */, + B9FB948C2B2E232300D81C07 /* OnlineImage.swift in Sources */, + B9FB94742B2DF6A100D81C07 /* ButtonStyles.swift in Sources */, + B9FB94702B2DF3CD00D81C07 /* Navigator.swift in Sources */, + B97BCE262B3DE5A10044756D /* AccountView.swift in Sources */, + B97BCE242B3DD8400044756D /* HapticManager.swift in Sources */, + B9FB949F2B2EF0F200D81C07 /* MastodonRequest.swift in Sources */, + B9842C182B2F36F500D9F3C1 /* AccountsList.swift in Sources */, + B9FB948E2B2E28E800D81C07 /* ShareableImage.swift in Sources */, + B9FB94A22B2EF24A00D81C07 /* AppInfo.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B9FB94A32B2F009F00D81C07 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B9FB94AD2B2F009F00D81C07 /* AuthenticationViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + B9FB94B32B2F009F00D81C07 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B9FB94A62B2F009F00D81C07 /* ThreadedAuthService */; + targetProxy = B9FB94B22B2F009F00D81C07 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + B9FB94AE2B2F009F00D81C07 /* AuthenticationViewController.xib */ = { + isa = PBXVariantGroup; + children = ( + B9FB94AF2B2F009F00D81C07 /* Base */, + ); + name = AuthenticationViewController.xib; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + B9FB94652B2DEECF00D81C07 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + B9FB94662B2DEECF00D81C07 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + B9FB94682B2DEECF00D81C07 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Threaded/Preview Content\""; + DEVELOPMENT_TEAM = HB5P3BML86; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Threaded/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = fr.lumaa.Threaded; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + B9FB94692B2DEECF00D81C07 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Threaded/Preview Content\""; + DEVELOPMENT_TEAM = HB5P3BML86; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Threaded/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = fr.lumaa.Threaded; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + B9FB94B62B2F009F00D81C07 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = HB5P3BML86; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AuthService/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = AuthService; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = fr.lumaa.Threaded.AuthService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + B9FB94B72B2F009F00D81C07 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = HB5P3BML86; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AuthService/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = AuthService; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = fr.lumaa.Threaded.AuthService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + B9FB94522B2DEECE00D81C07 /* Build configuration list for PBXProject "Threaded" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B9FB94652B2DEECF00D81C07 /* Debug */, + B9FB94662B2DEECF00D81C07 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B9FB94672B2DEECF00D81C07 /* Build configuration list for PBXNativeTarget "Threaded" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B9FB94682B2DEECF00D81C07 /* Debug */, + B9FB94692B2DEECF00D81C07 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B9FB94B52B2F009F00D81C07 /* Build configuration list for PBXNativeTarget "ThreadedAuthService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B9FB94B62B2F009F00D81C07 /* Debug */, + B9FB94B72B2F009F00D81C07 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + B9FB94822B2E20AF00D81C07 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/scinfu/SwiftSoup"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.6.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + B9FB94832B2E20AF00D81C07 /* SwiftSoup */ = { + isa = XCSwiftPackageProductDependency; + package = B9FB94822B2E20AF00D81C07 /* XCRemoteSwiftPackageReference "SwiftSoup" */; + productName = SwiftSoup; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = B9FB944F2B2DEECE00D81C07 /* Project object */; +} diff --git a/Threaded.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Threaded.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Threaded.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Threaded.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Threaded.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Threaded.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Threaded.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Threaded.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..e55f384 --- /dev/null +++ b/Threaded.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup", + "state" : { + "revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6", + "version" : "2.6.1" + } + } + ], + "version" : 2 +} diff --git a/Threaded/Assets.xcassets/AccentColor.colorset/Contents.json b/Threaded/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Threaded/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Threaded/Assets.xcassets/AppBackground.colorset/Contents.json b/Threaded/Assets.xcassets/AppBackground.colorset/Contents.json new file mode 100644 index 0000000..fe808bf --- /dev/null +++ b/Threaded/Assets.xcassets/AppBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x10", + "green" : "0x10", + "red" : "0x10" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Threaded/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Threaded/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..221a240 Binary files /dev/null and b/Threaded/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/Threaded/Assets.xcassets/AppIcon.appiconset/Contents.json b/Threaded/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..cefcc87 --- /dev/null +++ b/Threaded/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Threaded/Assets.xcassets/Contents.json b/Threaded/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Threaded/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Threaded/Assets.xcassets/HeroIcon.imageset/Contents.json b/Threaded/Assets.xcassets/HeroIcon.imageset/Contents.json new file mode 100644 index 0000000..78c34a6 --- /dev/null +++ b/Threaded/Assets.xcassets/HeroIcon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "HeroIcon_black.png", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "HeroIcon_white.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Threaded/Assets.xcassets/HeroIcon.imageset/HeroIcon_black.png b/Threaded/Assets.xcassets/HeroIcon.imageset/HeroIcon_black.png new file mode 100644 index 0000000..e9a8e46 Binary files /dev/null and b/Threaded/Assets.xcassets/HeroIcon.imageset/HeroIcon_black.png differ diff --git a/Threaded/Assets.xcassets/HeroIcon.imageset/HeroIcon_white.png b/Threaded/Assets.xcassets/HeroIcon.imageset/HeroIcon_white.png new file mode 100644 index 0000000..82731ae Binary files /dev/null and b/Threaded/Assets.xcassets/HeroIcon.imageset/HeroIcon_white.png differ diff --git a/Threaded/Assets.xcassets/Mastodon/Contents.json b/Threaded/Assets.xcassets/Mastodon/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Threaded/Assets.xcassets/Mastodon/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Threaded/Assets.xcassets/Mastodon/MastodonMark.imageset/Contents.json b/Threaded/Assets.xcassets/Mastodon/MastodonMark.imageset/Contents.json new file mode 100644 index 0000000..068d4ba --- /dev/null +++ b/Threaded/Assets.xcassets/Mastodon/MastodonMark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Logo violet 1mastodon.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Threaded/Assets.xcassets/Mastodon/MastodonMark.imageset/Logo violet 1mastodon.png b/Threaded/Assets.xcassets/Mastodon/MastodonMark.imageset/Logo violet 1mastodon.png new file mode 100644 index 0000000..99f3caf Binary files /dev/null and b/Threaded/Assets.xcassets/Mastodon/MastodonMark.imageset/Logo violet 1mastodon.png differ diff --git a/Threaded/Components/ButtonStyles.swift b/Threaded/Components/ButtonStyles.swift new file mode 100644 index 0000000..289e331 --- /dev/null +++ b/Threaded/Components/ButtonStyles.swift @@ -0,0 +1,43 @@ +//Made by Lumaa + +import SwiftUI + +struct LargeButton: ButtonStyle { + var filled: Bool = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding() + .background { + if filled { + Color(uiColor: UIColor.label) + } + } + .foregroundStyle(filled ? Color(uiColor: UIColor.systemBackground) : Color(uiColor: UIColor.label)) + .bold(filled) + .clipShape(.rect(cornerRadius: 15)) + .opacity(configuration.isPressed ? 0.3 : 1) + .overlay { + if !filled { + RoundedRectangle(cornerRadius: 15) + .stroke(Color(uiColor: UIColor.tertiaryLabel)) + .opacity(configuration.isPressed ? 0.3 : 1) + } + } + } +} + +struct NoTapAnimationStyle: PrimitiveButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .contentShape(Rectangle()) + .onTapGesture(perform: configuration.trigger) + } +} + +#Preview { + Button {} label: { + Text("Hello world") + } + .buttonStyle(NoTapAnimationStyle()) +} diff --git a/Threaded/Components/CompactPostView.swift b/Threaded/Components/CompactPostView.swift new file mode 100644 index 0000000..d53af92 --- /dev/null +++ b/Threaded/Components/CompactPostView.swift @@ -0,0 +1,345 @@ +//Made by Lumaa + +import SwiftUI + +struct CompactPostView: View { + @Environment(Client.self) private var client: Client + var status: Status + var navigator: Navigator + + @State private var isLiked: Bool = false + @State private var isReposted: Bool = false + + @State private var incrLike: Bool = false + + var body: some View { + VStack { + if status.reblog != nil { + VStack(alignment: .leading) { + repostNotice + .padding(.leading, 40) + + statusRepost + } + } else { + statusPost + } + + Rectangle() + .fill(Color.gray.opacity(0.2)) + .frame(width: .infinity, height: 1) + .padding(.bottom, 3) + } + .onAppear { + isLiked = status.reblog != nil ? status.reblog!.favourited ?? false : status.favourited ?? false + isReposted = status.reblog != nil ? status.reblog!.reblogged ?? false : status.reblogged ?? false + } + } + + func likePost() async throws { + guard client.isAuth else { fatalError("Client is not authenticated") } + let statusId: String = status.reblog != nil ? status.reblog!.id : status.id + let endpoint = !isLiked ? Statuses.favorite(id: statusId) : Statuses.unfavorite(id: statusId) + + isLiked = !isLiked + let newStatus: Status = try await client.post(endpoint: endpoint) + if isLiked != newStatus.favourited { + isLiked = newStatus.favourited ?? !isLiked + } + } + + func repostPost() async throws { + guard client.isAuth else { fatalError("Client is not authenticated") } + let statusId: String = status.reblog != nil ? status.reblog!.id : status.id + let endpoint = !isReposted ? Statuses.reblog(id: statusId) : Statuses.unreblog(id: statusId) + + isReposted = !isReposted + let newStatus: Status = try await client.post(endpoint: endpoint) + if isReposted != newStatus.reblogged { + isReposted = newStatus.reblogged ?? !isReposted + } + } + + var statusPost: some View { + HStack(alignment: .top, spacing: 0) { + // MARK: Profile picture + if status.repliesCount > 0 { + VStack { + profilePicture + .onTapGesture { + navigator.navigate(to: .account(acc: status.account)) + } + + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 2.5) + .clipShape(.capsule) + .padding([.vertical], 5) + + Image(systemName: "person.crop.circle") + .resizable() + .frame(width: 15, height: 15) + .symbolRenderingMode(.monochrome) + .foregroundStyle(Color.gray.opacity(0.3)) + .padding(.bottom, 2.5) + } + } else { + profilePicture + .onTapGesture { + navigator.navigate(to: .account(acc: status.account)) + } + } + + VStack(alignment: .leading) { + // MARK: Status main content + VStack(alignment: .leading, spacing: 10) { + Text(status.account.username) + .multilineTextAlignment(.leading) + .bold() + .onTapGesture { + navigator.navigate(to: .account(acc: status.account)) + } + + Text(status.content.asRawText) + .multilineTextAlignment(.leading) + .frame(width: 300, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) + } + + //MARK: Action buttons + HStack(spacing: 13) { + asyncActionButton(isLiked ? "heart.fill" : "heart") { + do { + HapticManager.playHaptics(haptics: Haptic.tap) + try await likePost() + } catch { + HapticManager.playHaptics(haptics: Haptic.error) + print("Error: \(error.localizedDescription)") + } + } + actionButton("bubble.right") { + print("reply") + navigator.presentedSheet = .post + } + asyncActionButton(isReposted ? "bolt.horizontal.fill" : "bolt.horizontal") { + do { + HapticManager.playHaptics(haptics: Haptic.tap) + try await repostPost() + } catch { + HapticManager.playHaptics(haptics: Haptic.error) + print("Error: \(error.localizedDescription)") + } + } + ShareLink(item: URL(string: status.url ?? "https://joinmastodon.org/")!) { + Image(systemName: "square.and.arrow.up") + .font(.title2) + } + .tint(Color(uiColor: UIColor.label)) + } + .padding(.top) + + // MARK: Status stats + stats.padding(.top, 5) + } + } + } + + var statusRepost: some View { + HStack(alignment: .top, spacing: 0) { + // MARK: Profile picture + if status.reblog!.repliesCount > 0 { + VStack { + profilePicture + .onTapGesture { + navigator.navigate(to: .account(acc: status.reblog!.account)) + } + + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(width: 2.5) + .clipShape(.capsule) + .padding([.vertical], 5) + + Image(systemName: "person.crop.circle") + .resizable() + .frame(width: 15, height: 15) + .symbolRenderingMode(.monochrome) + .foregroundStyle(Color.gray.opacity(0.3)) + .padding(.bottom, 2.5) + } + } else { + profilePicture + .onTapGesture { + navigator.navigate(to: .account(acc: status.reblog!.account)) + } + } + + VStack(alignment: .leading) { + // MARK: Status main content + VStack(alignment: .leading, spacing: 10) { + Text(status.reblog!.account.username) + .multilineTextAlignment(.leading) + .bold() + .onTapGesture { + navigator.navigate(to: .account(acc: status.reblog!.account)) + } + + Text(status.reblog!.content.asRawText) + .multilineTextAlignment(.leading) + .frame(width: 300, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) + } + + //MARK: Action buttons + HStack(spacing: 13) { + asyncActionButton(isLiked ? "heart.fill" : "heart") { + do { + HapticManager.playHaptics(haptics: Haptic.tap) + try await likePost() + incrLike = isLiked + } catch { + HapticManager.playHaptics(haptics: Haptic.error) + print("Error: \(error.localizedDescription)") + } + } + actionButton("bubble.right") { + print("reply") + navigator.presentedSheet = .post + } + asyncActionButton(isReposted ? "bolt.horizontal.fill" : "bolt.horizontal") { + do { + HapticManager.playHaptics(haptics: Haptic.tap) + try await repostPost() + } catch { + HapticManager.playHaptics(haptics: Haptic.error) + print("Error: \(error.localizedDescription)") + } + } + ShareLink(item: URL(string: status.reblog!.url ?? "https://joinmastodon.org/")!) { + Image(systemName: "square.and.arrow.up") + .font(.title2) + } + .tint(Color(uiColor: UIColor.label)) + } + .padding(.top) + + // MARK: Status stats + stats.padding(.top, 5) + } + } + } + + var repostNotice: some View { + HStack (alignment:.center, spacing: 5) { + Image(systemName: "bolt.horizontal") + + Text("status.reposted-by.\(status.account.username)") + } + .padding(.leading, 25) + .multilineTextAlignment(.leading) + .lineLimit(1) + .font(.caption) + .foregroundStyle(Color(uiColor: UIColor.label).opacity(0.3)) + } + + var profilePicture: some View { + if status.reblog != nil { + OnlineImage(url: status.reblog!.account.avatar) + .frame(width: 40, height: 40) + .padding(.horizontal) + .clipShape(.circle) + } else { + OnlineImage(url: status.account.avatar) + .frame(width: 40, height: 40) + .padding(.horizontal) + .clipShape(.circle) + } + } + + var stats: some View { + if status.reblog == nil { + HStack { + if status.repliesCount > 0 { + Text("status.replies-\(status.repliesCount)") + .monospacedDigit() + .foregroundStyle(.gray) + } + + if status.repliesCount > 0 && (status.favouritesCount > 0 || isLiked) { + Text("•") + .foregroundStyle(.gray) + } + + if status.favouritesCount > 0 || isLiked { + let addedLike: Int = incrLike ? 1 : 0 + Text("status.favourites-\(status.favouritesCount + addedLike)") + .monospacedDigit() + .foregroundStyle(.gray) + .contentTransition(.numericText(value: Double(status.favouritesCount + addedLike))) + .transaction { t in + t.animation = .default + } + } + } + } else { + HStack { + if status.reblog!.repliesCount > 0 { + Text("status.replies-\(status.reblog!.repliesCount)") + .monospacedDigit() + .foregroundStyle(.gray) + } + + if status.reblog!.repliesCount > 0 && (status.reblog!.favouritesCount > 0 || isLiked) { + Text("•") + .foregroundStyle(.gray) + } + + if status.reblog!.favouritesCount > 0 || isLiked { + let addedLike: Int = incrLike ? 1 : 0 + Text("status.favourites-\(status.reblog!.favouritesCount + addedLike)") + .monospacedDigit() + .foregroundStyle(.gray) + .contentTransition(.numericText(value: Double(status.reblog!.favouritesCount + addedLike))) + .transaction { t in + t.animation = .default + } + } + } + } + } + + @ViewBuilder + func actionButton(_ image: String, action: @escaping () -> Void) -> some View { + Button { + action() + } label: { + Image(systemName: image) + .font(.title2) + } + .tint(Color(uiColor: UIColor.label)) + } + + @ViewBuilder + func asyncActionButton(_ image: String, action: @escaping () async -> Void) -> some View { + Button { + Task { + await action() + } + } label: { + Image(systemName: image) + .font(.title2) + } + .tint(Color(uiColor: UIColor.label)) + } +} + +#Preview { + ScrollView { + VStack { + ForEach(Status.placeholders()) { status in + CompactPostView(status: status, navigator: Navigator()) + .environment(Client.init(server: AppInfo.defaultServer)) + } + } + } +} diff --git a/Threaded/Components/OnlineImage.swift b/Threaded/Components/OnlineImage.swift new file mode 100644 index 0000000..7bc5f1a --- /dev/null +++ b/Threaded/Components/OnlineImage.swift @@ -0,0 +1,23 @@ +//Made by Lumaa + +import SwiftUI + +struct OnlineImage: View { + var url: URL + + var body: some View { + AsyncImage(url: url) { element in + element + .resizable() + .scaledToFit() + .aspectRatio(1.0, contentMode: .fit) + } placeholder: { + Rectangle() + .fill(Color.gray) + .overlay { + ProgressView() + .progressViewStyle(.circular) + } + } + } +} diff --git a/Threaded/Components/TabsNavs/TabsView.swift b/Threaded/Components/TabsNavs/TabsView.swift new file mode 100644 index 0000000..8929743 --- /dev/null +++ b/Threaded/Components/TabsNavs/TabsView.swift @@ -0,0 +1,138 @@ +//Made by Lumaa + +import SwiftUI + +struct TabsView: View { + @State var navigator: Navigator + + var body: some View { + HStack(alignment: .center) { + Button { + navigator.selectedTab = .timeline + } label: { + if navigator.selectedTab == .timeline { + Tabs.timeline.imageFill + } else { + Tabs.timeline.image + } + } + .buttonStyle(NoTapAnimationStyle()) + + Spacer() + + Button { + navigator.selectedTab = .search + } label: { + if navigator.selectedTab == .search { + Tabs.search.imageFill + } else { + Tabs.search.image + } + } + .buttonStyle(NoTapAnimationStyle()) + + Spacer() + + Button { + navigator.presentedSheet = .post + } label: { + Tabs.post.image + } + .buttonStyle(NoTapAnimationStyle()) + + Spacer() + + Button { + navigator.selectedTab = .activity + } label: { + if navigator.selectedTab == .activity { + Tabs.activity.imageFill + } else { + Tabs.activity.image + } + } + .buttonStyle(NoTapAnimationStyle()) + + Spacer() + + Button { + navigator.selectedTab = .profile + } label: { + if navigator.selectedTab == .profile { + Tabs.profile.imageFill + } else { + Tabs.profile.image + } + } + .buttonStyle(NoTapAnimationStyle()) + } + .withSheets(sheetDestination: $navigator.presentedSheet) + .padding(.horizontal, 30) + .background(Color.appBackground) + } +} + +enum Tabs { + case timeline + case search + case post + case activity + case profile + + @ViewBuilder + var image: some View { + switch self { + case .timeline: + Image(systemName: "house") + .tabBarify() + case .search: + Image(systemName: "magnifyingglass") + .tabBarify() + case .post: + Image(systemName: "square.and.pencil") + .tabBarify() + case .activity: + Image(systemName: "heart") + .tabBarify() + case .profile: + Image(systemName: "person") + .tabBarify() + + } + } + + @ViewBuilder + var imageFill: some View { + switch self { + case .timeline: + Image(systemName: "house.fill") + .tabBarify(false) + case .search: + Image(systemName: "magnifyingglass") + .tabBarify(false) + case .post: + Image(systemName: "square.and.pencil") + .tabBarify(false) + case .activity: + Image(systemName: "heart.fill") + .tabBarify(false) + case .profile: + Image(systemName: "person.fill") + .tabBarify(false) + + } + } +} + +extension Image { + func tabBarify(_ neutral: Bool = true) -> some View { + self + .font(.title2) + .opacity(neutral ? 0.3 : 1) + } +} + +#Preview { + TabsView(navigator: Navigator()) + .previewLayout(.sizeThatFits) +} diff --git a/Threaded/Data/Accounts/Account+Elms.swift b/Threaded/Data/Accounts/Account+Elms.swift new file mode 100644 index 0000000..065b286 --- /dev/null +++ b/Threaded/Data/Accounts/Account+Elms.swift @@ -0,0 +1,75 @@ +//Made by Lumaa + +import Foundation + +public enum Visibility: String, Codable, CaseIterable, Hashable, Equatable, Sendable { + case pub = "public" + case unlisted + case priv = "private" + case direct +} + +private enum CodingKeys: CodingKey { + case asDate +} + +public struct ServerDate: Codable, Hashable, Equatable, Sendable { + public let asDate: Date + + public var relativeFormatted: String { + DateFormatterCache.shared.createdAtRelativeFormatter.localizedString(for: asDate, relativeTo: Date()) + } + + public var shortDateFormatted: String { + DateFormatterCache.shared.createdAtShortDateFormatted.string(from: asDate) + } + + private static let calendar = Calendar(identifier: .gregorian) + + public init() { + asDate = Date() - 100 + } + + public init(from decoder: Decoder) throws { + do { + // Decode from server + let container = try decoder.singleValueContainer() + let stringDate = try container.decode(String.self) + asDate = DateFormatterCache.shared.createdAtDateFormatter.date(from: stringDate) ?? Date() + } catch { + // Decode from cache + let container = try decoder.container(keyedBy: CodingKeys.self) + asDate = try container.decode(Date.self, forKey: .asDate) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(asDate, forKey: .asDate) + } +} + +class DateFormatterCache: @unchecked Sendable { + static let shared = DateFormatterCache() + + let createdAtRelativeFormatter: RelativeDateTimeFormatter + let createdAtShortDateFormatted: DateFormatter + let createdAtDateFormatter: DateFormatter + + init() { + let createdAtRelativeFormatter = RelativeDateTimeFormatter() + createdAtRelativeFormatter.unitsStyle = .short + self.createdAtRelativeFormatter = createdAtRelativeFormatter + + let createdAtShortDateFormatted = DateFormatter() + createdAtShortDateFormatted.dateStyle = .short + createdAtShortDateFormatted.timeStyle = .none + self.createdAtShortDateFormatted = createdAtShortDateFormatted + + let createdAtDateFormatter = DateFormatter() + createdAtDateFormatter.calendar = .init(identifier: .iso8601) + createdAtDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" + createdAtDateFormatter.timeZone = .init(abbreviation: "UTC") + self.createdAtDateFormatter = createdAtDateFormatter + } +} diff --git a/Threaded/Data/Accounts/Account.swift b/Threaded/Data/Accounts/Account.swift new file mode 100644 index 0000000..f5e40fc --- /dev/null +++ b/Threaded/Data/Accounts/Account.swift @@ -0,0 +1,127 @@ +//Made by Lumaa + +import Foundation + +public final class Account: Codable, Identifiable, Hashable, Sendable, Equatable { + public static func == (lhs: Account, rhs: Account) -> Bool { + lhs.id == rhs.id && + lhs.username == rhs.username && + lhs.note.asRawText == rhs.note.asRawText && + lhs.statusesCount == rhs.statusesCount && + lhs.followersCount == rhs.followersCount && + lhs.followingCount == rhs.followingCount && + lhs.acct == rhs.acct && + lhs.displayName == rhs.displayName && + lhs.fields == rhs.fields && + lhs.lastStatusAt == rhs.lastStatusAt && + lhs.discoverable == rhs.discoverable && + lhs.bot == rhs.bot && + lhs.locked == rhs.locked + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public struct Field: Codable, Equatable, Identifiable, Sendable { + public var id: String { + value.asRawText + name + } + + public let name: String + public let value: HTMLString + public let verifiedAt: String? + } + + public struct Source: Codable, Equatable, Sendable { + public let privacy: Visibility + public let sensitive: Bool + public let language: String? + public let note: String + public let fields: [Field] + } + + public let id: String + public let username: String + public let displayName: String? + public let avatar: URL + public let header: URL + public let acct: String + public let note: HTMLString + public let createdAt: ServerDate? + public let followersCount: Int? + public let followingCount: Int? + public let statusesCount: Int? + public let lastStatusAt: String? + public let fields: [Field] + public let locked: Bool + public let emojis: [Emoji] + public let url: URL? + public let source: Source? + public let bot: Bool + public let discoverable: Bool? + + public var haveAvatar: Bool { + avatar.lastPathComponent != "missing.png" + } + + public var haveHeader: Bool { + header.lastPathComponent != "missing.png" + } + + public init(id: String, username: String, displayName: String?, avatar: URL, header: URL, acct: String, note: HTMLString, createdAt: ServerDate, followersCount: Int, followingCount: Int, statusesCount: Int, lastStatusAt: String? = nil, fields: [Account.Field], locked: Bool, emojis: [Emoji], url: URL? = nil, source: Account.Source? = nil, bot: Bool, discoverable: Bool? = nil) { + self.id = id + self.username = username + self.displayName = displayName + self.avatar = avatar + self.header = header + self.acct = acct + self.note = note + self.createdAt = createdAt + self.followersCount = followersCount + self.followingCount = followingCount + self.statusesCount = statusesCount + self.lastStatusAt = lastStatusAt + self.fields = fields + self.locked = locked + self.emojis = emojis + self.url = url + self.source = source + self.bot = bot + self.discoverable = discoverable + } + + public static func placeholder() -> Account { + .init(id: UUID().uuidString, + username: "Username", + displayName: "John Mastodon", + avatar: URL(string: "https://files.mastodon.social/media_attachments/files/003/134/405/original/04060b07ddf7bb0b.png")!, + header: URL(string: "https://files.mastodon.social/media_attachments/files/003/134/405/original/04060b07ddf7bb0b.png")!, + acct: "johnm@example.com", + note: .init(stringValue: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent ullamcorper lectus ac finibus dui."), + createdAt: ServerDate(), + followersCount: 10, + followingCount: 10, + statusesCount: 10, + lastStatusAt: nil, + fields: [], + locked: false, + emojis: [], + url: nil, + source: nil, + bot: false, + discoverable: true) + } + + public static func placeholders() -> [Account] { + [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), + .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] + } +} + +public struct FamiliarAccounts: Decodable { + public let id: String + public let accounts: [Account] +} + +extension FamiliarAccounts: Sendable {} diff --git a/Threaded/Data/Accounts/AccountManager.swift b/Threaded/Data/Accounts/AccountManager.swift new file mode 100644 index 0000000..f62e472 --- /dev/null +++ b/Threaded/Data/Accounts/AccountManager.swift @@ -0,0 +1,101 @@ +//Made by Lumaa + +import Foundation + +public struct AppAccount: Codable, Identifiable, Hashable { + public let server: String + public var accountName: String? + public let oauthToken: OauthToken? + public static let saveKey: String = "threaded-appaccount.current" + + public var key: String { + if let oauthToken { + "\(server):\(oauthToken.createdAt)" + } else { + "\(server):anonymous" + } + } + + public var id: String { + key + } + + public init(server: String, + accountName: String?, + oauthToken: OauthToken? = nil) + { + self.server = server + self.accountName = accountName + self.oauthToken = oauthToken + } + + func saveAsCurrent() throws { + let encoder = JSONEncoder() + let json = try encoder.encode(self) + UserDefaults.standard.setValue(json, forKey: AppAccount.saveKey) + } + + static func loadAsCurrent() throws -> AppAccount? { + let decoder = JSONDecoder() + if let data = UserDefaults.standard.data(forKey: AppAccount.saveKey) { + let account = try decoder.decode(AppAccount.self, from: data) + return account + } + return nil + } +} + +extension AppAccount: Sendable {} + +public enum Oauth: Endpoint { + case authorize(clientId: String) + case token(code: String, clientId: String, clientSecret: String) + + public func path() -> String { + switch self { + case .authorize: + "oauth/authorize" + case .token: + "oauth/token" + } + } + + public var jsonValue: Encodable? { + switch self { + case let .token(code, clientId, clientSecret): + TokenData(clientId: clientId, clientSecret: clientSecret, code: code) + default: + nil + } + } + + public struct TokenData: Encodable { + public let grantType = "authorization_code" + public let clientId: String + public let clientSecret: String + public let redirectUri = AppInfo.scheme + public let code: String + public let scope = AppInfo.scopes + } + + public func queryItems() -> [URLQueryItem]? { + switch self { + case let .authorize(clientId): + return [ + .init(name: "response_type", value: "code"), + .init(name: "client_id", value: clientId), + .init(name: "redirect_uri", value: AppInfo.scheme), + .init(name: "scope", value: AppInfo.scopes), + ] + default: + return nil + } + } +} + +public struct OauthToken: Codable, Hashable, Sendable { + public let accessToken: String + public let tokenType: String + public let scope: String + public let createdAt: Double +} diff --git a/Threaded/Data/Accounts/TimelineFilter.swift b/Threaded/Data/Accounts/TimelineFilter.swift new file mode 100644 index 0000000..5aaa068 --- /dev/null +++ b/Threaded/Data/Accounts/TimelineFilter.swift @@ -0,0 +1,340 @@ +//Made by Lumaa + +import Foundation +import SwiftUI + +public enum RemoteTimelineFilter: String, CaseIterable, Hashable, Equatable { + case local, federated, trending + + public func localizedTitle() -> LocalizedStringKey { + switch self { + case .federated: + "timeline.federated" + case .local: + "timeline.local" + case .trending: + "timeline.trending" + } + } + + public func iconName() -> String { + switch self { + case .federated: + "globe.americas" + case .local: + "person.2" + case .trending: + "chart.line.uptrend.xyaxis" + } + } +} + +public enum TimelineFilter: Hashable, Equatable { + case home, local, federated, trending + case hashtag(tag: String, accountId: String?) + case tagGroup(title: String, tags: [String]) + case list(list: AccountsList) + case remoteLocal(server: String, filter: RemoteTimelineFilter) + case latest + + public func hash(into hasher: inout Hasher) { + hasher.combine(title) + } + + public static func availableTimeline(client: Client) -> [TimelineFilter] { + if !client.isAuth { + return [.local, .federated, .trending] + } + return [.home, .local, .federated, .trending] + } + + public var supportNewestPagination: Bool { + switch self { + case .trending: + false + case let .remoteLocal(_, filter): + filter != .trending + default: + true + } + } + + public var title: String { + switch self { + case .latest: + "Latest" + case .federated: + "Federated" + case .local: + "Local" + case .trending: + "Trending" + case .home: + "Home" + case let .hashtag(tag, _): + "#\(tag)" + case let .tagGroup(title, _): + title + case let .list(list): + list.title + case let .remoteLocal(server, _): + server + } + } + + public func localizedTitle() -> LocalizedStringKey { + switch self { + case .latest: + "timeline.latest" + case .federated: + "timeline.federated" + case .local: + "timeline.local" + case .trending: + "timeline.trending" + case .home: + "timeline.home" + case let .hashtag(tag, _): + "#\(tag)" + case let .tagGroup(title, _): + LocalizedStringKey(title) // ?? not sure since this can't be localized. + case let .list(list): + LocalizedStringKey(list.title) + case let .remoteLocal(server, _): + LocalizedStringKey(server) + } + } + + public func iconName() -> String? { + switch self { + case .latest: + "arrow.counterclockwise" + case .federated: + "globe.americas" + case .local: + "person.2" + case .trending: + "chart.line.uptrend.xyaxis" + case .home: + "house" + case .list: + "list.bullet" + case .remoteLocal: + "dot.radiowaves.right" + default: + nil + } + } + + public func endpoint(sinceId: String?, maxId: String?, minId: String?, offset: Int?) -> Endpoint { + switch self { + case .federated: return Timelines.pub(sinceId: sinceId, maxId: maxId, minId: minId, local: false) + case .local: return Timelines.pub(sinceId: sinceId, maxId: maxId, minId: minId, local: true) + case let .remoteLocal(_, filter): + switch filter { + case .local: + return Timelines.pub(sinceId: sinceId, maxId: maxId, minId: minId, local: true) + case .federated: + return Timelines.pub(sinceId: sinceId, maxId: maxId, minId: minId, local: false) + case .trending: + return Trends.statuses(offset: offset) + } + case .latest: return Timelines.home(sinceId: nil, maxId: nil, minId: nil) + case .home: return Timelines.home(sinceId: sinceId, maxId: maxId, minId: minId) + case .trending: return Trends.statuses(offset: offset) + case let .list(list): return Timelines.list(listId: list.id, sinceId: sinceId, maxId: maxId, minId: minId) + case let .hashtag(tag, accountId): + if let accountId { + return Accounts.statuses(id: accountId, sinceId: nil, tag: tag, onlyMedia: nil, excludeReplies: nil, pinned: nil) + } else { + return Timelines.hashtag(tag: tag, additional: nil, maxId: maxId) + } + case let .tagGroup(_, tags): + var tags = tags + if !tags.isEmpty { + let tag = tags.removeFirst() + return Timelines.hashtag(tag: tag, additional: tags, maxId: maxId) + } else { + return Timelines.hashtag(tag: "", additional: tags, maxId: maxId) + } + } + } +} + +extension TimelineFilter: Codable { + enum CodingKeys: String, CodingKey { + case home + case local + case federated + case trending + case hashtag + case tagGroup + case list + case remoteLocal + case latest + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let key = container.allKeys.first + switch key { + case .home: + self = .home + case .local: + self = .local + case .federated: + self = .federated + case .trending: + self = .trending + case .hashtag: + var nestedContainer = try container.nestedUnkeyedContainer(forKey: .hashtag) + let tag = try nestedContainer.decode(String.self) + let accountId = try nestedContainer.decode(String?.self) + self = .hashtag( + tag: tag, + accountId: accountId + ) + case .tagGroup: + var nestedContainer = try container.nestedUnkeyedContainer(forKey: .hashtag) + let title = try nestedContainer.decode(String.self) + let tags = try nestedContainer.decode([String].self) + self = .tagGroup( + title: title, + tags: tags + ) + case .list: + let list = try container.decode( + AccountsList.self, + forKey: .list + ) + self = .list(list: list) + case .remoteLocal: + var nestedContainer = try container.nestedUnkeyedContainer(forKey: .remoteLocal) + let server = try nestedContainer.decode(String.self) + let filter = try nestedContainer.decode(RemoteTimelineFilter.self) + self = .remoteLocal( + server: server, + filter: filter + ) + case .latest: + self = .latest + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Unabled to decode enum." + ) + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .home: + try container.encode(CodingKeys.home.rawValue, forKey: .home) + case .local: + try container.encode(CodingKeys.local.rawValue, forKey: .local) + case .federated: + try container.encode(CodingKeys.federated.rawValue, forKey: .federated) + case .trending: + try container.encode(CodingKeys.trending.rawValue, forKey: .trending) + case let .hashtag(tag, accountId): + var nestedContainer = container.nestedUnkeyedContainer(forKey: .hashtag) + try nestedContainer.encode(tag) + try nestedContainer.encode(accountId) + case let .tagGroup(title, tags): + var nestedContainer = container.nestedUnkeyedContainer(forKey: .tagGroup) + try nestedContainer.encode(title) + try nestedContainer.encode(tags) + case let .list(list): + try container.encode(list, forKey: .list) + case let .remoteLocal(server, filter): + var nestedContainer = container.nestedUnkeyedContainer(forKey: .hashtag) + try nestedContainer.encode(server) + try nestedContainer.encode(filter) + case .latest: + try container.encode(CodingKeys.latest.rawValue, forKey: .latest) + } + } +} + +extension TimelineFilter: RawRepresentable { + public init?(rawValue: String) { + guard let data = rawValue.data(using: .utf8), + let result = try? JSONDecoder().decode(TimelineFilter.self, from: data) + else { + return nil + } + self = result + } + + public var rawValue: String { + guard let data = try? JSONEncoder().encode(self), + let result = String(data: data, encoding: .utf8) + else { + return "[]" + } + return result + } +} + +extension RemoteTimelineFilter: Codable { + enum CodingKeys: String, CodingKey { + case local + case federated + case trending + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let key = container.allKeys.first + switch key { + case .local: + self = .local + case .federated: + self = .federated + case .trending: + self = .trending + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Unabled to decode enum." + ) + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .local: + try container.encode(CodingKeys.local.rawValue, forKey: .local) + case .federated: + try container.encode(CodingKeys.federated.rawValue, forKey: .federated) + case .trending: + try container.encode(CodingKeys.trending.rawValue, forKey: .trending) + } + } +} + +extension RemoteTimelineFilter: RawRepresentable { + public init?(rawValue: String) { + guard let data = rawValue.data(using: .utf8), + let result = try? JSONDecoder().decode(RemoteTimelineFilter.self, from: data) + else { + return nil + } + self = result + } + + public var rawValue: String { + guard let data = try? JSONEncoder().encode(self), + let result = String(data: data, encoding: .utf8) + else { + return "[]" + } + return result + } +} diff --git a/Threaded/Data/AccountsList.swift b/Threaded/Data/AccountsList.swift new file mode 100644 index 0000000..9efd5ef --- /dev/null +++ b/Threaded/Data/AccountsList.swift @@ -0,0 +1,20 @@ +//Made by Lumaa + +import Foundation + +public struct AccountsList: Codable, Identifiable, Equatable, Hashable { + public let id: String + public let title: String + public let repliesPolicy: RepliesPolicy? + public let exclusive: Bool? + + public enum RepliesPolicy: String, Sendable, Codable, CaseIterable, Identifiable { + public var id: String { + rawValue + } + + case followed, list, `none` + } +} + +extension AccountsList: Sendable {} diff --git a/Threaded/Data/AppInfo.swift b/Threaded/Data/AppInfo.swift new file mode 100644 index 0000000..4e93393 --- /dev/null +++ b/Threaded/Data/AppInfo.swift @@ -0,0 +1,11 @@ +//Made by Lumaa + +import Foundation + +public enum AppInfo { + public static let scopes = "read write follow" + public static let scheme = "threaded://" + public static let clientName = "ThreadedApp" + public static let defaultServer = "mastodon.social" + public static let website = "https://apps.lumaa.fr/" +} diff --git a/Threaded/Data/Client.swift b/Threaded/Data/Client.swift new file mode 100644 index 0000000..71f38a8 --- /dev/null +++ b/Threaded/Data/Client.swift @@ -0,0 +1,278 @@ +//Made by Lumaa + +import Combine +import Foundation +import Observation +import os +import SwiftUI + +@Observable +public final class Client: Equatable, Identifiable, Hashable { + public static func == (lhs: Client, rhs: Client) -> Bool { + let lhsToken = lhs.critical.withLock { $0.oauthToken } + let rhsToken = rhs.critical.withLock { $0.oauthToken } + + return (lhsToken != nil) == (rhsToken != nil) && + lhs.server == rhs.server && + lhsToken?.accessToken == rhsToken?.accessToken + } + + public enum Version: String, Sendable { + case v1, v2 + } + + public enum ClientError: Error { + case unexpectedRequest + } + + public enum OauthError: Error { + case missingApp + case invalidRedirectURL + } + + public var id: String { + critical.withLock { + let isAuth = $0.oauthToken != nil + return "\(isAuth)\(server)\($0.oauthToken?.createdAt ?? 0)" + } + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public let server: String + public let version: Version + private let urlSession: URLSession + private let decoder = JSONDecoder() + + // Putting all mutable state inside an `OSAllocatedUnfairLock` makes `Client` + // provably `Sendable`. The lock is a struct, but it uses a `ManagedBuffer` + // reference type to hold its associated state. + private let critical: OSAllocatedUnfairLock + private struct Critical: Sendable { + /// Only used as a transitionary app while in the oauth flow. + var oauthApp: InstanceApp? + var oauthToken: OauthToken? + var connections: Set = [] + } + + public var isAuth: Bool { + critical.withLock { $0.oauthToken != nil } + } + + public var connections: Set { + critical.withLock { $0.connections } + } + + public init(server: String, version: Version = .v1, oauthToken: OauthToken? = nil) { + self.server = server + self.version = version + critical = .init(initialState: Critical(oauthToken: oauthToken, connections: [server])) + urlSession = URLSession.shared + decoder.keyDecodingStrategy = .convertFromSnakeCase + } + + public func addConnections(_ connections: [String]) { + critical.withLock { + $0.connections.formUnion(connections) + } + } + + public func hasConnection(with url: URL) -> Bool { + guard let host = url.host else { return false } + return critical.withLock { + if let rootHost = host.split(separator: ".", maxSplits: 1).last { + // Sometimes the connection is with the root host instead of a subdomain + // eg. Mastodon runs on mastdon.domain.com but the connection is with domain.com + $0.connections.contains(host) || $0.connections.contains(String(rootHost)) + } else { + $0.connections.contains(host) + } + } + } + + private func makeURL(scheme: String = "https", + endpoint: Endpoint, + forceVersion: Version? = nil, + forceServer: String? = nil) throws -> URL + { + var components = URLComponents() + components.scheme = scheme + components.host = forceServer ?? server + if type(of: endpoint) == Oauth.self { + components.path += "/\(endpoint.path())" + } else { + components.path += "/api/\(forceVersion?.rawValue ?? version.rawValue)/\(endpoint.path())" + } + components.queryItems = endpoint.queryItems() + guard let url = components.url else { + throw ClientError.unexpectedRequest + } + return url + } + + private func makeURLRequest(url: URL, endpoint: Endpoint, httpMethod: String) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = httpMethod + if let oauthToken = critical.withLock({ $0.oauthToken }) { + request.setValue("Bearer \(oauthToken.accessToken)", forHTTPHeaderField: "Authorization") + } + if let json = endpoint.jsonValue { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.outputFormatting = .sortedKeys + do { + let jsonData = try encoder.encode(json) + request.httpBody = jsonData + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } catch { + print("Client Error encoding JSON: \(error.localizedDescription)") + } + } + return request + } + + private func makeGet(endpoint: Endpoint) throws -> URLRequest { + let url = try makeURL(endpoint: endpoint) + return makeURLRequest(url: url, endpoint: endpoint, httpMethod: "GET") + } + + public func get(endpoint: Endpoint, forceVersion: Version? = nil) async throws -> Entity { + try await makeEntityRequest(endpoint: endpoint, method: "GET", forceVersion: forceVersion) + } + + public func getWithLink(endpoint: Endpoint) async throws -> (Entity, LinkHandler?) { + let (data, httpResponse) = try await urlSession.data(for: makeGet(endpoint: endpoint)) + var linkHandler: LinkHandler? + if let response = httpResponse as? HTTPURLResponse, + let link = response.allHeaderFields["Link"] as? String + { + linkHandler = .init(rawLink: link) + } + logResponseOnError(httpResponse: httpResponse, data: data) + return try (decoder.decode(Entity.self, from: data), linkHandler) + } + + public func post(endpoint: Endpoint, forceVersion: Version? = nil) async throws -> Entity { + try await makeEntityRequest(endpoint: endpoint, method: "POST", forceVersion: forceVersion) + } + + public func post(endpoint: Endpoint, forceVersion: Version? = nil) async throws -> HTTPURLResponse? { + let url = try makeURL(endpoint: endpoint, forceVersion: forceVersion) + let request = makeURLRequest(url: url, endpoint: endpoint, httpMethod: "POST") + let (_, httpResponse) = try await urlSession.data(for: request) + return httpResponse as? HTTPURLResponse + } + + public func patch(endpoint: Endpoint) async throws -> HTTPURLResponse? { + let url = try makeURL(endpoint: endpoint) + let request = makeURLRequest(url: url, endpoint: endpoint, httpMethod: "PATCH") + let (_, httpResponse) = try await urlSession.data(for: request) + return httpResponse as? HTTPURLResponse + } + + public func put(endpoint: Endpoint, forceVersion: Version? = nil) async throws -> Entity { + try await makeEntityRequest(endpoint: endpoint, method: "PUT", forceVersion: forceVersion) + } + + public func delete(endpoint: Endpoint, forceVersion: Version? = nil) async throws -> HTTPURLResponse? { + let url = try makeURL(endpoint: endpoint, forceVersion: forceVersion) + let request = makeURLRequest(url: url, endpoint: endpoint, httpMethod: "DELETE") + let (_, httpResponse) = try await urlSession.data(for: request) + return httpResponse as? HTTPURLResponse + } + + private func makeEntityRequest(endpoint: Endpoint, + method: String, + forceVersion: Version? = nil) async throws -> Entity + { + let url = try makeURL(endpoint: endpoint, forceVersion: forceVersion) + let request = makeURLRequest(url: url, endpoint: endpoint, httpMethod: method) + let (data, httpResponse) = try await urlSession.data(for: request) + logResponseOnError(httpResponse: httpResponse, data: data) + do { + return try decoder.decode(Entity.self, from: data) + } catch { + print(error) + if var serverError = try? decoder.decode(ServerError.self, from: data) { + if let httpResponse = httpResponse as? HTTPURLResponse { + serverError.httpCode = httpResponse.statusCode + } + throw serverError + } + throw error + } + } + + public func oauthURL() async throws -> URL { + let app: InstanceApp = try await post(endpoint: Apps.registerApp) + critical.withLock { $0.oauthApp = app } + return try makeURL(endpoint: Oauth.authorize(clientId: app.clientId)) + } + + public func continueOauthFlow(url: URL) async throws -> OauthToken { + guard let app = critical.withLock({ $0.oauthApp }) else { + throw OauthError.missingApp + } + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let code = components.queryItems?.first(where: { $0.name == "code" })?.value + else { + throw OauthError.invalidRedirectURL + } + let token: OauthToken = try await post(endpoint: Oauth.token(code: code, + clientId: app.clientId, + clientSecret: app.clientSecret)) + critical.withLock { $0.oauthToken = token } + return token + } + + public func makeWebSocketTask(endpoint: Endpoint, instanceStreamingURL: URL?) throws -> URLSessionWebSocketTask { + let url = try makeURL(scheme: "wss", endpoint: endpoint, forceServer: instanceStreamingURL?.host) + var subprotocols: [String] = [] + if let oauthToken = critical.withLock({ $0.oauthToken }) { + subprotocols.append(oauthToken.accessToken) + } + return urlSession.webSocketTask(with: url, protocols: subprotocols) + } + + public func mediaUpload(endpoint: Endpoint, + version: Version, + method: String, + mimeType: String, + filename: String, + data: Data) async throws -> Entity + { + let url = try makeURL(endpoint: endpoint, forceVersion: version) + var request = makeURLRequest(url: url, endpoint: endpoint, httpMethod: method) + let boundary = UUID().uuidString + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + let httpBody = NSMutableData() + httpBody.append("--\(boundary)\r\n".data(using: .utf8)!) + httpBody.append("Content-Disposition: form-data; name=\"\(filename)\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) + httpBody.append("Content-Type: \(mimeType)\r\n".data(using: .utf8)!) + httpBody.append("\r\n".data(using: .utf8)!) + httpBody.append(data) + httpBody.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + request.httpBody = httpBody as Data + let (data, httpResponse) = try await urlSession.data(for: request) + logResponseOnError(httpResponse: httpResponse, data: data) + do { + return try decoder.decode(Entity.self, from: data) + } catch { + if let serverError = try? decoder.decode(ServerError.self, from: data) { + throw serverError + } + throw error + } + } + + private func logResponseOnError(httpResponse: URLResponse, data: Data) { + if let httpResponse = httpResponse as? HTTPURLResponse, httpResponse.statusCode > 299 { + print(httpResponse) + print(String(data: data, encoding: .utf8) ?? "") + } + } +} + +extension Client: Sendable {} diff --git a/Threaded/Data/Emoji.swift b/Threaded/Data/Emoji.swift new file mode 100644 index 0000000..ba05878 --- /dev/null +++ b/Threaded/Data/Emoji.swift @@ -0,0 +1,19 @@ +//Made by Lumaa + +import Foundation + +public struct Emoji: Codable, Hashable, Identifiable, Equatable, Sendable { + public func hash(into hasher: inout Hasher) { + hasher.combine(shortcode) + } + + public var id: String { + shortcode + } + + public let shortcode: String + public let url: URL + public let staticUrl: URL + public let visibleInPicker: Bool + public let category: String? +} diff --git a/Threaded/Data/FetchTimeline.swift b/Threaded/Data/FetchTimeline.swift new file mode 100644 index 0000000..efb0130 --- /dev/null +++ b/Threaded/Data/FetchTimeline.swift @@ -0,0 +1,42 @@ +//Made by Lumaa + +import Foundation + +struct FetchTimeline { + var client: Client + private var datasource: [Status] = [] + public var statusesState: LoadingState = .loading + + private var timeline: TimelineFilter = .home + + init(client: Client) { + self.client = client + } + + public mutating func fetch(client: Client) async { + self.statusesState = .loading + self.datasource = await fetchNewestStatuses() + } + + private mutating func fetchNewestStatuses() async -> [Status] { + do { + let statuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil, maxId: nil, minId: nil, offset: 0)) + self.statusesState = .loaded + return statuses + } catch { + statusesState = .error(error: error) + print("timeline parse error: \(error)") + } + return [] + } + + func getStatuses() -> [Status] { + return datasource + } + + enum LoadingState { + case loading + case loaded + case error(error: Error) + } +} diff --git a/Threaded/Data/HTMLString.swift b/Threaded/Data/HTMLString.swift new file mode 100644 index 0000000..7aee77b --- /dev/null +++ b/Threaded/Data/HTMLString.swift @@ -0,0 +1,293 @@ +//Made by Lumaa + +import Foundation +import SwiftSoup +import SwiftUI + +private enum CodingKeys: CodingKey { + case htmlValue, asMarkdown, asRawText, statusesURLs, links +} + +public struct HTMLString: Codable, Equatable, Hashable, @unchecked Sendable { + public var htmlValue: String = "" + public var asMarkdown: String = "" + public var asRawText: String = "" + public var statusesURLs = [URL]() + public private(set) var links = [Link]() + + public var asSafeMarkdownAttributedString: AttributedString = .init() + private var main_regex: NSRegularExpression? + private var underscore_regex: NSRegularExpression? + public init(from decoder: Decoder) { + var alreadyDecoded = false + do { + let container = try decoder.singleValueContainer() + htmlValue = try container.decode(String.self) + } catch { + do { + alreadyDecoded = true + let container = try decoder.container(keyedBy: CodingKeys.self) + htmlValue = try container.decode(String.self, forKey: .htmlValue) + asMarkdown = try container.decode(String.self, forKey: .asMarkdown) + asRawText = try container.decode(String.self, forKey: .asRawText) + statusesURLs = try container.decode([URL].self, forKey: .statusesURLs) + links = try container.decode([Link].self, forKey: .links) + } catch { + htmlValue = "" + } + } + + if !alreadyDecoded { + // https://daringfireball.net/projects/markdown/syntax + // Pre-escape \ ` _ * ~ and [ as these are the only + // characters the markdown parser uses when it renders + // to attributed text. Note that ~ for strikethrough is + // not documented in the syntax docs but is used by + // AttributedString. + main_regex = try? NSRegularExpression(pattern: "([\\*\\`\\~\\[\\\\])", options: .caseInsensitive) + // don't escape underscores that are between colons, they are most likely custom emoji + underscore_regex = try? NSRegularExpression(pattern: "(?!\\B:[^:]*)(_)(?![^:]*:\\B)", options: .caseInsensitive) + + asMarkdown = "" + do { + let document: Document = try SwiftSoup.parse(htmlValue) + handleNode(node: document) + + document.outputSettings(OutputSettings().prettyPrint(pretty: false)) + try document.select("br").after("\n") + try document.select("p").after("\n\n") + let html = try document.html() + var text = try SwiftSoup.clean(html, "", Whitelist.none(), OutputSettings().prettyPrint(pretty: false)) ?? "" + // Remove the two last line break added after the last paragraph. + if text.hasSuffix("\n\n") { + _ = text.removeLast() + _ = text.removeLast() + } + asRawText = text + + if asMarkdown.hasPrefix("\n") { + _ = asMarkdown.removeFirst() + } + + } catch { + asRawText = htmlValue + } + } + + do { + let options = AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true, + interpretedSyntax: .inlineOnlyPreservingWhitespace) + asSafeMarkdownAttributedString = try AttributedString(markdown: asMarkdown, options: options) + } catch { + asSafeMarkdownAttributedString = AttributedString(stringLiteral: htmlValue) + } + } + + public init(stringValue: String, parseMarkdown: Bool = false) { + htmlValue = stringValue + asMarkdown = stringValue + asRawText = stringValue + statusesURLs = [] + + if parseMarkdown { + do { + let options = AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true, + interpretedSyntax: .inlineOnlyPreservingWhitespace) + asSafeMarkdownAttributedString = try AttributedString(markdown: asMarkdown, options: options) + } catch { + asSafeMarkdownAttributedString = AttributedString(stringLiteral: htmlValue) + } + } else { + asSafeMarkdownAttributedString = AttributedString(stringLiteral: htmlValue) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(htmlValue, forKey: .htmlValue) + try container.encode(asMarkdown, forKey: .asMarkdown) + try container.encode(asRawText, forKey: .asRawText) + try container.encode(statusesURLs, forKey: .statusesURLs) + try container.encode(links, forKey: .links) + } + + private mutating func handleNode(node: SwiftSoup.Node) { + do { + if let className = try? node.attr("class") { + if className == "invisible" { + // don't display + return + } + + if className == "ellipsis" { + // descend into this one now and + // append the ellipsis + for nn in node.getChildNodes() { + handleNode(node: nn) + } + asMarkdown += "…" + return + } + } + + if node.nodeName() == "p" { + if asMarkdown.count > 0 { // ignore first opening

+ asMarkdown += "\n\n" + } + } else if node.nodeName() == "br" { + if asMarkdown.count > 0 { // ignore first opening
+ asMarkdown += "\n" + } + } else if node.nodeName() == "a" { + let href = try node.attr("href") + if href != "" { + if let url = URL(string: href), + let _ = Int(url.lastPathComponent) + { + statusesURLs.append(url) + } + } + asMarkdown += "[" + let start = asMarkdown.endIndex + // descend into this node now so we can wrap the + // inner part of the link in the right markup + for nn in node.getChildNodes() { + handleNode(node: nn) + } + let finish = asMarkdown.endIndex + + var linkRef = href + + // Try creating a URL from the string. If it fails, try URL encoding + // the string first. + var url = URL(string: href) + if url == nil { + url = URL(string: href, encodePath: true) + } + if let linkUrl = url { + linkRef = linkUrl.absoluteString + let displayString = asMarkdown[start ..< finish] + links.append(Link(linkUrl, displayString: String(displayString))) + } + + asMarkdown += "](" + asMarkdown += linkRef + asMarkdown += ")" + + return + } else if node.nodeName() == "#text" { + var txt = node.description + + if let underscore_regex, let main_regex { + // This is the markdown escaper + txt = main_regex.stringByReplacingMatches(in: txt, options: [], range: NSRange(location: 0, length: txt.count), withTemplate: "\\\\$1") + txt = underscore_regex.stringByReplacingMatches(in: txt, options: [], range: NSRange(location: 0, length: txt.count), withTemplate: "\\\\$1") + } + // Strip newlines and line separators - they should be being sent as
s + asMarkdown += txt.replacingOccurrences(of: "\n", with: "").replacingOccurrences(of: "\u{2028}", with: "") + } else if node.nodeName() == "ul" { + // Unordered (bulleted) list + // SwiftUI's Text won't display these in an AttributedString, but we can at least improve the appearance + asMarkdown += "\n\n" + for nn in node.getChildNodes() { + asMarkdown += "- " + handleNode(node: nn) + asMarkdown += "\n" + } + return + } else if node.nodeName() == "ol" { + // Ordered (numbered) list + // Same thing, won't display in a Text, but this is just an attempt to improve the appearance + asMarkdown += "\n\n" + var curNumber = 1 + for nn in node.getChildNodes() { + asMarkdown += "\(curNumber). " + handleNode(node: nn) + asMarkdown += "\n" + curNumber += 1 + } + return + } + + for n in node.getChildNodes() { + handleNode(node: n) + } + } catch {} + } + + public struct Link: Codable, Hashable, Identifiable { + public var id: Int { hashValue } + public let url: URL + public let displayString: String + public let type: LinkType + public let title: String + + init(_ url: URL, displayString: String) { + self.url = url + self.displayString = displayString + + switch displayString.first { + case "@": + type = .mention + title = displayString + case "#": + type = .hashtag + title = String(displayString.dropFirst()) + default: + type = .url + var hostNameUrl = url.host ?? url.absoluteString + if hostNameUrl.hasPrefix("www.") { + hostNameUrl = String(hostNameUrl.dropFirst(4)) + } + title = hostNameUrl + } + } + + public enum LinkType: String, Codable { + case url + case mention + case hashtag + } + } +} + +public extension URL { + // It's common to use non-ASCII characters in URLs even though they're technically + // invalid characters. Every modern browser handles this by silently encoding + // the invalid characters on the user's behalf. However, trying to create a URL + // object with un-encoded characters will result in nil so we need to encode the + // invalid characters before creating the URL object. The unencoded version + // should still be shown in the displayed status. + init?(string: String, encodePath: Bool) { + var encodedUrlString = "" + if encodePath, + string.starts(with: "http://") || string.starts(with: "https://"), + var startIndex = string.firstIndex(of: "/") + { + startIndex = string.index(startIndex, offsetBy: 1) + + // We don't want to encode the host portion of the URL + if var startIndex = string[startIndex...].firstIndex(of: "/") { + encodedUrlString = String(string[...startIndex]) + while let endIndex = string[string.index(after: startIndex)...].firstIndex(of: "/") { + let componentStartIndex = string.index(after: startIndex) + encodedUrlString = encodedUrlString + (string[componentStartIndex ... endIndex].addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "") + startIndex = endIndex + } + + // The last part of the path may have a query string appended to it + let componentStartIndex = string.index(after: startIndex) + if let queryStartIndex = string[componentStartIndex...].firstIndex(of: "?") { + encodedUrlString = encodedUrlString + (string[componentStartIndex ..< queryStartIndex].addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "") + encodedUrlString = encodedUrlString + (string[queryStartIndex...].addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "") + } else { + encodedUrlString = encodedUrlString + (string[componentStartIndex...].addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "") + } + } + } + if encodedUrlString.isEmpty { + encodedUrlString = string + } + self.init(string: encodedUrlString) + } +} diff --git a/Threaded/Data/HapticManager.swift b/Threaded/Data/HapticManager.swift new file mode 100644 index 0000000..e58a138 --- /dev/null +++ b/Threaded/Data/HapticManager.swift @@ -0,0 +1,67 @@ +//Made by Lumaa + +import Foundation +import CoreHaptics + +struct Haptic: Hashable { + var intensity: CGFloat + var sharpness: CGFloat + var interval: CGFloat + + static let tap: [Haptic] = [Haptic(intensity: 0.5, sharpness: 0.8, interval: 0.0)] + static let error: [Haptic] = [ + Haptic(intensity: 1.0, sharpness: 0.7, interval: 0.0), + Haptic(intensity: 1.0, sharpness: 0.3, interval: 0.2) + ] +} + +class HapticManager { + private static var supportsHaptics: Bool = false + private static var engine: CHHapticEngine? + + static func playHaptics(haptics: [Haptic]) { + guard supportsHaptics else { return } + var events = [CHHapticEvent]() + let hapticIntensity: [CGFloat] = haptics.map { $0.intensity } + let hapticSharpness: [CGFloat] = haptics.map { $0.sharpness } + let intervals: [CGFloat] = haptics.map({ $0.interval }) + + for index in 0.. String + func queryItems() -> [URLQueryItem]? + var jsonValue: Encodable? { get } +} + +public extension Endpoint { + var jsonValue: Encodable? { + nil + } +} + +extension Endpoint { + func makePaginationParam(sinceId: String?, maxId: String?, mindId: String?) -> [URLQueryItem]? { + if let sinceId { + return [.init(name: "since_id", value: sinceId)] + } else if let maxId { + return [.init(name: "max_id", value: maxId)] + } else if let mindId { + return [.init(name: "min_id", value: mindId)] + } + return nil + } +} + +public struct LinkHandler { + public let rawLink: String + + public var maxId: String? { + do { + let regex = try Regex("max_id=[0-9]+") + if let match = rawLink.firstMatch(of: regex) { + return match.output.first?.substring?.replacingOccurrences(of: "max_id=", with: "") + } + } catch { + return nil + } + return nil + } +} + +extension LinkHandler: Sendable {} + +public struct ServerError: Decodable, Error { + public let error: String? + public var httpCode: Int? +} + +extension ServerError: Sendable {} + +public enum Apps: Endpoint { + case registerApp + + public func path() -> String { + switch self { + case .registerApp: + "apps" + } + } + + public func queryItems() -> [URLQueryItem]? { + switch self { + case .registerApp: + return [ + .init(name: "client_name", value: AppInfo.clientName), + .init(name: "redirect_uris", value: AppInfo.scheme), + .init(name: "scopes", value: AppInfo.scopes), + .init(name: "website", value: AppInfo.website), + ] + } + } +} + +public enum Instances: Endpoint { + case instance + case peers + + public func path() -> String { + switch self { + case .instance: + "instance" + case .peers: + "instance/peers" + } + } + + public func queryItems() -> [URLQueryItem]? { + nil + } +} + +public enum Accounts: Endpoint { + case accounts(id: String) + case lookup(name: String) + case favorites(sinceId: String?) + case bookmarks(sinceId: String?) + case followedTags + case featuredTags(id: String) + case verifyCredentials + case updateCredentials(json: UpdateCredentialsData) + case statuses(id: String, + sinceId: String?, + tag: String?, + onlyMedia: Bool?, + excludeReplies: Bool?, + pinned: Bool?) + case relationships(ids: [String]) + case follow(id: String, notify: Bool, reblogs: Bool) + case unfollow(id: String) + case familiarFollowers(withAccount: String) + case suggestions + case followers(id: String, maxId: String?) + case following(id: String, maxId: String?) + case lists(id: String) + case preferences + case block(id: String) + case unblock(id: String) + case mute(id: String, json: MuteData) + case unmute(id: String) + case relationshipNote(id: String, json: RelationshipNoteData) + + public func path() -> String { + switch self { + case let .accounts(id): + "accounts/\(id)" + case .lookup: + "accounts/lookup" + case .favorites: + "favourites" + case .bookmarks: + "bookmarks" + case .followedTags: + "followed_tags" + case let .featuredTags(id): + "accounts/\(id)/featured_tags" + case .verifyCredentials: + "accounts/verify_credentials" + case .updateCredentials: + "accounts/update_credentials" + case let .statuses(id, _, _, _, _, _): + "accounts/\(id)/statuses" + case .relationships: + "accounts/relationships" + case let .follow(id, _, _): + "accounts/\(id)/follow" + case let .unfollow(id): + "accounts/\(id)/unfollow" + case .familiarFollowers: + "accounts/familiar_followers" + case .suggestions: + "suggestions" + case let .following(id, _): + "accounts/\(id)/following" + case let .followers(id, _): + "accounts/\(id)/followers" + case let .lists(id): + "accounts/\(id)/lists" + case .preferences: + "preferences" + case let .block(id): + "accounts/\(id)/block" + case let .unblock(id): + "accounts/\(id)/unblock" + case let .mute(id, _): + "accounts/\(id)/mute" + case let .unmute(id): + "accounts/\(id)/unmute" + case let .relationshipNote(id, _): + "accounts/\(id)/note" + } + } + + public func queryItems() -> [URLQueryItem]? { + switch self { + case let .lookup(name): + return [ + .init(name: "acct", value: name), + ] + case let .statuses(_, sinceId, tag, onlyMedia, excludeReplies, pinned): + var params: [URLQueryItem] = [] + if let tag { + params.append(.init(name: "tagged", value: tag)) + } + if let sinceId { + params.append(.init(name: "max_id", value: sinceId)) + } + if let onlyMedia { + params.append(.init(name: "only_media", value: onlyMedia ? "true" : "false")) + } + if let excludeReplies { + params.append(.init(name: "exclude_replies", value: excludeReplies ? "true" : "false")) + } + if let pinned { + params.append(.init(name: "pinned", value: pinned ? "true" : "false")) + } + return params + case let .relationships(ids): + return ids.map { + URLQueryItem(name: "id[]", value: $0) + } + case let .follow(_, notify, reblogs): + return [ + .init(name: "notify", value: notify ? "true" : "false"), + .init(name: "reblogs", value: reblogs ? "true" : "false"), + ] + case let .familiarFollowers(withAccount): + return [.init(name: "id[]", value: withAccount)] + case let .followers(_, maxId): + return makePaginationParam(sinceId: nil, maxId: maxId, mindId: nil) + case let .following(_, maxId): + return makePaginationParam(sinceId: nil, maxId: maxId, mindId: nil) + case let .favorites(sinceId): + guard let sinceId else { return nil } + return [.init(name: "max_id", value: sinceId)] + case let .bookmarks(sinceId): + guard let sinceId else { return nil } + return [.init(name: "max_id", value: sinceId)] + default: + return nil + } + } + + public var jsonValue: Encodable? { + switch self { + case let .mute(_, json): + json + case let .relationshipNote(_, json): + json + case let .updateCredentials(json): + json + default: + nil + } + } +} + +public struct MuteData: Encodable, Sendable { + public let duration: Int + + public init(duration: Int) { + self.duration = duration + } +} + +public struct RelationshipNoteData: Encodable, Sendable { + public let comment: String + + public init(note comment: String) { + self.comment = comment + } +} + +public struct UpdateCredentialsData: Encodable, Sendable { + public struct SourceData: Encodable, Sendable { + public let privacy: Visibility + public let sensitive: Bool + + public init(privacy: Visibility, sensitive: Bool) { + self.privacy = privacy + self.sensitive = sensitive + } + } + + public struct FieldData: Encodable, Sendable { + public let name: String + public let value: String + + public init(name: String, value: String) { + self.name = name + self.value = value + } + } + + public let displayName: String + public let note: String + public let source: SourceData + public let bot: Bool + public let locked: Bool + public let discoverable: Bool + public let fieldsAttributes: [String: FieldData] + + public init(displayName: String, + note: String, + source: UpdateCredentialsData.SourceData, + bot: Bool, + locked: Bool, + discoverable: Bool, + fieldsAttributes: [FieldData]) + { + self.displayName = displayName + self.note = note + self.source = source + self.bot = bot + self.locked = locked + self.discoverable = discoverable + + var fieldAttributes: [String: FieldData] = [:] + for (index, field) in fieldsAttributes.enumerated() { + fieldAttributes[String(index)] = field + } + self.fieldsAttributes = fieldAttributes + } +} + +public enum Timelines: Endpoint { + case pub(sinceId: String?, maxId: String?, minId: String?, local: Bool) + case home(sinceId: String?, maxId: String?, minId: String?) + case list(listId: String, sinceId: String?, maxId: String?, minId: String?) + case hashtag(tag: String, additional: [String]?, maxId: String?) + + public func path() -> String { + switch self { + case .pub: + "timelines/public" + case .home: + "timelines/home" + case let .list(listId, _, _, _): + "timelines/list/\(listId)" + case let .hashtag(tag, _, _): + "timelines/tag/\(tag)" + } + } + + public func queryItems() -> [URLQueryItem]? { + switch self { + case let .pub(sinceId, maxId, minId, local): + var params = makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: minId) ?? [] + params.append(.init(name: "local", value: local ? "true" : "false")) + return params + case let .home(sinceId, maxId, mindId): + return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId) + case let .list(_, sinceId, maxId, mindId): + return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId) + case let .hashtag(_, additional, maxId): + var params = makePaginationParam(sinceId: nil, maxId: maxId, mindId: nil) ?? [] + params.append(contentsOf: (additional ?? []) + .map { URLQueryItem(name: "any[]", value: $0) }) + return params + } + } +} + +public enum Statuses: Endpoint { + case postStatus(json: StatusData) + case editStatus(id: String, json: StatusData) + case status(id: String) + case context(id: String) + case favorite(id: String) + case unfavorite(id: String) + case reblog(id: String) + case unreblog(id: String) + case rebloggedBy(id: String, maxId: String?) + case favoritedBy(id: String, maxId: String?) + case pin(id: String) + case unpin(id: String) + case bookmark(id: String) + case unbookmark(id: String) + case history(id: String) + case translate(id: String, lang: String?) + case report(accountId: String, statusId: String, comment: String) + + public func path() -> String { + switch self { + case .postStatus: + "statuses" + case let .status(id): + "statuses/\(id)" + case let .editStatus(id, _): + "statuses/\(id)" + case let .context(id): + "statuses/\(id)/context" + case let .favorite(id): + "statuses/\(id)/favourite" + case let .unfavorite(id): + "statuses/\(id)/unfavourite" + case let .reblog(id): + "statuses/\(id)/reblog" + case let .unreblog(id): + "statuses/\(id)/unreblog" + case let .rebloggedBy(id, _): + "statuses/\(id)/reblogged_by" + case let .favoritedBy(id, _): + "statuses/\(id)/favourited_by" + case let .pin(id): + "statuses/\(id)/pin" + case let .unpin(id): + "statuses/\(id)/unpin" + case let .bookmark(id): + "statuses/\(id)/bookmark" + case let .unbookmark(id): + "statuses/\(id)/unbookmark" + case let .history(id): + "statuses/\(id)/history" + case let .translate(id, _): + "statuses/\(id)/translate" + case .report: + "reports" + } + } + + public func queryItems() -> [URLQueryItem]? { + switch self { + case let .rebloggedBy(_, maxId): + return makePaginationParam(sinceId: nil, maxId: maxId, mindId: nil) + case let .favoritedBy(_, maxId): + return makePaginationParam(sinceId: nil, maxId: maxId, mindId: nil) + case let .translate(_, lang): + if let lang { + return [.init(name: "lang", value: lang)] + } + return nil + case let .report(accountId, statusId, comment): + return [.init(name: "account_id", value: accountId), + .init(name: "status_ids[]", value: statusId), + .init(name: "comment", value: comment)] + default: + return nil + } + } + + public var jsonValue: Encodable? { + switch self { + case let .postStatus(json): + json + case let .editStatus(_, json): + json + default: + nil + } + } +} + +public struct StatusData: Encodable, Sendable { + public let status: String + public let visibility: Visibility + public let inReplyToId: String? + public let spoilerText: String? + public let mediaIds: [String]? + public let poll: PollData? + public let language: String? + public let mediaAttributes: [MediaAttribute]? + + public struct PollData: Encodable, Sendable { + public let options: [String] + public let multiple: Bool + public let expires_in: Int + + public init(options: [String], multiple: Bool, expires_in: Int) { + self.options = options + self.multiple = multiple + self.expires_in = expires_in + } + } + + public struct MediaAttribute: Encodable, Sendable { + public let id: String + public let description: String? + public let thumbnail: String? + public let focus: String? + + public init(id: String, description: String?, thumbnail: String?, focus: String?) { + self.id = id + self.description = description + self.thumbnail = thumbnail + self.focus = focus + } + } + + public init(status: String, + visibility: Visibility, + inReplyToId: String? = nil, + spoilerText: String? = nil, + mediaIds: [String]? = nil, + poll: PollData? = nil, + language: String? = nil, + mediaAttributes: [MediaAttribute]? = nil) + { + self.status = status + self.visibility = visibility + self.inReplyToId = inReplyToId + self.spoilerText = spoilerText + self.mediaIds = mediaIds + self.poll = poll + self.language = language + self.mediaAttributes = mediaAttributes + } +} + +public enum Trends: Endpoint { + case tags + case statuses(offset: Int?) + case links + + public func path() -> String { + switch self { + case .tags: + "trends/tags" + case .statuses: + "trends/statuses" + case .links: + "trends/links" + } + } + + public func queryItems() -> [URLQueryItem]? { + switch self { + case let .statuses(offset): + if let offset { + return [.init(name: "offset", value: String(offset))] + } + return nil + default: + return nil + } + } +} diff --git a/Threaded/Data/Navigator.swift b/Threaded/Data/Navigator.swift new file mode 100644 index 0000000..1557a7a --- /dev/null +++ b/Threaded/Data/Navigator.swift @@ -0,0 +1,121 @@ +//Made by Lumaa + +import Foundation +import SwiftUI + +@Observable +public class Navigator { + public var path: [RouterDestination] = [] + public var presentedSheet: SheetDestination? + public var selectedTab: TabDestination = .timeline + + public func navigate(to: RouterDestination) { + path.append(to) + } +} + +public enum TabDestination: Identifiable { + case timeline + case search + case activity + case profile + + public var id: String { + switch self { + case .timeline: + return "timeline" + case .search: + return "search" + case .activity: + return "activity" + case .profile: + return "profile" + } + } +} + +public enum SheetDestination: Identifiable { + case welcome + case mastodonLogin(logged: Binding) + case post + + public var id: String { + switch self { + case .welcome: + return "welcome" + case .mastodonLogin: + return "login" + case .post: + return "post" + } + } + + public var isCover: Bool { + switch self { + case .welcome: + return true + + case .mastodonLogin: + return false + + case .post: + return false + } + } +} + +public enum RouterDestination: Hashable { + case settings + case privacy + case account(acc: Account) +} + +extension View { + func withAppRouter() -> some View { + navigationDestination(for: RouterDestination.self) { destination in + switch destination { + case .settings: + SettingsView() + case .privacy: + PrivacyView() + case .account(let acc): + AccountView(account: acc) + } + } + } + + func withSheets(sheetDestination: Binding) -> some View { + sheet(item: sheetDestination) { destination in + viewRepresentation(destination: destination, isCover: false) + } + } + + func withCovers(sheetDestination: Binding) -> some View { + fullScreenCover(item: sheetDestination) { destination in + viewRepresentation(destination: destination, isCover: true) + } + } + + private func viewRepresentation(destination: SheetDestination, isCover: Bool) -> some View { + Group { + if destination.isCover { + switch destination { + case .welcome: + ConnectView() + default: + EmptyView() + } + } else { + switch destination { + case .post: + Text("Posting view") + case let .mastodonLogin(logged): + AddInstanceView(logged: logged) + .tint(Color.accentColor) + default: + EmptyView() + } + } + } + } +} diff --git a/Threaded/Data/ShareableImage.swift b/Threaded/Data/ShareableImage.swift new file mode 100644 index 0000000..1dbc4b6 --- /dev/null +++ b/Threaded/Data/ShareableImage.swift @@ -0,0 +1,22 @@ +//Made by Lumaa + +import Foundation +import SwiftUI + +struct AsyncImageTransferable: Codable, Transferable { + let url: URL + + func fetchAsImage() async -> Image { + let data = try? await URLSession.shared.data(from: url).0 + guard let data, let uiimage = UIImage(data: data) else { + return Image(systemName: "photo") + } + return Image(uiImage: uiimage) + } + + static var transferRepresentation: some TransferRepresentation { + ProxyRepresentation { media in + await media.fetchAsImage() + } + } +} diff --git a/Threaded/Data/Status.swift b/Threaded/Data/Status.swift new file mode 100644 index 0000000..1a2d306 --- /dev/null +++ b/Threaded/Data/Status.swift @@ -0,0 +1,421 @@ +//Made by Lumaa + +import Foundation + +public final class Status: AnyStatus, Codable, Identifiable, Equatable, Hashable { + public static func == (lhs: Status, rhs: Status) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public let id: String + public let content: HTMLString + public let account: Account + public let createdAt: ServerDate + public let editedAt: ServerDate? + public let reblog: ReblogStatus? + public let mediaAttachments: [MediaAttachment] + public let mentions: [Mention] + public let repliesCount: Int + public let reblogsCount: Int + public let favouritesCount: Int + public let card: Card? + public let favourited: Bool? + public let reblogged: Bool? + public let pinned: Bool? + public let bookmarked: Bool? + public let emojis: [Emoji] + public let url: String? + public let application: Application? + public let inReplyToId: String? + public let inReplyToAccountId: String? + public let visibility: Visibility + public let poll: Poll? + public let spoilerText: HTMLString + public let filtered: [Filtered]? + public let sensitive: Bool + public let language: String? + + public init(id: String, content: HTMLString, account: Account, createdAt: ServerDate, editedAt: ServerDate?, reblog: ReblogStatus?, mediaAttachments: [MediaAttachment], mentions: [Mention], repliesCount: Int, reblogsCount: Int, favouritesCount: Int, card: Card?, favourited: Bool?, reblogged: Bool?, pinned: Bool?, bookmarked: Bool?, emojis: [Emoji], url: String?, application: Application?, inReplyToId: String?, inReplyToAccountId: String?, visibility: Visibility, poll: Poll?, spoilerText: HTMLString, filtered: [Filtered]?, sensitive: Bool, language: String?) { + self.id = id + self.content = content + self.account = account + self.createdAt = createdAt + self.editedAt = editedAt + self.reblog = reblog + self.mediaAttachments = mediaAttachments + self.mentions = mentions + self.repliesCount = repliesCount + self.reblogsCount = reblogsCount + self.favouritesCount = favouritesCount + self.card = card + self.favourited = favourited + self.reblogged = reblogged + self.pinned = pinned + self.bookmarked = bookmarked + self.emojis = emojis + self.url = url + self.application = application + self.inReplyToId = inReplyToId + self.inReplyToAccountId = inReplyToAccountId + self.visibility = visibility + self.poll = poll + self.spoilerText = spoilerText + self.filtered = filtered + self.sensitive = sensitive + self.language = language + } + + public static func placeholder(forSettings: Bool = false, language: String? = nil) -> Status { + .init(id: UUID().uuidString, + content: .init(stringValue: "Here's to the [#crazy](#) ones", + parseMarkdown: forSettings), + + account: .placeholder(), + createdAt: ServerDate(), + editedAt: nil, + reblog: nil, + mediaAttachments: [], + mentions: [], + repliesCount: 2, + reblogsCount: 1, + favouritesCount: 3, + card: nil, + favourited: false, + reblogged: false, + pinned: false, + bookmarked: false, + emojis: [], + url: "https://example.com", + application: nil, + inReplyToId: nil, + inReplyToAccountId: nil, + visibility: .pub, + poll: nil, + spoilerText: .init(stringValue: ""), + filtered: [], + sensitive: false, + language: language) + } + + public static func placeholders() -> [Status] { + [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] + } + + public var reblogAsAsStatus: Status? { + if let reblog { + return .init(id: reblog.id, + content: reblog.content, + account: reblog.account, + createdAt: reblog.createdAt, + editedAt: reblog.editedAt, + reblog: nil, + mediaAttachments: reblog.mediaAttachments, + mentions: reblog.mentions, + repliesCount: reblog.repliesCount, + reblogsCount: reblog.reblogsCount, + favouritesCount: reblog.favouritesCount, + card: reblog.card, + favourited: reblog.favourited, + reblogged: reblog.reblogged, + pinned: reblog.pinned, + bookmarked: reblog.bookmarked, + emojis: reblog.emojis, + url: reblog.url, + application: reblog.application, + inReplyToId: reblog.inReplyToId, + inReplyToAccountId: reblog.inReplyToAccountId, + visibility: reblog.visibility, + poll: reblog.poll, + spoilerText: reblog.spoilerText, + filtered: reblog.filtered, + sensitive: reblog.sensitive, + language: reblog.language) + } + return nil + } +} + +public final class ReblogStatus: AnyStatus, Codable, Identifiable, Equatable, Hashable { + public static func == (lhs: ReblogStatus, rhs: ReblogStatus) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public let id: String + public let content: HTMLString + public let account: Account + public let createdAt: ServerDate + public let editedAt: ServerDate? + public let mediaAttachments: [MediaAttachment] + public let mentions: [Mention] + public let repliesCount: Int + public let reblogsCount: Int + public let favouritesCount: Int + public let card: Card? + public let favourited: Bool? + public let reblogged: Bool? + public let pinned: Bool? + public let bookmarked: Bool? + public let emojis: [Emoji] + public let url: String? + public let application: Application? + public let inReplyToId: String? + public let inReplyToAccountId: String? + public let visibility: Visibility + public let poll: Poll? + public let spoilerText: HTMLString + public let filtered: [Filtered]? + public let sensitive: Bool + public let language: String? + + public init(id: String, content: HTMLString, account: Account, createdAt: ServerDate, editedAt: ServerDate?, mediaAttachments: [MediaAttachment], mentions: [Mention], repliesCount: Int, reblogsCount: Int, favouritesCount: Int, card: Card?, favourited: Bool?, reblogged: Bool?, pinned: Bool?, bookmarked: Bool?, emojis: [Emoji], url: String?, application: Application? = nil, inReplyToId: String?, inReplyToAccountId: String?, visibility: Visibility, poll: Poll?, spoilerText: HTMLString, filtered: [Filtered]?, sensitive: Bool, language: String?) { + self.id = id + self.content = content + self.account = account + self.createdAt = createdAt + self.editedAt = editedAt + self.mediaAttachments = mediaAttachments + self.mentions = mentions + self.repliesCount = repliesCount + self.reblogsCount = reblogsCount + self.favouritesCount = favouritesCount + self.card = card + self.favourited = favourited + self.reblogged = reblogged + self.pinned = pinned + self.bookmarked = bookmarked + self.emojis = emojis + self.url = url + self.application = application + self.inReplyToId = inReplyToId + self.inReplyToAccountId = inReplyToAccountId + self.visibility = visibility + self.poll = poll + self.spoilerText = spoilerText + self.filtered = filtered + self.sensitive = sensitive + self.language = language + } +} + +// Every property in Status is immutable. +extension Status: Sendable {} + +// Every property in ReblogStatus is immutable. +extension ReblogStatus: Sendable {} + +public protocol AnyStatus { + var id: String { get } + var content: HTMLString { get } + var account: Account { get } + var createdAt: ServerDate { get } + var editedAt: ServerDate? { get } + var mediaAttachments: [MediaAttachment] { get } + var mentions: [Mention] { get } + var repliesCount: Int { get } + var reblogsCount: Int { get } + var favouritesCount: Int { get } + var card: Card? { get } + var favourited: Bool? { get } + var reblogged: Bool? { get } + var pinned: Bool? { get } + var bookmarked: Bool? { get } + var emojis: [Emoji] { get } + var url: String? { get } + var application: Application? { get } + var inReplyToId: String? { get } + var inReplyToAccountId: String? { get } + var visibility: Visibility { get } + var poll: Poll? { get } + var spoilerText: HTMLString { get } + var filtered: [Filtered]? { get } + var sensitive: Bool { get } + var language: String? { get } +} + +public struct MediaAttachment: Codable, Identifiable, Hashable, Equatable { + public struct MetaContainer: Codable, Equatable { + public struct Meta: Codable, Equatable { + public let width: Int? + public let height: Int? + } + + public let original: Meta? + } + + public enum SupportedType: String { + case image, gifv, video, audio + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public let id: String + public let type: String + public var supportedType: SupportedType? { + SupportedType(rawValue: type) + } + + public var localizedTypeDescription: String? { + if let supportedType { + switch supportedType { + case .image: + return NSLocalizedString("accessibility.media.supported-type.image.label", bundle: .main, comment: "A localized description of SupportedType.image") + case .gifv: + return NSLocalizedString("accessibility.media.supported-type.gifv.label", bundle: .main, comment: "A localized description of SupportedType.gifv") + case .video: + return NSLocalizedString("accessibility.media.supported-type.video.label", bundle: .main, comment: "A localized description of SupportedType.video") + case .audio: + return NSLocalizedString("accessibility.media.supported-type.audio.label", bundle: .main, comment: "A localized description of SupportedType.audio") + } + } + return nil + } + + public let url: URL? + public let previewUrl: URL? + public let description: String? + public let meta: MetaContainer? + + public static func imageWith(url: URL) -> MediaAttachment { + .init(id: UUID().uuidString, + type: "image", + url: url, + previewUrl: url, + description: "Alternative text", + meta: nil) + } +} + +extension MediaAttachment: Sendable {} +extension MediaAttachment.MetaContainer: Sendable {} +extension MediaAttachment.MetaContainer.Meta: Sendable {} +extension MediaAttachment.SupportedType: Sendable {} + +public struct Mention: Codable, Equatable, Hashable { + public let id: String + public let username: String + public let url: URL + public let acct: String +} + +extension Mention: Sendable {} + +public struct Card: Codable, Identifiable, Equatable, Hashable { + public var id: String { + url + } + + public let url: String + public let title: String? + public let description: String? + public let type: String + public let image: URL? +} + +extension Card: Sendable {} + +public struct Application: Codable, Identifiable, Hashable, Equatable, Sendable { + public var id: String { + name + } + + public let name: String + public let website: URL? +} + +public extension Application { + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + + name = try values.decodeIfPresent(String.self, forKey: .name) ?? "" + website = try? values.decodeIfPresent(URL.self, forKey: .website) + } +} + +public struct Filtered: Codable, Equatable, Hashable { + public let filter: Filter + public let keywordMatches: [String]? +} + +public struct Filter: Codable, Identifiable, Equatable, Hashable { + public enum Action: String, Codable, Equatable { + case warn, hide + } + + public enum Context: String, Codable { + case home, notifications, account, thread + case pub = "public" + } + + public let id: String + public let title: String + public let context: [String] + public let filterAction: Action +} + +extension Filtered: Sendable {} +extension Filter: Sendable {} +extension Filter.Action: Sendable {} +extension Filter.Context: Sendable {} + +public struct Poll: Codable, Equatable, Hashable { + public static func == (lhs: Poll, rhs: Poll) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public struct Option: Identifiable, Codable { + enum CodingKeys: String, CodingKey { + case title, votesCount + } + + public var id = UUID().uuidString + public let title: String + public let votesCount: Int? + } + + public let id: String + public let expiresAt: NullableString + public let expired: Bool + public let multiple: Bool + public let votesCount: Int + public let votersCount: Int? + public let voted: Bool? + public let ownVotes: [Int]? + public let options: [Option] + + // the votersCount can be null according to the docs when multiple is false. + // Didn't find that to be true, but we make sure + public var safeVotersCount: Int { + votersCount ?? votesCount + } +} + +public struct NullableString: Codable, Equatable, Hashable { + public let value: ServerDate? + + public init(from decoder: Decoder) throws { + do { + let container = try decoder.singleValueContainer() + value = try container.decode(ServerDate.self) + } catch { + value = nil + } + } +} + +extension Poll: Sendable {} +extension Poll.Option: Sendable {} +extension NullableString: Sendable {} diff --git a/Threaded/Data/Tag.swift b/Threaded/Data/Tag.swift new file mode 100644 index 0000000..d4f6fef --- /dev/null +++ b/Threaded/Data/Tag.swift @@ -0,0 +1,69 @@ +//Made by Lumaa + +import Foundation + +public struct Tag: Codable, Identifiable, Equatable, Hashable { + public struct History: Codable, Identifiable { + public var id: String { + day + } + public let day: String + public let accounts: String + public let uses: String + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + } + + public static func == (lhs: Tag, rhs: Tag) -> Bool { + lhs.name == rhs.name + } + + public var id: String { + name + } + + public let name: String + public let url: String + public let following: Bool + public let history: [History] + + public var totalUses: Int { + history.compactMap { Int($0.uses) }.reduce(0, +) + } + + public var totalAccounts: Int { + history.compactMap { Int($0.accounts) }.reduce(0, +) + } +} + +public struct FeaturedTag: Codable, Identifiable { + public let id: String + public let name: String + public let url: URL + public let statusesCount: String + public var statusesCountInt: Int { + Int(statusesCount) ?? 0 + } + + private enum CodingKeys: String, CodingKey { + case id, name, url, statusesCount + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + url = try container.decode(URL.self, forKey: .url) + do { + statusesCount = try container.decode(String.self, forKey: .statusesCount) + } catch DecodingError.typeMismatch { + statusesCount = try String(container.decode(Int.self, forKey: .statusesCount)) + } + } +} + +extension Tag: Sendable {} +extension Tag.History: Sendable {} +extension FeaturedTag: Sendable {} diff --git a/Threaded/Info.plist b/Threaded/Info.plist new file mode 100644 index 0000000..89bdd1b --- /dev/null +++ b/Threaded/Info.plist @@ -0,0 +1,19 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + threaded + CFBundleURLSchemes + + threaded:// + + + + + diff --git a/Threaded/Localizable.xcstrings b/Threaded/Localizable.xcstrings new file mode 100644 index 0000000..88d7557 --- /dev/null +++ b/Threaded/Localizable.xcstrings @@ -0,0 +1,232 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "#%@" : { + + }, + "%@" : { + + }, + "•" : { + + }, + "accessibility.media.supported-type.audio.label" : { + "comment" : "A localized description of SupportedType.audio" + }, + "accessibility.media.supported-type.gifv.label" : { + "comment" : "A localized description of SupportedType.gifv" + }, + "accessibility.media.supported-type.image.label" : { + "comment" : "A localized description of SupportedType.image" + }, + "accessibility.media.supported-type.video.label" : { + "comment" : "A localized description of SupportedType.video" + }, + "Hello world" : { + + }, + "instance.rules" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rules" + } + } + } + }, + "login.mastodon" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log in using Mastodon" + } + } + } + }, + "login.mastodon.footer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log in your Mastodon account using its instance URL" + } + } + } + }, + "login.mastodon.instance" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter the instance's URL" + } + } + } + }, + "login.mastodon.login" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log in" + } + } + } + }, + "login.mastodon.verify" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verify" + } + } + } + }, + "login.mastodon.verify-error" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This might not be a Mastodon instance." + } + } + } + }, + "login.no-account" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stay anonymous" + } + } + } + }, + "login.no-account.footer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Without an account, you cannot interact with posts, users and instances. You can only read posts and users' public data." + } + } + } + }, + "login.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Welcome to Threaded!" + } + } + } + }, + "logout" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log out" + } + } + } + }, + "Posting view" : { + + }, + "setting.privacy" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy" + } + } + } + }, + "settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "status.favourites-%lld" : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld like" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld likes" + } + } + } + } + } + } + }, + "status.replies-%lld" : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld reply" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld replies" + } + } + } + } + } + } + }, + "status.reposted-by.%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ reposted" + } + } + } + }, + "timeline.federated" : { + + }, + "timeline.home" : { + + }, + "timeline.latest" : { + + }, + "timeline.local" : { + + }, + "timeline.trending" : { + + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Threaded/Packages/Models/.gitignore b/Threaded/Packages/Models/.gitignore new file mode 100644 index 0000000..3b29812 --- /dev/null +++ b/Threaded/Packages/Models/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Threaded/Packages/Models/.swiftpm/xcode/xcshareddata/xcschemes/Models.xcscheme b/Threaded/Packages/Models/.swiftpm/xcode/xcshareddata/xcschemes/Models.xcscheme new file mode 100644 index 0000000..0b6ae59 --- /dev/null +++ b/Threaded/Packages/Models/.swiftpm/xcode/xcshareddata/xcschemes/Models.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Threaded/Packages/Models/.swiftpm/xcode/xcshareddata/xcschemes/ModelsTests.xcscheme b/Threaded/Packages/Models/.swiftpm/xcode/xcshareddata/xcschemes/ModelsTests.xcscheme new file mode 100644 index 0000000..a7819c7 --- /dev/null +++ b/Threaded/Packages/Models/.swiftpm/xcode/xcshareddata/xcschemes/ModelsTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/Threaded/Packages/Models/Package.swift b/Threaded/Packages/Models/Package.swift new file mode 100644 index 0000000..3de3587 --- /dev/null +++ b/Threaded/Packages/Models/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Models", + defaultLocalization: "en", + platforms: [ + .iOS(.v17), + ], + products: [ + .library( + name: "Models", + targets: ["Models"] + ), + ], + dependencies: [ + .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.4.3"), + ], + targets: [ + .target( + name: "Models", + dependencies: [ + "SwiftSoup", + ], + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), + ] + ), + .testTarget( + name: "ModelsTests", + dependencies: ["Models"] + ), + ] +) diff --git a/Threaded/Packages/Models/README.md b/Threaded/Packages/Models/README.md new file mode 100644 index 0000000..8924cea --- /dev/null +++ b/Threaded/Packages/Models/README.md @@ -0,0 +1,3 @@ +# Models + +A description of this package. diff --git a/Threaded/Packages/Models/Sources/Models/Account.swift b/Threaded/Packages/Models/Sources/Models/Account.swift new file mode 100644 index 0000000..8bfe66a --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Account.swift @@ -0,0 +1,125 @@ +import Foundation + +public final class Account: Codable, Identifiable, Hashable, Sendable, Equatable { + public static func == (lhs: Account, rhs: Account) -> Bool { + lhs.id == rhs.id && + lhs.username == rhs.username && + lhs.note.asRawText == rhs.note.asRawText && + lhs.statusesCount == rhs.statusesCount && + lhs.followersCount == rhs.followersCount && + lhs.followingCount == rhs.followingCount && + lhs.acct == rhs.acct && + lhs.displayName == rhs.displayName && + lhs.fields == rhs.fields && + lhs.lastStatusAt == rhs.lastStatusAt && + lhs.discoverable == rhs.discoverable && + lhs.bot == rhs.bot && + lhs.locked == rhs.locked + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public struct Field: Codable, Equatable, Identifiable, Sendable { + public var id: String { + value.asRawText + name + } + + public let name: String + public let value: HTMLString + public let verifiedAt: String? + } + + public struct Source: Codable, Equatable, Sendable { + public let privacy: Visibility + public let sensitive: Bool + public let language: String? + public let note: String + public let fields: [Field] + } + + public let id: String + public let username: String + public let displayName: String? + public let avatar: URL + public let header: URL + public let acct: String + public let note: HTMLString + public let createdAt: ServerDate + public let followersCount: Int? + public let followingCount: Int? + public let statusesCount: Int? + public let lastStatusAt: String? + public let fields: [Field] + public let locked: Bool + public let emojis: [Emoji] + public let url: URL? + public let source: Source? + public let bot: Bool + public let discoverable: Bool? + + public var haveAvatar: Bool { + avatar.lastPathComponent != "missing.png" + } + + public var haveHeader: Bool { + header.lastPathComponent != "missing.png" + } + + public init(id: String, username: String, displayName: String?, avatar: URL, header: URL, acct: String, note: HTMLString, createdAt: ServerDate, followersCount: Int, followingCount: Int, statusesCount: Int, lastStatusAt: String? = nil, fields: [Account.Field], locked: Bool, emojis: [Emoji], url: URL? = nil, source: Account.Source? = nil, bot: Bool, discoverable: Bool? = nil) { + self.id = id + self.username = username + self.displayName = displayName + self.avatar = avatar + self.header = header + self.acct = acct + self.note = note + self.createdAt = createdAt + self.followersCount = followersCount + self.followingCount = followingCount + self.statusesCount = statusesCount + self.lastStatusAt = lastStatusAt + self.fields = fields + self.locked = locked + self.emojis = emojis + self.url = url + self.source = source + self.bot = bot + self.discoverable = discoverable + } + + public static func placeholder() -> Account { + .init(id: UUID().uuidString, + username: "Username", + displayName: "John Mastodon", + avatar: URL(string: "https://files.mastodon.social/media_attachments/files/003/134/405/original/04060b07ddf7bb0b.png")!, + header: URL(string: "https://files.mastodon.social/media_attachments/files/003/134/405/original/04060b07ddf7bb0b.png")!, + acct: "johnm@example.com", + note: .init(stringValue: "Some content"), + createdAt: ServerDate(), + followersCount: 10, + followingCount: 10, + statusesCount: 10, + lastStatusAt: nil, + fields: [], + locked: false, + emojis: [], + url: nil, + source: nil, + bot: false, + discoverable: true) + } + + public static func placeholders() -> [Account] { + [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), + .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] + } +} + +public struct FamiliarAccounts: Decodable { + public let id: String + public let accounts: [Account] +} + +extension FamiliarAccounts: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/Alias/DateFormatterCache.swift b/Threaded/Packages/Models/Sources/Models/Alias/DateFormatterCache.swift new file mode 100644 index 0000000..bbc617b --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Alias/DateFormatterCache.swift @@ -0,0 +1,26 @@ +import Foundation + +class DateFormatterCache: @unchecked Sendable { + static let shared = DateFormatterCache() + + let createdAtRelativeFormatter: RelativeDateTimeFormatter + let createdAtShortDateFormatted: DateFormatter + let createdAtDateFormatter: DateFormatter + + init() { + let createdAtRelativeFormatter = RelativeDateTimeFormatter() + createdAtRelativeFormatter.unitsStyle = .short + self.createdAtRelativeFormatter = createdAtRelativeFormatter + + let createdAtShortDateFormatted = DateFormatter() + createdAtShortDateFormatted.dateStyle = .short + createdAtShortDateFormatted.timeStyle = .none + self.createdAtShortDateFormatted = createdAtShortDateFormatted + + let createdAtDateFormatter = DateFormatter() + createdAtDateFormatter.calendar = .init(identifier: .iso8601) + createdAtDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" + createdAtDateFormatter.timeZone = .init(abbreviation: "UTC") + self.createdAtDateFormatter = createdAtDateFormatter + } +} diff --git a/Threaded/Packages/Models/Sources/Models/Alias/HTMLString.swift b/Threaded/Packages/Models/Sources/Models/Alias/HTMLString.swift new file mode 100644 index 0000000..686e030 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Alias/HTMLString.swift @@ -0,0 +1,291 @@ +import Foundation +import SwiftSoup +import SwiftUI + +private enum CodingKeys: CodingKey { + case htmlValue, asMarkdown, asRawText, statusesURLs, links +} + +public struct HTMLString: Codable, Equatable, Hashable, @unchecked Sendable { + public var htmlValue: String = "" + public var asMarkdown: String = "" + public var asRawText: String = "" + public var statusesURLs = [URL]() + public private(set) var links = [Link]() + + public var asSafeMarkdownAttributedString: AttributedString = .init() + private var main_regex: NSRegularExpression? + private var underscore_regex: NSRegularExpression? + public init(from decoder: Decoder) { + var alreadyDecoded = false + do { + let container = try decoder.singleValueContainer() + htmlValue = try container.decode(String.self) + } catch { + do { + alreadyDecoded = true + let container = try decoder.container(keyedBy: CodingKeys.self) + htmlValue = try container.decode(String.self, forKey: .htmlValue) + asMarkdown = try container.decode(String.self, forKey: .asMarkdown) + asRawText = try container.decode(String.self, forKey: .asRawText) + statusesURLs = try container.decode([URL].self, forKey: .statusesURLs) + links = try container.decode([Link].self, forKey: .links) + } catch { + htmlValue = "" + } + } + + if !alreadyDecoded { + // https://daringfireball.net/projects/markdown/syntax + // Pre-escape \ ` _ * ~ and [ as these are the only + // characters the markdown parser uses when it renders + // to attributed text. Note that ~ for strikethrough is + // not documented in the syntax docs but is used by + // AttributedString. + main_regex = try? NSRegularExpression(pattern: "([\\*\\`\\~\\[\\\\])", options: .caseInsensitive) + // don't escape underscores that are between colons, they are most likely custom emoji + underscore_regex = try? NSRegularExpression(pattern: "(?!\\B:[^:]*)(_)(?![^:]*:\\B)", options: .caseInsensitive) + + asMarkdown = "" + do { + let document: Document = try SwiftSoup.parse(htmlValue) + handleNode(node: document) + + document.outputSettings(OutputSettings().prettyPrint(pretty: false)) + try document.select("br").after("\n") + try document.select("p").after("\n\n") + let html = try document.html() + var text = try SwiftSoup.clean(html, "", Whitelist.none(), OutputSettings().prettyPrint(pretty: false)) ?? "" + // Remove the two last line break added after the last paragraph. + if text.hasSuffix("\n\n") { + _ = text.removeLast() + _ = text.removeLast() + } + asRawText = text + + if asMarkdown.hasPrefix("\n") { + _ = asMarkdown.removeFirst() + } + + } catch { + asRawText = htmlValue + } + } + + do { + let options = AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true, + interpretedSyntax: .inlineOnlyPreservingWhitespace) + asSafeMarkdownAttributedString = try AttributedString(markdown: asMarkdown, options: options) + } catch { + asSafeMarkdownAttributedString = AttributedString(stringLiteral: htmlValue) + } + } + + public init(stringValue: String, parseMarkdown: Bool = false) { + htmlValue = stringValue + asMarkdown = stringValue + asRawText = stringValue + statusesURLs = [] + + if parseMarkdown { + do { + let options = AttributedString.MarkdownParsingOptions(allowsExtendedAttributes: true, + interpretedSyntax: .inlineOnlyPreservingWhitespace) + asSafeMarkdownAttributedString = try AttributedString(markdown: asMarkdown, options: options) + } catch { + asSafeMarkdownAttributedString = AttributedString(stringLiteral: htmlValue) + } + } else { + asSafeMarkdownAttributedString = AttributedString(stringLiteral: htmlValue) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(htmlValue, forKey: .htmlValue) + try container.encode(asMarkdown, forKey: .asMarkdown) + try container.encode(asRawText, forKey: .asRawText) + try container.encode(statusesURLs, forKey: .statusesURLs) + try container.encode(links, forKey: .links) + } + + private mutating func handleNode(node: SwiftSoup.Node) { + do { + if let className = try? node.attr("class") { + if className == "invisible" { + // don't display + return + } + + if className == "ellipsis" { + // descend into this one now and + // append the ellipsis + for nn in node.getChildNodes() { + handleNode(node: nn) + } + asMarkdown += "…" + return + } + } + + if node.nodeName() == "p" { + if asMarkdown.count > 0 { // ignore first opening

+ asMarkdown += "\n\n" + } + } else if node.nodeName() == "br" { + if asMarkdown.count > 0 { // ignore first opening
+ asMarkdown += "\n" + } + } else if node.nodeName() == "a" { + let href = try node.attr("href") + if href != "" { + if let url = URL(string: href), + let _ = Int(url.lastPathComponent) + { + statusesURLs.append(url) + } + } + asMarkdown += "[" + let start = asMarkdown.endIndex + // descend into this node now so we can wrap the + // inner part of the link in the right markup + for nn in node.getChildNodes() { + handleNode(node: nn) + } + let finish = asMarkdown.endIndex + + var linkRef = href + + // Try creating a URL from the string. If it fails, try URL encoding + // the string first. + var url = URL(string: href) + if url == nil { + url = URL(string: href, encodePath: true) + } + if let linkUrl = url { + linkRef = linkUrl.absoluteString + let displayString = asMarkdown[start ..< finish] + links.append(Link(linkUrl, displayString: String(displayString))) + } + + asMarkdown += "](" + asMarkdown += linkRef + asMarkdown += ")" + + return + } else if node.nodeName() == "#text" { + var txt = node.description + + if let underscore_regex, let main_regex { + // This is the markdown escaper + txt = main_regex.stringByReplacingMatches(in: txt, options: [], range: NSRange(location: 0, length: txt.count), withTemplate: "\\\\$1") + txt = underscore_regex.stringByReplacingMatches(in: txt, options: [], range: NSRange(location: 0, length: txt.count), withTemplate: "\\\\$1") + } + // Strip newlines and line separators - they should be being sent as
s + asMarkdown += txt.replacingOccurrences(of: "\n", with: "").replacingOccurrences(of: "\u{2028}", with: "") + } else if node.nodeName() == "ul" { + // Unordered (bulleted) list + // SwiftUI's Text won't display these in an AttributedString, but we can at least improve the appearance + asMarkdown += "\n\n" + for nn in node.getChildNodes() { + asMarkdown += "- " + handleNode(node: nn) + asMarkdown += "\n" + } + return + } else if node.nodeName() == "ol" { + // Ordered (numbered) list + // Same thing, won't display in a Text, but this is just an attempt to improve the appearance + asMarkdown += "\n\n" + var curNumber = 1 + for nn in node.getChildNodes() { + asMarkdown += "\(curNumber). " + handleNode(node: nn) + asMarkdown += "\n" + curNumber += 1 + } + return + } + + for n in node.getChildNodes() { + handleNode(node: n) + } + } catch {} + } + + public struct Link: Codable, Hashable, Identifiable { + public var id: Int { hashValue } + public let url: URL + public let displayString: String + public let type: LinkType + public let title: String + + init(_ url: URL, displayString: String) { + self.url = url + self.displayString = displayString + + switch displayString.first { + case "@": + type = .mention + title = displayString + case "#": + type = .hashtag + title = String(displayString.dropFirst()) + default: + type = .url + var hostNameUrl = url.host ?? url.absoluteString + if hostNameUrl.hasPrefix("www.") { + hostNameUrl = String(hostNameUrl.dropFirst(4)) + } + title = hostNameUrl + } + } + + public enum LinkType: String, Codable { + case url + case mention + case hashtag + } + } +} + +public extension URL { + // It's common to use non-ASCII characters in URLs even though they're technically + // invalid characters. Every modern browser handles this by silently encoding + // the invalid characters on the user's behalf. However, trying to create a URL + // object with un-encoded characters will result in nil so we need to encode the + // invalid characters before creating the URL object. The unencoded version + // should still be shown in the displayed status. + init?(string: String, encodePath: Bool) { + var encodedUrlString = "" + if encodePath, + string.starts(with: "http://") || string.starts(with: "https://"), + var startIndex = string.firstIndex(of: "/") + { + startIndex = string.index(startIndex, offsetBy: 1) + + // We don't want to encode the host portion of the URL + if var startIndex = string[startIndex...].firstIndex(of: "/") { + encodedUrlString = String(string[...startIndex]) + while let endIndex = string[string.index(after: startIndex)...].firstIndex(of: "/") { + let componentStartIndex = string.index(after: startIndex) + encodedUrlString = encodedUrlString + (string[componentStartIndex ... endIndex].addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "") + startIndex = endIndex + } + + // The last part of the path may have a query string appended to it + let componentStartIndex = string.index(after: startIndex) + if let queryStartIndex = string[componentStartIndex...].firstIndex(of: "?") { + encodedUrlString = encodedUrlString + (string[componentStartIndex ..< queryStartIndex].addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "") + encodedUrlString = encodedUrlString + (string[queryStartIndex...].addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "") + } else { + encodedUrlString = encodedUrlString + (string[componentStartIndex...].addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "") + } + } + } + if encodedUrlString.isEmpty { + encodedUrlString = string + } + self.init(string: encodedUrlString) + } +} diff --git a/Threaded/Packages/Models/Sources/Models/Alias/ServerDate.swift b/Threaded/Packages/Models/Sources/Models/Alias/ServerDate.swift new file mode 100644 index 0000000..e60d2a5 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Alias/ServerDate.swift @@ -0,0 +1,41 @@ +import Foundation + +private enum CodingKeys: CodingKey { + case asDate +} + +public struct ServerDate: Codable, Hashable, Equatable, Sendable { + public let asDate: Date + + public var relativeFormatted: String { + DateFormatterCache.shared.createdAtRelativeFormatter.localizedString(for: asDate, relativeTo: Date()) + } + + public var shortDateFormatted: String { + DateFormatterCache.shared.createdAtShortDateFormatted.string(from: asDate) + } + + private static let calendar = Calendar(identifier: .gregorian) + + public init() { + asDate = Date() - 100 + } + + public init(from decoder: Decoder) throws { + do { + // Decode from server + let container = try decoder.singleValueContainer() + let stringDate = try container.decode(String.self) + asDate = DateFormatterCache.shared.createdAtDateFormatter.date(from: stringDate) ?? Date() + } catch { + // Decode from cache + let container = try decoder.container(keyedBy: CodingKeys.self) + asDate = try container.decode(Date.self, forKey: .asDate) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(asDate, forKey: .asDate) + } +} diff --git a/Threaded/Packages/Models/Sources/Models/App/App.swift b/Threaded/Packages/Models/Sources/Models/App/App.swift new file mode 100644 index 0000000..dfde839 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/App/App.swift @@ -0,0 +1,12 @@ +import Foundation + +public enum AppInfo { + public static let appStoreAppId = "6444915884" + public static let clientName = "IceCubesApp" + public static let scheme = "icecubesapp://" + public static let scopes = "read write follow push" + public static let weblink = "https://github.com/Dimillian/IceCubesApp" + public static let revenueCatKey = "appl_JXmiRckOzXXTsHKitQiicXCvMQi" + public static let defaultServer = "mastodon.social" + public static let keychainGroup = "346J38YKE3.com.thomasricouard.IceCubesApp" +} diff --git a/Threaded/Packages/Models/Sources/Models/AppAccount.swift b/Threaded/Packages/Models/Sources/Models/AppAccount.swift new file mode 100644 index 0000000..a5c7291 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/AppAccount.swift @@ -0,0 +1,31 @@ +import Foundation +import SwiftUI + +public struct AppAccount: Codable, Identifiable, Hashable { + public let server: String + public var accountName: String? + public let oauthToken: OauthToken? + + public var key: String { + if let oauthToken { + "\(server):\(oauthToken.createdAt)" + } else { + "\(server):anonymous" + } + } + + public var id: String { + key + } + + public init(server: String, + accountName: String?, + oauthToken: OauthToken? = nil) + { + self.server = server + self.accountName = accountName + self.oauthToken = oauthToken + } +} + +extension AppAccount: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/Application.swift b/Threaded/Packages/Models/Sources/Models/Application.swift new file mode 100644 index 0000000..3033e4c --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Application.swift @@ -0,0 +1,19 @@ +import Foundation + +public struct Application: Codable, Identifiable, Hashable, Equatable, Sendable { + public var id: String { + name + } + + public let name: String + public let website: URL? +} + +public extension Application { + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + + name = try values.decodeIfPresent(String.self, forKey: .name) ?? "" + website = try? values.decodeIfPresent(URL.self, forKey: .website) + } +} diff --git a/Threaded/Packages/Models/Sources/Models/Card.swift b/Threaded/Packages/Models/Sources/Models/Card.swift new file mode 100644 index 0000000..132ead3 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Card.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct Card: Codable, Identifiable, Equatable, Hashable { + public var id: String { + url + } + + public let url: String + public let title: String? + public let description: String? + public let type: String + public let image: URL? +} + +extension Card: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/ConsolidatedNotification.swift b/Threaded/Packages/Models/Sources/Models/ConsolidatedNotification.swift new file mode 100644 index 0000000..5716192 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/ConsolidatedNotification.swift @@ -0,0 +1,45 @@ +// +// ConsolidatedNotification.swift +// +// +// Created by Jérôme Danthinne on 31/01/2023. +// + +import Foundation + +public struct ConsolidatedNotification: Identifiable { + public let notifications: [Notification] + public let type: Notification.NotificationType + public let createdAt: ServerDate + public let accounts: [Account] + public let status: Status? + + public var id: String? { notifications.first?.id } + + public init(notifications: [Notification], + type: Notification.NotificationType, + createdAt: ServerDate, + accounts: [Account], + status: Status?) + { + self.notifications = notifications + self.type = type + self.createdAt = createdAt + self.accounts = accounts + self.status = status ?? nil + } + + public static func placeholder() -> ConsolidatedNotification { + .init(notifications: [Notification.placeholder()], + type: .favourite, + createdAt: ServerDate(), + accounts: [.placeholder()], + status: .placeholder()) + } + + public static func placeholders() -> [ConsolidatedNotification] { + [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] + } +} + +extension ConsolidatedNotification: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/Conversation.swift b/Threaded/Packages/Models/Sources/Models/Conversation.swift new file mode 100644 index 0000000..7935cf8 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Conversation.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct Conversation: Identifiable, Decodable, Hashable, Equatable { + public let id: String + public let unread: Bool + public let lastStatus: Status? + public let accounts: [Account] + + public init(id: String, unread: Bool, lastStatus: Status? = nil, accounts: [Account]) { + self.id = id + self.unread = unread + self.lastStatus = lastStatus + self.accounts = accounts + } + + public static func placeholder() -> Conversation { + .init(id: UUID().uuidString, unread: false, lastStatus: .placeholder(), accounts: [.placeholder()]) + } + + public static func placeholders() -> [Conversation] { + [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), + .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] + } +} + +extension Conversation: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/Emoji.swift b/Threaded/Packages/Models/Sources/Models/Emoji.swift new file mode 100644 index 0000000..bc12b82 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Emoji.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct Emoji: Codable, Hashable, Identifiable, Equatable, Sendable { + public func hash(into hasher: inout Hasher) { + hasher.combine(shortcode) + } + + public var id: String { + shortcode + } + + public let shortcode: String + public let url: URL + public let staticUrl: URL + public let visibleInPicker: Bool + public let category: String? +} diff --git a/Threaded/Packages/Models/Sources/Models/Filter.swift b/Threaded/Packages/Models/Sources/Models/Filter.swift new file mode 100644 index 0000000..cf49a13 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Filter.swift @@ -0,0 +1,27 @@ +import Foundation + +public struct Filtered: Codable, Equatable, Hashable { + public let filter: Filter + public let keywordMatches: [String]? +} + +public struct Filter: Codable, Identifiable, Equatable, Hashable { + public enum Action: String, Codable, Equatable { + case warn, hide + } + + public enum Context: String, Codable { + case home, notifications, account, thread + case pub = "public" + } + + public let id: String + public let title: String + public let context: [String] + public let filterAction: Action +} + +extension Filtered: Sendable {} +extension Filter: Sendable {} +extension Filter.Action: Sendable {} +extension Filter.Context: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/Instance.swift b/Threaded/Packages/Models/Sources/Models/Instance.swift new file mode 100644 index 0000000..6900c20 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Instance.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct Instance: Codable, Sendable { + public struct Stats: Codable, Sendable { + public let userCount: Int + public let statusCount: Int + public let domainCount: Int + } + + public struct Configuration: Codable, Sendable { + public struct Statuses: Codable, Sendable { + public let maxCharacters: Int + public let maxMediaAttachments: Int + } + + public struct Polls: Codable, Sendable { + public let maxOptions: Int + public let maxCharactersPerOption: Int + public let minExpiration: Int + public let maxExpiration: Int + } + + public let statuses: Statuses + public let polls: Polls + } + + public struct Rule: Codable, Identifiable, Sendable { + public let id: String + public let text: String + } + + public struct URLs: Codable, Sendable { + public let streamingApi: URL? + } + + public let title: String + public let shortDescription: String + public let email: String + public let version: String + public let stats: Stats + public let languages: [String]? + public let registrations: Bool + public let thumbnail: URL? + public let configuration: Configuration? + public let rules: [Rule]? + public let urls: URLs? +} diff --git a/Threaded/Packages/Models/Sources/Models/InstanceApp.swift b/Threaded/Packages/Models/Sources/Models/InstanceApp.swift new file mode 100644 index 0000000..8f41ba9 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/InstanceApp.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct InstanceApp: Codable, Identifiable { + public let id: String + public let name: String + public let website: URL? + public let redirectUri: String + public let clientId: String + public let clientSecret: String + public let vapidKey: String? +} + +extension InstanceApp: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/InstanceSocial.swift b/Threaded/Packages/Models/Sources/Models/InstanceSocial.swift new file mode 100644 index 0000000..6d2dc36 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/InstanceSocial.swift @@ -0,0 +1,16 @@ +import Foundation + +public struct InstanceSocial: Decodable, Identifiable, Sendable { + public struct Info: Decodable, Sendable { + public let shortDescription: String? + } + + public let id: String + public let name: String + public let dead: Bool + public let users: String + public let activeUsers: Int? + public let statuses: String + public let thumbnail: URL? + public let info: Info? +} diff --git a/Threaded/Packages/Models/Sources/Models/Language.swift b/Threaded/Packages/Models/Sources/Models/Language.swift new file mode 100644 index 0000000..667b266 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Language.swift @@ -0,0 +1,23 @@ +import Foundation + +@MainActor +public struct Language: Identifiable, Equatable, Hashable { + public nonisolated var id: String { isoCode } + + public let isoCode: String + public let nativeName: String? + public let localizedName: String? + + public static var allAvailableLanguages: [Language] = Locale.LanguageCode.isoLanguageCodes + .filter { $0.identifier.count <= 3 } + .map { lang in + let nativeLocale = Locale(languageComponents: Locale.Language.Components(languageCode: lang)) + return Language( + isoCode: lang.identifier, + nativeName: nativeLocale.localizedString(forLanguageCode: lang.identifier)?.capitalized, + localizedName: Locale.current.localizedString(forLanguageCode: lang.identifier)?.localizedCapitalized + ) + } +} + +extension Language: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/List.swift b/Threaded/Packages/Models/Sources/Models/List.swift new file mode 100644 index 0000000..019fcf9 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/List.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct List: Codable, Identifiable, Equatable, Hashable { + public let id: String + public let title: String + public let repliesPolicy: RepliesPolicy? + public let exclusive: Bool? + + public enum RepliesPolicy: String, Sendable, Codable, CaseIterable, Identifiable { + public var id: String { + rawValue + } + + case followed, list, `none` + } +} + +extension List: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/MastodonPushNotification.swift b/Threaded/Packages/Models/Sources/Models/MastodonPushNotification.swift new file mode 100644 index 0000000..2d86ea0 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/MastodonPushNotification.swift @@ -0,0 +1,25 @@ +import Foundation + +public struct MastodonPushNotification: Codable { + public let accessToken: String + + public let notificationID: Int + public let notificationType: String + + public let preferredLocale: String? + public let icon: String? + public let title: String + public let body: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case notificationID = "notification_id" + case notificationType = "notification_type" + case preferredLocale = "preferred_locale" + case icon + case title + case body + } +} + +extension MastodonPushNotification: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/MediaAttachement.swift b/Threaded/Packages/Models/Sources/Models/MediaAttachement.swift new file mode 100644 index 0000000..1c52776 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/MediaAttachement.swift @@ -0,0 +1,61 @@ +import Foundation + +public struct MediaAttachment: Codable, Identifiable, Hashable, Equatable { + public struct MetaContainer: Codable, Equatable { + public struct Meta: Codable, Equatable { + public let width: Int? + public let height: Int? + } + + public let original: Meta? + } + + public enum SupportedType: String { + case image, gifv, video, audio + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public let id: String + public let type: String + public var supportedType: SupportedType? { + SupportedType(rawValue: type) + } + + public var localizedTypeDescription: String? { + if let supportedType { + switch supportedType { + case .image: + return NSLocalizedString("accessibility.media.supported-type.image.label", bundle: .main, comment: "A localized description of SupportedType.image") + case .gifv: + return NSLocalizedString("accessibility.media.supported-type.gifv.label", bundle: .main, comment: "A localized description of SupportedType.gifv") + case .video: + return NSLocalizedString("accessibility.media.supported-type.video.label", bundle: .main, comment: "A localized description of SupportedType.video") + case .audio: + return NSLocalizedString("accessibility.media.supported-type.audio.label", bundle: .main, comment: "A localized description of SupportedType.audio") + } + } + return nil + } + + public let url: URL? + public let previewUrl: URL? + public let description: String? + public let meta: MetaContainer? + + public static func imageWith(url: URL) -> MediaAttachment { + .init(id: UUID().uuidString, + type: "image", + url: url, + previewUrl: url, + description: "demo alt text here", + meta: nil) + } +} + +extension MediaAttachment: Sendable {} +extension MediaAttachment.MetaContainer: Sendable {} +extension MediaAttachment.MetaContainer.Meta: Sendable {} +extension MediaAttachment.SupportedType: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/Mention.swift b/Threaded/Packages/Models/Sources/Models/Mention.swift new file mode 100644 index 0000000..588b3d8 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Mention.swift @@ -0,0 +1,10 @@ +import Foundation + +public struct Mention: Codable, Equatable, Hashable { + public let id: String + public let username: String + public let url: URL + public let acct: String +} + +extension Mention: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/Notification.swift b/Threaded/Packages/Models/Sources/Models/Notification.swift new file mode 100644 index 0000000..405a2de --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Notification.swift @@ -0,0 +1,28 @@ +import Foundation + +public struct Notification: Decodable, Identifiable, Equatable { + public enum NotificationType: String, CaseIterable { + case follow, follow_request, mention, reblog, status, favourite, poll, update + } + + public let id: String + public let type: String + public let createdAt: ServerDate + public let account: Account + public let status: Status? + + public var supportedType: NotificationType? { + .init(rawValue: type) + } + + public static func placeholder() -> Notification { + .init(id: UUID().uuidString, + type: NotificationType.favourite.rawValue, + createdAt: ServerDate(), + account: .placeholder(), + status: .placeholder()) + } +} + +extension Notification: Sendable {} +extension Notification.NotificationType: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/OauthToken.swift b/Threaded/Packages/Models/Sources/Models/OauthToken.swift new file mode 100644 index 0000000..78fb741 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/OauthToken.swift @@ -0,0 +1,8 @@ +import Foundation + +public struct OauthToken: Codable, Hashable, Sendable { + public let accessToken: String + public let tokenType: String + public let scope: String + public let createdAt: Double +} diff --git a/Threaded/Packages/Models/Sources/Models/Poll.swift b/Threaded/Packages/Models/Sources/Models/Poll.swift new file mode 100644 index 0000000..7baab2d --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Poll.swift @@ -0,0 +1,54 @@ +import Foundation + +public struct Poll: Codable, Equatable, Hashable { + public static func == (lhs: Poll, rhs: Poll) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public struct Option: Identifiable, Codable { + enum CodingKeys: String, CodingKey { + case title, votesCount + } + + public var id = UUID().uuidString + public let title: String + public let votesCount: Int? + } + + public let id: String + public let expiresAt: NullableString + public let expired: Bool + public let multiple: Bool + public let votesCount: Int + public let votersCount: Int? + public let voted: Bool? + public let ownVotes: [Int]? + public let options: [Option] + + // the votersCount can be null according to the docs when multiple is false. + // Didn't find that to be true, but we make sure + public var safeVotersCount: Int { + votersCount ?? votesCount + } +} + +public struct NullableString: Codable, Equatable, Hashable { + public let value: ServerDate? + + public init(from decoder: Decoder) throws { + do { + let container = try decoder.singleValueContainer() + value = try container.decode(ServerDate.self) + } catch { + value = nil + } + } +} + +extension Poll: Sendable {} +extension Poll.Option: Sendable {} +extension NullableString: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/PushSubscription.swift b/Threaded/Packages/Models/Sources/Models/PushSubscription.swift new file mode 100644 index 0000000..d17075b --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/PushSubscription.swift @@ -0,0 +1,20 @@ +import Foundation + +public struct PushSubscription: Identifiable, Decodable { + public struct Alerts: Decodable { + public let follow: Bool + public let favourite: Bool + public let reblog: Bool + public let mention: Bool + public let poll: Bool + public let status: Bool + } + + public let id: Int + public let endpoint: URL + public let serverKey: String + public let alerts: Alerts +} + +extension PushSubscription: Sendable {} +extension PushSubscription.Alerts: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/Relationship.swift b/Threaded/Packages/Models/Sources/Models/Relationship.swift new file mode 100644 index 0000000..7dbf8f6 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Relationship.swift @@ -0,0 +1,54 @@ +import Foundation + +public struct Relationship: Codable { + public let id: String + public let following: Bool + public let showingReblogs: Bool + public let followedBy: Bool + public let blocking: Bool + public let blockedBy: Bool + public let muting: Bool + public let mutingNotifications: Bool + public let requested: Bool + public let domainBlocking: Bool + public let endorsed: Bool + public let note: String + public let notifying: Bool + + public static func placeholder() -> Relationship { + .init(id: UUID().uuidString, + following: false, + showingReblogs: false, + followedBy: false, + blocking: false, + blockedBy: false, + muting: false, + mutingNotifications: false, + requested: false, + domainBlocking: false, + endorsed: false, + note: "", + notifying: false) + } +} + +public extension Relationship { + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decodeIfPresent(String.self, forKey: .id) ?? "" + following = try values.decodeIfPresent(Bool.self, forKey: .following) ?? false + showingReblogs = try values.decodeIfPresent(Bool.self, forKey: .showingReblogs) ?? false + followedBy = try values.decodeIfPresent(Bool.self, forKey: .followedBy) ?? false + blocking = try values.decodeIfPresent(Bool.self, forKey: .blocking) ?? false + blockedBy = try values.decodeIfPresent(Bool.self, forKey: .blockedBy) ?? false + muting = try values.decodeIfPresent(Bool.self, forKey: .muting) ?? false + mutingNotifications = try values.decodeIfPresent(Bool.self, forKey: .mutingNotifications) ?? false + requested = try values.decodeIfPresent(Bool.self, forKey: .requested) ?? false + domainBlocking = try values.decodeIfPresent(Bool.self, forKey: .domainBlocking) ?? false + endorsed = try values.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false + note = try values.decodeIfPresent(String.self, forKey: .note) ?? "" + notifying = try values.decodeIfPresent(Bool.self, forKey: .notifying) ?? false + } +} + +extension Relationship: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/SearchResults.swift b/Threaded/Packages/Models/Sources/Models/SearchResults.swift new file mode 100644 index 0000000..6c8514c --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/SearchResults.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct SearchResults: Decodable { + enum CodingKeys: String, CodingKey { + case accounts, statuses, hashtags + } + + public let accounts: [Account] + public var relationships: [Relationship] = [] + public let statuses: [Status] + public let hashtags: [Tag] + + public var isEmpty: Bool { + accounts.isEmpty && statuses.isEmpty && hashtags.isEmpty + } +} + +extension SearchResults: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/ServerError.swift b/Threaded/Packages/Models/Sources/Models/ServerError.swift new file mode 100644 index 0000000..b81bf67 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/ServerError.swift @@ -0,0 +1,8 @@ +import Foundation + +public struct ServerError: Decodable, Error { + public let error: String? + public var httpCode: Int? +} + +extension ServerError: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/ServerFilter.swift b/Threaded/Packages/Models/Sources/Models/ServerFilter.swift new file mode 100644 index 0000000..0ca949c --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/ServerFilter.swift @@ -0,0 +1,80 @@ +import Foundation + +public struct ServerFilter: Codable, Identifiable, Hashable, Sendable { + public struct Keyword: Codable, Identifiable, Hashable, Sendable { + public let id: String + public let keyword: String + public let wholeWord: Bool + } + + public enum Context: String, Codable, CaseIterable, Sendable { + case home, notifications, `public`, thread, account + } + + public enum Action: String, Codable, CaseIterable, Sendable { + case warn, hide + } + + public let id: String + public let title: String + public let keywords: [Keyword] + public let filterAction: Action + public let context: [Context] + public let expiresIn: Int? + public let expiresAt: ServerDate? + + public func hasExpiry() -> Bool { + expiresAt != nil + } + + public func isExpired() -> Bool { + if let expiresAtDate = expiresAt?.asDate { + expiresAtDate < Date() + } else { + false + } + } +} + +public extension ServerFilter.Context { + var iconName: String { + switch self { + case .home: + "rectangle.stack" + case .notifications: + "bell" + case .public: + "globe.americas" + case .thread: + "bubble.left.and.bubble.right" + case .account: + "person.crop.circle" + } + } + + var name: String { + switch self { + case .home: + NSLocalizedString("filter.contexts.home", comment: "") + case .notifications: + NSLocalizedString("filter.contexts.notifications", comment: "") + case .public: + NSLocalizedString("filter.contexts.public", comment: "") + case .thread: + NSLocalizedString("filter.contexts.conversations", comment: "") + case .account: + NSLocalizedString("filter.contexts.profiles", comment: "") + } + } +} + +public extension ServerFilter.Action { + var label: String { + switch self { + case .warn: + NSLocalizedString("filter.action.warning", comment: "") + case .hide: + NSLocalizedString("filter.action.hide", comment: "") + } + } +} diff --git a/Threaded/Packages/Models/Sources/Models/ServerPreferences.swift b/Threaded/Packages/Models/Sources/Models/ServerPreferences.swift new file mode 100644 index 0000000..9b4f8ce --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/ServerPreferences.swift @@ -0,0 +1,38 @@ +import Foundation +import SwiftUI + +public struct ServerPreferences: Decodable { + public let postVisibility: Visibility? + public let postIsSensitive: Bool? + public let postLanguage: String? + public let autoExpandMedia: AutoExpandMedia? + public let autoExpandSpoilers: Bool? + + public enum AutoExpandMedia: String, Decodable, CaseIterable { + case showAll = "show_all" + case hideAll = "hide_all" + case hideSensitive = "default" + + public var description: LocalizedStringKey { + switch self { + case .showAll: + "enum.expand-media.show" + case .hideAll: + "enum.expand-media.hide" + case .hideSensitive: + "enum.expand-media.hide-sensitive" + } + } + } + + enum CodingKeys: String, CodingKey { + case postVisibility = "posting:default:visibility" + case postIsSensitive = "posting:default:sensitive" + case postLanguage = "posting:default:language" + case autoExpandMedia = "reading:expand:media" + case autoExpandSpoilers = "reading:expand:spoilers" + } +} + +extension ServerPreferences: Sendable {} +extension ServerPreferences.AutoExpandMedia: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/Status.swift b/Threaded/Packages/Models/Sources/Models/Status.swift new file mode 100644 index 0000000..b2b0029 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Status.swift @@ -0,0 +1,260 @@ +import Foundation + +public enum Visibility: String, Codable, CaseIterable, Hashable, Equatable, Sendable { + case pub = "public" + case unlisted + case priv = "private" + case direct +} + +public protocol AnyStatus { + var viewId: StatusViewId { get } + var id: String { get } + var content: HTMLString { get } + var account: Account { get } + var createdAt: ServerDate { get } + var editedAt: ServerDate? { get } + var mediaAttachments: [MediaAttachment] { get } + var mentions: [Mention] { get } + var repliesCount: Int { get } + var reblogsCount: Int { get } + var favouritesCount: Int { get } + var card: Card? { get } + var favourited: Bool? { get } + var reblogged: Bool? { get } + var pinned: Bool? { get } + var bookmarked: Bool? { get } + var emojis: [Emoji] { get } + var url: String? { get } + var application: Application? { get } + var inReplyToId: String? { get } + var inReplyToAccountId: String? { get } + var visibility: Visibility { get } + var poll: Poll? { get } + var spoilerText: HTMLString { get } + var filtered: [Filtered]? { get } + var sensitive: Bool { get } + var language: String? { get } +} + +public struct StatusViewId: Hashable { + let id: String + let editedAt: Date? +} + +public extension AnyStatus { + var viewId: StatusViewId { + StatusViewId(id: id, editedAt: editedAt?.asDate) + } +} + +public final class Status: AnyStatus, Codable, Identifiable, Equatable, Hashable { + public static func == (lhs: Status, rhs: Status) -> Bool { + lhs.id == rhs.id && lhs.viewId == rhs.viewId + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public let id: String + public let content: HTMLString + public let account: Account + public let createdAt: ServerDate + public let editedAt: ServerDate? + public let reblog: ReblogStatus? + public let mediaAttachments: [MediaAttachment] + public let mentions: [Mention] + public let repliesCount: Int + public let reblogsCount: Int + public let favouritesCount: Int + public let card: Card? + public let favourited: Bool? + public let reblogged: Bool? + public let pinned: Bool? + public let bookmarked: Bool? + public let emojis: [Emoji] + public let url: String? + public let application: Application? + public let inReplyToId: String? + public let inReplyToAccountId: String? + public let visibility: Visibility + public let poll: Poll? + public let spoilerText: HTMLString + public let filtered: [Filtered]? + public let sensitive: Bool + public let language: String? + + public init(id: String, content: HTMLString, account: Account, createdAt: ServerDate, editedAt: ServerDate?, reblog: ReblogStatus?, mediaAttachments: [MediaAttachment], mentions: [Mention], repliesCount: Int, reblogsCount: Int, favouritesCount: Int, card: Card?, favourited: Bool?, reblogged: Bool?, pinned: Bool?, bookmarked: Bool?, emojis: [Emoji], url: String?, application: Application?, inReplyToId: String?, inReplyToAccountId: String?, visibility: Visibility, poll: Poll?, spoilerText: HTMLString, filtered: [Filtered]?, sensitive: Bool, language: String?) { + self.id = id + self.content = content + self.account = account + self.createdAt = createdAt + self.editedAt = editedAt + self.reblog = reblog + self.mediaAttachments = mediaAttachments + self.mentions = mentions + self.repliesCount = repliesCount + self.reblogsCount = reblogsCount + self.favouritesCount = favouritesCount + self.card = card + self.favourited = favourited + self.reblogged = reblogged + self.pinned = pinned + self.bookmarked = bookmarked + self.emojis = emojis + self.url = url + self.application = application + self.inReplyToId = inReplyToId + self.inReplyToAccountId = inReplyToAccountId + self.visibility = visibility + self.poll = poll + self.spoilerText = spoilerText + self.filtered = filtered + self.sensitive = sensitive + self.language = language + } + + public static func placeholder(forSettings: Bool = false, language: String? = nil) -> Status { + .init(id: UUID().uuidString, + content: .init(stringValue: "Here's to the [#crazy](#) ones. The misfits.\nThe [@rebels](#). The troublemakers.", + parseMarkdown: forSettings), + + account: .placeholder(), + createdAt: ServerDate(), + editedAt: nil, + reblog: nil, + mediaAttachments: [], + mentions: [], + repliesCount: 0, + reblogsCount: 0, + favouritesCount: 0, + card: nil, + favourited: false, + reblogged: false, + pinned: false, + bookmarked: false, + emojis: [], + url: "https://example.com", + application: nil, + inReplyToId: nil, + inReplyToAccountId: nil, + visibility: .pub, + poll: nil, + spoilerText: .init(stringValue: ""), + filtered: [], + sensitive: false, + language: language) + } + + public static func placeholders() -> [Status] { + [.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()] + } + + public var reblogAsAsStatus: Status? { + if let reblog { + return .init(id: reblog.id, + content: reblog.content, + account: reblog.account, + createdAt: reblog.createdAt, + editedAt: reblog.editedAt, + reblog: nil, + mediaAttachments: reblog.mediaAttachments, + mentions: reblog.mentions, + repliesCount: reblog.repliesCount, + reblogsCount: reblog.reblogsCount, + favouritesCount: reblog.favouritesCount, + card: reblog.card, + favourited: reblog.favourited, + reblogged: reblog.reblogged, + pinned: reblog.pinned, + bookmarked: reblog.bookmarked, + emojis: reblog.emojis, + url: reblog.url, + application: reblog.application, + inReplyToId: reblog.inReplyToId, + inReplyToAccountId: reblog.inReplyToAccountId, + visibility: reblog.visibility, + poll: reblog.poll, + spoilerText: reblog.spoilerText, + filtered: reblog.filtered, + sensitive: reblog.sensitive, + language: reblog.language) + } + return nil + } +} + +public final class ReblogStatus: AnyStatus, Codable, Identifiable, Equatable, Hashable { + public static func == (lhs: ReblogStatus, rhs: ReblogStatus) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public let id: String + public let content: HTMLString + public let account: Account + public let createdAt: ServerDate + public let editedAt: ServerDate? + public let mediaAttachments: [MediaAttachment] + public let mentions: [Mention] + public let repliesCount: Int + public let reblogsCount: Int + public let favouritesCount: Int + public let card: Card? + public let favourited: Bool? + public let reblogged: Bool? + public let pinned: Bool? + public let bookmarked: Bool? + public let emojis: [Emoji] + public let url: String? + public let application: Application? + public let inReplyToId: String? + public let inReplyToAccountId: String? + public let visibility: Visibility + public let poll: Poll? + public let spoilerText: HTMLString + public let filtered: [Filtered]? + public let sensitive: Bool + public let language: String? + + public init(id: String, content: HTMLString, account: Account, createdAt: ServerDate, editedAt: ServerDate?, mediaAttachments: [MediaAttachment], mentions: [Mention], repliesCount: Int, reblogsCount: Int, favouritesCount: Int, card: Card?, favourited: Bool?, reblogged: Bool?, pinned: Bool?, bookmarked: Bool?, emojis: [Emoji], url: String?, application: Application? = nil, inReplyToId: String?, inReplyToAccountId: String?, visibility: Visibility, poll: Poll?, spoilerText: HTMLString, filtered: [Filtered]?, sensitive: Bool, language: String?) { + self.id = id + self.content = content + self.account = account + self.createdAt = createdAt + self.editedAt = editedAt + self.mediaAttachments = mediaAttachments + self.mentions = mentions + self.repliesCount = repliesCount + self.reblogsCount = reblogsCount + self.favouritesCount = favouritesCount + self.card = card + self.favourited = favourited + self.reblogged = reblogged + self.pinned = pinned + self.bookmarked = bookmarked + self.emojis = emojis + self.url = url + self.application = application + self.inReplyToId = inReplyToId + self.inReplyToAccountId = inReplyToAccountId + self.visibility = visibility + self.poll = poll + self.spoilerText = spoilerText + self.filtered = filtered + self.sensitive = sensitive + self.language = language + } +} + +extension StatusViewId: Sendable {} + +// Every property in Status is immutable. +extension Status: Sendable {} + +// Every property in ReblogStatus is immutable. +extension ReblogStatus: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/StatusContext.swift b/Threaded/Packages/Models/Sources/Models/StatusContext.swift new file mode 100644 index 0000000..63f81bd --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/StatusContext.swift @@ -0,0 +1,12 @@ +import Foundation + +public struct StatusContext: Decodable { + public let ancestors: [Status] + public let descendants: [Status] + + public static func empty() -> StatusContext { + .init(ancestors: [], descendants: []) + } +} + +extension StatusContext: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/StatusHistory.swift b/Threaded/Packages/Models/Sources/Models/StatusHistory.swift new file mode 100644 index 0000000..1d0f7fb --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/StatusHistory.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct StatusHistory: Decodable, Identifiable { + public var id: String { + createdAt.asDate.description + } + + public let content: HTMLString + public let createdAt: ServerDate + public let emojis: [Emoji] +} + +extension StatusHistory: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/Stream/StreamEvent.swift b/Threaded/Packages/Models/Sources/Models/Stream/StreamEvent.swift new file mode 100644 index 0000000..1e06719 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Stream/StreamEvent.swift @@ -0,0 +1,57 @@ +import Foundation + +public struct RawStreamEvent: Decodable { + public let event: String + public let stream: [String] + public let payload: String +} + +public protocol StreamEvent: Identifiable { + var date: Date { get } + var id: String { get } +} + +public struct StreamEventUpdate: StreamEvent { + public let date = Date() + public var id: String { status.id } + public let status: Status + public init(status: Status) { + self.status = status + } +} + +public struct StreamEventStatusUpdate: StreamEvent { + public let date = Date() + public var id: String { status.id } + public let status: Status + public init(status: Status) { + self.status = status + } +} + +public struct StreamEventDelete: StreamEvent { + public let date = Date() + public var id: String { status + date.description } + public let status: String + public init(status: String) { + self.status = status + } +} + +public struct StreamEventNotification: StreamEvent { + public let date = Date() + public var id: String { notification.id } + public let notification: Notification + public init(notification: Notification) { + self.notification = notification + } +} + +public struct StreamEventConversation: StreamEvent { + public let date = Date() + public var id: String { conversation.id } + public let conversation: Conversation + public init(conversation: Conversation) { + self.conversation = conversation + } +} diff --git a/Threaded/Packages/Models/Sources/Models/Stream/StreamMessage.swift b/Threaded/Packages/Models/Sources/Models/Stream/StreamMessage.swift new file mode 100644 index 0000000..c667987 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Stream/StreamMessage.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct StreamMessage: Encodable { + public let type: String + public let stream: String + + public init(type: String, stream: String) { + self.type = type + self.stream = stream + } +} diff --git a/Threaded/Packages/Models/Sources/Models/SwiftData/Draft.swift b/Threaded/Packages/Models/Sources/Models/SwiftData/Draft.swift new file mode 100644 index 0000000..34418c8 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/SwiftData/Draft.swift @@ -0,0 +1,13 @@ +import Foundation +import SwiftData +import SwiftUI + +@Model public class Draft { + public var content: String = "" + public var creationDate: Date = Date() + + public init(content: String) { + self.content = content + creationDate = Date() + } +} diff --git a/Threaded/Packages/Models/Sources/Models/SwiftData/LocalTimeline.swift b/Threaded/Packages/Models/Sources/Models/SwiftData/LocalTimeline.swift new file mode 100644 index 0000000..84319a1 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/SwiftData/LocalTimeline.swift @@ -0,0 +1,13 @@ +import Foundation +import SwiftData +import SwiftUI + +@Model public class LocalTimeline { + public var instance: String = "" + public var creationDate: Date = Date() + + public init(instance: String) { + self.instance = instance + creationDate = Date() + } +} diff --git a/Threaded/Packages/Models/Sources/Models/SwiftData/TagGroup.swift b/Threaded/Packages/Models/Sources/Models/SwiftData/TagGroup.swift new file mode 100644 index 0000000..de8acfe --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/SwiftData/TagGroup.swift @@ -0,0 +1,17 @@ +import Foundation +import SwiftData +import SwiftUI + +@Model public class TagGroup: Equatable { + public var title: String = "" + public var symbolName: String = "" + public var tags: [String] = [] + public var creationDate: Date = Date() + + public init(title: String, symbolName: String, tags: [String]) { + self.title = title + self.symbolName = symbolName + self.tags = tags + creationDate = Date() + } +} diff --git a/Threaded/Packages/Models/Sources/Models/Tag.swift b/Threaded/Packages/Models/Sources/Models/Tag.swift new file mode 100644 index 0000000..0069dc4 --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Tag.swift @@ -0,0 +1,67 @@ +import Foundation + +public struct Tag: Codable, Identifiable, Equatable, Hashable { + public struct History: Codable, Identifiable { + public var id: String { + day + } + public let day: String + public let accounts: String + public let uses: String + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + } + + public static func == (lhs: Tag, rhs: Tag) -> Bool { + lhs.name == rhs.name + } + + public var id: String { + name + } + + public let name: String + public let url: String + public let following: Bool + public let history: [History] + + public var totalUses: Int { + history.compactMap { Int($0.uses) }.reduce(0, +) + } + + public var totalAccounts: Int { + history.compactMap { Int($0.accounts) }.reduce(0, +) + } +} + +public struct FeaturedTag: Codable, Identifiable { + public let id: String + public let name: String + public let url: URL + public let statusesCount: String + public var statusesCountInt: Int { + Int(statusesCount) ?? 0 + } + + private enum CodingKeys: String, CodingKey { + case id, name, url, statusesCount + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + url = try container.decode(URL.self, forKey: .url) + do { + statusesCount = try container.decode(String.self, forKey: .statusesCount) + } catch DecodingError.typeMismatch { + statusesCount = try String(container.decode(Int.self, forKey: .statusesCount)) + } + } +} + +extension Tag: Sendable {} +extension Tag.History: Sendable {} +extension FeaturedTag: Sendable {} diff --git a/Threaded/Packages/Models/Sources/Models/Translation.swift b/Threaded/Packages/Models/Sources/Models/Translation.swift new file mode 100644 index 0000000..2b16e5f --- /dev/null +++ b/Threaded/Packages/Models/Sources/Models/Translation.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct Translation: Decodable { + public let content: HTMLString + public let detectedSourceLanguage: String + public let provider: String + + public init(content: String, detectedSourceLanguage: String, provider: String) { + self.content = .init(stringValue: content) + self.detectedSourceLanguage = detectedSourceLanguage + self.provider = provider + } +} + +extension Translation: Sendable {} diff --git a/Threaded/Packages/Models/Tests/ModelsTests/HTMLStringTests.swift b/Threaded/Packages/Models/Tests/ModelsTests/HTMLStringTests.swift new file mode 100644 index 0000000..9b47238 --- /dev/null +++ b/Threaded/Packages/Models/Tests/ModelsTests/HTMLStringTests.swift @@ -0,0 +1,91 @@ +@testable import Models +import XCTest + +final class HTMLStringTests: XCTestCase { + func testURLInit() throws { + XCTAssertNil(URL(string: "go to www.google.com", encodePath: true)) + XCTAssertNil(URL(string: "go to www.google.com", encodePath: false)) + XCTAssertNil(URL(string: "", encodePath: true)) + + let simpleUrl = URL(string: "https://www.google.com", encodePath: true) + XCTAssertEqual("https://www.google.com", simpleUrl?.absoluteString) + + let urlWithTrailingSlash = URL(string: "https://www.google.com/", encodePath: true) + XCTAssertEqual("https://www.google.com/", urlWithTrailingSlash?.absoluteString) + + let extendedCharPath = URL(string: "https://en.wikipedia.org/wiki/Elbbrücken_station", encodePath: true) + XCTAssertEqual("https://en.wikipedia.org/wiki/Elbbr%C3%BCcken_station", extendedCharPath?.absoluteString) + XCTAssertNil(URL(string: "https://en.wikipedia.org/wiki/Elbbrücken_station", encodePath: false)) + + let extendedCharQuery = URL(string: "http://test.com/blah/city?name=京都市", encodePath: true) + XCTAssertEqual("http://test.com/blah/city?name=%E4%BA%AC%E9%83%BD%E5%B8%82", extendedCharQuery?.absoluteString) + + // Double encoding will happen if you ask to encodePath on an already encoded string + let alreadyEncodedPath = URL(string: "https://en.wikipedia.org/wiki/Elbbr%C3%BCcken_station", encodePath: true) + XCTAssertEqual("https://en.wikipedia.org/wiki/Elbbr%25C3%25BCcken_station", alreadyEncodedPath?.absoluteString) + } + + func testHTMLStringInit() throws { + let decoder = JSONDecoder() + + let basicContent = "\"

This is a test

\"" + var htmlString = try decoder.decode(HTMLString.self, from: Data(basicContent.utf8)) + XCTAssertEqual("This is a test", htmlString.asRawText) + XCTAssertEqual("

This is a test

", htmlString.htmlValue) + XCTAssertEqual("This is a test", htmlString.asMarkdown) + XCTAssertEqual(0, htmlString.statusesURLs.count) + XCTAssertEqual(0, htmlString.links.count) + + let basicLink = "\"

This is a test

\"" + htmlString = try decoder.decode(HTMLString.self, from: Data(basicLink.utf8)) + XCTAssertEqual("This is a test", htmlString.asRawText) + XCTAssertEqual("

This is a test

", htmlString.htmlValue) + XCTAssertEqual("This is a [test](https://test.com)", htmlString.asMarkdown) + XCTAssertEqual(0, htmlString.statusesURLs.count) + XCTAssertEqual(1, htmlString.links.count) + XCTAssertEqual("https://test.com", htmlString.links[0].url.absoluteString) + XCTAssertEqual("test", htmlString.links[0].displayString) + + let extendedCharLink = "\"

This is a test

\"" + htmlString = try decoder.decode(HTMLString.self, from: Data(extendedCharLink.utf8)) + XCTAssertEqual("This is a test", htmlString.asRawText) + XCTAssertEqual("

This is a test

", htmlString.htmlValue) + XCTAssertEqual("This is a [test](https://test.com/go%C3%9F%C3%AB%C3%B1a)", htmlString.asMarkdown) + XCTAssertEqual(0, htmlString.statusesURLs.count) + XCTAssertEqual(1, htmlString.links.count) + XCTAssertEqual("https://test.com/go%C3%9F%C3%AB%C3%B1a", htmlString.links[0].url.absoluteString) + XCTAssertEqual("test", htmlString.links[0].displayString) + + let alreadyEncodedLink = "\"

This is a test

\"" + htmlString = try decoder.decode(HTMLString.self, from: Data(alreadyEncodedLink.utf8)) + XCTAssertEqual("This is a test", htmlString.asRawText) + XCTAssertEqual("

This is a test

", htmlString.htmlValue) + XCTAssertEqual("This is a [test](https://test.com/go%C3%9F%C3%AB%C3%B1a)", htmlString.asMarkdown) + XCTAssertEqual(0, htmlString.statusesURLs.count) + XCTAssertEqual(1, htmlString.links.count) + XCTAssertEqual("https://test.com/go%C3%9F%C3%AB%C3%B1a", htmlString.links[0].url.absoluteString) + XCTAssertEqual("test", htmlString.links[0].displayString) + } + + func testHTMLStringInit_markdownEscaping() throws { + let decoder = JSONDecoder() + + let stdMarkdownContent = "\"

This [*is*] `a`\\n**test**

\"" + var htmlString = try decoder.decode(HTMLString.self, from: Data(stdMarkdownContent.utf8)) + XCTAssertEqual("This [*is*] `a`\n**test**", htmlString.asRawText) + XCTAssertEqual("

This [*is*] `a`\n**test**

", htmlString.htmlValue) + XCTAssertEqual("This \\[\\*is\\*] \\`a\\` \\*\\*test\\*\\*", htmlString.asMarkdown) + + let underscoreContent = "\"

This _is_ an :emoji_maybe:

\"" + htmlString = try decoder.decode(HTMLString.self, from: Data(underscoreContent.utf8)) + XCTAssertEqual("This _is_ an :emoji_maybe:", htmlString.asRawText) + XCTAssertEqual("

This _is_ an :emoji_maybe:

", htmlString.htmlValue) + XCTAssertEqual("This \\_is\\_ an :emoji_maybe:", htmlString.asMarkdown) + + let strikeContent = "\"

This ~is~ a\\n`test`

\"" + htmlString = try decoder.decode(HTMLString.self, from: Data(strikeContent.utf8)) + XCTAssertEqual("This ~is~ a\n`test`", htmlString.asRawText) + XCTAssertEqual("

This ~is~ a\n`test`

", htmlString.htmlValue) + XCTAssertEqual("This \\~is\\~ a \\`test\\`", htmlString.asMarkdown) + } +} diff --git a/Threaded/Preview Content/Preview Assets.xcassets/Contents.json b/Threaded/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Threaded/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Threaded/ThreadedApp.swift b/Threaded/ThreadedApp.swift new file mode 100644 index 0000000..5576cbc --- /dev/null +++ b/Threaded/ThreadedApp.swift @@ -0,0 +1,17 @@ +//Made by Lumaa + +import SwiftUI + +@main +struct ThreadedApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .tint(Color(uiColor: .label)) + .background(Color.appBackground) + .onAppear { + HapticManager.prepareHaptics() + } + } + } +} diff --git a/Threaded/Views/AccountView.swift b/Threaded/Views/AccountView.swift new file mode 100644 index 0000000..8519981 --- /dev/null +++ b/Threaded/Views/AccountView.swift @@ -0,0 +1,179 @@ +//Made by Lumaa + +import SwiftUI + +struct AccountView: View { + @Environment(Client.self) private var client: Client + + @Namespace var accountAnims + @Namespace var animPicture + + @State private var navigator: Navigator = Navigator() + @State private var biggerPicture: Bool = false + @State private var location: CGPoint = .zero + + @State var account: Account + private let animPicCurve = Animation.smooth(duration: 0.25, extraBounce: 0.0) + + var body: some View { + ZStack (alignment: .center) { + if account != Account.placeholder() { + if biggerPicture { + big + } else { + wholeSmall + } + } else { + loading + } + } + .refreshable { + if let ref: Account = try? await client.get(endpoint: Accounts.accounts(id: account.id)) { + account = ref + } + } + .background(Color.appBackground) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(Color.appBackground, for: .navigationBar) + .toolbarBackground(.automatic, for: .navigationBar) + } + + // MARK: - Headers + + var wholeSmall: some View { + ScrollView { + VStack { + unbig + + HStack { + Text(account.note.asRawText) + .font(.body) + .multilineTextAlignment(.leading) + + Spacer() + } + } + .safeAreaPadding(.vertical) + .padding(.horizontal) + } + .withAppRouter() + } + + var loading: some View { + ScrollView { + VStack { + unbig + .redacted(reason: .placeholder) + + HStack { + Text(account.note.asRawText) + .font(.body) + .multilineTextAlignment(.leading) + .redacted(reason: .placeholder) + + Spacer() + } + } + .safeAreaPadding(.vertical) + .padding(.horizontal) + } + .withAppRouter() + } + + var unbig: some View { + HStack { + if account.displayName != nil { + VStack(alignment: .leading) { + Text(account.displayName!) + .font(.title2.bold()) + .multilineTextAlignment(.leading) + .lineLimit(1) + + let server = account.acct.split(separator: "@").last + + HStack(alignment: .center) { + if server != nil { + if server! != account.username { + Text("\(account.username)") + .font(.body) + .multilineTextAlignment(.leading) + + Text("\(server!.description)") + .font(.caption) + .foregroundStyle(Color.gray) + .multilineTextAlignment(.leading) + .pill() + } else { + Text("\(account.username)") + .font(.body) + .multilineTextAlignment(.leading) + + Text("\(client.server)") + .font(.caption) + .foregroundStyle(Color.gray) + .multilineTextAlignment(.leading) + .pill() + } + } else { + Text("\(account.username)") + .font(.body) + .multilineTextAlignment(.leading) + + Text("\(client.server)") + .font(.caption) + .foregroundStyle(Color.gray) + .multilineTextAlignment(.leading) + .pill() + } + } + } + } else { + Text(account.acct) + .font(.headline) + } + + Spacer() + + profilePicture + .frame(width: 75, height: 75) + } + } + + var big: some View { + ZStack (alignment: .center) { + Rectangle() + .fill(Material.ultraThin) + .ignoresSafeArea() + .onTapGesture { + withAnimation(animPicCurve) { + biggerPicture.toggle() + } + } + + profilePicture + .frame(width: 300, height: 300) + } + .zIndex(20) + } + + var profilePicture: some View { + OnlineImage(url: account.avatar) + .clipShape(.circle) + .matchedGeometryEffect(id: animPicture, in: accountAnims) + .onTapGesture { + withAnimation(animPicCurve) { + biggerPicture.toggle() + } + } + } +} + +private extension View { + func pill() -> some View { + self + .padding([.horizontal], 10) + .padding([.vertical], 5) + .background(Color(uiColor: UIColor.label).opacity(0.1)) + .clipShape(.capsule) + } +} diff --git a/Threaded/Views/AddInstanceView.swift b/Threaded/Views/AddInstanceView.swift new file mode 100644 index 0000000..bfe4dab --- /dev/null +++ b/Threaded/Views/AddInstanceView.swift @@ -0,0 +1,160 @@ +//Made by Lumaa + +import SwiftUI +import AuthenticationServices + +struct AddInstanceView: View { + @Environment(\.webAuthenticationSession) private var webAuthenticationSession + @Environment(\.dismiss) private var dismiss + + // Instance URL and verify + @State private var instanceUrl: String = "" + @State private var verifying: Bool = false + @State private var verified: Bool = false + @State private var verifyError: Bool = false + @State private var instanceInfo: Instance? + + @State private var signInClient: Client? + @Binding public var logged: Bool + + var body: some View { + Form { + Section { + TextField("login.mastodon.instance", text: $instanceUrl) + .keyboardType(.URL) + .textContentType(.URL) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .disabled(verifying) + + if !verifying { + if !verified { + Button { + verify() + } label: { + Text("login.mastodon.verify") + .disabled(instanceUrl.isEmpty) + } + .buttonStyle(.bordered) + + if verifyError == true { + Text("login.mastodon.verify-error") + .foregroundStyle(.red) + } + } else { + Button { + Task { + await signIn() + } + } label: { + Text("login.mastodon.login") + .disabled(instanceUrl.isEmpty) + } + .buttonStyle(.bordered) + } + } else { + ProgressView() + .progressViewStyle(.circular) + .tint(Color.white) + .foregroundStyle(Color.white) + } + } + + if verified && instanceInfo != nil { + Section { + VStack(alignment: .leading) { + Text(instanceInfo!.title) + .font(.headline) + Text(instanceInfo!.shortDescription) + } + + VStack(alignment: .leading, spacing: 15) { + Text("instance.rules") + .font(.headline) + + if !(instanceInfo!.rules?.isEmpty ?? true) { + ForEach(instanceInfo!.rules!) { rule in + Text(rule.text) + } + } + } + } + } + } + .onChange(of: instanceUrl) { _, newValue in + guard !self.verifying else { return } + verified = false + } + } + + func verify() { + withAnimation { + verifying = true + verified = false + verifyError = false + } + + let cleanInstance = instanceUrl + .replacingOccurrences(of: "http://", with: "") + .replacingOccurrences(of: "https://", with: "") + + let client = Client(server: cleanInstance) + + Task { + do { + let instance: Instance = try await client.get(endpoint: Instances.instance) + + withAnimation { + instanceInfo = instance + verifying = false + verified = true + verifyError = false + } + } catch { + print(error.localizedDescription) + + withAnimation { + verifying = false + verified = false + verifyError = true + } + } + } + } + + private func signIn() async { + let cleanInstance = instanceUrl + .replacingOccurrences(of: "http://", with: "") + .replacingOccurrences(of: "https://", with: "") + + signInClient = .init(server: cleanInstance) + if let oauthURL = try? await signInClient?.oauthURL(), + let url = try? await webAuthenticationSession.authenticate(using: oauthURL, callbackURLScheme: AppInfo.scheme.replacingOccurrences(of: "://", with: "")) { + await continueSignIn(url: url) + } + } + + private func continueSignIn(url: URL) async { + guard let client = signInClient else { + return + } + + do { + let oauthToken = try await client.continueOauthFlow(url: url) + let client = Client(server: client.server, oauthToken: oauthToken) + let account: Account = try await client.get(endpoint: Accounts.verifyCredentials) + let appAcc = AppAccount(server: client.server, accountName: "\(account.acct)@\(client.server)", oauthToken: oauthToken) + try appAcc.saveAsCurrent() + + signInClient = client + logged = true + dismiss() + } catch { + print(error) + } + } +} + +#Preview { + AddInstanceView(logged: .constant(false)) +} diff --git a/Threaded/Views/ConnectView.swift b/Threaded/Views/ConnectView.swift new file mode 100644 index 0000000..d1d8e5c --- /dev/null +++ b/Threaded/Views/ConnectView.swift @@ -0,0 +1,82 @@ +//Made by Lumaa + +import SwiftUI + +struct ConnectView: View { + @Environment(\.dismiss) private var dismiss + + @State private var sheet: SheetDestination? + @State private var logged: Bool = false + + var body: some View { + VStack { + Text("login.title") + .font(.title.bold()) + .multilineTextAlignment(.center) + + Spacer() + + VStack(spacing: 30) { + Button { + sheet = .mastodonLogin(logged: $logged) + } label: { + mastodon + } + .buttonStyle(LargeButton()) + + Button { + print("go directly") + } label: { + noAccount + } + .buttonStyle(LargeButton()) + } + .padding(.vertical, 100) + } + .withSheets(sheetDestination: $sheet) + .safeAreaPadding() + .onChange(of: logged) { _, newValue in + if newValue == true { + dismiss() + } + } + } + + var mastodon: some View { + HStack(alignment: .top) { + VStack(alignment: .leading) { + Text("login.mastodon") + Text("login.mastodon.footer") + .multilineTextAlignment(.leading) + .font(.caption) + .foregroundStyle(.gray) + } + Spacer() + Image("MastodonMark") + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + } + } + + var noAccount: some View { + HStack(alignment: .top) { + VStack(alignment: .leading) { + Text("login.no-account") + Text("login.no-account.footer") + .multilineTextAlignment(.leading) + .font(.caption) + .foregroundStyle(.gray) + } + Spacer() + Image(systemName: "person.crop.circle.dashed") + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + } + } +} + +#Preview { + ConnectView() +} diff --git a/Threaded/Views/ContentView.swift b/Threaded/Views/ContentView.swift new file mode 100644 index 0000000..068acad --- /dev/null +++ b/Threaded/Views/ContentView.swift @@ -0,0 +1,76 @@ +//Made by Lumaa + +import SwiftUI + +struct ContentView: View { + @State private var navigator = Navigator() + @State private var sheet: SheetDestination? + @State private var client: Client? + @State private var currentAccount: Account? + + var body: some View { + TabView(selection: $navigator.selectedTab, content: { + ZStack { + if client != nil { + TimelineView(timelineModel: FetchTimeline(client: self.client!)) + .background(Color.appBackground) + .safeAreaPadding() + } else { + ZStack { + Color.appBackground + .ignoresSafeArea() + } + } + } + .background(Color.appBackground) + .tag(TabDestination.timeline) + + Text(String("Search")) + .background(Color.appBackground) + .tag(TabDestination.search) + + Text(String("Activity")) + .background(Color.appBackground) + .tag(TabDestination.activity) + + ProfileView(account: currentAccount ?? .placeholder()) + .background(Color.appBackground) + .tag(TabDestination.profile) + }) + .overlay(alignment: .bottom) { + TabsView(navigator: navigator) + .safeAreaPadding(.vertical) + .zIndex(10) + } + .withCovers(sheetDestination: $sheet) + .environment(navigator) + .environment(client) + .onAppear { + let acc = try? AppAccount.loadAsCurrent() + if acc == nil { + sheet = .welcome + } else { + Task { + client = .init(server: acc!.server, oauthToken: acc!.oauthToken) + currentAccount = try? await client!.get(endpoint: Accounts.verifyCredentials) + } + } + } + } + + init() { + let appearance = UITabBarAppearance() + appearance.configureWithTransparentBackground() + appearance.stackedLayoutAppearance.normal.iconColor = .white + appearance.stackedLayoutAppearance.normal.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white] + + appearance.stackedLayoutAppearance.selected.iconColor = UIColor(Color.accentColor) + appearance.stackedLayoutAppearance.selected.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor(Color.accentColor)] + + UITabBar.appearance().standardAppearance = appearance + } +} + +#Preview { + ContentView() +} diff --git a/Threaded/Views/ProfileView.swift b/Threaded/Views/ProfileView.swift new file mode 100644 index 0000000..f388a36 --- /dev/null +++ b/Threaded/Views/ProfileView.swift @@ -0,0 +1,240 @@ +//Made by Lumaa + +import SwiftUI + +struct ProfileView: View { + @Environment(Client.self) private var client: Client + + @Namespace var accountAnims + @Namespace var animPicture + + @State private var navigator: Navigator = Navigator() + @State private var biggerPicture: Bool = false + @State private var location: CGPoint = .zero + + @State var account: Account + private let isCurrent: Bool = true + private let animPicCurve = Animation.smooth(duration: 0.25, extraBounce: 0.0) + + var body: some View { + NavigationStack(path: $navigator.path) { + ZStack (alignment: .center) { + if account != Account.placeholder() { + if biggerPicture { + big + } else { + wholeSmall + } + } else { + loading + } + } + } + .refreshable { + if isCurrent { + do { + if let saved: AppAccount = try AppAccount.loadAsCurrent() { + let cli: Client = Client(server: saved.server, oauthToken: saved.oauthToken) + let acc: Account? = try await client.get(endpoint: Accounts.verifyCredentials) + account = acc ?? Account.placeholder() + } + } catch { + print(error) + } + } else { + if let ref: Account = try? await client.get(endpoint: Accounts.accounts(id: account.id)) { + account = ref + } + } + } + .environment(navigator) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(Color(uiColor: UIColor.systemBackground), for: .navigationBar) + .toolbarBackground(.automatic, for: .navigationBar) + } + + // MARK: - Headers + + var wholeSmall: some View { + ScrollView { + VStack { + unbig + + HStack { + Text(account.note.asRawText) + .font(.body) + .multilineTextAlignment(.leading) + + Spacer() + } + } + .safeAreaPadding(.vertical) + .padding(.horizontal) + .offset(y: 50) + .overlay(alignment: .top) { + HStack { + Button { + navigator.navigate(to: .privacy) + } label: { + Image(systemName: "globe") + .font(.title2) + } + + Spacer() // middle seperation + + Button { + navigator.navigate(to: .settings) + } label: { + Image(systemName: "text.alignright") + .font(.title2) + } + } + .safeAreaPadding() + .background(Color(uiColor: UIColor.systemBackground)) + } + } + .withAppRouter() + } + + var loading: some View { + ScrollView { + VStack { + unbig + .redacted(reason: .placeholder) + + HStack { + Text(account.note.asRawText) + .font(.body) + .multilineTextAlignment(.leading) + .redacted(reason: .placeholder) + + Spacer() + } + } + .safeAreaPadding(.vertical) + .padding(.horizontal) + .offset(y: 50) + .overlay(alignment: .top) { + HStack { + Button { + navigator.navigate(to: .privacy) + } label: { + Image(systemName: "globe") + .font(.title2) + } + .disabled(true) + + Spacer() // middle seperation + + Button { + navigator.navigate(to: .settings) + } label: { + Image(systemName: "text.alignright") + .font(.title2) + } + .disabled(true) + } + .safeAreaPadding() + .background(Color(uiColor: UIColor.systemBackground)) + } + } + .withAppRouter() + } + + var unbig: some View { + HStack { + if account.displayName != nil { + VStack(alignment: .leading) { + Text(account.displayName!) + .font(.title2.bold()) + .multilineTextAlignment(.leading) + .lineLimit(1) + + let server = account.acct.split(separator: "@").last + + HStack(alignment: .center) { + if server != nil { + if server! != account.username { + Text("\(account.username)") + .font(.body) + .multilineTextAlignment(.leading) + + Text("\(server!.description)") + .font(.caption) + .foregroundStyle(Color.gray) + .multilineTextAlignment(.leading) + .pill() + } else { + Text("\(account.username)") + .font(.body) + .multilineTextAlignment(.leading) + + Text("\(client.server)") + .font(.caption) + .foregroundStyle(Color.gray) + .multilineTextAlignment(.leading) + .pill() + } + } else { + Text("\(account.username)") + .font(.body) + .multilineTextAlignment(.leading) + + Text("\(client.server)") + .font(.caption) + .foregroundStyle(Color.gray) + .multilineTextAlignment(.leading) + .pill() + } + } + } + } else { + Text(account.acct) + .font(.headline) + } + + Spacer() + + profilePicture + .frame(width: 75, height: 75) + } + } + + var big: some View { + ZStack (alignment: .center) { + Rectangle() + .fill(Material.ultraThin) + .ignoresSafeArea() + .onTapGesture { + withAnimation(animPicCurve) { + biggerPicture.toggle() + } + } + + profilePicture + .frame(width: 300, height: 300) + } + .zIndex(20) + } + + var profilePicture: some View { + OnlineImage(url: account.avatar) + .clipShape(.circle) + .matchedGeometryEffect(id: animPicture, in: accountAnims) + .onTapGesture { + withAnimation(animPicCurve) { + biggerPicture.toggle() + } + } + } +} + +private extension View { + func pill() -> some View { + self + .padding([.horizontal], 10) + .padding([.vertical], 5) + .background(Color(uiColor: UIColor.label).opacity(0.1)) + .clipShape(.capsule) + } +} diff --git a/Threaded/Views/Settings/PrivacyView.swift b/Threaded/Views/Settings/PrivacyView.swift new file mode 100644 index 0000000..0af9936 --- /dev/null +++ b/Threaded/Views/Settings/PrivacyView.swift @@ -0,0 +1,13 @@ +//Made by Lumaa + +import SwiftUI + +struct PrivacyView: View { + var body: some View { + Text("setting.privacy") + } +} + +#Preview { + PrivacyView() +} diff --git a/Threaded/Views/Settings/SettingsView.swift b/Threaded/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..ebbe61a --- /dev/null +++ b/Threaded/Views/Settings/SettingsView.swift @@ -0,0 +1,36 @@ +//Made by Lumaa + +import SwiftUI + +struct SettingsView: View { + @Environment(Navigator.self) private var navigator: Navigator + @State private var sheet: SheetDestination? + + var body: some View { + List { + Button { + navigator.navigate(to: .privacy) + } label: { + Label("setting.privacy", systemImage: "lock") + } +// .listRowSeparator(.hidden) + .listRowSeparator(.visible) + + Button { + UserDefaults.standard.removeObject(forKey: AppAccount.saveKey) + sheet = .welcome + } label: { + Text("logout") + } + .tint(Color.red) + .listRowSeparator(.hidden) + } + .withCovers(sheetDestination: $sheet) + .listStyle(.inset) + .navigationTitle("settings") + } +} + +#Preview { + SettingsView() +} diff --git a/Threaded/Views/TimelineView.swift b/Threaded/Views/TimelineView.swift new file mode 100644 index 0000000..7ecef21 --- /dev/null +++ b/Threaded/Views/TimelineView.swift @@ -0,0 +1,52 @@ +//Made by Lumaa + +import SwiftUI + +struct TimelineView: View { + @Environment(Client.self) private var client: Client + + @State private var navigator: Navigator = Navigator() + @State private var statuses: [Status]? + + @State var timelineModel: FetchTimeline + + var body: some View { + NavigationStack(path: $navigator.path) { + if statuses != nil { + ScrollView(showsIndicators: false) { + Image("HeroIcon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30) + .padding(.bottom) + + ForEach(statuses!, id: \.id) { status in + VStack(spacing: 2) { + CompactPostView(status: status, navigator: navigator) + } + } + } + .padding(.top) + .background(Color.appBackground) + .withAppRouter() + } else { + ZStack { + Color.appBackground + .ignoresSafeArea() + .onAppear { + Task { + statuses = try? await client.get(endpoint: Timelines.home(sinceId: nil, maxId: nil, minId: nil)) + } + } + + ProgressView() + .progressViewStyle(.circular) + } + } + } + .background(Color.appBackground) + .toolbarBackground(Color.appBackground, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .safeAreaPadding() + } +}