New post view, follow unfollow, Nuke, better CompactPostView, fixes and improvements

This commit is contained in:
Lumaa 2024-01-02 14:23:36 +01:00
parent 17463147c7
commit a2ee1a092b
22 changed files with 1306 additions and 270 deletions

View File

@ -10,4 +10,5 @@ Threaded is a 100% free, made in SwiftUI, [#OpenSource](https://github.com/luma
- [Lumaa](https://lumaa.fr/)
- [IceCubesApp](https://github.com/dimillian/IceCubesApp) by [@dimillian](https://github.com/dimillian)
- [SwiftSoup](https://github.com/scinfu/SwiftSoup)
- [SwiftSoup](https://github.com/scinfu/SwiftSoup)
- [Nuke](https://github.com/kean/Nuke)

View File

@ -7,6 +7,14 @@
objects = {
/* Begin PBXBuildFile section */
B93B676D2B42C94F000892E9 /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = B93B676C2B42C94F000892E9 /* Nuke */; };
B93B676F2B42C94F000892E9 /* NukeExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = B93B676E2B42C94F000892E9 /* NukeExtensions */; };
B93B67712B42C94F000892E9 /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = B93B67702B42C94F000892E9 /* NukeUI */; };
B93B67732B42C94F000892E9 /* NukeVideo in Frameworks */ = {isa = PBXBuildFile; productRef = B93B67722B42C94F000892E9 /* NukeVideo */; };
B93B67762B42E8AB000892E9 /* EmojiText in Frameworks */ = {isa = PBXBuildFile; productRef = B93B67752B42E8AB000892E9 /* EmojiText */; };
B93B67782B42E8F0000892E9 /* TextEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93B67772B42E8F0000892E9 /* TextEmoji.swift */; };
B93B677A2B42EC51000892E9 /* MetaPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93B67792B42EC51000892E9 /* MetaPicker.swift */; };
B93B677C2B433A6E000892E9 /* PostingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93B677B2B433A6E000892E9 /* PostingView.swift */; };
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 */; };
@ -17,6 +25,7 @@
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 */; };
B9B63B212B442D1500BBC82D /* DynamicTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B63B202B442D1500BBC82D /* DynamicTextEditor.swift */; };
B9CC45B82B40A2D6001E4FA5 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9CC45B72B40A2D6001E4FA5 /* AboutView.swift */; };
B9FB945B2B2DEECE00D81C07 /* ThreadedApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB945A2B2DEECE00D81C07 /* ThreadedApp.swift */; };
B9FB945D2B2DEECE00D81C07 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9FB945C2B2DEECE00D81C07 /* ContentView.swift */; };
@ -32,7 +41,6 @@
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 */; };
@ -75,6 +83,9 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
B93B67772B42E8F0000892E9 /* TextEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextEmoji.swift; sourceTree = "<group>"; };
B93B67792B42EC51000892E9 /* MetaPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetaPicker.swift; sourceTree = "<group>"; };
B93B677B2B433A6E000892E9 /* PostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingView.swift; sourceTree = "<group>"; };
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>"; };
@ -85,6 +96,7 @@
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>"; };
B9B63B202B442D1500BBC82D /* DynamicTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicTextEditor.swift; sourceTree = "<group>"; };
B9CC45B72B40A2D6001E4FA5 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
B9CC45B92B40AA1E001E4FA5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
B9FB94572B2DEECE00D81C07 /* Threaded.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Threaded.app; sourceTree = BUILT_PRODUCTS_DIR; };
@ -101,7 +113,6 @@
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>"; };
@ -126,7 +137,12 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B93B67762B42E8AB000892E9 /* EmojiText in Frameworks */,
B9FB94842B2E20AF00D81C07 /* SwiftSoup in Frameworks */,
B93B676D2B42C94F000892E9 /* Nuke in Frameworks */,
B93B67732B42C94F000892E9 /* NukeVideo in Frameworks */,
B93B676F2B42C94F000892E9 /* NukeExtensions in Frameworks */,
B93B67712B42C94F000892E9 /* NukeUI in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -212,11 +228,11 @@
children = (
B9FB94952B2EDAB600D81C07 /* Settings */,
B9FB94712B2DF49700D81C07 /* ConnectView.swift */,
B9FB94892B2E227000D81C07 /* ProfileView.swift */,
B9FB94982B2EEB9400D81C07 /* AddInstanceView.swift */,
B9FB945C2B2DEECE00D81C07 /* ContentView.swift */,
B9842C112B2F2A5800D9F3C1 /* TimelineView.swift */,
B97BCE252B3DE5A10044756D /* AccountView.swift */,
B93B677B2B433A6E000892E9 /* PostingView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -226,8 +242,11 @@
children = (
B9FB94752B2E023D00D81C07 /* TabsView.swift */,
B9FB94732B2DF6A100D81C07 /* ButtonStyles.swift */,
B93B67792B42EC51000892E9 /* MetaPicker.swift */,
B9FB948B2B2E232300D81C07 /* OnlineImage.swift */,
B9842C0D2B2F21B700D9F3C1 /* CompactPostView.swift */,
B93B67772B42E8F0000892E9 /* TextEmoji.swift */,
B9B63B202B442D1500BBC82D /* DynamicTextEditor.swift */,
);
path = Components;
sourceTree = "<group>";
@ -291,6 +310,11 @@
name = Threaded;
packageProductDependencies = (
B9FB94832B2E20AF00D81C07 /* SwiftSoup */,
B93B676C2B42C94F000892E9 /* Nuke */,
B93B676E2B42C94F000892E9 /* NukeExtensions */,
B93B67702B42C94F000892E9 /* NukeUI */,
B93B67722B42C94F000892E9 /* NukeVideo */,
B93B67752B42E8AB000892E9 /* EmojiText */,
);
productName = Threaded;
productReference = B9FB94572B2DEECE00D81C07 /* Threaded.app */;
@ -342,6 +366,8 @@
mainGroup = B9FB944E2B2DEECE00D81C07;
packageReferences = (
B9FB94822B2E20AF00D81C07 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
B93B676B2B42C94F000892E9 /* XCRemoteSwiftPackageReference "Nuke" */,
B93B67742B42E8AB000892E9 /* XCRemoteSwiftPackageReference "EmojiText" */,
);
productRefGroup = B9FB94582B2DEECE00D81C07 /* Products */;
projectDirPath = "";
@ -384,18 +410,19 @@
B9FB94922B2E35D000D81C07 /* SettingsView.swift in Sources */,
B9CC45B82B40A2D6001E4FA5 /* AboutView.swift in Sources */,
B9FB94882B2E223E00D81C07 /* Emoji.swift in Sources */,
B93B67782B42E8F0000892E9 /* TextEmoji.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 */,
B93B677A2B42EC51000892E9 /* MetaPicker.swift in Sources */,
B9FB94722B2DF49700D81C07 /* ConnectView.swift in Sources */,
B9FB945B2B2DEECE00D81C07 /* ThreadedApp.swift in Sources */,
B9FB94862B2E211200D81C07 /* Account+Elms.swift in Sources */,
@ -406,8 +433,10 @@
B9FB948C2B2E232300D81C07 /* OnlineImage.swift in Sources */,
B9FB94742B2DF6A100D81C07 /* ButtonStyles.swift in Sources */,
B9FB94702B2DF3CD00D81C07 /* Navigator.swift in Sources */,
B93B677C2B433A6E000892E9 /* PostingView.swift in Sources */,
B97BCE262B3DE5A10044756D /* AccountView.swift in Sources */,
B97BCE242B3DD8400044756D /* HapticManager.swift in Sources */,
B9B63B212B442D1500BBC82D /* DynamicTextEditor.swift in Sources */,
B9FB949F2B2EF0F200D81C07 /* MastodonRequest.swift in Sources */,
B9842C182B2F36F500D9F3C1 /* AccountsList.swift in Sources */,
B9FB948E2B2E28E800D81C07 /* ShareableImage.swift in Sources */,
@ -721,6 +750,22 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
B93B676B2B42C94F000892E9 /* XCRemoteSwiftPackageReference "Nuke" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kean/Nuke";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 12.2.0;
};
};
B93B67742B42E8AB000892E9 /* XCRemoteSwiftPackageReference "EmojiText" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/divadretlaw/EmojiText";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 3.3.0;
};
};
B9FB94822B2E20AF00D81C07 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/scinfu/SwiftSoup";
@ -732,6 +777,31 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
B93B676C2B42C94F000892E9 /* Nuke */ = {
isa = XCSwiftPackageProductDependency;
package = B93B676B2B42C94F000892E9 /* XCRemoteSwiftPackageReference "Nuke" */;
productName = Nuke;
};
B93B676E2B42C94F000892E9 /* NukeExtensions */ = {
isa = XCSwiftPackageProductDependency;
package = B93B676B2B42C94F000892E9 /* XCRemoteSwiftPackageReference "Nuke" */;
productName = NukeExtensions;
};
B93B67702B42C94F000892E9 /* NukeUI */ = {
isa = XCSwiftPackageProductDependency;
package = B93B676B2B42C94F000892E9 /* XCRemoteSwiftPackageReference "Nuke" */;
productName = NukeUI;
};
B93B67722B42C94F000892E9 /* NukeVideo */ = {
isa = XCSwiftPackageProductDependency;
package = B93B676B2B42C94F000892E9 /* XCRemoteSwiftPackageReference "Nuke" */;
productName = NukeVideo;
};
B93B67752B42E8AB000892E9 /* EmojiText */ = {
isa = XCSwiftPackageProductDependency;
package = B93B67742B42E8AB000892E9 /* XCRemoteSwiftPackageReference "EmojiText" */;
productName = EmojiText;
};
B9FB94832B2E20AF00D81C07 /* SwiftSoup */ = {
isa = XCSwiftPackageProductDependency;
package = B9FB94822B2E20AF00D81C07 /* XCRemoteSwiftPackageReference "SwiftSoup" */;

View File

@ -1,5 +1,23 @@
{
"pins" : [
{
"identity" : "emojitext",
"kind" : "remoteSourceControl",
"location" : "https://github.com/divadretlaw/EmojiText",
"state" : {
"revision" : "e24d8c0def5c77c551fee34fca09c38baa0860e6",
"version" : "3.3.0"
}
},
{
"identity" : "nuke",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kean/Nuke",
"state" : {
"revision" : "1694798e876113d44f6ec6ead965d7286695981d",
"version" : "12.2.0"
}
},
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",

View File

@ -4,10 +4,12 @@ import SwiftUI
struct LargeButton: ButtonStyle {
var filled: Bool = false
var height: CGFloat? = nil
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding()
.padding(.horizontal)
.padding(.vertical, height)
.background {
if filled {
Color(uiColor: UIColor.label)

View File

@ -3,9 +3,9 @@
import SwiftUI
struct CompactPostView: View {
@Environment(Client.self) private var client: Client
@Environment(AccountManager.self) private var accountManager: AccountManager
var status: Status
var navigator: Navigator
@ObservedObject var navigator: Navigator
var pinned: Bool = false
@State private var initialLike: Bool = false
@ -14,22 +14,18 @@ struct CompactPostView: View {
var body: some View {
VStack {
if status.reblog != nil {
VStack(alignment: .leading) {
VStack(alignment: .leading) {
if pinned {
pinnedNotice
.padding(.leading, 35)
}
if status.reblog != nil {
repostNotice
.padding(.leading, 30)
statusRepost
}
} else {
VStack(alignment: .leading) {
if pinned {
pinnedNotice
.padding(.leading, 15)
}
statusPost
}
statusPost(status.reblog ?? status)
}
Rectangle()
@ -45,58 +41,67 @@ struct CompactPostView: View {
}
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
if let client = accountManager.getClient() {
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
if let client = accountManager.getClient() {
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 {
@ViewBuilder
func statusPost(_ status: AnyStatus) -> 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 {
if status.repliesCount > 0 {
VStack {
profilePicture
.onTapGesture {
navigator.navigate(to: .account(acc: status.account))
}
Spacer()
Rectangle()
.fill(Color.gray.opacity(0.3))
.frame(width: 2.5)
.clipShape(.capsule)
.padding([.vertical], 5)
Spacer()
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
@ -108,10 +113,11 @@ struct CompactPostView: View {
navigator.navigate(to: .account(acc: status.account))
}
Text(status.content.asRawText)
.multilineTextAlignment(.leading)
.frame(width: 300, alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
TextEmoji(status.content, emojis: status.emojis, language: status.language)
.multilineTextAlignment(.leading)
.frame(width: 300, alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
.font(.callout)
}
//MARK: Action buttons
@ -127,7 +133,7 @@ struct CompactPostView: View {
}
actionButton("bubble.right") {
print("reply")
navigator.presentedSheet = .post
navigator.presentedSheet = .post()
}
asyncActionButton(isReposted ? "bolt.horizontal.fill" : "bolt.horizontal") {
do {
@ -152,90 +158,6 @@ struct CompactPostView: View {
}
}
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 {
try await likePost()
HapticManager.playHaptics(haptics: Haptic.tap)
} 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 {
try await repostPost()
HapticManager.playHaptics(haptics: Haptic.tap)
} 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 pinnedNotice: some View {
HStack (alignment:.center, spacing: 5) {
Image(systemName: "pin.fill")
@ -264,12 +186,12 @@ struct CompactPostView: View {
var profilePicture: some View {
if status.reblog != nil {
OnlineImage(url: status.reblog!.account.avatar)
OnlineImage(url: status.reblog!.account.avatar, size: 50, useNuke: true)
.frame(width: 40, height: 40)
.padding(.horizontal)
.clipShape(.circle)
} else {
OnlineImage(url: status.account.avatar)
OnlineImage(url: status.account.avatar, size: 50, useNuke: true)
.frame(width: 40, height: 40)
.padding(.horizontal)
.clipShape(.circle)
@ -292,11 +214,12 @@ struct CompactPostView: View {
}
if status.favouritesCount > 0 || isLiked {
let addedLike: Int = isLiked ? 1 : 0
Text("status.favourites-\(initialLike ? (status.favouritesCount - addedLike) : (status.favouritesCount + addedLike))")
let i: Int = status.favouritesCount
let favsCount: Int = i - (initialLike ? 1 : 0) + (isLiked ? 1 : 0)
Text("status.favourites-\(favsCount)")
.monospacedDigit()
.foregroundStyle(.gray)
.contentTransition(.numericText(value: Double(status.favouritesCount + addedLike)))
.contentTransition(.numericText(value: Double(favsCount)))
.transaction { t in
t.animation = .default
}
@ -316,11 +239,11 @@ struct CompactPostView: View {
}
if status.reblog!.favouritesCount > 0 || isLiked {
let addedLike: Int = isLiked ? 1 : 0
Text("status.favourites-\(initialLike ? (status.favouritesCount - addedLike) : (status.favouritesCount + addedLike))")
let favsCount: Int = (status.favouritesCount - (initialLike ? 1 : 0)) + (isLiked ? 1 : 0)
Text("status.favourites-\(favsCount)")
.monospacedDigit()
.foregroundStyle(.gray)
.contentTransition(.numericText(value: Double(status.reblog!.favouritesCount + addedLike)))
.contentTransition(.numericText(value: Double(favsCount)))
.transaction { t in
t.animation = .default
}

View File

@ -0,0 +1,226 @@
//Made by Lumaa
import SwiftUI
import UIKit
/// A SwiftUI TextView implementation that supports both scrolling and auto-sizing layouts
public struct DynamicTextEditor: View {
@Environment(\.layoutDirection) private var layoutDirection
@Binding private var text: NSMutableAttributedString
@Binding private var isEmpty: Bool
@State private var calculatedHeight: CGFloat = 44
private var getTextView: ((UITextView) -> Void)?
var placeholderView: AnyView?
var placeholderText: String?
var keyboard: UIKeyboardType = .default
/// Makes a new TextView that supports `NSAttributedString`
/// - Parameters:
/// - text: A binding to the attributed text
public init(_ text: Binding<NSMutableAttributedString>,
getTextView: ((UITextView) -> Void)? = nil)
{
_text = text
_isEmpty = Binding(
get: { text.wrappedValue.string.isEmpty },
set: { _ in }
)
self.getTextView = getTextView
}
public var body: some View {
Representable(
text: $text,
calculatedHeight: $calculatedHeight,
keyboard: keyboard,
getTextView: getTextView
)
.frame(
minHeight: calculatedHeight,
maxHeight: calculatedHeight
)
.accessibilityValue($text.wrappedValue.string.isEmpty ? (placeholderText ?? "") : $text.wrappedValue.string)
.background(
placeholderView?
.foregroundColor(Color(.placeholderText))
.multilineTextAlignment(.leading)
.font(.callout)
.padding(.horizontal, 0)
.padding(.vertical, 0)
.opacity(isEmpty ? 1 : 0)
.accessibilityHidden(true),
alignment: .topLeading
)
}
}
final class UIKitTextView: UITextView {
override var keyCommands: [UIKeyCommand]? {
(super.keyCommands ?? []) + [
UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(escape(_:))),
]
}
@objc private func escape(_: Any) {
resignFirstResponder()
}
}
public extension DynamicTextEditor {
/// Specify a placeholder text
/// - Parameter placeholder: The placeholder text
func placeholder(_ placeholder: String) -> DynamicTextEditor {
self.placeholder(placeholder) { $0 }
}
/// Specify a placeholder with the specified configuration
///
/// Example:
///
/// TextView($text)
/// .placeholder("placeholder") { view in
/// view.foregroundColor(.red)
/// }
func placeholder(_ placeholder: String, _ configure: (Text) -> some View) -> DynamicTextEditor {
var view = self
let text = Text(placeholder)
view.placeholderView = AnyView(configure(text))
view.placeholderText = placeholder
return view
}
/// Specify a custom placeholder view
func placeholder(_ placeholder: some View) -> DynamicTextEditor {
var view = self
view.placeholderView = AnyView(placeholder)
return view
}
func setKeyboardType(_ keyboardType: UIKeyboardType) -> DynamicTextEditor {
var view = self
view.keyboard = keyboardType
return view
}
}
extension DynamicTextEditor {
struct Representable: UIViewRepresentable {
@Binding var text: NSMutableAttributedString
@Binding var calculatedHeight: CGFloat
@Environment(\.sizeCategory) var sizeCategory
let keyboard: UIKeyboardType
var getTextView: ((UITextView) -> Void)?
func makeUIView(context: Context) -> UIKitTextView {
context.coordinator.textView
}
func updateUIView(_: UIKitTextView, context: Context) {
context.coordinator.update(representable: self)
if !context.coordinator.didBecomeFirstResponder {
context.coordinator.textView.becomeFirstResponder()
context.coordinator.didBecomeFirstResponder = true
}
}
@discardableResult func makeCoordinator() -> Coordinator {
Coordinator(
text: $text,
calculatedHeight: $calculatedHeight,
sizeCategory: sizeCategory,
getTextView: getTextView
)
}
}
}
extension DynamicTextEditor.Representable {
final class Coordinator: NSObject, UITextViewDelegate {
let textView: UIKitTextView
private var originalText: NSMutableAttributedString = .init()
private var text: Binding<NSMutableAttributedString>
private var sizeCategory: ContentSizeCategory
private var calculatedHeight: Binding<CGFloat>
var didBecomeFirstResponder = false
var getTextView: ((UITextView) -> Void)?
init(text: Binding<NSMutableAttributedString>,
calculatedHeight: Binding<CGFloat>,
sizeCategory: ContentSizeCategory,
getTextView: ((UITextView) -> Void)?)
{
textView = UIKitTextView()
textView.backgroundColor = .clear
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.isScrollEnabled = false
textView.textContainer.lineFragmentPadding = 0
textView.textContainerInset = .zero
self.text = text
self.calculatedHeight = calculatedHeight
self.sizeCategory = sizeCategory
self.getTextView = getTextView
super.init()
textView.delegate = self
textView.font = UIFont.preferredFont(forTextStyle: .callout)
textView.adjustsFontForContentSizeCategory = true
textView.autocapitalizationType = .sentences
textView.autocorrectionType = .yes
textView.isEditable = true
textView.isSelectable = true
textView.dataDetectorTypes = []
textView.allowsEditingTextAttributes = false
textView.returnKeyType = .default
textView.allowsEditingTextAttributes = true
self.getTextView?(textView)
}
func textViewDidBeginEditing(_: UITextView) {
originalText = text.wrappedValue
DispatchQueue.main.async {
self.recalculateHeight()
}
}
func textViewDidChange(_ textView: UITextView) {
DispatchQueue.main.async {
self.text.wrappedValue = NSMutableAttributedString(attributedString: textView.attributedText)
self.recalculateHeight()
}
}
func textView(_: UITextView, shouldChangeTextIn _: NSRange, replacementText _: String) -> Bool {
true
}
}
}
extension DynamicTextEditor.Representable.Coordinator {
func update(representable: DynamicTextEditor.Representable) {
textView.keyboardType = representable.keyboard
recalculateHeight()
textView.setNeedsDisplay()
}
private func recalculateHeight() {
let newSize = textView.sizeThatFits(CGSize(width: textView.frame.width, height: .greatestFiniteMagnitude))
guard calculatedHeight.wrappedValue != newSize.height else { return }
DispatchQueue.main.async { // call in next render cycle.
self.calculatedHeight.wrappedValue = newSize.height
}
}
}

View File

@ -0,0 +1,51 @@
//Made by Lumaa
import SwiftUI
struct MetaPicker<Content : View>: View {
@Namespace private var metaPicker
@Namespace private var selectBar
var items: [String]
@Binding var selectedItem: String
@ViewBuilder let content: (_ item: String) -> Content
var body: some View {
HStack {
ForEach(items, id: \.self) { item in
GeometryReader { geo in
let size = geo.size
VStack {
content(item)
.tag(item)
.foregroundStyle(item == selectedItem ? Color.white : Color.gray.opacity(0.3))
if item == selectedItem {
Rectangle()
.fill(Color.white)
.frame(width: size.width, height: 2)
.matchedGeometryEffect(id: selectBar, in: metaPicker)
} else {
Rectangle()
.fill(Color.gray.opacity(0.3))
.frame(width: size.width, height: 2)
}
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring) {
selectedItem = item
}
}
if items.last != item {
Spacer()
}
}
}
}
.padding(.vertical)
}
}

View File

@ -1,23 +1,72 @@
//Made by Lumaa
import SwiftUI
import Nuke
import NukeUI
struct OnlineImage: View {
var url: URL
var url: URL?
var size: CGFloat = 500
var priority: ImageRequest.Priority = .normal
var useNuke: Bool = true
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)
if useNuke {
LazyImage(url: url) { state in
if let image = state.image {
image
.resizable()
.scaledToFit()
.aspectRatio(1.0, contentMode: .fit)
} else if state.error != nil {
ContentUnavailableView("error.loading-image", systemImage: "rectangle.slash")
} else {
Rectangle()
.fill(Color.gray)
.overlay {
ProgressView()
.progressViewStyle(.circular)
}
}
}
.priority(priority)
.processors([.resize(width: size)])
} else {
AsyncImage(url: url) { element in
element
.resizable()
.scaledToFit()
.aspectRatio(1.0, contentMode: .fit)
} placeholder: {
Rectangle()
.fill(Color.gray)
.overlay {
ProgressView()
.progressViewStyle(.circular)
}
}
}
}
/// Creates a new OnlineImage using Nuke or not, default priority is .normal
init(url: URL? = nil, size: CGFloat, useNuke: Bool) {
self.url = url
self.size = size
self.priority = .normal
self.useNuke = useNuke
}
/// Creates a new OnlineImage using Nuke, using the selected priority
init(url: URL? = nil, size: CGFloat, priority: ImageRequest.Priority) {
self.url = url
self.size = size
self.priority = priority
self.useNuke = true
}
/// Change the priority of the Nuke OnlineImage
mutating func setPriority(_ priority: ImageRequest.Priority) {
guard self.useNuke == true else { return }
self.priority = priority
}
}

View File

@ -34,7 +34,7 @@ struct TabsView: View {
Spacer()
Button {
navigator.presentedSheet = .post
navigator.presentedSheet = .post()
} label: {
Tabs.post.image
}
@ -66,7 +66,6 @@ struct TabsView: View {
}
.buttonStyle(NoTapAnimationStyle())
}
.withSheets(sheetDestination: $navigator.presentedSheet)
.padding(.horizontal, 30)
.background(Color.appBackground)
}
@ -127,7 +126,7 @@ enum Tabs {
extension Image {
func tabBarify(_ neutral: Bool = true) -> some View {
self
.font(.title2)
.font(.title)
.opacity(neutral ? 0.3 : 1)
}
}

View File

@ -0,0 +1,44 @@
//Made by Lumaa
import EmojiText
import Foundation
import SwiftUI
public struct TextEmoji: View {
private let markdown: HTMLString
private let emojis: [any CustomEmoji]
private let language: String?
private let append: (() -> Text)?
// private let lineLimit: Int?
public init(_ markdown: HTMLString, emojis: [Emoji], language: String? = nil, append: (() -> Text)? = nil) {
self.markdown = markdown
self.emojis = emojis.map { RemoteEmoji(shortcode: $0.shortcode, url: $0.url) }
self.language = language
// self.lineLimit = lineLimit
self.append = append
}
public var body: some View {
if let append {
EmojiText(markdown: markdown.asMarkdown, emojis: emojis)
.append {
append()
}
// .lineLimit(lineLimit)
} else if emojis.isEmpty {
Text(markdown.asSafeMarkdownAttributedString)
// .lineLimit(lineLimit)
.environment(\.layoutDirection, isRTL() ? .rightToLeft : .leftToRight)
} else {
EmojiText(markdown: markdown.asMarkdown, emojis: emojis)
// .lineLimit(lineLimit)
.environment(\.layoutDirection, isRTL() ? .rightToLeft : .leftToRight)
}
}
private func isRTL() -> Bool {
// Arabic, Hebrew, Persian, Urdu, Kurdish, Azeri, Dhivehi
["ar", "he", "fa", "ur", "ku", "az", "dv"].contains(language)
}
}

View File

@ -73,3 +73,56 @@ class DateFormatterCache: @unchecked Sendable {
self.createdAtDateFormatter = createdAtDateFormatter
}
}
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

@ -2,11 +2,61 @@
import Foundation
@Observable
public class AccountManager {
private var client: Client?
private var account: Account?
init(client: Client? = nil, account: Account? = nil) {
self.client = client
self.account = account
}
public func clear() {
self.client = nil
self.account = nil
}
public func setClient(_ client: Client) {
self.client = client
}
public func getClient() -> Client? {
return client
}
public func getAccount() -> Account? {
return account
}
public func forceClient() -> Client {
if client != nil {
return client!
} else {
fatalError("Client is not existant in that context")
}
}
public func forceAccount() -> Account {
if account != nil {
return account!
} else {
fatalError("Account is not existant in that context and couldn't be fetched from Client")
}
}
public func fetchAccount() async -> Account? {
guard client != nil else { fatalError("Client is not existant in that context") }
account = try? await client!.get(endpoint: Accounts.verifyCredentials)
return account
}
}
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"
private static let saveKey: String = "threaded-appaccount.current"
public var key: String {
if let oauthToken {
@ -43,6 +93,10 @@ public struct AppAccount: Codable, Identifiable, Hashable {
}
return nil
}
static func clear() {
UserDefaults.standard.removeObject(forKey: AppAccount.saveKey)
}
}
extension AppAccount: Sendable {}

View File

@ -30,6 +30,10 @@ struct FetchTimeline {
return []
}
mutating func setTimelineFilter(_ filter: TimelineFilter) {
self.timeline = filter
}
func getStatuses() -> [Status] {
return datasource
}

View File

@ -4,13 +4,14 @@ import Foundation
import SwiftUI
@Observable
public class Navigator {
public class Navigator: ObservableObject {
public var path: [RouterDestination] = []
public var presentedSheet: SheetDestination?
public var selectedTab: TabDestination = .timeline
public func navigate(to: RouterDestination) {
path.append(to)
print("appended view")
}
}
@ -37,7 +38,7 @@ public enum TabDestination: Identifiable {
public enum SheetDestination: Identifiable {
case welcome
case mastodonLogin(logged: Binding<Bool>)
case post
case post(content: String = "")
public var id: String {
switch self {
@ -72,15 +73,15 @@ public enum RouterDestination: Hashable {
}
extension View {
func withAppRouter() -> some View {
func withAppRouter(_ navigator: Navigator) -> some View {
navigationDestination(for: RouterDestination.self) { destination in
switch destination {
case .settings:
SettingsView()
SettingsView(navigator: navigator)
case .privacy:
PrivacyView()
case .account(let acc):
AccountView(account: acc)
AccountView(account: acc, navigator: navigator)
case .about:
AboutView()
}
@ -110,8 +111,11 @@ extension View {
}
} else {
switch destination {
case .post:
Text("Posting view")
case .post(let content):
NavigationStack {
PostingView(initialString: content)
.tint(Color(uiColor: UIColor.label))
}
case let .mastodonLogin(logged):
AddInstanceView(logged: logged)
.tint(Color.accentColor)

View File

@ -45,7 +45,7 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Threaded uses third-party libraries and code:\n- [IceCubesApp](https://github.com/dimillian/IceCubesApp)\n- [SwiftSoup](https://github.com/scinfu/SwiftSoup)"
"value" : "Threaded uses third-party open-source libraries and code:\n- [IceCubesApp](https://github.com/dimillian/IceCubesApp)\n- [SwiftSoup](https://github.com/scinfu/SwiftSoup)\n- [Nuke](https://github.com/kean/Nuke)"
}
}
}
@ -62,6 +62,78 @@
"accessibility.media.supported-type.video.label" : {
"comment" : "A localized description of SupportedType.video"
},
"account.follow" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Follow"
}
}
}
},
"account.follow-back" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Follow back"
}
}
}
},
"account.followers-%lld" : {
"localizations" : {
"en" : {
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld follower"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld followers"
}
}
}
}
}
}
},
"account.mention" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mention"
}
}
}
},
"account.unfollow" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unfollow"
}
}
}
},
"error.loading-image" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Image Error"
}
}
}
},
"Hello world" : {
},
@ -174,9 +246,6 @@
}
}
}
},
"Posting view" : {
},
"setting.privacy" : {
"localizations" : {
@ -230,6 +299,96 @@
}
}
},
"status.posting" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "New post"
}
}
}
},
"status.posting.cancel" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cancel"
}
}
}
},
"status.posting.placeholder" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "What's new?"
}
}
}
},
"status.posting.post" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Post"
}
}
}
},
"status.posting.visibility" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Visibility"
}
}
}
},
"status.posting.visibility.direct" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Direct Message"
}
}
}
},
"status.posting.visibility.private" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Private"
}
}
}
},
"status.posting.visibility.public" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Public"
}
}
}
},
"status.posting.visibility.unlisted" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Unlisted"
}
}
}
},
"status.replies-%lld" : {
"localizations" : {
"en" : {

View File

@ -3,38 +3,91 @@
import SwiftUI
struct AccountView: View {
@Environment(Client.self) private var client: Client
@Environment(AccountManager.self) private var accountManager: AccountManager
@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 isCurrent: Bool = false
@State var account: Account
@State var statuses: [Status]?
@State var statusesPinned: [Status]?
@State var navigator: Navigator = Navigator()
@State private var canFollow: Bool? = nil
@State private var initialFollowing: Bool = false
@State private var isFollowing: Bool = false
@State private var accountFollows: Bool = false
@State private var statuses: [Status]?
@State private var statusesPinned: [Status]?
private let animPicCurve = Animation.smooth(duration: 0.25, extraBounce: 0.0)
var body: some View {
if isCurrent {
NavigationStack(path: $navigator.path) {
accountView
.onAppear {
account = accountManager.forceAccount()
}
}
} else {
accountView
}
}
var accountView: some View {
ZStack (alignment: .center) {
if account != Account.placeholder() {
if biggerPicture {
big
.navigationBarBackButtonHidden()
.toolbar(.hidden, for: .navigationBar)
} else {
wholeSmall
.offset(y: isCurrent ? 50 : 0)
.overlay(alignment: .top) {
if isCurrent {
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)
}
}
.tint(Color(uiColor: UIColor.label))
.safeAreaPadding()
.background(Color.appBackground)
}
}
}
} else {
loading
}
}
.task {
await updateRelationship()
initialFollowing = isFollowing
}
.refreshable {
if let ref: Account = try? await client.get(endpoint: Accounts.accounts(id: account.id)) {
account = ref
statuses = try? await client.get(endpoint: Accounts.statuses(id: account.id, sinceId: nil, tag: nil, onlyMedia: nil, excludeReplies: nil, pinned: nil))
statusesPinned = try? await client.get(endpoint: Accounts.statuses(id: account.id, sinceId: nil, tag: nil, onlyMedia: nil, excludeReplies: nil, pinned: true))
if let client = accountManager.getClient() {
if let ref: Account = try? await client.get(endpoint: Accounts.accounts(id: account.id)) {
account = ref
await updateRelationship()
statuses = try? await client.get(endpoint: Accounts.statuses(id: account.id, sinceId: nil, tag: nil, onlyMedia: nil, excludeReplies: nil, pinned: nil))
statusesPinned = try? await client.get(endpoint: Accounts.statuses(id: account.id, sinceId: nil, tag: nil, onlyMedia: nil, excludeReplies: nil, pinned: true))
}
}
}
.background(Color.appBackground)
@ -48,27 +101,72 @@ struct AccountView: View {
var wholeSmall: some View {
ScrollView {
VStack {
unbig
HStack {
Text(account.note.asRawText)
.font(.body)
.multilineTextAlignment(.leading)
VStack (alignment: .leading) {
unbig
Spacer()
Text(account.note.asRawText)
.font(.callout)
.multilineTextAlignment(.leading)
.padding(.vertical, 5)
let followCount = (account.followersCount ?? 0 - (initialFollowing ? 1 : 0)) + (isFollowing ? 1 : 0)
Text("account.followers-\(followCount)")
.foregroundStyle(Color.gray)
.multilineTextAlignment(.leading)
.font(.callout)
if canFollow != nil && (canFollow ?? true) == true {
HStack (spacing: 5) {
Button {
Task {
await followAccount()
}
} label: {
HStack {
Spacer()
Text(isFollowing ? "account.unfollow" : accountFollows ? "account.follow-back" : "account.follow")
.font(.callout)
Spacer()
}
}
.buttonStyle(LargeButton(filled: true, height: 10))
Button {
if let server = account.acct.split(separator: "@").last {
navigator.presentedSheet = .post(content: "@\(account.username)@\(server)")
} else {
let client = accountManager.getClient()
navigator.presentedSheet = .post(content: "@\(account.username)@\(client?.server ?? "???")")
}
} label: {
HStack {
Spacer()
Text("account.mention")
.font(.callout)
Spacer()
}
}
.buttonStyle(LargeButton(filled: false, height: 10))
}
}
}
.padding(.horizontal)
Rectangle()
.fill(Color.gray.opacity(0.2))
.frame(width: .infinity, height: 1)
.padding(.bottom, 3)
statusesList
VStack {
Rectangle()
.fill(Color.gray.opacity(0.2))
.frame(width: .infinity, height: 1)
.padding(.bottom, 3)
statusesList
}
}
.safeAreaPadding(.vertical)
.padding(.horizontal)
}
.withAppRouter()
.withAppRouter(navigator)
}
var statusesList: some View {
@ -91,14 +189,42 @@ struct AccountView: View {
}
.onAppear {
if statuses == nil {
Task {
statuses = try await client.get(endpoint: Accounts.statuses(id: account.id, sinceId: nil, tag: nil, onlyMedia: nil, excludeReplies: nil, pinned: nil))
statusesPinned = try await client.get(endpoint: Accounts.statuses(id: account.id, sinceId: nil, tag: nil, onlyMedia: nil, excludeReplies: nil, pinned: true))
if let client = accountManager.getClient() {
Task {
statuses = try await client.get(endpoint: Accounts.statuses(id: account.id, sinceId: nil, tag: nil, onlyMedia: nil, excludeReplies: nil, pinned: nil))
statusesPinned = try await client.get(endpoint: Accounts.statuses(id: account.id, sinceId: nil, tag: nil, onlyMedia: nil, excludeReplies: nil, pinned: true))
}
}
}
}
}
func followAccount() async {
if let client = accountManager.getClient() {
Task {
let endpoint: Endpoint = isFollowing ? Accounts.unfollow(id: account.id) : Accounts.follow(id: account.id, notify: false, reblogs: true)
HapticManager.playHaptics(haptics: Haptic.tap)
try await client.post(endpoint: endpoint) // Notify off until APNs? | Reblogs on by default (later changeable)
isFollowing = !isFollowing
}
}
}
func updateRelationship() async {
if let client = accountManager.getClient() {
if let currentAccount: Account = try? await client.get(endpoint: Accounts.verifyCredentials) {
canFollow = currentAccount.id != account.id
guard canFollow == true else { return }
if let relationship: [Relationship] = try? await client.get(endpoint: Accounts.relationships(ids: [account.id])) {
isFollowing = relationship.first!.following
accountFollows = relationship.first!.followedBy
}
} else {
canFollow = false
}
}
}
var loading: some View {
ScrollView {
VStack {
@ -117,7 +243,6 @@ struct AccountView: View {
.safeAreaPadding(.vertical)
.padding(.horizontal)
}
.withAppRouter()
}
var unbig: some View {
@ -130,6 +255,7 @@ struct AccountView: View {
.lineLimit(1)
let server = account.acct.split(separator: "@").last
let client = accountManager.getClient()
HStack(alignment: .center) {
if server != nil {
@ -148,7 +274,7 @@ struct AccountView: View {
.font(.body)
.multilineTextAlignment(.leading)
Text("\(client.server)")
Text("\(client?.server ?? "???")")
.font(.caption)
.foregroundStyle(Color.gray)
.multilineTextAlignment(.leading)
@ -159,7 +285,7 @@ struct AccountView: View {
.font(.body)
.multilineTextAlignment(.leading)
Text("\(client.server)")
Text("\(client?.server ?? "???")")
.font(.caption)
.foregroundStyle(Color.gray)
.multilineTextAlignment(.leading)
@ -197,7 +323,7 @@ struct AccountView: View {
}
var profilePicture: some View {
OnlineImage(url: account.avatar)
OnlineImage(url: account.avatar, size: biggerPicture ? 300 : 50, useNuke: true)
.clipShape(.circle)
.matchedGeometryEffect(id: animPicture, in: accountAnims)
.onTapGesture {

View File

@ -10,6 +10,12 @@ struct ConnectView: View {
var body: some View {
VStack {
Image("HeroIcon")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30)
.padding(.bottom)
Text("login.title")
.font(.title.bold())
.multilineTextAlignment(.center)
@ -30,6 +36,7 @@ struct ConnectView: View {
noAccount
}
.buttonStyle(LargeButton())
.disabled(true)
}
.padding(.vertical, 100)
}

View File

@ -5,14 +5,13 @@ 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?
@State private var accountManager: AccountManager = AccountManager()
var body: some View {
TabView(selection: $navigator.selectedTab, content: {
ZStack {
if client != nil {
TimelineView(timelineModel: FetchTimeline(client: self.client!))
if accountManager.getClient() != nil {
TimelineView(navigator: navigator, timelineModel: FetchTimeline(client: accountManager.forceClient()))
.background(Color.appBackground)
.safeAreaPadding()
} else {
@ -33,9 +32,10 @@ struct ContentView: View {
.background(Color.appBackground)
.tag(TabDestination.activity)
ProfileView(account: currentAccount ?? .placeholder())
AccountView(isCurrent: true, account: accountManager.getAccount() ?? .placeholder())
.background(Color.appBackground)
.tag(TabDestination.profile)
})
.overlay(alignment: .bottom) {
TabsView(navigator: navigator)
@ -43,17 +43,22 @@ struct ContentView: View {
.zIndex(10)
}
.withCovers(sheetDestination: $sheet)
.withSheets(sheetDestination: $navigator.presentedSheet)
.environment(accountManager)
.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)
}
.task {
await recognizeAccount()
}
}
func recognizeAccount() async {
let acc = try? AppAccount.loadAsCurrent()
if acc == nil {
sheet = .welcome
} else {
Task {
accountManager.setClient(.init(server: acc!.server, oauthToken: acc!.oauthToken))
await accountManager.fetchAccount()
}
}
}
@ -68,6 +73,7 @@ struct ContentView: View {
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor(Color.accentColor)]
UITabBar.appearance().standardAppearance = appearance
UINavigationBar.appearance().tintColor = UIColor.label
}
}

View File

@ -0,0 +1,185 @@
//Made by Lumaa
import SwiftUI
import UIKit
import PhotosUI
struct PostingView: View {
@Environment(\.dismiss) private var dismiss
@Environment(AccountManager.self) private var accountManager: AccountManager
var initialString: String = ""
@State private var postText: NSMutableAttributedString = .init(string: "")
@State private var visibility: Visibility = .pub
@State private var selectedPhotos: PhotosPickerItem?
@State private var postingStatus: Bool = false
var body: some View {
if accountManager.getAccount() != nil {
posting
} else {
loading
}
}
var posting: some View {
VStack {
HStack(alignment: .top, spacing: 0) {
// MARK: Profile picture
profilePicture
VStack(alignment: .leading) {
// MARK: Status main content
VStack(alignment: .leading, spacing: 10) {
Text(accountManager.forceAccount().username)
.multilineTextAlignment(.leading)
.bold()
DynamicTextEditor($postText)
.placeholder(String(localized: "status.posting.placeholder"))
.multilineTextAlignment(.leading)
.font(.callout)
.foregroundStyle(Color(uiColor: UIColor.label))
editorButtons
.padding(.vertical)
}
Spacer()
}
}
HStack {
Picker("status.posting.visibility", selection: $visibility) {
ForEach(Visibility.allCases, id: \.self) { item in
HStack(alignment: .firstTextBaseline) {
switch (item) {
case .pub:
Text("status.posting.visibility.public")
.foregroundStyle(Color.gray)
case .unlisted:
Text("status.posting.visibility.unlisted")
.foregroundStyle(Color.gray)
case .direct:
Text("status.posting.visibility.direct")
.foregroundStyle(Color.gray)
case .priv:
Text("status.posting.visibility.private")
.foregroundStyle(Color.gray)
}
Spacer()
}
}
}
.labelsHidden()
.pickerStyle(.menu)
.foregroundStyle(Color.gray)
.frame(width: 200, alignment: .leading)
.multilineTextAlignment(.leading)
Spacer()
Button {
Task {
if let client = accountManager.getClient() {
postingStatus = true
try await client.post(endpoint: Statuses.postStatus(json: .init(status: postText.string, visibility: visibility)))
postingStatus = false
dismiss()
}
}
} label: {
if postingStatus {
ProgressView()
.progressViewStyle(.circular)
.foregroundStyle(Color.appBackground)
} else {
Text("status.posting.post")
}
}
.disabled(postingStatus)
.buttonStyle(LargeButton(filled: true, height: 7.5))
}
.padding()
}
.navigationBarBackButtonHidden()
.navigationTitle(Text("status.posting"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Text("status.posting.cancel")
}
}
}
.onAppear {
DispatchQueue.main.async {
postText.append(NSAttributedString(string: initialString))
}
}
}
var loading: some View {
ProgressView()
.foregroundStyle(.white)
.progressViewStyle(.circular)
}
var editorButtons: some View {
HStack(spacing: 18) {
PhotosPicker(selection: $selectedPhotos, matching: .any(of: [.images, .videos]), label: {
Image(systemName: "photo.badge.plus")
.font(.callout)
.foregroundStyle(.gray)
})
.tint(Color.blue)
actionButton("number") {
DispatchQueue.main.async {
postText.append(NSAttributedString(string: "#"))
}
}
}
}
@ViewBuilder
func actionButton(_ image: String, action: @escaping () -> Void) -> some View {
Button {
action()
} label: {
Image(systemName: image)
.font(.callout)
}
.tint(Color.gray)
}
@ViewBuilder
func asyncActionButton(_ image: String, action: @escaping () async -> Void) -> some View {
Button {
Task {
await action()
}
} label: {
Image(systemName: image)
.font(.callout)
}
.tint(Color.gray)
}
var profilePicture: some View {
OnlineImage(url: accountManager.forceAccount().avatar, size: 50, useNuke: true)
.frame(width: 40, height: 40)
.padding(.horizontal)
.clipShape(.circle)
}
}
#Preview {
PostingView()
}

View File

@ -15,6 +15,8 @@ struct AboutView: View {
.listRowBackground(Color.appBackground)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(Color.appBackground)
.navigationTitle("about")
.navigationBarTitleDisplayMode(.inline)
}
@ -29,6 +31,8 @@ struct AboutView: View {
}
.padding(.horizontal)
}
.scrollContentBackground(.hidden)
.background(Color.appBackground)
.navigationTitle("about.app")
.navigationBarTitleDisplayMode(.large)
}

View File

@ -3,41 +3,51 @@
import SwiftUI
struct SettingsView: View {
@Environment(Navigator.self) private var navigator: Navigator
@State var navigator: Navigator
@State private var sheet: SheetDestination?
var body: some View {
List {
Button {
navigator.navigate(to: .about)
} label: {
Label("about", systemImage: "info.circle")
NavigationStack(path: $navigator.path) {
List {
Button {
navigator.navigate(to: .about)
} label: {
Label("about", systemImage: "info.circle")
}
.listRowSeparator(.hidden)
.listRowBackground(Color.appBackground)
Button {
navigator.navigate(to: .privacy)
} label: {
Label("setting.privacy", systemImage: "lock")
}
.listRowSeparator(.hidden)
.listRowBackground(Color.appBackground)
Button {
AppAccount.clear()
sheet = .welcome
} label: {
Text("logout")
.foregroundStyle(.red)
}
.tint(Color.red)
.listRowSeparator(.hidden)
.listRowBackground(Color.appBackground)
}
.listRowSeparator(.hidden)
Button {
navigator.navigate(to: .privacy)
} label: {
Label("setting.privacy", systemImage: "lock")
}
.listRowSeparator(.hidden)
Button {
UserDefaults.standard.removeObject(forKey: AppAccount.saveKey)
sheet = .welcome
} label: {
Text("logout")
.foregroundStyle(.red)
}
.tint(Color.red)
.listRowSeparator(.visible, edges: .bottom)
.withAppRouter(navigator)
.withCovers(sheetDestination: $sheet)
.scrollContentBackground(.hidden)
.tint(Color.white)
.background(Color.appBackground)
.listStyle(.inset)
.navigationTitle("settings")
.navigationBarTitleDisplayMode(.inline)
}
.withCovers(sheetDestination: $sheet)
.listStyle(.inset)
.navigationTitle("settings")
}
}
#Preview {
SettingsView()
SettingsView(navigator: .init())
}

View File

@ -3,23 +3,55 @@
import SwiftUI
struct TimelineView: View {
@Environment(Client.self) private var client: Client
@Environment(AccountManager.self) private var accountManager: AccountManager
@State var navigator: Navigator
@State private var showPicker: Bool = false
@State private var stringTimeline: String = "home"
@State private var timeline: TimelineFilter = .home
@State private var timelines: [TimelineFilter] = [.trending, .home]
@State private var navigator: Navigator = Navigator()
@State private var statuses: [Status]?
@State var timelineModel: FetchTimeline
@State var timelineModel: FetchTimeline // home timeline by default
var body: some View {
NavigationStack(path: $navigator.path) {
if statuses != nil {
if !statuses!.isEmpty {
ScrollView(showsIndicators: false) {
Image("HeroIcon")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30)
.padding(.bottom)
Button {
withAnimation(.easeInOut) {
showPicker.toggle()
}
} label: {
Image("HeroIcon")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30)
.padding(.bottom)
}
// if showPicker {
// //TODO: Fix this
//
// MetaPicker(items: timelines.map { $0.rawValue }, selectedItem: $stringTimeline) { item in
// let title: String = timelines.filter{ $0.rawValue == item }.first?.localizedTitle() ?? "Unknown"
// Text("\(title)")
// }
// .padding(.bottom)
// .onChange(of: stringTimeline) { _, newTimeline in
// let loc = timelines.filter{ $0.rawValue == newTimeline }.first?.localizedTitle()
// switch (loc) {
// case "home":
// timeline = .home
// case "trending":
// timeline = .trending
// default:
// timeline = .home
// }
// }
// }
ForEach(statuses!, id: \.id) { status in
VStack(spacing: 2) {
@ -27,9 +59,16 @@ struct TimelineView: View {
}
}
}
.refreshable {
if let client = accountManager.getClient() {
Task {
statuses = try? await client.get(endpoint: Timelines.home(sinceId: nil, maxId: nil, minId: nil))
}
}
}
.padding(.top)
.background(Color.appBackground)
.withAppRouter()
.withAppRouter(navigator)
} else {
ZStack {
Color.appBackground
@ -60,8 +99,10 @@ struct TimelineView: View {
Color.appBackground
.ignoresSafeArea()
.onAppear {
Task {
statuses = try? await client.get(endpoint: Timelines.home(sinceId: nil, maxId: nil, minId: nil))
if let client = accountManager.getClient() {
Task {
statuses = try? await client.get(endpoint: Timelines.home(sinceId: nil, maxId: nil, minId: nil))
}
}
}