diff --git a/Vernissage.xcodeproj/project.pbxproj b/Vernissage.xcodeproj/project.pbxproj index be75bd9..75bd39a 100644 --- a/Vernissage.xcodeproj/project.pbxproj +++ b/Vernissage.xcodeproj/project.pbxproj @@ -75,6 +75,8 @@ F897978D2968369600B22335 /* HapticService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F897978C2968369600B22335 /* HapticService.swift */; }; F897978F29684BCB00B22335 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F897978E29684BCB00B22335 /* LoadingView.swift */; }; F8984E4D296B648000A2610F /* UIImage+Blurhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8984E4C296B648000A2610F /* UIImage+Blurhash.swift */; }; + F898DE702972868A004B4A6A /* String+Empty.swift in Sources */ = {isa = PBXBuildFile; fileRef = F898DE6F2972868A004B4A6A /* String+Empty.swift */; }; + F898DE7229728CB2004B4A6A /* CommentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F898DE7129728CB2004B4A6A /* CommentViewModel.swift */; }; F8996DEB2971D29D0043EEC6 /* View+Transition.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8996DEA2971D29D0043EEC6 /* View+Transition.swift */; }; F89992C7296D3DF8005994BF /* MastodonKit in Frameworks */ = {isa = PBXBuildFile; productRef = F89992C6296D3DF8005994BF /* MastodonKit */; }; F89992C9296D6DC7005994BF /* CommentBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89992C8296D6DC7005994BF /* CommentBody.swift */; }; @@ -165,6 +167,8 @@ F897978C2968369600B22335 /* HapticService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticService.swift; sourceTree = ""; }; F897978E29684BCB00B22335 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; F8984E4C296B648000A2610F /* UIImage+Blurhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Blurhash.swift"; sourceTree = ""; }; + F898DE6F2972868A004B4A6A /* String+Empty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Empty.swift"; sourceTree = ""; }; + F898DE7129728CB2004B4A6A /* CommentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentViewModel.swift; sourceTree = ""; }; F8996DEA2971D29D0043EEC6 /* View+Transition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Transition.swift"; sourceTree = ""; }; F89992C8296D6DC7005994BF /* CommentBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentBody.swift; sourceTree = ""; }; F89992CB296D9231005994BF /* StatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusViewModel.swift; sourceTree = ""; }; @@ -235,6 +239,7 @@ F8341F8F295C636C009C8EE6 /* Data+Exif.swift */, F85D49862964334100751DF7 /* String+Date.swift */, F8C14391296AF0B3001FE31D /* String+Exif.swift */, + F898DE6F2972868A004B4A6A /* String+Empty.swift */, F8210DE42966E160001D9973 /* Color+SystemColors.swift */, F8210DE62966E1D1001D9973 /* Color+Assets.swift */, F8C14393296AF21B001FE31D /* Double+Round.swift */, @@ -412,6 +417,7 @@ F89992CB296D9231005994BF /* StatusViewModel.swift */, F89992CD296D92E7005994BF /* AttachmentViewModel.swift */, F89D6C4B297197FE001DA3D4 /* ImageViewerViewModel.swift */, + F898DE7129728CB2004B4A6A /* CommentViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -570,6 +576,7 @@ F88C246E295C37B80006098B /* MainView.swift in Sources */, F86B721E296C458700EE59EC /* BlurredImage.swift in Sources */, F88C2478295C37BB0006098B /* Vernissage.xcdatamodeld in Sources */, + F898DE7229728CB2004B4A6A /* CommentViewModel.swift in Sources */, F89A46DE296EABA20062125F /* StatusPlaceholder.swift in Sources */, F88C2482295C3A4F0006098B /* StatusView.swift in Sources */, F866F6A329604161002E8F88 /* AccountDataHandler.swift in Sources */, @@ -603,6 +610,7 @@ F8A93D802965FED4001D8331 /* AccountService.swift in Sources */, F866F6AA29605AFA002E8F88 /* SceneDelegate.swift in Sources */, F85D4973296406E700751DF7 /* BottomRight.swift in Sources */, + F898DE702972868A004B4A6A /* String+Empty.swift in Sources */, F86B7218296C27C100EE59EC /* ActionButton.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Vernissage/Extensions/String+Empty.swift b/Vernissage/Extensions/String+Empty.swift new file mode 100644 index 0000000..c4102f8 --- /dev/null +++ b/Vernissage/Extensions/String+Empty.swift @@ -0,0 +1,14 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + + +import Foundation + +extension String { + static func empty() -> String { + return "" + } +} diff --git a/Vernissage/Formatters/HTMLFotmattedText.swift b/Vernissage/Formatters/HTMLFotmattedText.swift index 4bae636..c1fd0bc 100644 --- a/Vernissage/Formatters/HTMLFotmattedText.swift +++ b/Vernissage/Formatters/HTMLFotmattedText.swift @@ -8,8 +8,9 @@ import UIKit import SwiftUI struct HTMLFormattedText: UIViewRepresentable { + @EnvironmentObject var applicationState: ApplicationState - let text: String + private let text: String private let textView = UITextView() private let fontSize: Int private let width: Int @@ -32,11 +33,11 @@ struct HTMLFormattedText: UIViewRepresentable { } func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext) { - DispatchQueue.main.async { + Task { @MainActor in if let attributeText = self.converHTML(text: text) { textView.attributedText = attributeText } else { - textView.text = "" + textView.text = String.empty() } } } @@ -53,13 +54,14 @@ struct HTMLFormattedText: UIViewRepresentable { let linkAttributes = [ NSAttributedString.Key.font: UIFont.systemFont(ofSize: CGFloat(self.fontSize)), - NSAttributedString.Key.foregroundColor: UIColor(.accentColor) + NSAttributedString.Key.foregroundColor: UIColor(applicationState.tintColor.color()) ] - if let attributedString = try? NSMutableAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) { - + if let attributedString = try? NSMutableAttributedString(data: data, + options: [.documentType: NSAttributedString.DocumentType.html], + documentAttributes: nil) { attributedString.enumerateAttributes(in: NSRange(0.. Context { - let client = MastodonClient(baseURL: accountData.serverUrl).getAuthenticated(token: accountData.accessToken ?? "") - return try await client.getContext(for: statusId) + public func getComments(for statusId: String, and accountData: AccountData) async throws -> [CommentViewModel] { + var commentViewModels: [CommentViewModel] = [] + + // Get first level of comments. + let client = MastodonClient(baseURL: accountData.serverUrl).getAuthenticated(token: accountData.accessToken ?? String.empty()) + let context = try await client.getContext(for: statusId) + + // Iterate throught first level of comments and download descendants/ + let descendants = context.descendants.toStatusViewModel() + for status in descendants { + commentViewModels.append(CommentViewModel(status: status, showDivider: true)) + try await self.getCommentDescendants(for: status.id, client: client, to: &commentViewModels) + } + + return commentViewModels + } + + private func getCommentDescendants(for statusId: String, client: MastodonClientAuthenticated, to commentViewModels: inout [CommentViewModel]) async throws { + let context = try await client.getContext(for: statusId) + + let descendants = context.descendants.toStatusViewModel() + for status in descendants { + commentViewModels.append(CommentViewModel(status: status, showDivider: false)) + try await self.getCommentDescendants(for: status.id, client: client, to: &commentViewModels) + } } private func loadData(for accountData: AccountData, on backgroundContext: NSManagedObjectContext, minId: String? = nil, maxId: String? = nil) async throws -> Int { diff --git a/Vernissage/ViewModels/CommentViewModel.swift b/Vernissage/ViewModels/CommentViewModel.swift new file mode 100644 index 0000000..bac3b06 --- /dev/null +++ b/Vernissage/ViewModels/CommentViewModel.swift @@ -0,0 +1,12 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import Foundation + +public struct CommentViewModel { + var status: StatusViewModel + var showDivider: Bool +} diff --git a/Vernissage/ViewModels/StatusViewModel.swift b/Vernissage/ViewModels/StatusViewModel.swift index a2f1766..caf6639 100644 --- a/Vernissage/ViewModels/StatusViewModel.swift +++ b/Vernissage/ViewModels/StatusViewModel.swift @@ -152,8 +152,12 @@ public extension StatusViewModel { public extension [Status] { func toStatusViewModel() -> [StatusViewModel] { - self.map { status in - StatusViewModel(status: status) - } + self + .sorted(by: { lhs, rhs in + lhs.id < rhs.id + }) + .map { status in + StatusViewModel(status: status) + } } } diff --git a/Vernissage/Views/ComposeView.swift b/Vernissage/Views/ComposeView.swift index bad1952..afc76f9 100644 --- a/Vernissage/Views/ComposeView.swift +++ b/Vernissage/Views/ComposeView.swift @@ -16,7 +16,7 @@ struct ComposeView: View { @Environment(\.dismiss) private var dismiss @Binding var statusViewModel: StatusViewModel? - @State private var text = "" + @State private var text = String.empty() @FocusState private var focusedField: FocusField? @@ -120,7 +120,7 @@ struct ComposeView: View { } private func getUserName(statusViewModel: StatusViewModel) -> String { - return self.statusViewModel?.account.displayName ?? self.statusViewModel?.account.acct ?? self.statusViewModel?.account.username ?? "" + return self.statusViewModel?.account.displayName ?? self.statusViewModel?.account.acct ?? self.statusViewModel?.account.username ?? String.empty() } } diff --git a/Vernissage/Views/MainView.swift b/Vernissage/Views/MainView.swift index 66d2cf2..333ffdb 100644 --- a/Vernissage/Views/MainView.swift +++ b/Vernissage/Views/MainView.swift @@ -64,24 +64,24 @@ struct MainView: View { private func getMainView() -> some View { switch self.viewMode { case .home: - HomeFeedView(accountId: applicationState.accountData?.id ?? "") - .id(applicationState.accountData?.id ?? "") + HomeFeedView(accountId: applicationState.accountData?.id ?? String.empty()) + .id(applicationState.accountData?.id ?? String.empty()) case .local: LocalFeedView() - .id(applicationState.accountData?.id ?? "") + .id(applicationState.accountData?.id ?? String.empty()) case .federated: FederatedFeedView() - .id(applicationState.accountData?.id ?? "") + .id(applicationState.accountData?.id ?? String.empty()) case .profile: if let accountData = self.applicationState.accountData { UserProfileView(accountId: accountData.id, accountDisplayName: accountData.displayName, accountUserName: accountData.acct) - .id(applicationState.accountData?.id ?? "") + .id(applicationState.accountData?.id ?? String.empty()) } case .notifications: NotificationsView() - .id(applicationState.accountData?.id ?? "") + .id(applicationState.accountData?.id ?? String.empty()) } } diff --git a/Vernissage/Views/SettingsView.swift b/Vernissage/Views/SettingsView.swift index 68263cb..bf9e285 100644 --- a/Vernissage/Views/SettingsView.swift +++ b/Vernissage/Views/SettingsView.swift @@ -45,7 +45,7 @@ struct SettingsView: View { HStack { Text("Version") Spacer() - Text(appVersion ?? "") + Text(appVersion ?? String.empty()) .foregroundColor(.accentColor) } } diff --git a/Vernissage/Views/SignInView.swift b/Vernissage/Views/SignInView.swift index ae12c2e..f69849b 100644 --- a/Vernissage/Views/SignInView.swift +++ b/Vernissage/Views/SignInView.swift @@ -11,7 +11,7 @@ struct SignInView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject var applicationState: ApplicationState - @State private var serverAddress: String = "" + @State private var serverAddress: String = String.empty() var onSignInStateChenge: ((_ applicationViewMode: ApplicationViewMode) -> Void)? diff --git a/Vernissage/Views/StatusView.swift b/Vernissage/Views/StatusView.swift index ee2e6c5..da02abf 100644 --- a/Vernissage/Views/StatusView.swift +++ b/Vernissage/Views/StatusView.swift @@ -105,14 +105,8 @@ struct StatusView: View { .fullScreenCover(isPresented: $showImageViewer, content: { if let statusViewModel = self.statusViewModel { ImagesViewer(statusViewModel: statusViewModel) - // ImagesViewer(statusViewModel: statusViewModel, imgViewModel: imgViewModel) } }) -// .overlay(content: { -// if self.showImageViewer, let statusViewModel = self.statusViewModel { -// ImagesViewer(showImageViewer: $showImageViewer, statusViewModel: statusViewModel) -// } -// }) .task { do { guard firstLoadFinished == false else { @@ -158,6 +152,7 @@ struct StatusView: View { return self.calculateHeight(width: Double(imageWidth), height: Double(imageHeight)) } + // If we don't have image height and width in metadata, we have to use some constant height. return UIScreen.main.bounds.width * 0.75 } diff --git a/Vernissage/Widgets/ImagesCarousel.swift b/Vernissage/Widgets/ImagesCarousel.swift index fc1de75..d2266e7 100644 --- a/Vernissage/Widgets/ImagesCarousel.swift +++ b/Vernissage/Widgets/ImagesCarousel.swift @@ -9,7 +9,7 @@ import SwiftUI struct ImagesCarousel: View { @State public var attachments: [AttachmentViewModel] @State private var height: Double = 0.0 - @State private var selectedAttachmentId = "" + @State private var selectedAttachmentId = String.empty() @Binding public var exifCamera: String? @Binding public var exifExposure: String? @@ -38,7 +38,7 @@ struct ImagesCarousel: View { } }) .onAppear { - self.selectedAttachmentId = self.attachments.first?.id ?? "" + self.selectedAttachmentId = self.attachments.first?.id ?? String.empty() self.calculateImageHeight() } } diff --git a/Vernissage/Widgets/StatusView/CommentBody.swift b/Vernissage/Widgets/StatusView/CommentBody.swift index dac4f8f..7336f3c 100644 --- a/Vernissage/Widgets/StatusView/CommentBody.swift +++ b/Vernissage/Widgets/StatusView/CommentBody.swift @@ -85,7 +85,7 @@ struct CommentBody: View { .onTapGesture { withAnimation(.linear(duration: 0.3)) { if self.statusViewModel.id == self.applicationState.showInteractionStatusId { - self.applicationState.showInteractionStatusId = "" + self.applicationState.showInteractionStatusId = String.empty() } else { self.applicationState.showInteractionStatusId = self.statusViewModel.id } diff --git a/Vernissage/Widgets/StatusView/CommentsSection.swift b/Vernissage/Widgets/StatusView/CommentsSection.swift index d03da11..5e8666c 100644 --- a/Vernissage/Widgets/StatusView/CommentsSection.swift +++ b/Vernissage/Widgets/StatusView/CommentsSection.swift @@ -12,29 +12,29 @@ struct CommentsSection: View { @EnvironmentObject var applicationState: ApplicationState @State public var statusId: String - @State public var withDivider = true - @State private var context: Context? - var onNewStatus: ((_ context: StatusViewModel) -> Void)? + @State private var commentViewModels: [CommentViewModel]? + var body: some View { VStack(alignment: .leading, spacing: 0) { - if let context = context { - ForEach(context.descendants.toStatusViewModel(), id: \.id) { statusViewModel in + if let commentViewModels { + ForEach(commentViewModels, id: \.status.id) { commentViewModel in VStack(alignment: .leading, spacing: 0) { - if withDivider { + if commentViewModel.showDivider { Divider() - .foregroundColor(.mainTextColor) + .frame(height: 1) + .overlay(Color.placeholderText.opacity(0.3)) .padding(0) } - CommentBody(statusViewModel: statusViewModel) + CommentBody(statusViewModel: commentViewModel.status) - if self.applicationState.showInteractionStatusId == statusViewModel.id { + if self.applicationState.showInteractionStatusId == commentViewModel.status.id { VStack (alignment: .leading, spacing: 0) { - InteractionRow(statusViewModel: statusViewModel) { - self.onNewStatus?(statusViewModel) + InteractionRow(statusViewModel: commentViewModel.status) { + self.onNewStatus?(commentViewModel.status) } .foregroundColor(self.getInteractionRowTextColor()) .padding(.horizontal, 16) @@ -43,20 +43,20 @@ struct CommentsSection: View { .background(Color.lightGrayColor.opacity(0.5)) .transition(AnyTransition.move(edge: .top).combined(with: .opacity)) } - - CommentsSection(statusId: statusViewModel.id, withDivider: false) { context in - self.onNewStatus?(context) - } } } + } else { + HStack { + Spacer() + LoadingIndicator() + Spacer() + } } } .task { do { if let accountData = applicationState.accountData { - self.context = try await TimelineService.shared.getComments( - for: statusId, - and: accountData) + self.commentViewModels = try await TimelineService.shared.getComments(for: statusId, and: accountData) } } catch { print("Error \(error.localizedDescription)") @@ -71,6 +71,6 @@ struct CommentsSection: View { struct CommentsSection_Previews: PreviewProvider { static var previews: some View { - CommentsSection(statusId: "", withDivider: true) + CommentsSection(statusId: "") } }