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