From b4f7b02d7826f718738e734badb78e0bd5271963 Mon Sep 17 00:00:00 2001 From: Marcin Czachurski Date: Tue, 25 Apr 2023 15:47:21 +0200 Subject: [PATCH] #49 Approve/reject follow requests --- .../ClientKit/Client+FollowRequests.swift | 25 +++ ClientKit/Sources/ClientKit/Client.swift | 1 + Localization/en.lproj/Localizable.strings | 14 +- Localization/eu.lproj/Localizable.strings | 14 +- Localization/fr.lproj/Localizable.strings | 14 +- Localization/pl.lproj/Localizable.strings | 16 +- .../PixelfedClient+FollowRequests.swift | 40 ++++ .../PixelfedKit/Targets/FollowRequests.swift | 45 +++-- Vernissage.xcodeproj/project.pbxproj | 8 + Vernissage/AppRouteur.swift | 2 + Vernissage/Models/RelationshipModel.swift | 28 +++ Vernissage/Services/RouterPath.swift | 1 + Vernissage/Views/FollowRequestsView.swift | 179 ++++++++++++++++++ .../Subviews/UserProfileHeaderView.swift | 82 +++++--- .../UserProfilePrivateAccountView.swift | 22 +++ .../UserProfileView/UserProfileView.swift | 32 +++- .../WidgetsKit/Widgets/TagWidget.swift | 4 +- 17 files changed, 473 insertions(+), 54 deletions(-) create mode 100644 ClientKit/Sources/ClientKit/Client+FollowRequests.swift create mode 100644 PixelfedKit/Sources/PixelfedKit/PixelfedClient+FollowRequests.swift create mode 100644 Vernissage/Views/FollowRequestsView.swift create mode 100644 Vernissage/Views/UserProfileView/Subviews/UserProfilePrivateAccountView.swift diff --git a/ClientKit/Sources/ClientKit/Client+FollowRequests.swift b/ClientKit/Sources/ClientKit/Client+FollowRequests.swift new file mode 100644 index 0000000..5febabe --- /dev/null +++ b/ClientKit/Sources/ClientKit/Client+FollowRequests.swift @@ -0,0 +1,25 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the Apache License 2.0. +// + +import Foundation +import PixelfedKit + +extension Client { + public class FollowRequests: BaseClient { + public func followRequests(limit: Int = 10, + page: Int? = nil) async throws -> [Account] { + return try await pixelfedClient.followRequests(limit: limit, page: page) + } + + public func authorizeRequest(id: EntityId) async throws -> Relationship { + return try await pixelfedClient.authorizeRequest(id: id) + } + + public func rejectRequest(id: EntityId) async throws -> Relationship { + return try await pixelfedClient.rejectRequest(id: id) + } + } +} diff --git a/ClientKit/Sources/ClientKit/Client.swift b/ClientKit/Sources/ClientKit/Client.swift index bf11ab9..e358347 100644 --- a/ClientKit/Sources/ClientKit/Client.swift +++ b/ClientKit/Sources/ClientKit/Client.swift @@ -39,6 +39,7 @@ extension Client { public var blocks: Blocks? { return Blocks(pixelfedClient: self.pixelfedClient) } public var mutes: Mutes? { return Mutes(pixelfedClient: self.pixelfedClient) } public var reports: Reports? { return Reports(pixelfedClient: self.pixelfedClient) } + public var followRequests: FollowRequests? { return FollowRequests(pixelfedClient: self.pixelfedClient) } public var instances: Instances { return Instances() } } diff --git a/Localization/en.lproj/Localizable.strings b/Localization/en.lproj/Localizable.strings index 7431351..053256e 100644 --- a/Localization/en.lproj/Localizable.strings +++ b/Localization/en.lproj/Localizable.strings @@ -100,7 +100,6 @@ "userProfile.title.following" = "Following"; "userProfile.title.joined" = "Joined %@"; "userProfile.title.unfollow" = "Unfollow"; -"userProfile.title.followBack" = "Follow back"; "userProfile.title.follow" = "Follow"; "userProfile.title.instance" = "Instance information"; "userProfile.title.blocks" = "Blocked accounts"; @@ -110,6 +109,12 @@ "userProfile.title.blocked" = "Account blocked"; "userProfile.title.unblocked" = "Account unblocked"; "userProfile.title.report" = "Report"; +"userProfile.title.followsYou" = "Follows you"; +"userProfile.title.requestFollow" = "Request follow"; +"userProfile.title.cancelRequestFollow" = "Cancel request"; +"userProfile.title.followRequests" = "Follow requests"; +"userProfile.title.privateProfileTitle" = "This profile is private."; +"userProfile.title.privateProfileSubtitle" = "Only approved followers can see photos."; "userProfile.error.notExists" = "Account does not exists."; "userProfile.error.loadingAccountFailed" = "Error during download account from server."; "userProfile.error.muting" = "Muting/unmuting action failed."; @@ -350,3 +355,10 @@ "report.title.scam" = "Bullying or harassment"; "report.title.terrorism" = "Terrorism"; "report.error.notReported" = "Error during sending report."; + +// Mark: Following requests. +"followingRequests.navigationBar.title" = "Following requests"; +"followingRequests.title.approve" = "Approve"; +"followingRequests.title.reject" = "Reject"; +"followingRequests.error.approve" = "Error during approving request."; +"followingRequests.error.reject" = "Error during rejecting request."; diff --git a/Localization/eu.lproj/Localizable.strings b/Localization/eu.lproj/Localizable.strings index 0e2b619..4fcd929 100644 --- a/Localization/eu.lproj/Localizable.strings +++ b/Localization/eu.lproj/Localizable.strings @@ -100,7 +100,6 @@ "userProfile.title.following" = "Jarraitzen"; "userProfile.title.joined" = "%@ egin zuen bat"; "userProfile.title.unfollow" = "Utzi jarraitzeari"; -"userProfile.title.followBack" = "Jarraitu bera ere"; "userProfile.title.follow" = "Jarraitu"; "userProfile.title.instance" = "Instantziari buruzko informazioa"; "userProfile.title.blocks" = "Blokeatutako kontuak"; @@ -110,6 +109,12 @@ "userProfile.title.blocked" = "Kontua blokeatu da"; "userProfile.title.unblocked" = "Kontua blokeatzeari utzi zaio"; "userProfile.title.report" = "Salatu"; +"userProfile.title.followsYou" = "Jarraitzen dizu"; +"userProfile.title.requestFollow" = "Request follow"; +"userProfile.title.cancelRequestFollow" = "Cancel request"; +"userProfile.title.followRequests" = "Follow requests"; +"userProfile.title.privateProfileTitle" = "This profile is private."; +"userProfile.title.privateProfileSubtitle" = "Only approved followers can see photos."; "userProfile.error.notExists" = "Kontua ez da existitzen."; "userProfile.error.loadingAccountFailed" = "Errorea zerbitzaritik kontua eskuratzean."; "userProfile.error.muting" = "Mututu/Mututzeari uzteak huts egin du."; @@ -350,3 +355,10 @@ "report.title.scam" = "Bullyinga edo jazarpena"; "report.title.terrorism" = "Terrorismoa"; "report.error.notReported" = "Errorea salaketa bidaltzerakoan."; + +// Mark: Following requests. +"followingRequests.navigationBar.title" = "Following requests"; +"followingRequests.title.approve" = "Approve"; +"followingRequests.title.reject" = "Reject"; +"followingRequests.error.approve" = "Error during approving request."; +"followingRequests.error.reject" = "Error during rejecting request."; diff --git a/Localization/fr.lproj/Localizable.strings b/Localization/fr.lproj/Localizable.strings index 68cdd73..e551724 100644 --- a/Localization/fr.lproj/Localizable.strings +++ b/Localization/fr.lproj/Localizable.strings @@ -100,7 +100,6 @@ "userProfile.title.following" = "Suivis"; "userProfile.title.joined" = "Joint %@"; "userProfile.title.unfollow" = "Ne plus suivre"; -"userProfile.title.followBack" = "Suivre en retour"; "userProfile.title.follow" = "Suivre"; "userProfile.title.instance" = "Information sur l'instance"; "userProfile.title.blocks" = "Comptes bloqués"; @@ -110,6 +109,12 @@ "userProfile.title.blocked" = "Compte bloqué"; "userProfile.title.unblocked" = "Compte débloqué"; "userProfile.title.report" = "Rapport"; +"userProfile.title.followsYou" = "Vous suit"; +"userProfile.title.requestFollow" = "Request follow"; +"userProfile.title.cancelRequestFollow" = "Cancel request"; +"userProfile.title.followRequests" = "Follow requests"; +"userProfile.title.privateProfileTitle" = "This profile is private."; +"userProfile.title.privateProfileSubtitle" = "Only approved followers can see photos."; "userProfile.error.notExists" = "Le compte n'existe pas."; "userProfile.error.loadingAccountFailed" = "Erreur pendant le téléchargement du compte depuis le serveur."; "userProfile.error.muting" = "L'action sourdine / réactivation a échoué."; @@ -350,3 +355,10 @@ "report.title.scam" = "Intimidation ou harcèlement"; "report.title.terrorism" = "Le terrorisme"; "report.error.notReported" = "Erreur lors de l'envoi du rapport."; + +// Mark: Following requests. +"followingRequests.navigationBar.title" = "Following requests"; +"followingRequests.title.approve" = "Approve"; +"followingRequests.title.reject" = "Reject"; +"followingRequests.error.approve" = "Error during approving request."; +"followingRequests.error.reject" = "Error during rejecting request."; diff --git a/Localization/pl.lproj/Localizable.strings b/Localization/pl.lproj/Localizable.strings index 5e45841..a8ab551 100644 --- a/Localization/pl.lproj/Localizable.strings +++ b/Localization/pl.lproj/Localizable.strings @@ -100,10 +100,8 @@ "userProfile.title.following" = "Obserwowani"; "userProfile.title.joined" = "Dołączył(a) %@"; "userProfile.title.unfollow" = "Przestań obserwować"; -"userProfile.title.followBack" = "Również obserwuj"; "userProfile.title.follow" = "Obserwuj"; "userProfile.title.instance" = "Informacje o instancji"; -"userProfile.error.notExists" = "Konto nie istnieje."; "userProfile.title.blocks" = "Zablokowane konta"; "userProfile.title.mutes" = "Wyciszone konta"; "userProfile.title.muted" = "Konto wyciszone"; @@ -111,6 +109,13 @@ "userProfile.title.blocked" = "Konto zablokowane"; "userProfile.title.unblocked" = "Konto odblokowane"; "userProfile.title.report" = "Zgłoś"; +"userProfile.title.followsYou" = "Obserwuje ciebie"; +"userProfile.title.requestFollow" = "Poproś o obserwowanie"; +"userProfile.title.cancelRequestFollow" = "Anuluj prośbę"; +"userProfile.title.followRequests" = "Prośby o obserwowanie"; +"userProfile.title.privateProfileTitle" = "To konto jest prywatne."; +"userProfile.title.privateProfileSubtitle" = "Tylko zaakceptowani użytkownicy mogą przeglądać zdjęcia."; +"userProfile.error.notExists" = "Konto nie istnieje."; "userProfile.error.notExists" = "Błąd podczas pobierania danych użytkownika."; "userProfile.error.mute" = "Błąd podczas wyciszania użytkownika."; "userProfile.error.block" = "Błąd podczas blokowania/odblokowywania użytkownika."; @@ -350,3 +355,10 @@ "report.title.scam" = "Znęcanie się lub nękanie"; "report.title.terrorism" = "Terroryzm"; "report.error.notReported" = "Błąd podczas wysyłania zgłoszenia."; + +// Mark: Following requests. +"followingRequests.navigationBar.title" = "Prośby o obserwowanie"; +"followingRequests.title.approve" = "Zaakceptuj"; +"followingRequests.title.reject" = "Odrzuć"; +"followingRequests.error.approve" = "Błąd podczas akceptowania prośby."; +"followingRequests.error.reject" = "Błąd podczas odrzucania prośby."; diff --git a/PixelfedKit/Sources/PixelfedKit/PixelfedClient+FollowRequests.swift b/PixelfedKit/Sources/PixelfedKit/PixelfedClient+FollowRequests.swift new file mode 100644 index 0000000..cf37fda --- /dev/null +++ b/PixelfedKit/Sources/PixelfedKit/PixelfedClient+FollowRequests.swift @@ -0,0 +1,40 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the Apache License 2.0. +// + +import Foundation + +public extension PixelfedClientAuthenticated { + func followRequests(limit: Int? = nil, + page: Page? = nil) async throws -> [Account] { + let request = try Self.request( + for: baseURL, + target: Pixelfed.FollowRequests.followRequests(limit, page), + withBearerToken: token + ) + + return try await downloadJson([Account].self, request: request) + } + + func authorizeRequest(id: EntityId) async throws -> Relationship { + let request = try Self.request( + for: baseURL, + target: Pixelfed.FollowRequests.authorize(id), + withBearerToken: token + ) + + return try await downloadJson(Relationship.self, request: request) + } + + func rejectRequest(id: EntityId) async throws -> Relationship { + let request = try Self.request( + for: baseURL, + target: Pixelfed.FollowRequests.reject(id), + withBearerToken: token + ) + + return try await downloadJson(Relationship.self, request: request) + } +} diff --git a/PixelfedKit/Sources/PixelfedKit/Targets/FollowRequests.swift b/PixelfedKit/Sources/PixelfedKit/Targets/FollowRequests.swift index 9f15795..e89906d 100644 --- a/PixelfedKit/Sources/PixelfedKit/Targets/FollowRequests.swift +++ b/PixelfedKit/Sources/PixelfedKit/Targets/FollowRequests.swift @@ -8,7 +8,7 @@ import Foundation extension Pixelfed { public enum FollowRequests { - case followRequests + case followRequests(Limit?, Page?) case authorize(String) case reject(String) } @@ -22,10 +22,10 @@ extension Pixelfed.FollowRequests: TargetType { switch self { case .followRequests: return "\(apiPath)" - case .authorize: - return "\(apiPath)/authorize" - case .reject: - return "\(apiPath)/reject" + case .authorize(let id): + return "\(apiPath)/\(id)/authorize" + case .reject(let id): + return "\(apiPath)/\(id)/reject" } } @@ -39,9 +39,29 @@ extension Pixelfed.FollowRequests: TargetType { } } - /// The parameters to be incoded in the request. public var queryItems: [(String, String)]? { - nil + var params: [(String, String)] = [] + + var limit: Limit? + var page: Page? + + switch self { + case .followRequests(let paramLimit, let paramPage): + limit = paramLimit + page = paramPage + default: + return nil + } + + if let limit { + params.append(("limit", "\(limit)")) + } + + if let page { + params.append(("page", "\(page)")) + } + + return params } public var headers: [String: String]? { @@ -49,15 +69,6 @@ extension Pixelfed.FollowRequests: TargetType { } public var httpBody: Data? { - switch self { - case .followRequests: - return nil - case .authorize(let id): - return try? JSONEncoder().encode( - ["id": id] - ) - case .reject: - return nil - } + return nil } } diff --git a/Vernissage.xcodeproj/project.pbxproj b/Vernissage.xcodeproj/project.pbxproj index 9c339de..f68a799 100644 --- a/Vernissage.xcodeproj/project.pbxproj +++ b/Vernissage.xcodeproj/project.pbxproj @@ -25,6 +25,8 @@ F8210DDD2966CF17001D9973 /* StatusData+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8210DDC2966CF17001D9973 /* StatusData+Status.swift */; }; F8210DDF2966CFC7001D9973 /* AttachmentData+Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8210DDE2966CFC7001D9973 /* AttachmentData+Attachment.swift */; }; F8210DEA2966E4F9001D9973 /* AnimatePlaceholderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8210DE92966E4F9001D9973 /* AnimatePlaceholderModifier.swift */; }; + F825F0C929F7A562008BD204 /* UserProfilePrivateAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F825F0C829F7A562008BD204 /* UserProfilePrivateAccountView.swift */; }; + F825F0CB29F7CFC4008BD204 /* FollowRequestsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F825F0CA29F7CFC4008BD204 /* FollowRequestsView.swift */; }; F835082329BEF9C400DE3247 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F835082629BEF9C400DE3247 /* Localizable.strings */; }; F835082429BEF9C400DE3247 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F835082629BEF9C400DE3247 /* Localizable.strings */; }; F83CBEFB298298A1002972C8 /* ImageCarouselPicture.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83CBEFA298298A1002972C8 /* ImageCarouselPicture.swift */; }; @@ -231,6 +233,8 @@ F8210DDC2966CF17001D9973 /* StatusData+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusData+Status.swift"; sourceTree = ""; }; F8210DDE2966CFC7001D9973 /* AttachmentData+Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentData+Attachment.swift"; sourceTree = ""; }; F8210DE92966E4F9001D9973 /* AnimatePlaceholderModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatePlaceholderModifier.swift; sourceTree = ""; }; + F825F0C829F7A562008BD204 /* UserProfilePrivateAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfilePrivateAccountView.swift; sourceTree = ""; }; + F825F0CA29F7CFC4008BD204 /* FollowRequestsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestsView.swift; sourceTree = ""; }; F835082529BEF9C400DE3247 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; F835082729BEFA1E00DE3247 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; F837269429A221420098D3C4 /* PixelfedKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = PixelfedKit; sourceTree = ""; }; @@ -477,6 +481,7 @@ F89B5CC129D01BF700549F2F /* InstanceView.swift */, F805DCF029DBEF83006A1FD9 /* ReportView.swift */, F86BC9EA29EBDA2E009415EC /* ActivityView.swift */, + F825F0CA29F7CFC4008BD204 /* FollowRequestsView.swift */, ); path = Views; sourceTree = ""; @@ -607,6 +612,7 @@ children = ( F86B7213296BFDCE00EE59EC /* UserProfileHeaderView.swift */, F86B7215296BFFDA00EE59EC /* UserProfileStatusesView.swift */, + F825F0C829F7A562008BD204 /* UserProfilePrivateAccountView.swift */, ); path = Subviews; sourceTree = ""; @@ -1136,6 +1142,8 @@ F8FB8ABA29EB2ED400342C04 /* NavigationMenuButtons.swift in Sources */, F88BC51D29E0377B00CE6141 /* AccountData+AccountModel.swift in Sources */, F89B5CC229D01BF700549F2F /* InstanceView.swift in Sources */, + F825F0CB29F7CFC4008BD204 /* FollowRequestsView.swift in Sources */, + F825F0C929F7A562008BD204 /* UserProfilePrivateAccountView.swift in Sources */, F89F57B029D1C11200001EE3 /* RelationshipModel.swift in Sources */, F88AB05829B36B8200345EDE /* AccountsPhotoView.swift in Sources */, F85D4971296402DC00751DF7 /* AuthorizationService.swift in Sources */, diff --git a/Vernissage/AppRouteur.swift b/Vernissage/AppRouteur.swift index c83901c..e7bda46 100644 --- a/Vernissage/AppRouteur.swift +++ b/Vernissage/AppRouteur.swift @@ -50,6 +50,8 @@ extension View { EditProfileView() case .instance: InstanceView() + case .followRequests: + FollowRequestsView() } } } diff --git a/Vernissage/Models/RelationshipModel.swift b/Vernissage/Models/RelationshipModel.swift index 25247e9..13ccacd 100644 --- a/Vernissage/Models/RelationshipModel.swift +++ b/Vernissage/Models/RelationshipModel.swift @@ -9,6 +9,12 @@ import Foundation import PixelfedKit public class RelationshipModel: ObservableObject { + enum RelationshipAction { + case follow + case unfollow + case requestFollow + case cancelRequestFollow + } /// The account ID. @Published public var id: EntityId @@ -122,3 +128,25 @@ extension RelationshipModel { self.note = relationship.note } } + +extension RelationshipModel { + func haveAccessToPhotos(account: Account) -> Bool { + return !account.locked || (account.locked && self.following) + } + + func getRelationshipAction(account: Account) -> RelationshipAction { + if self.following { + return .unfollow + } + + if self.requested { + return .cancelRequestFollow + } + + if account.locked { + return .requestFollow + } + + return .follow + } +} diff --git a/Vernissage/Services/RouterPath.swift b/Vernissage/Services/RouterPath.swift index 0437410..ac5c653 100644 --- a/Vernissage/Services/RouterPath.swift +++ b/Vernissage/Services/RouterPath.swift @@ -26,6 +26,7 @@ enum RouteurDestinations: Hashable { case search case editProfile case instance + case followRequests } enum SheetDestinations: Identifiable { diff --git a/Vernissage/Views/FollowRequestsView.swift b/Vernissage/Views/FollowRequestsView.swift new file mode 100644 index 0000000..9a0a026 --- /dev/null +++ b/Vernissage/Views/FollowRequestsView.swift @@ -0,0 +1,179 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the Apache License 2.0. +// + +import SwiftUI +import PixelfedKit +import ClientKit +import Foundation +import ServicesKit +import EnvironmentKit +import WidgetsKit + +struct FollowRequestsView: View { + @EnvironmentObject var applicationState: ApplicationState + @EnvironmentObject var routerPath: RouterPath + @EnvironmentObject var client: Client + + @State private var accounts: [Account] = [] + @State private var downloadedPage = 1 + @State private var allItemsLoaded = false + @State private var state: ViewState = .loading + + var body: some View { + self.mainBody() + .navigationTitle("followingRequests.navigationBar.title") + } + + @ViewBuilder + private func mainBody() -> some View { + switch state { + case .loading: + LoadingIndicator() + .task { + await self.loadData(page: self.downloadedPage) + } + case .loaded: + if self.accounts.isEmpty { + NoDataView(imageSystemName: "person.3.sequence", text: "accounts.title.noAccounts") + } else { + self.list() + } + case .error(let error): + ErrorView(error: error) { + self.state = .loading + + self.downloadedPage = 1 + self.allItemsLoaded = false + self.accounts = [] + await self.loadData(page: self.downloadedPage) + } + .padding() + } + } + + @ViewBuilder + private func list() -> some View { + List { + ForEach(accounts, id: \.id) { account in + NavigationLink(value: RouteurDestinations.userProfile( + accountId: account.id, + accountDisplayName: account.displayNameWithoutEmojis, + accountUserName: account.acct) + ) { + VStack(alignment: .leading) { + HStack(alignment: .center) { + UserAvatar(accountAvatar: account.avatar, size: .list) + + VStack(alignment: .leading) { + Text(account.displayName ?? account.username) + .foregroundColor(.mainTextColor) + Text("@\(account.acct)") + .foregroundColor(.lightGrayColor) + .font(.footnote) + } + .padding(.leading, 8) + } + + if let note = account.note, !note.asMarkdown.isEmpty { + MarkdownFormattedText(note.asMarkdown) + .font(.footnote) + .environment(\.openURL, OpenURLAction { url in + routerPath.handle(url: url) + }) + .padding(.vertical, 4) + } + } + .swipeActions { + Button { + } label: { + Label("followingRequests.title.approve", systemImage: "checkmark") + } + + Button(role: .destructive) { + } label: { + Label("followingRequests.title.reject", systemImage: "xmark") + } + .tint(.dangerColor) + } + } + } + + if allItemsLoaded == false { + HStack { + Spacer() + LoadingIndicator() + .task { + self.downloadedPage = self.downloadedPage + 1 + await self.loadData(page: self.downloadedPage) + } + Spacer() + } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + } + + private func loadData(page: Int) async { + do { + try await self.loadAccounts(page: page) + self.state = .loaded + } catch { + if !Task.isCancelled { + ErrorService.shared.handle(error, message: "accounts.error.loadingAccountsFailed", showToastr: true) + self.state = .error(error) + } else { + ErrorService.shared.handle(error, message: "accounts.error.loadingAccountsFailed", showToastr: false) + } + } + } + + private func loadAccounts(page: Int) async throws { + let accountsFromApi = try await self.loadFromApi(page: page) + + if accountsFromApi.isEmpty { + self.allItemsLoaded = true + return + } + + await self.downloadAvatars(accounts: accountsFromApi) + self.accounts.append(contentsOf: accountsFromApi) + } + + private func loadFromApi(page: Int) async throws -> [Account] { + // TODO: Workaround for not working paging for favourites/reblogged issues: https://github.com/pixelfed/pixelfed/issues/4182. + if page == 1 { + let results = try await self.client.followRequests?.followRequests(limit: 100, page: page) + return results ?? [] + } else { + return [] + } + } + + private func approve(account: Account) async { + do { + _ = try await self.client.followRequests?.authorizeRequest(id: account.id) + } catch { + ErrorService.shared.handle(error, message: "followingRequests.error.approve", showToastr: true) + } + } + + private func reject(account: Account) async { + do { + _ = try await self.client.followRequests?.rejectRequest(id: account.id) + } catch { + ErrorService.shared.handle(error, message: "followingRequests.error.reject", showToastr: true) + } + } + + private func downloadAvatars(accounts: [Account]) async { + await withTaskGroup(of: Void.self) { group in + for account in accounts { + group.addTask { await CacheImageService.shared.download(url: account.avatar) } + } + } + } +} diff --git a/Vernissage/Views/UserProfileView/Subviews/UserProfileHeaderView.swift b/Vernissage/Views/UserProfileView/Subviews/UserProfileHeaderView.swift index b869621..bfc2c86 100644 --- a/Vernissage/Views/UserProfileView/Subviews/UserProfileHeaderView.swift +++ b/Vernissage/Views/UserProfileView/Subviews/UserProfileHeaderView.swift @@ -21,18 +21,6 @@ struct UserProfileHeaderView: View { var body: some View { VStack(alignment: .leading) { - HStack(alignment: .top) { - Spacer() - - if self.relationship.muting == true { - TagWidget(value: "userProfile.title.muted", color: .accentColor, systemImage: "message.and.waveform.fill") - } - - if self.relationship.blocking == true { - TagWidget(value: "userProfile.title.blocked", color: .dangerColor, systemImage: "hand.raised.fill") - } - } - HStack(alignment: .center) { UserAvatar(accountAvatar: account.avatar, size: .profile) @@ -108,6 +96,8 @@ struct UserProfileHeaderView: View { .font(.footnote) } + self.accountRelationshipPanel() + Text(String(format: NSLocalizedString("userProfile.title.joined", comment: "Joined"), account.createdAt.toRelative(.isoDateTimeMilliSec))) .foregroundColor(.lightGrayColor.opacity(0.5)) .font(.footnote) @@ -115,6 +105,27 @@ struct UserProfileHeaderView: View { .padding() } + @ViewBuilder + private func accountRelationshipPanel() -> some View { + if self.relationship.followedBy || self.relationship.muting || self.relationship.blocking { + HStack(alignment: .top) { + if self.relationship.followedBy { + TagWidget(value: "userProfile.title.followsYou", color: .secondary, systemImage: "person.crop.circle.badge.checkmark") + } + + if self.relationship.muting { + TagWidget(value: "userProfile.title.muted", color: .accentColor, systemImage: "message.and.waveform.fill") + } + + if self.relationship.blocking { + TagWidget(value: "userProfile.title.blocked", color: .dangerColor, systemImage: "hand.raised.fill") + } + + Spacer() + } + } + } + @ViewBuilder private func otherAccountActionButtons() -> some View { ActionButton { @@ -122,24 +133,51 @@ struct UserProfileHeaderView: View { } label: { HStack { Image(systemName: relationship.following == true ? "person.badge.minus" : "person.badge.plus") - Text(relationship.following == true - ? "userProfile.title.unfollow" - : (relationship.followedBy == true ? "userProfile.title.followBack" : "userProfile.title.follow"), comment: "Follow/unfollow actions") + Text(self.getRelationshipActionText(), comment: "Follow/unfollow actions") } } .buttonStyle(.borderedProminent) - .tint(relationship.following == true ? .dangerColor : .accentColor) + .tint(self.getTintColor()) + } + + private func getRelationshipActionText() -> LocalizedStringKey { + let relationshipAction = self.relationship.getRelationshipAction(account: self.account) + + switch relationshipAction { + case .follow: + return "userProfile.title.follow" + case .cancelRequestFollow: + return "userProfile.title.cancelRequestFollow" + case .requestFollow: + return "userProfile.title.requestFollow" + case .unfollow: + return "userProfile.title.unfollow" + } + } + + private func getTintColor() -> Color { + let relationshipAction = self.relationship.getRelationshipAction(account: self.account) + + switch relationshipAction { + case .follow, .requestFollow: + return .accentColor + case .cancelRequestFollow, .unfollow: + return .dangerColor + } } private func onRelationshipButtonTap() async { do { - if self.relationship.following == true { - if let relationship = try await self.client.accounts?.unfollow(account: self.account.id) { - self.relationship.following = relationship.following - } - } else { + let relationshipAction = self.relationship.getRelationshipAction(account: self.account) + + switch relationshipAction { + case .follow, .requestFollow: if let relationship = try await self.client.accounts?.follow(account: self.account.id) { - self.relationship.following = relationship.following + self.relationship.update(relationship: relationship) + } + case .cancelRequestFollow, .unfollow: + if let relationship = try await self.client.accounts?.unfollow(account: self.account.id) { + self.relationship.update(relationship: relationship) } } } catch { diff --git a/Vernissage/Views/UserProfileView/Subviews/UserProfilePrivateAccountView.swift b/Vernissage/Views/UserProfileView/Subviews/UserProfilePrivateAccountView.swift new file mode 100644 index 0000000..699b669 --- /dev/null +++ b/Vernissage/Views/UserProfileView/Subviews/UserProfilePrivateAccountView.swift @@ -0,0 +1,22 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the Apache License 2.0. +// + +import SwiftUI + +struct UserProfilePrivateAccountView: View { + var body: some View { + VStack(alignment: .center) { + Spacer() + Text("userProfile.title.privateProfileTitle", comment: "This profile is private.") + .fontWeight(.bold) + .font(.headline) + Text("userProfile.title.privateProfileSubtitle", comment: "Only approved followers can see photos.") + .fontWeight(.light) + .font(.subheadline) + Spacer() + }.padding(.top, 60) + } +} diff --git a/Vernissage/Views/UserProfileView/UserProfileView.swift b/Vernissage/Views/UserProfileView/UserProfileView.swift index 81b5bf9..1d4d4ee 100644 --- a/Vernissage/Views/UserProfileView/UserProfileView.swift +++ b/Vernissage/Views/UserProfileView/UserProfileView.swift @@ -66,7 +66,12 @@ struct UserProfileView: View { ScrollView { UserProfileHeaderView(account: account, relationship: relationship) .id(self.viewId) - UserProfileStatusesView(accountId: account.id) + + if self.applicationState.account?.id == account.id || self.relationship.haveAccessToPhotos(account: account) { + UserProfileStatusesView(accountId: account.id) + } else { + UserProfilePrivateAccountView() + } } .onAppear { if let updatedProfile = self.applicationState.updatedProfile { @@ -189,13 +194,7 @@ struct UserProfileView: View { Divider() - NavigationLink(value: RouteurDestinations.accounts(listType: .blocks)) { - Label(NSLocalizedString("userProfile.title.blocks", comment: "Blocked accounts"), systemImage: "hand.raised.fill") - } - - NavigationLink(value: RouteurDestinations.accounts(listType: .mutes)) { - Label(NSLocalizedString("userProfile.title.mutes", comment: "Muted accounts"), systemImage: "message.and.waveform.fill") - } + self.accountsMenuView(account: account) Divider() @@ -220,6 +219,23 @@ struct UserProfileView: View { } } + @ViewBuilder + private func accountsMenuView(account: Account) -> some View { + NavigationLink(value: RouteurDestinations.accounts(listType: .blocks)) { + Label(NSLocalizedString("userProfile.title.blocks", comment: "Blocked accounts"), systemImage: "hand.raised.fill") + } + + NavigationLink(value: RouteurDestinations.accounts(listType: .mutes)) { + Label(NSLocalizedString("userProfile.title.mutes", comment: "Muted accounts"), systemImage: "message.and.waveform.fill") + } + + if account.locked { + NavigationLink(value: RouteurDestinations.followRequests) { + Label(NSLocalizedString("userProfile.title.followRequests", comment: "FollowRequests"), systemImage: "person.crop.circle.badge.checkmark") + } + } + } + private func onMuteAccount(account: Account) async { do { if self.relationship.muting == true { diff --git a/WidgetsKit/Sources/WidgetsKit/Widgets/TagWidget.swift b/WidgetsKit/Sources/WidgetsKit/Widgets/TagWidget.swift index c1fa3d8..f992f0c 100644 --- a/WidgetsKit/Sources/WidgetsKit/Widgets/TagWidget.swift +++ b/WidgetsKit/Sources/WidgetsKit/Widgets/TagWidget.swift @@ -30,8 +30,8 @@ public struct TagWidget: View { .font(.footnote) .fontWeight(.semibold) } - .padding(.horizontal, 8) - .padding(.vertical, 2) + .padding(.horizontal, 10) + .padding(.vertical, 6) .background(Capsule().foregroundColor(self.color)) } }