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

@ -12,9 +12,9 @@ import Timeline
@main @main
struct IceCubesApp: App { struct IceCubesApp: App {
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@StateObject private var appAccountsManager = AppAccountsManager.shared @StateObject private var appAccountsManager = AppAccountsManager.shared
@StateObject private var currentInstance = CurrentInstance.shared @StateObject private var currentInstance = CurrentInstance.shared
@StateObject private var currentAccount = CurrentAccount.shared @StateObject private var currentAccount = CurrentAccount.shared
@ -23,18 +23,18 @@ struct IceCubesApp: App {
@StateObject private var quickLook = QuickLook() @StateObject private var quickLook = QuickLook()
@StateObject private var theme = Theme.shared @StateObject private var theme = Theme.shared
@StateObject private var sidebarRouterPath = RouterPath() @StateObject private var sidebarRouterPath = RouterPath()
@State private var selectedTab: Tab = .timeline @State private var selectedTab: Tab = .timeline
@State private var selectSidebarItem: Tab? = .timeline @State private var selectSidebarItem: Tab? = .timeline
@State private var popToRootTab: Tab = .other @State private var popToRootTab: Tab = .other
@State private var sideBarLoadedTabs: Set<Tab> = Set() @State private var sideBarLoadedTabs: Set<Tab> = Set()
private let feedbackGenerator = UISelectionFeedbackGenerator() private let feedbackGenerator = UISelectionFeedbackGenerator()
private var availableTabs: [Tab] { private var availableTabs: [Tab] {
appAccountsManager.currentClient.isAuth ? Tab.loggedInTabs() : Tab.loggedOutTab() appAccountsManager.currentClient.isAuth ? Tab.loggedInTabs() : Tab.loggedOutTab()
} }
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
appView appView
@ -71,7 +71,7 @@ struct IceCubesApp: App {
} }
} }
} }
@ViewBuilder @ViewBuilder
private var appView: some View { private var appView: some View {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
@ -80,14 +80,14 @@ struct IceCubesApp: App {
tabBarView tabBarView
} }
} }
private func badgeFor(tab: Tab) -> Int { private func badgeFor(tab: Tab) -> Int {
if tab == .notifications && selectedTab != tab { if tab == .notifications && selectedTab != tab {
return watcher.unreadNotificationsCount + userPreferences.pushNotificationsCount return watcher.unreadNotificationsCount + userPreferences.pushNotificationsCount
} }
return 0 return 0
} }
private var sidebarView: some View { private var sidebarView: some View {
SideBarView(selectedTab: $selectedTab, SideBarView(selectedTab: $selectedTab,
popToRootTab: $popToRootTab, popToRootTab: $popToRootTab,
@ -116,7 +116,7 @@ struct IceCubesApp: App {
sideBarLoadedTabs.removeAll() sideBarLoadedTabs.removeAll()
} }
} }
private var tabBarView: some View { private var tabBarView: some View {
TabView(selection: .init(get: { TabView(selection: .init(get: {
selectedTab selectedTab
@ -142,14 +142,14 @@ struct IceCubesApp: App {
} }
} }
} }
private func setNewClientsInEnv(client: Client) { private func setNewClientsInEnv(client: Client) {
currentAccount.setClient(client: client) currentAccount.setClient(client: client)
currentInstance.setClient(client: client) currentInstance.setClient(client: client)
userPreferences.setClient(client: client) userPreferences.setClient(client: client)
watcher.setClient(client: client) watcher.setClient(client: client)
} }
private func handleScenePhase(scenePhase: ScenePhase) { private func handleScenePhase(scenePhase: ScenePhase) {
switch scenePhase { switch scenePhase {
case .background: case .background:
@ -166,16 +166,16 @@ struct IceCubesApp: App {
break break
} }
} }
private func setupRevenueCat() { private func setupRevenueCat() {
Purchases.logLevel = .error Purchases.logLevel = .error
Purchases.configure(withAPIKey: "appl_JXmiRckOzXXTsHKitQiicXCvMQi") Purchases.configure(withAPIKey: "appl_JXmiRckOzXXTsHKitQiicXCvMQi")
} }
private func refreshPushSubs() { private func refreshPushSubs() {
PushNotificationsService.shared.requestPushNotifications() PushNotificationsService.shared.requestPushNotifications()
} }
@CommandsBuilder @CommandsBuilder
private var appMenu: some Commands { private var appMenu: some Commands {
CommandGroup(replacing: .newItem) { CommandGroup(replacing: .newItem) {
@ -202,29 +202,29 @@ struct IceCubesApp: App {
class AppDelegate: NSObject, UIApplicationDelegate { class AppDelegate: NSObject, UIApplicationDelegate {
let themeObserver = ThemeObserverViewController(nibName: nil, bundle: nil) let themeObserver = ThemeObserverViewController(nibName: nil, bundle: nil)
func application(_: UIApplication, func application(_: UIApplication,
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
{ {
try? AVAudioSession.sharedInstance().setCategory(.ambient, options: .mixWithOthers) try? AVAudioSession.sharedInstance().setCategory(.ambient, options: .mixWithOthers)
return true return true
} }
func application(_: UIApplication, func application(_: UIApplication,
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
@ -236,7 +236,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
class ThemeObserverViewController: UIViewController { class ThemeObserverViewController: UIViewController {
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection) super.traitCollectionDidChange(previousTraitCollection)
print(traitCollection.userInterfaceStyle.rawValue) print(traitCollection.userInterfaceStyle.rawValue)
} }
} }

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

@ -100,7 +100,7 @@ struct SettingsTabs: View {
} }
.listRowBackground(theme.primaryBackgroundColor) .listRowBackground(theme.primaryBackgroundColor)
} }
private var otherSections: some View { private var otherSections: some View {
Section("settings.section.other") { Section("settings.section.other") {
if !ProcessInfo.processInfo.isiOSAppOnMac { if !ProcessInfo.processInfo.isiOSAppOnMac {

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 {
@ -51,7 +51,7 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(bestAttemptContent) contentHandler(bestAttemptContent)
return return
} }
bestAttemptContent.title = notification.title bestAttemptContent.title = notification.title
if AppAccountsManager.shared.availableAccounts.count > 1 { if AppAccountsManager.shared.availableAccounts.count > 1 {
bestAttemptContent.subtitle = bestAttemptContent.userInfo["i"] as? String ?? "" bestAttemptContent.subtitle = bestAttemptContent.userInfo["i"] as? String ?? ""

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

@ -111,7 +111,7 @@ public struct FollowButton: View {
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.disabled(viewModel.isUpdating) .disabled(viewModel.isUpdating)
Button { Button {
Task { Task {
await viewModel.toggleReboosts() await viewModel.toggleReboosts()

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

@ -9,7 +9,7 @@ public struct AppAccountsSelectorView: View {
@ObservedObject var routerPath: RouterPath @ObservedObject var routerPath: RouterPath
@State private var accountsViewModel: [AppAccountViewModel] = [] @State private var accountsViewModel: [AppAccountViewModel] = []
let feedbackGenerator = UIImpactFeedbackGenerator() let feedbackGenerator = UIImpactFeedbackGenerator()
private let accountCreationEnabled: Bool private let accountCreationEnabled: Bool
@ -22,7 +22,7 @@ public struct AppAccountsSelectorView: View {
self.routerPath = routerPath self.routerPath = routerPath
self.accountCreationEnabled = accountCreationEnabled self.accountCreationEnabled = accountCreationEnabled
self.avatarSize = avatarSize self.avatarSize = avatarSize
feedbackGenerator.prepare() feedbackGenerator.prepare()
} }
@ -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)
}
} }
} }
@ -73,7 +81,7 @@ public struct AppAccountsSelectorView: View {
} else { } else {
appAccounts.currentAccount = viewModel.appAccount appAccounts.currentAccount = viewModel.appAccount
} }
feedbackGenerator.impactOccurred(intensity: 0.7) feedbackGenerator.impactOccurred(intensity: 0.7)
} label: { } label: {
HStack { HStack {

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 {
@ -16,18 +16,18 @@ public struct ConversationDetailView: View {
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@EnvironmentObject private var watcher: StreamWatcher @EnvironmentObject private var watcher: StreamWatcher
@StateObject private var viewModel: ConversationDetailViewModel @StateObject private var viewModel: ConversationDetailViewModel
@FocusState private var isMessageFieldFocused: Bool @FocusState private var isMessageFieldFocused: Bool
@State private var scrollProxy: ScrollViewProxy? @State private var scrollProxy: ScrollViewProxy?
@State private var didAppear: Bool = false @State private var didAppear: Bool = false
public init(conversation: Conversation) { public init(conversation: Conversation) {
_viewModel = StateObject(wrappedValue: .init(conversation: conversation)) _viewModel = StateObject(wrappedValue: .init(conversation: conversation))
} }
public var body: some View { public var body: some View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
@ -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 {
@ -92,7 +93,7 @@ public struct ConversationDetailView: View {
} }
} }
} }
private var loadingView: some View { private var loadingView: some View {
ForEach(Status.placeholders()) { message in ForEach(Status.placeholders()) { message in
ConversationMessageView(message: message, conversation: viewModel.conversation) ConversationMessageView(message: message, conversation: viewModel.conversation)
@ -100,14 +101,14 @@ public struct ConversationDetailView: View {
.shimmering() .shimmering()
} }
} }
private var bottomAnchorView: some View { private var bottomAnchorView: some View {
Rectangle() Rectangle()
.fill(Color.clear) .fill(Color.clear)
.frame(height: 40) .frame(height: 40)
.id(Constants.bottomAnchor) .id(Constants.bottomAnchor)
} }
private var inputTextView: some View { private var inputTextView: some View {
VStack { VStack {
HStack(alignment: .bottom, spacing: 8) { HStack(alignment: .bottom, spacing: 8) {
@ -117,7 +118,7 @@ public struct ConversationDetailView: View {
Image(systemName: "plus") Image(systemName: "plus")
} }
.padding(.bottom, 6) .padding(.bottom, 6)
TextField("conversations.new.message.placeholder", text: $viewModel.newMessageText, axis: .vertical) TextField("conversations.new.message.placeholder", text: $viewModel.newMessageText, axis: .vertical)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.focused($isMessageFieldFocused) .focused($isMessageFieldFocused)

View file

@ -8,18 +8,18 @@ class ConversationDetailViewModel: ObservableObject {
var client: Client? var client: Client?
var conversation: Conversation var conversation: Conversation
@Published var isLoadingMessages: Bool = true @Published var isLoadingMessages: Bool = true
@Published var messages: [Status] = [] @Published var messages: [Status] = []
@Published var isSendingMessage: Bool = false @Published var isSendingMessage: Bool = false
@Published var newMessageText: String = "" @Published var newMessageText: String = ""
init(conversation: Conversation) { init(conversation: Conversation) {
self.conversation = conversation self.conversation = conversation
messages = [conversation.lastStatus] messages = [conversation.lastStatus]
} }
func fetchMessages() async { func fetchMessages() async {
guard let client, let lastMessageId = messages.last?.id else { return } guard let client, let lastMessageId = messages.last?.id else { return }
do { do {
@ -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,
@ -52,21 +50,24 @@ class ConversationDetailViewModel: ObservableObject {
isSendingMessage = false isSendingMessage = false
} }
} }
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)
} }
} }
private func appendNewStatus(status: Status) { private func appendNewStatus(status: Status) {
if !messages.contains(where: { $0.id == status.id }) { if !messages.contains(where: { $0.id == status.id }) {
messages.append(status) messages.append(status)

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
@ -11,12 +11,12 @@ struct ConversationMessageView: View {
@EnvironmentObject private var currentAccount: CurrentAccount @EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
let message: Status let message: Status
let conversation: Conversation let conversation: Conversation
@State private var isLiked: Bool = false @State private var isLiked: Bool = false
var body: some View { var body: some View {
let isOwnMessage = message.account.id == currentAccount.account?.id let isOwnMessage = message.account.id == currentAccount.account?.id
VStack { VStack {
@ -49,18 +49,18 @@ struct ConversationMessageView: View {
.contextMenu { .contextMenu {
contextMenu contextMenu
} }
if !isOwnMessage { if !isOwnMessage {
Spacer() Spacer()
} }
} }
ForEach(message.mediaAttachments) { media in ForEach(message.mediaAttachments) { media in
makeMediaView(media) makeMediaView(media)
.padding(.leading, isOwnMessage ? 24 : 0) .padding(.leading, isOwnMessage ? 24 : 0)
.padding(.trailing, isOwnMessage ? 0 : 24) .padding(.trailing, isOwnMessage ? 0 : 24)
} }
if message.id == conversation.lastStatus.id { if message.id == conversation.lastStatus.id {
HStack { HStack {
if isOwnMessage { if isOwnMessage {
@ -79,7 +79,7 @@ struct ConversationMessageView: View {
isLiked = message.favourited == true isLiked = message.favourited == true
} }
} }
@ViewBuilder @ViewBuilder
private var contextMenu: some View { private var contextMenu: some View {
Button { Button {
@ -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",
@ -119,7 +119,7 @@ struct ConversationMessageView: View {
} }
} }
} }
private func makeMediaView(_ attachement: MediaAttachment) -> some View { private func makeMediaView(_ attachement: MediaAttachment) -> some View {
LazyImage(url: attachement.url) { state in LazyImage(url: attachement.url) { state in
if let image = state.image { if let image = state.image {
@ -144,7 +144,7 @@ struct ConversationMessageView: View {
} }
} }
} }
private var likeView: some View { private var likeView: some View {
HStack { HStack {
Spacer() Spacer()

View file

@ -2,15 +2,16 @@ import UIKit
public class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate { public class SceneDelegate: NSObject, ObservableObject, UIWindowSceneDelegate {
public var window: UIWindow? public var window: UIWindow?
public var windowWidth: CGFloat { public var windowWidth: CGFloat {
window?.bounds.size.width ?? UIScreen.main.bounds.size.width window?.bounds.size.width ?? UIScreen.main.bounds.size.width
} }
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

@ -31,9 +31,9 @@ public struct EmojiTextApp: View {
.environment(\.layoutDirection, isRTL() ? .rightToLeft : .leftToRight) .environment(\.layoutDirection, isRTL() ? .rightToLeft : .leftToRight)
} }
} }
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

@ -19,7 +19,7 @@ public extension View {
public struct StatusEditorToolbarItem: ToolbarContent { public struct StatusEditorToolbarItem: ToolbarContent {
@EnvironmentObject private var routerPath: RouterPath @EnvironmentObject private var routerPath: RouterPath
let visibility: Models.Visibility let visibility: Models.Visibility
let feedbackGenerator = UISelectionFeedbackGenerator() let feedbackGenerator = UISelectionFeedbackGenerator()

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

@ -4,11 +4,11 @@ public struct Poll: Codable, Equatable, Hashable {
public static func == (lhs: Poll, rhs: Poll) -> Bool { public static func == (lhs: Poll, rhs: Poll) -> Bool {
lhs.id == rhs.id lhs.id == rhs.id
} }
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
hasher.combine(id) hasher.combine(id)
} }
public struct Option: Identifiable, Codable { public struct Option: Identifiable, Codable {
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case title, votesCount case title, votesCount

View file

@ -58,11 +58,11 @@ public struct Status: AnyStatus, Decodable, Identifiable, Equatable, Hashable {
public var viewId: String { public var viewId: String {
id + createdAt + (editedAt ?? "") id + createdAt + (editedAt ?? "")
} }
public static func == (lhs: Status, rhs: Status) -> Bool { public static func == (lhs: Status, rhs: Status) -> Bool {
lhs.id == rhs.id lhs.id == rhs.id
} }
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
hasher.combine(id) hasher.combine(id)
} }
@ -132,11 +132,11 @@ public struct ReblogStatus: AnyStatus, Decodable, Identifiable, Equatable, Hasha
public var viewId: String { public var viewId: String {
id + createdAt + (editedAt ?? "") id + createdAt + (editedAt ?? "")
} }
public static func == (lhs: ReblogStatus, rhs: ReblogStatus) -> Bool { public static func == (lhs: ReblogStatus, rhs: ReblogStatus) -> Bool {
lhs.id == rhs.id lhs.id == rhs.id
} }
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
hasher.combine(id) hasher.combine(id)
} }

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
@ -39,7 +39,7 @@ struct StatusEditorMediaView: View {
.preferredColorScheme(theme.selectedScheme == .dark ? .dark : .light) .preferredColorScheme(theme.selectedScheme == .dark ? .dark : .light)
} }
} }
private func makeVideoAttachement(container: StatusEditorMediaContainer) -> some View { private func makeVideoAttachement(container: StatusEditorMediaContainer) -> some View {
ZStack(alignment: .center) { ZStack(alignment: .center) {
placeholderView placeholderView
@ -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)
@ -147,7 +148,7 @@ struct StatusEditorMediaView: View {
.background(.thinMaterial) .background(.thinMaterial)
.cornerRadius(8) .cornerRadius(8)
} }
private var placeholderView: some View { private var placeholderView: some View {
Rectangle() Rectangle()
.foregroundColor(theme.secondaryBackgroundColor) .foregroundColor(theme.secondaryBackgroundColor)

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 {
@ -12,7 +12,7 @@ enum StatusEditorUTTypeSupported: String, CaseIterable {
case image = "public.image" case image = "public.image"
case jpeg = "public.jpeg" case jpeg = "public.jpeg"
case png = "public.png" case png = "public.png"
case video = "public.video" case video = "public.video"
case movie = "public.movie" case movie = "public.movie"
case mp4 = "public.mpeg-4" case mp4 = "public.mpeg-4"
@ -21,7 +21,7 @@ enum StatusEditorUTTypeSupported: String, CaseIterable {
static func types() -> [UTType] { static func types() -> [UTType] {
[.url, .text, .plainText, .image, .jpeg, .png, .video, .mpeg4Movie, .gif, .movie] [.url, .text, .plainText, .image, .jpeg, .png, .video, .mpeg4Movie, .gif, .movie]
} }
var isVideo: Bool { var isVideo: Bool {
switch self { switch self {
case .video, .movie, .mp4, .gif: case .video, .movie, .mp4, .gif:
@ -53,12 +53,12 @@ enum StatusEditorUTTypeSupported: String, CaseIterable {
return nil return nil
} }
} }
private func getVideoTransferable(item: NSItemProvider) async -> MovieFileTranseferable? { private func getVideoTransferable(item: NSItemProvider) async -> MovieFileTranseferable? {
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))
@ -70,39 +70,39 @@ enum StatusEditorUTTypeSupported: String, CaseIterable {
struct MovieFileTranseferable: Transferable { struct MovieFileTranseferable: Transferable {
let url: URL let url: URL
static var transferRepresentation: some TransferRepresentation { static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .movie) { movie in FileRepresentation(contentType: .movie) { movie in
SentTransferredFile(movie.url) SentTransferredFile(movie.url)
} 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)
} }
} }
} }
struct ImageFileTranseferable: Transferable { struct ImageFileTranseferable: Transferable {
let url: URL let url: URL
lazy var data: Data? = try? Data(contentsOf: url) lazy var data: Data? = try? Data(contentsOf: url)
lazy var compressedData: Data? = image?.jpegData(compressionQuality: 0.90) lazy var compressedData: Data? = image?.jpegData(compressionQuality: 0.90)
lazy var image: UIImage? = UIImage(data: data ?? Data()) lazy var image: UIImage? = UIImage(data: data ?? Data())
static var transferRepresentation: some TransferRepresentation { static var transferRepresentation: some TransferRepresentation {
FileRepresentation(contentType: .image) { image in FileRepresentation(contentType: .image) { image in
SentTransferredFile(image.url) SentTransferredFile(image.url)
} 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

@ -24,7 +24,7 @@ public class StatusEditorViewModel: ObservableObject {
private var urlLengthAdjustments: Int = 0 private var urlLengthAdjustments: Int = 0
private let maxLengthOfUrl = 23 private let maxLengthOfUrl = 23
private var spoilerTextCount: Int { private var spoilerTextCount: Int {
spoilerOn ? spoilerText.utf16.count : 0 spoilerOn ? spoilerText.utf16.count : 0
} }
@ -61,7 +61,7 @@ public class StatusEditorViewModel: ObservableObject {
@Published var embeddedStatus: Status? @Published var embeddedStatus: Status?
@Published var customEmojis: [Emoji] = [] @Published var customEmojis: [Emoji] = []
@Published var postingError: String? @Published var postingError: String?
@Published var showPostingErrorAlert: Bool = false @Published var showPostingErrorAlert: Bool = false
@ -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
@ -466,9 +466,10 @@ public class StatusEditorViewModel: ObservableObject {
mediaAttachment: nil, mediaAttachment: nil,
error: error)) error: error))
} }
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,
@ -480,7 +481,7 @@ public class StatusEditorViewModel: ObservableObject {
error: nil)) error: nil))
} }
} }
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.mediasImages = medias self?.mediasImages = medias
self?.processMediasToUpload() self?.processMediasToUpload()
@ -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)
} }
@ -542,18 +545,19 @@ public class StatusEditorViewModel: ObservableObject {
} }
} }
} }
private func scheduleAsyncMediaRefresh(mediaAttachement: MediaAttachment) { private func scheduleAsyncMediaRefresh(mediaAttachement: MediaAttachment) {
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

@ -30,7 +30,7 @@ public struct StatusMediaPreviewView: View {
} }
return sceneDelegate.windowWidth return sceneDelegate.windowWidth
} }
var appLayoutWidth: CGFloat { var appLayoutWidth: CGFloat {
let avatarColumnWidth = theme.avatarPosition == .leading ? AvatarView.Size.status.size.width + .statusColumnsSpacing : 0 let avatarColumnWidth = theme.avatarPosition == .leading ? AvatarView.Size.status.size.width + .statusColumnsSpacing : 0
var sidebarWidth: CGFloat = 0 var sidebarWidth: CGFloat = 0
@ -39,7 +39,7 @@ public struct StatusMediaPreviewView: View {
} }
return (.layoutPadding * 2) + avatarColumnWidth + sidebarWidth return (.layoutPadding * 2) + avatarColumnWidth + sidebarWidth
} }
private var imageMaxHeight: CGFloat { private var imageMaxHeight: CGFloat {
if isNotifications { if isNotifications {
if UIDevice.current.userInterfaceIdiom == .pad { if UIDevice.current.userInterfaceIdiom == .pad {

View file

@ -75,9 +75,10 @@ struct StatusRowContextMenu: View {
} label: { } label: {
Label("status.action.copy-text", systemImage: "doc.on.doc") Label("status.action.copy-text", systemImage: "doc.on.doc")
} }
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

@ -243,15 +243,15 @@ public struct StatusRowView: View {
}) })
Spacer() Spacer()
} }
makeTranslateView(status: status) makeTranslateView(status: status)
if let poll = status.poll { if let poll = status.poll {
StatusPollView(poll: poll, status: status) StatusPollView(poll: poll, status: status)
} }
embedStatusView embedStatusView
makeMediasView(status: status) makeMediasView(status: status)
.accessibilityHidden(!viewModel.isFocused) .accessibilityHidden(!viewModel.isFocused)
makeCardView(status: status) makeCardView(status: status)
@ -314,7 +314,7 @@ public struct StatusRowView: View {
} }
} }
} }
if let translation = viewModel.translation, !viewModel.isLoadingTranslation { if let translation = viewModel.translation, !viewModel.isLoadingTranslation {
GroupBox { GroupBox {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@ -360,12 +360,13 @@ public struct StatusRowView: View {
StatusCardView(card: card) StatusCardView(card: card)
} }
} }
@ViewBuilder @ViewBuilder
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())