first commit
This commit is contained in:
commit
bf77e2c4c1
|
@ -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
|
|
@ -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!)
|
||||
// }
|
||||
// })
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="13142" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12042"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="AuthenticationViewController">
|
||||
<connections>
|
||||
<outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/>
|
||||
</connections>
|
||||
</placeholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="i5M-Pr-FkT">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<viewLayoutGuide key="safeArea" id="Q5M-cg-NOt"/>
|
||||
</view>
|
||||
</objects>
|
||||
</document>
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict/>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.AppSSO.idp-extension</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).AuthenticationViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
|
@ -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.
|
|
@ -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 = "<group>"; };
|
||||
B97BCE252B3DE5A10044756D /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = "<group>"; };
|
||||
B97BCE272B3ED2A80044756D /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = "<group>"; };
|
||||
B97BCE292B3ED2C80044756D /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
|
||||
B9842C0D2B2F21B700D9F3C1 /* CompactPostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactPostView.swift; sourceTree = "<group>"; };
|
||||
B9842C0F2B2F228C00D9F3C1 /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = "<group>"; };
|
||||
B9842C112B2F2A5800D9F3C1 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = "<group>"; };
|
||||
B9842C132B2F310C00D9F3C1 /* FetchTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchTimeline.swift; sourceTree = "<group>"; };
|
||||
B9842C152B2F363600D9F3C1 /* TimelineFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineFilter.swift; sourceTree = "<group>"; };
|
||||
B9842C172B2F36F500D9F3C1 /* AccountsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsList.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
B9FB945C2B2DEECE00D81C07 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
B9FB94602B2DEECF00D81C07 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
B9FB94632B2DEECF00D81C07 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
B9FB946F2B2DF3CD00D81C07 /* Navigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Navigator.swift; sourceTree = "<group>"; };
|
||||
B9FB94712B2DF49700D81C07 /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = "<group>"; };
|
||||
B9FB94732B2DF6A100D81C07 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = "<group>"; };
|
||||
B9FB94752B2E023D00D81C07 /* TabsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabsView.swift; sourceTree = "<group>"; };
|
||||
B9FB947C2B2E19E300D81C07 /* AccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountManager.swift; sourceTree = "<group>"; };
|
||||
B9FB947E2B2E1D5F00D81C07 /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = "<group>"; };
|
||||
B9FB94802B2E1FEF00D81C07 /* HTMLString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLString.swift; sourceTree = "<group>"; };
|
||||
B9FB94852B2E211200D81C07 /* Account+Elms.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Elms.swift"; sourceTree = "<group>"; };
|
||||
B9FB94872B2E223E00D81C07 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
|
||||
B9FB94892B2E227000D81C07 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
|
||||
B9FB948B2B2E232300D81C07 /* OnlineImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineImage.swift; sourceTree = "<group>"; };
|
||||
B9FB948D2B2E28E800D81C07 /* ShareableImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareableImage.swift; sourceTree = "<group>"; };
|
||||
B9FB948F2B2E2B0E00D81C07 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
B9FB94912B2E35D000D81C07 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
B9FB94962B2EDABF00D81C07 /* PrivacyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyView.swift; sourceTree = "<group>"; };
|
||||
B9FB94982B2EEB9400D81C07 /* AddInstanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddInstanceView.swift; sourceTree = "<group>"; };
|
||||
B9FB949A2B2EF09A00D81C07 /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = "<group>"; };
|
||||
B9FB949C2B2EF0D600D81C07 /* Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Instance.swift; sourceTree = "<group>"; };
|
||||
B9FB949E2B2EF0F200D81C07 /* MastodonRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRequest.swift; sourceTree = "<group>"; };
|
||||
B9FB94A02B2EF23100D81C07 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
B9FB94A12B2EF24A00D81C07 /* AppInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfo.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
B9FB94AF2B2F009F00D81C07 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/AuthenticationViewController.xib; sourceTree = "<group>"; };
|
||||
B9FB94B12B2F009F00D81C07 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
B9FB94BB2B2F035500D81C07 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
B9FB94582B2DEECE00D81C07 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B9FB94572B2DEECE00D81C07 /* Threaded.app */,
|
||||
B9FB94A72B2F009F00D81C07 /* ThreadedAuthService.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
B9FB94622B2DEECF00D81C07 /* Preview Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B9FB94632B2DEECF00D81C07 /* Preview Assets.xcassets */,
|
||||
);
|
||||
path = "Preview Content";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
B9FB946E2B2DF3BB00D81C07 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B9FB94792B2E137100D81C07 /* TabsNavs */,
|
||||
B9FB94732B2DF6A100D81C07 /* ButtonStyles.swift */,
|
||||
B9FB948B2B2E232300D81C07 /* OnlineImage.swift */,
|
||||
B9842C0D2B2F21B700D9F3C1 /* CompactPostView.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B9FB94792B2E137100D81C07 /* TabsNavs */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B9FB94752B2E023D00D81C07 /* TabsView.swift */,
|
||||
);
|
||||
path = TabsNavs;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B9FB94952B2EDAB600D81C07 /* Settings */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B9FB94912B2E35D000D81C07 /* SettingsView.swift */,
|
||||
B9FB94962B2EDABF00D81C07 /* PrivacyView.swift */,
|
||||
);
|
||||
path = Settings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B9FB94A82B2F009F00D81C07 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B9FB94A92B2F009F00D81C07 /* AuthenticationServices.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B9FB94AB2B2F009F00D81C07 /* AuthService */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B9FB94AC2B2F009F00D81C07 /* AuthenticationViewController.swift */,
|
||||
B9FB94AE2B2F009F00D81C07 /* AuthenticationViewController.xib */,
|
||||
B9FB94B12B2F009F00D81C07 /* Info.plist */,
|
||||
);
|
||||
path = AuthService;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B9FB94BD2B2F038D00D81C07 /* Accounts */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B9FB947C2B2E19E300D81C07 /* AccountManager.swift */,
|
||||
B9FB947E2B2E1D5F00D81C07 /* Account.swift */,
|
||||
B9FB94852B2E211200D81C07 /* Account+Elms.swift */,
|
||||
B9842C152B2F363600D9F3C1 /* TimelineFilter.swift */,
|
||||
);
|
||||
path = Accounts;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swiftsoup",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/scinfu/SwiftSoup",
|
||||
"state" : {
|
||||
"revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6",
|
||||
"version" : "2.6.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 39 KiB |
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
After Width: | Height: | Size: 49 KiB |
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Logo violet 1mastodon.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Threaded/Assets.xcassets/Mastodon/MastodonMark.imageset/Logo violet 1mastodon.png
vendored
Normal file
BIN
Threaded/Assets.xcassets/Mastodon/MastodonMark.imageset/Logo violet 1mastodon.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 116 KiB |
|
@ -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())
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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/"
|
||||
}
|
|
@ -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<Critical>
|
||||
private struct Critical: Sendable {
|
||||
/// Only used as a transitionary app while in the oauth flow.
|
||||
var oauthApp: InstanceApp?
|
||||
var oauthToken: OauthToken?
|
||||
var connections: Set<String> = []
|
||||
}
|
||||
|
||||
public var isAuth: Bool {
|
||||
critical.withLock { $0.oauthToken != nil }
|
||||
}
|
||||
|
||||
public var connections: Set<String> {
|
||||
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<Entity: Decodable>(endpoint: Endpoint, forceVersion: Version? = nil) async throws -> Entity {
|
||||
try await makeEntityRequest(endpoint: endpoint, method: "GET", forceVersion: forceVersion)
|
||||
}
|
||||
|
||||
public func getWithLink<Entity: Decodable>(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<Entity: Decodable>(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<Entity: Decodable>(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<Entity: Decodable>(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<Entity: Decodable>(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 {}
|
|
@ -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?
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 <p>
|
||||
asMarkdown += "\n\n"
|
||||
}
|
||||
} else if node.nodeName() == "br" {
|
||||
if asMarkdown.count > 0 { // ignore first opening <br>
|
||||
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 <br>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)
|
||||
}
|
||||
}
|
|
@ -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..<haptics.count {
|
||||
let relativeInterval: TimeInterval = TimeInterval(intervals[0...index].reduce(0, +))
|
||||
let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: Float(hapticIntensity[index]))
|
||||
let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: Float(hapticSharpness[index]))
|
||||
let event = CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: relativeInterval)
|
||||
events.append(event)
|
||||
}
|
||||
|
||||
do {
|
||||
let pattern = try CHHapticPattern(events: events, parameters: [])
|
||||
let player = try engine?.makePlayer(with: pattern)
|
||||
try player?.start(atTime: CHHapticTimeImmediate)
|
||||
} catch {
|
||||
print("Failed to play pattern: \(error.localizedDescription).")
|
||||
}
|
||||
}
|
||||
|
||||
static func prepareHaptics() {
|
||||
let hapticCapability = CHHapticEngine.capabilitiesForHardware()
|
||||
HapticManager.supportsHaptics = hapticCapability.supportsHaptics
|
||||
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
|
||||
|
||||
do {
|
||||
HapticManager.engine = try CHHapticEngine()
|
||||
try engine?.start()
|
||||
} catch {
|
||||
print("Error creating the engine: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
engine?.resetHandler = {
|
||||
print("Restarting haptic engine")
|
||||
do {
|
||||
try self.engine?.start()
|
||||
} catch {
|
||||
fatalError("Failed to restart the engine: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
//Made by Lumaa
|
||||
|
||||
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?
|
||||
}
|
||||
|
||||
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 {}
|
|
@ -0,0 +1,521 @@
|
|||
//Made by Lumaa
|
||||
|
||||
import Foundation
|
||||
import RegexBuilder
|
||||
|
||||
public protocol Endpoint: Sendable {
|
||||
func path() -> 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Bool>)
|
||||
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<SheetDestination?>) -> some View {
|
||||
sheet(item: sheetDestination) { destination in
|
||||
viewRepresentation(destination: destination, isCover: false)
|
||||
}
|
||||
}
|
||||
|
||||
func withCovers(sheetDestination: Binding<SheetDestination?>) -> 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>threaded</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>threaded://</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
|
@ -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"
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/config/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
|
@ -0,0 +1,66 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Models"
|
||||
BuildableName = "Models"
|
||||
BlueprintName = "Models"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "Models"
|
||||
BuildableName = "Models"
|
||||
BlueprintName = "Models"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "ModelsTests"
|
||||
BuildableName = "ModelsTests"
|
||||
BlueprintName = "ModelsTests"
|
||||
ReferencedContainer = "container:">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
|
@ -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"]
|
||||
),
|
||||
]
|
||||
)
|
|
@ -0,0 +1,3 @@
|
|||
# Models
|
||||
|
||||
A description of this package.
|
|
@ -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 {}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 <p>
|
||||
asMarkdown += "\n\n"
|
||||
}
|
||||
} else if node.nodeName() == "br" {
|
||||
if asMarkdown.count > 0 { // ignore first opening <br>
|
||||
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 <br>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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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 {}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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?
|
||||
}
|
|
@ -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 {}
|
|
@ -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?
|
||||
}
|
|
@ -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 {}
|
|
@ -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?
|
||||
}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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
|
||||
}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -0,0 +1,8 @@
|
|||
import Foundation
|
||||
|
||||
public struct ServerError: Decodable, Error {
|
||||
public let error: String?
|
||||
public var httpCode: Int?
|
||||
}
|
||||
|
||||
extension ServerError: Sendable {}
|
|
@ -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: "")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
@ -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 = "\"<p>This is a test</p>\""
|
||||
var htmlString = try decoder.decode(HTMLString.self, from: Data(basicContent.utf8))
|
||||
XCTAssertEqual("This is a test", htmlString.asRawText)
|
||||
XCTAssertEqual("<p>This is a test</p>", htmlString.htmlValue)
|
||||
XCTAssertEqual("This is a test", htmlString.asMarkdown)
|
||||
XCTAssertEqual(0, htmlString.statusesURLs.count)
|
||||
XCTAssertEqual(0, htmlString.links.count)
|
||||
|
||||
let basicLink = "\"<p>This is a <a href=\\\"https://test.com\\\">test</a></p>\""
|
||||
htmlString = try decoder.decode(HTMLString.self, from: Data(basicLink.utf8))
|
||||
XCTAssertEqual("This is a test", htmlString.asRawText)
|
||||
XCTAssertEqual("<p>This is a <a href=\"https://test.com\">test</a></p>", 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 = "\"<p>This is a <a href=\\\"https://test.com/goßëña\\\">test</a></p>\""
|
||||
htmlString = try decoder.decode(HTMLString.self, from: Data(extendedCharLink.utf8))
|
||||
XCTAssertEqual("This is a test", htmlString.asRawText)
|
||||
XCTAssertEqual("<p>This is a <a href=\"https://test.com/goßëña\">test</a></p>", 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 = "\"<p>This is a <a href=\\\"https://test.com/go%C3%9F%C3%AB%C3%B1a\\\">test</a></p>\""
|
||||
htmlString = try decoder.decode(HTMLString.self, from: Data(alreadyEncodedLink.utf8))
|
||||
XCTAssertEqual("This is a test", htmlString.asRawText)
|
||||
XCTAssertEqual("<p>This is a <a href=\"https://test.com/go%C3%9F%C3%AB%C3%B1a\">test</a></p>", 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 = "\"<p>This [*is*] `a`\\n**test**</p>\""
|
||||
var htmlString = try decoder.decode(HTMLString.self, from: Data(stdMarkdownContent.utf8))
|
||||
XCTAssertEqual("This [*is*] `a`\n**test**", htmlString.asRawText)
|
||||
XCTAssertEqual("<p>This [*is*] `a`\n**test**</p>", htmlString.htmlValue)
|
||||
XCTAssertEqual("This \\[\\*is\\*] \\`a\\` \\*\\*test\\*\\*", htmlString.asMarkdown)
|
||||
|
||||
let underscoreContent = "\"<p>This _is_ an :emoji_maybe:</p>\""
|
||||
htmlString = try decoder.decode(HTMLString.self, from: Data(underscoreContent.utf8))
|
||||
XCTAssertEqual("This _is_ an :emoji_maybe:", htmlString.asRawText)
|
||||
XCTAssertEqual("<p>This _is_ an :emoji_maybe:</p>", htmlString.htmlValue)
|
||||
XCTAssertEqual("This \\_is\\_ an :emoji_maybe:", htmlString.asMarkdown)
|
||||
|
||||
let strikeContent = "\"<p>This ~is~ a\\n`test`</p>\""
|
||||
htmlString = try decoder.decode(HTMLString.self, from: Data(strikeContent.utf8))
|
||||
XCTAssertEqual("This ~is~ a\n`test`", htmlString.asRawText)
|
||||
XCTAssertEqual("<p>This ~is~ a\n`test`</p>", htmlString.htmlValue)
|
||||
XCTAssertEqual("This \\~is\\~ a \\`test\\`", htmlString.asMarkdown)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
//Made by Lumaa
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PrivacyView: View {
|
||||
var body: some View {
|
||||
Text("setting.privacy")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PrivacyView()
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue