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
public static var shared = AppAccountsManager()
init() {
var defaultAccount = AppAccount(server: AppInfo.defaultServer, accountName: nil, oauthToken: nil)
var defaultAccount = AppAccount(
server: AppInfo.defaultServer, accountName: nil, oauthToken: nil)
let keychainAccounts = AppAccount.retrieveAll()
availableAccounts = keychainAccounts
if let currentAccount = keychainAccounts.first(where: { $0.id == Self.latestCurrentAccountKey }) {
if let currentAccount = keychainAccounts.first(where: { $0.id == Self.latestCurrentAccountKey })
{
defaultAccount = currentAccount
} else {
defaultAccount = keychainAccounts.last ?? defaultAccount
@ -53,9 +56,12 @@ import SwiftUI
availableAccounts.removeAll(where: { $0.id == account.id })
account.delete()
if currentAccount.id == account.id {
currentAccount = availableAccounts.first ?? AppAccount(server: AppInfo.defaultServer,
accountName: nil,
oauthToken: nil)
currentAccount =
availableAccounts.first
?? AppAccount(
server: AppInfo.defaultServer,
accountName: nil,
oauthToken: nil)
}
}
}

View file

@ -31,10 +31,11 @@ public struct AppAccountsSelectorView: View {
return baseHeight
}
public init(routerPath: RouterPath,
accountCreationEnabled: Bool = true,
avatarConfig: AvatarView.FrameConfig? = nil)
{
public init(
routerPath: RouterPath,
accountCreationEnabled: Bool = true,
avatarConfig: AvatarView.FrameConfig? = nil
) {
self.routerPath = routerPath
self.accountCreationEnabled = accountCreationEnabled
self.avatarConfig = avatarConfig ?? .badge
@ -48,14 +49,17 @@ public struct AppAccountsSelectorView: View {
labelView
.contentShape(Rectangle())
}
.sheet(isPresented: $isPresented, content: {
accountsView.presentationDetents([.height(preferredHeight), .large])
.presentationBackground(.ultraThinMaterial)
.presentationCornerRadius(16)
.onAppear {
refreshAccounts()
}
})
.sheet(
isPresented: $isPresented,
content: {
accountsView.presentationDetents([.height(preferredHeight), .large])
.presentationBackground(.ultraThinMaterial)
.presentationCornerRadius(16)
.onAppear {
refreshAccounts()
}
}
)
.onChange(of: currentAccount.account?.id) {
refreshAccounts()
}
@ -92,16 +96,17 @@ public struct AppAccountsSelectorView: View {
NavigationStack {
List {
Section {
ForEach(accountsViewModel.sorted { $0.acct < $1.acct }, id: \.appAccount.id) { viewModel in
ForEach(accountsViewModel.sorted { $0.acct < $1.acct }, id: \.appAccount.id) {
viewModel in
AppAccountView(viewModel: viewModel, isParentPresented: $isPresented)
}
addAccountButton
#if os(visionOS)
.foregroundStyle(theme.labelColor)
#endif
#if os(visionOS)
.foregroundStyle(theme.labelColor)
#endif
}
#if !os(visionOS)
.listRowBackground(theme.primaryBackgroundColor.opacity(0.4))
.listRowBackground(theme.primaryBackgroundColor.opacity(0.4))
#endif
if accountCreationEnabled {
@ -111,9 +116,9 @@ public struct AppAccountsSelectorView: View {
supportButton
}
#if os(visionOS)
.foregroundStyle(theme.labelColor)
.foregroundStyle(theme.labelColor)
#else
.listRowBackground(theme.primaryBackgroundColor.opacity(0.4))
.listRowBackground(theme.primaryBackgroundColor.opacity(0.4))
#endif
}
}
@ -186,7 +191,8 @@ public struct AppAccountsSelectorView: View {
private func refreshAccounts() {
accountsViewModel = []
for account in appAccounts.availableAccounts {
let viewModel: AppAccountViewModel = .init(appAccount: account, isInSettings: false, showBadge: true)
let viewModel: AppAccountViewModel = .init(
appAccount: account, isInSettings: false, showBadge: true)
accountsViewModel.append(viewModel)
}
}

View file

@ -14,7 +14,7 @@ let package = Package(
.library(
name: "Conversations",
targets: ["Conversations"]
),
)
],
dependencies: [
.package(name: "Models", path: "../Models"),
@ -32,8 +32,8 @@ let package = Package(
.product(name: "DesignSystem", package: "DesignSystem"),
],
swiftSettings: [
.swiftLanguageMode(.v6),
.swiftLanguageMode(.v6)
]
),
)
]
)

View file

@ -37,17 +37,19 @@ public struct ConversationDetailView: View {
loadingView
}
ForEach(viewModel.messages) { message in
ConversationMessageView(message: message,
conversation: viewModel.conversation)
.padding(.vertical, 4)
.id(message.id)
ConversationMessageView(
message: message,
conversation: viewModel.conversation
)
.padding(.vertical, 4)
.id(message.id)
}
bottomAnchorView
}
.padding(.horizontal, .layoutPadding)
}
#if !os(visionOS)
.scrollDismissesKeyboard(.interactively)
.scrollDismissesKeyboard(.interactively)
#endif
.safeAreaInset(edge: .bottom) {
inputTextView
@ -74,32 +76,32 @@ public struct ConversationDetailView: View {
.scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor)
#endif
.toolbar {
ToolbarItem(placement: .principal) {
if viewModel.conversation.accounts.count == 1,
let account = viewModel.conversation.accounts.first
{
EmojiTextApp(.init(stringValue: account.safeDisplayName), emojis: account.emojis)
.font(.scaledHeadline)
.foregroundColor(theme.labelColor)
.emojiText.size(Font.scaledHeadlineFont.emojiSize)
.emojiText.baselineOffset(Font.scaledHeadlineFont.emojiBaselineOffset)
} else {
Text("Direct message with \(viewModel.conversation.accounts.count) people")
.font(.scaledHeadline)
}
}
}
.onChange(of: watcher.latestEvent?.id) {
if let latestEvent = watcher.latestEvent {
viewModel.handleEvent(event: latestEvent)
DispatchQueue.main.async {
withAnimation {
scrollProxy?.scrollTo(Constants.bottomAnchor, anchor: .bottom)
}
.toolbar {
ToolbarItem(placement: .principal) {
if viewModel.conversation.accounts.count == 1,
let account = viewModel.conversation.accounts.first
{
EmojiTextApp(.init(stringValue: account.safeDisplayName), emojis: account.emojis)
.font(.scaledHeadline)
.foregroundColor(theme.labelColor)
.emojiText.size(Font.scaledHeadlineFont.emojiSize)
.emojiText.baselineOffset(Font.scaledHeadlineFont.emojiBaselineOffset)
} else {
Text("Direct message with \(viewModel.conversation.accounts.count) people")
.font(.scaledHeadline)
}
}
}
.onChange(of: watcher.latestEvent?.id) {
if let latestEvent = watcher.latestEvent {
viewModel.handleEvent(event: latestEvent)
DispatchQueue.main.async {
withAnimation {
scrollProxy?.scrollTo(Constants.bottomAnchor, anchor: .bottom)
}
}
}
}
}
private var loadingView: some View {
@ -124,23 +126,26 @@ public struct ConversationDetailView: View {
HStack(alignment: .bottom, spacing: 8) {
if viewModel.conversation.lastStatus != nil {
Button {
routerPath.presentedSheet = .replyToStatusEditor(status: viewModel.conversation.lastStatus!)
routerPath.presentedSheet = .replyToStatusEditor(
status: viewModel.conversation.lastStatus!)
} label: {
Image(systemName: "plus")
}
.padding(.bottom, 7)
}
TextField("conversations.new.message.placeholder", text: $viewModel.newMessageText, axis: .vertical)
.focused($isMessageFieldFocused)
.keyboardType(.default)
.backgroundStyle(.thickMaterial)
.padding(6)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(.gray, lineWidth: 1)
)
.font(.scaledBody)
TextField(
"conversations.new.message.placeholder", text: $viewModel.newMessageText, axis: .vertical
)
.focused($isMessageFieldFocused)
.keyboardType(.default)
.backgroundStyle(.thickMaterial)
.padding(6)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(.gray, lineWidth: 1)
)
.font(.scaledBody)
if !viewModel.newMessageText.isEmpty {
Button {
Task {

View file

@ -23,7 +23,8 @@ import SwiftUI
func fetchMessages() async {
guard let client, let lastMessageId = messages.last?.id else { return }
do {
let context: StatusContext = try await client.get(endpoint: Statuses.context(id: lastMessageId))
let context: StatusContext = try await client.get(
endpoint: Statuses.context(id: lastMessageId))
isLoadingMessages = false
messages.insert(contentsOf: context.ancestors, at: 0)
messages.append(contentsOf: context.descendants)
@ -36,9 +37,10 @@ import SwiftUI
var finalText = conversation.accounts.map { "@\($0.acct)" }.joined(separator: " ")
finalText += " "
finalText += newMessageText
let data = StatusData(status: finalText,
visibility: .direct,
inReplyToId: messages.last?.id)
let data = StatusData(
status: finalText,
visibility: .direct,
inReplyToId: messages.last?.id)
do {
let status: Status = try await client.post(endpoint: Statuses.postStatus(json: data))
appendNewStatus(status: status)
@ -53,15 +55,15 @@ import SwiftUI
func handleEvent(event: any StreamEvent) {
if let event = event as? StreamEventStatusUpdate,
let index = messages.firstIndex(where: { $0.id == event.status.id })
let index = messages.firstIndex(where: { $0.id == event.status.id })
{
messages[index] = event.status
} else if let event = event as? StreamEventDelete,
let index = messages.firstIndex(where: { $0.id == event.status })
let index = messages.firstIndex(where: { $0.id == event.status })
{
messages.remove(at: index)
} else if let event = event as? StreamEventConversation,
event.conversation.id == conversation.id
event.conversation.id == conversation.id
{
conversation = event.conversation
if conversation.lastStatus != nil {

View file

@ -39,14 +39,16 @@ struct ConversationMessageView: View {
.emojiText.size(Font.scaledBodyFont.emojiSize)
.emojiText.baselineOffset(Font.scaledBodyFont.emojiBaselineOffset)
.padding(6)
.environment(\.openURL, OpenURLAction { url in
routerPath.handleStatus(status: message, url: url)
})
.environment(
\.openURL,
OpenURLAction { url in
routerPath.handleStatus(status: message, url: url)
})
}
#if os(visionOS)
.background(isOwnMessage ? Material.ultraThick : Material.regular)
.background(isOwnMessage ? Material.ultraThick : Material.regular)
#else
.background(isOwnMessage ? theme.tintColor.opacity(0.2) : theme.secondaryBackgroundColor)
.background(isOwnMessage ? theme.tintColor.opacity(0.2) : theme.secondaryBackgroundColor)
#endif
.cornerRadius(8)
.padding(.leading, isOwnMessage ? 24 : 0)
@ -77,8 +79,7 @@ struct ConversationMessageView: View {
Spacer()
}
Group {
Text(message.createdAt.shortDateFormatted) +
Text(" ")
Text(message.createdAt.shortDateFormatted) + Text(" ")
Text(message.createdAt.asDate, style: .time)
}
.font(.scaledFootnote)
@ -122,25 +123,28 @@ struct ConversationMessageView: View {
} catch {}
}
} label: {
Label(isLiked ? "status.action.unfavorite" : "status.action.favorite",
systemImage: isLiked ? "star.fill" : "star")
Label(
isLiked ? "status.action.unfavorite" : "status.action.favorite",
systemImage: isLiked ? "star.fill" : "star")
}
Button {
Task {
do {
let status: Status
if isBookmarked {
status = try await client.post(endpoint: Statuses.unbookmark(id: message.id))
} else {
status = try await client.post(endpoint: Statuses.bookmark(id: message.id))
}
withAnimation {
isBookmarked = status.bookmarked == true
}
} catch {}
} } label: {
Label(isBookmarked ? "status.action.unbookmark" : "status.action.bookmark",
systemImage: isBookmarked ? "bookmark.fill" : "bookmark")
do {
let status: Status
if isBookmarked {
status = try await client.post(endpoint: Statuses.unbookmark(id: message.id))
} else {
status = try await client.post(endpoint: Statuses.bookmark(id: message.id))
}
withAnimation {
isBookmarked = status.bookmarked == true
}
} catch {}
}
} label: {
Label(
isBookmarked ? "status.action.unbookmark" : "status.action.bookmark",
systemImage: isBookmarked ? "bookmark.fill" : "bookmark")
}
Divider()
if message.account.id == currentAccount.account?.id {
@ -152,7 +156,8 @@ struct ConversationMessageView: View {
} else {
Section(message.reblog?.account.acct ?? message.account.acct) {
Button {
routerPath.presentedSheet = .mentionStatusEditor(account: message.reblog?.account ?? message.account, visibility: .pub)
routerPath.presentedSheet = .mentionStatusEditor(
account: message.reblog?.account ?? message.account, visibility: .pub)
} label: {
Label("status.action.mention", systemImage: "at")
}
@ -183,9 +188,11 @@ struct ConversationMessageView: View {
GeometryReader { proxy in
let width = mediaWidth(proxy: proxy)
if let url = attachement.url {
LazyImage(request: makeImageRequest(for: url,
size: .init(width: width, height: 200)))
{ state in
LazyImage(
request: makeImageRequest(
for: url,
size: .init(width: width, height: 200))
) { state in
if let image = state.image {
image
.resizable()
@ -207,8 +214,10 @@ struct ConversationMessageView: View {
.contentShape(Rectangle())
.onTapGesture {
#if targetEnvironment(macCatalyst) || os(visionOS)
openWindow(value: WindowDestinationMedia.mediaViewer(attachments: [attachement],
selectedAttachment: attachement))
openWindow(
value: WindowDestinationMedia.mediaViewer(
attachments: [attachement],
selectedAttachment: attachement))
#else
quickLook.prepareFor(selectedMediaAttachment: attachement, mediaAttachments: [attachement])
#endif

View file

@ -29,15 +29,18 @@ struct ConversationsListRow: View {
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
HStack {
EmojiTextApp(.init(stringValue: conversation.accounts.map(\.safeDisplayName).joined(separator: ", ")),
emojis: conversation.accounts.flatMap(\.emojis))
.font(.scaledSubheadline)
.foregroundColor(theme.labelColor)
.emojiText.size(Font.scaledSubheadlineFont.emojiSize)
.emojiText.baselineOffset(Font.scaledSubheadlineFont.emojiBaselineOffset)
.fontWeight(.semibold)
.foregroundColor(theme.labelColor)
.multilineTextAlignment(.leading)
EmojiTextApp(
.init(
stringValue: conversation.accounts.map(\.safeDisplayName).joined(separator: ", ")),
emojis: conversation.accounts.flatMap(\.emojis)
)
.font(.scaledSubheadline)
.foregroundColor(theme.labelColor)
.emojiText.size(Font.scaledSubheadlineFont.emojiSize)
.emojiText.baselineOffset(Font.scaledSubheadlineFont.emojiBaselineOffset)
.fontWeight(.semibold)
.foregroundColor(theme.labelColor)
.multilineTextAlignment(.leading)
Spacer()
if conversation.unread {
Circle()
@ -53,13 +56,16 @@ struct ConversationsListRow: View {
.font(.scaledFootnote)
}
}
EmojiTextApp(conversation.lastStatus?.content ?? HTMLString(stringValue: ""), emojis: conversation.lastStatus?.emojis ?? [])
.multilineTextAlignment(.leading)
.font(.scaledBody)
.foregroundColor(theme.labelColor)
.emojiText.size(Font.scaledBodyFont.emojiSize)
.emojiText.baselineOffset(Font.scaledBodyFont.emojiBaselineOffset)
.accessibilityLabel(conversation.lastStatus?.content.asRawText ?? "")
EmojiTextApp(
conversation.lastStatus?.content ?? HTMLString(stringValue: ""),
emojis: conversation.lastStatus?.emojis ?? []
)
.multilineTextAlignment(.leading)
.font(.scaledBody)
.foregroundColor(theme.labelColor)
.emojiText.size(Font.scaledBodyFont.emojiSize)
.emojiText.baselineOffset(Font.scaledBodyFont.emojiBaselineOffset)
.accessibilityLabel(conversation.lastStatus?.content.asRawText ?? "")
}
Spacer()
}
@ -146,7 +152,8 @@ struct ConversationsListRow: View {
if message.account.id != currentAccount.account?.id {
Section(message.reblog?.account.acct ?? message.account.acct) {
Button {
routerPath.presentedSheet = .mentionStatusEditor(account: message.reblog?.account ?? message.account, visibility: .pub)
routerPath.presentedSheet = .mentionStatusEditor(
account: message.reblog?.account ?? message.account, visibility: .pub)
} label: {
Label("status.action.mention", systemImage: "at")
}
@ -177,16 +184,20 @@ struct ConversationsListRow: View {
await viewModel.favorite(conversation: conversation)
}
} label: {
Label(conversation.lastStatus?.favourited ?? false ? "status.action.unfavorite" : "status.action.favorite",
systemImage: conversation.lastStatus?.favourited ?? false ? "star.fill" : "star")
Label(
conversation.lastStatus?.favourited ?? false
? "status.action.unfavorite" : "status.action.favorite",
systemImage: conversation.lastStatus?.favourited ?? false ? "star.fill" : "star")
}
Button {
Task {
await viewModel.bookmark(conversation: conversation)
}
} label: {
Label(conversation.lastStatus?.bookmarked ?? false ? "status.action.unbookmark" : "status.action.bookmark",
systemImage: conversation.lastStatus?.bookmarked ?? false ? "bookmark.fill" : "bookmark")
Label(
conversation.lastStatus?.bookmarked ?? false
? "status.action.unbookmark" : "status.action.bookmark",
systemImage: conversation.lastStatus?.bookmarked ?? false ? "bookmark.fill" : "bookmark")
}
}

View file

@ -14,7 +14,7 @@ public struct ConversationsListView: View {
@State private var viewModel = ConversationsListViewModel()
public init() { }
public init() {}
private var conversations: Binding<[Conversation]> {
if viewModel.isLoadingFirstPage {
@ -44,14 +44,16 @@ public struct ConversationsListView: View {
Divider()
}
} else if conversations.isEmpty, !viewModel.isLoadingFirstPage, !viewModel.isError {
PlaceholderView(iconName: "tray",
title: "conversations.empty.title",
message: "conversations.empty.message")
PlaceholderView(
iconName: "tray",
title: "conversations.empty.title",
message: "conversations.empty.message")
} else if viewModel.isError {
ErrorView(title: "conversations.error.title",
message: "conversations.error.message",
buttonTitle: "conversations.error.button")
{
ErrorView(
title: "conversations.error.title",
message: "conversations.error.message",
buttonTitle: "conversations.error.button"
) {
await viewModel.fetchConversations()
}
}
@ -75,8 +77,8 @@ public struct ConversationsListView: View {
.padding(.top, .layoutPadding)
}
#if !os(visionOS)
.scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor)
.scrollContentBackground(.hidden)
.background(theme.primaryBackgroundColor)
#endif
.navigationTitle("conversations.navigation-title")
.navigationBarTitleDisplayMode(.inline)

View file

@ -23,7 +23,8 @@ import SwiftUI
isLoadingFirstPage = true
}
do {
(conversations, nextPage) = try await client.getWithLink(endpoint: Conversations.conversations(maxId: nil))
(conversations, nextPage) = try await client.getWithLink(
endpoint: Conversations.conversations(maxId: nil))
if nextPage?.maxId == nil {
nextPage = nil
}
@ -39,7 +40,8 @@ import SwiftUI
do {
isLoadingNextPage = true
var nextMessages: [Conversation] = []
(nextMessages, nextPage) = try await client.getWithLink(endpoint: Conversations.conversations(maxId: maxId))
(nextMessages, nextPage) = try await client.getWithLink(
endpoint: Conversations.conversations(maxId: maxId))
conversations.append(contentsOf: nextMessages)
if nextPage?.maxId == nil {
nextPage = nil
@ -62,11 +64,12 @@ import SwiftUI
func favorite(conversation: Conversation) async {
guard let client, let message = conversation.lastStatus else { return }
let endpoint: Endpoint = if message.favourited ?? false {
Statuses.unfavorite(id: message.id)
} else {
Statuses.favorite(id: message.id)
}
let endpoint: Endpoint =
if message.favourited ?? false {
Statuses.unfavorite(id: message.id)
} else {
Statuses.favorite(id: message.id)
}
do {
let status: Status = try await client.post(endpoint: endpoint)
updateConversationWithNewLastStatus(conversation: conversation, newLastStatus: status)
@ -75,19 +78,24 @@ import SwiftUI
func bookmark(conversation: Conversation) async {
guard let client, let message = conversation.lastStatus else { return }
let endpoint: Endpoint = if message.bookmarked ?? false {
Statuses.unbookmark(id: message.id)
} else {
Statuses.bookmark(id: message.id)
}
let endpoint: Endpoint =
if message.bookmarked ?? false {
Statuses.unbookmark(id: message.id)
} else {
Statuses.bookmark(id: message.id)
}
do {
let status: Status = try await client.post(endpoint: endpoint)
updateConversationWithNewLastStatus(conversation: conversation, newLastStatus: status)
} catch {}
}
private func updateConversationWithNewLastStatus(conversation: Conversation, newLastStatus: Status) {
let newConversation = Conversation(id: conversation.id, unread: conversation.unread, lastStatus: newLastStatus, accounts: conversation.accounts)
private func updateConversationWithNewLastStatus(
conversation: Conversation, newLastStatus: Status
) {
let newConversation = Conversation(
id: conversation.id, unread: conversation.unread, lastStatus: newLastStatus,
accounts: conversation.accounts)
updateConversations(conversation: newConversation)
}
@ -96,7 +104,9 @@ import SwiftUI
conversations.remove(at: index)
}
conversations.insert(conversation, at: 0)
conversations = conversations.sorted(by: { ($0.lastStatus?.createdAt.asDate ?? Date.now) > ($1.lastStatus?.createdAt.asDate ?? Date.now) })
conversations = conversations.sorted(by: {
($0.lastStatus?.createdAt.asDate ?? Date.now) > ($1.lastStatus?.createdAt.asDate ?? Date.now)
})
}
func handleEvent(event: any StreamEvent) {

View file

@ -14,7 +14,7 @@ let package = Package(
.library(
name: "DesignSystem",
targets: ["DesignSystem"]
),
)
],
dependencies: [
.package(name: "Models", path: "../Models"),
@ -33,8 +33,8 @@ let package = Package(
.product(name: "EmojiText", package: "EmojiText"),
],
swiftSettings: [
.swiftLanguageMode(.v6),
.swiftLanguageMode(.v6)
]
),
)
]
)

View file

@ -3,20 +3,20 @@ import Models
import NukeUI
import SwiftUI
public extension Account {
extension Account {
private struct Part: Identifiable {
let id = UUID().uuidString
let value: Substring
}
var safeDisplayName: String {
public var safeDisplayName: String {
if let displayName, !displayName.isEmpty {
return displayName
}
return "@\(username)"
}
var displayNameWithoutEmojis: String {
public var displayNameWithoutEmojis: String {
var name = safeDisplayName
for emoji in emojis {
name = name.replacingOccurrences(of: ":\(emoji.shortcode):", with: "")

View file

@ -1,13 +1,15 @@
import SwiftUI
public let availableColorsSets: [ColorSetCouple] =
[.init(light: IceCubeLight(), dark: IceCubeDark()),
.init(light: IceCubeNeonLight(), dark: IceCubeNeonDark()),
.init(light: DesertLight(), dark: DesertDark()),
.init(light: NemesisLight(), dark: NemesisDark()),
.init(light: MediumLight(), dark: MediumDark()),
.init(light: ConstellationLight(), dark: ConstellationDark()),
.init(light: ThreadsLight(), dark: ThreadsDark())]
[
.init(light: IceCubeLight(), dark: IceCubeDark()),
.init(light: IceCubeNeonLight(), dark: IceCubeNeonDark()),
.init(light: DesertLight(), dark: DesertDark()),
.init(light: NemesisLight(), dark: NemesisDark()),
.init(light: MediumLight(), dark: MediumDark()),
.init(light: ConstellationLight(), dark: ConstellationDark()),
.init(light: ThreadsLight(), dark: ThreadsDark()),
]
public protocol ColorSet: Sendable {
var name: ColorSetName { get }

View file

@ -1,7 +1,9 @@
import SwiftUI
public extension View {
@ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
extension View {
@ViewBuilder public func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content)
-> some View
{
if condition {
transform(self)
} else {

View file

@ -1,17 +1,17 @@
import Foundation
@MainActor
public extension CGFloat {
static var layoutPadding: CGFloat {
extension CGFloat {
public static var layoutPadding: CGFloat {
Theme.shared.compactLayoutPadding ? 20 : 8
}
static let dividerPadding: CGFloat = 2
static let scrollToViewHeight: CGFloat = 1
static let statusColumnsSpacing: CGFloat = 8
static let statusComponentSpacing: CGFloat = 6
static let secondaryColumnWidth: CGFloat = 400
static let sidebarWidth: CGFloat = 90
static let sidebarWidthExpanded: CGFloat = 220
static let pollBarHeight: CGFloat = 30
public static let dividerPadding: CGFloat = 2
public static let scrollToViewHeight: CGFloat = 1
public static let statusColumnsSpacing: CGFloat = 8
public static let statusComponentSpacing: CGFloat = 6
public static let secondaryColumnWidth: CGFloat = 400
public static let sidebarWidth: CGFloat = 90
public static let sidebarWidthExpanded: CGFloat = 220
public static let pollBarHeight: CGFloat = 30
}

View file

@ -2,7 +2,7 @@ import Env
import SwiftUI
@MainActor
public extension Font {
extension Font {
// See https://gist.github.com/zacwest/916d31da5d03405809c4 for iOS values
// Custom values for Mac
private static let title = 28.0
@ -45,84 +45,85 @@ public extension Font {
UIFontMetrics.default.scaledValue(for: baseSize * Theme.shared.fontSizeScale)
}
static var scaledTitle: Font {
public static var scaledTitle: Font {
customFont(size: userScaledFontSize(baseSize: title), relativeTo: .title)
}
static var scaledHeadline: Font {
customFont(size: userScaledFontSize(baseSize: headline), relativeTo: .headline).weight(.semibold)
public static var scaledHeadline: Font {
customFont(size: userScaledFontSize(baseSize: headline), relativeTo: .headline).weight(
.semibold)
}
static var scaledHeadlineFont: UIFont {
public static var scaledHeadlineFont: UIFont {
customUIFont(size: userScaledFontSize(baseSize: headline))
}
static var scaledBodyFocused: Font {
public static var scaledBodyFocused: Font {
customFont(size: userScaledFontSize(baseSize: body + 2), relativeTo: .body)
}
static var scaledBodyFocusedFont: UIFont {
public static var scaledBodyFocusedFont: UIFont {
customUIFont(size: userScaledFontSize(baseSize: body + 2))
}
static var scaledBody: Font {
public static var scaledBody: Font {
customFont(size: userScaledFontSize(baseSize: body), relativeTo: .body)
}
static var scaledBodyFont: UIFont {
public static var scaledBodyFont: UIFont {
customUIFont(size: userScaledFontSize(baseSize: body))
}
static var scaledBodyUIFont: UIFont {
public static var scaledBodyUIFont: UIFont {
customUIFont(size: userScaledFontSize(baseSize: body))
}
static var scaledCallout: Font {
public static var scaledCallout: Font {
customFont(size: userScaledFontSize(baseSize: callout), relativeTo: .callout)
}
static var scaledCalloutFont: UIFont {
public static var scaledCalloutFont: UIFont {
customUIFont(size: userScaledFontSize(baseSize: body))
}
static var scaledSubheadline: Font {
public static var scaledSubheadline: Font {
customFont(size: userScaledFontSize(baseSize: subheadline), relativeTo: .subheadline)
}
static var scaledSubheadlineFont: UIFont {
public static var scaledSubheadlineFont: UIFont {
customUIFont(size: userScaledFontSize(baseSize: subheadline))
}
static var scaledFootnote: Font {
public static var scaledFootnote: Font {
customFont(size: userScaledFontSize(baseSize: footnote), relativeTo: .footnote)
}
static var scaledFootnoteFont: UIFont {
public static var scaledFootnoteFont: UIFont {
customUIFont(size: userScaledFontSize(baseSize: footnote))
}
static var scaledCaption: Font {
public static var scaledCaption: Font {
customFont(size: userScaledFontSize(baseSize: caption), relativeTo: .caption)
}
static var scaledCaptionFont: UIFont {
public static var scaledCaptionFont: UIFont {
customUIFont(size: userScaledFontSize(baseSize: caption))
}
}
public extension UIFont {
func rounded() -> UIFont {
extension UIFont {
public func rounded() -> UIFont {
guard let descriptor = fontDescriptor.withDesign(.rounded) else {
return self
}
return UIFont(descriptor: descriptor, size: pointSize)
}
var emojiSize: CGFloat {
public var emojiSize: CGFloat {
pointSize
}
var emojiBaselineOffset: CGFloat {
public var emojiBaselineOffset: CGFloat {
// Center emoji with capital letter size of font
-(emojiSize - capHeight) / 2
}

View file

@ -1,19 +1,19 @@
import SwiftUI
public extension Color {
static var brand: Color {
extension Color {
public static var brand: Color {
Color(red: 187 / 255, green: 59 / 255, blue: 226 / 255)
}
static var primaryBackground: Color {
public static var primaryBackground: Color {
Color(red: 16 / 255, green: 21 / 255, blue: 35 / 255)
}
static var secondaryBackground: Color {
public static var secondaryBackground: Color {
Color(red: 30 / 255, green: 35 / 255, blue: 62 / 255)
}
static var label: Color {
public static var label: Color {
Color(.label)
}
}

View file

@ -1,5 +1,3 @@
import Foundation
import SwiftUI
@ -7,8 +5,8 @@ import SwiftUI
// images named in lower case are Apple's symbols
// images inamed in CamelCase are custom
public extension Label where Title == Text, Icon == Image {
init(_ title: LocalizedStringKey, imageNamed: String) {
extension Label where Title == Text, Icon == Image {
public init(_ title: LocalizedStringKey, imageNamed: String) {
if imageNamed.lowercased() == imageNamed {
self.init(title, systemImage: imageNamed)
} else {

View file

@ -12,10 +12,11 @@ import UIKit
public private(set) var windowHeight: CGFloat = UIScreen.main.bounds.size.height
#endif
public func scene(_ scene: UIScene,
willConnectTo _: UISceneSession,
options _: UIScene.ConnectionOptions)
{
public func scene(
_ scene: UIScene,
willConnectTo _: UISceneSession,
options _: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
window = windowScene.keyWindow
@ -29,12 +30,12 @@ import UIKit
override public init() {
super.init()
Task { @MainActor in
setup()
}
}
private func setup() {
#if os(visionOS)
windowWidth = window?.bounds.size.width ?? 0
@ -44,7 +45,7 @@ import UIKit
windowHeight = window?.bounds.size.height ?? UIScreen.main.bounds.size.height
#endif
Self.observedSceneDelegate.insert(self)
_ = Self.observer // just for activating the lazy static property
_ = Self.observer // just for activating the lazy static property
}
private static var observedSceneDelegate: Set<SceneDelegate> = []

View file

@ -20,18 +20,25 @@ public final class Theme {
@AppStorage("is_previously_set") public var isThemePreviouslySet: Bool = false
@AppStorage(ThemeKey.selectedScheme.rawValue) public var selectedScheme: ColorScheme = .dark
@AppStorage(ThemeKey.tint.rawValue) public var tintColor: Color = .black
@AppStorage(ThemeKey.primaryBackground.rawValue) public var primaryBackgroundColor: Color = .white
@AppStorage(ThemeKey.secondaryBackground.rawValue) public var secondaryBackgroundColor: Color = .gray
@AppStorage(ThemeKey.primaryBackground.rawValue) public var primaryBackgroundColor: Color =
.white
@AppStorage(ThemeKey.secondaryBackground.rawValue) public var secondaryBackgroundColor: Color =
.gray
@AppStorage(ThemeKey.label.rawValue) public var labelColor: Color = .black
@AppStorage(ThemeKey.avatarPosition2.rawValue) var avatarPosition: AvatarPosition = .leading
@AppStorage(ThemeKey.avatarShape2.rawValue) var avatarShape: AvatarShape = .circle
@AppStorage(ThemeKey.selectedSet.rawValue) var storedSet: ColorSetName = .iceCubeDark
@AppStorage(ThemeKey.statusActionsDisplay.rawValue) public var statusActionsDisplay: StatusActionsDisplay = .full
@AppStorage(ThemeKey.statusDisplayStyle.rawValue) public var statusDisplayStyle: StatusDisplayStyle = .large
@AppStorage(ThemeKey.followSystemColorSchme.rawValue) public var followSystemColorScheme: Bool = true
@AppStorage(ThemeKey.displayFullUsernameTimeline.rawValue) public var displayFullUsername: Bool = false
@AppStorage(ThemeKey.statusActionsDisplay.rawValue) public var statusActionsDisplay:
StatusActionsDisplay = .full
@AppStorage(ThemeKey.statusDisplayStyle.rawValue) public var statusDisplayStyle:
StatusDisplayStyle = .large
@AppStorage(ThemeKey.followSystemColorSchme.rawValue) public var followSystemColorScheme: Bool =
true
@AppStorage(ThemeKey.displayFullUsernameTimeline.rawValue) public var displayFullUsername:
Bool = false
@AppStorage(ThemeKey.lineSpacing.rawValue) public var lineSpacing: Double = 1.2
@AppStorage(ThemeKey.statusActionSecondary.rawValue) public var statusActionSecondary: StatusActionSecondary = .share
@AppStorage(ThemeKey.statusActionSecondary.rawValue) public var statusActionSecondary:
StatusActionSecondary = .share
@AppStorage(ThemeKey.contentGradient.rawValue) public var showContentGradient: Bool = true
@AppStorage(ThemeKey.compactLayoutPadding.rawValue) public var compactLayoutPadding: Bool = true
@AppStorage("font_size_scale") public var fontSizeScale: Double = 1
@ -139,14 +146,17 @@ public final class Theme {
return _cachedChoosenFont
}
guard let chosenFontData,
let font = try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIFont.self, from: chosenFontData) else { return nil }
let font = try? NSKeyedUnarchiver.unarchivedObject(
ofClass: UIFont.self, from: chosenFontData)
else { return nil }
_cachedChoosenFont = font
return font
}
set {
if let font = newValue,
let data = try? NSKeyedArchiver.archivedData(withRootObject: font, requiringSecureCoding: false)
let data = try? NSKeyedArchiver.archivedData(
withRootObject: font, requiringSecureCoding: false)
{
chosenFontData = data
} else {
@ -292,13 +302,13 @@ public final class Theme {
themeStorage.showContentGradient = showContentGradient
}
}
public var compactLayoutPadding: Bool {
didSet {
themeStorage.compactLayoutPadding = compactLayoutPadding
}
}
public var selectedSet: ColorSetName = .iceCubeDark
public static let shared = Theme()
@ -327,7 +337,7 @@ public final class Theme {
primaryBackgroundColor = themeStorage.primaryBackgroundColor
secondaryBackgroundColor = themeStorage.secondaryBackgroundColor
labelColor = themeStorage.labelColor
contrastingTintColor = .red // real work done in computeContrastingTintColor()
contrastingTintColor = .red // real work done in computeContrastingTintColor()
avatarPosition = themeStorage.avatarPosition
avatarShape = themeStorage.avatarShape
storedSet = themeStorage.storedSet

View file

@ -1,10 +1,11 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
public extension View {
@MainActor func applyTheme(_ theme: Theme) -> some View {
extension View {
@MainActor public func applyTheme(_ theme: Theme) -> some View {
modifier(ThemeApplier(theme: theme))
}
}
@ -26,40 +27,46 @@ struct ThemeApplier: ViewModifier {
content
.tint(theme.tintColor)
.preferredColorScheme(actualColorScheme)
#if canImport(UIKit)
.onAppear {
// If theme is never set before set the default store. This should only execute once after install.
if !theme.isThemePreviouslySet {
theme.applySet(set: colorScheme == .dark ? .iceCubeDark : .iceCubeLight)
theme.isThemePreviouslySet = true
} else if theme.followSystemColorScheme, theme.isThemePreviouslySet,
let sets = availableColorsSets
.first(where: { $0.light.name == theme.selectedSet || $0.dark.name == theme.selectedSet })
{
theme.applySet(set: colorScheme == .dark ? sets.dark.name : sets.light.name)
#if canImport(UIKit)
.onAppear {
// If theme is never set before set the default store. This should only execute once after install.
if !theme.isThemePreviouslySet {
theme.applySet(set: colorScheme == .dark ? .iceCubeDark : .iceCubeLight)
theme.isThemePreviouslySet = true
} else if theme.followSystemColorScheme, theme.isThemePreviouslySet,
let sets =
availableColorsSets
.first(where: {
$0.light.name == theme.selectedSet || $0.dark.name == theme.selectedSet
})
{
theme.applySet(set: colorScheme == .dark ? sets.dark.name : sets.light.name)
}
setWindowTint(theme.tintColor)
setWindowUserInterfaceStyle(from: theme.selectedScheme)
setBarsColor(theme.primaryBackgroundColor)
}
setWindowTint(theme.tintColor)
setWindowUserInterfaceStyle(from: theme.selectedScheme)
setBarsColor(theme.primaryBackgroundColor)
}
.onChange(of: theme.tintColor) { _, newValue in
setWindowTint(newValue)
}
.onChange(of: theme.primaryBackgroundColor) { _, newValue in
setBarsColor(newValue)
}
.onChange(of: theme.selectedScheme) { _, newValue in
setWindowUserInterfaceStyle(from: newValue)
}
.onChange(of: colorScheme) { _, newColorScheme in
if theme.followSystemColorScheme,
let sets = availableColorsSets
.first(where: { $0.light.name == theme.selectedSet || $0.dark.name == theme.selectedSet })
{
theme.applySet(set: newColorScheme == .dark ? sets.dark.name : sets.light.name)
.onChange(of: theme.tintColor) { _, newValue in
setWindowTint(newValue)
}
}
#endif
.onChange(of: theme.primaryBackgroundColor) { _, newValue in
setBarsColor(newValue)
}
.onChange(of: theme.selectedScheme) { _, newValue in
setWindowUserInterfaceStyle(from: newValue)
}
.onChange(of: colorScheme) { _, newColorScheme in
if theme.followSystemColorScheme,
let sets =
availableColorsSets
.first(where: {
$0.light.name == theme.selectedSet || $0.dark.name == theme.selectedSet
})
{
theme.applySet(set: newColorScheme == .dark ? sets.dark.name : sets.light.name)
}
}
#endif
}
#if canImport(UIKit)

View file

@ -7,11 +7,14 @@ public struct CloseToolbarItem: ToolbarContent {
public var body: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
dismiss()
}, label: {
Image(systemName: "xmark.circle")
})
Button(
action: {
dismiss()
},
label: {
Image(systemName: "xmark.circle")
}
)
.keyboardShortcut(.cancelAction)
}
}

View file

@ -7,7 +7,7 @@ import SwiftUI
@MainActor
struct AccountPopoverView: View {
let account: Account
let theme: Theme // using `@Environment(Theme.self) will crash the SwiftUI preview
let theme: Theme // using `@Environment(Theme.self) will crash the SwiftUI preview
private let config: AvatarView.FrameConfig = .account
@Binding var showPopup: Bool
@ -16,7 +16,8 @@ struct AccountPopoverView: View {
var body: some View {
VStack(alignment: .leading) {
LazyImage(request: ImageRequest(url: account.header)
LazyImage(
request: ImageRequest(url: account.header)
) { state in
if let image = state.image {
image.resizable().scaledToFill()
@ -96,7 +97,9 @@ struct AccountPopoverView: View {
}
@MainActor
private func makeCustomInfoLabel(title: LocalizedStringKey, count: Int, needsBadge: Bool = false) -> some View {
private func makeCustomInfoLabel(title: LocalizedStringKey, count: Int, needsBadge: Bool = false)
-> some View
{
VStack {
Text(count, format: .number.notation(.compactName))
.font(.scaledHeadline)
@ -112,9 +115,11 @@ struct AccountPopoverView: View {
Text(title)
.font(.scaledFootnote)
.foregroundStyle(.secondary)
.alignmentGuide(.bottomAvatar, computeValue: { dimension in
dimension[.firstTextBaseline]
})
.alignmentGuide(
.bottomAvatar,
computeValue: { dimension in
dimension[.firstTextBaseline]
})
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(title)
@ -122,12 +127,14 @@ struct AccountPopoverView: View {
}
private var adaptiveConfig: AvatarView.FrameConfig {
let cornerRadius: CGFloat = if config == .badge || theme.avatarShape == .circle {
config.width / 2
} else {
config.cornerRadius
}
return AvatarView.FrameConfig(width: config.width, height: config.height, cornerRadius: cornerRadius)
let cornerRadius: CGFloat =
if config == .badge || theme.avatarShape == .circle {
config.width / 2
} else {
config.cornerRadius
}
return AvatarView.FrameConfig(
width: config.width, height: config.height, cornerRadius: cornerRadius)
}
}
@ -156,33 +163,34 @@ public struct AccountPopoverModifier: ViewModifier {
return AnyView(content)
}
return AnyView(content
.onHover { hovering in
if hovering {
toggleTask.cancel()
toggleTask = Task {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 2)
guard !Task.isCancelled else { return }
return AnyView(
content
.onHover { hovering in
if hovering {
toggleTask.cancel()
toggleTask = Task {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 2)
guard !Task.isCancelled else { return }
if !showPopup {
showPopup = true
}
}
} else {
if !showPopup {
showPopup = true
toggleTask.cancel()
}
}
} else {
if !showPopup {
toggleTask.cancel()
}
}
}
.hoverEffect(.lift)
.popover(isPresented: $showPopup) {
AccountPopoverView(
account: account,
theme: theme,
showPopup: $showPopup,
autoDismiss: $autoDismiss,
toggleTask: $toggleTask
)
})
.hoverEffect(.lift)
.popover(isPresented: $showPopup) {
AccountPopoverView(
account: account,
theme: theme,
showPopup: $showPopup,
autoDismiss: $autoDismiss,
toggleTask: $toggleTask
)
})
}
init(_ account: Account) {
@ -190,8 +198,8 @@ public struct AccountPopoverModifier: ViewModifier {
}
}
public extension View {
func accountPopover(_ account: Account) -> some View {
extension View {
public func accountPopover(_ account: Account) -> some View {
modifier(AccountPopoverModifier(account))
}
}

View file

@ -20,11 +20,12 @@ public struct AvatarView: View {
}
private var adaptiveConfig: FrameConfig {
let cornerRadius: CGFloat = if config == .badge || theme.avatarShape == .circle {
config.width / 2
} else {
config.cornerRadius
}
let cornerRadius: CGFloat =
if config == .badge || theme.avatarShape == .circle {
config.width / 2
} else {
config.cornerRadius
}
return FrameConfig(width: config.width, height: config.height, cornerRadius: cornerRadius)
}
@ -88,10 +89,14 @@ struct PreviewWrapper: View {
id: UUID().uuidString,
username: "@clattner_llvm",
displayName: "Chris Lattner",
avatar: URL(string: "https://pbs.twimg.com/profile_images/1484209565788897285/1n6Viahb_400x400.jpg")!,
avatar: URL(
string: "https://pbs.twimg.com/profile_images/1484209565788897285/1n6Viahb_400x400.jpg")!,
header: URL(string: "https://pbs.twimg.com/profile_banners/2543588034/1656822255/1500x500")!,
acct: "clattner_llvm@example.com",
note: .init(stringValue: "Building beautiful things @Modular_AI 🔥, lifting the world of production AI/ML software into a new phase of innovation. Were hiring! 🚀🧠"),
note: .init(
stringValue:
"Building beautiful things @Modular_AI 🔥, lifting the world of production AI/ML software into a new phase of innovation. Were hiring! 🚀🧠"
),
createdAt: ServerDate(),
followersCount: 77100,
followingCount: 167,
@ -117,7 +122,8 @@ struct AvatarImage: View {
if reasons == .placeholder {
AvatarPlaceHolder(config: config)
} else {
LazyImage(request: ImageRequest(url: avatar, processors: [.resize(size: config.size)])
LazyImage(
request: ImageRequest(url: avatar, processors: [.resize(size: config.size)])
) { state in
if let image = state.image {
image

View file

@ -10,7 +10,10 @@ public struct EmojiTextApp: View {
private let append: (() -> Text)?
private let lineLimit: Int?
public init(_ markdown: HTMLString, emojis: [Emoji], language: String? = nil, lineLimit: Int? = nil, append: (() -> Text)? = nil) {
public init(
_ markdown: HTMLString, emojis: [Emoji], language: String? = nil, lineLimit: Int? = nil,
append: (() -> Text)? = nil
) {
self.markdown = markdown
self.emojis = emojis.map { RemoteEmoji(shortcode: $0.shortcode, url: $0.url) }
self.language = language

View file

@ -6,7 +6,10 @@ public struct ErrorView: View {
public let buttonTitle: LocalizedStringKey
public let onButtonPress: () async -> Void
public init(title: LocalizedStringKey, message: LocalizedStringKey, buttonTitle: LocalizedStringKey, onButtonPress: @escaping (() async -> Void)) {
public init(
title: LocalizedStringKey, message: LocalizedStringKey, buttonTitle: LocalizedStringKey,
onButtonPress: @escaping (() async -> Void)
) {
self.title = title
self.message = message
self.buttonTitle = buttonTitle
@ -46,7 +49,9 @@ public struct ErrorView: View {
}
#Preview {
ErrorView(title: "Error",
message: "Error loading. Please try again",
buttonTitle: "Retry") {}
ErrorView(
title: "Error",
message: "Error loading. Please try again",
buttonTitle: "Retry"
) {}
}

View file

@ -12,7 +12,8 @@ import SwiftUI
/// A LazyImage (Nuke) with a geometry reader under the hood in order to use a Resize Processor to optimize performances on lists.
/// This views also allows smooth resizing of the images by debouncing the update of the ImageProcessor.
public struct LazyResizableImage<Content: View>: View {
public init(url: URL?, @ViewBuilder content: @escaping (LazyImageState, GeometryProxy) -> Content) {
public init(url: URL?, @ViewBuilder content: @escaping (LazyImageState, GeometryProxy) -> Content)
{
imageURL = url
self.content = content
}

View file

@ -12,14 +12,16 @@ public struct PlaceholderView: View {
}
public var body: some View {
ContentUnavailableView(title,
systemImage: iconName,
description: Text(message))
ContentUnavailableView(
title,
systemImage: iconName,
description: Text(message))
}
}
#Preview {
PlaceholderView(iconName: "square.and.arrow.up.trianglebadge.exclamationmark",
title: "Nothing to see",
message: "This is a preview. Please try again.")
PlaceholderView(
iconName: "square.and.arrow.up.trianglebadge.exclamationmark",
title: "Nothing to see",
message: "This is a preview. Please try again.")
}

Some files were not shown because too many files have changed in this diff Show more