Format all code using swift-format

This commit is contained in:
Thomas Ricouard 2024-10-28 10:57:48 +01:00
parent 42f880aaa8
commit 35e8cb6512
246 changed files with 5509 additions and 3908 deletions

View file

@ -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]

View file

@ -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)

View file

@ -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)

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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()
}
}
}
}

View file

@ -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()

View file

@ -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()

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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)

View file

@ -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")

View file

@ -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
}

View file

@ -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")

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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() {

View file

@ -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()
}
}
}

View file

@ -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()
}
}
}

View file

@ -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
}
}

View file

@ -23,7 +23,7 @@ struct SidebarEntriesSettingsView: View {
.onMove(perform: move)
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor)
.listRowBackground(theme.primaryBackgroundColor)
#endif
}
.environment(\.editMode, .constant(.active))

View file

@ -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
}

View file

@ -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)

View file

@ -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")

View file

@ -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()
}
}
}

View file

@ -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
}
}

View file

@ -3,6 +3,6 @@ import WishKit
struct WishlistView: View {
var body: some View {
WishKit.view
WishKit.FeedbackListView()
}
}

View file

@ -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
}
}

View file

@ -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: {

View file

@ -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() {

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -23,7 +23,7 @@ struct AppShortcuts: AppShortcutsProvider {
AppShortcut(
intent: TabIntent(),
phrases: [
"Open \(.applicationName)",
"Open \(.applicationName)"
],
shortTitle: "Open Ice Cubes",
systemImageName: "cube"

View file

@ -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))

View file

@ -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 {

View file

@ -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 {

View file

@ -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)
}

View file

@ -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: [:])
}

View file

@ -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: [:])
}

View file

@ -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: [:])
}

View file

@ -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: [:])
}

View file

@ -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)
}
})
}
}

View file

@ -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:

View file

@ -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

View file

@ -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)

View file

@ -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()
}
}

View file

@ -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(

View file

@ -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")
}
}

View file

@ -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)&currency=\(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)&currency=\(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)
}
}

View file

@ -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
}
}

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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 {}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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 })
}

View file

@ -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)
}

View file

@ -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()

View file

@ -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")

View file

@ -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 })
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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) {}

View file

@ -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")

View file

@ -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)&currency=\(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)&currency=\(subscription.currency)&theme=\(colorScheme)"
)
{
openURL(url)
}
}

View file

@ -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.

View file

@ -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)
]
),
)
]
)

View file

@ -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 {

View file

@ -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()

View file

@ -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

View file

@ -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