first commit

This commit is contained in:
Lumaa 2023-12-29 11:17:37 +01:00
commit bf77e2c4c1
98 changed files with 7423 additions and 0 deletions

91
.gitignore vendored Normal file
View File

@ -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

View File

@ -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!)
// }
// })
}
}

View File

@ -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>

15
AuthService/Info.plist Normal file
View File

@ -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>

201
LICENSE Normal file
View File

@ -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.

View File

@ -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 */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -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>

View File

@ -0,0 +1,14 @@
{
"pins" : [
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup",
"state" : {
"revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6",
"version" : "2.6.1"
}
}
],
"version" : 2
}

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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

View File

@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Logo violet 1mastodon.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@ -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())
}

View File

@ -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))
}
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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 {}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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 {}

View File

@ -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/"
}

278
Threaded/Data/Client.swift Normal file
View File

@ -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 {}

19
Threaded/Data/Emoji.swift Normal file
View File

@ -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?
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)")
}
}
}
}

View File

@ -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 {}

View File

@ -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
}
}
}

View File

@ -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()
}
}
}
}
}

View File

@ -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()
}
}
}

421
Threaded/Data/Status.swift Normal file
View File

@ -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 {}

69
Threaded/Data/Tag.swift Normal file
View File

@ -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 {}

19
Threaded/Info.plist Normal file
View File

@ -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>

View File

@ -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"
}

9
Threaded/Packages/Models/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -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>

View File

@ -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>

View File

@ -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"]
),
]
)

View File

@ -0,0 +1,3 @@
# Models
A description of this package.

View File

@ -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 {}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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"
}

View File

@ -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 {}

View File

@ -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)
}
}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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?
}

View File

@ -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 {}

View File

@ -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?
}

View File

@ -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 {}

View File

@ -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?
}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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
}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -0,0 +1,8 @@
import Foundation
public struct ServerError: Decodable, Error {
public let error: String?
public var httpCode: Int?
}
extension ServerError: Sendable {}

View File

@ -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: "")
}
}
}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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)
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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()
}
}
}
}

View File

@ -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)
}
}

View File

@ -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))
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -0,0 +1,13 @@
//Made by Lumaa
import SwiftUI
struct PrivacyView: View {
var body: some View {
Text("setting.privacy")
}
}
#Preview {
PrivacyView()
}

View File

@ -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()
}

View File

@ -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()
}
}