Support for follow requests (#376) close #321

* Support for follow requests (#321)

* Run SwiftFormat

Co-authored-by: Thomas Ricouard <ricouard77@gmail.com>
This commit is contained in:
Jérôme Danthinne 2023-01-25 13:02:28 +01:00 committed by GitHub
parent 3ccd66a6bb
commit 9b3b3692ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 371 additions and 170 deletions

View file

@ -1,12 +1,12 @@
import Account import Account
import AppAccount import AppAccount
import Conversations
import DesignSystem import DesignSystem
import Env import Env
import Lists import Lists
import Status import Status
import SwiftUI import SwiftUI
import Timeline import Timeline
import Conversations
@MainActor @MainActor
extension View { extension View {

View file

@ -214,17 +214,17 @@ class AppDelegate: NSObject, UIApplicationDelegate {
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)
{ {
PushNotificationsService.shared.pushToken = deviceToken PushNotificationsService.shared.pushToken = deviceToken
#if !DEBUG #if !DEBUG
Task { Task {
await PushNotificationsService.shared.fetchSubscriptions(accounts: AppAccountsManager.shared.pushAccounts) await PushNotificationsService.shared.fetchSubscriptions(accounts: AppAccountsManager.shared.pushAccounts)
await PushNotificationsService.shared.updateSubscriptions(accounts: AppAccountsManager.shared.pushAccounts) await PushNotificationsService.shared.updateSubscriptions(accounts: AppAccountsManager.shared.pushAccounts)
} }
#endif #endif
} }
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {} func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {}
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
if connectingSceneSession.role == .windowApplication { if connectingSceneSession.role == .windowApplication {
configuration.delegateClass = SceneDelegate.self configuration.delegateClass = SceneDelegate.self

View file

@ -51,12 +51,12 @@ struct QuickLookPreview: UIViewControllerRepresentable {
class AppQLPreviewController: QLPreviewController { class AppQLPreviewController: QLPreviewController {
private var closeButton: UIBarButtonItem { private var closeButton: UIBarButtonItem {
.init( .init(
title: NSLocalizedString("action.done", comment: ""), title: NSLocalizedString("action.done", comment: ""),
style: .plain, style: .plain,
target: self, target: self,
action: #selector(onCloseButton) action: #selector(onCloseButton)
) )
} }
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {

View file

@ -166,6 +166,10 @@
"account.follow.follow" = "Folgen"; "account.follow.follow" = "Folgen";
"account.follow.following" = "Gefolgt"; "account.follow.following" = "Gefolgt";
"account.follow.requested" = "Angefragt"; "account.follow.requested" = "Angefragt";
"account.follow-request.accept" = "Accept";
"account.follow-request.reject" = "Reject";
"account.follow-requests.pending-requests" = "Pending requests";
"account.follow-requests.instructions" = "Those users won't see your posts until you accept them.";
"account.followers" = "Follower"; "account.followers" = "Follower";
"account.following" = "Folgt"; "account.following" = "Folgt";
"account.list.create" = "Neue Liste erstellen"; "account.list.create" = "Neue Liste erstellen";

View file

@ -169,6 +169,10 @@
"account.follow.follow" = "Follow"; "account.follow.follow" = "Follow";
"account.follow.following" = "Following"; "account.follow.following" = "Following";
"account.follow.requested" = "Requested"; "account.follow.requested" = "Requested";
"account.follow-request.accept" = "Accept";
"account.follow-request.reject" = "Reject";
"account.follow-requests.pending-requests" = "Pending requests";
"account.follow-requests.instructions" = "Those users won't see your posts until you accept them.";
"account.followers" = "Followers"; "account.followers" = "Followers";
"account.following" = "Following"; "account.following" = "Following";
"account.list.create" = "Create a new list"; "account.list.create" = "Create a new list";

View file

@ -166,6 +166,10 @@
"account.follow.follow" = "Seguir"; "account.follow.follow" = "Seguir";
"account.follow.following" = "Siguiendo"; "account.follow.following" = "Siguiendo";
"account.follow.requested" = "Solicitado"; "account.follow.requested" = "Solicitado";
"account.follow-request.accept" = "Accept";
"account.follow-request.reject" = "Reject";
"account.follow-requests.pending-requests" = "Pending requests";
"account.follow-requests.instructions" = "Those users won't see your posts until you accept them.";
"account.followers" = "Seguidores"; "account.followers" = "Seguidores";
"account.following" = "Siguiendo"; "account.following" = "Siguiendo";
"account.list.create" = "Crear una lista nueva"; "account.list.create" = "Crear una lista nueva";

View file

@ -166,6 +166,10 @@
"account.follow.follow" = "Segui"; "account.follow.follow" = "Segui";
"account.follow.following" = "Segui già"; "account.follow.following" = "Segui già";
"account.follow.requested" = "Richiesto"; "account.follow.requested" = "Richiesto";
"account.follow-request.accept" = "Accept";
"account.follow-request.reject" = "Reject";
"account.follow-requests.pending-requests" = "Pending requests";
"account.follow-requests.instructions" = "Those users won't see your posts until you accept them.";
"account.followers" = "Seguito da"; "account.followers" = "Seguito da";
"account.following" = "Seguiti"; "account.following" = "Seguiti";
"account.list.create" = "Crea una nuova lista"; "account.list.create" = "Crea una nuova lista";

View file

@ -152,6 +152,10 @@
"account.follow.follow" = "フォロー"; "account.follow.follow" = "フォロー";
"account.follow.following" = "フォローしている"; "account.follow.following" = "フォローしている";
"account.follow.requested" = "リクエストしました"; "account.follow.requested" = "リクエストしました";
"account.follow-request.accept" = "Accept";
"account.follow-request.reject" = "Reject";
"account.follow-requests.pending-requests" = "Pending requests";
"account.follow-requests.instructions" = "Those users won't see your posts until you accept them.";
"account.followers" = "フォロワー"; "account.followers" = "フォロワー";
"account.following" = "フォローしている"; "account.following" = "フォローしている";
"account.list.create" = "新しいリストを作成"; "account.list.create" = "新しいリストを作成";

View file

@ -166,6 +166,10 @@
"account.follow.follow" = "Volg"; "account.follow.follow" = "Volg";
"account.follow.following" = "Volgend"; "account.follow.following" = "Volgend";
"account.follow.requested" = "Verzocht"; "account.follow.requested" = "Verzocht";
"account.follow-request.accept" = "Accept";
"account.follow-request.reject" = "Reject";
"account.follow-requests.pending-requests" = "Pending requests";
"account.follow-requests.instructions" = "Those users won't see your posts until you accept them.";
"account.followers" = "Volgers"; "account.followers" = "Volgers";
"account.following" = "Volgend"; "account.following" = "Volgend";
"account.list.create" = "Maak een nieuwe lijst"; "account.list.create" = "Maak een nieuwe lijst";

View file

@ -167,6 +167,10 @@
"account.follow.follow" = "关注"; "account.follow.follow" = "关注";
"account.follow.following" = "正在关注"; "account.follow.following" = "正在关注";
"account.follow.requested" = "已申请"; "account.follow.requested" = "已申请";
"account.follow-request.accept" = "Accept";
"account.follow-request.reject" = "Reject";
"account.follow-requests.pending-requests" = "Pending requests";
"account.follow-requests.instructions" = "Those users won't see your posts until you accept them.";
"account.followers" = "粉丝"; "account.followers" = "粉丝";
"account.following" = "关注"; "account.following" = "关注";
"account.list.create" = "新建一个列表"; "account.list.create" = "新建一个列表";

View file

@ -1,10 +1,10 @@
import AppAccount
import CryptoKit import CryptoKit
import Env import Env
import KeychainSwift import KeychainSwift
import Models import Models
import UIKit import UIKit
import UserNotifications import UserNotifications
import AppAccount
@MainActor @MainActor
class NotificationService: UNNotificationServiceExtension { class NotificationService: UNNotificationServiceExtension {

View file

@ -10,6 +10,7 @@ struct AccountDetailHeaderView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var quickLook: QuickLook @EnvironmentObject private var quickLook: QuickLook
@EnvironmentObject private var routerPath: RouterPath @EnvironmentObject private var routerPath: RouterPath
@EnvironmentObject private var currentAccount: CurrentAccount
@Environment(\.redactionReasons) private var reasons @Environment(\.redactionReasons) private var reasons
@ObservedObject var viewModel: AccountDetailViewModel @ObservedObject var viewModel: AccountDetailViewModel
@ -95,7 +96,11 @@ struct AccountDetailHeaderView: View {
makeCustomInfoLabel(title: "account.following", count: account.followingCount) makeCustomInfoLabel(title: "account.following", count: account.followingCount)
} }
NavigationLink(value: RouterDestinations.followers(id: account.id)) { NavigationLink(value: RouterDestinations.followers(id: account.id)) {
makeCustomInfoLabel(title: "account.followers", count: account.followersCount) makeCustomInfoLabel(
title: "account.followers",
count: account.followersCount,
needsBadge: currentAccount.account?.id == account.id && !currentAccount.followRequests.isEmpty
)
} }
}.offset(y: 20) }.offset(y: 20)
} }
@ -136,11 +141,19 @@ struct AccountDetailHeaderView: View {
.offset(y: -40) .offset(y: -40)
} }
private func makeCustomInfoLabel(title: LocalizedStringKey, count: Int) -> some View { private func makeCustomInfoLabel(title: LocalizedStringKey, count: Int, needsBadge: Bool = false) -> some View {
VStack { VStack {
Text("\(count)") Text("\(count)")
.font(.scaledHeadline) .font(.scaledHeadline)
.foregroundColor(theme.tintColor) .foregroundColor(theme.tintColor)
.overlay(alignment: .trailing) {
if needsBadge {
Circle()
.fill(Color.red)
.frame(width: 9, height: 9)
.offset(x: 12)
}
}
Text(title) Text(title)
.font(.scaledFootnote) .font(.scaledFootnote)
.foregroundColor(.gray) .foregroundColor(.gray)

View file

@ -10,9 +10,9 @@ public class AccountsListRowViewModel: ObservableObject {
var client: Client? var client: Client?
@Published var account: Account @Published var account: Account
@Published var relationShip: Relationship @Published var relationShip: Relationship?
public init(account: Account, relationShip: Relationship) { public init(account: Account, relationShip: Relationship? = nil) {
self.account = account self.account = account
self.relationShip = relationShip self.relationShip = relationShip
} }
@ -24,9 +24,13 @@ public struct AccountsListRow: View {
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@StateObject var viewModel: AccountsListRowViewModel @StateObject var viewModel: AccountsListRowViewModel
let isFollowRequest: Bool
let requestUpdated: (() -> Void)?
public init(viewModel: AccountsListRowViewModel) { public init(viewModel: AccountsListRowViewModel, isFollowRequest: Bool = false, requestUpdated: (() -> Void)? = nil) {
_viewModel = StateObject(wrappedValue: viewModel) _viewModel = StateObject(wrappedValue: viewModel)
self.isFollowRequest = isFollowRequest
self.requestUpdated = requestUpdated
} }
public var body: some View { public var body: some View {
@ -45,11 +49,17 @@ public struct AccountsListRow: View {
.environment(\.openURL, OpenURLAction { url in .environment(\.openURL, OpenURLAction { url in
routerPath.handle(url: url) routerPath.handle(url: url)
}) })
if isFollowRequest {
FollowRequestButtons(account: viewModel.account,
requestUpdated: requestUpdated)
}
} }
Spacer() Spacer()
if currentAccount.account?.id != viewModel.account.id { if currentAccount.account?.id != viewModel.account.id,
let relationShip = viewModel.relationShip
{
FollowButton(viewModel: .init(accountId: viewModel.account.id, FollowButton(viewModel: .init(accountId: viewModel.account.id,
relationship: viewModel.relationShip, relationship: relationShip,
shouldDisplayNotify: false, shouldDisplayNotify: false,
relationshipUpdated: { _ in })) relationshipUpdated: { _ in }))
} }

View file

@ -8,6 +8,7 @@ import SwiftUI
public struct AccountsListView: View { public struct AccountsListView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@EnvironmentObject private var currentAccount: CurrentAccount
@StateObject private var viewModel: AccountsListViewModel @StateObject private var viewModel: AccountsListViewModel
@State private var didAppear: Bool = false @State private var didAppear: Bool = false
@ -26,11 +27,37 @@ public struct AccountsListView: View {
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
case let .display(accounts, relationships, nextPageState): case let .display(accounts, relationships, nextPageState):
ForEach(accounts) { account in if case .followers = viewModel.mode,
if let relationship = relationships.first(where: { $0.id == account.id }) { !currentAccount.followRequests.isEmpty
AccountsListRow(viewModel: .init(account: account, {
relationShip: relationship)) Section(
header: Text("account.follow-requests.pending-requests"),
footer: Text("account.follow-requests.instructions")
.font(.scaledFootnote)
.foregroundColor(.secondary)
.offset(y: -8)
) {
ForEach(currentAccount.followRequests) { account in
AccountsListRow(
viewModel: .init(account: account),
isFollowRequest: true,
requestUpdated: {
Task {
await viewModel.fetch()
}
}
)
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
}
}
}
Section {
ForEach(accounts) { account in
if let relationship = relationships.first(where: { $0.id == account.id }) {
AccountsListRow(viewModel: .init(account: account,
relationShip: relationship))
.listRowBackground(theme.primaryBackgroundColor)
}
} }
} }

View file

@ -66,7 +66,7 @@ class AccountsListViewModel: ObservableObject {
maxId: nil)) maxId: nil))
case let .favoritedBy(statusId): case let .favoritedBy(statusId):
(accounts, link) = try await client.getWithLink(endpoint: Statuses.favoritedBy(id: statusId, (accounts, link) = try await client.getWithLink(endpoint: Statuses.favoritedBy(id: statusId,
maxId: nil)) maxId: nil))
} }
nextPageId = link?.maxId nextPageId = link?.maxId
relationships = try await client.get(endpoint: relationships = try await client.get(endpoint:
@ -95,7 +95,7 @@ class AccountsListViewModel: ObservableObject {
maxId: nextPageId)) maxId: nextPageId))
case let .favoritedBy(statusId): case let .favoritedBy(statusId):
(newAccounts, link) = try await client.getWithLink(endpoint: Statuses.favoritedBy(id: statusId, (newAccounts, link) = try await client.getWithLink(endpoint: Statuses.favoritedBy(id: statusId,
maxId: nextPageId)) maxId: nextPageId))
} }
accounts.append(contentsOf: newAccounts) accounts.append(contentsOf: newAccounts)
let newRelationships: [Relationship] = let newRelationships: [Relationship] =

View file

@ -31,7 +31,8 @@ public struct AppAccount: Codable, Identifiable {
public init(server: String, public init(server: String,
accountName: String?, accountName: String?,
oauthToken: OauthToken? = nil) { oauthToken: OauthToken? = nil)
{
self.server = server self.server = server
self.accountName = accountName self.accountName = accountName
self.oauthToken = oauthToken self.oauthToken = oauthToken

View file

@ -54,10 +54,18 @@ public struct AppAccountsSelectorView: View {
@ViewBuilder @ViewBuilder
private var labelView: some View { private var labelView: some View {
if let avatar = currentAccount.account?.avatar { Group {
AvatarView(url: avatar, size: avatarSize) if let avatar = currentAccount.account?.avatar {
} else { AvatarView(url: avatar, size: avatarSize)
EmptyView() } else {
EmptyView()
}
}.overlay(alignment: .topTrailing) {
if !currentAccount.followRequests.isEmpty {
Circle()
.fill(Color.red)
.frame(width: 9, height: 9)
}
} }
} }

View file

@ -1,9 +1,9 @@
import SwiftUI
import Models
import DesignSystem import DesignSystem
import Network
import Env import Env
import Models
import Network
import NukeUI import NukeUI
import SwiftUI
public struct ConversationDetailView: View { public struct ConversationDetailView: View {
private enum Constants { private enum Constants {
@ -72,7 +72,8 @@ public struct ConversationDetailView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
if viewModel.conversation.accounts.count == 1, if viewModel.conversation.accounts.count == 1,
let account = viewModel.conversation.accounts.first { let account = viewModel.conversation.accounts.first
{
EmojiTextApp(.init(stringValue: account.safeDisplayName), emojis: account.emojis) EmojiTextApp(.init(stringValue: account.safeDisplayName), emojis: account.emojis)
.font(.scaledHeadline) .font(.scaledHeadline)
} else { } else {

View file

@ -27,15 +27,13 @@ class ConversationDetailViewModel: ObservableObject {
isLoadingMessages = false isLoadingMessages = false
messages.insert(contentsOf: context.ancestors, at: 0) messages.insert(contentsOf: context.ancestors, at: 0)
messages.append(contentsOf: context.descendants) messages.append(contentsOf: context.descendants)
} catch { } catch {}
}
} }
func postMessage() async { func postMessage() async {
guard let client else { return } guard let client else { return }
isSendingMessage = true isSendingMessage = true
var finalText = conversation.accounts.map{ "@\($0.acct)" }.joined(separator: " ") var finalText = conversation.accounts.map { "@\($0.acct)" }.joined(separator: " ")
finalText += " " finalText += " "
finalText += newMessageText finalText += newMessageText
let data = StatusData(status: finalText, let data = StatusData(status: finalText,
@ -55,14 +53,17 @@ class ConversationDetailViewModel: ObservableObject {
func handleEvent(event: any StreamEvent) { func handleEvent(event: any StreamEvent) {
if let event = event as? StreamEventStatusUpdate, if let event = event as? StreamEventStatusUpdate,
let index = messages.firstIndex(where: { $0.id == event.status.id }) { let index = messages.firstIndex(where: { $0.id == event.status.id })
{
messages[index] = event.status messages[index] = event.status
} else if let event = event as? StreamEventDelete, } else if let event = event as? StreamEventDelete,
let index = messages.firstIndex(where: { $0.id == event.status }) { let index = messages.firstIndex(where: { $0.id == event.status })
{
messages.remove(at: index) messages.remove(at: index)
} else if let event = event as? StreamEventConversation, } else if let event = event as? StreamEventConversation,
event.conversation.id == conversation.id { event.conversation.id == conversation.id
self.conversation = event.conversation {
conversation = event.conversation
appendNewStatus(status: conversation.lastStatus) appendNewStatus(status: conversation.lastStatus)
} }
} }

View file

@ -1,9 +1,9 @@
import SwiftUI
import Env
import DesignSystem import DesignSystem
import Network import Env
import Models import Models
import Network
import NukeUI import NukeUI
import SwiftUI
struct ConversationMessageView: View { struct ConversationMessageView: View {
@EnvironmentObject private var quickLook: QuickLook @EnvironmentObject private var quickLook: QuickLook
@ -104,7 +104,7 @@ struct ConversationMessageView: View {
withAnimation { withAnimation {
isLiked = status.favourited == true isLiked = status.favourited == true
} }
} catch { } } catch {}
} }
} label: { } label: {
Label(isLiked ? "status.action.unfavorite" : "status.action.favorite", Label(isLiked ? "status.action.unfavorite" : "status.action.favorite",

View file

@ -8,9 +8,10 @@ public class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
} }
public func scene(_ scene: UIScene, public func scene(_ scene: UIScene,
willConnectTo session: UISceneSession, willConnectTo _: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) { options _: UIScene.ConnectionOptions)
{
guard let windowScene = scene as? UIWindowScene else { return } guard let windowScene = scene as? UIWindowScene else { return }
self.window = windowScene.keyWindow window = windowScene.keyWindow
} }
} }

View file

@ -34,6 +34,6 @@ public struct EmojiTextApp: View {
private func isRTL() -> Bool { private func isRTL() -> Bool {
// Arabic, Hebrew, Persian, Urdu, Kurdish, Azeri, Dhivehi // Arabic, Hebrew, Persian, Urdu, Kurdish, Azeri, Dhivehi
return ["ar", "he", "fa", "ur", "ku", "az", "dv"].contains(self.language) return ["ar", "he", "fa", "ur", "ku", "az", "dv"].contains(language)
} }
} }

View file

@ -0,0 +1,41 @@
import Env
import Models
import SwiftUI
public struct FollowRequestButtons: View {
@EnvironmentObject private var currentAccount: CurrentAccount
let account: Account
let requestUpdated: (() -> Void)?
public init(account: Account, requestUpdated: (() -> Void)? = nil) {
self.account = account
self.requestUpdated = requestUpdated
}
public var body: some View {
HStack {
Button {
Task {
await currentAccount.acceptFollowerRequest(id: account.id)
requestUpdated?()
}
} label: {
Text("account.follow-request.accept")
.frame(maxWidth: .infinity)
}
Button {
Task {
await currentAccount.rejectFollowerRequest(id: account.id)
requestUpdated?()
}
} label: {
Text("account.follow-request.reject")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.bordered)
.disabled(currentAccount.isUpdating)
.padding(.top, 4)
}
}

View file

@ -7,6 +7,8 @@ public class CurrentAccount: ObservableObject {
@Published public private(set) var account: Account? @Published public private(set) var account: Account?
@Published public private(set) var lists: [List] = [] @Published public private(set) var lists: [List] = []
@Published public private(set) var tags: [Tag] = [] @Published public private(set) var tags: [Tag] = []
@Published public private(set) var followRequests: [Account] = []
@Published public private(set) var isUpdating: Bool = false
private var client: Client? private var client: Client?
@ -28,6 +30,7 @@ public class CurrentAccount: ObservableObject {
group.addTask { await self.fetchCurrentAccount() } group.addTask { await self.fetchCurrentAccount() }
group.addTask { await self.fetchLists() } group.addTask { await self.fetchLists() }
group.addTask { await self.fetchFollowedTags() } group.addTask { await self.fetchFollowedTags() }
group.addTask { await self.fetchFollowerRequests() }
} }
} }
@ -103,4 +106,37 @@ public class CurrentAccount: ObservableObject {
return nil return nil
} }
} }
public func fetchFollowerRequests() async {
guard let client else { return }
do {
followRequests = try await client.get(endpoint: FollowRequests.list)
} catch {
followRequests = []
}
}
public func acceptFollowerRequest(id: String) async {
guard let client else { return }
do {
isUpdating = true
defer {
isUpdating = false
}
_ = try await client.post(endpoint: FollowRequests.accept(id: id))
await fetchFollowerRequests()
} catch {}
}
public func rejectFollowerRequest(id: String) async {
guard let client else { return }
do {
isUpdating = true
defer {
isUpdating = false
}
_ = try await client.post(endpoint: FollowRequests.reject(id: id))
await fetchFollowerRequests()
} catch {}
}
} }

View file

@ -71,7 +71,6 @@ public class RouterPath: ObservableObject {
// That is on the same host as the person that posted the tag, // That is on the same host as the person that posted the tag,
// i.e. not a link that matches the pattern but elsewhere on the internet // i.e. not a link that matches the pattern but elsewhere on the internet
// In those circumstances, hijack the link and goto the tags page instead // In those circumstances, hijack the link and goto the tags page instead
navigate(to: .hashTag(tag: tag, account: nil)) navigate(to: .hashTag(tag: tag, account: nil))
return .handled return .handled
} else if let mention = status.mentions.first(where: { $0.url == url }) { } else if let mention = status.mentions.first(where: { $0.url == url }) {

View file

@ -146,7 +146,7 @@ public class Client: ObservableObject, Equatable {
logResponseOnError(httpResponse: httpResponse, data: data) logResponseOnError(httpResponse: httpResponse, data: data)
do { do {
return try decoder.decode(Entity.self, from: data) return try decoder.decode(Entity.self, from: data)
} catch let error { } catch {
if let serverError = try? decoder.decode(ServerError.self, from: data) { if let serverError = try? decoder.decode(ServerError.self, from: data) {
throw serverError throw serverError
} }

View file

@ -109,7 +109,7 @@ public enum Accounts: Endpoint {
case let .follow(_, notify, reblogs): case let .follow(_, notify, reblogs):
return [ return [
.init(name: "notify", value: notify ? "true" : "false"), .init(name: "notify", value: notify ? "true" : "false"),
.init(name: "reblogs", value: reblogs ? "true" : "false") .init(name: "reblogs", value: reblogs ? "true" : "false"),
] ]
case let .familiarFollowers(withAccount): case let .familiarFollowers(withAccount):
return [.init(name: "id[]", value: withAccount)] return [.init(name: "id[]", value: withAccount)]

View file

@ -0,0 +1,22 @@
import Foundation
public enum FollowRequests: Endpoint {
case list
case accept(id: String)
case reject(id: String)
public func path() -> String {
switch self {
case .list:
return "follow_requests"
case let .accept(id):
return "follow_requests/\(id)/authorize"
case let .reject(id):
return "follow_requests/\(id)/reject"
}
}
public func queryItems() -> [URLQueryItem]? {
nil
}
}

View file

@ -6,6 +6,7 @@ import Status
import SwiftUI import SwiftUI
struct NotificationRowView: View { struct NotificationRowView: View {
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var routerPath: RouterPath @EnvironmentObject private var routerPath: RouterPath
@Environment(\.redactionReasons) private var reasons @Environment(\.redactionReasons) private var reasons
@ -19,6 +20,11 @@ struct NotificationRowView: View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
makeMainLabel(type: type) makeMainLabel(type: type)
makeContent(type: type) makeContent(type: type)
if type == .follow_request,
currentAccount.followRequests.map(\.id).contains(notification.account.id)
{
FollowRequestButtons(account: notification.account)
}
} }
} }
} else { } else {

View file

@ -1,8 +1,8 @@
import Foundation import Foundation
import UIKit
import Models import Models
import PhotosUI import PhotosUI
import SwiftUI import SwiftUI
import UIKit
struct StatusEditorMediaContainer: Identifiable { struct StatusEditorMediaContainer: Identifiable {
let id = UUID().uuidString let id = UUID().uuidString

View file

@ -1,9 +1,9 @@
import AVKit
import DesignSystem import DesignSystem
import Env import Env
import Models import Models
import NukeUI import NukeUI
import SwiftUI import SwiftUI
import AVKit
struct StatusEditorMediaView: View { struct StatusEditorMediaView: View {
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@ -108,7 +108,8 @@ struct StatusEditorMediaView: View {
ProgressView() ProgressView()
} }
if mediaAttachement.url != nil, if mediaAttachement.url != nil,
mediaAttachement.supportedType == .video || mediaAttachement.supportedType == .gifv { mediaAttachement.supportedType == .video || mediaAttachement.supportedType == .gifv
{
Image(systemName: "play.fill") Image(systemName: "play.fill")
.font(.headline) .font(.headline)
.tint(.white) .tint(.white)

View file

@ -1,8 +1,8 @@
import Foundation import Foundation
import PhotosUI
import SwiftUI
import UIKit import UIKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
import SwiftUI
import PhotosUI
@MainActor @MainActor
enum StatusEditorUTTypeSupported: String, CaseIterable { enum StatusEditorUTTypeSupported: String, CaseIterable {
@ -58,7 +58,7 @@ enum StatusEditorUTTypeSupported: String, CaseIterable {
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
_ = item.loadTransferable(type: MovieFileTranseferable.self) { result in _ = item.loadTransferable(type: MovieFileTranseferable.self) { result in
switch result { switch result {
case .success(let success): case let .success(success):
continuation.resume(with: .success(success)) continuation.resume(with: .success(success))
case .failure: case .failure:
continuation.resume(with: .success(nil)) continuation.resume(with: .success(nil))
@ -77,7 +77,7 @@ struct MovieFileTranseferable: Transferable {
} importing: { received in } importing: { received in
let copy = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).\(received.file.pathExtension)") let copy = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).\(received.file.pathExtension)")
try FileManager.default.copyItem(at: received.file, to: copy) try FileManager.default.copyItem(at: received.file, to: copy)
return Self.init(url: copy) return Self(url: copy)
} }
} }
} }
@ -95,14 +95,14 @@ struct ImageFileTranseferable: Transferable {
} importing: { received in } importing: { received in
let copy = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).\(received.file.pathExtension)") let copy = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).\(received.file.pathExtension)")
try FileManager.default.copyItem(at: received.file, to: copy) try FileManager.default.copyItem(at: received.file, to: copy)
return Self.init(url: copy) return Self(url: copy)
} }
} }
} }
extension URL { public extension URL {
public func mimeType() -> String { func mimeType() -> String {
if let mimeType = UTType(filenameExtension: self.pathExtension)?.preferredMIMEType { if let mimeType = UTType(filenameExtension: pathExtension)?.preferredMIMEType {
return mimeType return mimeType
} else { } else {
return "application/octet-stream" return "application/octet-stream"

View file

@ -96,10 +96,10 @@ public struct StatusEditorView: View {
.alert("Error while posting", .alert("Error while posting",
isPresented: $viewModel.showPostingErrorAlert, isPresented: $viewModel.showPostingErrorAlert,
actions: { actions: {
Button("Ok") { } Button("Ok") {}
}, message: { }, message: {
Text(viewModel.postingError ?? "") Text(viewModel.postingError ?? "")
}) })
.toolbar { .toolbar {
if preferences.isOpenAIEnabled { if preferences.isOpenAIEnabled {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {

View file

@ -143,7 +143,7 @@ public class StatusEditorViewModel: ObservableObject {
} }
isPosting = false isPosting = false
return postStatus return postStatus
} catch let error { } catch {
if let error = error as? Models.ServerError { if let error = error as? Models.ServerError {
postingError = error.error postingError = error.error
showPostingErrorAlert = true showPostingErrorAlert = true
@ -468,7 +468,8 @@ public class StatusEditorViewModel: ObservableObject {
} }
if var imageFile = file as? ImageFileTranseferable, if var imageFile = file as? ImageFileTranseferable,
let image = imageFile.image { let image = imageFile.image
{
medias.append(.init(image: image, medias.append(.init(image: image,
movieTransferable: nil, movieTransferable: nil,
mediaAttachment: nil, mediaAttachment: nil,
@ -511,22 +512,24 @@ public class StatusEditorViewModel: ObservableObject {
do { do {
if let index = indexOf(container: newContainer) { if let index = indexOf(container: newContainer) {
if let image = originalContainer.image, if let image = originalContainer.image,
let data = image.jpegData(compressionQuality: 0.90) { let data = image.jpegData(compressionQuality: 0.90)
{
let uploadedMedia = try await uploadMedia(data: data, mimeType: "image/jpeg") let uploadedMedia = try await uploadMedia(data: data, mimeType: "image/jpeg")
mediasImages[index] = .init(image: mode.isInShareExtension ? originalContainer.image : nil, mediasImages[index] = .init(image: mode.isInShareExtension ? originalContainer.image : nil,
movieTransferable: nil, movieTransferable: nil,
mediaAttachment: uploadedMedia, mediaAttachment: uploadedMedia,
error: nil) error: nil)
if let uploadedMedia, uploadedMedia.url == nil { if let uploadedMedia, uploadedMedia.url == nil {
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia) scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
} }
} else if let videoURL = originalContainer.movieTransferable?.url, } else if let videoURL = originalContainer.movieTransferable?.url,
let data = try? Data(contentsOf: videoURL) { let data = try? Data(contentsOf: videoURL)
{
let uploadedMedia = try await uploadMedia(data: data, mimeType: videoURL.mimeType()) let uploadedMedia = try await uploadMedia(data: data, mimeType: videoURL.mimeType())
mediasImages[index] = .init(image: mode.isInShareExtension ? originalContainer.image : nil, mediasImages[index] = .init(image: mode.isInShareExtension ? originalContainer.image : nil,
movieTransferable: originalContainer.movieTransferable, movieTransferable: originalContainer.movieTransferable,
mediaAttachment: uploadedMedia, mediaAttachment: uploadedMedia,
error: nil) error: nil)
if let uploadedMedia, uploadedMedia.url == nil { if let uploadedMedia, uploadedMedia.url == nil {
scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia) scheduleAsyncMediaRefresh(mediaAttachement: uploadedMedia)
} }
@ -547,13 +550,14 @@ public class StatusEditorViewModel: ObservableObject {
Task { Task {
repeat { repeat {
if let client, if let client,
let index = mediasImages.firstIndex(where: { $0.mediaAttachment?.id == mediaAttachement.id }) { let index = mediasImages.firstIndex(where: { $0.mediaAttachment?.id == mediaAttachement.id })
{
guard mediasImages[index].mediaAttachment?.url == nil else { guard mediasImages[index].mediaAttachment?.url == nil else {
return return
} }
do { do {
let newAttachement: MediaAttachment = try await client.get(endpoint: Media.media(id: mediaAttachement.id, let newAttachement: MediaAttachment = try await client.get(endpoint: Media.media(id: mediaAttachement.id,
description: nil)) description: nil))
if newAttachement.url != nil { if newAttachement.url != nil {
let oldContainer = mediasImages[index] let oldContainer = mediasImages[index]
mediasImages[index] = .init(image: oldContainer.image, mediasImages[index] = .init(image: oldContainer.image,
@ -561,10 +565,10 @@ public class StatusEditorViewModel: ObservableObject {
mediaAttachment: newAttachement, mediaAttachment: newAttachement,
error: nil) error: nil)
} }
} catch { } } catch {}
} }
try? await Task.sleep(for: .seconds(5)) try? await Task.sleep(for: .seconds(5))
} while (!Task.isCancelled) } while !Task.isCancelled
} }
} }

View file

@ -77,7 +77,8 @@ struct StatusRowContextMenu: View {
} }
if let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier, if let lang = preferences.serverPreferences?.postLanguage ?? Locale.current.language.languageCode?.identifier,
viewModel.status.language != lang { viewModel.status.language != lang
{
Button { Button {
Task { Task {
await viewModel.translate(userLang: lang) await viewModel.translate(userLang: lang)

View file

@ -365,7 +365,8 @@ public struct StatusRowView: View {
private var embedStatusView: some View { private var embedStatusView: some View {
if !reasons.contains(.placeholder) { if !reasons.contains(.placeholder) {
if !viewModel.isCompact, !viewModel.isEmbedLoading, if !viewModel.isCompact, !viewModel.isEmbedLoading,
let embed = viewModel.embeddedStatus { let embed = viewModel.embeddedStatus
{
StatusEmbeddedView(status: embed) StatusEmbeddedView(status: embed)
} else if viewModel.isEmbedLoading, !viewModel.isCompact { } else if viewModel.isEmbedLoading, !viewModel.isCompact {
StatusEmbeddedView(status: .placeholder()) StatusEmbeddedView(status: .placeholder())