diff --git a/Vernissage.xcodeproj/project.pbxproj b/Vernissage.xcodeproj/project.pbxproj index 77973e4..7028453 100644 --- a/Vernissage.xcodeproj/project.pbxproj +++ b/Vernissage.xcodeproj/project.pbxproj @@ -40,6 +40,9 @@ F85D498329642FAC00751DF7 /* AttachmentData+Comperable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D498229642FAC00751DF7 /* AttachmentData+Comperable.swift */; }; F85D49852964301800751DF7 /* StatusData+Attachments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D49842964301800751DF7 /* StatusData+Attachments.swift */; }; F85D49872964334100751DF7 /* String+Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85D49862964334100751DF7 /* String+Date.swift */; }; + F85DBF8F296732E20069BF89 /* FollowersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85DBF8E296732E20069BF89 /* FollowersView.swift */; }; + F85DBF912967385F0069BF89 /* FollowingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85DBF902967385F0069BF89 /* FollowingView.swift */; }; + F85DBF93296760790069BF89 /* CacheAvatarService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85DBF92296760790069BF89 /* CacheAvatarService.swift */; }; F866F6A0296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F69E296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift */; }; F866F6A1296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F69F296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift */; }; F866F6A329604161002E8F88 /* AccountDataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F866F6A229604161002E8F88 /* AccountDataHandler.swift */; }; @@ -100,6 +103,9 @@ F85D498229642FAC00751DF7 /* AttachmentData+Comperable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentData+Comperable.swift"; sourceTree = ""; }; F85D49842964301800751DF7 /* StatusData+Attachments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusData+Attachments.swift"; sourceTree = ""; }; F85D49862964334100751DF7 /* String+Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Date.swift"; sourceTree = ""; }; + F85DBF8E296732E20069BF89 /* FollowersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersView.swift; sourceTree = ""; }; + F85DBF902967385F0069BF89 /* FollowingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingView.swift; sourceTree = ""; }; + F85DBF92296760790069BF89 /* CacheAvatarService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheAvatarService.swift; sourceTree = ""; }; F866F69E296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ApplicationSettings+CoreDataClass.swift"; sourceTree = ""; }; F866F69F296040A8002E8F88 /* ApplicationSettings+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ApplicationSettings+CoreDataProperties.swift"; sourceTree = ""; }; F866F6A229604161002E8F88 /* AccountDataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDataHandler.swift; sourceTree = ""; }; @@ -164,6 +170,8 @@ F88FAD26295F400E009B20C9 /* NotificationsView.swift */, F866F6A629604629002E8F88 /* SignInView.swift */, F8A93D7D2965FD89001D8331 /* UserProfileView.swift */, + F85DBF8E296732E20069BF89 /* FollowersView.swift */, + F85DBF902967385F0069BF89 /* FollowingView.swift */, ); path = Views; sourceTree = ""; @@ -293,6 +301,7 @@ F8A93D7F2965FED4001D8331 /* AccountService.swift */, F8210DE02966D0C4001D9973 /* StatusService.swift */, F8210DEB2966F30C001D9973 /* UserFeedbackService.swift */, + F85DBF92296760790069BF89 /* CacheAvatarService.swift */, ); path = Services; sourceTree = ""; @@ -383,6 +392,7 @@ F866F6A0296040A8002E8F88 /* ApplicationSettings+CoreDataClass.swift in Sources */, F8210DEA2966E4F9001D9973 /* AnimatePlaceholderModifier.swift in Sources */, F8210DE52966E160001D9973 /* Color+SystemColors.swift in Sources */, + F85DBF93296760790069BF89 /* CacheAvatarService.swift in Sources */, F88FAD23295F3FC4009B20C9 /* LocalFeedView.swift in Sources */, F88FAD2B295F43B8009B20C9 /* AccountData+CoreDataProperties.swift in Sources */, F85D4975296407F100751DF7 /* TimelineService.swift in Sources */, @@ -391,6 +401,7 @@ F88C2475295C37BB0006098B /* CoreDataHandler.swift in Sources */, F88FAD2A295F43B8009B20C9 /* AccountData+CoreDataClass.swift in Sources */, F8210DE12966D0C4001D9973 /* StatusService.swift in Sources */, + F85DBF8F296732E20069BF89 /* FollowersView.swift in Sources */, F8A93D822965FF5D001D8331 /* MastodonClientAuthenticated+Account.swift in Sources */, F85D49872964334100751DF7 /* String+Date.swift in Sources */, F8341F92295C63BB009C8EE6 /* ImageStatus.swift in Sources */, @@ -402,6 +413,7 @@ F80048052961850500E6868A /* StatusData+CoreDataClass.swift in Sources */, F80048042961850500E6868A /* AttachmentData+CoreDataProperties.swift in Sources */, F83901A6295D8EC000456AE2 /* LabelIcon.swift in Sources */, + F85DBF912967385F0069BF89 /* FollowingView.swift in Sources */, F800480A2961EA1900E6868A /* AttachmentDataHandler.swift in Sources */, F80048032961850500E6868A /* AttachmentData+CoreDataClass.swift in Sources */, F8341F90295C636C009C8EE6 /* UIImage+Exif.swift in Sources */, diff --git a/Vernissage/CoreData/AccountData+CoreDataProperties.swift b/Vernissage/CoreData/AccountData+CoreDataProperties.swift index 732cd82..ff3a93a 100644 --- a/Vernissage/CoreData/AccountData+CoreDataProperties.swift +++ b/Vernissage/CoreData/AccountData+CoreDataProperties.swift @@ -17,20 +17,20 @@ extension AccountData { } @NSManaged public var accessToken: String? - @NSManaged public var acct: String? + @NSManaged public var acct: String @NSManaged public var avatar: URL? @NSManaged public var avatarData: Data? - @NSManaged public var createdAt: String? + @NSManaged public var createdAt: String @NSManaged public var displayName: String? @NSManaged public var followersCount: Int32 @NSManaged public var followingCount: Int32 @NSManaged public var header: URL? - @NSManaged public var id: String? + @NSManaged public var id: String @NSManaged public var locked: Bool @NSManaged public var note: String? @NSManaged public var statusesCount: Int32 @NSManaged public var url: URL? - @NSManaged public var username: String? + @NSManaged public var username: String @NSManaged public var clientId: String @NSManaged public var clientSecret: String @NSManaged public var clientVapidKey: String diff --git a/Vernissage/CoreData/StatusData+Status.swift b/Vernissage/CoreData/StatusData+Status.swift index 71bd768..f0a91d0 100644 --- a/Vernissage/CoreData/StatusData+Status.swift +++ b/Vernissage/CoreData/StatusData+Status.swift @@ -14,7 +14,7 @@ extension StatusData { self.accountAvatar = status.account?.avatar self.accountDisplayName = status.account?.displayName self.accountId = status.account!.id - self.accountUsername = status.account!.username + self.accountUsername = status.account!.acct self.applicationName = status.application?.name self.applicationWebsite = status.application?.website self.bookmarked = status.bookmarked diff --git a/Vernissage/Extensions/MastodonClientAuthenticated+Account.swift b/Vernissage/Extensions/MastodonClientAuthenticated+Account.swift index 3319883..3662d5a 100644 --- a/Vernissage/Extensions/MastodonClientAuthenticated+Account.swift +++ b/Vernissage/Extensions/MastodonClientAuthenticated+Account.swift @@ -52,4 +52,26 @@ extension MastodonClientAuthenticated { let (data, _) = try await urlSession.data(for: request) return try JSONDecoder().decode(Relationship.self, from: data) } + + func getFollowers(for accountId: String, page: Int = 1) async throws -> [Account] { + let request = try Self.request( + for: baseURL, + target: Mastodon.Account.followers(accountId, nil, nil, nil, nil, page), + withBearerToken: token + ) + + let (data, _) = try await urlSession.data(for: request) + return try JSONDecoder().decode([Account].self, from: data) + } + + func getFollowing(for accountId: String, page: Int = 1) async throws -> [Account] { + let request = try Self.request( + for: baseURL, + target: Mastodon.Account.following(accountId, nil, nil, nil, nil, page), + withBearerToken: token + ) + + let (data, _) = try await urlSession.data(for: request) + return try JSONDecoder().decode([Account].self, from: data) + } } diff --git a/Vernissage/Services/AccountService.swift b/Vernissage/Services/AccountService.swift index 9487692..ac56bf7 100644 --- a/Vernissage/Services/AccountService.swift +++ b/Vernissage/Services/AccountService.swift @@ -46,4 +46,22 @@ public class AccountService { let client = MastodonClient(baseURL: serverUrl).getAuthenticated(token: accessToken) return try await client.follow(for: accountId) } + + public func getFollowers(forAccountId accountId: String, andContext accountData: AccountData?, page: Int) async throws -> [Account] { + guard let accessToken = accountData?.accessToken, let serverUrl = accountData?.serverUrl else { + return [] + } + + let client = MastodonClient(baseURL: serverUrl).getAuthenticated(token: accessToken) + return try await client.getFollowers(for: accountId, page: page) + } + + public func getFollowing(forAccountId accountId: String, andContext accountData: AccountData?, page: Int) async throws -> [Account] { + guard let accessToken = accountData?.accessToken, let serverUrl = accountData?.serverUrl else { + return [] + } + + let client = MastodonClient(baseURL: serverUrl).getAuthenticated(token: accessToken) + return try await client.getFollowing(for: accountId, page: page) + } } diff --git a/Vernissage/Services/CacheAvatarService.swift b/Vernissage/Services/CacheAvatarService.swift new file mode 100644 index 0000000..7ac5588 --- /dev/null +++ b/Vernissage/Services/CacheAvatarService.swift @@ -0,0 +1,26 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + + +import Foundation +import SwiftUI + +public class CacheAvatarService { + public static let shared = CacheAvatarService() + private init() { } + + private var cache: Dictionary = [:] + + func addImage(for id: String, data: Data) { + if let uiImage = UIImage(data: data) { + self.cache[id] = uiImage + } + } + + func getImage(for id: String) -> UIImage? { + return self.cache[id] + } +} diff --git a/Vernissage/Views/DetailsView.swift b/Vernissage/Views/DetailsView.swift index 7092b8b..cc4331a 100644 --- a/Vernissage/Views/DetailsView.swift +++ b/Vernissage/Views/DetailsView.swift @@ -26,7 +26,9 @@ struct DetailsView: View { accountDisplayName: statusData.accountDisplayName, accountUserName: statusData.accountUsername) .environmentObject(applicationState)) { - UsernameRow(statusData: statusData) + UsernameRow(accountAvatar: statusData.accountAvatar, + accountDisplayName: statusData.accountDisplayName, + accountUsername: statusData.accountUsername) } HTMLFormattedText(statusData.content) @@ -38,6 +40,7 @@ struct DetailsView: View { LabelIcon(iconName: "timelapse", value: "24.0 mm, f/1.8, 1/640s, ISO 100") LabelIcon(iconName: "calendar", value: "2 Oct 2022") } + .padding(.bottom, 2) .foregroundColor(Color.lightGrayColor) HStack { @@ -55,12 +58,7 @@ struct DetailsView: View { .padding(8) } .padding(8) - - Rectangle() - .size(width: UIScreen.main.bounds.width, height: 4) - .fill(Color.mainTextColor) - .opacity(0.1) - + CommentsSection(statusId: statusData.id) } } else { @@ -69,22 +67,10 @@ struct DetailsView: View { .fill(Color.placeholderText) .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width) - HStack (alignment: .center) { - Circle() - .fill(Color.placeholderText) - .frame(width: 48.0, height: 48.0) - - VStack (alignment: .leading) { - Text("Verylong Displayname") - .foregroundColor(Color.mainTextColor) - Text("@username") - .foregroundColor(Color.lightGrayColor) - .font(.footnote) - } - .padding(.leading, 8) - }.padding(8) - VStack(alignment: .leading) { + UsernameRow(accountDisplayName: "Verylong Displayname", + accountUsername: "@username") + Text("Lorem ispum text something") .foregroundColor(Color.lightGrayColor) .font(.footnote) diff --git a/Vernissage/Views/FollowersView.swift b/Vernissage/Views/FollowersView.swift new file mode 100644 index 0000000..6a2aa5f --- /dev/null +++ b/Vernissage/Views/FollowersView.swift @@ -0,0 +1,94 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import SwiftUI +import MastodonSwift + +struct FollowersView: View { + @EnvironmentObject var applicationState: ApplicationState + + @State var accountId: String + @State private var accounts: [Account] = [] + @State private var page = 1 + @State private var allItemsLoaded = false + + var body: some View { + List(accounts, id: \.id) { account in + NavigationLink(destination: UserProfileView( + accountId: account.id, + accountDisplayName: account.displayName, + accountUserName: account.acct) + .environmentObject(applicationState)) { + UsernameRow(accountAvatar: account.avatar, + accountDisplayName: account.displayName, + accountUsername: account.acct, + cachedAvatar: CacheAvatarService.shared.getImage(for: account.id)) + } + + if allItemsLoaded == false && accounts.last?.id == account.id { + HStack(alignment: .center) { + Spacer() + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .onAppear { + Task { + self.page = self.page + 1 + await self.loadAccounts(page: self.page) + } + } + Spacer() + } + } + } + .navigationBarTitle("Followers") + .listStyle(PlainListStyle()) + .task { + if self.accounts.isEmpty == false { + return + } + + await self.loadAccounts(page: self.page) + } + } + + func loadAccounts(page: Int) async { + do { + let accountsFromApi = try await AccountService.shared.getFollowers( + forAccountId: self.accountId, + andContext: self.applicationState.accountData, + page: page) + + if accountsFromApi.isEmpty { + self.allItemsLoaded = true + return + } + + for account in accountsFromApi { + guard let avatarUrl = account.avatar else { + continue + } + + do { + if let avatarData = try await RemoteFileService.shared.fetchData(url: avatarUrl) { + CacheAvatarService.shared.addImage(for: account.id, data: avatarData) + } + } catch { + print("Error \(error.localizedDescription)") + } + } + + self.accounts.append(contentsOf: accountsFromApi) + } catch { + print("Error \(error.localizedDescription)") + } + } +} + +struct FollowersView_Previews: PreviewProvider { + static var previews: some View { + FollowersView(accountId: "") + } +} diff --git a/Vernissage/Views/FollowingView.swift b/Vernissage/Views/FollowingView.swift new file mode 100644 index 0000000..e0aa06d --- /dev/null +++ b/Vernissage/Views/FollowingView.swift @@ -0,0 +1,94 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the MIT License. +// + +import SwiftUI +import MastodonSwift + +struct FollowingView: View { + @EnvironmentObject var applicationState: ApplicationState + + @State var accountId: String + @State private var accounts: [Account] = [] + @State private var page = 1 + @State private var allItemsLoaded = false + + var body: some View { + List(accounts, id: \.id) { account in + NavigationLink(destination: UserProfileView( + accountId: account.id, + accountDisplayName: account.displayName, + accountUserName: account.acct) + .environmentObject(applicationState)) { + UsernameRow(accountAvatar: account.avatar, + accountDisplayName: account.displayName, + accountUsername: account.acct, + cachedAvatar: CacheAvatarService.shared.getImage(for: account.id)) + } + + if allItemsLoaded == false && accounts.last?.id == account.id { + HStack(alignment: .center) { + Spacer() + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .onAppear { + Task { + self.page = self.page + 1 + await self.loadAccounts(page: self.page) + } + } + Spacer() + } + } + } + .navigationBarTitle("Following") + .listStyle(PlainListStyle()) + .task { + if self.accounts.isEmpty == false { + return + } + + await self.loadAccounts(page: self.page) + } + } + + func loadAccounts(page: Int) async { + do { + let accountsFromApi = try await AccountService.shared.getFollowing( + forAccountId: self.accountId, + andContext: self.applicationState.accountData, + page: page) + + if accountsFromApi.isEmpty { + self.allItemsLoaded = true + return + } + + for account in accountsFromApi { + guard let avatarUrl = account.avatar else { + continue + } + + do { + if let avatarData = try await RemoteFileService.shared.fetchData(url: avatarUrl) { + CacheAvatarService.shared.addImage(for: account.id, data: avatarData) + } + } catch { + print("Error \(error.localizedDescription)") + } + } + + self.accounts.append(contentsOf: accountsFromApi) + } catch { + print("Error \(error.localizedDescription)") + } + } +} + +struct FollowingView_Previews: PreviewProvider { + static var previews: some View { + FollowingView(accountId: "") + } +} diff --git a/Vernissage/Views/MainView.swift b/Vernissage/Views/MainView.swift index e8eeab0..ebe150e 100644 --- a/Vernissage/Views/MainView.swift +++ b/Vernissage/Views/MainView.swift @@ -21,7 +21,7 @@ struct MainView: View { } private enum ViewMode { - case home, local, federated, notifications + case home, local, federated, profile, notifications } var body: some View { @@ -43,6 +43,12 @@ struct MainView: View { LocalFeedView() case .federated: FederatedFeedView() + case .profile: + if let accountData = self.applicationState.accountData { + UserProfileView(accountId: accountData.id, + accountDisplayName: accountData.displayName, + accountUserName: accountData.username) + } case .notifications: NotificationsView() } @@ -80,6 +86,15 @@ struct MainView: View { } Divider() + + Button { + viewMode = .profile + } label: { + HStack { + Text(self.getViewTitle(viewMode: .profile)) + Image(systemName: "person") + } + } Button { viewMode = .notifications @@ -152,6 +167,8 @@ struct MainView: View { return "Local" case .federated: return "Federated" + case .profile: + return "Profile" case .notifications: return "Notifications" } diff --git a/Vernissage/Views/UserProfileView.swift b/Vernissage/Views/UserProfileView.swift index 3a40800..27e5b2a 100644 --- a/Vernissage/Views/UserProfileView.swift +++ b/Vernissage/Views/UserProfileView.swift @@ -48,62 +48,73 @@ struct UserProfileView: View { Spacer() - VStack(alignment: .center) { - Text("\(account.followersCount)") - .font(.title3) - Text("Followers") - .font(.subheadline) - .opacity(0.6) - } + NavigationLink(destination: FollowersView(accountId: account.id) + .environmentObject(applicationState) + ) { + VStack(alignment: .center) { + Text("\(account.followersCount)") + .font(.title3) + Text("Followers") + .font(.subheadline) + .opacity(0.6) + } + }.foregroundColor(Color.mainTextColor) Spacer() - VStack(alignment: .center) { - Text("\(account.followingCount)") - .font(.title3) - Text("Following") - .font(.subheadline) - .opacity(0.6) - } + NavigationLink(destination: FollowingView(accountId: account.id) + .environmentObject(applicationState) + ) { + VStack(alignment: .center) { + Text("\(account.followingCount)") + .font(.title3) + Text("Following") + .font(.subheadline) + .opacity(0.6) + } + }.foregroundColor(Color.mainTextColor) } HStack (alignment: .center) { - Text(account.displayName ?? account.username) - .foregroundColor(Color.mainTextColor) - .font(.footnote) - .fontWeight(.bold) - Text("@\(account.username)") - .foregroundColor(Color.lightGrayColor) - .font(.footnote) + VStack(alignment: .leading) { + Text(account.displayName ?? account.username) + .foregroundColor(Color.mainTextColor) + .font(.footnote) + .fontWeight(.bold) + Text("@\(account.username)") + .foregroundColor(Color.lightGrayColor) + .font(.footnote) + } Spacer() - Button { - Task { - do { - if let relationship = try await AccountService.shared.follow( - forAccountId: self.accountId, - andContext: self.applicationState.accountData - ) { - UserFeedbackService.shared.send() - self.relationship = relationship + if self.applicationState.accountData?.id != self.accountId { + Button { + Task { + do { + if let relationship = try await AccountService.shared.follow( + forAccountId: self.accountId, + andContext: self.applicationState.accountData + ) { + UserFeedbackService.shared.send() + self.relationship = relationship + } + } catch { + print("Error \(error.localizedDescription)") } - } catch { - print("Error \(error.localizedDescription)") + } + } label: { + HStack { + Image(systemName: relationship?.following == true ? "person.badge.minus" : "person.badge.plus") + Text(relationship?.following == true ? "Unfollow" : (relationship?.followedBy == true ? "Follow back" : "Follow")) } } - } label: { - HStack { - Image(systemName: relationship?.following == true ? "person.badge.minus" : "person.badge.plus") - Text(relationship?.following == true ? "Unfollow" : (relationship?.followedBy == true ? "Follow back" : "Follow")) - } + .buttonStyle(.borderedProminent) + .tint(relationship?.following == true ? Color.dangerColor : .accentColor) } - .buttonStyle(.borderedProminent) - .tint(relationship?.following == true ? Color.dangerColor : .accentColor) - } - if let note = account.note { + if let note = account.note, !note.isEmpty { HTMLFormattedText(note, withFontSize: 14, andWidth: Int(UIScreen.main.bounds.width) - 16) .padding(.top, -10) .padding(.leading, -4) diff --git a/Vernissage/Widgets/CommentsSection.swift b/Vernissage/Widgets/CommentsSection.swift index 8e0a036..97345c8 100644 --- a/Vernissage/Widgets/CommentsSection.swift +++ b/Vernissage/Widgets/CommentsSection.swift @@ -20,6 +20,14 @@ struct CommentsSection: View { VStack { if let context = context { ForEach(context.descendants, id: \.id) { status in + + if withDivider { + Rectangle() + .size(width: UIScreen.main.bounds.width, height: 4) + .fill(Color.mainTextColor) + .opacity(0.1) + } + HStack (alignment: .top) { if let account = status.account { @@ -92,15 +100,7 @@ struct CommentsSection: View { .padding(.horizontal, 8) .padding(.bottom, 8) - CommentsSection(statusId: status.id, withDivider: false) - - if withDivider { - Rectangle() - .size(width: UIScreen.main.bounds.width, height: 4) - .fill(Color.mainTextColor) - .opacity(0.1) - } } } } diff --git a/Vernissage/Widgets/UsernameRow.swift b/Vernissage/Widgets/UsernameRow.swift index 07a3da3..076c9b2 100644 --- a/Vernissage/Widgets/UsernameRow.swift +++ b/Vernissage/Widgets/UsernameRow.swift @@ -5,28 +5,43 @@ // import SwiftUI +import NukeUI struct UsernameRow: View { - @ObservedObject public var statusData: StatusData + @State public var accountAvatar: URL? + @State public var accountDisplayName: String? + @State public var accountUsername: String + @State public var cachedAvatar: UIImage? var body: some View { HStack (alignment: .center) { - AsyncImage(url: statusData.accountAvatar) { image in - image + if let cachedAvatar { + Image(uiImage: cachedAvatar) .resizable() .clipShape(Circle()) .aspectRatio(contentMode: .fit) - } placeholder: { - Image(systemName: "person.circle") - .resizable() - .foregroundColor(Color.mainTextColor) + .frame(width: 48.0, height: 48.0) + } + else { + AsyncImage(url: accountAvatar) { image in + image + .resizable() + .clipShape(Circle()) + .aspectRatio(contentMode: .fit) + } placeholder: { + Image(systemName: "person.circle") + .resizable() + .clipShape(Circle()) + .aspectRatio(contentMode: .fit) + .foregroundColor(Color.mainTextColor) + } + .frame(width: 48.0, height: 48.0) } - .frame(width: 48.0, height: 48.0) VStack (alignment: .leading) { - Text(statusData.accountDisplayName ?? statusData.accountUsername) + Text(accountDisplayName ?? accountUsername) .foregroundColor(Color.mainTextColor) - Text("@\(statusData.accountUsername)") + Text("@\(accountUsername)") .foregroundColor(Color.lightGrayColor) .font(.footnote) } @@ -37,6 +52,6 @@ struct UsernameRow: View { struct UsernameRow_Previews: PreviewProvider { static var previews: some View { - UsernameRow(statusData: StatusData()) + UsernameRow(accountUsername: "") } }