mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-24 01:01:02 +00:00
Format all code using swift-format
This commit is contained in:
parent
42f880aaa8
commit
35e8cb6512
246 changed files with 5509 additions and 3908 deletions
|
@ -6,11 +6,10 @@
|
|||
//
|
||||
|
||||
import MobileCoreServices
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
import Models
|
||||
import Network
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// Sample code was sending this from a thread to another, let asume @Sendable for this
|
||||
extension NSExtensionContext: @unchecked @retroactive Sendable {}
|
||||
|
@ -79,11 +78,17 @@ extension ActionRequestHandler {
|
|||
guard itemProvider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else {
|
||||
continue
|
||||
}
|
||||
guard let dictionary = try await itemProvider.loadItem(forTypeIdentifier: UTType.propertyList.identifier) as? [String: Any] else {
|
||||
guard
|
||||
let dictionary = try await itemProvider.loadItem(
|
||||
forTypeIdentifier: UTType.propertyList.identifier) as? [String: Any]
|
||||
else {
|
||||
throw Error.loadedItemHasWrongType
|
||||
}
|
||||
let input = dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as! [String: Any]? ?? [:]
|
||||
guard let absoluteStringUrl = input["url"] as? String, let url = URL(string: absoluteStringUrl) else {
|
||||
let input =
|
||||
dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as! [String: Any]? ?? [:]
|
||||
guard let absoluteStringUrl = input["url"] as? String,
|
||||
let url = URL(string: absoluteStringUrl)
|
||||
else {
|
||||
throw Error.urlNotFound
|
||||
}
|
||||
return url
|
||||
|
@ -96,7 +101,8 @@ extension ActionRequestHandler {
|
|||
private func output(wrapping deeplink: URL) -> [NSExtensionItem] {
|
||||
let results = ["deeplink": deeplink.absoluteString]
|
||||
let dictionary = [NSExtensionJavaScriptFinalizeArgumentKey: results]
|
||||
let provider = NSItemProvider(item: dictionary as NSDictionary, typeIdentifier: UTType.propertyList.identifier)
|
||||
let provider = NSItemProvider(
|
||||
item: dictionary as NSDictionary, typeIdentifier: UTType.propertyList.identifier)
|
||||
let item = NSExtensionItem()
|
||||
item.attachments = [provider]
|
||||
return [item]
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import AVFoundation
|
||||
import Account
|
||||
import AppAccount
|
||||
import AVFoundation
|
||||
import DesignSystem
|
||||
import Env
|
||||
import KeychainSwift
|
||||
|
@ -32,7 +32,8 @@ struct AppView: View {
|
|||
#if os(visionOS)
|
||||
tabBarView
|
||||
#else
|
||||
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac
|
||||
{
|
||||
sidebarView
|
||||
} else {
|
||||
tabBarView
|
||||
|
@ -54,11 +55,15 @@ struct AppView: View {
|
|||
|
||||
@ViewBuilder
|
||||
var tabBarView: some View {
|
||||
TabView(selection: .init(get: {
|
||||
selectedTab
|
||||
}, set: { newTab in
|
||||
updateTab(with: newTab)
|
||||
})) {
|
||||
TabView(
|
||||
selection: .init(
|
||||
get: {
|
||||
selectedTab
|
||||
},
|
||||
set: { newTab in
|
||||
updateTab(with: newTab)
|
||||
})
|
||||
) {
|
||||
ForEach(availableTabs) { tab in
|
||||
tab.makeContentView(selectedTab: $selectedTab)
|
||||
.tabItem {
|
||||
|
@ -78,17 +83,19 @@ struct AppView: View {
|
|||
.withSheetDestinations(sheetDestinations: $appRouterPath.presentedSheet)
|
||||
.environment(\.selectedTabScrollToTop, selectedTabScrollToTop)
|
||||
}
|
||||
|
||||
|
||||
private func updateTab(with newTab: AppTab) {
|
||||
if newTab == .post {
|
||||
#if os(visionOS)
|
||||
openWindow(value: WindowDestinationEditor.newStatusEditor(visibility: userPreferences.postVisibility))
|
||||
openWindow(
|
||||
value: WindowDestinationEditor.newStatusEditor(visibility: userPreferences.postVisibility)
|
||||
)
|
||||
#else
|
||||
appRouterPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
HapticManager.shared.fireHaptic(.tabSelection)
|
||||
SoundEffectManager.shared.playSound(.tabSelection)
|
||||
|
||||
|
@ -100,13 +107,13 @@ struct AppView: View {
|
|||
} else {
|
||||
selectedTabScrollToTop = -1
|
||||
}
|
||||
|
||||
|
||||
selectedTab = newTab
|
||||
}
|
||||
|
||||
private func badgeFor(tab: AppTab) -> Int {
|
||||
if tab == .notifications, selectedTab != tab,
|
||||
let token = appAccountsManager.currentAccount.oauthToken
|
||||
let token = appAccountsManager.currentAccount.oauthToken
|
||||
{
|
||||
return watcher.unreadNotificationsCount + (userPreferences.notificationsCount[token] ?? 0)
|
||||
}
|
||||
|
@ -115,29 +122,32 @@ struct AppView: View {
|
|||
|
||||
#if !os(visionOS)
|
||||
var sidebarView: some View {
|
||||
SideBarView(selectedTab: .init(get: {
|
||||
selectedTab
|
||||
}, set: { newTab in
|
||||
updateTab(with: newTab)
|
||||
}), tabs: availableTabs)
|
||||
{
|
||||
SideBarView(
|
||||
selectedTab: .init(
|
||||
get: {
|
||||
selectedTab
|
||||
},
|
||||
set: { newTab in
|
||||
updateTab(with: newTab)
|
||||
}), tabs: availableTabs
|
||||
) {
|
||||
HStack(spacing: 0) {
|
||||
if #available(iOS 18.0, *) {
|
||||
baseTabView
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
.introspect(.tabView, on: .iOS(.v17, .v18)) { (tabview: UITabBarController) in
|
||||
tabview.sidebar.isHidden = true
|
||||
}
|
||||
#else
|
||||
.tabViewStyle(.tabBarOnly)
|
||||
#endif
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
.introspect(.tabView, on: .iOS(.v17, .v18)) { (tabview: UITabBarController) in
|
||||
tabview.sidebar.isHidden = true
|
||||
}
|
||||
#else
|
||||
.tabViewStyle(.tabBarOnly)
|
||||
#endif
|
||||
} else {
|
||||
baseTabView
|
||||
}
|
||||
if horizontalSizeClass == .regular,
|
||||
appAccountsManager.currentClient.isAuth,
|
||||
userPreferences.showiPadSecondaryColumn
|
||||
appAccountsManager.currentClient.isAuth,
|
||||
userPreferences.showiPadSecondaryColumn
|
||||
{
|
||||
Divider().edgesIgnoringSafeArea(.all)
|
||||
notificationsSecondaryColumn
|
||||
|
@ -148,7 +158,7 @@ struct AppView: View {
|
|||
.environment(\.selectedTabScrollToTop, selectedTabScrollToTop)
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
private var baseTabView: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
ForEach(availableTabs) { tab in
|
||||
|
@ -162,17 +172,16 @@ struct AppView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.introspect(.tabView, on: .iOS(.v17, .v18)) { (tabview: UITabBarController) in
|
||||
tabview.tabBar.isHidden = horizontalSizeClass == .regular
|
||||
tabview.customizableViewControllers = []
|
||||
tabview.moreNavigationController.isNavigationBarHidden = true
|
||||
}
|
||||
.introspect(.tabView, on: .iOS(.v17, .v18)) { (tabview: UITabBarController) in
|
||||
tabview.tabBar.isHidden = horizontalSizeClass == .regular
|
||||
tabview.customizableViewControllers = []
|
||||
tabview.moreNavigationController.isNavigationBarHidden = true
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var notificationsSecondaryColumn: some View {
|
||||
NotificationsTab(selectedTab: .constant(.notifications)
|
||||
, lockedType: nil)
|
||||
NotificationsTab(selectedTab: .constant(.notifications), lockedType: nil)
|
||||
.environment(\.isSecondaryColumn, true)
|
||||
.frame(maxWidth: .secondaryColumnWidth)
|
||||
.id(appAccountsManager.currentAccount.id)
|
||||
|
|
|
@ -17,9 +17,12 @@ extension IceCubesApp {
|
|||
.keyboardShortcut("n", modifiers: .shift)
|
||||
Button("menu.new-post") {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
openWindow(value: WindowDestinationEditor.newStatusEditor(visibility: userPreferences.postVisibility))
|
||||
openWindow(
|
||||
value: WindowDestinationEditor.newStatusEditor(
|
||||
visibility: userPreferences.postVisibility))
|
||||
#else
|
||||
appRouterPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
|
||||
appRouterPath.presentedSheet = .newStatusEditor(
|
||||
visibility: userPreferences.postVisibility)
|
||||
#endif
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
|
|
|
@ -27,15 +27,19 @@ extension IceCubesApp {
|
|||
.environment(\.isSupporter, isSupporter)
|
||||
.sheet(item: $quickLook.selectedMediaAttachment) { selectedMediaAttachment in
|
||||
if #available(iOS 18.0, *) {
|
||||
MediaUIView(selectedAttachment: selectedMediaAttachment,
|
||||
attachments: quickLook.mediaAttachments)
|
||||
MediaUIView(
|
||||
selectedAttachment: selectedMediaAttachment,
|
||||
attachments: quickLook.mediaAttachments
|
||||
)
|
||||
.presentationBackground(.ultraThinMaterial)
|
||||
.presentationCornerRadius(16)
|
||||
.presentationSizing(.page)
|
||||
.withEnvironments()
|
||||
} else {
|
||||
MediaUIView(selectedAttachment: selectedMediaAttachment,
|
||||
attachments: quickLook.mediaAttachments)
|
||||
MediaUIView(
|
||||
selectedAttachment: selectedMediaAttachment,
|
||||
attachments: quickLook.mediaAttachments
|
||||
)
|
||||
.presentationBackground(.ultraThinMaterial)
|
||||
.presentationCornerRadius(16)
|
||||
.withEnvironments()
|
||||
|
@ -44,9 +48,11 @@ extension IceCubesApp {
|
|||
.onChange(of: pushNotificationsService.handledNotification) { _, newValue in
|
||||
if newValue != nil {
|
||||
pushNotificationsService.handledNotification = nil
|
||||
if appAccountsManager.currentAccount.oauthToken?.accessToken != newValue?.account.token.accessToken,
|
||||
let account = appAccountsManager.availableAccounts.first(where:
|
||||
{ $0.oauthToken?.accessToken == newValue?.account.token.accessToken })
|
||||
if appAccountsManager.currentAccount.oauthToken?.accessToken
|
||||
!= newValue?.account.token.accessToken,
|
||||
let account = appAccountsManager.availableAccounts.first(where: {
|
||||
$0.oauthToken?.accessToken == newValue?.account.token.accessToken
|
||||
})
|
||||
{
|
||||
appAccountsManager.currentAccount = account
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
|
@ -79,9 +85,9 @@ extension IceCubesApp {
|
|||
}
|
||||
}
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.windowResize()
|
||||
.windowResize()
|
||||
#elseif os(visionOS)
|
||||
.defaultSize(width: 800, height: 1200)
|
||||
.defaultSize(width: 800, height: 1200)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -122,8 +128,9 @@ extension IceCubesApp {
|
|||
Group {
|
||||
switch destination.wrappedValue {
|
||||
case let .mediaViewer(attachments, selectedAttachment):
|
||||
MediaUIView(selectedAttachment: selectedAttachment,
|
||||
attachments: attachments)
|
||||
MediaUIView(
|
||||
selectedAttachment: selectedAttachment,
|
||||
attachments: attachments)
|
||||
case .none:
|
||||
EmptyView()
|
||||
}
|
||||
|
@ -141,19 +148,23 @@ extension IceCubesApp {
|
|||
private func handleIntent(_: any AppIntent) {
|
||||
if let postIntent = appIntentService.handledIntent?.intent as? PostIntent {
|
||||
#if os(visionOS) || os(macOS)
|
||||
openWindow(value: WindowDestinationEditor.prefilledStatusEditor(text: postIntent.content ?? "",
|
||||
visibility: userPreferences.postVisibility))
|
||||
openWindow(
|
||||
value: WindowDestinationEditor.prefilledStatusEditor(
|
||||
text: postIntent.content ?? "",
|
||||
visibility: userPreferences.postVisibility))
|
||||
#else
|
||||
appRouterPath.presentedSheet = .prefilledStatusEditor(text: postIntent.content ?? "",
|
||||
visibility: userPreferences.postVisibility)
|
||||
appRouterPath.presentedSheet = .prefilledStatusEditor(
|
||||
text: postIntent.content ?? "",
|
||||
visibility: userPreferences.postVisibility)
|
||||
#endif
|
||||
} else if let tabIntent = appIntentService.handledIntent?.intent as? TabIntent {
|
||||
selectedTab = tabIntent.tab.toAppTab
|
||||
} else if let imageIntent = appIntentService.handledIntent?.intent as? PostImageIntent,
|
||||
let urls = imageIntent.images?.compactMap({ $0.fileURL })
|
||||
let urls = imageIntent.images?.compactMap({ $0.fileURL })
|
||||
{
|
||||
appRouterPath.presentedSheet = .imageURL(urls: urls,
|
||||
visibility: userPreferences.postVisibility)
|
||||
appRouterPath.presentedSheet = .imageURL(
|
||||
urls: urls,
|
||||
visibility: userPreferences.postVisibility)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import AVFoundation
|
||||
import Account
|
||||
import AppAccount
|
||||
import AVFoundation
|
||||
import DesignSystem
|
||||
import Env
|
||||
import KeychainSwift
|
||||
|
@ -36,9 +36,9 @@ struct IceCubesApp: App {
|
|||
|
||||
init() {
|
||||
#if DEBUG
|
||||
// Enable "GraphReuseLogging" for debugging purpose
|
||||
// subsystem: "com.apple.SwiftUI" category: "GraphReuse"
|
||||
UserDefaults.standard.register(defaults: ["com.apple.SwiftUI.GraphReuseLogging": true])
|
||||
// Enable "GraphReuseLogging" for debugging purpose
|
||||
// subsystem: "com.apple.SwiftUI" category: "GraphReuse"
|
||||
UserDefaults.standard.register(defaults: ["com.apple.SwiftUI.GraphReuseLogging": true])
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -53,7 +53,8 @@ struct IceCubesApp: App {
|
|||
userPreferences.setClient(client: client)
|
||||
Task {
|
||||
await currentInstance.fetchCurrentInstance()
|
||||
watcher.setClient(client: client, instanceStreamingURL: currentInstance.instance?.urls?.streamingApi)
|
||||
watcher.setClient(
|
||||
client: client, instanceStreamingURL: currentInstance.instance?.urls?.streamingApi)
|
||||
watcher.watch(streams: [.user, .direct])
|
||||
}
|
||||
}
|
||||
|
@ -65,7 +66,8 @@ struct IceCubesApp: App {
|
|||
case .active:
|
||||
watcher.watch(streams: [.user, .direct])
|
||||
UNUserNotificationCenter.current().setBadgeCount(0)
|
||||
userPreferences.reloadNotificationsCount(tokens: appAccountsManager.availableAccounts.compactMap(\.oauthToken))
|
||||
userPreferences.reloadNotificationsCount(
|
||||
tokens: appAccountsManager.availableAccounts.compactMap(\.oauthToken))
|
||||
Task {
|
||||
await userPreferences.refreshServerPreferences()
|
||||
}
|
||||
|
@ -90,9 +92,10 @@ struct IceCubesApp: App {
|
|||
}
|
||||
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(_: UIApplication,
|
||||
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool
|
||||
{
|
||||
func application(
|
||||
_: UIApplication,
|
||||
didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.ambient, options: .mixWithOthers)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
|
||||
|
@ -102,9 +105,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
return true
|
||||
}
|
||||
|
||||
func application(_: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)
|
||||
{
|
||||
func application(
|
||||
_: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
) {
|
||||
PushNotificationsService.shared.pushToken = deviceToken
|
||||
Task {
|
||||
PushNotificationsService.shared.setAccounts(accounts: AppAccountsManager.shared.pushAccounts)
|
||||
|
@ -114,12 +118,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
|
||||
func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError _: Error) {}
|
||||
|
||||
func application(_: UIApplication, didReceiveRemoteNotification _: [AnyHashable: Any]) async -> UIBackgroundFetchResult {
|
||||
UserPreferences.shared.reloadNotificationsCount(tokens: AppAccountsManager.shared.availableAccounts.compactMap(\.oauthToken))
|
||||
func application(_: UIApplication, didReceiveRemoteNotification _: [AnyHashable: Any]) async
|
||||
-> UIBackgroundFetchResult
|
||||
{
|
||||
UserPreferences.shared.reloadNotificationsCount(
|
||||
tokens: AppAccountsManager.shared.availableAccounts.compactMap(\.oauthToken))
|
||||
return .noData
|
||||
}
|
||||
|
||||
func 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)
|
||||
if connectingSceneSession.role == .windowApplication {
|
||||
configuration.delegateClass = SceneDelegate.self
|
||||
|
|
|
@ -23,9 +23,10 @@ public struct ReportView: View {
|
|||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("report.comment.placeholder",
|
||||
text: $commentText,
|
||||
axis: .vertical)
|
||||
TextField(
|
||||
"report.comment.placeholder",
|
||||
text: $commentText,
|
||||
axis: .vertical)
|
||||
}
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
|
||||
|
@ -40,33 +41,35 @@ public struct ReportView: View {
|
|||
.background(theme.secondaryBackgroundColor)
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
isSendingReport = true
|
||||
Task {
|
||||
do {
|
||||
let _: ReportSent =
|
||||
try await client.post(endpoint: Statuses.report(accountId: status.account.id,
|
||||
statusId: status.id,
|
||||
comment: commentText))
|
||||
dismiss()
|
||||
isSendingReport = false
|
||||
} catch {
|
||||
isSendingReport = false
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if isSendingReport {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("report.action.send")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
isSendingReport = true
|
||||
Task {
|
||||
do {
|
||||
let _: ReportSent =
|
||||
try await client.post(
|
||||
endpoint: Statuses.report(
|
||||
accountId: status.account.id,
|
||||
statusId: status.id,
|
||||
comment: commentText))
|
||||
dismiss()
|
||||
isSendingReport = false
|
||||
} catch {
|
||||
isSendingReport = false
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if isSendingReport {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("report.action.send")
|
||||
}
|
||||
}
|
||||
|
||||
CancelToolbarItem()
|
||||
}
|
||||
|
||||
CancelToolbarItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import AppAccount
|
||||
import DesignSystem
|
||||
import Env
|
||||
import Models
|
||||
import Observation
|
||||
import SafariServices
|
||||
import SwiftUI
|
||||
import AppAccount
|
||||
import WebKit
|
||||
|
||||
extension View {
|
||||
|
@ -27,21 +27,25 @@ private struct SafariRouter: ViewModifier {
|
|||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
// Open internal URL.
|
||||
guard !isSecondaryColumn else { return .discarded }
|
||||
return routerPath.handle(url: url)
|
||||
})
|
||||
.environment(
|
||||
\.openURL,
|
||||
OpenURLAction { url in
|
||||
// Open internal URL.
|
||||
guard !isSecondaryColumn else { return .discarded }
|
||||
return routerPath.handle(url: url)
|
||||
}
|
||||
)
|
||||
.onOpenURL { url in
|
||||
// Open external URL (from icecubesapp://)
|
||||
guard !isSecondaryColumn else { return }
|
||||
if url.absoluteString == "icecubesapp://subclub" {
|
||||
#if !os(visionOS)
|
||||
safariManager.dismiss()
|
||||
safariManager.dismiss()
|
||||
#endif
|
||||
return
|
||||
}
|
||||
let urlString = url.absoluteString.replacingOccurrences(of: AppInfo.scheme, with: "https://")
|
||||
let urlString = url.absoluteString.replacingOccurrences(
|
||||
of: AppInfo.scheme, with: "https://")
|
||||
guard let url = URL(string: urlString), url.host != nil else { return }
|
||||
_ = routerPath.handleDeepLink(url: url)
|
||||
}
|
||||
|
@ -57,17 +61,18 @@ private struct SafariRouter: ViewModifier {
|
|||
return .handled
|
||||
}
|
||||
} else if url.query()?.contains("callback=") == false,
|
||||
url.host() == AppInfo.premiumInstance,
|
||||
let accountName = appAccount.currentAccount.accountName {
|
||||
url.host() == AppInfo.premiumInstance,
|
||||
let accountName = appAccount.currentAccount.accountName
|
||||
{
|
||||
let newURL = url.appending(queryItems: [
|
||||
.init(name: "callback", value: "icecubesapp://subclub"),
|
||||
.init(name: "id", value: "@\(accountName)")
|
||||
.init(name: "id", value: "@\(accountName)"),
|
||||
])
|
||||
|
||||
|
||||
#if !os(visionOS)
|
||||
return safariManager.open(newURL)
|
||||
return safariManager.open(newURL)
|
||||
#else
|
||||
return .systemAction
|
||||
return .systemAction
|
||||
#endif
|
||||
}
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
|
@ -86,13 +91,13 @@ private struct SafariRouter: ViewModifier {
|
|||
#endif
|
||||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.background {
|
||||
WindowReader { window in
|
||||
safariManager.windowScene = window.windowScene
|
||||
#if !os(visionOS)
|
||||
.background {
|
||||
WindowReader { window in
|
||||
safariManager.windowScene = window.windowScene
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -123,7 +128,7 @@ private struct SafariRouter: ViewModifier {
|
|||
|
||||
return .handled
|
||||
}
|
||||
|
||||
|
||||
func dismiss() {
|
||||
viewController.presentedViewController?.dismiss(animated: true)
|
||||
window?.resignKey()
|
||||
|
|
|
@ -26,7 +26,7 @@ struct SideBarView<Content: View>: View {
|
|||
|
||||
private func badgeFor(tab: AppTab) -> Int {
|
||||
if tab == .notifications, selectedTab != tab,
|
||||
let token = appAccounts.currentAccount.oauthToken
|
||||
let token = appAccounts.currentAccount.oauthToken
|
||||
{
|
||||
return watcher.unreadNotificationsCount + (userPreferences.notificationsCount[token] ?? 0)
|
||||
}
|
||||
|
@ -36,8 +36,9 @@ struct SideBarView<Content: View>: View {
|
|||
private func makeIconForTab(tab: AppTab) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
HStack {
|
||||
SideBarIcon(systemIconName: tab.iconName,
|
||||
isSelected: tab == selectedTab)
|
||||
SideBarIcon(
|
||||
systemIconName: tab.iconName,
|
||||
isSelected: tab == selectedTab)
|
||||
if userPreferences.isSidebarExpanded {
|
||||
Text(tab.title)
|
||||
.font(.headline)
|
||||
|
@ -45,14 +46,19 @@ struct SideBarView<Content: View>: View {
|
|||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.frame(width: (userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth) - 24, height: 50)
|
||||
.background(tab == selectedTab ? theme.primaryBackgroundColor : .clear,
|
||||
in: RoundedRectangle(cornerRadius: 8))
|
||||
.frame(
|
||||
width: (userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth) - 24,
|
||||
height: 50
|
||||
)
|
||||
.background(
|
||||
tab == selectedTab ? theme.primaryBackgroundColor : .clear,
|
||||
in: RoundedRectangle(cornerRadius: 8)
|
||||
)
|
||||
.cornerRadius(8)
|
||||
.shadow(color: tab == selectedTab ? .black.opacity(0.2) : .clear, radius: 5)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(tab == selectedTab ? theme.labelColor.opacity(0.1) : .clear, lineWidth: 1)
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(tab == selectedTab ? theme.labelColor.opacity(0.1) : .clear, lineWidth: 1)
|
||||
)
|
||||
let badge = badgeFor(tab: tab)
|
||||
if badge > 0 {
|
||||
|
@ -76,7 +82,9 @@ struct SideBarView<Content: View>: View {
|
|||
private var postButton: some View {
|
||||
Button {
|
||||
#if targetEnvironment(macCatalyst) || os(visionOS)
|
||||
openWindow(value: WindowDestinationEditor.newStatusEditor(visibility: userPreferences.postVisibility))
|
||||
openWindow(
|
||||
value: WindowDestinationEditor.newStatusEditor(visibility: userPreferences.postVisibility)
|
||||
)
|
||||
#else
|
||||
routerPath.presentedSheet = .newStatusEditor(visibility: userPreferences.postVisibility)
|
||||
#endif
|
||||
|
@ -106,21 +114,25 @@ struct SideBarView<Content: View>: View {
|
|||
} label: {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
if userPreferences.isSidebarExpanded {
|
||||
AppAccountView(viewModel: .init(appAccount: account,
|
||||
isCompact: false,
|
||||
isInSettings: false),
|
||||
isParentPresented: .constant(false))
|
||||
AppAccountView(
|
||||
viewModel: .init(
|
||||
appAccount: account,
|
||||
isCompact: false,
|
||||
isInSettings: false),
|
||||
isParentPresented: .constant(false))
|
||||
} else {
|
||||
AppAccountView(viewModel: .init(appAccount: account,
|
||||
isCompact: true,
|
||||
isInSettings: false),
|
||||
isParentPresented: .constant(false))
|
||||
AppAccountView(
|
||||
viewModel: .init(
|
||||
appAccount: account,
|
||||
isCompact: true,
|
||||
isInSettings: false),
|
||||
isParentPresented: .constant(false))
|
||||
}
|
||||
if !userPreferences.isSidebarExpanded,
|
||||
showBadge,
|
||||
let token = account.oauthToken,
|
||||
let notificationsCount = userPreferences.notificationsCount[token],
|
||||
notificationsCount > 0
|
||||
showBadge,
|
||||
let token = account.oauthToken,
|
||||
let notificationsCount = userPreferences.notificationsCount[token],
|
||||
notificationsCount > 0
|
||||
{
|
||||
makeBadgeView(count: notificationsCount)
|
||||
}
|
||||
|
@ -128,10 +140,13 @@ struct SideBarView<Content: View>: View {
|
|||
.padding(.leading, userPreferences.isSidebarExpanded ? 16 : 0)
|
||||
}
|
||||
.help(accountButtonTitle(accountName: account.accountName))
|
||||
.frame(width: userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth, height: 50)
|
||||
.frame(
|
||||
width: userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth, height: 50
|
||||
)
|
||||
.padding(.vertical, 8)
|
||||
.background(selectedTab == .profile && account.id == appAccounts.currentAccount.id ?
|
||||
theme.secondaryBackgroundColor : .clear)
|
||||
.background(
|
||||
selectedTab == .profile && account.id == appAccounts.currentAccount.id
|
||||
? theme.secondaryBackgroundColor : .clear)
|
||||
}
|
||||
|
||||
private func accountButtonTitle(accountName: String?) -> LocalizedStringKey {
|
||||
|
@ -174,8 +189,9 @@ struct SideBarView<Content: View>: View {
|
|||
tabsView
|
||||
} else {
|
||||
ForEach(appAccounts.availableAccounts) { account in
|
||||
makeAccountButton(account: account,
|
||||
showBadge: account.id != appAccounts.currentAccount.id)
|
||||
makeAccountButton(
|
||||
account: account,
|
||||
showBadge: account.id != appAccounts.currentAccount.id)
|
||||
if account.id == appAccounts.currentAccount.id {
|
||||
tabsView
|
||||
}
|
||||
|
@ -186,21 +202,23 @@ struct SideBarView<Content: View>: View {
|
|||
.frame(width: userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.thinMaterial)
|
||||
.safeAreaInset(edge: .bottom, content: {
|
||||
HStack(spacing: 16) {
|
||||
postButton
|
||||
.padding(.vertical, 24)
|
||||
.padding(.leading, userPreferences.isSidebarExpanded ? 18 : 0)
|
||||
if userPreferences.isSidebarExpanded {
|
||||
Text("menu.new-post")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(theme.labelColor)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.safeAreaInset(
|
||||
edge: .bottom,
|
||||
content: {
|
||||
HStack(spacing: 16) {
|
||||
postButton
|
||||
.padding(.vertical, 24)
|
||||
.padding(.leading, userPreferences.isSidebarExpanded ? 18 : 0)
|
||||
if userPreferences.isSidebarExpanded {
|
||||
Text("menu.new-post")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(theme.labelColor)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth)
|
||||
.background(.thinMaterial)
|
||||
})
|
||||
.frame(width: userPreferences.isSidebarExpanded ? .sidebarWidthExpanded : .sidebarWidth)
|
||||
.background(.thinMaterial)
|
||||
})
|
||||
Divider().edgesIgnoringSafeArea(.all)
|
||||
}
|
||||
content()
|
||||
|
|
|
@ -57,7 +57,8 @@ struct NotificationsTab: View {
|
|||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
switch type {
|
||||
case .follow, .follow_request:
|
||||
routerPath.navigate(to: .accountDetailWithAccount(account: newValue.notification.account))
|
||||
routerPath.navigate(
|
||||
to: .accountDetailWithAccount(account: newValue.notification.account))
|
||||
default:
|
||||
if let status = newValue.notification.status {
|
||||
routerPath.navigate(to: .statusDetailWithStatus(status: status))
|
||||
|
@ -81,7 +82,9 @@ struct NotificationsTab: View {
|
|||
|
||||
private func clearNotifications() {
|
||||
if selectedTab == .notifications || isSecondaryColumn {
|
||||
if let token = appAccount.currentAccount.oauthToken, userPreferences.notificationsCount[token] ?? 0 > 0 {
|
||||
if let token = appAccount.currentAccount.oauthToken,
|
||||
userPreferences.notificationsCount[token] ?? 0 > 0
|
||||
{
|
||||
userPreferences.notificationsCount[token] = 0
|
||||
}
|
||||
if watcher.unreadNotificationsCount > 0 {
|
||||
|
|
|
@ -49,22 +49,26 @@ struct AboutView: View {
|
|||
Spacer()
|
||||
}
|
||||
#endif
|
||||
Link(destination: URL(string: "https://github.com/Dimillian/IceCubesApp/blob/main/PRIVACY.MD")!) {
|
||||
Link(
|
||||
destination: URL(string: "https://github.com/Dimillian/IceCubesApp/blob/main/PRIVACY.MD")!
|
||||
) {
|
||||
Label("settings.support.privacy-policy", systemImage: "lock")
|
||||
}
|
||||
|
||||
Link(destination: URL(string: "https://github.com/Dimillian/IceCubesApp/blob/main/TERMS.MD")!) {
|
||||
Link(
|
||||
destination: URL(string: "https://github.com/Dimillian/IceCubesApp/blob/main/TERMS.MD")!
|
||||
) {
|
||||
Label("settings.support.terms-of-use", systemImage: "checkmark.shield")
|
||||
}
|
||||
} footer: {
|
||||
Text("\(versionNumber)© 2024 Thomas Ricouard")
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
followAccountsSection
|
||||
|
||||
|
||||
Section("Telemetry") {
|
||||
Link(destination: .init(string: "https://telemetrydeck.com")!) {
|
||||
Label("Telemetry by TelemetryDeck", systemImage: "link")
|
||||
|
@ -74,35 +78,37 @@ struct AboutView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
Section {
|
||||
Text("""
|
||||
• [EmojiText](https://github.com/divadretlaw/EmojiText)
|
||||
Text(
|
||||
"""
|
||||
• [EmojiText](https://github.com/divadretlaw/EmojiText)
|
||||
|
||||
• [HTML2Markdown](https://gitlab.com/mflint/HTML2Markdown)
|
||||
• [HTML2Markdown](https://gitlab.com/mflint/HTML2Markdown)
|
||||
|
||||
• [KeychainSwift](https://github.com/evgenyneu/keychain-swift)
|
||||
• [KeychainSwift](https://github.com/evgenyneu/keychain-swift)
|
||||
|
||||
• [LRUCache](https://github.com/nicklockwood/LRUCache)
|
||||
• [LRUCache](https://github.com/nicklockwood/LRUCache)
|
||||
|
||||
• [Bodega](https://github.com/mergesort/Bodega)
|
||||
• [Bodega](https://github.com/mergesort/Bodega)
|
||||
|
||||
• [Nuke](https://github.com/kean/Nuke)
|
||||
• [Nuke](https://github.com/kean/Nuke)
|
||||
|
||||
• [SwiftSoup](https://github.com/scinfu/SwiftSoup.git)
|
||||
• [SwiftSoup](https://github.com/scinfu/SwiftSoup.git)
|
||||
|
||||
• [Atkinson Hyperlegible](https://github.com/googlefonts/atkinson-hyperlegible)
|
||||
• [Atkinson Hyperlegible](https://github.com/googlefonts/atkinson-hyperlegible)
|
||||
|
||||
• [OpenDyslexic](http://opendyslexic.org)
|
||||
• [OpenDyslexic](http://opendyslexic.org)
|
||||
|
||||
• [SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect)
|
||||
• [SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect)
|
||||
|
||||
• [RevenueCat](https://github.com/RevenueCat/purchases-ios)
|
||||
• [RevenueCat](https://github.com/RevenueCat/purchases-ios)
|
||||
|
||||
• [SFSafeSymbols](https://github.com/SFSafeSymbols/SFSafeSymbols)
|
||||
""")
|
||||
• [SFSafeSymbols](https://github.com/SFSafeSymbols/SFSafeSymbols)
|
||||
"""
|
||||
)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.scaledSubheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
@ -111,7 +117,7 @@ struct AboutView: View {
|
|||
.textCase(nil)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
.task {
|
||||
|
@ -122,9 +128,11 @@ struct AboutView: View {
|
|||
.scrollContentBackground(.hidden)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.navigationTitle(Text("settings.about.title"))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
.navigationTitle(Text("settings.about.title"))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.environment(
|
||||
\.openURL,
|
||||
OpenURLAction { url in
|
||||
routerPath.handle(url: url)
|
||||
})
|
||||
}
|
||||
|
@ -137,14 +145,14 @@ struct AboutView: View {
|
|||
AccountsListRow(viewModel: dimillianAccount)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
} else {
|
||||
Section {
|
||||
ProgressView()
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@ -152,13 +160,15 @@ struct AboutView: View {
|
|||
private func fetchAccounts() async {
|
||||
await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
let viewModel = try await fetchAccountViewModel(client, account: "dimillian@mastodon.social")
|
||||
let viewModel = try await fetchAccountViewModel(
|
||||
client, account: "dimillian@mastodon.social")
|
||||
await MainActor.run {
|
||||
dimillianAccount = viewModel
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
let viewModel = try await fetchAccountViewModel(client, account: "icecubesapp@mastodon.online")
|
||||
let viewModel = try await fetchAccountViewModel(
|
||||
client, account: "icecubesapp@mastodon.online")
|
||||
await MainActor.run {
|
||||
iceCubesAccount = viewModel
|
||||
}
|
||||
|
@ -166,9 +176,12 @@ struct AboutView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func fetchAccountViewModel(_ client: Client, account: String) async throws -> AccountsListRowViewModel {
|
||||
private func fetchAccountViewModel(_ client: Client, account: String) async throws
|
||||
-> AccountsListRowViewModel
|
||||
{
|
||||
let dimillianAccount: Account = try await client.get(endpoint: Accounts.lookup(name: account))
|
||||
let rel: [Relationship] = try await client.get(endpoint: Accounts.relationships(ids: [dimillianAccount.id]))
|
||||
let rel: [Relationship] = try await client.get(
|
||||
endpoint: Accounts.relationships(ids: [dimillianAccount.id]))
|
||||
return .init(account: dimillianAccount, relationShip: rel.first)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,20 +47,25 @@ struct AccountSettingsView: View {
|
|||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if let subscription = pushNotifications.subscriptions.first(where: { $0.account.token == appAccount.oauthToken }) {
|
||||
if let subscription = pushNotifications.subscriptions.first(where: {
|
||||
$0.account.token == appAccount.oauthToken
|
||||
}) {
|
||||
NavigationLink(destination: PushNotificationsView(subscription: subscription)) {
|
||||
Label("settings.general.push-notifications", systemImage: "bell.and.waves.left.and.right")
|
||||
Label(
|
||||
"settings.general.push-notifications", systemImage: "bell.and.waves.left.and.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
|
||||
Section {
|
||||
Label("settings.account.cached-posts-\(String(cachedPostsCount))", systemImage: "internaldrive")
|
||||
Label(
|
||||
"settings.account.cached-posts-\(String(cachedPostsCount))", systemImage: "internaldrive")
|
||||
Button("settings.account.action.delete-cache", role: .destructive) {
|
||||
Task {
|
||||
await timelineCache.clearCache(for: appAccountsManager.currentClient.id)
|
||||
cachedPostsCount = await timelineCache.cachedPostsCount(for: appAccountsManager.currentClient.id)
|
||||
cachedPostsCount = await timelineCache.cachedPostsCount(
|
||||
for: appAccountsManager.currentClient.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -81,7 +86,9 @@ struct AccountSettingsView: View {
|
|||
Task {
|
||||
let client = Client(server: appAccount.server, oauthToken: token)
|
||||
await timelineCache.clearCache(for: client.id)
|
||||
if let sub = pushNotifications.subscriptions.first(where: { $0.account.token == token }) {
|
||||
if let sub = pushNotifications.subscriptions.first(where: {
|
||||
$0.account.token == token
|
||||
}) {
|
||||
await sub.deleteSubscription()
|
||||
}
|
||||
appAccountsManager.delete(account: appAccount)
|
||||
|
@ -106,7 +113,8 @@ struct AccountSettingsView: View {
|
|||
}
|
||||
}
|
||||
.task {
|
||||
cachedPostsCount = await timelineCache.cachedPostsCount(for: appAccountsManager.currentClient.id)
|
||||
cachedPostsCount = await timelineCache.cachedPostsCount(
|
||||
for: appAccountsManager.currentClient.id)
|
||||
}
|
||||
.navigationTitle(account.safeDisplayName)
|
||||
#if !os(visionOS)
|
||||
|
|
|
@ -35,13 +35,14 @@ struct AddAccountView: View {
|
|||
private let instanceNamePublisher = PassthroughSubject<String, Never>()
|
||||
|
||||
private var sanitizedName: String {
|
||||
var name = instanceName
|
||||
var name =
|
||||
instanceName
|
||||
.replacingOccurrences(of: "http://", with: "")
|
||||
.replacingOccurrences(of: "https://", with: "")
|
||||
|
||||
if name.contains("@") {
|
||||
let parts = name.components(separatedBy: "@")
|
||||
name = parts[parts.count - 1] // [@]username@server.address.com
|
||||
name = parts[parts.count - 1] // [@]username@server.address.com
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
@ -56,9 +57,9 @@ struct AddAccountView: View {
|
|||
NavigationStack {
|
||||
Form {
|
||||
TextField("instance.url", text: $instanceName)
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
.keyboardType(.URL)
|
||||
.textContentType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
|
@ -87,73 +88,73 @@ struct AddAccountView: View {
|
|||
.background(theme.secondaryBackgroundColor)
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
#endif
|
||||
.toolbar {
|
||||
CancelToolbarItem()
|
||||
}
|
||||
.onAppear {
|
||||
isInstanceURLFieldFocused = true
|
||||
let instanceName = instanceName
|
||||
Task {
|
||||
let instances = await instanceSocialClient.fetchInstances(keyword: instanceName)
|
||||
withAnimation {
|
||||
self.instances = instances
|
||||
}
|
||||
.toolbar {
|
||||
CancelToolbarItem()
|
||||
}
|
||||
.onAppear {
|
||||
isInstanceURLFieldFocused = true
|
||||
let instanceName = instanceName
|
||||
Task {
|
||||
let instances = await instanceSocialClient.fetchInstances(keyword: instanceName)
|
||||
withAnimation {
|
||||
self.instances = instances
|
||||
}
|
||||
isSigninIn = false
|
||||
}
|
||||
.onChange(of: instanceName) {
|
||||
searchingTask.cancel()
|
||||
let instanceName = instanceName
|
||||
let instanceSocialClient = instanceSocialClient
|
||||
searchingTask = Task {
|
||||
try? await Task.sleep(for: .seconds(0.1))
|
||||
guard !Task.isCancelled else { return }
|
||||
isSigninIn = false
|
||||
}
|
||||
.onChange(of: instanceName) {
|
||||
searchingTask.cancel()
|
||||
let instanceName = instanceName
|
||||
let instanceSocialClient = instanceSocialClient
|
||||
searchingTask = Task {
|
||||
try? await Task.sleep(for: .seconds(0.1))
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
let instances = await instanceSocialClient.fetchInstances(keyword: instanceName)
|
||||
withAnimation {
|
||||
self.instances = instances
|
||||
}
|
||||
let instances = await instanceSocialClient.fetchInstances(keyword: instanceName)
|
||||
withAnimation {
|
||||
self.instances = instances
|
||||
}
|
||||
}
|
||||
|
||||
getInstanceDetailTask.cancel()
|
||||
getInstanceDetailTask = Task {
|
||||
try? await Task.sleep(for: .seconds(0.1))
|
||||
guard !Task.isCancelled else { return }
|
||||
getInstanceDetailTask.cancel()
|
||||
getInstanceDetailTask = Task {
|
||||
try? await Task.sleep(for: .seconds(0.1))
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
do {
|
||||
// bare bones preflight for domain validity
|
||||
let instanceDetailClient = Client(server: sanitizedName)
|
||||
if
|
||||
instanceDetailClient.server.contains("."),
|
||||
instanceDetailClient.server.last != "."
|
||||
{
|
||||
let instance: Instance = try await instanceDetailClient.get(endpoint: Instances.instance)
|
||||
withAnimation {
|
||||
self.instance = instance
|
||||
self.instanceName = sanitizedName // clean up the text box, principally to chop off the username if present so it's clear that you might not wind up siging in as the thing in the box
|
||||
}
|
||||
instanceFetchError = nil
|
||||
} else {
|
||||
instance = nil
|
||||
instanceFetchError = nil
|
||||
do {
|
||||
// bare bones preflight for domain validity
|
||||
let instanceDetailClient = Client(server: sanitizedName)
|
||||
if instanceDetailClient.server.contains("."),
|
||||
instanceDetailClient.server.last != "."
|
||||
{
|
||||
let instance: Instance = try await instanceDetailClient.get(
|
||||
endpoint: Instances.instance)
|
||||
withAnimation {
|
||||
self.instance = instance
|
||||
self.instanceName = sanitizedName // clean up the text box, principally to chop off the username if present so it's clear that you might not wind up siging in as the thing in the box
|
||||
}
|
||||
} catch _ as ServerError {
|
||||
instance = nil
|
||||
instanceFetchError = "account.add.error.instance-not-supported"
|
||||
} catch {
|
||||
instanceFetchError = nil
|
||||
} else {
|
||||
instance = nil
|
||||
instanceFetchError = nil
|
||||
}
|
||||
} catch _ as ServerError {
|
||||
instance = nil
|
||||
instanceFetchError = "account.add.error.instance-not-supported"
|
||||
} catch {
|
||||
instance = nil
|
||||
instanceFetchError = nil
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newValue in
|
||||
switch newValue {
|
||||
case .active:
|
||||
isSigninIn = false
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newValue in
|
||||
switch newValue {
|
||||
case .active:
|
||||
isSigninIn = false
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -183,7 +184,7 @@ struct AddAccountView: View {
|
|||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.tintColor)
|
||||
.listRowBackground(theme.tintColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -222,20 +223,23 @@ struct AddAccountView: View {
|
|||
.foregroundStyle(theme.tintColor)
|
||||
}
|
||||
.padding(.bottom, 5)
|
||||
Text(instance.info?.shortDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "")
|
||||
.foregroundStyle(Color.secondary)
|
||||
.lineLimit(10)
|
||||
Text(
|
||||
instance.info?.shortDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
?? ""
|
||||
)
|
||||
.foregroundStyle(Color.secondary)
|
||||
.lineLimit(10)
|
||||
}
|
||||
.font(.scaledFootnote)
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.background(theme.primaryBackgroundColor)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 10, leading: 0, bottom: 10, trailing: 0))
|
||||
.listRowSeparator(.hidden)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.background(theme.primaryBackgroundColor)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 10, leading: 0, bottom: 10, trailing: 0))
|
||||
.listRowSeparator(.hidden)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@ -273,8 +277,9 @@ struct AddAccountView: View {
|
|||
private func signIn() async {
|
||||
signInClient = .init(server: sanitizedName)
|
||||
if let oauthURL = try? await signInClient?.oauthURL(),
|
||||
let url = try? await webAuthenticationSession.authenticate(using: oauthURL,
|
||||
callbackURLScheme: AppInfo.scheme.replacingOccurrences(of: "://", with: ""))
|
||||
let url = try? await webAuthenticationSession.authenticate(
|
||||
using: oauthURL,
|
||||
callbackURLScheme: AppInfo.scheme.replacingOccurrences(of: "://", with: ""))
|
||||
{
|
||||
await continueSignIn(url: url)
|
||||
} else {
|
||||
|
@ -292,9 +297,11 @@ struct AddAccountView: View {
|
|||
let client = Client(server: client.server, oauthToken: oauthToken)
|
||||
let account: Account = try await client.get(endpoint: Accounts.verifyCredentials)
|
||||
Telemetry.signal("account.added")
|
||||
appAccountsManager.add(account: AppAccount(server: client.server,
|
||||
accountName: "\(account.acct)@\(client.server)",
|
||||
oauthToken: oauthToken))
|
||||
appAccountsManager.add(
|
||||
account: AppAccount(
|
||||
server: client.server,
|
||||
accountName: "\(account.acct)@\(client.server)",
|
||||
oauthToken: oauthToken))
|
||||
Task {
|
||||
pushNotifications.setAccounts(accounts: appAccountsManager.pushAccounts)
|
||||
await pushNotifications.updateSubscriptions(forceCreate: true)
|
||||
|
|
|
@ -30,11 +30,14 @@ struct ContentSettingsView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
Section("settings.content.sharing") {
|
||||
Picker("settings.content.sharing.share-button-behavior", selection: $userPreferences.shareButtonBehavior) {
|
||||
Picker(
|
||||
"settings.content.sharing.share-button-behavior",
|
||||
selection: $userPreferences.shareButtonBehavior
|
||||
) {
|
||||
ForEach(PreferredShareButtonBehavior.allCases, id: \.rawValue) { option in
|
||||
Text(option.title)
|
||||
.tag(option)
|
||||
|
@ -42,7 +45,7 @@ struct ContentSettingsView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
Section("settings.content.instance-settings") {
|
||||
|
@ -51,7 +54,7 @@ struct ContentSettingsView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
.onChange(of: userPreferences.useInstanceContentSettings) { _, newVal in
|
||||
if newVal {
|
||||
|
@ -85,21 +88,27 @@ struct ContentSettingsView: View {
|
|||
Text("settings.content.collapse-long-posts-hint")
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
Section("settings.content.posting") {
|
||||
Picker("settings.content.default-visibility", selection: $userPreferences.appDefaultPostVisibility) {
|
||||
Picker(
|
||||
"settings.content.default-visibility",
|
||||
selection: $userPreferences.appDefaultPostVisibility
|
||||
) {
|
||||
ForEach(Visibility.allCases, id: \.rawValue) { vis in
|
||||
Text(vis.title).tag(vis)
|
||||
}
|
||||
}
|
||||
.disabled(userPreferences.useInstanceContentSettings)
|
||||
|
||||
Picker("settings.content.default-reply-visibility", selection: $userPreferences.appDefaultReplyVisibility) {
|
||||
Picker(
|
||||
"settings.content.default-reply-visibility",
|
||||
selection: $userPreferences.appDefaultReplyVisibility
|
||||
) {
|
||||
ForEach(Visibility.allCases, id: \.rawValue) { vis in
|
||||
if UserPreferences.getIntOfVisibility(vis) <=
|
||||
UserPreferences.getIntOfVisibility(userPreferences.postVisibility)
|
||||
if UserPreferences.getIntOfVisibility(vis)
|
||||
<= UserPreferences.getIntOfVisibility(userPreferences.postVisibility)
|
||||
{
|
||||
Text(vis.title).tag(vis)
|
||||
}
|
||||
|
@ -119,7 +128,7 @@ struct ContentSettingsView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
Section("timeline.content-filter.title") {
|
||||
|
@ -137,7 +146,7 @@ struct ContentSettingsView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
.navigationTitle("settings.content.navigation-title")
|
||||
|
|
|
@ -29,9 +29,10 @@ struct DisplaySettingsView: View {
|
|||
|
||||
@State private var isFontSelectorPresented = false
|
||||
|
||||
private let previewStatusViewModel = StatusRowViewModel(status: Status.placeholder(forSettings: true, language: "la"),
|
||||
client: Client(server: ""),
|
||||
routerPath: RouterPath()) // translate from latin button
|
||||
private let previewStatusViewModel = StatusRowViewModel(
|
||||
status: Status.placeholder(forSettings: true, language: "la"),
|
||||
client: Client(server: ""),
|
||||
routerPath: RouterPath()) // translate from latin button
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
|
@ -53,30 +54,30 @@ struct DisplaySettingsView: View {
|
|||
.scrollContentBackground(.hidden)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.task(id: localValues.tintColor) {
|
||||
do { try await Task.sleep(for: .microseconds(500)) } catch {}
|
||||
theme.tintColor = localValues.tintColor
|
||||
}
|
||||
.task(id: localValues.primaryBackgroundColor) {
|
||||
do { try await Task.sleep(for: .microseconds(500)) } catch {}
|
||||
theme.primaryBackgroundColor = localValues.primaryBackgroundColor
|
||||
}
|
||||
.task(id: localValues.secondaryBackgroundColor) {
|
||||
do { try await Task.sleep(for: .microseconds(500)) } catch {}
|
||||
theme.secondaryBackgroundColor = localValues.secondaryBackgroundColor
|
||||
}
|
||||
.task(id: localValues.labelColor) {
|
||||
do { try await Task.sleep(for: .microseconds(500)) } catch {}
|
||||
theme.labelColor = localValues.labelColor
|
||||
}
|
||||
.task(id: localValues.lineSpacing) {
|
||||
do { try await Task.sleep(for: .microseconds(500)) } catch {}
|
||||
theme.lineSpacing = localValues.lineSpacing
|
||||
}
|
||||
.task(id: localValues.fontSizeScale) {
|
||||
do { try await Task.sleep(for: .microseconds(500)) } catch {}
|
||||
theme.fontSizeScale = localValues.fontSizeScale
|
||||
}
|
||||
.task(id: localValues.tintColor) {
|
||||
do { try await Task.sleep(for: .microseconds(500)) } catch {}
|
||||
theme.tintColor = localValues.tintColor
|
||||
}
|
||||
.task(id: localValues.primaryBackgroundColor) {
|
||||
do { try await Task.sleep(for: .microseconds(500)) } catch {}
|
||||
theme.primaryBackgroundColor = localValues.primaryBackgroundColor
|
||||
}
|
||||
.task(id: localValues.secondaryBackgroundColor) {
|
||||
do { try await Task.sleep(for: .microseconds(500)) } catch {}
|
||||
theme.secondaryBackgroundColor = localValues.secondaryBackgroundColor
|
||||
}
|
||||
.task(id: localValues.labelColor) {
|
||||
do { try await Task.sleep(for: .microseconds(500)) } catch {}
|
||||
theme.labelColor = localValues.labelColor
|
||||
}
|
||||
.task(id: localValues.lineSpacing) {
|
||||
do { try await Task.sleep(for: .microseconds(500)) } catch {}
|
||||
theme.lineSpacing = localValues.lineSpacing
|
||||
}
|
||||
.task(id: localValues.fontSizeScale) {
|
||||
do { try await Task.sleep(for: .microseconds(500)) } catch {}
|
||||
theme.fontSizeScale = localValues.fontSizeScale
|
||||
}
|
||||
#if !os(visionOS)
|
||||
examplePost
|
||||
#endif
|
||||
|
@ -96,8 +97,10 @@ struct DisplaySettingsView: View {
|
|||
Rectangle()
|
||||
.fill(theme.secondaryBackgroundColor)
|
||||
.frame(height: 30)
|
||||
.mask(LinearGradient(gradient: Gradient(colors: [theme.secondaryBackgroundColor, .clear]),
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
.mask(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [theme.secondaryBackgroundColor, .clear]),
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -109,8 +112,11 @@ struct DisplaySettingsView: View {
|
|||
themeSelectorButton
|
||||
Group {
|
||||
ColorPicker("settings.display.theme.tint", selection: $localValues.tintColor)
|
||||
ColorPicker("settings.display.theme.background", selection: $localValues.primaryBackgroundColor)
|
||||
ColorPicker("settings.display.theme.secondary-background", selection: $localValues.secondaryBackgroundColor)
|
||||
ColorPicker(
|
||||
"settings.display.theme.background", selection: $localValues.primaryBackgroundColor)
|
||||
ColorPicker(
|
||||
"settings.display.theme.secondary-background",
|
||||
selection: $localValues.secondaryBackgroundColor)
|
||||
ColorPicker("settings.display.theme.text-color", selection: $localValues.labelColor)
|
||||
}
|
||||
.disabled(theme.followSystemColorScheme)
|
||||
|
@ -129,35 +135,40 @@ struct DisplaySettingsView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var fontSection: some View {
|
||||
Section("settings.display.section.font") {
|
||||
Picker("settings.display.font", selection: .init(get: { () -> FontState in
|
||||
if theme.chosenFont?.fontName == "OpenDyslexic-Regular" {
|
||||
return FontState.openDyslexic
|
||||
} else if theme.chosenFont?.fontName == "AtkinsonHyperlegible-Regular" {
|
||||
return FontState.hyperLegible
|
||||
} else if theme.chosenFont?.fontName == ".AppleSystemUIFontRounded-Regular" {
|
||||
return FontState.SFRounded
|
||||
}
|
||||
return theme.chosenFontData != nil ? FontState.custom : FontState.system
|
||||
}, set: { newValue in
|
||||
switch newValue {
|
||||
case .system:
|
||||
theme.chosenFont = nil
|
||||
case .openDyslexic:
|
||||
theme.chosenFont = UIFont(name: "OpenDyslexic", size: 1)
|
||||
case .hyperLegible:
|
||||
theme.chosenFont = UIFont(name: "Atkinson Hyperlegible", size: 1)
|
||||
case .SFRounded:
|
||||
theme.chosenFont = UIFont.systemFont(ofSize: 1).rounded()
|
||||
case .custom:
|
||||
isFontSelectorPresented = true
|
||||
}
|
||||
})) {
|
||||
Picker(
|
||||
"settings.display.font",
|
||||
selection: .init(
|
||||
get: { () -> FontState in
|
||||
if theme.chosenFont?.fontName == "OpenDyslexic-Regular" {
|
||||
return FontState.openDyslexic
|
||||
} else if theme.chosenFont?.fontName == "AtkinsonHyperlegible-Regular" {
|
||||
return FontState.hyperLegible
|
||||
} else if theme.chosenFont?.fontName == ".AppleSystemUIFontRounded-Regular" {
|
||||
return FontState.SFRounded
|
||||
}
|
||||
return theme.chosenFontData != nil ? FontState.custom : FontState.system
|
||||
},
|
||||
set: { newValue in
|
||||
switch newValue {
|
||||
case .system:
|
||||
theme.chosenFont = nil
|
||||
case .openDyslexic:
|
||||
theme.chosenFont = UIFont(name: "OpenDyslexic", size: 1)
|
||||
case .hyperLegible:
|
||||
theme.chosenFont = UIFont(name: "Atkinson Hyperlegible", size: 1)
|
||||
case .SFRounded:
|
||||
theme.chosenFont = UIFont.systemFont(ofSize: 1).rounded()
|
||||
case .custom:
|
||||
isFontSelectorPresented = true
|
||||
}
|
||||
})
|
||||
) {
|
||||
ForEach(FontState.allCases, id: \.rawValue) { fontState in
|
||||
Text(fontState.title).tag(fontState)
|
||||
}
|
||||
|
@ -165,7 +176,7 @@ struct DisplaySettingsView: View {
|
|||
.navigationDestination(isPresented: $isFontSelectorPresented, destination: { FontPicker() })
|
||||
|
||||
VStack {
|
||||
Slider(value: $localValues.fontSizeScale, in: 0.5 ... 1.5, step: 0.1)
|
||||
Slider(value: $localValues.fontSizeScale, in: 0.5...1.5, step: 0.1)
|
||||
Text("settings.display.font.scaling-\(String(format: "%.1f", localValues.fontSizeScale))")
|
||||
.font(.scaledBody)
|
||||
}
|
||||
|
@ -174,16 +185,18 @@ struct DisplaySettingsView: View {
|
|||
}
|
||||
|
||||
VStack {
|
||||
Slider(value: $localValues.lineSpacing, in: 0.4 ... 10.0, step: 0.2)
|
||||
Text("settings.display.font.line-spacing-\(String(format: "%.1f", localValues.lineSpacing))")
|
||||
.font(.scaledBody)
|
||||
Slider(value: $localValues.lineSpacing, in: 0.4...10.0, step: 0.2)
|
||||
Text(
|
||||
"settings.display.font.line-spacing-\(String(format: "%.1f", localValues.lineSpacing))"
|
||||
)
|
||||
.font(.scaledBody)
|
||||
}
|
||||
.alignmentGuide(.listRowSeparatorLeading) { d in
|
||||
d[.leading]
|
||||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -224,13 +237,18 @@ struct DisplaySettingsView: View {
|
|||
Toggle("settings.display.show-reply-indentation", isOn: $userPreferences.showReplyIndentation)
|
||||
if userPreferences.showReplyIndentation {
|
||||
VStack {
|
||||
Slider(value: .init(get: {
|
||||
Double(userPreferences.maxReplyIndentation)
|
||||
}, set: { newVal in
|
||||
userPreferences.maxReplyIndentation = UInt(newVal)
|
||||
}), in: 1 ... 20, step: 1)
|
||||
Text("settings.display.max-reply-indentation-\(String(userPreferences.maxReplyIndentation))")
|
||||
.font(.scaledBody)
|
||||
Slider(
|
||||
value: .init(
|
||||
get: {
|
||||
Double(userPreferences.maxReplyIndentation)
|
||||
},
|
||||
set: { newVal in
|
||||
userPreferences.maxReplyIndentation = UInt(newVal)
|
||||
}), in: 1...20, step: 1)
|
||||
Text(
|
||||
"settings.display.max-reply-indentation-\(String(userPreferences.maxReplyIndentation))"
|
||||
)
|
||||
.font(.scaledBody)
|
||||
}
|
||||
.alignmentGuide(.listRowSeparatorLeading) { d in
|
||||
d[.leading]
|
||||
|
@ -241,7 +259,7 @@ struct DisplaySettingsView: View {
|
|||
Toggle("Compact Layout", isOn: $theme.compactLayoutPadding)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -254,7 +272,7 @@ struct DisplaySettingsView: View {
|
|||
Toggle("settings.display.show-ipad-column", isOn: $userPreferences.showiPadSecondaryColumn)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@ -268,7 +286,7 @@ struct DisplaySettingsView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ struct HapticSettingsView: View {
|
|||
Toggle("settings.haptic.buttons", isOn: $userPreferences.hapticButtonPressEnabled)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
.navigationTitle("settings.haptic.navigation-title")
|
||||
|
|
|
@ -17,7 +17,8 @@ struct IconSelectorView: View {
|
|||
}
|
||||
|
||||
case primary = 0
|
||||
case alt1, alt2, alt3, alt4, alt5, alt6, alt7, alt8, alt9, alt10, alt11, alt12, alt13, alt14, alt15
|
||||
case alt1, alt2, alt3, alt4, alt5, alt6, alt7, alt8, alt9, alt10, alt11, alt12, alt13, alt14,
|
||||
alt15
|
||||
case alt16, alt17, alt18, alt19, alt20, alt21
|
||||
case alt22, alt23, alt24, alt25, alt26
|
||||
case alt27, alt28, alt29
|
||||
|
@ -32,7 +33,7 @@ struct IconSelectorView: View {
|
|||
var appIconName: String {
|
||||
return "AppIconAlternate\(rawValue)"
|
||||
}
|
||||
|
||||
|
||||
var previewImageName: String {
|
||||
return "AppIconAlternate\(rawValue)-image"
|
||||
}
|
||||
|
@ -44,25 +45,45 @@ struct IconSelectorView: View {
|
|||
let icons: [Icon]
|
||||
|
||||
static let items = [
|
||||
IconSelector(title: "settings.app.icon.official".localized, icons: [
|
||||
.primary, .alt46, .alt1, .alt2, .alt3, .alt4,
|
||||
.alt5, .alt6, .alt7, .alt8,
|
||||
.alt9, .alt10, .alt11, .alt12, .alt13, .alt14, .alt15,
|
||||
.alt16, .alt17, .alt18, .alt19, .alt20, .alt21]),
|
||||
IconSelector(title: "\("settings.app.icon.designed-by".localized) Albert Kinng", icons: [.alt22, .alt23, .alt24, .alt25, .alt26]),
|
||||
IconSelector(title: "\("settings.app.icon.designed-by".localized) Dan van Moll", icons: [.alt27, .alt28, .alt29]),
|
||||
IconSelector(title: "\("settings.app.icon.designed-by".localized) Chanhwi Joo (GitHub @te6-in)", icons: [.alt30, .alt31, .alt32, .alt33, .alt34, .alt35, .alt36]),
|
||||
IconSelector(title: "\("settings.app.icon.designed-by".localized) W. Kovács Ágnes (@wildgica)", icons: [.alt37]),
|
||||
IconSelector(title: "\("settings.app.icon.designed-by".localized) Duncan Horne", icons: [.alt38]),
|
||||
IconSelector(title: "\("settings.app.icon.designed-by".localized) BeAware@social.beaware.live", icons: [.alt39, .alt40, .alt41, .alt42, .alt43]),
|
||||
IconSelector(title: "\("settings.app.icon.designed-by".localized) Simone Margio", icons: [.alt44, .alt45]),
|
||||
IconSelector(title: "\("settings.app.icon.designed-by".localized) Peter Broqvist (@PKB)", icons: [.alt47, .alt48]),
|
||||
IconSelector(title: "\("settings.app.icon.designed-by".localized) Oz Tsori (@oztsori)", icons: [.alt49]),
|
||||
IconSelector(
|
||||
title: "settings.app.icon.official".localized,
|
||||
icons: [
|
||||
.primary, .alt46, .alt1, .alt2, .alt3, .alt4,
|
||||
.alt5, .alt6, .alt7, .alt8,
|
||||
.alt9, .alt10, .alt11, .alt12, .alt13, .alt14, .alt15,
|
||||
.alt16, .alt17, .alt18, .alt19, .alt20, .alt21,
|
||||
]),
|
||||
IconSelector(
|
||||
title: "\("settings.app.icon.designed-by".localized) Albert Kinng",
|
||||
icons: [.alt22, .alt23, .alt24, .alt25, .alt26]),
|
||||
IconSelector(
|
||||
title: "\("settings.app.icon.designed-by".localized) Dan van Moll",
|
||||
icons: [.alt27, .alt28, .alt29]),
|
||||
IconSelector(
|
||||
title: "\("settings.app.icon.designed-by".localized) Chanhwi Joo (GitHub @te6-in)",
|
||||
icons: [.alt30, .alt31, .alt32, .alt33, .alt34, .alt35, .alt36]),
|
||||
IconSelector(
|
||||
title: "\("settings.app.icon.designed-by".localized) W. Kovács Ágnes (@wildgica)",
|
||||
icons: [.alt37]),
|
||||
IconSelector(
|
||||
title: "\("settings.app.icon.designed-by".localized) Duncan Horne", icons: [.alt38]),
|
||||
IconSelector(
|
||||
title: "\("settings.app.icon.designed-by".localized) BeAware@social.beaware.live",
|
||||
icons: [.alt39, .alt40, .alt41, .alt42, .alt43]),
|
||||
IconSelector(
|
||||
title: "\("settings.app.icon.designed-by".localized) Simone Margio",
|
||||
icons: [.alt44, .alt45]),
|
||||
IconSelector(
|
||||
title: "\("settings.app.icon.designed-by".localized) Peter Broqvist (@PKB)",
|
||||
icons: [.alt47, .alt48]),
|
||||
IconSelector(
|
||||
title: "\("settings.app.icon.designed-by".localized) Oz Tsori (@oztsori)", icons: [.alt49]),
|
||||
]
|
||||
}
|
||||
|
||||
@Environment(Theme.self) private var theme
|
||||
@State private var currentIcon = UIApplication.shared.alternateIconName ?? Icon.primary.appIconName
|
||||
@State private var currentIcon =
|
||||
UIApplication.shared.alternateIconName ?? Icon.primary.appIconName
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 125, maximum: 1024))]
|
||||
|
||||
|
@ -82,7 +103,7 @@ struct IconSelectorView: View {
|
|||
.navigationTitle("settings.app.icon.navigation-title")
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.background(theme.primaryBackgroundColor)
|
||||
.background(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
|
@ -38,7 +38,7 @@ public struct InstanceInfoSection: View {
|
|||
LabeledContent("instance.info.domains", value: format(instance.stats.domainCount))
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
if let rules = instance.rules {
|
||||
|
@ -48,7 +48,7 @@ public struct InstanceInfoSection: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,78 +18,106 @@ struct PushNotificationsView: View {
|
|||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle(isOn: .init(get: {
|
||||
subscription.isEnabled
|
||||
}, set: { newValue in
|
||||
subscription.isEnabled = newValue
|
||||
if newValue {
|
||||
updateSubscription()
|
||||
} else {
|
||||
deleteSubscription()
|
||||
}
|
||||
})) {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
subscription.isEnabled
|
||||
},
|
||||
set: { newValue in
|
||||
subscription.isEnabled = newValue
|
||||
if newValue {
|
||||
updateSubscription()
|
||||
} else {
|
||||
deleteSubscription()
|
||||
}
|
||||
})
|
||||
) {
|
||||
Text("settings.push.main-toggle")
|
||||
}
|
||||
} footer: {
|
||||
Text("settings.push.main-toggle.description")
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
if subscription.isEnabled {
|
||||
Section {
|
||||
Toggle(isOn: .init(get: {
|
||||
subscription.isMentionNotificationEnabled
|
||||
}, set: { newValue in
|
||||
subscription.isMentionNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})) {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
subscription.isMentionNotificationEnabled
|
||||
},
|
||||
set: { newValue in
|
||||
subscription.isMentionNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})
|
||||
) {
|
||||
Label("settings.push.mentions", systemImage: "at")
|
||||
}
|
||||
Toggle(isOn: .init(get: {
|
||||
subscription.isFollowNotificationEnabled
|
||||
}, set: { newValue in
|
||||
subscription.isFollowNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})) {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
subscription.isFollowNotificationEnabled
|
||||
},
|
||||
set: { newValue in
|
||||
subscription.isFollowNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})
|
||||
) {
|
||||
Label("settings.push.follows", systemImage: "person.badge.plus")
|
||||
}
|
||||
Toggle(isOn: .init(get: {
|
||||
subscription.isFavoriteNotificationEnabled
|
||||
}, set: { newValue in
|
||||
subscription.isFavoriteNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})) {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
subscription.isFavoriteNotificationEnabled
|
||||
},
|
||||
set: { newValue in
|
||||
subscription.isFavoriteNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})
|
||||
) {
|
||||
Label("settings.push.favorites", systemImage: "star")
|
||||
}
|
||||
Toggle(isOn: .init(get: {
|
||||
subscription.isReblogNotificationEnabled
|
||||
}, set: { newValue in
|
||||
subscription.isReblogNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})) {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
subscription.isReblogNotificationEnabled
|
||||
},
|
||||
set: { newValue in
|
||||
subscription.isReblogNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})
|
||||
) {
|
||||
Label("settings.push.boosts", image: "Rocket")
|
||||
}
|
||||
Toggle(isOn: .init(get: {
|
||||
subscription.isPollNotificationEnabled
|
||||
}, set: { newValue in
|
||||
subscription.isPollNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})) {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
subscription.isPollNotificationEnabled
|
||||
},
|
||||
set: { newValue in
|
||||
subscription.isPollNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})
|
||||
) {
|
||||
Label("settings.push.polls", systemImage: "chart.bar")
|
||||
}
|
||||
Toggle(isOn: .init(get: {
|
||||
subscription.isNewPostsNotificationEnabled
|
||||
}, set: { newValue in
|
||||
subscription.isNewPostsNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})) {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
subscription.isNewPostsNotificationEnabled
|
||||
},
|
||||
set: { newValue in
|
||||
subscription.isNewPostsNotificationEnabled = newValue
|
||||
updateSubscription()
|
||||
})
|
||||
) {
|
||||
Label("settings.push.new-posts", systemImage: "bubble.right")
|
||||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -106,7 +134,7 @@ struct PushNotificationsView: View {
|
|||
Text("settings.push.duplicate.footer")
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
.navigationTitle("settings.push.navigation-title")
|
||||
|
@ -114,9 +142,9 @@ struct PushNotificationsView: View {
|
|||
.scrollContentBackground(.hidden)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.task {
|
||||
await subscription.fetchSubscription()
|
||||
}
|
||||
.task {
|
||||
await subscription.fetchSubscription()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSubscription() {
|
||||
|
|
|
@ -29,7 +29,7 @@ struct RecenTagsSettingView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
.navigationTitle("settings.general.recent-tags")
|
||||
|
@ -37,8 +37,8 @@ struct RecenTagsSettingView: View {
|
|||
#if !os(visionOS)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.toolbar {
|
||||
EditButton()
|
||||
}
|
||||
.toolbar {
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ struct RemoteTimelinesSettingView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
Button {
|
||||
routerPath.presentedSheet = .addRemoteLocalTimeline
|
||||
|
@ -30,7 +30,7 @@ struct RemoteTimelinesSettingView: View {
|
|||
Label("settings.timeline.add", systemImage: "badge.plus.radiowaves.right")
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
.navigationTitle("settings.general.remote-timelines")
|
||||
|
@ -38,8 +38,8 @@ struct RemoteTimelinesSettingView: View {
|
|||
#if !os(visionOS)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.toolbar {
|
||||
EditButton()
|
||||
}
|
||||
.toolbar {
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,51 +47,53 @@ struct SettingsTabs: View {
|
|||
#if !os(visionOS)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.navigationTitle(Text("settings.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar)
|
||||
.toolbar {
|
||||
if isModal {
|
||||
ToolbarItem {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("action.done").bold()
|
||||
}
|
||||
.navigationTitle(Text("settings.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar)
|
||||
.toolbar {
|
||||
if isModal {
|
||||
ToolbarItem {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("action.done").bold()
|
||||
}
|
||||
}
|
||||
if UIDevice.current.userInterfaceIdiom == .pad, !preferences.showiPadSecondaryColumn, !isModal {
|
||||
SecondaryColumnToolbarItem()
|
||||
}
|
||||
}
|
||||
.withAppRouter()
|
||||
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
|
||||
.onAppear {
|
||||
startingPoint = RouterPath.settingsStartingPoint
|
||||
RouterPath.settingsStartingPoint = nil
|
||||
if UIDevice.current.userInterfaceIdiom == .pad, !preferences.showiPadSecondaryColumn,
|
||||
!isModal
|
||||
{
|
||||
SecondaryColumnToolbarItem()
|
||||
}
|
||||
.navigationDestination(item: $startingPoint) { targetView in
|
||||
switch targetView {
|
||||
case .display:
|
||||
DisplaySettingsView()
|
||||
case .haptic:
|
||||
HapticSettingsView()
|
||||
case .remoteTimelines:
|
||||
RemoteTimelinesSettingView()
|
||||
case .tagGroups:
|
||||
TagsGroupSettingView()
|
||||
case .recentTags:
|
||||
RecenTagsSettingView()
|
||||
case .content:
|
||||
ContentSettingsView()
|
||||
case .swipeActions:
|
||||
SwipeActionsSettingsView()
|
||||
case .tabAndSidebarEntries:
|
||||
EmptyView()
|
||||
case .translation:
|
||||
TranslationSettingsView()
|
||||
}
|
||||
}
|
||||
.withAppRouter()
|
||||
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
|
||||
.onAppear {
|
||||
startingPoint = RouterPath.settingsStartingPoint
|
||||
RouterPath.settingsStartingPoint = nil
|
||||
}
|
||||
.navigationDestination(item: $startingPoint) { targetView in
|
||||
switch targetView {
|
||||
case .display:
|
||||
DisplaySettingsView()
|
||||
case .haptic:
|
||||
HapticSettingsView()
|
||||
case .remoteTimelines:
|
||||
RemoteTimelinesSettingView()
|
||||
case .tagGroups:
|
||||
TagsGroupSettingView()
|
||||
case .recentTags:
|
||||
RecenTagsSettingView()
|
||||
case .content:
|
||||
ContentSettingsView()
|
||||
case .swipeActions:
|
||||
SwipeActionsSettingsView()
|
||||
case .tabAndSidebarEntries:
|
||||
EmptyView()
|
||||
case .translation:
|
||||
TranslationSettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
routerPath.client = client
|
||||
|
@ -137,13 +139,13 @@ struct SettingsTabs: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func logoutAccount(account: AppAccount) async {
|
||||
if let token = account.oauthToken,
|
||||
let sub = pushNotifications.subscriptions.first(where: { $0.account.token == token })
|
||||
let sub = pushNotifications.subscriptions.first(where: { $0.account.token == token })
|
||||
{
|
||||
let client = Client(server: account.server, oauthToken: token)
|
||||
await timelineCache.clearCache(for: client.id)
|
||||
|
@ -188,7 +190,9 @@ struct SettingsTabs: View {
|
|||
NavigationLink(destination: TabbarEntriesSettingsView()) {
|
||||
Label("settings.general.tabbarEntries", systemImage: "platter.filled.bottom.iphone")
|
||||
}
|
||||
} else if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
|
||||
} else if UIDevice.current.userInterfaceIdiom == .pad
|
||||
|| UIDevice.current.userInterfaceIdiom == .mac
|
||||
{
|
||||
NavigationLink(destination: SidebarEntriesSettingsView()) {
|
||||
Label("settings.general.sidebarEntries", systemImage: "sidebar.squares.leading")
|
||||
}
|
||||
|
@ -204,7 +208,7 @@ struct SettingsTabs: View {
|
|||
#endif
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -245,10 +249,10 @@ struct SettingsTabs: View {
|
|||
Text("settings.section.other.footer")
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var postStreamingSection: some View {
|
||||
@Bindable var preferences = preferences
|
||||
|
@ -259,13 +263,15 @@ struct SettingsTabs: View {
|
|||
} header: {
|
||||
Text("Streaming")
|
||||
} footer: {
|
||||
Text("Enabling post streaming will automatically add new posts at the top of your home timeline. Disable if you get performance issues.")
|
||||
Text(
|
||||
"Enabling post streaming will automatically add new posts at the top of your home timeline. Disable if you get performance issues."
|
||||
)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var AISection: some View {
|
||||
@Bindable var preferences = preferences
|
||||
|
@ -276,10 +282,12 @@ struct SettingsTabs: View {
|
|||
} header: {
|
||||
Text("AI")
|
||||
} footer: {
|
||||
Text("Disable to hide AI assisted tool options such as copywritting and alt-image description generated using AI. Uses OpenAI API. See our Privacy Policy for more information.")
|
||||
Text(
|
||||
"Disable to hide AI assisted tool options such as copywritting and alt-image description generated using AI. Uses OpenAI API. See our Privacy Policy for more information."
|
||||
)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -290,7 +298,8 @@ struct SettingsTabs: View {
|
|||
Label {
|
||||
Text("settings.app.icon")
|
||||
} icon: {
|
||||
let icon = IconSelectorView.Icon(string: UIApplication.shared.alternateIconName ?? "AppIcon")
|
||||
let icon = IconSelectorView.Icon(
|
||||
string: UIApplication.shared.alternateIconName ?? "AppIcon")
|
||||
if let image: UIImage = .init(named: icon.previewImageName) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
|
@ -313,7 +322,9 @@ struct SettingsTabs: View {
|
|||
Label("settings.app.support", systemImage: "wand.and.stars")
|
||||
}
|
||||
|
||||
if let reviewURL = URL(string: "https://apps.apple.com/app/id\(AppInfo.appStoreAppId)?action=write-review") {
|
||||
if let reviewURL = URL(
|
||||
string: "https://apps.apple.com/app/id\(AppInfo.appStoreAppId)?action=write-review")
|
||||
{
|
||||
Link(destination: reviewURL) {
|
||||
Label("settings.rate", systemImage: "link")
|
||||
}
|
||||
|
@ -326,7 +337,7 @@ struct SettingsTabs: View {
|
|||
} label: {
|
||||
Label("settings.app.about", systemImage: "info.circle")
|
||||
}
|
||||
|
||||
|
||||
NavigationLink {
|
||||
WishlistView()
|
||||
} label: {
|
||||
|
@ -337,11 +348,12 @@ struct SettingsTabs: View {
|
|||
Text("settings.section.app")
|
||||
} footer: {
|
||||
if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
|
||||
Text("settings.section.app.footer \(appVersion)").frame(maxWidth: .infinity, alignment: .center)
|
||||
Text("settings.section.app.footer \(appVersion)").frame(
|
||||
maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -391,7 +403,7 @@ struct SettingsTabs: View {
|
|||
Text("Remove all cached images and videos")
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ struct SidebarEntriesSettingsView: View {
|
|||
.onMove(perform: move)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
.environment(\.editMode, .constant(.active))
|
||||
|
|
|
@ -72,21 +72,37 @@ struct SupportAppView: View {
|
|||
.scrollContentBackground(.hidden)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.alert("settings.support.alert.title", isPresented: $purchaseSuccessDisplayed, actions: {
|
||||
Button { purchaseSuccessDisplayed = false } label: { Text("alert.button.ok") }
|
||||
}, message: {
|
||||
.alert(
|
||||
"settings.support.alert.title", isPresented: $purchaseSuccessDisplayed,
|
||||
actions: {
|
||||
Button {
|
||||
purchaseSuccessDisplayed = false
|
||||
} label: {
|
||||
Text("alert.button.ok")
|
||||
}
|
||||
},
|
||||
message: {
|
||||
Text("settings.support.alert.message")
|
||||
})
|
||||
.alert("alert.error", isPresented: $purchaseErrorDisplayed, actions: {
|
||||
Button { purchaseErrorDisplayed = false } label: { Text("alert.button.ok") }
|
||||
}, message: {
|
||||
Text("settings.support.alert.error.message")
|
||||
})
|
||||
.onAppear {
|
||||
loadingProducts = true
|
||||
fetchStoreProducts()
|
||||
refreshUserInfo()
|
||||
}
|
||||
)
|
||||
.alert(
|
||||
"alert.error", isPresented: $purchaseErrorDisplayed,
|
||||
actions: {
|
||||
Button {
|
||||
purchaseErrorDisplayed = false
|
||||
} label: {
|
||||
Text("alert.button.ok")
|
||||
}
|
||||
},
|
||||
message: {
|
||||
Text("settings.support.alert.error.message")
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
loadingProducts = true
|
||||
fetchStoreProducts()
|
||||
refreshUserInfo()
|
||||
}
|
||||
}
|
||||
|
||||
private func purchase(product: StoreProduct) async {
|
||||
|
@ -107,7 +123,8 @@ struct SupportAppView: View {
|
|||
private func fetchStoreProducts() {
|
||||
Purchases.shared.getProducts(Tip.allCases.map(\.productId)) { products in
|
||||
subscription = products.first(where: { $0.productIdentifier == Tip.supporter.productId })
|
||||
self.products = products.filter { $0.productIdentifier != Tip.supporter.productId }.sorted(by: { $0.price < $1.price })
|
||||
self.products = products.filter { $0.productIdentifier != Tip.supporter.productId }.sorted(
|
||||
by: { $0.price < $1.price })
|
||||
withAnimation {
|
||||
loadingProducts = false
|
||||
}
|
||||
|
@ -153,7 +170,7 @@ struct SupportAppView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -166,15 +183,15 @@ struct SupportAppView: View {
|
|||
if customerInfo?.entitlements["Supporter"]?.isActive == true {
|
||||
Text(Image(systemName: "checkmark.seal.fill"))
|
||||
.foregroundColor(theme.tintColor)
|
||||
.baselineOffset(-1) +
|
||||
Text("settings.support.supporter.subscribed")
|
||||
.baselineOffset(-1)
|
||||
+ Text("settings.support.supporter.subscribed")
|
||||
.font(.scaledSubheadline)
|
||||
} else {
|
||||
VStack(alignment: .leading) {
|
||||
Text(Image(systemName: "checkmark.seal.fill"))
|
||||
.foregroundColor(theme.tintColor)
|
||||
.baselineOffset(-1) +
|
||||
Text(Tip.supporter.title)
|
||||
.baselineOffset(-1)
|
||||
+ Text(Tip.supporter.title)
|
||||
.font(.scaledSubheadline)
|
||||
Text(Tip.supporter.subtitle)
|
||||
.font(.scaledFootnote)
|
||||
|
@ -192,7 +209,7 @@ struct SupportAppView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -219,7 +236,7 @@ struct SupportAppView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -240,7 +257,7 @@ struct SupportAppView: View {
|
|||
Text("settings.support.restore-purchase.explanation")
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.secondaryBackgroundColor)
|
||||
.listRowBackground(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -262,7 +279,7 @@ struct SupportAppView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.secondaryBackgroundColor)
|
||||
.listRowBackground(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
|
@ -14,32 +14,40 @@ struct SwipeActionsSettingsView: View {
|
|||
Label("settings.swipeactions.status.leading", systemImage: "arrow.right")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
createStatusActionPicker(selection: $userPreferences.swipeActionsStatusLeadingLeft,
|
||||
label: "settings.swipeactions.primary")
|
||||
.onChange(of: userPreferences.swipeActionsStatusLeadingLeft) { _, action in
|
||||
if action == .none {
|
||||
userPreferences.swipeActionsStatusLeadingRight = .none
|
||||
}
|
||||
createStatusActionPicker(
|
||||
selection: $userPreferences.swipeActionsStatusLeadingLeft,
|
||||
label: "settings.swipeactions.primary"
|
||||
)
|
||||
.onChange(of: userPreferences.swipeActionsStatusLeadingLeft) { _, action in
|
||||
if action == .none {
|
||||
userPreferences.swipeActionsStatusLeadingRight = .none
|
||||
}
|
||||
}
|
||||
|
||||
createStatusActionPicker(selection: $userPreferences.swipeActionsStatusLeadingRight,
|
||||
label: "settings.swipeactions.secondary")
|
||||
.disabled(userPreferences.swipeActionsStatusLeadingLeft == .none)
|
||||
createStatusActionPicker(
|
||||
selection: $userPreferences.swipeActionsStatusLeadingRight,
|
||||
label: "settings.swipeactions.secondary"
|
||||
)
|
||||
.disabled(userPreferences.swipeActionsStatusLeadingLeft == .none)
|
||||
|
||||
Label("settings.swipeactions.status.trailing", systemImage: "arrow.left")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
createStatusActionPicker(selection: $userPreferences.swipeActionsStatusTrailingRight,
|
||||
label: "settings.swipeactions.primary")
|
||||
.onChange(of: userPreferences.swipeActionsStatusTrailingRight) { _, action in
|
||||
if action == .none {
|
||||
userPreferences.swipeActionsStatusTrailingLeft = .none
|
||||
}
|
||||
createStatusActionPicker(
|
||||
selection: $userPreferences.swipeActionsStatusTrailingRight,
|
||||
label: "settings.swipeactions.primary"
|
||||
)
|
||||
.onChange(of: userPreferences.swipeActionsStatusTrailingRight) { _, action in
|
||||
if action == .none {
|
||||
userPreferences.swipeActionsStatusTrailingLeft = .none
|
||||
}
|
||||
}
|
||||
|
||||
createStatusActionPicker(selection: $userPreferences.swipeActionsStatusTrailingLeft,
|
||||
label: "settings.swipeactions.secondary")
|
||||
.disabled(userPreferences.swipeActionsStatusTrailingRight == .none)
|
||||
createStatusActionPicker(
|
||||
selection: $userPreferences.swipeActionsStatusTrailingLeft,
|
||||
label: "settings.swipeactions.secondary"
|
||||
)
|
||||
.disabled(userPreferences.swipeActionsStatusTrailingRight == .none)
|
||||
|
||||
} header: {
|
||||
Text("settings.swipeactions.status")
|
||||
|
@ -47,11 +55,14 @@ struct SwipeActionsSettingsView: View {
|
|||
Text("settings.swipeactions.status.explanation")
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
Section {
|
||||
Picker(selection: $userPreferences.swipeActionsIconStyle, label: Text("settings.swipeactions.icon-style")) {
|
||||
Picker(
|
||||
selection: $userPreferences.swipeActionsIconStyle,
|
||||
label: Text("settings.swipeactions.icon-style")
|
||||
) {
|
||||
ForEach(UserPreferences.SwipeActionsIconStyle.allCases, id: \.rawValue) { style in
|
||||
Text(style.description).tag(style)
|
||||
}
|
||||
|
@ -65,7 +76,7 @@ struct SwipeActionsSettingsView: View {
|
|||
Text("settings.swipeactions.use-theme-colors-explanation")
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
.navigationTitle("settings.swipeactions.navigation-title")
|
||||
|
@ -75,7 +86,9 @@ struct SwipeActionsSettingsView: View {
|
|||
#endif
|
||||
}
|
||||
|
||||
private func createStatusActionPicker(selection: Binding<StatusAction>, label: LocalizedStringKey) -> some View {
|
||||
private func createStatusActionPicker(selection: Binding<StatusAction>, label: LocalizedStringKey)
|
||||
-> some View
|
||||
{
|
||||
Picker(selection: selection, label: Text(label)) {
|
||||
Section {
|
||||
Text(StatusAction.none.displayName()).tag(StatusAction.none)
|
||||
|
|
|
@ -50,14 +50,14 @@ struct TabbarEntriesSettingsView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
Section {
|
||||
Toggle("settings.display.show-tab-label", isOn: $userPreferences.showiPhoneTabLabel)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
.navigationTitle("settings.general.tabbarEntries")
|
||||
|
|
|
@ -26,7 +26,7 @@ struct TagsGroupSettingView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
Button {
|
||||
|
@ -35,7 +35,7 @@ struct TagsGroupSettingView: View {
|
|||
Label("timeline.filter.add-tag-groups", systemImage: "plus")
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
.navigationTitle("timeline.filter.tag-groups")
|
||||
|
@ -43,8 +43,8 @@ struct TagsGroupSettingView: View {
|
|||
#if !os(visionOS)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.toolbar {
|
||||
EditButton()
|
||||
}
|
||||
.toolbar {
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ struct TranslationSettingsView: View {
|
|||
.textContentType(.password)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
if apiKey.isEmpty {
|
||||
|
@ -30,7 +30,7 @@ struct TranslationSettingsView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@ -42,11 +42,11 @@ struct TranslationSettingsView: View {
|
|||
.scrollContentBackground(.hidden)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.onChange(of: apiKey) {
|
||||
writeNewValue()
|
||||
}
|
||||
.onAppear(perform: updatePrefs)
|
||||
.onAppear(perform: readValue)
|
||||
.onChange(of: apiKey) {
|
||||
writeNewValue()
|
||||
}
|
||||
.onAppear(perform: updatePrefs)
|
||||
.onAppear(perform: readValue)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
@ -58,7 +58,7 @@ struct TranslationSettingsView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -99,19 +99,20 @@ struct TranslationSettingsView: View {
|
|||
Text("settings.translation.auto-detect-post-language-footer")
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var backgroundAPIKey: some View {
|
||||
if preferences.preferredTranslationType != .useDeepl,
|
||||
!apiKey.isEmpty
|
||||
!apiKey.isEmpty
|
||||
{
|
||||
Section {
|
||||
Text("The DeepL API Key is still stored!")
|
||||
if preferences.preferredTranslationType == .useServerIfPossible {
|
||||
Text("It can however still be used as a fallback for your instance's translation service.")
|
||||
Text(
|
||||
"It can however still be used as a fallback for your instance's translation service.")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
withAnimation {
|
||||
|
@ -123,7 +124,7 @@ struct TranslationSettingsView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,6 @@ import WishKit
|
|||
|
||||
struct WishlistView: View {
|
||||
var body: some View {
|
||||
WishKit.view
|
||||
WishKit.FeedbackListView()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ struct EditTagGroupView: View {
|
|||
)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
Section("add-tag-groups.edit.tags") {
|
||||
|
@ -50,7 +50,7 @@ struct EditTagGroupView: View {
|
|||
)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
@ -65,16 +65,16 @@ struct EditTagGroupView: View {
|
|||
.background(theme.secondaryBackgroundColor)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
.toolbar {
|
||||
CancelToolbarItem()
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("action.save", action: { save() })
|
||||
.disabled(!tagGroup.isValid)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
focusedField = .title
|
||||
.toolbar {
|
||||
CancelToolbarItem()
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("action.save", action: { save() })
|
||||
.disabled(!tagGroup.isValid)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
focusedField = .title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -140,8 +140,7 @@ private struct TitleInputView: View {
|
|||
var warningText: LocalizedStringKey {
|
||||
if case let .invalid(description) = titleValidationStatus {
|
||||
return description
|
||||
} else if
|
||||
isNewGroup,
|
||||
} else if isNewGroup,
|
||||
tagGroups.contains(where: { $0.title == title })
|
||||
{
|
||||
return "\(title) add-tag-groups.edit.title.field.warning.already-exists"
|
||||
|
@ -180,7 +179,7 @@ private struct SymbolInputView: View {
|
|||
}
|
||||
|
||||
if case let .invalid(description) = selectedSymbolValidationStatus,
|
||||
focusedField == .symbol
|
||||
focusedField == .symbol
|
||||
{
|
||||
Text(description).warningLabel()
|
||||
}
|
||||
|
@ -210,7 +209,9 @@ private struct TagsInputView: View {
|
|||
HStack {
|
||||
Text(tag)
|
||||
Spacer()
|
||||
Button { deleteTag(tag) } label: {
|
||||
Button {
|
||||
deleteTag(tag)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
@ -240,7 +241,9 @@ private struct TagsInputView: View {
|
|||
Spacer()
|
||||
|
||||
if !newTag.isEmpty, !tags.contains(newTag) {
|
||||
Button { addNewTag() } label: {
|
||||
Button {
|
||||
addNewTag()
|
||||
} label: {
|
||||
Image(systemName: "checkmark.circle.fill").tint(.green)
|
||||
}
|
||||
}
|
||||
|
@ -281,7 +284,7 @@ private struct TagsInputView: View {
|
|||
|
||||
private func addTag(_ tag: String) {
|
||||
guard !tag.isEmpty,
|
||||
!tags.contains(tag)
|
||||
!tags.contains(tag)
|
||||
else { return }
|
||||
|
||||
tags.append(tag)
|
||||
|
@ -347,12 +350,15 @@ private struct SymbolSearchResultsView: View {
|
|||
var validationStatus: ValidationStatus {
|
||||
if results.isEmpty {
|
||||
if symbolQuery == selectedSymbol,
|
||||
!symbolQuery.isEmpty,
|
||||
results.count == 0
|
||||
!symbolQuery.isEmpty,
|
||||
results.count == 0
|
||||
{
|
||||
.invalid(description: "\(symbolQuery) add-tag-groups.edit.tags.field.warning.search-results.already-selected")
|
||||
.invalid(
|
||||
description:
|
||||
"\(symbolQuery) add-tag-groups.edit.tags.field.warning.search-results.already-selected")
|
||||
} else {
|
||||
.invalid(description: "add-tag-groups.edit.tags.field.warning.search-results.no-symbol-found")
|
||||
.invalid(
|
||||
description: "add-tag-groups.edit.tags.field.warning.search-results.no-symbol-found")
|
||||
}
|
||||
} else {
|
||||
.valid
|
||||
|
@ -385,7 +391,8 @@ extension TagGroup {
|
|||
if symbolName.isEmpty {
|
||||
return .invalid(description: "add-tag-groups.edit.title.field.warning.no-symbol-selected")
|
||||
} else if !Self.allSymbols.contains(symbolName) {
|
||||
return .invalid(description: "\(symbolName) add-tag-groups.edit.title.field.warning.invalid-sfsymbol-name")
|
||||
return .invalid(
|
||||
description: "\(symbolName) add-tag-groups.edit.title.field.warning.invalid-sfsymbol-name")
|
||||
}
|
||||
|
||||
return .valid
|
||||
|
@ -430,8 +437,7 @@ extension TagGroup {
|
|||
guard !query.isEmpty else { return [] }
|
||||
|
||||
return allSymbols.filter {
|
||||
$0.contains(query) &&
|
||||
$0 != excludedSymbol
|
||||
$0.contains(query) && $0 != excludedSymbol
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -62,26 +62,28 @@ struct AddRemoteTimelineView: View {
|
|||
.background(theme.secondaryBackgroundColor)
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
#endif
|
||||
.toolbar {
|
||||
CancelToolbarItem()
|
||||
.toolbar {
|
||||
CancelToolbarItem()
|
||||
}
|
||||
.onChange(of: instanceName) { _, newValue in
|
||||
instanceNamePublisher.send(newValue)
|
||||
}
|
||||
.onReceive(
|
||||
instanceNamePublisher.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
|
||||
) { newValue in
|
||||
Task {
|
||||
let client = Client(server: newValue)
|
||||
instance = try? await client.get(endpoint: Instances.instance)
|
||||
}
|
||||
.onChange(of: instanceName) { _, newValue in
|
||||
instanceNamePublisher.send(newValue)
|
||||
}
|
||||
.onReceive(instanceNamePublisher.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)) { newValue in
|
||||
Task {
|
||||
let client = Client(server: newValue)
|
||||
instance = try? await client.get(endpoint: Instances.instance)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
isInstanceURLFieldFocused = true
|
||||
let client = InstanceSocialClient()
|
||||
let instanceName = instanceName
|
||||
Task {
|
||||
instances = await client.fetchInstances(keyword: instanceName)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
isInstanceURLFieldFocused = true
|
||||
let client = InstanceSocialClient()
|
||||
let instanceName = instanceName
|
||||
Task {
|
||||
instances = await client.fetchInstances(keyword: instanceName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -91,7 +93,10 @@ struct AddRemoteTimelineView: View {
|
|||
ProgressView()
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
} else {
|
||||
ForEach(instanceName.isEmpty ? instances : instances.filter { $0.name.contains(instanceName.lowercased()) }) { instance in
|
||||
ForEach(
|
||||
instanceName.isEmpty
|
||||
? instances : instances.filter { $0.name.contains(instanceName.lowercased()) }
|
||||
) { instance in
|
||||
Button {
|
||||
instanceName = instance.name
|
||||
} label: {
|
||||
|
|
|
@ -38,17 +38,19 @@ struct TimelineTab: View {
|
|||
|
||||
var body: some View {
|
||||
NavigationStack(path: $routerPath.path) {
|
||||
TimelineView(timeline: $timeline,
|
||||
pinnedFilters: $pinnedFilters,
|
||||
selectedTagGroup: $selectedTagGroup,
|
||||
canFilterTimeline: canFilterTimeline)
|
||||
.withAppRouter()
|
||||
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
|
||||
.toolbar {
|
||||
toolbarView
|
||||
}
|
||||
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar)
|
||||
.id(client.id)
|
||||
TimelineView(
|
||||
timeline: $timeline,
|
||||
pinnedFilters: $pinnedFilters,
|
||||
selectedTagGroup: $selectedTagGroup,
|
||||
canFilterTimeline: canFilterTimeline
|
||||
)
|
||||
.withAppRouter()
|
||||
.withSheetDestinations(sheetDestinations: $routerPath.presentedSheet)
|
||||
.toolbar {
|
||||
toolbarView
|
||||
}
|
||||
.toolbarBackground(theme.primaryBackgroundColor.opacity(0.30), for: .navigationBar)
|
||||
.id(client.id)
|
||||
}
|
||||
.onAppear {
|
||||
routerPath.client = client
|
||||
|
@ -182,7 +184,8 @@ struct TimelineTab: View {
|
|||
Button {
|
||||
timeline = .latest
|
||||
} label: {
|
||||
Label(TimelineFilter.latest.localizedTitle(), systemImage: TimelineFilter.latest.iconName())
|
||||
Label(
|
||||
TimelineFilter.latest.localizedTitle(), systemImage: TimelineFilter.latest.iconName())
|
||||
}
|
||||
}
|
||||
if timeline == .home {
|
||||
|
@ -190,8 +193,9 @@ struct TimelineTab: View {
|
|||
timeline = .resume
|
||||
} label: {
|
||||
VStack {
|
||||
Label(TimelineFilter.resume.localizedTitle(),
|
||||
systemImage: TimelineFilter.resume.iconName())
|
||||
Label(
|
||||
TimelineFilter.resume.localizedTitle(),
|
||||
systemImage: TimelineFilter.resume.iconName())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -206,10 +210,10 @@ struct TimelineTab: View {
|
|||
withAnimation {
|
||||
if let index {
|
||||
let timeline = pinnedFilters.remove(at: index)
|
||||
Telemetry.signal("timeline.pin.removed", parameters: ["timeline" : timeline.rawValue])
|
||||
Telemetry.signal("timeline.pin.removed", parameters: ["timeline": timeline.rawValue])
|
||||
} else {
|
||||
pinnedFilters.append(timeline)
|
||||
Telemetry.signal("timeline.pin.added", parameters: ["timeline" : timeline.rawValue])
|
||||
Telemetry.signal("timeline.pin.added", parameters: ["timeline": timeline.rawValue])
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
|
@ -305,11 +309,13 @@ struct TimelineTab: View {
|
|||
}
|
||||
|
||||
private var contentFilterButton: some View {
|
||||
Button(action: {
|
||||
routerPath.presentedSheet = .timelineContentFilter
|
||||
}, label: {
|
||||
Label("timeline.content-filter.title", systemSymbol: .line3HorizontalDecrease)
|
||||
})
|
||||
Button(
|
||||
action: {
|
||||
routerPath.presentedSheet = .timelineContentFilter
|
||||
},
|
||||
label: {
|
||||
Label("timeline.content-filter.title", systemSymbol: .line3HorizontalDecrease)
|
||||
})
|
||||
}
|
||||
|
||||
private func resetTimelineFilter() {
|
||||
|
|
|
@ -17,7 +17,9 @@ struct ToolbarTab: ToolbarContent {
|
|||
if !isSecondaryColumn {
|
||||
if horizontalSizeClass == .regular {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad
|
||||
|| UIDevice.current.userInterfaceIdiom == .mac
|
||||
{
|
||||
Button {
|
||||
withAnimation {
|
||||
userPreferences.isSidebarExpanded.toggle()
|
||||
|
@ -32,13 +34,16 @@ struct ToolbarTab: ToolbarContent {
|
|||
}
|
||||
}
|
||||
}
|
||||
statusEditorToolbarItem(routerPath: routerPath,
|
||||
visibility: userPreferences.postVisibility)
|
||||
if UIDevice.current.userInterfaceIdiom != .pad ||
|
||||
(UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact)
|
||||
statusEditorToolbarItem(
|
||||
routerPath: routerPath,
|
||||
visibility: userPreferences.postVisibility)
|
||||
if UIDevice.current.userInterfaceIdiom != .pad
|
||||
|| (UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .compact)
|
||||
{
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
AppAccountsSelectorView(routerPath: routerPath, avatarConfig: theme.avatarShape == .circle ? .badge : .badgeRounded)
|
||||
AppAccountsSelectorView(
|
||||
routerPath: routerPath,
|
||||
avatarConfig: theme.avatarShape == .circle ? .badge : .badgeRounded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,9 @@ import SwiftUI
|
|||
@Observable
|
||||
public class AppIntentService: @unchecked Sendable {
|
||||
struct HandledIntent: Equatable {
|
||||
static func == (lhs: AppIntentService.HandledIntent, rhs: AppIntentService.HandledIntent) -> Bool {
|
||||
static func == (lhs: AppIntentService.HandledIntent, rhs: AppIntentService.HandledIntent)
|
||||
-> Bool
|
||||
{
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ struct AppShortcuts: AppShortcutsProvider {
|
|||
AppShortcut(
|
||||
intent: TabIntent(),
|
||||
phrases: [
|
||||
"Open \(.applicationName)",
|
||||
"Open \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Open Ice Cubes",
|
||||
systemImageName: "cube"
|
||||
|
|
|
@ -9,10 +9,12 @@ enum PostVisibility: String, AppEnum {
|
|||
case direct, priv, unlisted, pub
|
||||
|
||||
public static var caseDisplayRepresentations: [PostVisibility: DisplayRepresentation] {
|
||||
[.direct: "Private",
|
||||
.priv: "Followers Only",
|
||||
.unlisted: "Quiet Public",
|
||||
.pub: "Public"]
|
||||
[
|
||||
.direct: "Private",
|
||||
.priv: "Followers Only",
|
||||
.unlisted: "Quiet Public",
|
||||
.pub: "Public",
|
||||
]
|
||||
}
|
||||
|
||||
static var typeDisplayName: LocalizedStringResource { "Visibility" }
|
||||
|
@ -44,12 +46,15 @@ struct InlinePostIntent: AppIntent {
|
|||
@Parameter(title: "Post visibility", requestValueDialog: IntentDialog("Visibility of your post"))
|
||||
var visibility: PostVisibility
|
||||
|
||||
@Parameter(title: "Post content", requestValueDialog: IntentDialog("Content of the post to be sent to Mastodon"))
|
||||
@Parameter(
|
||||
title: "Post content",
|
||||
requestValueDialog: IntentDialog("Content of the post to be sent to Mastodon"))
|
||||
var content: String
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
|
||||
let client = Client(server: account.account.server, version: .v1, oauthToken: account.account.oauthToken)
|
||||
let client = Client(
|
||||
server: account.account.server, version: .v1, oauthToken: account.account.oauthToken)
|
||||
let status = StatusData(status: content, visibility: visibility.toAppVisibility)
|
||||
do {
|
||||
let status: Status = try await client.post(endpoint: Statuses.postStatus(json: status))
|
||||
|
|
|
@ -3,13 +3,15 @@ import Foundation
|
|||
|
||||
struct PostImageIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Post an image to Mastodon"
|
||||
static let description: IntentDescription = "Use Ice Cubes to compose a post with an image to Mastodon"
|
||||
static let description: IntentDescription =
|
||||
"Use Ice Cubes to compose a post with an image to Mastodon"
|
||||
static let openAppWhenRun: Bool = true
|
||||
|
||||
@Parameter(title: "Image",
|
||||
description: "Image to post on Mastodon",
|
||||
supportedTypeIdentifiers: ["public.image"],
|
||||
inputConnectionBehavior: .connectToPreviousIntentResult)
|
||||
@Parameter(
|
||||
title: "Image",
|
||||
description: "Image to post on Mastodon",
|
||||
supportedTypeIdentifiers: ["public.image"],
|
||||
inputConnectionBehavior: .connectToPreviousIntentResult)
|
||||
var images: [IntentFile]?
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
|
|
|
@ -17,22 +17,24 @@ enum TabEnum: String, AppEnum, Sendable {
|
|||
static let typeDisplayRepresentation: TypeDisplayRepresentation = "Tab"
|
||||
|
||||
nonisolated static var caseDisplayRepresentations: [TabEnum: DisplayRepresentation] {
|
||||
[.timeline: .init(title: "Home Timeline"),
|
||||
.trending: .init(title: "Trending Timeline"),
|
||||
.federated: .init(title: "Federated Timeline"),
|
||||
.local: .init(title: "Local Timeline"),
|
||||
.notifications: .init(title: "Notifications"),
|
||||
.mentions: .init(title: "Mentions"),
|
||||
.explore: .init(title: "Explore & Trending"),
|
||||
.messages: .init(title: "Private Messages"),
|
||||
.settings: .init(title: "Settings"),
|
||||
.profile: .init(title: "Profile"),
|
||||
.bookmarks: .init(title: "Bookmarks"),
|
||||
.favorites: .init(title: "Favorites"),
|
||||
.followedTags: .init(title: "Followed Tags"),
|
||||
.lists: .init(title: "Lists"),
|
||||
.links: .init(title: "Trending Links"),
|
||||
.post: .init(title: "New post")]
|
||||
[
|
||||
.timeline: .init(title: "Home Timeline"),
|
||||
.trending: .init(title: "Trending Timeline"),
|
||||
.federated: .init(title: "Federated Timeline"),
|
||||
.local: .init(title: "Local Timeline"),
|
||||
.notifications: .init(title: "Notifications"),
|
||||
.mentions: .init(title: "Mentions"),
|
||||
.explore: .init(title: "Explore & Trending"),
|
||||
.messages: .init(title: "Private Messages"),
|
||||
.settings: .init(title: "Settings"),
|
||||
.profile: .init(title: "Profile"),
|
||||
.bookmarks: .init(title: "Bookmarks"),
|
||||
.favorites: .init(title: "Favorites"),
|
||||
.followedTags: .init(title: "Followed Tags"),
|
||||
.lists: .init(title: "Lists"),
|
||||
.links: .init(title: "Trending Links"),
|
||||
.post: .init(title: "New post"),
|
||||
]
|
||||
}
|
||||
|
||||
var toAppTab: AppTab {
|
||||
|
|
|
@ -10,24 +10,30 @@ struct AccountWidgetProvider: AppIntentTimelineProvider {
|
|||
.init(date: Date(), account: .placeholder(), avatar: nil)
|
||||
}
|
||||
|
||||
func snapshot(for configuration: AccountWidgetConfiguration, in _: Context) async -> AccountWidgetEntry {
|
||||
func snapshot(for configuration: AccountWidgetConfiguration, in _: Context) async
|
||||
-> AccountWidgetEntry
|
||||
{
|
||||
let account = await fetchAccount(configuration: configuration)
|
||||
return .init(date: Date(), account: account, avatar: nil)
|
||||
}
|
||||
|
||||
func timeline(for configuration: AccountWidgetConfiguration, in _: Context) async -> Timeline<AccountWidgetEntry> {
|
||||
func timeline(for configuration: AccountWidgetConfiguration, in _: Context) async -> Timeline<
|
||||
AccountWidgetEntry
|
||||
> {
|
||||
let account = await fetchAccount(configuration: configuration)
|
||||
let images = try? await loadImages(urls: [account.avatar])
|
||||
return .init(entries: [.init(date: Date(), account: account, avatar: images?.first?.value)],
|
||||
policy: .atEnd)
|
||||
return .init(
|
||||
entries: [.init(date: Date(), account: account, avatar: images?.first?.value)],
|
||||
policy: .atEnd)
|
||||
}
|
||||
|
||||
private func fetchAccount(configuration: AccountWidgetConfiguration) async -> Account {
|
||||
guard let account = configuration.account else {
|
||||
return .placeholder()
|
||||
}
|
||||
let client = Client(server: account.account.server,
|
||||
oauthToken: account.account.oauthToken)
|
||||
let client = Client(
|
||||
server: account.account.server,
|
||||
oauthToken: account.account.oauthToken)
|
||||
do {
|
||||
let account: Account = try await client.get(endpoint: Accounts.verifyCredentials)
|
||||
return account
|
||||
|
@ -47,10 +53,11 @@ struct AccountWidget: Widget {
|
|||
let kind: String = "AccountWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind,
|
||||
intent: AccountWidgetConfiguration.self,
|
||||
provider: AccountWidgetProvider())
|
||||
{ entry in
|
||||
AppIntentConfiguration(
|
||||
kind: kind,
|
||||
intent: AccountWidgetConfiguration.self,
|
||||
provider: AccountWidgetProvider()
|
||||
) { entry in
|
||||
AccountWidgetView(entry: entry)
|
||||
.containerBackground(Color("WidgetBackground").gradient, for: .widget)
|
||||
}
|
||||
|
|
|
@ -7,50 +7,71 @@ import WidgetKit
|
|||
|
||||
struct HashtagPostsWidgetProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in _: Context) -> PostsWidgetEntry {
|
||||
.init(date: Date(),
|
||||
title: "#Mastodon",
|
||||
statuses: [.placeholder()],
|
||||
images: [:])
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "#Mastodon",
|
||||
statuses: [.placeholder()],
|
||||
images: [:])
|
||||
}
|
||||
|
||||
func snapshot(for configuration: HashtagPostsWidgetConfiguration, in context: Context) async -> PostsWidgetEntry {
|
||||
func snapshot(for configuration: HashtagPostsWidgetConfiguration, in context: Context) async
|
||||
-> PostsWidgetEntry
|
||||
{
|
||||
if let entry = await timeline(for: configuration, context: context).entries.first {
|
||||
return entry
|
||||
}
|
||||
return .init(date: Date(),
|
||||
title: "#Mastodon",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
return .init(
|
||||
date: Date(),
|
||||
title: "#Mastodon",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
}
|
||||
|
||||
func timeline(for configuration: HashtagPostsWidgetConfiguration, in context: Context) async -> Timeline<PostsWidgetEntry> {
|
||||
func timeline(for configuration: HashtagPostsWidgetConfiguration, in context: Context) async
|
||||
-> Timeline<PostsWidgetEntry>
|
||||
{
|
||||
await timeline(for: configuration, context: context)
|
||||
}
|
||||
|
||||
private func timeline(for configuration: HashtagPostsWidgetConfiguration, context: Context) async -> Timeline<PostsWidgetEntry> {
|
||||
private func timeline(for configuration: HashtagPostsWidgetConfiguration, context: Context) async
|
||||
-> Timeline<PostsWidgetEntry>
|
||||
{
|
||||
do {
|
||||
guard let account = configuration.account, let hashgtag = configuration.hashgtag else {
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
title: "#Mastodon",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
policy: .atEnd)
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "#Mastodon",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
let timeline: TimelineFilter = .hashtag(tag: hashgtag, accountId: nil)
|
||||
let statuses = await loadStatuses(for: timeline,
|
||||
account: account,
|
||||
widgetFamily: context.family)
|
||||
let statuses = await loadStatuses(
|
||||
for: timeline,
|
||||
account: account,
|
||||
widgetFamily: context.family)
|
||||
let images = try await loadImages(urls: statuses.map { $0.account.avatar })
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
title: timeline.title,
|
||||
statuses: statuses,
|
||||
images: images)], policy: .atEnd)
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: timeline.title,
|
||||
statuses: statuses,
|
||||
images: images)
|
||||
], policy: .atEnd)
|
||||
} catch {
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
title: "#Mastodon",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
policy: .atEnd)
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "#Mastodon",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -59,10 +80,11 @@ struct HashtagPostsWidget: Widget {
|
|||
let kind: String = "HashtagPostsWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind,
|
||||
intent: HashtagPostsWidgetConfiguration.self,
|
||||
provider: HashtagPostsWidgetProvider())
|
||||
{ entry in
|
||||
AppIntentConfiguration(
|
||||
kind: kind,
|
||||
intent: HashtagPostsWidgetConfiguration.self,
|
||||
provider: HashtagPostsWidgetProvider()
|
||||
) { entry in
|
||||
PostsWidgetView(entry: entry)
|
||||
.containerBackground(Color("WidgetBackground").gradient, for: .widget)
|
||||
}
|
||||
|
@ -75,8 +97,9 @@ struct HashtagPostsWidget: Widget {
|
|||
#Preview(as: .systemMedium) {
|
||||
HashtagPostsWidget()
|
||||
} timeline: {
|
||||
PostsWidgetEntry(date: .now,
|
||||
title: "#Mastodon",
|
||||
statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()],
|
||||
images: [:])
|
||||
PostsWidgetEntry(
|
||||
date: .now,
|
||||
title: "#Mastodon",
|
||||
statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()],
|
||||
images: [:])
|
||||
}
|
||||
|
|
|
@ -7,49 +7,70 @@ import WidgetKit
|
|||
|
||||
struct LatestPostsWidgetProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in _: Context) -> PostsWidgetEntry {
|
||||
.init(date: Date(),
|
||||
title: "Home",
|
||||
statuses: [.placeholder()],
|
||||
images: [:])
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "Home",
|
||||
statuses: [.placeholder()],
|
||||
images: [:])
|
||||
}
|
||||
|
||||
func snapshot(for configuration: LatestPostsWidgetConfiguration, in context: Context) async -> PostsWidgetEntry {
|
||||
func snapshot(for configuration: LatestPostsWidgetConfiguration, in context: Context) async
|
||||
-> PostsWidgetEntry
|
||||
{
|
||||
if let entry = await timeline(for: configuration, context: context).entries.first {
|
||||
return entry
|
||||
}
|
||||
return .init(date: Date(),
|
||||
title: configuration.timeline?.timeline.title ?? "",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
return .init(
|
||||
date: Date(),
|
||||
title: configuration.timeline?.timeline.title ?? "",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
}
|
||||
|
||||
func timeline(for configuration: LatestPostsWidgetConfiguration, in context: Context) async -> Timeline<PostsWidgetEntry> {
|
||||
func timeline(for configuration: LatestPostsWidgetConfiguration, in context: Context) async
|
||||
-> Timeline<PostsWidgetEntry>
|
||||
{
|
||||
await timeline(for: configuration, context: context)
|
||||
}
|
||||
|
||||
private func timeline(for configuration: LatestPostsWidgetConfiguration, context: Context) async -> Timeline<PostsWidgetEntry> {
|
||||
private func timeline(for configuration: LatestPostsWidgetConfiguration, context: Context) async
|
||||
-> Timeline<PostsWidgetEntry>
|
||||
{
|
||||
do {
|
||||
guard let timeline = configuration.timeline, let account = configuration.account else {
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
title: "",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
policy: .atEnd)
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
let statuses = await loadStatuses(for: timeline.timeline,
|
||||
account: account,
|
||||
widgetFamily: context.family)
|
||||
let statuses = await loadStatuses(
|
||||
for: timeline.timeline,
|
||||
account: account,
|
||||
widgetFamily: context.family)
|
||||
let images = try await loadImages(urls: statuses.map { $0.account.avatar })
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
title: timeline.timeline.title,
|
||||
statuses: statuses,
|
||||
images: images)], policy: .atEnd)
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: timeline.timeline.title,
|
||||
statuses: statuses,
|
||||
images: images)
|
||||
], policy: .atEnd)
|
||||
} catch {
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
title: configuration.timeline?.timeline.title ?? "",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
policy: .atEnd)
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: configuration.timeline?.timeline.title ?? "",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -77,10 +98,11 @@ struct LatestPostsWidget: Widget {
|
|||
let kind: String = "LatestPostsWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind,
|
||||
intent: LatestPostsWidgetConfiguration.self,
|
||||
provider: LatestPostsWidgetProvider())
|
||||
{ entry in
|
||||
AppIntentConfiguration(
|
||||
kind: kind,
|
||||
intent: LatestPostsWidgetConfiguration.self,
|
||||
provider: LatestPostsWidgetProvider()
|
||||
) { entry in
|
||||
PostsWidgetView(entry: entry)
|
||||
.containerBackground(Color("WidgetBackground").gradient, for: .widget)
|
||||
}
|
||||
|
@ -93,8 +115,9 @@ struct LatestPostsWidget: Widget {
|
|||
#Preview(as: .systemMedium) {
|
||||
LatestPostsWidget()
|
||||
} timeline: {
|
||||
PostsWidgetEntry(date: .now,
|
||||
title: "Mastodon",
|
||||
statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()],
|
||||
images: [:])
|
||||
PostsWidgetEntry(
|
||||
date: .now,
|
||||
title: "Mastodon",
|
||||
statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()],
|
||||
images: [:])
|
||||
}
|
||||
|
|
|
@ -7,50 +7,71 @@ import WidgetKit
|
|||
|
||||
struct ListsWidgetProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in _: Context) -> PostsWidgetEntry {
|
||||
.init(date: Date(),
|
||||
title: "List name",
|
||||
statuses: [.placeholder()],
|
||||
images: [:])
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "List name",
|
||||
statuses: [.placeholder()],
|
||||
images: [:])
|
||||
}
|
||||
|
||||
func snapshot(for configuration: ListsWidgetConfiguration, in context: Context) async -> PostsWidgetEntry {
|
||||
func snapshot(for configuration: ListsWidgetConfiguration, in context: Context) async
|
||||
-> PostsWidgetEntry
|
||||
{
|
||||
if let entry = await timeline(for: configuration, context: context).entries.first {
|
||||
return entry
|
||||
}
|
||||
return .init(date: Date(),
|
||||
title: "List name",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
return .init(
|
||||
date: Date(),
|
||||
title: "List name",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
}
|
||||
|
||||
func timeline(for configuration: ListsWidgetConfiguration, in context: Context) async -> Timeline<PostsWidgetEntry> {
|
||||
func timeline(for configuration: ListsWidgetConfiguration, in context: Context) async -> Timeline<
|
||||
PostsWidgetEntry
|
||||
> {
|
||||
await timeline(for: configuration, context: context)
|
||||
}
|
||||
|
||||
private func timeline(for configuration: ListsWidgetConfiguration, context: Context) async -> Timeline<PostsWidgetEntry> {
|
||||
private func timeline(for configuration: ListsWidgetConfiguration, context: Context) async
|
||||
-> Timeline<PostsWidgetEntry>
|
||||
{
|
||||
do {
|
||||
guard let account = configuration.account, let timeline = configuration.timeline else {
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
title: "List name",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
policy: .atEnd)
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "List name",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
let filter: TimelineFilter = .list(list: timeline.list)
|
||||
let statuses = await loadStatuses(for: filter,
|
||||
account: account,
|
||||
widgetFamily: context.family)
|
||||
let statuses = await loadStatuses(
|
||||
for: filter,
|
||||
account: account,
|
||||
widgetFamily: context.family)
|
||||
let images = try await loadImages(urls: statuses.map { $0.account.avatar })
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
title: filter.title,
|
||||
statuses: statuses,
|
||||
images: images)], policy: .atEnd)
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: filter.title,
|
||||
statuses: statuses,
|
||||
images: images)
|
||||
], policy: .atEnd)
|
||||
} catch {
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
title: "List name",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
policy: .atEnd)
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "List name",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -59,10 +80,11 @@ struct ListsPostWidget: Widget {
|
|||
let kind: String = "ListsPostWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind,
|
||||
intent: ListsWidgetConfiguration.self,
|
||||
provider: ListsWidgetProvider())
|
||||
{ entry in
|
||||
AppIntentConfiguration(
|
||||
kind: kind,
|
||||
intent: ListsWidgetConfiguration.self,
|
||||
provider: ListsWidgetProvider()
|
||||
) { entry in
|
||||
PostsWidgetView(entry: entry)
|
||||
.containerBackground(Color("WidgetBackground").gradient, for: .widget)
|
||||
}
|
||||
|
@ -75,8 +97,9 @@ struct ListsPostWidget: Widget {
|
|||
#Preview(as: .systemMedium) {
|
||||
ListsPostWidget()
|
||||
} timeline: {
|
||||
PostsWidgetEntry(date: .now,
|
||||
title: "List name",
|
||||
statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()],
|
||||
images: [:])
|
||||
PostsWidgetEntry(
|
||||
date: .now,
|
||||
title: "List name",
|
||||
statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()],
|
||||
images: [:])
|
||||
}
|
||||
|
|
|
@ -7,56 +7,79 @@ import WidgetKit
|
|||
|
||||
struct MentionsWidgetProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in _: Context) -> PostsWidgetEntry {
|
||||
.init(date: Date(),
|
||||
title: "Mentions",
|
||||
statuses: [.placeholder()],
|
||||
images: [:])
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "Mentions",
|
||||
statuses: [.placeholder()],
|
||||
images: [:])
|
||||
}
|
||||
|
||||
func snapshot(for configuration: MentionsWidgetConfiguration, in context: Context) async -> PostsWidgetEntry {
|
||||
func snapshot(for configuration: MentionsWidgetConfiguration, in context: Context) async
|
||||
-> PostsWidgetEntry
|
||||
{
|
||||
if let entry = await timeline(for: configuration, context: context).entries.first {
|
||||
return entry
|
||||
}
|
||||
return .init(date: Date(),
|
||||
title: "Mentions",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
return .init(
|
||||
date: Date(),
|
||||
title: "Mentions",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
}
|
||||
|
||||
func timeline(for configuration: MentionsWidgetConfiguration, in context: Context) async -> Timeline<PostsWidgetEntry> {
|
||||
func timeline(for configuration: MentionsWidgetConfiguration, in context: Context) async
|
||||
-> Timeline<PostsWidgetEntry>
|
||||
{
|
||||
await timeline(for: configuration, context: context)
|
||||
}
|
||||
|
||||
private func timeline(for configuration: MentionsWidgetConfiguration, context _: Context) async -> Timeline<PostsWidgetEntry> {
|
||||
private func timeline(for configuration: MentionsWidgetConfiguration, context _: Context) async
|
||||
-> Timeline<PostsWidgetEntry>
|
||||
{
|
||||
do {
|
||||
guard let account = configuration.account else {
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
title: "Mentions",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
policy: .atEnd)
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "Mentions",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
let client = Client(server: account.account.server,
|
||||
oauthToken: account.account.oauthToken)
|
||||
let client = Client(
|
||||
server: account.account.server,
|
||||
oauthToken: account.account.oauthToken)
|
||||
var excludedTypes = Models.Notification.NotificationType.allCases
|
||||
excludedTypes.removeAll(where: { $0 == .mention })
|
||||
let notifications: [Models.Notification] =
|
||||
try await client.get(endpoint: Notifications.notifications(minId: nil,
|
||||
maxId: nil,
|
||||
types: excludedTypes.map(\.rawValue),
|
||||
limit: 5))
|
||||
try await client.get(
|
||||
endpoint: Notifications.notifications(
|
||||
minId: nil,
|
||||
maxId: nil,
|
||||
types: excludedTypes.map(\.rawValue),
|
||||
limit: 5))
|
||||
let statuses = notifications.compactMap { $0.status }
|
||||
let images = try await loadImages(urls: statuses.map { $0.account.avatar })
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
title: "Mentions",
|
||||
statuses: statuses,
|
||||
images: images)], policy: .atEnd)
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "Mentions",
|
||||
statuses: statuses,
|
||||
images: images)
|
||||
], policy: .atEnd)
|
||||
} catch {
|
||||
return Timeline(entries: [.init(date: Date(),
|
||||
title: "Mentions",
|
||||
statuses: [],
|
||||
images: [:])],
|
||||
policy: .atEnd)
|
||||
return Timeline(
|
||||
entries: [
|
||||
.init(
|
||||
date: Date(),
|
||||
title: "Mentions",
|
||||
statuses: [],
|
||||
images: [:])
|
||||
],
|
||||
policy: .atEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -65,10 +88,11 @@ struct MentionsWidget: Widget {
|
|||
let kind: String = "MentionsWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind,
|
||||
intent: MentionsWidgetConfiguration.self,
|
||||
provider: MentionsWidgetProvider())
|
||||
{ entry in
|
||||
AppIntentConfiguration(
|
||||
kind: kind,
|
||||
intent: MentionsWidgetConfiguration.self,
|
||||
provider: MentionsWidgetProvider()
|
||||
) { entry in
|
||||
PostsWidgetView(entry: entry)
|
||||
.containerBackground(Color("WidgetBackground").gradient, for: .widget)
|
||||
}
|
||||
|
@ -81,8 +105,9 @@ struct MentionsWidget: Widget {
|
|||
#Preview(as: .systemMedium) {
|
||||
MentionsWidget()
|
||||
} timeline: {
|
||||
PostsWidgetEntry(date: .now,
|
||||
title: "Mentions",
|
||||
statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()],
|
||||
images: [:])
|
||||
PostsWidgetEntry(
|
||||
date: .now,
|
||||
title: "Mentions",
|
||||
statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()],
|
||||
images: [:])
|
||||
}
|
||||
|
|
|
@ -52,16 +52,18 @@ struct PostsWidgetView: View {
|
|||
@ViewBuilder
|
||||
private func makeStatusView(_ status: Status) -> some View {
|
||||
if let url = URL(string: status.url ?? "") {
|
||||
Link(destination: url, label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
makeStatusHeaderView(status)
|
||||
Text(status.content.asSafeMarkdownAttributedString)
|
||||
.font(.footnote)
|
||||
.lineLimit(contentLineLimit)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 20)
|
||||
}
|
||||
})
|
||||
Link(
|
||||
destination: url,
|
||||
label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
makeStatusHeaderView(status)
|
||||
Text(status.content.asSafeMarkdownAttributedString)
|
||||
.font(.footnote)
|
||||
.lineLimit(contentLineLimit)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 20)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,17 +7,20 @@ import Timeline
|
|||
import UIKit
|
||||
import WidgetKit
|
||||
|
||||
func loadStatuses(for timeline: TimelineFilter,
|
||||
account: AppAccountEntity,
|
||||
widgetFamily: WidgetFamily) async -> [Status]
|
||||
{
|
||||
func loadStatuses(
|
||||
for timeline: TimelineFilter,
|
||||
account: AppAccountEntity,
|
||||
widgetFamily: WidgetFamily
|
||||
) async -> [Status] {
|
||||
let client = Client(server: account.account.server, oauthToken: account.account.oauthToken)
|
||||
do {
|
||||
var statuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
|
||||
maxId: nil,
|
||||
minId: nil,
|
||||
offset: nil,
|
||||
limit: 6))
|
||||
var statuses: [Status] = try await client.get(
|
||||
endpoint: timeline.endpoint(
|
||||
sinceId: nil,
|
||||
maxId: nil,
|
||||
minId: nil,
|
||||
offset: nil,
|
||||
limit: 6))
|
||||
statuses = statuses.filter { $0.reblog == nil && !$0.content.asRawText.isEmpty }
|
||||
switch widgetFamily {
|
||||
case .systemSmall, .systemMedium:
|
||||
|
|
|
@ -9,16 +9,19 @@ import Notifications
|
|||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
extension UNMutableNotificationContent: @unchecked @retroactive Sendable { }
|
||||
extension UNMutableNotificationContent: @unchecked @retroactive Sendable {}
|
||||
|
||||
class NotificationService: UNNotificationServiceExtension {
|
||||
override func didReceive(_ request: UNNotificationRequest,
|
||||
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||
|
||||
override func didReceive(
|
||||
_ request: UNNotificationRequest,
|
||||
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
||||
) {
|
||||
|
||||
let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent
|
||||
let provider = NotificationServiceContentProvider(bestAttemptContent: bestAttemptContent)
|
||||
let casted = unsafeBitCast(contentHandler,
|
||||
to: (@Sendable (UNNotificationContent) -> Void).self)
|
||||
let casted = unsafeBitCast(
|
||||
contentHandler,
|
||||
to: (@Sendable (UNNotificationContent) -> Void).self)
|
||||
Task {
|
||||
if let content = await provider.buildContent() {
|
||||
casted(content)
|
||||
|
@ -29,65 +32,72 @@ class NotificationService: UNNotificationServiceExtension {
|
|||
|
||||
actor NotificationServiceContentProvider {
|
||||
var bestAttemptContent: UNMutableNotificationContent?
|
||||
|
||||
|
||||
private let pushKeys = PushKeys()
|
||||
private let keychainAccounts = AppAccount.retrieveAll()
|
||||
|
||||
|
||||
init(bestAttemptContent: UNMutableNotificationContent? = nil) {
|
||||
self.bestAttemptContent = bestAttemptContent
|
||||
}
|
||||
|
||||
|
||||
func buildContent() async -> UNMutableNotificationContent? {
|
||||
if var bestAttemptContent {
|
||||
let privateKey = pushKeys.notificationsPrivateKeyAsKey
|
||||
let auth = pushKeys.notificationsAuthKeyAsKey
|
||||
|
||||
|
||||
guard let encodedPayload = bestAttemptContent.userInfo["m"] as? String,
|
||||
let payload = Data(base64Encoded: encodedPayload.URLSafeBase64ToBase64())
|
||||
let payload = Data(base64Encoded: encodedPayload.URLSafeBase64ToBase64())
|
||||
else {
|
||||
return bestAttemptContent
|
||||
}
|
||||
|
||||
|
||||
guard let encodedPublicKey = bestAttemptContent.userInfo["k"] as? String,
|
||||
let publicKeyData = Data(base64Encoded: encodedPublicKey.URLSafeBase64ToBase64()),
|
||||
let publicKey = try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData)
|
||||
let publicKeyData = Data(base64Encoded: encodedPublicKey.URLSafeBase64ToBase64()),
|
||||
let publicKey = try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData)
|
||||
else {
|
||||
return bestAttemptContent
|
||||
}
|
||||
|
||||
|
||||
guard let encodedSalt = bestAttemptContent.userInfo["s"] as? String,
|
||||
let salt = Data(base64Encoded: encodedSalt.URLSafeBase64ToBase64())
|
||||
let salt = Data(base64Encoded: encodedSalt.URLSafeBase64ToBase64())
|
||||
else {
|
||||
return bestAttemptContent
|
||||
}
|
||||
|
||||
guard let plaintextData = NotificationService.decrypt(payload: payload,
|
||||
salt: salt,
|
||||
auth: auth,
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey),
|
||||
let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData)
|
||||
|
||||
guard
|
||||
let plaintextData = NotificationService.decrypt(
|
||||
payload: payload,
|
||||
salt: salt,
|
||||
auth: auth,
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey),
|
||||
let notification = try? JSONDecoder().decode(
|
||||
MastodonPushNotification.self, from: plaintextData)
|
||||
else {
|
||||
return bestAttemptContent
|
||||
}
|
||||
|
||||
|
||||
bestAttemptContent.title = notification.title
|
||||
if keychainAccounts.count > 1 {
|
||||
bestAttemptContent.subtitle = bestAttemptContent.userInfo["i"] as? String ?? ""
|
||||
}
|
||||
bestAttemptContent.body = notification.body.escape()
|
||||
bestAttemptContent.userInfo["plaintext"] = plaintextData
|
||||
bestAttemptContent.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "glass.caf"))
|
||||
bestAttemptContent.sound = UNNotificationSound(
|
||||
named: UNNotificationSoundName(rawValue: "glass.caf"))
|
||||
let badgeCount = await updateBadgeCoung(notification: notification)
|
||||
bestAttemptContent.badge = .init(integerLiteral: badgeCount)
|
||||
|
||||
|
||||
if let urlString = notification.icon,
|
||||
let url = URL(string: urlString) {
|
||||
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("notification-attachments")
|
||||
try? FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||
let url = URL(string: urlString)
|
||||
{
|
||||
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("notification-attachments")
|
||||
try? FileManager.default.createDirectory(
|
||||
at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||
let filename = url.lastPathComponent
|
||||
let fileURL = temporaryDirectoryURL.appendingPathComponent(filename)
|
||||
|
||||
|
||||
// Warning: Non-sendable type '(any URLSessionTaskDelegate)?' exiting main actor-isolated
|
||||
// context in call to non-isolated instance method 'data(for:delegate:)' cannot cross actor
|
||||
// boundary.
|
||||
|
@ -96,20 +106,23 @@ actor NotificationServiceContentProvider {
|
|||
if let (data, _) = try? await URLSession.shared.data(for: .init(url: url)) {
|
||||
if let image = UIImage(data: data) {
|
||||
try? image.pngData()?.write(to: fileURL)
|
||||
|
||||
|
||||
if let remoteNotification = await toRemoteNotification(localNotification: notification),
|
||||
let type = remoteNotification.supportedType
|
||||
let type = remoteNotification.supportedType
|
||||
{
|
||||
let intent = buildMessageIntent(remoteNotification: remoteNotification,
|
||||
currentUser: bestAttemptContent.userInfo["i"] as? String ?? "",
|
||||
avatarURL: fileURL)
|
||||
let intent = buildMessageIntent(
|
||||
remoteNotification: remoteNotification,
|
||||
currentUser: bestAttemptContent.userInfo["i"] as? String ?? "",
|
||||
avatarURL: fileURL)
|
||||
do {
|
||||
bestAttemptContent = try bestAttemptContent.updating(from: intent) as! UNMutableNotificationContent
|
||||
bestAttemptContent =
|
||||
try bestAttemptContent.updating(from: intent) as! UNMutableNotificationContent
|
||||
bestAttemptContent.threadIdentifier = remoteNotification.type
|
||||
if type == .mention {
|
||||
bestAttemptContent.body = notification.body.escape()
|
||||
} else {
|
||||
let newBody = "\(NSLocalizedString(type.notificationKey(), bundle: .main, comment: ""))\(notification.body.escape())"
|
||||
let newBody =
|
||||
"\(NSLocalizedString(type.notificationKey(), bundle: .main, comment: ""))\(notification.body.escape())"
|
||||
bestAttemptContent.body = newBody
|
||||
}
|
||||
return bestAttemptContent
|
||||
|
@ -117,9 +130,11 @@ actor NotificationServiceContentProvider {
|
|||
return bestAttemptContent
|
||||
}
|
||||
} else {
|
||||
if let attachment = try? UNNotificationAttachment(identifier: filename,
|
||||
url: fileURL,
|
||||
options: nil) {
|
||||
if let attachment = try? UNNotificationAttachment(
|
||||
identifier: filename,
|
||||
url: fileURL,
|
||||
options: nil)
|
||||
{
|
||||
bestAttemptContent.attachments = [attachment]
|
||||
}
|
||||
}
|
||||
|
@ -133,13 +148,17 @@ actor NotificationServiceContentProvider {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
private func toRemoteNotification(localNotification: MastodonPushNotification) async -> Models.Notification? {
|
||||
|
||||
private func toRemoteNotification(localNotification: MastodonPushNotification) async -> Models
|
||||
.Notification?
|
||||
{
|
||||
do {
|
||||
if let account = keychainAccounts.first(where: { $0.oauthToken?.accessToken == localNotification.accessToken }) {
|
||||
if let account = keychainAccounts.first(where: {
|
||||
$0.oauthToken?.accessToken == localNotification.accessToken
|
||||
}) {
|
||||
let client = Client(server: account.server, oauthToken: account.oauthToken)
|
||||
let remoteNotification: Models.Notification = try await client.get(endpoint: Notifications.notification(id: String(localNotification.notificationID)))
|
||||
let remoteNotification: Models.Notification = try await client.get(
|
||||
endpoint: Notifications.notification(id: String(localNotification.notificationID)))
|
||||
return remoteNotification
|
||||
}
|
||||
} catch {
|
||||
|
@ -147,52 +166,58 @@ actor NotificationServiceContentProvider {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func buildMessageIntent(remoteNotification: Models.Notification,
|
||||
currentUser: String,
|
||||
avatarURL: URL) -> INSendMessageIntent
|
||||
{
|
||||
|
||||
private func buildMessageIntent(
|
||||
remoteNotification: Models.Notification,
|
||||
currentUser: String,
|
||||
avatarURL: URL
|
||||
) -> INSendMessageIntent {
|
||||
let handle = INPersonHandle(value: remoteNotification.account.id, type: .unknown)
|
||||
let avatar = INImage(url: avatarURL)
|
||||
let sender = INPerson(personHandle: handle,
|
||||
nameComponents: nil,
|
||||
displayName: remoteNotification.account.safeDisplayName,
|
||||
image: avatar,
|
||||
contactIdentifier: nil,
|
||||
customIdentifier: nil)
|
||||
let sender = INPerson(
|
||||
personHandle: handle,
|
||||
nameComponents: nil,
|
||||
displayName: remoteNotification.account.safeDisplayName,
|
||||
image: avatar,
|
||||
contactIdentifier: nil,
|
||||
customIdentifier: nil)
|
||||
var recipents: [INPerson]?
|
||||
var groupName: INSpeakableString?
|
||||
if keychainAccounts.count > 1 {
|
||||
let me = INPerson(personHandle: .init(value: currentUser, type: .unknown),
|
||||
nameComponents: nil,
|
||||
displayName: currentUser,
|
||||
image: nil,
|
||||
contactIdentifier: nil,
|
||||
customIdentifier: nil)
|
||||
let me = INPerson(
|
||||
personHandle: .init(value: currentUser, type: .unknown),
|
||||
nameComponents: nil,
|
||||
displayName: currentUser,
|
||||
image: nil,
|
||||
contactIdentifier: nil,
|
||||
customIdentifier: nil)
|
||||
recipents = [me, sender]
|
||||
groupName = .init(spokenPhrase: currentUser)
|
||||
}
|
||||
let intent = INSendMessageIntent(recipients: recipents,
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: nil,
|
||||
speakableGroupName: groupName,
|
||||
conversationIdentifier: remoteNotification.account.id,
|
||||
serviceName: nil,
|
||||
sender: sender,
|
||||
attachments: nil)
|
||||
let intent = INSendMessageIntent(
|
||||
recipients: recipents,
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: nil,
|
||||
speakableGroupName: groupName,
|
||||
conversationIdentifier: remoteNotification.account.id,
|
||||
serviceName: nil,
|
||||
sender: sender,
|
||||
attachments: nil)
|
||||
if groupName != nil {
|
||||
intent.setImage(avatar, forParameterNamed: \.speakableGroupName)
|
||||
}
|
||||
return intent
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
private func updateBadgeCoung(notification: MastodonPushNotification) -> Int {
|
||||
let preferences = UserPreferences.shared
|
||||
let tokens = AppAccountsManager.shared.pushAccounts.map(\.token)
|
||||
preferences.reloadNotificationsCount(tokens: tokens)
|
||||
|
||||
if let token = keychainAccounts.first(where: { $0.oauthToken?.accessToken == notification.accessToken })?.oauthToken {
|
||||
|
||||
if let token = keychainAccounts.first(where: {
|
||||
$0.oauthToken?.accessToken == notification.accessToken
|
||||
})?.oauthToken {
|
||||
var currentCount = preferences.notificationsCount[token] ?? 0
|
||||
currentCount += 1
|
||||
preferences.notificationsCount[token] = currentCount
|
||||
|
|
|
@ -2,18 +2,29 @@ import CryptoKit
|
|||
import Foundation
|
||||
|
||||
extension NotificationService {
|
||||
static func decrypt(payload: Data, salt: Data, auth: Data, privateKey: P256.KeyAgreement.PrivateKey, publicKey: P256.KeyAgreement.PublicKey) -> Data? {
|
||||
static func decrypt(
|
||||
payload: Data, salt: Data, auth: Data, privateKey: P256.KeyAgreement.PrivateKey,
|
||||
publicKey: P256.KeyAgreement.PublicKey
|
||||
) -> Data? {
|
||||
guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: publicKey) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let keyMaterial = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: auth, sharedInfo: Data("Content-Encoding: auth\0".utf8), outputByteCount: 32)
|
||||
let keyMaterial = sharedSecret.hkdfDerivedSymmetricKey(
|
||||
using: SHA256.self, salt: auth, sharedInfo: Data("Content-Encoding: auth\0".utf8),
|
||||
outputByteCount: 32)
|
||||
|
||||
let keyInfo = info(type: "aesgcm", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
|
||||
let key = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16)
|
||||
let keyInfo = info(
|
||||
type: "aesgcm", clientPublicKey: privateKey.publicKey.x963Representation,
|
||||
serverPublicKey: publicKey.x963Representation)
|
||||
let key = HKDF<SHA256>.deriveKey(
|
||||
inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16)
|
||||
|
||||
let nonceInfo = info(type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation)
|
||||
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12)
|
||||
let nonceInfo = info(
|
||||
type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation,
|
||||
serverPublicKey: publicKey.x963Representation)
|
||||
let nonce = HKDF<SHA256>.deriveKey(
|
||||
inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12)
|
||||
|
||||
let nonceData = nonce.withUnsafeBytes(Array.init)
|
||||
|
||||
|
|
|
@ -59,10 +59,11 @@ class ShareViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(forName: .shareSheetClose,
|
||||
object: nil,
|
||||
queue: nil)
|
||||
{ [weak self] _ in
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .shareSheetClose,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { [weak self] _ in
|
||||
self?.close()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ let package = Package(
|
|||
.library(
|
||||
name: "Account",
|
||||
targets: ["Account"]
|
||||
),
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Network", path: "../Network"),
|
||||
|
@ -36,7 +36,7 @@ let package = Package(
|
|||
.product(name: "WrappingHStack", package: "WrappingHStack"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v6),
|
||||
.swiftLanguageMode(.v6)
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
|
|
|
@ -18,8 +18,9 @@ public struct AccountDetailContextMenu: View {
|
|||
Section(account.acct) {
|
||||
if !viewModel.isCurrentUser {
|
||||
Button {
|
||||
routerPath.presentedSheet = .mentionStatusEditor(account: account,
|
||||
visibility: preferences.postVisibility)
|
||||
routerPath.presentedSheet = .mentionStatusEditor(
|
||||
account: account,
|
||||
visibility: preferences.postVisibility)
|
||||
} label: {
|
||||
Label("account.action.mention", systemImage: "at")
|
||||
}
|
||||
|
@ -37,11 +38,13 @@ public struct AccountDetailContextMenu: View {
|
|||
Button {
|
||||
Task {
|
||||
do {
|
||||
viewModel.relationship = try await client.post(endpoint: Accounts.unblock(id: account.id))
|
||||
viewModel.relationship = try await client.post(
|
||||
endpoint: Accounts.unblock(id: account.id))
|
||||
} catch {}
|
||||
}
|
||||
} label: {
|
||||
Label("account.action.unblock", systemImage: "person.crop.circle.badge.exclamationmark")
|
||||
Label(
|
||||
"account.action.unblock", systemImage: "person.crop.circle.badge.exclamationmark")
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
|
@ -55,7 +58,8 @@ public struct AccountDetailContextMenu: View {
|
|||
Button {
|
||||
Task {
|
||||
do {
|
||||
viewModel.relationship = try await client.post(endpoint: Accounts.unmute(id: account.id))
|
||||
viewModel.relationship = try await client.post(
|
||||
endpoint: Accounts.unmute(id: account.id))
|
||||
} catch {}
|
||||
}
|
||||
} label: {
|
||||
|
@ -67,7 +71,9 @@ public struct AccountDetailContextMenu: View {
|
|||
Button(duration.description) {
|
||||
Task {
|
||||
do {
|
||||
viewModel.relationship = try await client.post(endpoint: Accounts.mute(id: account.id, json: MuteData(duration: duration.rawValue)))
|
||||
viewModel.relationship = try await client.post(
|
||||
endpoint: Accounts.mute(
|
||||
id: account.id, json: MuteData(duration: duration.rawValue)))
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
@ -78,15 +84,17 @@ public struct AccountDetailContextMenu: View {
|
|||
}
|
||||
|
||||
if let relationship = viewModel.relationship,
|
||||
relationship.following
|
||||
relationship.following
|
||||
{
|
||||
if relationship.notifying {
|
||||
Button {
|
||||
Task {
|
||||
do {
|
||||
viewModel.relationship = try await client.post(endpoint: Accounts.follow(id: account.id,
|
||||
notify: false,
|
||||
reblogs: relationship.showingReblogs))
|
||||
viewModel.relationship = try await client.post(
|
||||
endpoint: Accounts.follow(
|
||||
id: account.id,
|
||||
notify: false,
|
||||
reblogs: relationship.showingReblogs))
|
||||
} catch {}
|
||||
}
|
||||
} label: {
|
||||
|
@ -96,9 +104,11 @@ public struct AccountDetailContextMenu: View {
|
|||
Button {
|
||||
Task {
|
||||
do {
|
||||
viewModel.relationship = try await client.post(endpoint: Accounts.follow(id: account.id,
|
||||
notify: true,
|
||||
reblogs: relationship.showingReblogs))
|
||||
viewModel.relationship = try await client.post(
|
||||
endpoint: Accounts.follow(
|
||||
id: account.id,
|
||||
notify: true,
|
||||
reblogs: relationship.showingReblogs))
|
||||
} catch {}
|
||||
}
|
||||
} label: {
|
||||
|
@ -109,9 +119,11 @@ public struct AccountDetailContextMenu: View {
|
|||
Button {
|
||||
Task {
|
||||
do {
|
||||
viewModel.relationship = try await client.post(endpoint: Accounts.follow(id: account.id,
|
||||
notify: relationship.notifying,
|
||||
reblogs: false))
|
||||
viewModel.relationship = try await client.post(
|
||||
endpoint: Accounts.follow(
|
||||
id: account.id,
|
||||
notify: relationship.notifying,
|
||||
reblogs: false))
|
||||
} catch {}
|
||||
}
|
||||
} label: {
|
||||
|
@ -121,9 +133,11 @@ public struct AccountDetailContextMenu: View {
|
|||
Button {
|
||||
Task {
|
||||
do {
|
||||
viewModel.relationship = try await client.post(endpoint: Accounts.follow(id: account.id,
|
||||
notify: relationship.notifying,
|
||||
reblogs: true))
|
||||
viewModel.relationship = try await client.post(
|
||||
endpoint: Accounts.follow(
|
||||
id: account.id,
|
||||
notify: relationship.notifying,
|
||||
reblogs: true))
|
||||
} catch {}
|
||||
}
|
||||
} label: {
|
||||
|
@ -159,7 +173,9 @@ public struct AccountDetailContextMenu: View {
|
|||
ShareLink(item: url, subject: Text(account.safeDisplayName)) {
|
||||
Label("account.action.share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Button { UIApplication.shared.open(url) } label: {
|
||||
Button {
|
||||
UIApplication.shared.open(url)
|
||||
} label: {
|
||||
Label("status.action.view-in-browser", systemImage: "safari")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import AppAccount
|
||||
import DesignSystem
|
||||
import EmojiText
|
||||
import Env
|
||||
import Models
|
||||
import NukeUI
|
||||
import SwiftUI
|
||||
import AppAccount
|
||||
|
||||
@MainActor
|
||||
struct AccountDetailHeaderView: View {
|
||||
enum Constants {
|
||||
static let headerHeight: CGFloat = 200
|
||||
}
|
||||
|
||||
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@Environment(Theme.self) private var theme
|
||||
@Environment(QuickLook.self) private var quickLook
|
||||
|
@ -27,7 +27,7 @@ struct AccountDetailHeaderView: View {
|
|||
var viewModel: AccountDetailViewModel
|
||||
let account: Account
|
||||
let scrollViewProxy: ScrollViewProxy?
|
||||
|
||||
|
||||
private let premiumTimer = Timer.publish(every: 5, on: .main, in: .common).autoconnect()
|
||||
@State private var shouldListenToPremiumTimer: Bool = false
|
||||
@State private var isTipSheetPresented: Bool = false
|
||||
|
@ -54,13 +54,16 @@ struct AccountDetailHeaderView: View {
|
|||
Spacer()
|
||||
}
|
||||
.onChange(of: watcher.latestEvent?.id) {
|
||||
if let latestEvent = watcher.latestEvent, let latestEvent = latestEvent as? StreamEventNotification {
|
||||
if latestEvent.notification.account.id == viewModel.accountId ||
|
||||
latestEvent.notification.account.id == viewModel.premiumAccount?.id {
|
||||
if let latestEvent = watcher.latestEvent,
|
||||
let latestEvent = latestEvent as? StreamEventNotification
|
||||
{
|
||||
if latestEvent.notification.account.id == viewModel.accountId
|
||||
|| latestEvent.notification.account.id == viewModel.premiumAccount?.id
|
||||
{
|
||||
Task {
|
||||
if viewModel.account?.isLinkedToPremiumAccount == true {
|
||||
await viewModel.fetchAccount()
|
||||
} else{
|
||||
} else {
|
||||
try? await viewModel.followButtonViewModel?.refreshRelationship()
|
||||
}
|
||||
}
|
||||
|
@ -72,7 +75,7 @@ struct AccountDetailHeaderView: View {
|
|||
Task {
|
||||
if viewModel.account?.isLinkedToPremiumAccount == true {
|
||||
await viewModel.fetchAccount()
|
||||
} else{
|
||||
} else {
|
||||
try? await viewModel.followButtonViewModel?.refreshRelationship()
|
||||
}
|
||||
}
|
||||
|
@ -105,7 +108,7 @@ struct AccountDetailHeaderView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.frame(height: Constants.headerHeight)
|
||||
.onTapGesture {
|
||||
|
@ -114,10 +117,11 @@ struct AccountDetailHeaderView: View {
|
|||
}
|
||||
let attachement = MediaAttachment.imageWith(url: account.header)
|
||||
#if targetEnvironment(macCatalyst) || os(visionOS)
|
||||
openWindow(value: WindowDestinationMedia.mediaViewer(
|
||||
attachments: [attachement],
|
||||
selectedAttachment: attachement
|
||||
))
|
||||
openWindow(
|
||||
value: WindowDestinationMedia.mediaViewer(
|
||||
attachments: [attachement],
|
||||
selectedAttachment: attachement
|
||||
))
|
||||
#else
|
||||
quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement])
|
||||
#endif
|
||||
|
@ -139,8 +143,10 @@ struct AccountDetailHeaderView: View {
|
|||
.resizable()
|
||||
.frame(width: 25, height: 25)
|
||||
.foregroundColor(theme.tintColor)
|
||||
.offset(x: theme.avatarShape == .circle ? 0 : 10,
|
||||
y: theme.avatarShape == .circle ? 0 : -10)
|
||||
.offset(
|
||||
x: theme.avatarShape == .circle ? 0 : 10,
|
||||
y: theme.avatarShape == .circle ? 0 : -10
|
||||
)
|
||||
.accessibilityRemoveTraits(.isSelected)
|
||||
.accessibilityLabel("accessibility.tabs.profile.user-avatar.supporter.label")
|
||||
}
|
||||
|
@ -151,10 +157,13 @@ struct AccountDetailHeaderView: View {
|
|||
}
|
||||
let attachement = MediaAttachment.imageWith(url: account.avatar)
|
||||
#if targetEnvironment(macCatalyst) || os(visionOS)
|
||||
openWindow(value: WindowDestinationMedia.mediaViewer(attachments: [attachement],
|
||||
selectedAttachment: attachement))
|
||||
openWindow(
|
||||
value: WindowDestinationMedia.mediaViewer(
|
||||
attachments: [attachement],
|
||||
selectedAttachment: attachement))
|
||||
#else
|
||||
quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement])
|
||||
quickLook.prepareFor(
|
||||
selectedMediaAttachment: attachement, mediaAttachments: [attachement])
|
||||
#endif
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
|
@ -188,7 +197,8 @@ struct AccountDetailHeaderView: View {
|
|||
makeCustomInfoLabel(
|
||||
title: "account.followers",
|
||||
count: account.followersCount ?? 0,
|
||||
needsBadge: currentAccount.account?.id == account.id && !currentAccount.followRequests.isEmpty
|
||||
needsBadge: currentAccount.account?.id == account.id
|
||||
&& !currentAccount.followRequests.isEmpty
|
||||
)
|
||||
}
|
||||
.accessibilityHint("accessibility.tabs.profile.follower-count.hint")
|
||||
|
@ -244,7 +254,11 @@ struct AccountDetailHeaderView: View {
|
|||
.accessibilityRespondsToUserInteraction(false)
|
||||
movedToView
|
||||
joinedAtView
|
||||
if viewModel.account?.isPremiumAccount == true && viewModel.relationship?.following == false || viewModel.account?.isLinkedToPremiumAccount == true && viewModel.premiumRelationship?.following == false {
|
||||
if viewModel.account?.isPremiumAccount == true
|
||||
&& viewModel.relationship?.following == false
|
||||
|| viewModel.account?.isLinkedToPremiumAccount == true
|
||||
&& viewModel.premiumRelationship?.following == false
|
||||
{
|
||||
subscribeButton
|
||||
}
|
||||
}
|
||||
|
@ -262,7 +276,7 @@ struct AccountDetailHeaderView: View {
|
|||
}
|
||||
|
||||
if let note = viewModel.relationship?.note, !note.isEmpty,
|
||||
!viewModel.isCurrentUser
|
||||
!viewModel.isCurrentUser
|
||||
{
|
||||
makeNoteView(note)
|
||||
}
|
||||
|
@ -274,9 +288,12 @@ struct AccountDetailHeaderView: View {
|
|||
.emojiText.baselineOffset(Font.scaledBodyFont.emojiBaselineOffset)
|
||||
.padding(.top, 8)
|
||||
.textSelection(.enabled)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
routerPath.handle(url: url)
|
||||
})
|
||||
.environment(
|
||||
\.openURL,
|
||||
OpenURLAction { url in
|
||||
routerPath.handle(url: url)
|
||||
}
|
||||
)
|
||||
.accessibilityRespondsToUserInteraction(false)
|
||||
|
||||
if let translation = viewModel.translation, !viewModel.isLoadingTranslation {
|
||||
|
@ -284,9 +301,12 @@ struct AccountDetailHeaderView: View {
|
|||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(translation.content.asSafeMarkdownAttributedString)
|
||||
.font(.scaledBody)
|
||||
Text(getLocalizedStringLabel(langCode: translation.detectedSourceLanguage, provider: translation.provider))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(
|
||||
getLocalizedStringLabel(
|
||||
langCode: translation.detectedSourceLanguage, provider: translation.provider)
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
@ -307,7 +327,9 @@ struct AccountDetailHeaderView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func makeCustomInfoLabel(title: LocalizedStringKey, count: Int, needsBadge: Bool = false) -> some View {
|
||||
private func makeCustomInfoLabel(title: LocalizedStringKey, count: Int, needsBadge: Bool = false)
|
||||
-> some View
|
||||
{
|
||||
VStack {
|
||||
Text(count, format: .number.notation(.compactName))
|
||||
.font(.scaledHeadline)
|
||||
|
@ -344,27 +366,31 @@ struct AccountDetailHeaderView: View {
|
|||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var subscribeButton: some View {
|
||||
Button {
|
||||
if let subscription = viewModel.subClubUser?.subscription,
|
||||
let accountName = appAccount.currentAccount.accountName,
|
||||
let premiumUsername = account.premiumUsername,
|
||||
let url = URL(string: "https://\(AppInfo.premiumInstance)/@\(premiumUsername)/subscribe?callback=icecubesapp://subclub&id=@\(accountName)&amount=\(subscription.unitAmount)¤cy=\(subscription.currency)&theme=\(colorScheme)") {
|
||||
let accountName = appAccount.currentAccount.accountName,
|
||||
let premiumUsername = account.premiumUsername,
|
||||
let url = URL(
|
||||
string:
|
||||
"https://\(AppInfo.premiumInstance)/@\(premiumUsername)/subscribe?callback=icecubesapp://subclub&id=@\(accountName)&amount=\(subscription.unitAmount)¤cy=\(subscription.currency)&theme=\(colorScheme)"
|
||||
)
|
||||
{
|
||||
openURL(url)
|
||||
} else {
|
||||
isTipSheetPresented = true
|
||||
shouldListenToPremiumTimer = true
|
||||
}
|
||||
|
||||
|
||||
Task {
|
||||
if viewModel.account?.isLinkedToPremiumAccount == true {
|
||||
try? await viewModel.followPremiumAccount()
|
||||
}
|
||||
try? await viewModel.followButtonViewModel?.follow()
|
||||
}
|
||||
|
||||
|
||||
} label: {
|
||||
if let subscription = viewModel.subClubUser?.subscription {
|
||||
Text("Subscribe \(subscription.formattedAmount) / month")
|
||||
|
@ -401,9 +427,9 @@ struct AccountDetailHeaderView: View {
|
|||
Text(note)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
#if !os(visionOS)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
#if !os(visionOS)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.cornerRadius(4)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
|
@ -433,10 +459,15 @@ struct AccountDetailHeaderView: View {
|
|||
.emojiText.size(Font.scaledBodyFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledBodyFont.emojiBaselineOffset)
|
||||
.foregroundColor(theme.tintColor)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
routerPath.handle(url: url)
|
||||
})
|
||||
.accessibilityValue(field.verifiedAt != nil ? "accessibility.tabs.profile.fields.verified.label" : "")
|
||||
.environment(
|
||||
\.openURL,
|
||||
OpenURLAction { url in
|
||||
routerPath.handle(url: url)
|
||||
}
|
||||
)
|
||||
.accessibilityValue(
|
||||
field.verifiedAt != nil
|
||||
? "accessibility.tabs.profile.fields.verified.label" : "")
|
||||
}
|
||||
.font(.scaledBody)
|
||||
if viewModel.fields.last != field {
|
||||
|
@ -447,7 +478,9 @@ struct AccountDetailHeaderView: View {
|
|||
Spacer()
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.modifier(ConditionalUserDefinedFieldAccessibilityActionModifier(field: field, routerPath: routerPath))
|
||||
.modifier(
|
||||
ConditionalUserDefinedFieldAccessibilityActionModifier(
|
||||
field: field, routerPath: routerPath))
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
|
@ -458,11 +491,11 @@ struct AccountDetailHeaderView: View {
|
|||
#else
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.cornerRadius(4)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(.gray.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
.cornerRadius(4)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(.gray.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -492,8 +525,9 @@ private struct ConditionalUserDefinedFieldAccessibilityActionModifier: ViewModif
|
|||
|
||||
struct AccountDetailHeaderView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
AccountDetailHeaderView(viewModel: .init(account: .placeholder()),
|
||||
account: .placeholder(),
|
||||
scrollViewProxy: nil)
|
||||
AccountDetailHeaderView(
|
||||
viewModel: .init(account: .placeholder()),
|
||||
account: .placeholder(),
|
||||
scrollViewProxy: nil)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,8 +52,7 @@ public struct AccountDetailView: View {
|
|||
.applyAccountDetailsRowStyle(theme: theme)
|
||||
|
||||
Picker("", selection: $viewModel.selectedTab) {
|
||||
ForEach(viewModel.tabs, id: \.self)
|
||||
{ tab in
|
||||
ForEach(viewModel.tabs, id: \.self) { tab in
|
||||
if tab == .boosts {
|
||||
Image("Rocket")
|
||||
.tag(tab)
|
||||
|
@ -81,17 +80,20 @@ public struct AccountDetailView: View {
|
|||
}
|
||||
.onTapGesture {
|
||||
if let account = viewModel.account {
|
||||
routerPath.navigate(to: .accountMediaGridView(account: account,
|
||||
initialMediaStatuses: viewModel.statusesMedias))
|
||||
routerPath.navigate(
|
||||
to: .accountMediaGridView(
|
||||
account: account,
|
||||
initialMediaStatuses: viewModel.statusesMedias))
|
||||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
StatusesListView(fetcher: viewModel,
|
||||
client: client,
|
||||
routerPath: routerPath)
|
||||
StatusesListView(
|
||||
fetcher: viewModel,
|
||||
client: client,
|
||||
routerPath: routerPath)
|
||||
}
|
||||
.environment(\.defaultMinListRowHeight, 0)
|
||||
.listStyle(.plain)
|
||||
|
@ -133,7 +135,7 @@ public struct AccountDetailView: View {
|
|||
}
|
||||
.onChange(of: watcher.latestEvent?.id) {
|
||||
if let latestEvent = watcher.latestEvent,
|
||||
viewModel.accountId == currentAccount.account?.id
|
||||
viewModel.accountId == currentAccount.account?.id
|
||||
{
|
||||
viewModel.handleEvent(event: latestEvent, currentAccount: currentAccount)
|
||||
}
|
||||
|
@ -146,9 +148,12 @@ public struct AccountDetailView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isEditingRelationshipNote, content: {
|
||||
EditRelationshipNoteView(accountDetailViewModel: viewModel)
|
||||
})
|
||||
.sheet(
|
||||
isPresented: $isEditingRelationshipNote,
|
||||
content: {
|
||||
EditRelationshipNoteView(accountDetailViewModel: viewModel)
|
||||
}
|
||||
)
|
||||
.edgesIgnoringSafeArea(.top)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
@ -160,15 +165,18 @@ public struct AccountDetailView: View {
|
|||
private func makeHeaderView(proxy: ScrollViewProxy?) -> some View {
|
||||
switch viewModel.accountState {
|
||||
case .loading:
|
||||
AccountDetailHeaderView(viewModel: viewModel,
|
||||
account: .placeholder(),
|
||||
scrollViewProxy: proxy)
|
||||
.redacted(reason: .placeholder)
|
||||
.allowsHitTesting(false)
|
||||
AccountDetailHeaderView(
|
||||
viewModel: viewModel,
|
||||
account: .placeholder(),
|
||||
scrollViewProxy: proxy
|
||||
)
|
||||
.redacted(reason: .placeholder)
|
||||
.allowsHitTesting(false)
|
||||
case let .data(account):
|
||||
AccountDetailHeaderView(viewModel: viewModel,
|
||||
account: account,
|
||||
scrollViewProxy: proxy)
|
||||
AccountDetailHeaderView(
|
||||
viewModel: viewModel,
|
||||
account: account,
|
||||
scrollViewProxy: proxy)
|
||||
case let .error(error):
|
||||
Text("Error: \(error.localizedDescription)")
|
||||
}
|
||||
|
@ -237,23 +245,27 @@ public struct AccountDetailView: View {
|
|||
.font(.scaledFootnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fontWeight(.semibold)
|
||||
.listRowInsets(.init(top: 0,
|
||||
leading: 12,
|
||||
bottom: 0,
|
||||
trailing: .layoutPadding))
|
||||
.listRowInsets(
|
||||
.init(
|
||||
top: 0,
|
||||
leading: 12,
|
||||
bottom: 0,
|
||||
trailing: .layoutPadding)
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
ForEach(viewModel.pinned) { status in
|
||||
StatusRowExternalView(viewModel: .init(status: status, client: client, routerPath: routerPath))
|
||||
StatusRowExternalView(
|
||||
viewModel: .init(status: status, client: client, routerPath: routerPath))
|
||||
}
|
||||
Rectangle()
|
||||
#if os(visionOS)
|
||||
.fill(Color.clear)
|
||||
#else
|
||||
.fill(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
#if os(visionOS)
|
||||
.fill(Color.clear)
|
||||
#else
|
||||
.fill(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.frame(height: 12)
|
||||
.listRowInsets(.init())
|
||||
.listRowSeparator(.hidden)
|
||||
|
@ -278,10 +290,13 @@ public struct AccountDetailView: View {
|
|||
Button {
|
||||
if let account = viewModel.account {
|
||||
#if targetEnvironment(macCatalyst) || os(visionOS)
|
||||
openWindow(value: WindowDestinationEditor.mentionStatusEditor(account: account, visibility: preferences.postVisibility))
|
||||
openWindow(
|
||||
value: WindowDestinationEditor.mentionStatusEditor(
|
||||
account: account, visibility: preferences.postVisibility))
|
||||
#else
|
||||
routerPath.presentedSheet = .mentionStatusEditor(account: account,
|
||||
visibility: preferences.postVisibility)
|
||||
routerPath.presentedSheet = .mentionStatusEditor(
|
||||
account: account,
|
||||
visibility: preferences.postVisibility)
|
||||
#endif
|
||||
}
|
||||
} label: {
|
||||
|
@ -290,9 +305,10 @@ public struct AccountDetailView: View {
|
|||
}
|
||||
|
||||
Menu {
|
||||
AccountDetailContextMenu(showBlockConfirmation: $showBlockConfirmation,
|
||||
showTranslateView: $showTranslateView,
|
||||
viewModel: viewModel)
|
||||
AccountDetailContextMenu(
|
||||
showBlockConfirmation: $showBlockConfirmation,
|
||||
showTranslateView: $showTranslateView,
|
||||
viewModel: viewModel)
|
||||
|
||||
if !viewModel.isCurrentUser {
|
||||
Button {
|
||||
|
@ -349,7 +365,10 @@ public struct AccountDetailView: View {
|
|||
Divider()
|
||||
|
||||
Button {
|
||||
if let url = URL(string: "https://mastometrics.com/auth/login?username=\(account.acct)@\(client.server)&instance=\(client.server)&auto=true") {
|
||||
if let url = URL(
|
||||
string:
|
||||
"https://mastometrics.com/auth/login?username=\(account.acct)@\(client.server)&instance=\(client.server)&auto=true"
|
||||
) {
|
||||
openURL(url)
|
||||
}
|
||||
} label: {
|
||||
|
@ -379,7 +398,8 @@ public struct AccountDetailView: View {
|
|||
Button("account.action.block-user-\(account.username)", role: .destructive) {
|
||||
Task {
|
||||
do {
|
||||
viewModel.relationship = try await client.post(endpoint: Accounts.block(id: account.id))
|
||||
viewModel.relationship = try await client.post(
|
||||
endpoint: Accounts.block(id: account.id))
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
@ -388,7 +408,8 @@ public struct AccountDetailView: View {
|
|||
Text("account.action.block-user-confirmation")
|
||||
}
|
||||
#if canImport(_Translation_SwiftUI)
|
||||
.addTranslateView(isPresented: $showTranslateView, text: viewModel.account?.note.asRawText ?? "")
|
||||
.addTranslateView(
|
||||
isPresented: $showTranslateView, text: viewModel.account?.note.asRawText ?? "")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@ -399,9 +420,9 @@ extension View {
|
|||
func applyAccountDetailsRowStyle(theme: Theme) -> some View {
|
||||
listRowInsets(.init())
|
||||
.listRowSeparator(.hidden)
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -12,7 +12,9 @@ import SwiftUI
|
|||
var isCurrentUser: Bool = false
|
||||
|
||||
enum AccountState {
|
||||
case loading, data(account: Account), error(error: Error)
|
||||
case loading
|
||||
case data(account: Account)
|
||||
case error(error: Error)
|
||||
}
|
||||
|
||||
enum Tab: Int {
|
||||
|
@ -25,7 +27,7 @@ import SwiftUI
|
|||
static var accountTabs: [Tab] {
|
||||
[.statuses, .replies, .boosts, .media]
|
||||
}
|
||||
|
||||
|
||||
static var premiumAccountTabs: [Tab] {
|
||||
[.statuses, .premiumPosts, .replies, .boosts, .media]
|
||||
}
|
||||
|
@ -54,8 +56,7 @@ import SwiftUI
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
var tabs: [Tab] {
|
||||
if isCurrentUser {
|
||||
return Tab.currentAccountTabs
|
||||
|
@ -78,13 +79,13 @@ import SwiftUI
|
|||
var featuredTags: [FeaturedTag] = []
|
||||
var fields: [Account.Field] = []
|
||||
var familiarFollowers: [Account] = []
|
||||
|
||||
|
||||
// Sub.club stuff
|
||||
var premiumAccount: Account?
|
||||
var premiumRelationship: Relationship?
|
||||
var subClubUser: SubClubUser?
|
||||
private let subClubClient = SubClubClient()
|
||||
|
||||
|
||||
var selectedTab = Tab.statuses {
|
||||
didSet {
|
||||
switch selectedTab {
|
||||
|
@ -103,9 +104,9 @@ import SwiftUI
|
|||
|
||||
var translation: Translation?
|
||||
var isLoadingTranslation = false
|
||||
|
||||
|
||||
var followButtonViewModel: FollowButtonViewModel?
|
||||
|
||||
|
||||
private(set) var account: Account?
|
||||
private var tabTask: Task<Void, Never>?
|
||||
|
||||
|
@ -139,7 +140,7 @@ import SwiftUI
|
|||
guard let client else { return }
|
||||
do {
|
||||
let data = try await fetchAccountData(accountId: accountId, client: client)
|
||||
|
||||
|
||||
accountState = .data(account: data.account)
|
||||
try await fetchPremiumAccount(fromAccount: data.account, client: client)
|
||||
account = data.account
|
||||
|
@ -151,13 +152,14 @@ import SwiftUI
|
|||
if let followButtonViewModel {
|
||||
followButtonViewModel.relationship = relationship
|
||||
} else {
|
||||
followButtonViewModel = .init(client: client,
|
||||
accountId: accountId,
|
||||
relationship: relationship,
|
||||
shouldDisplayNotify: true,
|
||||
relationshipUpdated: { [weak self] relationship in
|
||||
self?.relationship = relationship
|
||||
})
|
||||
followButtonViewModel = .init(
|
||||
client: client,
|
||||
accountId: accountId,
|
||||
relationship: relationship,
|
||||
shouldDisplayNotify: true,
|
||||
relationshipUpdated: { [weak self] relationship in
|
||||
self?.relationship = relationship
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
@ -171,26 +173,32 @@ import SwiftUI
|
|||
|
||||
private func fetchAccountData(accountId: String, client: Client) async throws -> AccountData {
|
||||
async let account: Account = client.get(endpoint: Accounts.accounts(id: accountId))
|
||||
async let featuredTags: [FeaturedTag] = client.get(endpoint: Accounts.featuredTags(id: accountId))
|
||||
async let featuredTags: [FeaturedTag] = client.get(
|
||||
endpoint: Accounts.featuredTags(id: accountId))
|
||||
if client.isAuth, !isCurrentUser {
|
||||
async let relationships: [Relationship] = client.get(endpoint: Accounts.relationships(ids: [accountId]))
|
||||
async let relationships: [Relationship] = client.get(
|
||||
endpoint: Accounts.relationships(ids: [accountId]))
|
||||
do {
|
||||
return try await .init(account: account,
|
||||
featuredTags: featuredTags,
|
||||
relationships: relationships)
|
||||
return try await .init(
|
||||
account: account,
|
||||
featuredTags: featuredTags,
|
||||
relationships: relationships)
|
||||
} catch {
|
||||
return try await .init(account: account,
|
||||
featuredTags: [],
|
||||
relationships: relationships)
|
||||
return try await .init(
|
||||
account: account,
|
||||
featuredTags: [],
|
||||
relationships: relationships)
|
||||
}
|
||||
}
|
||||
return try await .init(account: account,
|
||||
featuredTags: featuredTags,
|
||||
relationships: [])
|
||||
return try await .init(
|
||||
account: account,
|
||||
featuredTags: featuredTags,
|
||||
relationships: [])
|
||||
}
|
||||
|
||||
func fetchFamilliarFollowers() async {
|
||||
let familiarFollowers: [FamiliarAccounts]? = try? await client?.get(endpoint: Accounts.familiarFollowers(withAccount: accountId))
|
||||
let familiarFollowers: [FamiliarAccounts]? = try? await client?.get(
|
||||
endpoint: Accounts.familiarFollowers(withAccount: accountId))
|
||||
self.familiarFollowers = familiarFollowers?.first?.accounts ?? []
|
||||
}
|
||||
|
||||
|
@ -204,31 +212,37 @@ import SwiftUI
|
|||
accountIdToFetch = accountId
|
||||
}
|
||||
statuses =
|
||||
try await client.get(endpoint: Accounts.statuses(id: accountIdToFetch,
|
||||
sinceId: nil,
|
||||
tag: nil,
|
||||
onlyMedia: selectedTab == .media,
|
||||
excludeReplies: selectedTab != .replies,
|
||||
excludeReblogs: selectedTab != .boosts,
|
||||
pinned: nil))
|
||||
try await client.get(
|
||||
endpoint: Accounts.statuses(
|
||||
id: accountIdToFetch,
|
||||
sinceId: nil,
|
||||
tag: nil,
|
||||
onlyMedia: selectedTab == .media,
|
||||
excludeReplies: selectedTab != .replies,
|
||||
excludeReblogs: selectedTab != .boosts,
|
||||
pinned: nil))
|
||||
StatusDataControllerProvider.shared.updateDataControllers(for: statuses, client: client)
|
||||
if selectedTab == .boosts {
|
||||
boosts = statuses.filter { $0.reblog != nil }
|
||||
}
|
||||
if selectedTab == .statuses {
|
||||
pinned =
|
||||
try await client.get(endpoint: Accounts.statuses(id: accountId,
|
||||
sinceId: nil,
|
||||
tag: nil,
|
||||
onlyMedia: false,
|
||||
excludeReplies: false,
|
||||
excludeReblogs: false,
|
||||
pinned: true))
|
||||
try await client.get(
|
||||
endpoint: Accounts.statuses(
|
||||
id: accountId,
|
||||
sinceId: nil,
|
||||
tag: nil,
|
||||
onlyMedia: false,
|
||||
excludeReplies: false,
|
||||
excludeReblogs: false,
|
||||
pinned: true))
|
||||
StatusDataControllerProvider.shared.updateDataControllers(for: pinned, client: client)
|
||||
}
|
||||
if isCurrentUser {
|
||||
(favorites, favoritesNextPage) = try await client.getWithLink(endpoint: Accounts.favorites(sinceId: nil))
|
||||
(bookmarks, bookmarksNextPage) = try await client.getWithLink(endpoint: Accounts.bookmarks(sinceId: nil))
|
||||
(favorites, favoritesNextPage) = try await client.getWithLink(
|
||||
endpoint: Accounts.favorites(sinceId: nil))
|
||||
(bookmarks, bookmarksNextPage) = try await client.getWithLink(
|
||||
endpoint: Accounts.bookmarks(sinceId: nil))
|
||||
StatusDataControllerProvider.shared.updateDataControllers(for: favorites, client: client)
|
||||
StatusDataControllerProvider.shared.updateDataControllers(for: bookmarks, client: client)
|
||||
}
|
||||
|
@ -248,13 +262,15 @@ import SwiftUI
|
|||
accountIdToFetch = accountId
|
||||
}
|
||||
let newStatuses: [Status] =
|
||||
try await client.get(endpoint: Accounts.statuses(id: accountIdToFetch,
|
||||
sinceId: lastId,
|
||||
tag: nil,
|
||||
onlyMedia: selectedTab == .media,
|
||||
excludeReplies: selectedTab != .replies,
|
||||
excludeReblogs: selectedTab != .boosts,
|
||||
pinned: nil))
|
||||
try await client.get(
|
||||
endpoint: Accounts.statuses(
|
||||
id: accountIdToFetch,
|
||||
sinceId: lastId,
|
||||
tag: nil,
|
||||
onlyMedia: selectedTab == .media,
|
||||
excludeReplies: selectedTab != .replies,
|
||||
excludeReblogs: selectedTab != .boosts,
|
||||
pinned: nil))
|
||||
statuses.append(contentsOf: newStatuses)
|
||||
if selectedTab == .boosts {
|
||||
let newBoosts = statuses.filter { $0.reblog != nil }
|
||||
|
@ -262,23 +278,27 @@ import SwiftUI
|
|||
}
|
||||
StatusDataControllerProvider.shared.updateDataControllers(for: newStatuses, client: client)
|
||||
if selectedTab == .boosts {
|
||||
statusesState = .display(statuses: boosts,
|
||||
nextPageState: newStatuses.count < 20 ? .none : .hasNextPage)
|
||||
statusesState = .display(
|
||||
statuses: boosts,
|
||||
nextPageState: newStatuses.count < 20 ? .none : .hasNextPage)
|
||||
} else {
|
||||
statusesState = .display(statuses: statuses,
|
||||
nextPageState: newStatuses.count < 20 ? .none : .hasNextPage)
|
||||
statusesState = .display(
|
||||
statuses: statuses,
|
||||
nextPageState: newStatuses.count < 20 ? .none : .hasNextPage)
|
||||
}
|
||||
case .favorites:
|
||||
guard let nextPageId = favoritesNextPage?.maxId else { return }
|
||||
let newFavorites: [Status]
|
||||
(newFavorites, favoritesNextPage) = try await client.getWithLink(endpoint: Accounts.favorites(sinceId: nextPageId))
|
||||
(newFavorites, favoritesNextPage) = try await client.getWithLink(
|
||||
endpoint: Accounts.favorites(sinceId: nextPageId))
|
||||
favorites.append(contentsOf: newFavorites)
|
||||
StatusDataControllerProvider.shared.updateDataControllers(for: newFavorites, client: client)
|
||||
statusesState = .display(statuses: favorites, nextPageState: .hasNextPage)
|
||||
case .bookmarks:
|
||||
guard let nextPageId = bookmarksNextPage?.maxId else { return }
|
||||
let newBookmarks: [Status]
|
||||
(newBookmarks, bookmarksNextPage) = try await client.getWithLink(endpoint: Accounts.bookmarks(sinceId: nextPageId))
|
||||
(newBookmarks, bookmarksNextPage) = try await client.getWithLink(
|
||||
endpoint: Accounts.bookmarks(sinceId: nextPageId))
|
||||
StatusDataControllerProvider.shared.updateDataControllers(for: newBookmarks, client: client)
|
||||
bookmarks.append(contentsOf: newBookmarks)
|
||||
statusesState = .display(statuses: bookmarks, nextPageState: .hasNextPage)
|
||||
|
@ -288,23 +308,27 @@ import SwiftUI
|
|||
private func reloadTabState() {
|
||||
switch selectedTab {
|
||||
case .statuses, .replies, .media, .premiumPosts:
|
||||
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||
statusesState = .display(
|
||||
statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||
case .boosts:
|
||||
statusesState = .display(statuses: boosts, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||
statusesState = .display(
|
||||
statuses: boosts, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||
case .favorites:
|
||||
statusesState = .display(statuses: favorites,
|
||||
nextPageState: favoritesNextPage != nil ? .hasNextPage : .none)
|
||||
statusesState = .display(
|
||||
statuses: favorites,
|
||||
nextPageState: favoritesNextPage != nil ? .hasNextPage : .none)
|
||||
case .bookmarks:
|
||||
statusesState = .display(statuses: bookmarks,
|
||||
nextPageState: bookmarksNextPage != nil ? .hasNextPage : .none)
|
||||
statusesState = .display(
|
||||
statuses: bookmarks,
|
||||
nextPageState: bookmarksNextPage != nil ? .hasNextPage : .none)
|
||||
}
|
||||
}
|
||||
|
||||
func handleEvent(event: any StreamEvent, currentAccount: CurrentAccount) {
|
||||
if let event = event as? StreamEventUpdate {
|
||||
if event.status.account.id == currentAccount.account?.id {
|
||||
if (event.status.inReplyToId == nil && selectedTab == .statuses) ||
|
||||
(event.status.inReplyToId != nil && selectedTab == .replies)
|
||||
if (event.status.inReplyToId == nil && selectedTab == .statuses)
|
||||
|| (event.status.inReplyToId != nil && selectedTab == .replies)
|
||||
{
|
||||
statuses.insert(event.status, at: 0)
|
||||
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
|
||||
|
@ -329,30 +353,35 @@ import SwiftUI
|
|||
extension AccountDetailViewModel {
|
||||
private func fetchPremiumAccount(fromAccount: Account, client: Client) async throws {
|
||||
if fromAccount.isLinkedToPremiumAccount, let acct = fromAccount.premiumAcct {
|
||||
let results: SearchResults? = try await client.get(endpoint: Search.search(query: acct,
|
||||
type: .accounts,
|
||||
offset: nil,
|
||||
following: nil),
|
||||
forceVersion: .v2)
|
||||
let results: SearchResults? = try await client.get(
|
||||
endpoint: Search.search(
|
||||
query: acct,
|
||||
type: .accounts,
|
||||
offset: nil,
|
||||
following: nil),
|
||||
forceVersion: .v2)
|
||||
if let premiumAccount = results?.accounts.first {
|
||||
self.premiumAccount = premiumAccount
|
||||
await fetchSubClubAccount(premiumUsername: premiumAccount.username)
|
||||
let relationships: [Relationship] = try await client.get(endpoint: Accounts.relationships(ids: [premiumAccount.id]))
|
||||
let relationships: [Relationship] = try await client.get(
|
||||
endpoint: Accounts.relationships(ids: [premiumAccount.id]))
|
||||
self.premiumRelationship = relationships.first
|
||||
}
|
||||
} else if fromAccount.isPremiumAccount {
|
||||
await fetchSubClubAccount(premiumUsername: fromAccount.username)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func followPremiumAccount() async throws {
|
||||
if let premiumAccount {
|
||||
premiumRelationship = try await client?.post(endpoint: Accounts.follow(id: premiumAccount.id,
|
||||
notify: false,
|
||||
reblogs: true))
|
||||
premiumRelationship = try await client?.post(
|
||||
endpoint: Accounts.follow(
|
||||
id: premiumAccount.id,
|
||||
notify: false,
|
||||
reblogs: true))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func fetchSubClubAccount(premiumUsername: String) async {
|
||||
let user = await subClubClient.getUser(username: premiumUsername)
|
||||
subClubUser = user
|
||||
|
|
|
@ -37,7 +37,10 @@ public struct AccountsListRow: View {
|
|||
let isFollowRequest: Bool
|
||||
let requestUpdated: (() -> Void)?
|
||||
|
||||
public init(viewModel: AccountsListRowViewModel, isFollowRequest: Bool = false, requestUpdated: (() -> Void)? = nil) {
|
||||
public init(
|
||||
viewModel: AccountsListRowViewModel, isFollowRequest: Bool = false,
|
||||
requestUpdated: (() -> Void)? = nil
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.isFollowRequest = isFollowRequest
|
||||
self.requestUpdated = requestUpdated
|
||||
|
@ -47,19 +50,23 @@ public struct AccountsListRow: View {
|
|||
HStack(alignment: .top) {
|
||||
AvatarView(viewModel.account.avatar)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
EmojiTextApp(.init(stringValue: viewModel.account.safeDisplayName), emojis: viewModel.account.emojis)
|
||||
.font(.scaledSubheadline)
|
||||
.emojiText.size(Font.scaledSubheadlineFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledSubheadlineFont.emojiBaselineOffset)
|
||||
.fontWeight(.semibold)
|
||||
EmojiTextApp(
|
||||
.init(stringValue: viewModel.account.safeDisplayName), emojis: viewModel.account.emojis
|
||||
)
|
||||
.font(.scaledSubheadline)
|
||||
.emojiText.size(Font.scaledSubheadlineFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledSubheadlineFont.emojiBaselineOffset)
|
||||
.fontWeight(.semibold)
|
||||
Text("@\(viewModel.account.acct)")
|
||||
.font(.scaledFootnote)
|
||||
.foregroundStyle(Color.secondary)
|
||||
|
||||
// First parameter is the number for the plural
|
||||
// Second parameter is the formatted string to show
|
||||
Text("account.label.followers \(viewModel.account.followersCount ?? 0) \(viewModel.account.followersCount ?? 0, format: .number.notation(.compactName))")
|
||||
.font(.scaledFootnote)
|
||||
Text(
|
||||
"account.label.followers \(viewModel.account.followersCount ?? 0) \(viewModel.account.followersCount ?? 0, format: .number.notation(.compactName))"
|
||||
)
|
||||
.font(.scaledFootnote)
|
||||
|
||||
if let field = viewModel.account.fields.filter({ $0.verifiedAt != nil }).first {
|
||||
HStack(spacing: 2) {
|
||||
|
@ -71,9 +78,11 @@ public struct AccountsListRow: View {
|
|||
.font(.scaledFootnote)
|
||||
.emojiText.size(Font.scaledFootnoteFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledFootnoteFont.emojiBaselineOffset)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
routerPath.handle(url: url)
|
||||
})
|
||||
.environment(
|
||||
\.openURL,
|
||||
OpenURLAction { url in
|
||||
routerPath.handle(url: url)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -81,25 +90,30 @@ public struct AccountsListRow: View {
|
|||
.font(.scaledCaption)
|
||||
.emojiText.size(Font.scaledFootnoteFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledFootnoteFont.emojiBaselineOffset)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
routerPath.handle(url: url)
|
||||
})
|
||||
.environment(
|
||||
\.openURL,
|
||||
OpenURLAction { url in
|
||||
routerPath.handle(url: url)
|
||||
})
|
||||
|
||||
if isFollowRequest {
|
||||
FollowRequestButtons(account: viewModel.account,
|
||||
requestUpdated: requestUpdated)
|
||||
FollowRequestButtons(
|
||||
account: viewModel.account,
|
||||
requestUpdated: requestUpdated)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if currentAccount.account?.id != viewModel.account.id,
|
||||
let relationShip = viewModel.relationShip
|
||||
let relationShip = viewModel.relationShip
|
||||
{
|
||||
VStack(alignment: .center) {
|
||||
FollowButton(viewModel: .init(client: client,
|
||||
accountId: viewModel.account.id,
|
||||
relationship: relationShip,
|
||||
shouldDisplayNotify: false,
|
||||
relationshipUpdated: { _ in }))
|
||||
FollowButton(
|
||||
viewModel: .init(
|
||||
client: client,
|
||||
accountId: viewModel.account.id,
|
||||
relationship: relationShip,
|
||||
shouldDisplayNotify: false,
|
||||
relationshipUpdated: { _ in }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -111,18 +125,21 @@ public struct AccountsListRow: View {
|
|||
routerPath.navigate(to: .accountDetailWithAccount(account: viewModel.account))
|
||||
}
|
||||
#if canImport(_Translation_SwiftUI)
|
||||
.addTranslateView(isPresented: $showTranslateView, text: viewModel.account.note.asRawText)
|
||||
.addTranslateView(isPresented: $showTranslateView, text: viewModel.account.note.asRawText)
|
||||
#endif
|
||||
.contextMenu {
|
||||
AccountDetailContextMenu(showBlockConfirmation: $showBlockConfirmation,
|
||||
showTranslateView: $showTranslateView,
|
||||
viewModel: .init(account: viewModel.account))
|
||||
AccountDetailContextMenu(
|
||||
showBlockConfirmation: $showBlockConfirmation,
|
||||
showTranslateView: $showTranslateView,
|
||||
viewModel: .init(account: viewModel.account))
|
||||
} preview: {
|
||||
List {
|
||||
AccountDetailHeaderView(viewModel: .init(account: viewModel.account),
|
||||
account: viewModel.account,
|
||||
scrollViewProxy: nil)
|
||||
.applyAccountDetailsRowStyle(theme: theme)
|
||||
AccountDetailHeaderView(
|
||||
viewModel: .init(account: viewModel.account),
|
||||
account: viewModel.account,
|
||||
scrollViewProxy: nil
|
||||
)
|
||||
.applyAccountDetailsRowStyle(theme: theme)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
|
|
|
@ -18,32 +18,32 @@ public struct AccountsListView: View {
|
|||
|
||||
public var body: some View {
|
||||
listView
|
||||
#if !os(visionOS)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
.listStyle(.plain)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
VStack {
|
||||
Text(viewModel.mode.title)
|
||||
.font(.headline)
|
||||
if let count = viewModel.totalCount {
|
||||
Text(String(count))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
#if !os(visionOS)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
.listStyle(.plain)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
VStack {
|
||||
Text(viewModel.mode.title)
|
||||
.font(.headline)
|
||||
if let count = viewModel.totalCount {
|
||||
Text(String(count))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(viewModel.mode.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
viewModel.client = client
|
||||
guard !didAppear else { return }
|
||||
didAppear = true
|
||||
await viewModel.fetch()
|
||||
}
|
||||
.navigationTitle(viewModel.mode.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
viewModel.client = client
|
||||
guard !didAppear else { return }
|
||||
didAppear = true
|
||||
await viewModel.fetch()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
@ -62,8 +62,10 @@ public struct AccountsListView: View {
|
|||
List {
|
||||
listContent
|
||||
}
|
||||
.searchable(text: $viewModel.searchQuery,
|
||||
placement: .navigationBarDrawer(displayMode: .always))
|
||||
.searchable(
|
||||
text: $viewModel.searchQuery,
|
||||
placement: .navigationBarDrawer(displayMode: .always)
|
||||
)
|
||||
.task(id: viewModel.searchQuery) {
|
||||
if !viewModel.searchQuery.isEmpty {
|
||||
await viewModel.search()
|
||||
|
@ -92,13 +94,13 @@ public struct AccountsListView: View {
|
|||
AccountsListRow(viewModel: .init(account: .placeholder(), relationShip: .placeholder()))
|
||||
.redacted(reason: .placeholder)
|
||||
.allowsHitTesting(false)
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
case let .display(accounts, relationships, nextPageState):
|
||||
if case .followers = viewModel.mode,
|
||||
!currentAccount.followRequests.isEmpty
|
||||
!currentAccount.followRequests.isEmpty
|
||||
{
|
||||
Section(
|
||||
header: Text("account.follow-requests.pending-requests"),
|
||||
|
@ -118,28 +120,32 @@ public struct AccountsListView: View {
|
|||
}
|
||||
)
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
if accounts.isEmpty {
|
||||
PlaceholderView(iconName: "person.icloud",
|
||||
title: "No accounts found",
|
||||
message: "This list of accounts is empty")
|
||||
.listRowSeparator(.hidden)
|
||||
PlaceholderView(
|
||||
iconName: "person.icloud",
|
||||
title: "No accounts found",
|
||||
message: "This list of accounts is empty"
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
} else {
|
||||
ForEach(accounts) { account in
|
||||
if let relationship = relationships.first(where: { $0.id == account.id }) {
|
||||
AccountsListRow(viewModel: .init(account: account,
|
||||
relationShip: relationship))
|
||||
AccountsListRow(
|
||||
viewModel: .init(
|
||||
account: account,
|
||||
relationShip: relationship))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
switch nextPageState {
|
||||
|
@ -148,7 +154,7 @@ public struct AccountsListView: View {
|
|||
try await viewModel.fetchNextPage()
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
case .none:
|
||||
|
@ -157,17 +163,19 @@ public struct AccountsListView: View {
|
|||
|
||||
case let .error(error):
|
||||
Text(error.localizedDescription)
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
List {
|
||||
AccountsListRow(viewModel: .init(account: .placeholder(),
|
||||
relationShip: .placeholder()))
|
||||
AccountsListRow(
|
||||
viewModel: .init(
|
||||
account: .placeholder(),
|
||||
relationShip: .placeholder()))
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.withPreviewsEnv()
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import Models
|
||||
import Network
|
||||
import Observation
|
||||
import OSLog
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
public enum AccountsListMode {
|
||||
case following(accountId: String), followers(accountId: String)
|
||||
case favoritedBy(statusId: String), rebloggedBy(statusId: String)
|
||||
case following(accountId: String)
|
||||
case followers(accountId: String)
|
||||
case favoritedBy(statusId: String)
|
||||
case rebloggedBy(statusId: String)
|
||||
case accountsList(accounts: [Account])
|
||||
case blocked, muted
|
||||
|
||||
|
@ -42,9 +44,10 @@ public enum AccountsListMode {
|
|||
}
|
||||
|
||||
case loading
|
||||
case display(accounts: [Account],
|
||||
relationships: [Relationship],
|
||||
nextPageState: PagingState)
|
||||
case display(
|
||||
accounts: [Account],
|
||||
relationships: [Relationship],
|
||||
nextPageState: PagingState)
|
||||
case error(error: Error)
|
||||
}
|
||||
|
||||
|
@ -72,20 +75,28 @@ public enum AccountsListMode {
|
|||
case let .followers(accountId):
|
||||
let account: Account = try await client.get(endpoint: Accounts.accounts(id: accountId))
|
||||
totalCount = account.followersCount
|
||||
(accounts, link) = try await client.getWithLink(endpoint: Accounts.followers(id: accountId,
|
||||
maxId: nil))
|
||||
(accounts, link) = try await client.getWithLink(
|
||||
endpoint: Accounts.followers(
|
||||
id: accountId,
|
||||
maxId: nil))
|
||||
case let .following(accountId):
|
||||
self.accountId = accountId
|
||||
let account: Account = try await client.get(endpoint: Accounts.accounts(id: accountId))
|
||||
totalCount = account.followingCount
|
||||
(accounts, link) = try await client.getWithLink(endpoint: Accounts.following(id: accountId,
|
||||
maxId: nil))
|
||||
(accounts, link) = try await client.getWithLink(
|
||||
endpoint: Accounts.following(
|
||||
id: accountId,
|
||||
maxId: nil))
|
||||
case let .rebloggedBy(statusId):
|
||||
(accounts, link) = try await client.getWithLink(endpoint: Statuses.rebloggedBy(id: statusId,
|
||||
maxId: nil))
|
||||
(accounts, link) = try await client.getWithLink(
|
||||
endpoint: Statuses.rebloggedBy(
|
||||
id: statusId,
|
||||
maxId: nil))
|
||||
case let .favoritedBy(statusId):
|
||||
(accounts, link) = try await client.getWithLink(endpoint: Statuses.favoritedBy(id: statusId,
|
||||
maxId: nil))
|
||||
(accounts, link) = try await client.getWithLink(
|
||||
endpoint: Statuses.favoritedBy(
|
||||
id: statusId,
|
||||
maxId: nil))
|
||||
case let .accountsList(accounts):
|
||||
self.accounts = accounts
|
||||
link = nil
|
||||
|
@ -97,11 +108,13 @@ public enum AccountsListMode {
|
|||
(accounts, link) = try await client.getWithLink(endpoint: Accounts.muteList)
|
||||
}
|
||||
nextPageId = link?.maxId
|
||||
relationships = try await client.get(endpoint:
|
||||
Accounts.relationships(ids: accounts.map(\.id)))
|
||||
state = .display(accounts: accounts,
|
||||
relationships: relationships,
|
||||
nextPageState: link?.maxId != nil ? .hasNextPage : .none)
|
||||
relationships = try await client.get(
|
||||
endpoint:
|
||||
Accounts.relationships(ids: accounts.map(\.id)))
|
||||
state = .display(
|
||||
accounts: accounts,
|
||||
relationships: relationships,
|
||||
nextPageState: link?.maxId != nil ? .hasNextPage : .none)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
@ -111,17 +124,25 @@ public enum AccountsListMode {
|
|||
let link: LinkHandler?
|
||||
switch mode {
|
||||
case let .followers(accountId):
|
||||
(newAccounts, link) = try await client.getWithLink(endpoint: Accounts.followers(id: accountId,
|
||||
maxId: nextPageId))
|
||||
(newAccounts, link) = try await client.getWithLink(
|
||||
endpoint: Accounts.followers(
|
||||
id: accountId,
|
||||
maxId: nextPageId))
|
||||
case let .following(accountId):
|
||||
(newAccounts, link) = try await client.getWithLink(endpoint: Accounts.following(id: accountId,
|
||||
maxId: nextPageId))
|
||||
(newAccounts, link) = try await client.getWithLink(
|
||||
endpoint: Accounts.following(
|
||||
id: accountId,
|
||||
maxId: nextPageId))
|
||||
case let .rebloggedBy(statusId):
|
||||
(newAccounts, link) = try await client.getWithLink(endpoint: Statuses.rebloggedBy(id: statusId,
|
||||
maxId: nextPageId))
|
||||
(newAccounts, link) = try await client.getWithLink(
|
||||
endpoint: Statuses.rebloggedBy(
|
||||
id: statusId,
|
||||
maxId: nextPageId))
|
||||
case let .favoritedBy(statusId):
|
||||
(newAccounts, link) = try await client.getWithLink(endpoint: Statuses.favoritedBy(id: statusId,
|
||||
maxId: nextPageId))
|
||||
(newAccounts, link) = try await client.getWithLink(
|
||||
endpoint: Statuses.favoritedBy(
|
||||
id: statusId,
|
||||
maxId: nextPageId))
|
||||
case .accountsList:
|
||||
newAccounts = []
|
||||
link = nil
|
||||
|
@ -139,9 +160,10 @@ public enum AccountsListMode {
|
|||
|
||||
relationships.append(contentsOf: newRelationships)
|
||||
self.nextPageId = link?.maxId
|
||||
state = .display(accounts: accounts,
|
||||
relationships: relationships,
|
||||
nextPageState: link?.maxId != nil ? .hasNextPage : .none)
|
||||
state = .display(
|
||||
accounts: accounts,
|
||||
relationships: relationships,
|
||||
nextPageState: link?.maxId != nil ? .hasNextPage : .none)
|
||||
}
|
||||
|
||||
func search() async {
|
||||
|
@ -149,18 +171,21 @@ public enum AccountsListMode {
|
|||
do {
|
||||
state = .loading
|
||||
try await Task.sleep(for: .milliseconds(250))
|
||||
var results: SearchResults = try await client.get(endpoint: Search.search(query: searchQuery,
|
||||
type: .accounts,
|
||||
offset: nil,
|
||||
following: true),
|
||||
forceVersion: .v2)
|
||||
var results: SearchResults = try await client.get(
|
||||
endpoint: Search.search(
|
||||
query: searchQuery,
|
||||
type: .accounts,
|
||||
offset: nil,
|
||||
following: true),
|
||||
forceVersion: .v2)
|
||||
let relationships: [Relationship] =
|
||||
try await client.get(endpoint: Accounts.relationships(ids: results.accounts.map(\.id)))
|
||||
results.relationships = relationships
|
||||
withAnimation {
|
||||
state = .display(accounts: results.accounts,
|
||||
relationships: relationships,
|
||||
nextPageState: .none)
|
||||
state = .display(
|
||||
accounts: results.accounts,
|
||||
relationships: relationships,
|
||||
nextPageState: .none)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
|
|
@ -35,20 +35,22 @@ public struct EditAccountView: View {
|
|||
.background(theme.secondaryBackgroundColor)
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
#endif
|
||||
.navigationTitle("account.edit.navigation-title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
toolbarContent
|
||||
}
|
||||
.alert("account.edit.error.save.title",
|
||||
isPresented: $viewModel.saveError,
|
||||
actions: {
|
||||
Button("alert.button.ok", action: {})
|
||||
}, message: { Text("account.edit.error.save.message") })
|
||||
.task {
|
||||
viewModel.client = client
|
||||
await viewModel.fetchAccount()
|
||||
}
|
||||
.navigationTitle("account.edit.navigation-title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
toolbarContent
|
||||
}
|
||||
.alert(
|
||||
"account.edit.error.save.title",
|
||||
isPresented: $viewModel.saveError,
|
||||
actions: {
|
||||
Button("alert.button.ok", action: {})
|
||||
}, message: { Text("account.edit.error.save.message") }
|
||||
)
|
||||
.task {
|
||||
viewModel.client = client
|
||||
await viewModel.fetchAccount()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -61,7 +63,7 @@ public struct EditAccountView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -138,11 +140,12 @@ public struct EditAccountView: View {
|
|||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
.listRowBackground(theme.secondaryBackgroundColor)
|
||||
.photosPicker(isPresented: $viewModel.isPhotoPickerPresented,
|
||||
selection: $viewModel.mediaPickers,
|
||||
maxSelectionCount: 1,
|
||||
matching: .any(of: [.images]),
|
||||
photoLibrary: .shared())
|
||||
.photosPicker(
|
||||
isPresented: $viewModel.isPhotoPickerPresented,
|
||||
selection: $viewModel.mediaPickers,
|
||||
maxSelectionCount: 1,
|
||||
matching: .any(of: [.images]),
|
||||
photoLibrary: .shared())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
@ -156,7 +159,7 @@ public struct EditAccountView: View {
|
|||
.frame(maxHeight: 150)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -178,7 +181,7 @@ public struct EditAccountView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -188,14 +191,16 @@ public struct EditAccountView: View {
|
|||
Label("account.edit.account-settings.private", systemImage: "lock")
|
||||
}
|
||||
Toggle(isOn: $viewModel.isBot) {
|
||||
Label("account.edit.account-settings.bot", systemImage: "laptopcomputer.trianglebadge.exclamationmark")
|
||||
Label(
|
||||
"account.edit.account-settings.bot",
|
||||
systemImage: "laptopcomputer.trianglebadge.exclamationmark")
|
||||
}
|
||||
Toggle(isOn: $viewModel.isDiscoverable) {
|
||||
Label("account.edit.account-settings.discoverable", systemImage: "magnifyingglass")
|
||||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -231,7 +236,7 @@ public struct EditAccountView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
|
@ -94,13 +94,14 @@ import SwiftUI
|
|||
func save() async {
|
||||
isSaving = true
|
||||
do {
|
||||
let data = UpdateCredentialsData(displayName: displayName,
|
||||
note: note,
|
||||
source: .init(privacy: postPrivacy, sensitive: isSensitive),
|
||||
bot: isBot,
|
||||
locked: isLocked,
|
||||
discoverable: isDiscoverable,
|
||||
fieldsAttributes: fields.map { .init(name: $0.name, value: $0.value) })
|
||||
let data = UpdateCredentialsData(
|
||||
displayName: displayName,
|
||||
note: note,
|
||||
source: .init(privacy: postPrivacy, sensitive: isSensitive),
|
||||
bot: isBot,
|
||||
locked: isLocked,
|
||||
discoverable: isDiscoverable,
|
||||
fieldsAttributes: fields.map { .init(name: $0.name, value: $0.value) })
|
||||
let response = try await client?.patch(endpoint: Accounts.updateCredentials(json: data))
|
||||
if response?.statusCode != 200 {
|
||||
saveError = true
|
||||
|
@ -137,12 +138,13 @@ import SwiftUI
|
|||
private func uploadHeader(data: Data) async -> Bool {
|
||||
guard let client else { return false }
|
||||
do {
|
||||
let response = try await client.mediaUpload(endpoint: Accounts.updateCredentialsMedia,
|
||||
version: .v1,
|
||||
method: "PATCH",
|
||||
mimeType: "image/jpeg",
|
||||
filename: "header",
|
||||
data: data)
|
||||
let response = try await client.mediaUpload(
|
||||
endpoint: Accounts.updateCredentialsMedia,
|
||||
version: .v1,
|
||||
method: "PATCH",
|
||||
mimeType: "image/jpeg",
|
||||
filename: "header",
|
||||
data: data)
|
||||
return response?.statusCode == 200
|
||||
} catch {
|
||||
return false
|
||||
|
@ -152,12 +154,13 @@ import SwiftUI
|
|||
private func uploadAvatar(data: Data) async -> Bool {
|
||||
guard let client else { return false }
|
||||
do {
|
||||
let response = try await client.mediaUpload(endpoint: Accounts.updateCredentialsMedia,
|
||||
version: .v1,
|
||||
method: "PATCH",
|
||||
mimeType: "image/jpeg",
|
||||
filename: "avatar",
|
||||
data: data)
|
||||
let response = try await client.mediaUpload(
|
||||
endpoint: Accounts.updateCredentialsMedia,
|
||||
version: .v1,
|
||||
method: "PATCH",
|
||||
mimeType: "image/jpeg",
|
||||
filename: "avatar",
|
||||
data: data)
|
||||
return response?.statusCode == 200
|
||||
} catch {
|
||||
return false
|
||||
|
@ -165,18 +168,21 @@ import SwiftUI
|
|||
}
|
||||
|
||||
private func getItemImageData(item: PhotosPickerItem, for type: ItemType) async -> Data? {
|
||||
guard let imageFile = try? await item.loadTransferable(type: StatusEditor.ImageFileTranseferable.self) else { return nil }
|
||||
guard
|
||||
let imageFile = try? await item.loadTransferable(
|
||||
type: StatusEditor.ImageFileTranseferable.self)
|
||||
else { return nil }
|
||||
|
||||
let compressor = StatusEditor.Compressor()
|
||||
|
||||
guard let compressedData = await compressor.compressImageFrom(url: imageFile.url),
|
||||
let image = UIImage(data: compressedData),
|
||||
let uploadData = try? await compressor.compressImageForUpload(
|
||||
image,
|
||||
maxSize: 2 * 1024 * 1024, // 2MB
|
||||
maxHeight: type.maxHeight,
|
||||
maxWidth: type.maxWidth
|
||||
)
|
||||
let image = UIImage(data: compressedData),
|
||||
let uploadData = try? await compressor.compressImageForUpload(
|
||||
image,
|
||||
maxSize: 2 * 1024 * 1024, // 2MB
|
||||
maxHeight: type.maxHeight,
|
||||
maxWidth: type.maxWidth
|
||||
)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -15,27 +15,31 @@ public struct EditRelationshipNoteView: View {
|
|||
NavigationStack {
|
||||
Form {
|
||||
Section("account.relation.note.label") {
|
||||
TextField("account.relation.note.edit.placeholder", text: $viewModel.note, axis: .vertical)
|
||||
.frame(minHeight: 150, maxHeight: 150, alignment: .top)
|
||||
TextField(
|
||||
"account.relation.note.edit.placeholder", text: $viewModel.note, axis: .vertical
|
||||
)
|
||||
.frame(minHeight: 150, maxHeight: 150, alignment: .top)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.navigationTitle("account.relation.note.edit")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
toolbarContent
|
||||
}
|
||||
.alert("account.relation.note.edit.error.save.title",
|
||||
isPresented: $viewModel.saveError,
|
||||
actions: {
|
||||
Button("alert.button.ok", action: {})
|
||||
}, message: { Text("account.relation.note.edit.error.save.message") })
|
||||
.alert(
|
||||
"account.relation.note.edit.error.save.title",
|
||||
isPresented: $viewModel.saveError,
|
||||
actions: {
|
||||
Button("alert.button.ok", action: {})
|
||||
}, message: { Text("account.relation.note.edit.error.save.message") }
|
||||
)
|
||||
.task {
|
||||
viewModel.client = client
|
||||
viewModel.relatedAccountId = accountDetailViewModel.accountId
|
||||
|
|
|
@ -15,11 +15,13 @@ import SwiftUI
|
|||
|
||||
func save() async {
|
||||
if relatedAccountId != nil,
|
||||
client != nil
|
||||
client != nil
|
||||
{
|
||||
isSaving = true
|
||||
do {
|
||||
_ = try await client!.post(endpoint: Accounts.relationshipNote(id: relatedAccountId!, json: RelationshipNoteData(note: note)))
|
||||
_ = try await client!.post(
|
||||
endpoint: Accounts.relationshipNote(
|
||||
id: relatedAccountId!, json: RelationshipNoteData(note: note)))
|
||||
} catch {
|
||||
isSaving = false
|
||||
saveError = true
|
||||
|
|
|
@ -29,19 +29,21 @@ struct EditFilterView: View {
|
|||
@FocusState private var focusedField: Fields?
|
||||
|
||||
private var data: ServerFilterData {
|
||||
let expiresIn: String? = switch expirySelection {
|
||||
case .infinite:
|
||||
"" // need to send an empty value in order for the server to clear this field in the filter
|
||||
case .custom:
|
||||
String(Int(expiresAt?.timeIntervalSince(Date()) ?? 0) + 50)
|
||||
default:
|
||||
String(expirySelection.rawValue + 50)
|
||||
}
|
||||
let expiresIn: String? =
|
||||
switch expirySelection {
|
||||
case .infinite:
|
||||
"" // need to send an empty value in order for the server to clear this field in the filter
|
||||
case .custom:
|
||||
String(Int(expiresAt?.timeIntervalSince(Date()) ?? 0) + 50)
|
||||
default:
|
||||
String(expirySelection.rawValue + 50)
|
||||
}
|
||||
|
||||
return ServerFilterData(title: title,
|
||||
context: contexts,
|
||||
filterAction: filterAction,
|
||||
expiresIn: expiresIn)
|
||||
return ServerFilterData(
|
||||
title: title,
|
||||
context: contexts,
|
||||
filterAction: filterAction,
|
||||
expiresIn: expiresIn)
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
|
@ -75,16 +77,16 @@ struct EditFilterView: View {
|
|||
.scrollDismissesKeyboard(.interactively)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.onAppear {
|
||||
if filter == nil {
|
||||
focusedField = .title
|
||||
}
|
||||
.onAppear {
|
||||
if filter == nil {
|
||||
focusedField = .title
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
saveButton
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
saveButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var expirySection: some View {
|
||||
|
@ -100,14 +102,16 @@ struct EditFilterView: View {
|
|||
}
|
||||
}
|
||||
if expirySelection != .infinite {
|
||||
DatePicker("filter.edit.expiry.date-time",
|
||||
selection: Binding<Date>(get: { expiresAt ?? Date() }, set: { expiresAt = $0 }),
|
||||
displayedComponents: [.date, .hourAndMinute])
|
||||
.disabled(expirySelection != .custom)
|
||||
DatePicker(
|
||||
"filter.edit.expiry.date-time",
|
||||
selection: Binding<Date>(get: { expiresAt ?? Date() }, set: { expiresAt = $0 }),
|
||||
displayedComponents: [.date, .hourAndMinute]
|
||||
)
|
||||
.disabled(expirySelection != .custom)
|
||||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -123,7 +127,7 @@ struct EditFilterView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
|
||||
if filter == nil, !title.isEmpty {
|
||||
|
@ -145,7 +149,7 @@ struct EditFilterView: View {
|
|||
.transition(.opacity)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.secondaryBackgroundColor)
|
||||
.listRowBackground(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@ -201,31 +205,35 @@ struct EditFilterView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var contextsSection: some View {
|
||||
Section("filter.edit.contexts") {
|
||||
ForEach(ServerFilter.Context.allCases, id: \.self) { context in
|
||||
Toggle(isOn: .init(get: {
|
||||
contexts.contains(where: { $0 == context })
|
||||
}, set: { _ in
|
||||
if let index = contexts.firstIndex(of: context) {
|
||||
contexts.remove(at: index)
|
||||
} else {
|
||||
contexts.append(context)
|
||||
}
|
||||
Task {
|
||||
await saveFilter(client)
|
||||
}
|
||||
})) {
|
||||
Toggle(
|
||||
isOn: .init(
|
||||
get: {
|
||||
contexts.contains(where: { $0 == context })
|
||||
},
|
||||
set: { _ in
|
||||
if let index = contexts.firstIndex(of: context) {
|
||||
contexts.remove(at: index)
|
||||
} else {
|
||||
contexts.append(context)
|
||||
}
|
||||
Task {
|
||||
await saveFilter(client)
|
||||
}
|
||||
})
|
||||
) {
|
||||
Label(context.name, systemImage: context.iconName)
|
||||
}
|
||||
.disabled(isSavingFilter)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@ -248,7 +256,7 @@ struct EditFilterView: View {
|
|||
.pickerStyle(.inline)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -277,11 +285,13 @@ struct EditFilterView: View {
|
|||
do {
|
||||
isSavingFilter = true
|
||||
if let filter {
|
||||
self.filter = try await client.put(endpoint: ServerFilters.editFilter(id: filter.id, json: data),
|
||||
forceVersion: .v2)
|
||||
self.filter = try await client.put(
|
||||
endpoint: ServerFilters.editFilter(id: filter.id, json: data),
|
||||
forceVersion: .v2)
|
||||
} else {
|
||||
let newFilter: ServerFilter = try await client.post(endpoint: ServerFilters.createFilter(json: data),
|
||||
forceVersion: .v2)
|
||||
let newFilter: ServerFilter = try await client.post(
|
||||
endpoint: ServerFilters.createFilter(json: data),
|
||||
forceVersion: .v2)
|
||||
filter = newFilter
|
||||
}
|
||||
} catch {}
|
||||
|
@ -292,11 +302,12 @@ struct EditFilterView: View {
|
|||
guard let filterId = filter?.id else { return }
|
||||
isSavingFilter = true
|
||||
do {
|
||||
let keyword: ServerFilter.Keyword = try await
|
||||
client.post(endpoint: ServerFilters.addKeyword(filter: filterId,
|
||||
keyword: name,
|
||||
wholeWord: true),
|
||||
forceVersion: .v2)
|
||||
let keyword: ServerFilter.Keyword = try await client.post(
|
||||
endpoint: ServerFilters.addKeyword(
|
||||
filter: filterId,
|
||||
keyword: name,
|
||||
wholeWord: true),
|
||||
forceVersion: .v2)
|
||||
keywords.append(keyword)
|
||||
} catch {}
|
||||
isSavingFilter = false
|
||||
|
@ -305,8 +316,9 @@ struct EditFilterView: View {
|
|||
private func deleteKeyword(_ client: Client, keyword: ServerFilter.Keyword) async {
|
||||
isSavingFilter = true
|
||||
do {
|
||||
let response = try await client.delete(endpoint: ServerFilters.removeKeyword(id: keyword.id),
|
||||
forceVersion: .v2)
|
||||
let response = try await client.delete(
|
||||
endpoint: ServerFilters.removeKeyword(id: keyword.id),
|
||||
forceVersion: .v2)
|
||||
if response?.statusCode == 200 {
|
||||
keywords.removeAll(where: { $0.id == keyword.id })
|
||||
}
|
||||
|
|
|
@ -55,7 +55,7 @@ public struct FiltersListView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -65,7 +65,7 @@ public struct FiltersListView: View {
|
|||
}
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
.toolbar {
|
||||
|
@ -77,15 +77,15 @@ public struct FiltersListView: View {
|
|||
.scrollContentBackground(.hidden)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.task {
|
||||
do {
|
||||
isLoading = true
|
||||
filters = try await client.get(endpoint: ServerFilters.filters, forceVersion: .v2)
|
||||
isLoading = false
|
||||
} catch {
|
||||
isLoading = false
|
||||
}
|
||||
.task {
|
||||
do {
|
||||
isLoading = true
|
||||
filters = try await client.get(endpoint: ServerFilters.filters, forceVersion: .v2)
|
||||
isLoading = false
|
||||
} catch {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -93,8 +93,9 @@ public struct FiltersListView: View {
|
|||
if let index = indexes.first {
|
||||
Task {
|
||||
do {
|
||||
let response = try await client.delete(endpoint: ServerFilters.filter(id: filters[index].id),
|
||||
forceVersion: .v2)
|
||||
let response = try await client.delete(
|
||||
endpoint: ServerFilters.filter(id: filters[index].id),
|
||||
forceVersion: .v2)
|
||||
if response?.statusCode == 200 {
|
||||
filters.remove(at: index)
|
||||
}
|
||||
|
|
|
@ -3,8 +3,8 @@ import Combine
|
|||
import Foundation
|
||||
import Models
|
||||
import Network
|
||||
import Observation
|
||||
import OSLog
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
|
@ -16,12 +16,13 @@ import SwiftUI
|
|||
public let relationshipUpdated: (Relationship) -> Void
|
||||
public var relationship: Relationship
|
||||
|
||||
public init(client: Client,
|
||||
accountId: String,
|
||||
relationship: Relationship,
|
||||
shouldDisplayNotify: Bool,
|
||||
relationshipUpdated: @escaping ((Relationship) -> Void))
|
||||
{
|
||||
public init(
|
||||
client: Client,
|
||||
accountId: String,
|
||||
relationship: Relationship,
|
||||
shouldDisplayNotify: Bool,
|
||||
relationshipUpdated: @escaping ((Relationship) -> Void)
|
||||
) {
|
||||
self.client = client
|
||||
self.accountId = accountId
|
||||
self.relationship = relationship
|
||||
|
@ -31,7 +32,8 @@ import SwiftUI
|
|||
|
||||
func follow() async throws {
|
||||
do {
|
||||
relationship = try await client.post(endpoint: Accounts.follow(id: accountId, notify: false, reblogs: true))
|
||||
relationship = try await client.post(
|
||||
endpoint: Accounts.follow(id: accountId, notify: false, reblogs: true))
|
||||
relationshipUpdated(relationship)
|
||||
} catch {
|
||||
throw error
|
||||
|
@ -46,9 +48,10 @@ import SwiftUI
|
|||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func refreshRelationship() async throws {
|
||||
let relationships: [Relationship] = try await client.get(endpoint: Accounts.relationships(ids: [accountId]))
|
||||
let relationships: [Relationship] = try await client.get(
|
||||
endpoint: Accounts.relationships(ids: [accountId]))
|
||||
if let relationship = relationships.first {
|
||||
self.relationship = relationship
|
||||
relationshipUpdated(relationship)
|
||||
|
@ -57,9 +60,11 @@ import SwiftUI
|
|||
|
||||
func toggleNotify() async throws {
|
||||
do {
|
||||
relationship = try await client.post(endpoint: Accounts.follow(id: accountId,
|
||||
notify: !relationship.notifying,
|
||||
reblogs: relationship.showingReblogs))
|
||||
relationship = try await client.post(
|
||||
endpoint: Accounts.follow(
|
||||
id: accountId,
|
||||
notify: !relationship.notifying,
|
||||
reblogs: relationship.showingReblogs))
|
||||
relationshipUpdated(relationship)
|
||||
} catch {
|
||||
throw error
|
||||
|
@ -68,9 +73,11 @@ import SwiftUI
|
|||
|
||||
func toggleReboosts() async throws {
|
||||
do {
|
||||
relationship = try await client.post(endpoint: Accounts.follow(id: accountId,
|
||||
notify: relationship.notifying,
|
||||
reblogs: !relationship.showingReblogs))
|
||||
relationship = try await client.post(
|
||||
endpoint: Accounts.follow(
|
||||
id: accountId,
|
||||
notify: relationship.notifying,
|
||||
reblogs: !relationship.showingReblogs))
|
||||
relationshipUpdated(relationship)
|
||||
} catch {
|
||||
throw error
|
||||
|
@ -98,13 +105,17 @@ public struct FollowButton: View {
|
|||
if viewModel.relationship.requested == true {
|
||||
Text("account.follow.requested")
|
||||
} else {
|
||||
Text(viewModel.relationship.following ? "account.follow.following" : "account.follow.follow")
|
||||
.accessibilityLabel("account.follow.following")
|
||||
.accessibilityValue(viewModel.relationship.following ? "accessibility.general.toggle.on" : "accessibility.general.toggle.off")
|
||||
Text(
|
||||
viewModel.relationship.following ? "account.follow.following" : "account.follow.follow"
|
||||
)
|
||||
.accessibilityLabel("account.follow.following")
|
||||
.accessibilityValue(
|
||||
viewModel.relationship.following
|
||||
? "accessibility.general.toggle.on" : "accessibility.general.toggle.off")
|
||||
}
|
||||
}
|
||||
if viewModel.relationship.following,
|
||||
viewModel.shouldDisplayNotify
|
||||
viewModel.shouldDisplayNotify
|
||||
{
|
||||
HStack {
|
||||
AsyncButton {
|
||||
|
@ -113,14 +124,18 @@ public struct FollowButton: View {
|
|||
Image(systemName: viewModel.relationship.notifying ? "bell.fill" : "bell")
|
||||
}
|
||||
.accessibilityLabel("accessibility.tabs.profile.user-notifications.label")
|
||||
.accessibilityValue(viewModel.relationship.notifying ? "accessibility.general.toggle.on" : "accessibility.general.toggle.off")
|
||||
.accessibilityValue(
|
||||
viewModel.relationship.notifying
|
||||
? "accessibility.general.toggle.on" : "accessibility.general.toggle.off")
|
||||
AsyncButton {
|
||||
try await viewModel.toggleReboosts()
|
||||
} label: {
|
||||
Image(viewModel.relationship.showingReblogs ? "Rocket.Fill" : "Rocket")
|
||||
}
|
||||
.accessibilityLabel("accessibility.tabs.profile.user-reblogs.label")
|
||||
.accessibilityValue(viewModel.relationship.showingReblogs ? "accessibility.general.toggle.on" : "accessibility.general.toggle.off")
|
||||
.accessibilityValue(
|
||||
viewModel.relationship.showingReblogs
|
||||
? "accessibility.general.toggle.on" : "accessibility.general.toggle.off")
|
||||
}
|
||||
.asyncButtonStyle(.none)
|
||||
.disabledWhenLoading()
|
||||
|
|
|
@ -17,7 +17,7 @@ public struct ListsListView: View {
|
|||
.font(.scaledHeadline)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
.onDelete { index in
|
||||
|
@ -35,8 +35,8 @@ public struct ListsListView: View {
|
|||
await currentAccount.fetchLists()
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("timeline.filter.lists")
|
||||
|
|
|
@ -23,11 +23,14 @@ public struct AccountDetailMediaGridView: View {
|
|||
|
||||
public var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
LazyVGrid(columns: [.init(.flexible(minimum: 100), spacing: 4),
|
||||
.init(.flexible(minimum: 100), spacing: 4),
|
||||
.init(.flexible(minimum: 100), spacing: 4)],
|
||||
spacing: 4)
|
||||
{
|
||||
LazyVGrid(
|
||||
columns: [
|
||||
.init(.flexible(minimum: 100), spacing: 4),
|
||||
.init(.flexible(minimum: 100), spacing: 4),
|
||||
.init(.flexible(minimum: 100), spacing: 4),
|
||||
],
|
||||
spacing: 4
|
||||
) {
|
||||
ForEach(mediaStatuses) { status in
|
||||
GeometryReader { proxy in
|
||||
if let url = status.attachment.url {
|
||||
|
@ -60,12 +63,14 @@ public struct AccountDetailMediaGridView: View {
|
|||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
quickLook.prepareFor(selectedMediaAttachment: status.attachment,
|
||||
mediaAttachments: status.status.mediaAttachments)
|
||||
quickLook.prepareFor(
|
||||
selectedMediaAttachment: status.attachment,
|
||||
mediaAttachments: status.status.mediaAttachments)
|
||||
} label: {
|
||||
Label("Open Media", systemImage: "photo")
|
||||
}
|
||||
MediaUIShareLink(url: url, type: status.attachment.supportedType == .image ? .image : .av)
|
||||
MediaUIShareLink(
|
||||
url: url, type: status.attachment.supportedType == .image ? .image : .av)
|
||||
Button {
|
||||
Task {
|
||||
let transferable = MediaUIImageTransferable(url: url)
|
||||
|
@ -104,13 +109,15 @@ public struct AccountDetailMediaGridView: View {
|
|||
|
||||
private func fetchNextPage() async throws {
|
||||
let newStatuses: [Status] =
|
||||
try await client.get(endpoint: Accounts.statuses(id: account.id,
|
||||
sinceId: mediaStatuses.last?.id,
|
||||
tag: nil,
|
||||
onlyMedia: true,
|
||||
excludeReplies: true,
|
||||
excludeReblogs: true,
|
||||
pinned: nil))
|
||||
try await client.get(
|
||||
endpoint: Accounts.statuses(
|
||||
id: account.id,
|
||||
sinceId: mediaStatuses.last?.id,
|
||||
tag: nil,
|
||||
onlyMedia: true,
|
||||
excludeReplies: true,
|
||||
excludeReblogs: true,
|
||||
pinned: nil))
|
||||
mediaStatuses.append(contentsOf: newStatuses.flatMap { $0.asMediaStatus })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,24 +27,24 @@ public struct AccountStatusesListView: View {
|
|||
.scrollContentBackground(.hidden)
|
||||
.background(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
.navigationTitle(viewModel.mode.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.refreshable {
|
||||
await viewModel.fetchNewestStatuses(pullToRefresh: true)
|
||||
}
|
||||
.task {
|
||||
guard !isLoaded else { return }
|
||||
viewModel.client = client
|
||||
.navigationTitle(viewModel.mode.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.refreshable {
|
||||
await viewModel.fetchNewestStatuses(pullToRefresh: true)
|
||||
}
|
||||
.task {
|
||||
guard !isLoaded else { return }
|
||||
viewModel.client = client
|
||||
await viewModel.fetchNewestStatuses(pullToRefresh: false)
|
||||
isLoaded = true
|
||||
}
|
||||
.onChange(of: client.id) { _, _ in
|
||||
isLoaded = false
|
||||
viewModel.client = client
|
||||
Task {
|
||||
await viewModel.fetchNewestStatuses(pullToRefresh: false)
|
||||
isLoaded = true
|
||||
}
|
||||
.onChange(of: client.id) { _, _ in
|
||||
isLoaded = false
|
||||
viewModel.client = client
|
||||
Task {
|
||||
await viewModel.fetchNewestStatuses(pullToRefresh: false)
|
||||
isLoaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,8 +46,9 @@ public class AccountStatusesListViewModel: StatusesFetcher {
|
|||
do {
|
||||
(statuses, nextPage) = try await client.getWithLink(endpoint: mode.endpoint(sinceId: nil))
|
||||
StatusDataControllerProvider.shared.updateDataControllers(for: statuses, client: client)
|
||||
statusesState = .display(statuses: statuses,
|
||||
nextPageState: nextPage?.maxId != nil ? .hasNextPage : .none)
|
||||
statusesState = .display(
|
||||
statuses: statuses,
|
||||
nextPageState: nextPage?.maxId != nil ? .hasNextPage : .none)
|
||||
} catch {
|
||||
statusesState = .error(error: error)
|
||||
}
|
||||
|
@ -59,8 +60,9 @@ public class AccountStatusesListViewModel: StatusesFetcher {
|
|||
(newStatuses, nextPage) = try await client.getWithLink(endpoint: mode.endpoint(sinceId: nextId))
|
||||
statuses.append(contentsOf: newStatuses)
|
||||
StatusDataControllerProvider.shared.updateDataControllers(for: statuses, client: client)
|
||||
statusesState = .display(statuses: statuses,
|
||||
nextPageState: nextPage?.maxId != nil ? .hasNextPage : .none)
|
||||
statusesState = .display(
|
||||
statuses: statuses,
|
||||
nextPageState: nextPage?.maxId != nil ? .hasNextPage : .none)
|
||||
}
|
||||
|
||||
public func statusDidAppear(status _: Status) {}
|
||||
|
|
|
@ -12,9 +12,9 @@ public struct FollowedTagsListView: View {
|
|||
public var body: some View {
|
||||
List(currentAccount.tags) { tag in
|
||||
TagRowView(tag: tag)
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.task {
|
||||
|
@ -24,8 +24,8 @@ public struct FollowedTagsListView: View {
|
|||
await currentAccount.fetchFollowedTags()
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("timeline.filter.tags")
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import SwiftUI
|
||||
import Models
|
||||
import Env
|
||||
import DesignSystem
|
||||
import WrappingHStack
|
||||
import AppAccount
|
||||
import DesignSystem
|
||||
import Env
|
||||
import Models
|
||||
import Network
|
||||
import SwiftUI
|
||||
import WrappingHStack
|
||||
|
||||
@MainActor
|
||||
struct PremiumAcccountSubsciptionSheetView: View {
|
||||
|
@ -13,20 +13,20 @@ struct PremiumAcccountSubsciptionSheetView: View {
|
|||
@Environment(\.openURL) private var openURL
|
||||
@Environment(AppAccountsManager.self) private var appAccount: AppAccountsManager
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
|
||||
@State private var isSubscibeSelected: Bool = false
|
||||
|
||||
|
||||
private enum SheetState: Int, Equatable {
|
||||
case selection, preparing, webview
|
||||
}
|
||||
|
||||
|
||||
@State private var state: SheetState = .selection
|
||||
@State private var animationsending: Bool = false
|
||||
@State private var subClubUser: SubClubUser?
|
||||
|
||||
|
||||
let account: Account
|
||||
let subClubClient = SubClubClient()
|
||||
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
switch state {
|
||||
|
@ -52,7 +52,7 @@ struct PremiumAcccountSubsciptionSheetView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var tipView: some View {
|
||||
HStack {
|
||||
|
@ -91,9 +91,9 @@ struct PremiumAcccountSubsciptionSheetView: View {
|
|||
.background(theme.secondaryBackgroundColor.opacity(0.4))
|
||||
.cornerRadius(8)
|
||||
.padding(12)
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
if isSubscibeSelected {
|
||||
Button {
|
||||
withAnimation {
|
||||
|
@ -111,7 +111,7 @@ struct PremiumAcccountSubsciptionSheetView: View {
|
|||
.padding(.bottom, 38)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var preparingView: some View {
|
||||
Label("Preparing...", systemImage: "wifi")
|
||||
.symbolEffect(.variableColor.iterative, options: .repeating, value: animationsending)
|
||||
|
@ -129,7 +129,7 @@ struct PremiumAcccountSubsciptionSheetView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var webView: some View {
|
||||
VStack(alignment: .center) {
|
||||
Text("Almost there...")
|
||||
|
@ -139,9 +139,13 @@ struct PremiumAcccountSubsciptionSheetView: View {
|
|||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
if let subscription = subClubUser?.subscription,
|
||||
let accountName = appAccount.currentAccount.accountName,
|
||||
let premiumUsername = account.premiumUsername,
|
||||
let url = URL(string: "https://\(AppInfo.premiumInstance)/@\(premiumUsername)/subscribe?callback=icecubesapp://subclub&id=@\(accountName)&amount=\(subscription.unitAmount)¤cy=\(subscription.currency)&theme=\(colorScheme)") {
|
||||
let accountName = appAccount.currentAccount.accountName,
|
||||
let premiumUsername = account.premiumUsername,
|
||||
let url = URL(
|
||||
string:
|
||||
"https://\(AppInfo.premiumInstance)/@\(premiumUsername)/subscribe?callback=icecubesapp://subclub&id=@\(accountName)&amount=\(subscription.unitAmount)¤cy=\(subscription.currency)&theme=\(colorScheme)"
|
||||
)
|
||||
{
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
@testable import Account
|
||||
import XCTest
|
||||
|
||||
@testable import Account
|
||||
|
||||
final class AccountTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
// This is an example of a functional test case.
|
||||
|
|
|
@ -14,7 +14,7 @@ let package = Package(
|
|||
.library(
|
||||
name: "AppAccount",
|
||||
targets: ["AppAccount"]
|
||||
),
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Network", path: "../Network"),
|
||||
|
@ -32,8 +32,8 @@ let package = Package(
|
|||
.product(name: "DesignSystem", package: "DesignSystem"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v6),
|
||||
.swiftLanguageMode(.v6)
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
|
@ -4,7 +4,7 @@ import Models
|
|||
import Network
|
||||
import SwiftUI
|
||||
|
||||
public extension AppAccount {
|
||||
extension AppAccount {
|
||||
private static var keychain: KeychainSwift {
|
||||
let keychain = KeychainSwift()
|
||||
#if !DEBUG && !targetEnvironment(simulator)
|
||||
|
@ -13,17 +13,17 @@ public extension AppAccount {
|
|||
return keychain
|
||||
}
|
||||
|
||||
func save() throws {
|
||||
public func save() throws {
|
||||
let encoder = JSONEncoder()
|
||||
let data = try encoder.encode(self)
|
||||
Self.keychain.set(data, forKey: key, withAccess: .accessibleAfterFirstUnlock)
|
||||
}
|
||||
|
||||
func delete() {
|
||||
public func delete() {
|
||||
Self.keychain.delete(key)
|
||||
}
|
||||
|
||||
static func retrieveAll() -> [AppAccount] {
|
||||
public static func retrieveAll() -> [AppAccount] {
|
||||
let keychain = Self.keychain
|
||||
let decoder = JSONDecoder()
|
||||
let keys = keychain.allKeys
|
||||
|
@ -39,7 +39,7 @@ public extension AppAccount {
|
|||
return accounts
|
||||
}
|
||||
|
||||
static func deleteAll() {
|
||||
public static func deleteAll() {
|
||||
let keychain = Self.keychain
|
||||
let keys = keychain.allKeys
|
||||
for key in keys {
|
||||
|
|
|
@ -48,10 +48,11 @@ public struct AppAccountView: View {
|
|||
private var fullView: some View {
|
||||
Button {
|
||||
if appAccounts.currentAccount.id == viewModel.appAccount.id,
|
||||
let account = viewModel.account
|
||||
let account = viewModel.account
|
||||
{
|
||||
if viewModel.isInSettings {
|
||||
routerPath.navigate(to: .accountSettingsWithAccount(account: account, appAccount: viewModel.appAccount))
|
||||
routerPath.navigate(
|
||||
to: .accountSettingsWithAccount(account: account, appAccount: viewModel.appAccount))
|
||||
HapticManager.shared.fireHaptic(.buttonPress)
|
||||
} else {
|
||||
isParentPresented = false
|
||||
|
@ -76,9 +77,9 @@ public struct AppAccountView: View {
|
|||
.foregroundStyle(.white, .green)
|
||||
.offset(x: 5, y: -5)
|
||||
} else if viewModel.showBadge,
|
||||
let token = viewModel.appAccount.oauthToken,
|
||||
let notificationsCount = preferences.notificationsCount[token],
|
||||
notificationsCount > 0
|
||||
let token = viewModel.appAccount.oauthToken,
|
||||
let notificationsCount = preferences.notificationsCount[token],
|
||||
notificationsCount > 0
|
||||
{
|
||||
ZStack {
|
||||
Circle()
|
||||
|
|
|
@ -32,7 +32,10 @@ import SwiftUI
|
|||
}
|
||||
}
|
||||
|
||||
public init(appAccount: AppAccount, isCompact: Bool = false, isInSettings: Bool = true, showBadge: Bool = false) {
|
||||
public init(
|
||||
appAccount: AppAccount, isCompact: Bool = false, isInSettings: Bool = true,
|
||||
showBadge: Bool = false
|
||||
) {
|
||||
self.appAccount = appAccount
|
||||
self.isCompact = isCompact
|
||||
self.isInSettings = isInSettings
|
||||
|
|
|
@ -13,8 +13,9 @@ import SwiftUI
|
|||
public var currentAccount: AppAccount {
|
||||
didSet {
|
||||
Self.latestCurrentAccountKey = currentAccount.id
|
||||
currentClient = .init(server: currentAccount.server,
|
||||
oauthToken: currentAccount.oauthToken)
|
||||
currentClient = .init(
|
||||
server: currentAccount.server,
|
||||
oauthToken: currentAccount.oauthToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -29,10 +30,12 @@ import SwiftUI
|
|||
public static var shared = AppAccountsManager()
|
||||
|
||||
init() {
|
||||
var defaultAccount = AppAccount(server: AppInfo.defaultServer, accountName: nil, oauthToken: nil)
|
||||
var defaultAccount = AppAccount(
|
||||
server: AppInfo.defaultServer, accountName: nil, oauthToken: nil)
|
||||
let keychainAccounts = AppAccount.retrieveAll()
|
||||
availableAccounts = keychainAccounts
|
||||
if let currentAccount = keychainAccounts.first(where: { $0.id == Self.latestCurrentAccountKey }) {
|
||||
if let currentAccount = keychainAccounts.first(where: { $0.id == Self.latestCurrentAccountKey })
|
||||
{
|
||||
defaultAccount = currentAccount
|
||||
} else {
|
||||
defaultAccount = keychainAccounts.last ?? defaultAccount
|
||||
|
@ -53,9 +56,12 @@ import SwiftUI
|
|||
availableAccounts.removeAll(where: { $0.id == account.id })
|
||||
account.delete()
|
||||
if currentAccount.id == account.id {
|
||||
currentAccount = availableAccounts.first ?? AppAccount(server: AppInfo.defaultServer,
|
||||
accountName: nil,
|
||||
oauthToken: nil)
|
||||
currentAccount =
|
||||
availableAccounts.first
|
||||
?? AppAccount(
|
||||
server: AppInfo.defaultServer,
|
||||
accountName: nil,
|
||||
oauthToken: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,10 +31,11 @@ public struct AppAccountsSelectorView: View {
|
|||
return baseHeight
|
||||
}
|
||||
|
||||
public init(routerPath: RouterPath,
|
||||
accountCreationEnabled: Bool = true,
|
||||
avatarConfig: AvatarView.FrameConfig? = nil)
|
||||
{
|
||||
public init(
|
||||
routerPath: RouterPath,
|
||||
accountCreationEnabled: Bool = true,
|
||||
avatarConfig: AvatarView.FrameConfig? = nil
|
||||
) {
|
||||
self.routerPath = routerPath
|
||||
self.accountCreationEnabled = accountCreationEnabled
|
||||
self.avatarConfig = avatarConfig ?? .badge
|
||||
|
@ -48,14 +49,17 @@ public struct AppAccountsSelectorView: View {
|
|||
labelView
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.sheet(isPresented: $isPresented, content: {
|
||||
accountsView.presentationDetents([.height(preferredHeight), .large])
|
||||
.presentationBackground(.ultraThinMaterial)
|
||||
.presentationCornerRadius(16)
|
||||
.onAppear {
|
||||
refreshAccounts()
|
||||
}
|
||||
})
|
||||
.sheet(
|
||||
isPresented: $isPresented,
|
||||
content: {
|
||||
accountsView.presentationDetents([.height(preferredHeight), .large])
|
||||
.presentationBackground(.ultraThinMaterial)
|
||||
.presentationCornerRadius(16)
|
||||
.onAppear {
|
||||
refreshAccounts()
|
||||
}
|
||||
}
|
||||
)
|
||||
.onChange(of: currentAccount.account?.id) {
|
||||
refreshAccounts()
|
||||
}
|
||||
|
@ -92,16 +96,17 @@ public struct AppAccountsSelectorView: View {
|
|||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ForEach(accountsViewModel.sorted { $0.acct < $1.acct }, id: \.appAccount.id) { viewModel in
|
||||
ForEach(accountsViewModel.sorted { $0.acct < $1.acct }, id: \.appAccount.id) {
|
||||
viewModel in
|
||||
AppAccountView(viewModel: viewModel, isParentPresented: $isPresented)
|
||||
}
|
||||
addAccountButton
|
||||
#if os(visionOS)
|
||||
.foregroundStyle(theme.labelColor)
|
||||
#endif
|
||||
#if os(visionOS)
|
||||
.foregroundStyle(theme.labelColor)
|
||||
#endif
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.listRowBackground(theme.primaryBackgroundColor.opacity(0.4))
|
||||
.listRowBackground(theme.primaryBackgroundColor.opacity(0.4))
|
||||
#endif
|
||||
|
||||
if accountCreationEnabled {
|
||||
|
@ -111,9 +116,9 @@ public struct AppAccountsSelectorView: View {
|
|||
supportButton
|
||||
}
|
||||
#if os(visionOS)
|
||||
.foregroundStyle(theme.labelColor)
|
||||
.foregroundStyle(theme.labelColor)
|
||||
#else
|
||||
.listRowBackground(theme.primaryBackgroundColor.opacity(0.4))
|
||||
.listRowBackground(theme.primaryBackgroundColor.opacity(0.4))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@ -186,7 +191,8 @@ public struct AppAccountsSelectorView: View {
|
|||
private func refreshAccounts() {
|
||||
accountsViewModel = []
|
||||
for account in appAccounts.availableAccounts {
|
||||
let viewModel: AppAccountViewModel = .init(appAccount: account, isInSettings: false, showBadge: true)
|
||||
let viewModel: AppAccountViewModel = .init(
|
||||
appAccount: account, isInSettings: false, showBadge: true)
|
||||
accountsViewModel.append(viewModel)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ let package = Package(
|
|||
.library(
|
||||
name: "Conversations",
|
||||
targets: ["Conversations"]
|
||||
),
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Models", path: "../Models"),
|
||||
|
@ -32,8 +32,8 @@ let package = Package(
|
|||
.product(name: "DesignSystem", package: "DesignSystem"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v6),
|
||||
.swiftLanguageMode(.v6)
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
|
@ -37,17 +37,19 @@ public struct ConversationDetailView: View {
|
|||
loadingView
|
||||
}
|
||||
ForEach(viewModel.messages) { message in
|
||||
ConversationMessageView(message: message,
|
||||
conversation: viewModel.conversation)
|
||||
.padding(.vertical, 4)
|
||||
.id(message.id)
|
||||
ConversationMessageView(
|
||||
message: message,
|
||||
conversation: viewModel.conversation
|
||||
)
|
||||
.padding(.vertical, 4)
|
||||
.id(message.id)
|
||||
}
|
||||
bottomAnchorView
|
||||
}
|
||||
.padding(.horizontal, .layoutPadding)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
inputTextView
|
||||
|
@ -74,32 +76,32 @@ public struct ConversationDetailView: View {
|
|||
.scrollContentBackground(.hidden)
|
||||
.background(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
if viewModel.conversation.accounts.count == 1,
|
||||
let account = viewModel.conversation.accounts.first
|
||||
{
|
||||
EmojiTextApp(.init(stringValue: account.safeDisplayName), emojis: account.emojis)
|
||||
.font(.scaledHeadline)
|
||||
.foregroundColor(theme.labelColor)
|
||||
.emojiText.size(Font.scaledHeadlineFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledHeadlineFont.emojiBaselineOffset)
|
||||
} else {
|
||||
Text("Direct message with \(viewModel.conversation.accounts.count) people")
|
||||
.font(.scaledHeadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: watcher.latestEvent?.id) {
|
||||
if let latestEvent = watcher.latestEvent {
|
||||
viewModel.handleEvent(event: latestEvent)
|
||||
DispatchQueue.main.async {
|
||||
withAnimation {
|
||||
scrollProxy?.scrollTo(Constants.bottomAnchor, anchor: .bottom)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
if viewModel.conversation.accounts.count == 1,
|
||||
let account = viewModel.conversation.accounts.first
|
||||
{
|
||||
EmojiTextApp(.init(stringValue: account.safeDisplayName), emojis: account.emojis)
|
||||
.font(.scaledHeadline)
|
||||
.foregroundColor(theme.labelColor)
|
||||
.emojiText.size(Font.scaledHeadlineFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledHeadlineFont.emojiBaselineOffset)
|
||||
} else {
|
||||
Text("Direct message with \(viewModel.conversation.accounts.count) people")
|
||||
.font(.scaledHeadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: watcher.latestEvent?.id) {
|
||||
if let latestEvent = watcher.latestEvent {
|
||||
viewModel.handleEvent(event: latestEvent)
|
||||
DispatchQueue.main.async {
|
||||
withAnimation {
|
||||
scrollProxy?.scrollTo(Constants.bottomAnchor, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingView: some View {
|
||||
|
@ -124,23 +126,26 @@ public struct ConversationDetailView: View {
|
|||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if viewModel.conversation.lastStatus != nil {
|
||||
Button {
|
||||
routerPath.presentedSheet = .replyToStatusEditor(status: viewModel.conversation.lastStatus!)
|
||||
routerPath.presentedSheet = .replyToStatusEditor(
|
||||
status: viewModel.conversation.lastStatus!)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.padding(.bottom, 7)
|
||||
}
|
||||
|
||||
TextField("conversations.new.message.placeholder", text: $viewModel.newMessageText, axis: .vertical)
|
||||
.focused($isMessageFieldFocused)
|
||||
.keyboardType(.default)
|
||||
.backgroundStyle(.thickMaterial)
|
||||
.padding(6)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(.gray, lineWidth: 1)
|
||||
)
|
||||
.font(.scaledBody)
|
||||
TextField(
|
||||
"conversations.new.message.placeholder", text: $viewModel.newMessageText, axis: .vertical
|
||||
)
|
||||
.focused($isMessageFieldFocused)
|
||||
.keyboardType(.default)
|
||||
.backgroundStyle(.thickMaterial)
|
||||
.padding(6)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(.gray, lineWidth: 1)
|
||||
)
|
||||
.font(.scaledBody)
|
||||
if !viewModel.newMessageText.isEmpty {
|
||||
Button {
|
||||
Task {
|
||||
|
|
|
@ -23,7 +23,8 @@ import SwiftUI
|
|||
func fetchMessages() async {
|
||||
guard let client, let lastMessageId = messages.last?.id else { return }
|
||||
do {
|
||||
let context: StatusContext = try await client.get(endpoint: Statuses.context(id: lastMessageId))
|
||||
let context: StatusContext = try await client.get(
|
||||
endpoint: Statuses.context(id: lastMessageId))
|
||||
isLoadingMessages = false
|
||||
messages.insert(contentsOf: context.ancestors, at: 0)
|
||||
messages.append(contentsOf: context.descendants)
|
||||
|
@ -36,9 +37,10 @@ import SwiftUI
|
|||
var finalText = conversation.accounts.map { "@\($0.acct)" }.joined(separator: " ")
|
||||
finalText += " "
|
||||
finalText += newMessageText
|
||||
let data = StatusData(status: finalText,
|
||||
visibility: .direct,
|
||||
inReplyToId: messages.last?.id)
|
||||
let data = StatusData(
|
||||
status: finalText,
|
||||
visibility: .direct,
|
||||
inReplyToId: messages.last?.id)
|
||||
do {
|
||||
let status: Status = try await client.post(endpoint: Statuses.postStatus(json: data))
|
||||
appendNewStatus(status: status)
|
||||
|
@ -53,15 +55,15 @@ import SwiftUI
|
|||
|
||||
func handleEvent(event: any StreamEvent) {
|
||||
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
|
||||
} 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)
|
||||
} else if let event = event as? StreamEventConversation,
|
||||
event.conversation.id == conversation.id
|
||||
event.conversation.id == conversation.id
|
||||
{
|
||||
conversation = event.conversation
|
||||
if conversation.lastStatus != nil {
|
||||
|
|
|
@ -39,14 +39,16 @@ struct ConversationMessageView: View {
|
|||
.emojiText.size(Font.scaledBodyFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledBodyFont.emojiBaselineOffset)
|
||||
.padding(6)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
routerPath.handleStatus(status: message, url: url)
|
||||
})
|
||||
.environment(
|
||||
\.openURL,
|
||||
OpenURLAction { url in
|
||||
routerPath.handleStatus(status: message, url: url)
|
||||
})
|
||||
}
|
||||
#if os(visionOS)
|
||||
.background(isOwnMessage ? Material.ultraThick : Material.regular)
|
||||
.background(isOwnMessage ? Material.ultraThick : Material.regular)
|
||||
#else
|
||||
.background(isOwnMessage ? theme.tintColor.opacity(0.2) : theme.secondaryBackgroundColor)
|
||||
.background(isOwnMessage ? theme.tintColor.opacity(0.2) : theme.secondaryBackgroundColor)
|
||||
#endif
|
||||
.cornerRadius(8)
|
||||
.padding(.leading, isOwnMessage ? 24 : 0)
|
||||
|
@ -77,8 +79,7 @@ struct ConversationMessageView: View {
|
|||
Spacer()
|
||||
}
|
||||
Group {
|
||||
Text(message.createdAt.shortDateFormatted) +
|
||||
Text(" ")
|
||||
Text(message.createdAt.shortDateFormatted) + Text(" ")
|
||||
Text(message.createdAt.asDate, style: .time)
|
||||
}
|
||||
.font(.scaledFootnote)
|
||||
|
@ -122,25 +123,28 @@ struct ConversationMessageView: View {
|
|||
} catch {}
|
||||
}
|
||||
} label: {
|
||||
Label(isLiked ? "status.action.unfavorite" : "status.action.favorite",
|
||||
systemImage: isLiked ? "star.fill" : "star")
|
||||
Label(
|
||||
isLiked ? "status.action.unfavorite" : "status.action.favorite",
|
||||
systemImage: isLiked ? "star.fill" : "star")
|
||||
}
|
||||
Button {
|
||||
Task {
|
||||
do {
|
||||
let status: Status
|
||||
if isBookmarked {
|
||||
status = try await client.post(endpoint: Statuses.unbookmark(id: message.id))
|
||||
} else {
|
||||
status = try await client.post(endpoint: Statuses.bookmark(id: message.id))
|
||||
}
|
||||
withAnimation {
|
||||
isBookmarked = status.bookmarked == true
|
||||
}
|
||||
} catch {}
|
||||
} } label: {
|
||||
Label(isBookmarked ? "status.action.unbookmark" : "status.action.bookmark",
|
||||
systemImage: isBookmarked ? "bookmark.fill" : "bookmark")
|
||||
do {
|
||||
let status: Status
|
||||
if isBookmarked {
|
||||
status = try await client.post(endpoint: Statuses.unbookmark(id: message.id))
|
||||
} else {
|
||||
status = try await client.post(endpoint: Statuses.bookmark(id: message.id))
|
||||
}
|
||||
withAnimation {
|
||||
isBookmarked = status.bookmarked == true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
isBookmarked ? "status.action.unbookmark" : "status.action.bookmark",
|
||||
systemImage: isBookmarked ? "bookmark.fill" : "bookmark")
|
||||
}
|
||||
Divider()
|
||||
if message.account.id == currentAccount.account?.id {
|
||||
|
@ -152,7 +156,8 @@ struct ConversationMessageView: View {
|
|||
} else {
|
||||
Section(message.reblog?.account.acct ?? message.account.acct) {
|
||||
Button {
|
||||
routerPath.presentedSheet = .mentionStatusEditor(account: message.reblog?.account ?? message.account, visibility: .pub)
|
||||
routerPath.presentedSheet = .mentionStatusEditor(
|
||||
account: message.reblog?.account ?? message.account, visibility: .pub)
|
||||
} label: {
|
||||
Label("status.action.mention", systemImage: "at")
|
||||
}
|
||||
|
@ -183,9 +188,11 @@ struct ConversationMessageView: View {
|
|||
GeometryReader { proxy in
|
||||
let width = mediaWidth(proxy: proxy)
|
||||
if let url = attachement.url {
|
||||
LazyImage(request: makeImageRequest(for: url,
|
||||
size: .init(width: width, height: 200)))
|
||||
{ state in
|
||||
LazyImage(
|
||||
request: makeImageRequest(
|
||||
for: url,
|
||||
size: .init(width: width, height: 200))
|
||||
) { state in
|
||||
if let image = state.image {
|
||||
image
|
||||
.resizable()
|
||||
|
@ -207,8 +214,10 @@ struct ConversationMessageView: View {
|
|||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
#if targetEnvironment(macCatalyst) || os(visionOS)
|
||||
openWindow(value: WindowDestinationMedia.mediaViewer(attachments: [attachement],
|
||||
selectedAttachment: attachement))
|
||||
openWindow(
|
||||
value: WindowDestinationMedia.mediaViewer(
|
||||
attachments: [attachement],
|
||||
selectedAttachment: attachement))
|
||||
#else
|
||||
quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement])
|
||||
#endif
|
||||
|
|
|
@ -29,15 +29,18 @@ struct ConversationsListRow: View {
|
|||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
EmojiTextApp(.init(stringValue: conversation.accounts.map(\.safeDisplayName).joined(separator: ", ")),
|
||||
emojis: conversation.accounts.flatMap(\.emojis))
|
||||
.font(.scaledSubheadline)
|
||||
.foregroundColor(theme.labelColor)
|
||||
.emojiText.size(Font.scaledSubheadlineFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledSubheadlineFont.emojiBaselineOffset)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(theme.labelColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
EmojiTextApp(
|
||||
.init(
|
||||
stringValue: conversation.accounts.map(\.safeDisplayName).joined(separator: ", ")),
|
||||
emojis: conversation.accounts.flatMap(\.emojis)
|
||||
)
|
||||
.font(.scaledSubheadline)
|
||||
.foregroundColor(theme.labelColor)
|
||||
.emojiText.size(Font.scaledSubheadlineFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledSubheadlineFont.emojiBaselineOffset)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(theme.labelColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer()
|
||||
if conversation.unread {
|
||||
Circle()
|
||||
|
@ -53,13 +56,16 @@ struct ConversationsListRow: View {
|
|||
.font(.scaledFootnote)
|
||||
}
|
||||
}
|
||||
EmojiTextApp(conversation.lastStatus?.content ?? HTMLString(stringValue: ""), emojis: conversation.lastStatus?.emojis ?? [])
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.scaledBody)
|
||||
.foregroundColor(theme.labelColor)
|
||||
.emojiText.size(Font.scaledBodyFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledBodyFont.emojiBaselineOffset)
|
||||
.accessibilityLabel(conversation.lastStatus?.content.asRawText ?? "")
|
||||
EmojiTextApp(
|
||||
conversation.lastStatus?.content ?? HTMLString(stringValue: ""),
|
||||
emojis: conversation.lastStatus?.emojis ?? []
|
||||
)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.scaledBody)
|
||||
.foregroundColor(theme.labelColor)
|
||||
.emojiText.size(Font.scaledBodyFont.emojiSize)
|
||||
.emojiText.baselineOffset(Font.scaledBodyFont.emojiBaselineOffset)
|
||||
.accessibilityLabel(conversation.lastStatus?.content.asRawText ?? "")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
@ -146,7 +152,8 @@ struct ConversationsListRow: View {
|
|||
if message.account.id != currentAccount.account?.id {
|
||||
Section(message.reblog?.account.acct ?? message.account.acct) {
|
||||
Button {
|
||||
routerPath.presentedSheet = .mentionStatusEditor(account: message.reblog?.account ?? message.account, visibility: .pub)
|
||||
routerPath.presentedSheet = .mentionStatusEditor(
|
||||
account: message.reblog?.account ?? message.account, visibility: .pub)
|
||||
} label: {
|
||||
Label("status.action.mention", systemImage: "at")
|
||||
}
|
||||
|
@ -177,16 +184,20 @@ struct ConversationsListRow: View {
|
|||
await viewModel.favorite(conversation: conversation)
|
||||
}
|
||||
} label: {
|
||||
Label(conversation.lastStatus?.favourited ?? false ? "status.action.unfavorite" : "status.action.favorite",
|
||||
systemImage: conversation.lastStatus?.favourited ?? false ? "star.fill" : "star")
|
||||
Label(
|
||||
conversation.lastStatus?.favourited ?? false
|
||||
? "status.action.unfavorite" : "status.action.favorite",
|
||||
systemImage: conversation.lastStatus?.favourited ?? false ? "star.fill" : "star")
|
||||
}
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.bookmark(conversation: conversation)
|
||||
}
|
||||
} label: {
|
||||
Label(conversation.lastStatus?.bookmarked ?? false ? "status.action.unbookmark" : "status.action.bookmark",
|
||||
systemImage: conversation.lastStatus?.bookmarked ?? false ? "bookmark.fill" : "bookmark")
|
||||
Label(
|
||||
conversation.lastStatus?.bookmarked ?? false
|
||||
? "status.action.unbookmark" : "status.action.bookmark",
|
||||
systemImage: conversation.lastStatus?.bookmarked ?? false ? "bookmark.fill" : "bookmark")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ public struct ConversationsListView: View {
|
|||
|
||||
@State private var viewModel = ConversationsListViewModel()
|
||||
|
||||
public init() { }
|
||||
public init() {}
|
||||
|
||||
private var conversations: Binding<[Conversation]> {
|
||||
if viewModel.isLoadingFirstPage {
|
||||
|
@ -44,14 +44,16 @@ public struct ConversationsListView: View {
|
|||
Divider()
|
||||
}
|
||||
} else if conversations.isEmpty, !viewModel.isLoadingFirstPage, !viewModel.isError {
|
||||
PlaceholderView(iconName: "tray",
|
||||
title: "conversations.empty.title",
|
||||
message: "conversations.empty.message")
|
||||
PlaceholderView(
|
||||
iconName: "tray",
|
||||
title: "conversations.empty.title",
|
||||
message: "conversations.empty.message")
|
||||
} else if viewModel.isError {
|
||||
ErrorView(title: "conversations.error.title",
|
||||
message: "conversations.error.message",
|
||||
buttonTitle: "conversations.error.button")
|
||||
{
|
||||
ErrorView(
|
||||
title: "conversations.error.title",
|
||||
message: "conversations.error.message",
|
||||
buttonTitle: "conversations.error.button"
|
||||
) {
|
||||
await viewModel.fetchConversations()
|
||||
}
|
||||
}
|
||||
|
@ -75,8 +77,8 @@ public struct ConversationsListView: View {
|
|||
.padding(.top, .layoutPadding)
|
||||
}
|
||||
#if !os(visionOS)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(theme.primaryBackgroundColor)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(theme.primaryBackgroundColor)
|
||||
#endif
|
||||
.navigationTitle("conversations.navigation-title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
|
|
@ -23,7 +23,8 @@ import SwiftUI
|
|||
isLoadingFirstPage = true
|
||||
}
|
||||
do {
|
||||
(conversations, nextPage) = try await client.getWithLink(endpoint: Conversations.conversations(maxId: nil))
|
||||
(conversations, nextPage) = try await client.getWithLink(
|
||||
endpoint: Conversations.conversations(maxId: nil))
|
||||
if nextPage?.maxId == nil {
|
||||
nextPage = nil
|
||||
}
|
||||
|
@ -39,7 +40,8 @@ import SwiftUI
|
|||
do {
|
||||
isLoadingNextPage = true
|
||||
var nextMessages: [Conversation] = []
|
||||
(nextMessages, nextPage) = try await client.getWithLink(endpoint: Conversations.conversations(maxId: maxId))
|
||||
(nextMessages, nextPage) = try await client.getWithLink(
|
||||
endpoint: Conversations.conversations(maxId: maxId))
|
||||
conversations.append(contentsOf: nextMessages)
|
||||
if nextPage?.maxId == nil {
|
||||
nextPage = nil
|
||||
|
@ -62,11 +64,12 @@ import SwiftUI
|
|||
|
||||
func favorite(conversation: Conversation) async {
|
||||
guard let client, let message = conversation.lastStatus else { return }
|
||||
let endpoint: Endpoint = if message.favourited ?? false {
|
||||
Statuses.unfavorite(id: message.id)
|
||||
} else {
|
||||
Statuses.favorite(id: message.id)
|
||||
}
|
||||
let endpoint: Endpoint =
|
||||
if message.favourited ?? false {
|
||||
Statuses.unfavorite(id: message.id)
|
||||
} else {
|
||||
Statuses.favorite(id: message.id)
|
||||
}
|
||||
do {
|
||||
let status: Status = try await client.post(endpoint: endpoint)
|
||||
updateConversationWithNewLastStatus(conversation: conversation, newLastStatus: status)
|
||||
|
@ -75,19 +78,24 @@ import SwiftUI
|
|||
|
||||
func bookmark(conversation: Conversation) async {
|
||||
guard let client, let message = conversation.lastStatus else { return }
|
||||
let endpoint: Endpoint = if message.bookmarked ?? false {
|
||||
Statuses.unbookmark(id: message.id)
|
||||
} else {
|
||||
Statuses.bookmark(id: message.id)
|
||||
}
|
||||
let endpoint: Endpoint =
|
||||
if message.bookmarked ?? false {
|
||||
Statuses.unbookmark(id: message.id)
|
||||
} else {
|
||||
Statuses.bookmark(id: message.id)
|
||||
}
|
||||
do {
|
||||
let status: Status = try await client.post(endpoint: endpoint)
|
||||
updateConversationWithNewLastStatus(conversation: conversation, newLastStatus: status)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private func updateConversationWithNewLastStatus(conversation: Conversation, newLastStatus: Status) {
|
||||
let newConversation = Conversation(id: conversation.id, unread: conversation.unread, lastStatus: newLastStatus, accounts: conversation.accounts)
|
||||
private func updateConversationWithNewLastStatus(
|
||||
conversation: Conversation, newLastStatus: Status
|
||||
) {
|
||||
let newConversation = Conversation(
|
||||
id: conversation.id, unread: conversation.unread, lastStatus: newLastStatus,
|
||||
accounts: conversation.accounts)
|
||||
updateConversations(conversation: newConversation)
|
||||
}
|
||||
|
||||
|
@ -96,7 +104,9 @@ import SwiftUI
|
|||
conversations.remove(at: index)
|
||||
}
|
||||
conversations.insert(conversation, at: 0)
|
||||
conversations = conversations.sorted(by: { ($0.lastStatus?.createdAt.asDate ?? Date.now) > ($1.lastStatus?.createdAt.asDate ?? Date.now) })
|
||||
conversations = conversations.sorted(by: {
|
||||
($0.lastStatus?.createdAt.asDate ?? Date.now) > ($1.lastStatus?.createdAt.asDate ?? Date.now)
|
||||
})
|
||||
}
|
||||
|
||||
func handleEvent(event: any StreamEvent) {
|
||||
|
|
|
@ -14,7 +14,7 @@ let package = Package(
|
|||
.library(
|
||||
name: "DesignSystem",
|
||||
targets: ["DesignSystem"]
|
||||
),
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(name: "Models", path: "../Models"),
|
||||
|
@ -33,8 +33,8 @@ let package = Package(
|
|||
.product(name: "EmojiText", package: "EmojiText"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v6),
|
||||
.swiftLanguageMode(.v6)
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
|
@ -3,20 +3,20 @@ import Models
|
|||
import NukeUI
|
||||
import SwiftUI
|
||||
|
||||
public extension Account {
|
||||
extension Account {
|
||||
private struct Part: Identifiable {
|
||||
let id = UUID().uuidString
|
||||
let value: Substring
|
||||
}
|
||||
|
||||
var safeDisplayName: String {
|
||||
public var safeDisplayName: String {
|
||||
if let displayName, !displayName.isEmpty {
|
||||
return displayName
|
||||
}
|
||||
return "@\(username)"
|
||||
}
|
||||
|
||||
var displayNameWithoutEmojis: String {
|
||||
public var displayNameWithoutEmojis: String {
|
||||
var name = safeDisplayName
|
||||
for emoji in emojis {
|
||||
name = name.replacingOccurrences(of: ":\(emoji.shortcode):", with: "")
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import SwiftUI
|
||||
|
||||
public let availableColorsSets: [ColorSetCouple] =
|
||||
[.init(light: IceCubeLight(), dark: IceCubeDark()),
|
||||
.init(light: IceCubeNeonLight(), dark: IceCubeNeonDark()),
|
||||
.init(light: DesertLight(), dark: DesertDark()),
|
||||
.init(light: NemesisLight(), dark: NemesisDark()),
|
||||
.init(light: MediumLight(), dark: MediumDark()),
|
||||
.init(light: ConstellationLight(), dark: ConstellationDark()),
|
||||
.init(light: ThreadsLight(), dark: ThreadsDark())]
|
||||
[
|
||||
.init(light: IceCubeLight(), dark: IceCubeDark()),
|
||||
.init(light: IceCubeNeonLight(), dark: IceCubeNeonDark()),
|
||||
.init(light: DesertLight(), dark: DesertDark()),
|
||||
.init(light: NemesisLight(), dark: NemesisDark()),
|
||||
.init(light: MediumLight(), dark: MediumDark()),
|
||||
.init(light: ConstellationLight(), dark: ConstellationDark()),
|
||||
.init(light: ThreadsLight(), dark: ThreadsDark()),
|
||||
]
|
||||
|
||||
public protocol ColorSet: Sendable {
|
||||
var name: ColorSetName { get }
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import SwiftUI
|
||||
|
||||
public extension View {
|
||||
@ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
||||
extension View {
|
||||
@ViewBuilder public func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content)
|
||||
-> some View
|
||||
{
|
||||
if condition {
|
||||
transform(self)
|
||||
} else {
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public extension CGFloat {
|
||||
static var layoutPadding: CGFloat {
|
||||
extension CGFloat {
|
||||
public static var layoutPadding: CGFloat {
|
||||
Theme.shared.compactLayoutPadding ? 20 : 8
|
||||
}
|
||||
|
||||
static let dividerPadding: CGFloat = 2
|
||||
static let scrollToViewHeight: CGFloat = 1
|
||||
static let statusColumnsSpacing: CGFloat = 8
|
||||
static let statusComponentSpacing: CGFloat = 6
|
||||
static let secondaryColumnWidth: CGFloat = 400
|
||||
static let sidebarWidth: CGFloat = 90
|
||||
static let sidebarWidthExpanded: CGFloat = 220
|
||||
static let pollBarHeight: CGFloat = 30
|
||||
|
||||
public static let dividerPadding: CGFloat = 2
|
||||
public static let scrollToViewHeight: CGFloat = 1
|
||||
public static let statusColumnsSpacing: CGFloat = 8
|
||||
public static let statusComponentSpacing: CGFloat = 6
|
||||
public static let secondaryColumnWidth: CGFloat = 400
|
||||
public static let sidebarWidth: CGFloat = 90
|
||||
public static let sidebarWidthExpanded: CGFloat = 220
|
||||
public static let pollBarHeight: CGFloat = 30
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import Env
|
|||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public extension Font {
|
||||
extension Font {
|
||||
// See https://gist.github.com/zacwest/916d31da5d03405809c4 for iOS values
|
||||
// Custom values for Mac
|
||||
private static let title = 28.0
|
||||
|
@ -45,84 +45,85 @@ public extension Font {
|
|||
UIFontMetrics.default.scaledValue(for: baseSize * Theme.shared.fontSizeScale)
|
||||
}
|
||||
|
||||
static var scaledTitle: Font {
|
||||
public static var scaledTitle: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: title), relativeTo: .title)
|
||||
}
|
||||
|
||||
static var scaledHeadline: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: headline), relativeTo: .headline).weight(.semibold)
|
||||
public static var scaledHeadline: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: headline), relativeTo: .headline).weight(
|
||||
.semibold)
|
||||
}
|
||||
|
||||
static var scaledHeadlineFont: UIFont {
|
||||
public static var scaledHeadlineFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: headline))
|
||||
}
|
||||
|
||||
static var scaledBodyFocused: Font {
|
||||
public static var scaledBodyFocused: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: body + 2), relativeTo: .body)
|
||||
}
|
||||
|
||||
static var scaledBodyFocusedFont: UIFont {
|
||||
public static var scaledBodyFocusedFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: body + 2))
|
||||
}
|
||||
|
||||
static var scaledBody: Font {
|
||||
public static var scaledBody: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: body), relativeTo: .body)
|
||||
}
|
||||
|
||||
static var scaledBodyFont: UIFont {
|
||||
public static var scaledBodyFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: body))
|
||||
}
|
||||
|
||||
static var scaledBodyUIFont: UIFont {
|
||||
public static var scaledBodyUIFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: body))
|
||||
}
|
||||
|
||||
static var scaledCallout: Font {
|
||||
public static var scaledCallout: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: callout), relativeTo: .callout)
|
||||
}
|
||||
|
||||
static var scaledCalloutFont: UIFont {
|
||||
public static var scaledCalloutFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: body))
|
||||
}
|
||||
|
||||
static var scaledSubheadline: Font {
|
||||
public static var scaledSubheadline: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: subheadline), relativeTo: .subheadline)
|
||||
}
|
||||
|
||||
static var scaledSubheadlineFont: UIFont {
|
||||
public static var scaledSubheadlineFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: subheadline))
|
||||
}
|
||||
|
||||
static var scaledFootnote: Font {
|
||||
public static var scaledFootnote: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: footnote), relativeTo: .footnote)
|
||||
}
|
||||
|
||||
static var scaledFootnoteFont: UIFont {
|
||||
public static var scaledFootnoteFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: footnote))
|
||||
}
|
||||
|
||||
static var scaledCaption: Font {
|
||||
public static var scaledCaption: Font {
|
||||
customFont(size: userScaledFontSize(baseSize: caption), relativeTo: .caption)
|
||||
}
|
||||
|
||||
static var scaledCaptionFont: UIFont {
|
||||
public static var scaledCaptionFont: UIFont {
|
||||
customUIFont(size: userScaledFontSize(baseSize: caption))
|
||||
}
|
||||
}
|
||||
|
||||
public extension UIFont {
|
||||
func rounded() -> UIFont {
|
||||
extension UIFont {
|
||||
public func rounded() -> UIFont {
|
||||
guard let descriptor = fontDescriptor.withDesign(.rounded) else {
|
||||
return self
|
||||
}
|
||||
return UIFont(descriptor: descriptor, size: pointSize)
|
||||
}
|
||||
|
||||
var emojiSize: CGFloat {
|
||||
public var emojiSize: CGFloat {
|
||||
pointSize
|
||||
}
|
||||
|
||||
var emojiBaselineOffset: CGFloat {
|
||||
public var emojiBaselineOffset: CGFloat {
|
||||
// Center emoji with capital letter size of font
|
||||
-(emojiSize - capHeight) / 2
|
||||
}
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
import SwiftUI
|
||||
|
||||
public extension Color {
|
||||
static var brand: Color {
|
||||
extension Color {
|
||||
public static var brand: Color {
|
||||
Color(red: 187 / 255, green: 59 / 255, blue: 226 / 255)
|
||||
}
|
||||
|
||||
static var primaryBackground: Color {
|
||||
public static var primaryBackground: Color {
|
||||
Color(red: 16 / 255, green: 21 / 255, blue: 35 / 255)
|
||||
}
|
||||
|
||||
static var secondaryBackground: Color {
|
||||
public static var secondaryBackground: Color {
|
||||
Color(red: 30 / 255, green: 35 / 255, blue: 62 / 255)
|
||||
}
|
||||
|
||||
static var label: Color {
|
||||
public static var label: Color {
|
||||
Color(.label)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
|
@ -7,8 +5,8 @@ import SwiftUI
|
|||
// images named in lower case are Apple's symbols
|
||||
// images inamed in CamelCase are custom
|
||||
|
||||
public extension Label where Title == Text, Icon == Image {
|
||||
init(_ title: LocalizedStringKey, imageNamed: String) {
|
||||
extension Label where Title == Text, Icon == Image {
|
||||
public init(_ title: LocalizedStringKey, imageNamed: String) {
|
||||
if imageNamed.lowercased() == imageNamed {
|
||||
self.init(title, systemImage: imageNamed)
|
||||
} else {
|
||||
|
|
|
@ -12,10 +12,11 @@ import UIKit
|
|||
public private(set) var windowHeight: CGFloat = UIScreen.main.bounds.size.height
|
||||
#endif
|
||||
|
||||
public func scene(_ scene: UIScene,
|
||||
willConnectTo _: UISceneSession,
|
||||
options _: UIScene.ConnectionOptions)
|
||||
{
|
||||
public func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo _: UISceneSession,
|
||||
options _: UIScene.ConnectionOptions
|
||||
) {
|
||||
guard let windowScene = scene as? UIWindowScene else { return }
|
||||
window = windowScene.keyWindow
|
||||
|
||||
|
@ -29,12 +30,12 @@ import UIKit
|
|||
|
||||
override public init() {
|
||||
super.init()
|
||||
|
||||
|
||||
Task { @MainActor in
|
||||
setup()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func setup() {
|
||||
#if os(visionOS)
|
||||
windowWidth = window?.bounds.size.width ?? 0
|
||||
|
@ -44,7 +45,7 @@ import UIKit
|
|||
windowHeight = window?.bounds.size.height ?? UIScreen.main.bounds.size.height
|
||||
#endif
|
||||
Self.observedSceneDelegate.insert(self)
|
||||
_ = Self.observer // just for activating the lazy static property
|
||||
_ = Self.observer // just for activating the lazy static property
|
||||
}
|
||||
|
||||
private static var observedSceneDelegate: Set<SceneDelegate> = []
|
||||
|
|
|
@ -20,18 +20,25 @@ public final class Theme {
|
|||
@AppStorage("is_previously_set") public var isThemePreviouslySet: Bool = false
|
||||
@AppStorage(ThemeKey.selectedScheme.rawValue) public var selectedScheme: ColorScheme = .dark
|
||||
@AppStorage(ThemeKey.tint.rawValue) public var tintColor: Color = .black
|
||||
@AppStorage(ThemeKey.primaryBackground.rawValue) public var primaryBackgroundColor: Color = .white
|
||||
@AppStorage(ThemeKey.secondaryBackground.rawValue) public var secondaryBackgroundColor: Color = .gray
|
||||
@AppStorage(ThemeKey.primaryBackground.rawValue) public var primaryBackgroundColor: Color =
|
||||
.white
|
||||
@AppStorage(ThemeKey.secondaryBackground.rawValue) public var secondaryBackgroundColor: Color =
|
||||
.gray
|
||||
@AppStorage(ThemeKey.label.rawValue) public var labelColor: Color = .black
|
||||
@AppStorage(ThemeKey.avatarPosition2.rawValue) var avatarPosition: AvatarPosition = .leading
|
||||
@AppStorage(ThemeKey.avatarShape2.rawValue) var avatarShape: AvatarShape = .circle
|
||||
@AppStorage(ThemeKey.selectedSet.rawValue) var storedSet: ColorSetName = .iceCubeDark
|
||||
@AppStorage(ThemeKey.statusActionsDisplay.rawValue) public var statusActionsDisplay: StatusActionsDisplay = .full
|
||||
@AppStorage(ThemeKey.statusDisplayStyle.rawValue) public var statusDisplayStyle: StatusDisplayStyle = .large
|
||||
@AppStorage(ThemeKey.followSystemColorSchme.rawValue) public var followSystemColorScheme: Bool = true
|
||||
@AppStorage(ThemeKey.displayFullUsernameTimeline.rawValue) public var displayFullUsername: Bool = false
|
||||
@AppStorage(ThemeKey.statusActionsDisplay.rawValue) public var statusActionsDisplay:
|
||||
StatusActionsDisplay = .full
|
||||
@AppStorage(ThemeKey.statusDisplayStyle.rawValue) public var statusDisplayStyle:
|
||||
StatusDisplayStyle = .large
|
||||
@AppStorage(ThemeKey.followSystemColorSchme.rawValue) public var followSystemColorScheme: Bool =
|
||||
true
|
||||
@AppStorage(ThemeKey.displayFullUsernameTimeline.rawValue) public var displayFullUsername:
|
||||
Bool = false
|
||||
@AppStorage(ThemeKey.lineSpacing.rawValue) public var lineSpacing: Double = 1.2
|
||||
@AppStorage(ThemeKey.statusActionSecondary.rawValue) public var statusActionSecondary: StatusActionSecondary = .share
|
||||
@AppStorage(ThemeKey.statusActionSecondary.rawValue) public var statusActionSecondary:
|
||||
StatusActionSecondary = .share
|
||||
@AppStorage(ThemeKey.contentGradient.rawValue) public var showContentGradient: Bool = true
|
||||
@AppStorage(ThemeKey.compactLayoutPadding.rawValue) public var compactLayoutPadding: Bool = true
|
||||
@AppStorage("font_size_scale") public var fontSizeScale: Double = 1
|
||||
|
@ -139,14 +146,17 @@ public final class Theme {
|
|||
return _cachedChoosenFont
|
||||
}
|
||||
guard let chosenFontData,
|
||||
let font = try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIFont.self, from: chosenFontData) else { return nil }
|
||||
let font = try? NSKeyedUnarchiver.unarchivedObject(
|
||||
ofClass: UIFont.self, from: chosenFontData)
|
||||
else { return nil }
|
||||
|
||||
_cachedChoosenFont = font
|
||||
return font
|
||||
}
|
||||
set {
|
||||
if let font = newValue,
|
||||
let data = try? NSKeyedArchiver.archivedData(withRootObject: font, requiringSecureCoding: false)
|
||||
let data = try? NSKeyedArchiver.archivedData(
|
||||
withRootObject: font, requiringSecureCoding: false)
|
||||
{
|
||||
chosenFontData = data
|
||||
} else {
|
||||
|
@ -292,13 +302,13 @@ public final class Theme {
|
|||
themeStorage.showContentGradient = showContentGradient
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public var compactLayoutPadding: Bool {
|
||||
didSet {
|
||||
themeStorage.compactLayoutPadding = compactLayoutPadding
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public var selectedSet: ColorSetName = .iceCubeDark
|
||||
|
||||
public static let shared = Theme()
|
||||
|
@ -327,7 +337,7 @@ public final class Theme {
|
|||
primaryBackgroundColor = themeStorage.primaryBackgroundColor
|
||||
secondaryBackgroundColor = themeStorage.secondaryBackgroundColor
|
||||
labelColor = themeStorage.labelColor
|
||||
contrastingTintColor = .red // real work done in computeContrastingTintColor()
|
||||
contrastingTintColor = .red // real work done in computeContrastingTintColor()
|
||||
avatarPosition = themeStorage.avatarPosition
|
||||
avatarShape = themeStorage.avatarShape
|
||||
storedSet = themeStorage.storedSet
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
public extension View {
|
||||
@MainActor func applyTheme(_ theme: Theme) -> some View {
|
||||
extension View {
|
||||
@MainActor public func applyTheme(_ theme: Theme) -> some View {
|
||||
modifier(ThemeApplier(theme: theme))
|
||||
}
|
||||
}
|
||||
|
@ -26,40 +27,46 @@ struct ThemeApplier: ViewModifier {
|
|||
content
|
||||
.tint(theme.tintColor)
|
||||
.preferredColorScheme(actualColorScheme)
|
||||
#if canImport(UIKit)
|
||||
.onAppear {
|
||||
// If theme is never set before set the default store. This should only execute once after install.
|
||||
if !theme.isThemePreviouslySet {
|
||||
theme.applySet(set: colorScheme == .dark ? .iceCubeDark : .iceCubeLight)
|
||||
theme.isThemePreviouslySet = true
|
||||
} else if theme.followSystemColorScheme, theme.isThemePreviouslySet,
|
||||
let sets = availableColorsSets
|
||||
.first(where: { $0.light.name == theme.selectedSet || $0.dark.name == theme.selectedSet })
|
||||
{
|
||||
theme.applySet(set: colorScheme == .dark ? sets.dark.name : sets.light.name)
|
||||
#if canImport(UIKit)
|
||||
.onAppear {
|
||||
// If theme is never set before set the default store. This should only execute once after install.
|
||||
if !theme.isThemePreviouslySet {
|
||||
theme.applySet(set: colorScheme == .dark ? .iceCubeDark : .iceCubeLight)
|
||||
theme.isThemePreviouslySet = true
|
||||
} else if theme.followSystemColorScheme, theme.isThemePreviouslySet,
|
||||
let sets =
|
||||
availableColorsSets
|
||||
.first(where: {
|
||||
$0.light.name == theme.selectedSet || $0.dark.name == theme.selectedSet
|
||||
})
|
||||
{
|
||||
theme.applySet(set: colorScheme == .dark ? sets.dark.name : sets.light.name)
|
||||
}
|
||||
setWindowTint(theme.tintColor)
|
||||
setWindowUserInterfaceStyle(from: theme.selectedScheme)
|
||||
setBarsColor(theme.primaryBackgroundColor)
|
||||
}
|
||||
setWindowTint(theme.tintColor)
|
||||
setWindowUserInterfaceStyle(from: theme.selectedScheme)
|
||||
setBarsColor(theme.primaryBackgroundColor)
|
||||
}
|
||||
.onChange(of: theme.tintColor) { _, newValue in
|
||||
setWindowTint(newValue)
|
||||
}
|
||||
.onChange(of: theme.primaryBackgroundColor) { _, newValue in
|
||||
setBarsColor(newValue)
|
||||
}
|
||||
.onChange(of: theme.selectedScheme) { _, newValue in
|
||||
setWindowUserInterfaceStyle(from: newValue)
|
||||
}
|
||||
.onChange(of: colorScheme) { _, newColorScheme in
|
||||
if theme.followSystemColorScheme,
|
||||
let sets = availableColorsSets
|
||||
.first(where: { $0.light.name == theme.selectedSet || $0.dark.name == theme.selectedSet })
|
||||
{
|
||||
theme.applySet(set: newColorScheme == .dark ? sets.dark.name : sets.light.name)
|
||||
.onChange(of: theme.tintColor) { _, newValue in
|
||||
setWindowTint(newValue)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.onChange(of: theme.primaryBackgroundColor) { _, newValue in
|
||||
setBarsColor(newValue)
|
||||
}
|
||||
.onChange(of: theme.selectedScheme) { _, newValue in
|
||||
setWindowUserInterfaceStyle(from: newValue)
|
||||
}
|
||||
.onChange(of: colorScheme) { _, newColorScheme in
|
||||
if theme.followSystemColorScheme,
|
||||
let sets =
|
||||
availableColorsSets
|
||||
.first(where: {
|
||||
$0.light.name == theme.selectedSet || $0.dark.name == theme.selectedSet
|
||||
})
|
||||
{
|
||||
theme.applySet(set: newColorScheme == .dark ? sets.dark.name : sets.light.name)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
|
|
|
@ -7,11 +7,14 @@ public struct CloseToolbarItem: ToolbarContent {
|
|||
|
||||
public var body: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}, label: {
|
||||
Image(systemName: "xmark.circle")
|
||||
})
|
||||
Button(
|
||||
action: {
|
||||
dismiss()
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "xmark.circle")
|
||||
}
|
||||
)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import SwiftUI
|
|||
@MainActor
|
||||
struct AccountPopoverView: View {
|
||||
let account: Account
|
||||
let theme: Theme // using `@Environment(Theme.self) will crash the SwiftUI preview
|
||||
let theme: Theme // using `@Environment(Theme.self) will crash the SwiftUI preview
|
||||
private let config: AvatarView.FrameConfig = .account
|
||||
|
||||
@Binding var showPopup: Bool
|
||||
|
@ -16,7 +16,8 @@ struct AccountPopoverView: View {
|
|||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
LazyImage(request: ImageRequest(url: account.header)
|
||||
LazyImage(
|
||||
request: ImageRequest(url: account.header)
|
||||
) { state in
|
||||
if let image = state.image {
|
||||
image.resizable().scaledToFill()
|
||||
|
@ -96,7 +97,9 @@ struct AccountPopoverView: View {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
private func makeCustomInfoLabel(title: LocalizedStringKey, count: Int, needsBadge: Bool = false) -> some View {
|
||||
private func makeCustomInfoLabel(title: LocalizedStringKey, count: Int, needsBadge: Bool = false)
|
||||
-> some View
|
||||
{
|
||||
VStack {
|
||||
Text(count, format: .number.notation(.compactName))
|
||||
.font(.scaledHeadline)
|
||||
|
@ -112,9 +115,11 @@ struct AccountPopoverView: View {
|
|||
Text(title)
|
||||
.font(.scaledFootnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.alignmentGuide(.bottomAvatar, computeValue: { dimension in
|
||||
dimension[.firstTextBaseline]
|
||||
})
|
||||
.alignmentGuide(
|
||||
.bottomAvatar,
|
||||
computeValue: { dimension in
|
||||
dimension[.firstTextBaseline]
|
||||
})
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(title)
|
||||
|
@ -122,12 +127,14 @@ struct AccountPopoverView: View {
|
|||
}
|
||||
|
||||
private var adaptiveConfig: AvatarView.FrameConfig {
|
||||
let cornerRadius: CGFloat = if config == .badge || theme.avatarShape == .circle {
|
||||
config.width / 2
|
||||
} else {
|
||||
config.cornerRadius
|
||||
}
|
||||
return AvatarView.FrameConfig(width: config.width, height: config.height, cornerRadius: cornerRadius)
|
||||
let cornerRadius: CGFloat =
|
||||
if config == .badge || theme.avatarShape == .circle {
|
||||
config.width / 2
|
||||
} else {
|
||||
config.cornerRadius
|
||||
}
|
||||
return AvatarView.FrameConfig(
|
||||
width: config.width, height: config.height, cornerRadius: cornerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -156,33 +163,34 @@ public struct AccountPopoverModifier: ViewModifier {
|
|||
return AnyView(content)
|
||||
}
|
||||
|
||||
return AnyView(content
|
||||
.onHover { hovering in
|
||||
if hovering {
|
||||
toggleTask.cancel()
|
||||
toggleTask = Task {
|
||||
try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 2)
|
||||
guard !Task.isCancelled else { return }
|
||||
return AnyView(
|
||||
content
|
||||
.onHover { hovering in
|
||||
if hovering {
|
||||
toggleTask.cancel()
|
||||
toggleTask = Task {
|
||||
try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 2)
|
||||
guard !Task.isCancelled else { return }
|
||||
if !showPopup {
|
||||
showPopup = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !showPopup {
|
||||
showPopup = true
|
||||
toggleTask.cancel()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !showPopup {
|
||||
toggleTask.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
.hoverEffect(.lift)
|
||||
.popover(isPresented: $showPopup) {
|
||||
AccountPopoverView(
|
||||
account: account,
|
||||
theme: theme,
|
||||
showPopup: $showPopup,
|
||||
autoDismiss: $autoDismiss,
|
||||
toggleTask: $toggleTask
|
||||
)
|
||||
})
|
||||
.hoverEffect(.lift)
|
||||
.popover(isPresented: $showPopup) {
|
||||
AccountPopoverView(
|
||||
account: account,
|
||||
theme: theme,
|
||||
showPopup: $showPopup,
|
||||
autoDismiss: $autoDismiss,
|
||||
toggleTask: $toggleTask
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
init(_ account: Account) {
|
||||
|
@ -190,8 +198,8 @@ public struct AccountPopoverModifier: ViewModifier {
|
|||
}
|
||||
}
|
||||
|
||||
public extension View {
|
||||
func accountPopover(_ account: Account) -> some View {
|
||||
extension View {
|
||||
public func accountPopover(_ account: Account) -> some View {
|
||||
modifier(AccountPopoverModifier(account))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,11 +20,12 @@ public struct AvatarView: View {
|
|||
}
|
||||
|
||||
private var adaptiveConfig: FrameConfig {
|
||||
let cornerRadius: CGFloat = if config == .badge || theme.avatarShape == .circle {
|
||||
config.width / 2
|
||||
} else {
|
||||
config.cornerRadius
|
||||
}
|
||||
let cornerRadius: CGFloat =
|
||||
if config == .badge || theme.avatarShape == .circle {
|
||||
config.width / 2
|
||||
} else {
|
||||
config.cornerRadius
|
||||
}
|
||||
return FrameConfig(width: config.width, height: config.height, cornerRadius: cornerRadius)
|
||||
}
|
||||
|
||||
|
@ -88,10 +89,14 @@ struct PreviewWrapper: View {
|
|||
id: UUID().uuidString,
|
||||
username: "@clattner_llvm",
|
||||
displayName: "Chris Lattner",
|
||||
avatar: URL(string: "https://pbs.twimg.com/profile_images/1484209565788897285/1n6Viahb_400x400.jpg")!,
|
||||
avatar: URL(
|
||||
string: "https://pbs.twimg.com/profile_images/1484209565788897285/1n6Viahb_400x400.jpg")!,
|
||||
header: URL(string: "https://pbs.twimg.com/profile_banners/2543588034/1656822255/1500x500")!,
|
||||
acct: "clattner_llvm@example.com",
|
||||
note: .init(stringValue: "Building beautiful things @Modular_AI 🔥, lifting the world of production AI/ML software into a new phase of innovation. We’re hiring! 🚀🧠"),
|
||||
note: .init(
|
||||
stringValue:
|
||||
"Building beautiful things @Modular_AI 🔥, lifting the world of production AI/ML software into a new phase of innovation. We’re hiring! 🚀🧠"
|
||||
),
|
||||
createdAt: ServerDate(),
|
||||
followersCount: 77100,
|
||||
followingCount: 167,
|
||||
|
@ -117,7 +122,8 @@ struct AvatarImage: View {
|
|||
if reasons == .placeholder {
|
||||
AvatarPlaceHolder(config: config)
|
||||
} else {
|
||||
LazyImage(request: ImageRequest(url: avatar, processors: [.resize(size: config.size)])
|
||||
LazyImage(
|
||||
request: ImageRequest(url: avatar, processors: [.resize(size: config.size)])
|
||||
) { state in
|
||||
if let image = state.image {
|
||||
image
|
||||
|
|
|
@ -10,7 +10,10 @@ public struct EmojiTextApp: View {
|
|||
private let append: (() -> Text)?
|
||||
private let lineLimit: Int?
|
||||
|
||||
public init(_ markdown: HTMLString, emojis: [Emoji], language: String? = nil, lineLimit: Int? = nil, append: (() -> Text)? = nil) {
|
||||
public init(
|
||||
_ markdown: HTMLString, emojis: [Emoji], language: String? = nil, lineLimit: Int? = nil,
|
||||
append: (() -> Text)? = nil
|
||||
) {
|
||||
self.markdown = markdown
|
||||
self.emojis = emojis.map { RemoteEmoji(shortcode: $0.shortcode, url: $0.url) }
|
||||
self.language = language
|
||||
|
|
|
@ -6,7 +6,10 @@ public struct ErrorView: View {
|
|||
public let buttonTitle: LocalizedStringKey
|
||||
public let onButtonPress: () async -> Void
|
||||
|
||||
public init(title: LocalizedStringKey, message: LocalizedStringKey, buttonTitle: LocalizedStringKey, onButtonPress: @escaping (() async -> Void)) {
|
||||
public init(
|
||||
title: LocalizedStringKey, message: LocalizedStringKey, buttonTitle: LocalizedStringKey,
|
||||
onButtonPress: @escaping (() async -> Void)
|
||||
) {
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.buttonTitle = buttonTitle
|
||||
|
@ -46,7 +49,9 @@ public struct ErrorView: View {
|
|||
}
|
||||
|
||||
#Preview {
|
||||
ErrorView(title: "Error",
|
||||
message: "Error loading. Please try again",
|
||||
buttonTitle: "Retry") {}
|
||||
ErrorView(
|
||||
title: "Error",
|
||||
message: "Error loading. Please try again",
|
||||
buttonTitle: "Retry"
|
||||
) {}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,8 @@ import SwiftUI
|
|||
/// A LazyImage (Nuke) with a geometry reader under the hood in order to use a Resize Processor to optimize performances on lists.
|
||||
/// This views also allows smooth resizing of the images by debouncing the update of the ImageProcessor.
|
||||
public struct LazyResizableImage<Content: View>: View {
|
||||
public init(url: URL?, @ViewBuilder content: @escaping (LazyImageState, GeometryProxy) -> Content) {
|
||||
public init(url: URL?, @ViewBuilder content: @escaping (LazyImageState, GeometryProxy) -> Content)
|
||||
{
|
||||
imageURL = url
|
||||
self.content = content
|
||||
}
|
||||
|
|
|
@ -12,14 +12,16 @@ public struct PlaceholderView: View {
|
|||
}
|
||||
|
||||
public var body: some View {
|
||||
ContentUnavailableView(title,
|
||||
systemImage: iconName,
|
||||
description: Text(message))
|
||||
ContentUnavailableView(
|
||||
title,
|
||||
systemImage: iconName,
|
||||
description: Text(message))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PlaceholderView(iconName: "square.and.arrow.up.trianglebadge.exclamationmark",
|
||||
title: "Nothing to see",
|
||||
message: "This is a preview. Please try again.")
|
||||
PlaceholderView(
|
||||
iconName: "square.and.arrow.up.trianglebadge.exclamationmark",
|
||||
title: "Nothing to see",
|
||||
message: "This is a preview. Please try again.")
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue